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

sapcc / limes / 17066243393

19 Aug 2025 09:57AM UTC coverage: 80.158% (-0.02%) from 80.182%
17066243393

Pull #767

github

majewsky
localize MockLiquidClient factory for each test

This will allow tests to still run in parallel (once we choose to enable
that), following the changes from the previous commit.
Pull Request #767: remove liquidServiceType (only used for testing), attempt 2

26 of 42 new or added lines in 4 files covered. (61.9%)

7405 of 9238 relevant lines covered (80.16%)

57.51 hits per line

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

81.37
/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
        "context"
9
        "database/sql"
10
        "encoding/json"
11
        "errors"
12
        "fmt"
13
        "maps"
14
        "net/http"
15
        "slices"
16
        "strings"
17
        "time"
18

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

35
        "github.com/sapcc/limes/internal/core"
36
        "github.com/sapcc/limes/internal/datamodel"
37
        "github.com/sapcc/limes/internal/db"
38
        "github.com/sapcc/limes/internal/reports"
39
)
40

41
var (
42
        getProjectCommitmentsQuery = sqlext.SimplifyWhitespace(db.ExpandEnumPlaceholders(`
43
                SELECT pc.*
44
                  FROM project_commitments pc
45
                  JOIN az_resources cazr ON pc.az_resource_id = cazr.id
46
                  JOIN resources cr ON cazr.resource_id = cr.id {{AND cr.name = $resource_name}}
47
                  JOIN services cs ON cr.service_id = cs.id {{AND cs.type = $service_type}}
48
                 WHERE %s AND pc.status NOT IN ({{liquid.CommitmentStatusSuperseded}}, {{liquid.CommitmentStatusExpired}})
49
                 ORDER BY pc.id
50
        `))
51

52
        getAZResourceLocationsQuery = sqlext.SimplifyWhitespace(`
53
                SELECT cazr.id, cs.type, cr.name, cazr.az
54
                  FROM project_az_resources pazr
55
                  JOIN az_resources cazr on pazr.az_resource_id = cazr.id
56
                  JOIN resources cr ON cazr.resource_id = cr.id {{AND cr.name = $resource_name}}
57
                  JOIN services cs ON cr.service_id = cs.id {{AND cs.type = $service_type}}
58
                 WHERE %s
59
        `)
60

61
        findProjectCommitmentByIDQuery = sqlext.SimplifyWhitespace(`
62
                SELECT pc.*
63
                  FROM project_commitments pc
64
                 WHERE pc.id = $1 AND pc.project_id = $2
65
        `)
66

67
        findAZResourceIDByLocationQuery = sqlext.SimplifyWhitespace(db.ExpandEnumPlaceholders(`
68
                SELECT cazr.id, pr.forbidden IS NOT TRUE as resource_allows_commitments, COALESCE(total_confirmed, 0) as total_confirmed
69
                FROM az_resources cazr
70
                JOIN resources cr ON cazr.resource_id = cr.id
71
                JOIN services cs ON cr.service_id = cs.id
72
                JOIN project_resources pr ON pr.resource_id = cr.id
73
                LEFT JOIN (
74
                        SELECT SUM(pc.amount) as total_confirmed
75
                        FROM az_resources cazr
76
                        JOIN resources cr ON cazr.resource_id = cr.id
77
                        JOIN services cs ON cr.service_id = cs.id
78
                        JOIN project_commitments pc ON cazr.id = pc.az_resource_id
79
                        WHERE pc.project_id = $1 AND cs.type = $2 AND cr.name = $3 AND cazr.az = $4 AND status = {{liquid.CommitmentStatusConfirmed}}
80
                ) pc ON 1=1
81
                WHERE pr.project_id = $1 AND cs.type = $2 AND cr.name = $3 AND cazr.az = $4
82
        `))
83

84
        findAZResourceLocationByIDQuery = sqlext.SimplifyWhitespace(db.ExpandEnumPlaceholders(`
85
                SELECT cs.type, cr.name, cazr.az, COALESCE(pc.total_confirmed,0) AS total_confirmed
86
                FROM az_resources cazr
87
                JOIN resources cr ON cazr.resource_id = cr.id
88
                JOIN services cs ON cr.service_id = cs.id
89
                LEFT JOIN (
90
                                SELECT SUM(amount) as total_confirmed
91
                                FROM project_commitments pc
92
                                WHERE az_resource_id = $1 AND project_id = $2 AND status = {{liquid.CommitmentStatusConfirmed}}
93
                ) pc ON 1=1
94
                WHERE cazr.id = $1;
95
        `))
96
        getCommitmentWithMatchingTransferTokenQuery = sqlext.SimplifyWhitespace(`
97
                SELECT * FROM project_commitments WHERE id = $1 AND transfer_token = $2
98
        `)
99
        findCommitmentByTransferToken = sqlext.SimplifyWhitespace(`
100
                SELECT * FROM project_commitments WHERE transfer_token = $1
101
        `)
102
)
103

104
// GetProjectCommitments handles GET /v1/domains/:domain_id/projects/:project_id/commitments.
105
func (p *v1Provider) GetProjectCommitments(w http.ResponseWriter, r *http.Request) {
15✔
106
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments")
15✔
107
        token := p.CheckToken(r)
15✔
108
        if !token.Require(w, "project:show") {
16✔
109
                return
1✔
110
        }
1✔
111
        dbDomain := p.FindDomainFromRequest(w, r)
14✔
112
        if dbDomain == nil {
15✔
113
                return
1✔
114
        }
1✔
115
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
13✔
116
        if dbProject == nil {
14✔
117
                return
1✔
118
        }
1✔
119
        serviceInfos, err := p.Cluster.AllServiceInfos()
12✔
120
        if respondwith.ObfuscatedErrorText(w, err) {
12✔
121
                return
×
122
        }
×
123

124
        // enumerate project AZ resources
125
        filter := reports.ReadFilter(r, p.Cluster, serviceInfos)
12✔
126
        queryStr, joinArgs := filter.PrepareQuery(getAZResourceLocationsQuery)
12✔
127
        whereStr, whereArgs := db.BuildSimpleWhereClause(map[string]any{"pazr.project_id": dbProject.ID}, len(joinArgs))
12✔
128
        azResourceLocationsByID := make(map[db.AZResourceID]core.AZResourceLocation)
12✔
129
        err = sqlext.ForeachRow(p.DB, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...), func(rows *sql.Rows) error {
185✔
130
                var (
173✔
131
                        id  db.AZResourceID
173✔
132
                        loc core.AZResourceLocation
173✔
133
                )
173✔
134
                err := rows.Scan(&id, &loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
173✔
135
                if err != nil {
173✔
136
                        return err
×
137
                }
×
138
                // this check is defense in depth (the DB should be consistent with our config)
139
                if core.HasResource(serviceInfos, loc.ServiceType, loc.ResourceName) {
346✔
140
                        azResourceLocationsByID[id] = loc
173✔
141
                }
173✔
142
                return nil
173✔
143
        })
144
        if respondwith.ObfuscatedErrorText(w, err) {
12✔
145
                return
×
146
        }
×
147

148
        // enumerate relevant project commitments
149
        queryStr, joinArgs = filter.PrepareQuery(getProjectCommitmentsQuery)
12✔
150
        whereStr, whereArgs = db.BuildSimpleWhereClause(map[string]any{"pc.project_id": dbProject.ID}, len(joinArgs))
12✔
151
        var dbCommitments []db.ProjectCommitment
12✔
152
        _, err = p.DB.Select(&dbCommitments, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...)...)
12✔
153
        if respondwith.ObfuscatedErrorText(w, err) {
12✔
154
                return
×
155
        }
×
156

157
        // render response
158
        result := make([]limesresources.Commitment, 0, len(dbCommitments))
12✔
159
        for _, c := range dbCommitments {
26✔
160
                loc, exists := azResourceLocationsByID[c.AZResourceID]
14✔
161
                if !exists {
14✔
162
                        // defense in depth (the DB should not change that much between those two queries above)
×
163
                        continue
×
164
                }
165
                serviceInfo := core.InfoForService(serviceInfos, loc.ServiceType)
14✔
166
                resInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
14✔
167
                result = append(result, p.convertCommitmentToDisplayForm(c, loc, token, resInfo.Unit))
14✔
168
        }
169

170
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitments": result})
12✔
171
}
172

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

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

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

253
        loc := core.AZResourceLocation{
36✔
254
                ServiceType:      dbServiceType,
36✔
255
                ResourceName:     dbResourceName,
36✔
256
                AvailabilityZone: req.AvailabilityZone,
36✔
257
        }
36✔
258
        return &req, &loc, &behavior, &serviceInfo
36✔
259
}
260

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

281
        var (
8✔
282
                azResourceID              db.AZResourceID
8✔
283
                resourceAllowsCommitments bool
8✔
284
                totalConfirmed            uint64
8✔
285
        )
8✔
286
        err := p.DB.QueryRow(findAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
8✔
287
                Scan(&azResourceID, &resourceAllowsCommitments, &totalConfirmed)
8✔
288
        if respondwith.ObfuscatedErrorText(w, err) {
8✔
289
                return
×
290
        }
×
291
        if !resourceAllowsCommitments {
8✔
292
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
×
293
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
294
                return
×
295
        }
×
296
        _ = azResourceID // returned by the above query, but not used in this function
8✔
297

8✔
298
        // this api should always check CanConfirm at now()
8✔
299
        now := p.timeNow()
8✔
300
        if req.ConfirmBy != nil {
8✔
301
                http.Error(w, "this API can only check whether a commitment can be confirmed immediately", http.StatusUnprocessableEntity)
×
302
                return
×
303
        }
×
304
        canConfirmErrMsg := behavior.CanConfirmCommitmentsAt(now)
8✔
305
        if canConfirmErrMsg != "" {
9✔
306
                respondwith.JSON(w, http.StatusOK, map[string]bool{"result": false})
1✔
307
                return
1✔
308
        }
1✔
309

310
        // check for committable capacity
311
        newStatus := liquid.CommitmentStatusConfirmed
7✔
312
        totalConfirmedAfter := totalConfirmed + req.Amount
7✔
313

7✔
314
        commitmentChangeResponse, err := p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
7✔
315
                DryRun:      true,
7✔
316
                AZ:          loc.AvailabilityZone,
7✔
317
                InfoVersion: serviceInfo.Version,
7✔
318
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
7✔
319
                        dbProject.UUID: {
7✔
320
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, *serviceInfo),
7✔
321
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
7✔
322
                                        loc.ResourceName: {
7✔
323
                                                TotalConfirmedBefore: totalConfirmed,
7✔
324
                                                TotalConfirmedAfter:  totalConfirmedAfter,
7✔
325
                                                // TODO: change when introducing "guaranteed" commitments
7✔
326
                                                TotalGuaranteedBefore: 0,
7✔
327
                                                TotalGuaranteedAfter:  0,
7✔
328
                                                Commitments: []liquid.Commitment{
7✔
329
                                                        {
7✔
330
                                                                UUID:      p.generateProjectCommitmentUUID(),
7✔
331
                                                                OldStatus: None[liquid.CommitmentStatus](),
7✔
332
                                                                NewStatus: Some(newStatus),
7✔
333
                                                                Amount:    req.Amount,
7✔
334
                                                                ExpiresAt: req.Duration.AddTo(now),
7✔
335
                                                        },
7✔
336
                                                },
7✔
337
                                        },
7✔
338
                                },
7✔
339
                        },
7✔
340
                },
7✔
341
        }, loc.ServiceType, *serviceInfo, p.DB)
7✔
342
        if respondwith.ObfuscatedErrorText(w, err) {
7✔
343
                return
×
344
        }
×
345
        result := true
7✔
346
        if commitmentChangeResponse.RejectionReason != "" {
9✔
347
                evaluateRetryHeader(commitmentChangeResponse, w)
2✔
348
                result = false
2✔
349
        }
2✔
350
        respondwith.JSON(w, http.StatusOK, map[string]bool{"result": result})
7✔
351
}
352

353
// CreateProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/new.
354
func (p *v1Provider) CreateProjectCommitment(w http.ResponseWriter, r *http.Request) {
42✔
355
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/new")
42✔
356
        token := p.CheckToken(r)
42✔
357
        if !token.Require(w, "project:edit") {
43✔
358
                return
1✔
359
        }
1✔
360
        dbDomain := p.FindDomainFromRequest(w, r)
41✔
361
        if dbDomain == nil {
42✔
362
                return
1✔
363
        }
1✔
364
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
40✔
365
        if dbProject == nil {
41✔
366
                return
1✔
367
        }
1✔
368
        req, loc, behavior, serviceInfo := p.parseAndValidateCommitmentRequest(w, r, *dbDomain)
39✔
369
        if req == nil {
50✔
370
                return
11✔
371
        }
11✔
372

373
        var (
28✔
374
                azResourceID              db.AZResourceID
28✔
375
                resourceAllowsCommitments bool
28✔
376
                totalConfirmed            uint64
28✔
377
        )
28✔
378
        err := p.DB.QueryRow(findAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
28✔
379
                Scan(&azResourceID, &resourceAllowsCommitments, &totalConfirmed)
28✔
380
        if respondwith.ObfuscatedErrorText(w, err) {
28✔
381
                return
×
382
        }
×
383
        if !resourceAllowsCommitments {
29✔
384
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
1✔
385
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
386
                return
1✔
387
        }
1✔
388

389
        // if given, confirm_by must definitely after time.Now(), and also after the MinConfirmDate if configured
390
        now := p.timeNow()
27✔
391
        if req.ConfirmBy != nil && req.ConfirmBy.Before(now) {
28✔
392
                http.Error(w, "confirm_by must not be set in the past", http.StatusUnprocessableEntity)
1✔
393
                return
1✔
394
        }
1✔
395
        confirmBy := options.Map(options.FromPointer(req.ConfirmBy), fromUnixEncodedTime)
26✔
396
        canConfirmErrMsg := behavior.CanConfirmCommitmentsAt(confirmBy.UnwrapOr(now))
26✔
397
        if canConfirmErrMsg != "" {
27✔
398
                http.Error(w, canConfirmErrMsg, http.StatusUnprocessableEntity)
1✔
399
                return
1✔
400
        }
1✔
401

402
        // we want to validate committable capacity in the same transaction that creates the commitment
403
        tx, err := p.DB.Begin()
25✔
404
        if respondwith.ObfuscatedErrorText(w, err) {
25✔
405
                return
×
406
        }
×
407
        defer sqlext.RollbackUnlessCommitted(tx)
25✔
408

25✔
409
        // prepare commitment
25✔
410
        creationContext := db.CommitmentWorkflowContext{Reason: db.CommitmentReasonCreate}
25✔
411
        buf, err := json.Marshal(creationContext)
25✔
412
        if respondwith.ObfuscatedErrorText(w, err) {
25✔
413
                return
×
414
        }
×
415
        dbCommitment := db.ProjectCommitment{
25✔
416
                UUID:                p.generateProjectCommitmentUUID(),
25✔
417
                AZResourceID:        azResourceID,
25✔
418
                ProjectID:           dbProject.ID,
25✔
419
                Amount:              req.Amount,
25✔
420
                Duration:            req.Duration,
25✔
421
                CreatedAt:           now,
25✔
422
                CreatorUUID:         token.UserUUID(),
25✔
423
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
25✔
424
                ConfirmBy:           confirmBy,
25✔
425
                ConfirmedAt:         None[time.Time](), // may be set below
25✔
426
                ExpiresAt:           req.Duration.AddTo(confirmBy.UnwrapOr(now)),
25✔
427
                CreationContextJSON: json.RawMessage(buf),
25✔
428
        }
25✔
429
        if req.NotifyOnConfirm && confirmBy.IsNone() {
26✔
430
                http.Error(w, "notification on confirm cannot be set for commitments with immediate confirmation", http.StatusConflict)
1✔
431
                return
1✔
432
        }
1✔
433
        dbCommitment.NotifyOnConfirm = req.NotifyOnConfirm
24✔
434

24✔
435
        // we do an information to liquid in any case, right now we only check the result when confirming immediately
24✔
436
        newStatus := liquid.CommitmentStatusPlanned
24✔
437
        totalConfirmedAfter := totalConfirmed
24✔
438
        if confirmBy.IsNone() {
42✔
439
                newStatus = liquid.CommitmentStatusConfirmed
18✔
440
                totalConfirmedAfter += req.Amount
18✔
441
        }
18✔
442
        commitmentChangeRequest := liquid.CommitmentChangeRequest{
24✔
443
                AZ:          loc.AvailabilityZone,
24✔
444
                InfoVersion: serviceInfo.Version,
24✔
445
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
24✔
446
                        dbProject.UUID: {
24✔
447
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, *serviceInfo),
24✔
448
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
24✔
449
                                        loc.ResourceName: {
24✔
450
                                                TotalConfirmedBefore: totalConfirmed,
24✔
451
                                                TotalConfirmedAfter:  totalConfirmedAfter,
24✔
452
                                                // TODO: change when introducing "guaranteed" commitments
24✔
453
                                                TotalGuaranteedBefore: 0,
24✔
454
                                                TotalGuaranteedAfter:  0,
24✔
455
                                                Commitments: []liquid.Commitment{
24✔
456
                                                        {
24✔
457
                                                                UUID:      dbCommitment.UUID,
24✔
458
                                                                OldStatus: None[liquid.CommitmentStatus](),
24✔
459
                                                                NewStatus: Some(newStatus),
24✔
460
                                                                Amount:    req.Amount,
24✔
461
                                                                ConfirmBy: confirmBy,
24✔
462
                                                                ExpiresAt: req.Duration.AddTo(confirmBy.UnwrapOr(now)),
24✔
463
                                                        },
24✔
464
                                                },
24✔
465
                                        },
24✔
466
                                },
24✔
467
                        },
24✔
468
                },
24✔
469
        }
24✔
470
        commitmentChangeResponse, err := p.DelegateChangeCommitments(r.Context(), commitmentChangeRequest, loc.ServiceType, *serviceInfo, p.DB)
24✔
471
        if respondwith.ObfuscatedErrorText(w, err) {
24✔
472
                return
×
473
        }
×
474

475
        if commitmentChangeRequest.RequiresConfirmation() {
42✔
476
                // if not planned for confirmation in the future, confirm immediately (or fail)
18✔
477
                if commitmentChangeResponse.RejectionReason != "" {
18✔
478
                        evaluateRetryHeader(commitmentChangeResponse, w)
×
479
                        http.Error(w, commitmentChangeResponse.RejectionReason, http.StatusConflict)
×
480
                        return
×
481
                }
×
482
                dbCommitment.ConfirmedAt = Some(now)
18✔
483
                dbCommitment.Status = liquid.CommitmentStatusConfirmed
18✔
484
        } else {
6✔
485
                // TODO: when introducing guaranteed, the customer can choose via the API signature whether he wants to create
6✔
486
                // the commitment only as guaranteed (RequestAsGuaranteed). If this request then fails, the customer could
6✔
487
                // resubmit it and get a planned commitment, which might never get confirmed.
6✔
488
                dbCommitment.Status = liquid.CommitmentStatusPlanned
6✔
489
        }
6✔
490

491
        // create commitment
492
        err = tx.Insert(&dbCommitment)
24✔
493
        if respondwith.ObfuscatedErrorText(w, err) {
24✔
494
                return
×
495
        }
×
496

497
        err = tx.Commit()
24✔
498
        if respondwith.ObfuscatedErrorText(w, err) {
24✔
499
                return
×
500
        }
×
501

502
        resourceInfo := core.InfoForResource(*serviceInfo, loc.ResourceName)
24✔
503
        commitment := p.convertCommitmentToDisplayForm(dbCommitment, *loc, token, resourceInfo.Unit)
24✔
504
        p.auditor.Record(audittools.Event{
24✔
505
                Time:       now,
24✔
506
                Request:    r,
24✔
507
                User:       token,
24✔
508
                ReasonCode: http.StatusCreated,
24✔
509
                Action:     cadf.CreateAction,
24✔
510
                Target: commitmentEventTarget{
24✔
511
                        DomainID:        dbDomain.UUID,
24✔
512
                        DomainName:      dbDomain.Name,
24✔
513
                        ProjectID:       dbProject.UUID,
24✔
514
                        ProjectName:     dbProject.Name,
24✔
515
                        Commitments:     []limesresources.Commitment{commitment},
24✔
516
                        WorkflowContext: Some(creationContext),
24✔
517
                },
24✔
518
        })
24✔
519

24✔
520
        // if the commitment is immediately confirmed, trigger a capacity scrape in
24✔
521
        // order to ApplyComputedProjectQuotas based on the new commitment
24✔
522
        if dbCommitment.ConfirmedAt.IsSome() {
42✔
523
                _, err := p.DB.Exec(`UPDATE services SET next_scrape_at = $1 WHERE type = $2`, now, loc.ServiceType)
18✔
524
                if err != nil {
18✔
525
                        logg.Error("could not trigger a new capacity scrape after creating commitment %s: %s", dbCommitment.UUID, err.Error())
×
526
                }
×
527
        }
528

529
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": commitment})
24✔
530
}
531

532
// MergeProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/merge.
533
func (p *v1Provider) MergeProjectCommitments(w http.ResponseWriter, r *http.Request) {
12✔
534
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/merge")
12✔
535
        token := p.CheckToken(r)
12✔
536
        if !token.Require(w, "project:edit") {
13✔
537
                return
1✔
538
        }
1✔
539
        dbDomain := p.FindDomainFromRequest(w, r)
11✔
540
        if dbDomain == nil {
12✔
541
                return
1✔
542
        }
1✔
543
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
10✔
544
        if dbProject == nil {
11✔
545
                return
1✔
546
        }
1✔
547
        var parseTarget struct {
9✔
548
                CommitmentIDs []db.ProjectCommitmentID `json:"commitment_ids"`
9✔
549
        }
9✔
550
        if !RequireJSON(w, r, &parseTarget) {
9✔
551
                return
×
552
        }
×
553
        commitmentIDs := parseTarget.CommitmentIDs
9✔
554
        if len(commitmentIDs) < 2 {
10✔
555
                http.Error(w, fmt.Sprintf("merging requires at least two commitments, but %d were given", len(commitmentIDs)), http.StatusBadRequest)
1✔
556
                return
1✔
557
        }
1✔
558

559
        // Load commitments
560
        dbCommitments := make([]db.ProjectCommitment, len(commitmentIDs))
8✔
561
        commitmentUUIDs := make([]liquid.CommitmentUUID, len(commitmentIDs))
8✔
562
        for i, commitmentID := range commitmentIDs {
24✔
563
                err := p.DB.SelectOne(&dbCommitments[i], findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
16✔
564
                if errors.Is(err, sql.ErrNoRows) {
17✔
565
                        http.Error(w, "no such commitment", http.StatusNotFound)
1✔
566
                        return
1✔
567
                } else if respondwith.ObfuscatedErrorText(w, err) {
16✔
568
                        return
×
569
                }
×
570
                commitmentUUIDs[i] = dbCommitments[i].UUID
15✔
571
        }
572

573
        // Verify that all commitments agree on resource and AZ and are confirmed
574
        azResourceID := dbCommitments[0].AZResourceID
7✔
575
        for _, dbCommitment := range dbCommitments {
21✔
576
                if dbCommitment.AZResourceID != azResourceID {
16✔
577
                        http.Error(w, "all commitments must be on the same resource and AZ", http.StatusConflict)
2✔
578
                        return
2✔
579
                }
2✔
580
                if dbCommitment.Status != liquid.CommitmentStatusConfirmed {
16✔
581
                        http.Error(w, "only confirmed commitments may be merged", http.StatusConflict)
4✔
582
                        return
4✔
583
                }
4✔
584
        }
585

586
        var (
1✔
587
                loc            core.AZResourceLocation
1✔
588
                totalConfirmed uint64
1✔
589
        )
1✔
590
        err := p.DB.QueryRow(findAZResourceLocationByIDQuery, azResourceID, dbProject.ID).
1✔
591
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
1✔
592
        if errors.Is(err, sql.ErrNoRows) {
1✔
593
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
594
                return
×
595
        } else if respondwith.ObfuscatedErrorText(w, err) {
1✔
596
                return
×
597
        }
×
598

599
        // Start transaction for creating new commitment and marking merged commitments as superseded
600
        tx, err := p.DB.Begin()
1✔
601
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
602
                return
×
603
        }
×
604
        defer sqlext.RollbackUnlessCommitted(tx)
1✔
605

1✔
606
        // Create merged template
1✔
607
        now := p.timeNow()
1✔
608
        dbMergedCommitment := db.ProjectCommitment{
1✔
609
                UUID:         p.generateProjectCommitmentUUID(),
1✔
610
                ProjectID:    dbProject.ID,
1✔
611
                AZResourceID: azResourceID,
1✔
612
                Amount:       0,                                   // overwritten below
1✔
613
                Duration:     limesresources.CommitmentDuration{}, // overwritten below
1✔
614
                CreatedAt:    now,
1✔
615
                CreatorUUID:  token.UserUUID(),
1✔
616
                CreatorName:  fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
1✔
617
                ConfirmedAt:  Some(now),
1✔
618
                ExpiresAt:    time.Time{}, // overwritten below
1✔
619
                Status:       liquid.CommitmentStatusConfirmed,
1✔
620
        }
1✔
621

1✔
622
        // Fill amount and latest expiration date
1✔
623
        for _, dbCommitment := range dbCommitments {
3✔
624
                dbMergedCommitment.Amount += dbCommitment.Amount
2✔
625
                if dbCommitment.ExpiresAt.After(dbMergedCommitment.ExpiresAt) {
4✔
626
                        dbMergedCommitment.ExpiresAt = dbCommitment.ExpiresAt
2✔
627
                        dbMergedCommitment.Duration = dbCommitment.Duration
2✔
628
                }
2✔
629
        }
630

631
        // Fill workflow context
632
        creationContext := db.CommitmentWorkflowContext{
1✔
633
                Reason:                 db.CommitmentReasonMerge,
1✔
634
                RelatedCommitmentIDs:   commitmentIDs,
1✔
635
                RelatedCommitmentUUIDs: commitmentUUIDs,
1✔
636
        }
1✔
637
        buf, err := json.Marshal(creationContext)
1✔
638
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
639
                return
×
640
        }
×
641
        dbMergedCommitment.CreationContextJSON = json.RawMessage(buf)
1✔
642

1✔
643
        // Insert into database
1✔
644
        err = tx.Insert(&dbMergedCommitment)
1✔
645
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
646
                return
×
647
        }
×
648

649
        // Mark merged commits as superseded
650
        supersedeContext := db.CommitmentWorkflowContext{
1✔
651
                Reason:                 db.CommitmentReasonMerge,
1✔
652
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbMergedCommitment.ID},
1✔
653
                RelatedCommitmentUUIDs: []liquid.CommitmentUUID{dbMergedCommitment.UUID},
1✔
654
        }
1✔
655
        buf, err = json.Marshal(supersedeContext)
1✔
656
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
657
                return
×
658
        }
×
659
        for _, dbCommitment := range dbCommitments {
3✔
660
                dbCommitment.SupersededAt = Some(now)
2✔
661
                dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
662
                dbCommitment.Status = liquid.CommitmentStatusSuperseded
2✔
663
                _, err = tx.Update(&dbCommitment)
2✔
664
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
665
                        return
×
666
                }
×
667
        }
668

669
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
670
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
671
                return
×
672
        }
×
673
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
674
        if !ok {
1✔
675
                http.Error(w, "service not found", http.StatusNotFound)
×
676
                return
×
677
        }
×
678

679
        liquidCommitments := make([]liquid.Commitment, 1, len(dbCommitments)+1)
1✔
680
        // new
1✔
681
        liquidCommitments[0] = liquid.Commitment{
1✔
682
                UUID:      dbMergedCommitment.UUID,
1✔
683
                OldStatus: None[liquid.CommitmentStatus](),
1✔
684
                NewStatus: Some(liquid.CommitmentStatusConfirmed),
1✔
685
                Amount:    dbMergedCommitment.Amount,
1✔
686
                ConfirmBy: dbMergedCommitment.ConfirmBy,
1✔
687
                ExpiresAt: dbMergedCommitment.ExpiresAt,
1✔
688
        }
1✔
689
        // old
1✔
690
        for _, dbCommitment := range dbCommitments {
3✔
691
                liquidCommitments = append(liquidCommitments, liquid.Commitment{
2✔
692
                        UUID:      dbCommitment.UUID,
2✔
693
                        OldStatus: Some(liquid.CommitmentStatusConfirmed),
2✔
694
                        NewStatus: Some(liquid.CommitmentStatusSuperseded),
2✔
695
                        Amount:    dbCommitment.Amount,
2✔
696
                        ConfirmBy: dbCommitment.ConfirmBy,
2✔
697
                        ExpiresAt: dbCommitment.ExpiresAt,
2✔
698
                })
2✔
699
        }
2✔
700
        _, err = p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
1✔
701
                AZ:          loc.AvailabilityZone,
1✔
702
                InfoVersion: serviceInfo.Version,
1✔
703
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
1✔
704
                        dbProject.UUID: {
1✔
705
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
1✔
706
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
1✔
707
                                        loc.ResourceName: {
1✔
708
                                                TotalConfirmedBefore: totalConfirmed,
1✔
709
                                                TotalConfirmedAfter:  totalConfirmed,
1✔
710
                                                // TODO: change when introducing "guaranteed" commitments
1✔
711
                                                TotalGuaranteedBefore: 0,
1✔
712
                                                TotalGuaranteedAfter:  0,
1✔
713
                                                Commitments:           liquidCommitments,
1✔
714
                                        },
1✔
715
                                },
1✔
716
                        },
1✔
717
                },
1✔
718
        }, loc.ServiceType, serviceInfo, tx)
1✔
719
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
720
                return
×
721
        }
×
722

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

728
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
729
        c := p.convertCommitmentToDisplayForm(dbMergedCommitment, loc, token, resourceInfo.Unit)
1✔
730
        auditEvent := commitmentEventTarget{
1✔
731
                DomainID:        dbDomain.UUID,
1✔
732
                DomainName:      dbDomain.Name,
1✔
733
                ProjectID:       dbProject.UUID,
1✔
734
                ProjectName:     dbProject.Name,
1✔
735
                Commitments:     []limesresources.Commitment{c},
1✔
736
                WorkflowContext: Some(creationContext),
1✔
737
        }
1✔
738
        p.auditor.Record(audittools.Event{
1✔
739
                Time:       p.timeNow(),
1✔
740
                Request:    r,
1✔
741
                User:       token,
1✔
742
                ReasonCode: http.StatusAccepted,
1✔
743
                Action:     cadf.UpdateAction,
1✔
744
                Target:     auditEvent,
1✔
745
        })
1✔
746

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

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

753
// RenewProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/:id/renew.
754
func (p *v1Provider) RenewProjectCommitments(w http.ResponseWriter, r *http.Request) {
6✔
755
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/renew")
6✔
756
        token := p.CheckToken(r)
6✔
757
        if !token.Require(w, "project:edit") {
6✔
758
                return
×
759
        }
×
760
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
761
        if dbDomain == nil {
6✔
762
                return
×
763
        }
×
764
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
765
        if dbProject == nil {
6✔
766
                return
×
767
        }
×
768

769
        // Load commitment
770
        var dbCommitment db.ProjectCommitment
6✔
771
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
772
        if errors.Is(err, sql.ErrNoRows) {
6✔
773
                http.Error(w, "no such commitment", http.StatusNotFound)
×
774
                return
×
775
        } else if respondwith.ObfuscatedErrorText(w, err) {
6✔
776
                return
×
777
        }
×
778
        now := p.timeNow()
6✔
779

6✔
780
        // Check if commitment can be renewed
6✔
781
        var errs errext.ErrorSet
6✔
782
        if dbCommitment.Status != liquid.CommitmentStatusConfirmed {
7✔
783
                errs.Addf("invalid status %q", dbCommitment.Status)
1✔
784
        } else if now.After(dbCommitment.ExpiresAt) {
7✔
785
                errs.Addf("invalid status %q", liquid.CommitmentStatusExpired)
1✔
786
        }
1✔
787
        if now.Before(dbCommitment.ExpiresAt.Add(-commitmentRenewalPeriod)) {
7✔
788
                errs.Addf("renewal attempt too early")
1✔
789
        }
1✔
790
        if dbCommitment.RenewContextJSON.IsSome() {
7✔
791
                errs.Addf("already renewed")
1✔
792
        }
1✔
793

794
        if !errs.IsEmpty() {
10✔
795
                msg := "cannot renew this commitment: " + errs.Join(", ")
4✔
796
                http.Error(w, msg, http.StatusConflict)
4✔
797
                return
4✔
798
        }
4✔
799

800
        // Create renewed commitment
801
        tx, err := p.DB.Begin()
2✔
802
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
803
                return
×
804
        }
×
805
        defer sqlext.RollbackUnlessCommitted(tx)
2✔
806

2✔
807
        var (
2✔
808
                loc            core.AZResourceLocation
2✔
809
                totalConfirmed uint64
2✔
810
        )
2✔
811
        err = tx.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbProject.ID).
2✔
812
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
2✔
813
        if errors.Is(err, sql.ErrNoRows) {
2✔
814
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
815
                return
×
816
        } else if respondwith.ObfuscatedErrorText(w, err) {
2✔
817
                return
×
818
        }
×
819

820
        creationContext := db.CommitmentWorkflowContext{
2✔
821
                Reason:                 db.CommitmentReasonRenew,
2✔
822
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
2✔
823
                RelatedCommitmentUUIDs: []liquid.CommitmentUUID{dbCommitment.UUID},
2✔
824
        }
2✔
825
        buf, err := json.Marshal(creationContext)
2✔
826
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
827
                return
×
828
        }
×
829
        dbRenewedCommitment := db.ProjectCommitment{
2✔
830
                UUID:                p.generateProjectCommitmentUUID(),
2✔
831
                ProjectID:           dbProject.ID,
2✔
832
                AZResourceID:        dbCommitment.AZResourceID,
2✔
833
                Amount:              dbCommitment.Amount,
2✔
834
                Duration:            dbCommitment.Duration,
2✔
835
                CreatedAt:           now,
2✔
836
                CreatorUUID:         token.UserUUID(),
2✔
837
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
2✔
838
                ConfirmBy:           Some(dbCommitment.ExpiresAt),
2✔
839
                ExpiresAt:           dbCommitment.Duration.AddTo(dbCommitment.ExpiresAt),
2✔
840
                Status:              liquid.CommitmentStatusPlanned,
2✔
841
                CreationContextJSON: json.RawMessage(buf),
2✔
842
        }
2✔
843

2✔
844
        err = tx.Insert(&dbRenewedCommitment)
2✔
845
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
846
                return
×
847
        }
×
848

849
        renewContext := db.CommitmentWorkflowContext{
2✔
850
                Reason:                 db.CommitmentReasonRenew,
2✔
851
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbRenewedCommitment.ID},
2✔
852
                RelatedCommitmentUUIDs: []liquid.CommitmentUUID{dbRenewedCommitment.UUID},
2✔
853
        }
2✔
854
        buf, err = json.Marshal(renewContext)
2✔
855
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
856
                return
×
857
        }
×
858
        dbCommitment.RenewContextJSON = Some(json.RawMessage(buf))
2✔
859
        _, err = tx.Update(&dbCommitment)
2✔
860
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
861
                return
×
862
        }
×
863

864
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
865
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
866
                return
×
867
        }
×
868
        serviceInfo, ok := maybeServiceInfo.Unpack()
2✔
869
        if !ok {
2✔
870
                http.Error(w, "service not found", http.StatusNotFound)
×
871
                return
×
872
        }
×
873

874
        // TODO: for now, this is CommitmentChangeRequest.RequiresConfirmation() = false, because totalConfirmed stays and guaranteed is not used yet.
875
        // when we change this, we need to evaluate the response of the liquid
876
        _, err = p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
2✔
877
                AZ:          loc.AvailabilityZone,
2✔
878
                InfoVersion: serviceInfo.Version,
2✔
879
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
2✔
880
                        dbProject.UUID: {
2✔
881
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
2✔
882
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
2✔
883
                                        loc.ResourceName: {
2✔
884
                                                TotalConfirmedBefore: totalConfirmed,
2✔
885
                                                TotalConfirmedAfter:  totalConfirmed,
2✔
886
                                                // TODO: change when introducing "guaranteed" commitments
2✔
887
                                                TotalGuaranteedBefore: 0,
2✔
888
                                                TotalGuaranteedAfter:  0,
2✔
889
                                                Commitments: []liquid.Commitment{
2✔
890
                                                        {
2✔
891
                                                                UUID:      dbRenewedCommitment.UUID,
2✔
892
                                                                OldStatus: None[liquid.CommitmentStatus](),
2✔
893
                                                                NewStatus: Some(liquid.CommitmentStatusPlanned),
2✔
894
                                                                Amount:    dbRenewedCommitment.Amount,
2✔
895
                                                                ConfirmBy: dbRenewedCommitment.ConfirmBy,
2✔
896
                                                                ExpiresAt: dbRenewedCommitment.ExpiresAt,
2✔
897
                                                        },
2✔
898
                                                },
2✔
899
                                        },
2✔
900
                                },
2✔
901
                        },
2✔
902
                },
2✔
903
        }, loc.ServiceType, serviceInfo, tx)
2✔
904
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
905
                return
×
906
        }
×
907

908
        err = tx.Commit()
2✔
909
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
910
                return
×
911
        }
×
912

913
        // Create resultset and auditlogs
914
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
915
        c := p.convertCommitmentToDisplayForm(dbRenewedCommitment, loc, token, resourceInfo.Unit)
2✔
916
        auditEvent := commitmentEventTarget{
2✔
917
                DomainID:        dbDomain.UUID,
2✔
918
                DomainName:      dbDomain.Name,
2✔
919
                ProjectID:       dbProject.UUID,
2✔
920
                ProjectName:     dbProject.Name,
2✔
921
                Commitments:     []limesresources.Commitment{c},
2✔
922
                WorkflowContext: Some(creationContext),
2✔
923
        }
2✔
924

2✔
925
        p.auditor.Record(audittools.Event{
2✔
926
                Time:       p.timeNow(),
2✔
927
                Request:    r,
2✔
928
                User:       token,
2✔
929
                ReasonCode: http.StatusAccepted,
2✔
930
                Action:     cadf.UpdateAction,
2✔
931
                Target:     auditEvent,
2✔
932
        })
2✔
933

2✔
934
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
935
}
936

937
// DeleteProjectCommitment handles DELETE /v1/domains/:domain_id/projects/:project_id/commitments/:id.
938
func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Request) {
8✔
939
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id")
8✔
940
        token := p.CheckToken(r)
8✔
941
        if !token.Require(w, "project:edit") { // NOTE: There is a more specific AuthZ check further down below.
8✔
942
                return
×
943
        }
×
944
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
945
        if dbDomain == nil {
9✔
946
                return
1✔
947
        }
1✔
948
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
949
        if dbProject == nil {
8✔
950
                return
1✔
951
        }
1✔
952

953
        // load commitment
954
        var dbCommitment db.ProjectCommitment
6✔
955
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
956
        if errors.Is(err, sql.ErrNoRows) {
7✔
957
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
958
                return
1✔
959
        } else if respondwith.ObfuscatedErrorText(w, err) {
6✔
960
                return
×
961
        }
×
962
        var (
5✔
963
                loc            core.AZResourceLocation
5✔
964
                totalConfirmed uint64
5✔
965
        )
5✔
966
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbProject.ID).
5✔
967
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
5✔
968
        if errors.Is(err, sql.ErrNoRows) {
5✔
969
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
970
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
971
                return
×
972
        } else if respondwith.ObfuscatedErrorText(w, err) {
5✔
973
                return
×
974
        }
×
975

976
        // check authorization for this specific commitment
977
        if !p.canDeleteCommitment(token, dbCommitment) {
6✔
978
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
979
                return
1✔
980
        }
1✔
981

982
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
4✔
983
        if respondwith.ObfuscatedErrorText(w, err) {
4✔
984
                return
×
985
        }
×
986
        serviceInfo, ok := maybeServiceInfo.Unpack()
4✔
987
        if !ok {
4✔
988
                http.Error(w, "service not found", http.StatusNotFound)
×
989
                return
×
990
        }
×
991

992
        totalConfirmedAfter := totalConfirmed
4✔
993
        if dbCommitment.Status == liquid.CommitmentStatusConfirmed {
6✔
994
                totalConfirmedAfter -= dbCommitment.Amount
2✔
995
        }
2✔
996

997
        _, err = p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
4✔
998
                AZ:          loc.AvailabilityZone,
4✔
999
                InfoVersion: serviceInfo.Version,
4✔
1000
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
4✔
1001
                        dbProject.UUID: {
4✔
1002
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
4✔
1003
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
4✔
1004
                                        loc.ResourceName: {
4✔
1005
                                                TotalConfirmedBefore: totalConfirmed,
4✔
1006
                                                TotalConfirmedAfter:  totalConfirmedAfter,
4✔
1007
                                                // TODO: change when introducing "guaranteed" commitments
4✔
1008
                                                TotalGuaranteedBefore: 0,
4✔
1009
                                                TotalGuaranteedAfter:  0,
4✔
1010
                                                Commitments: []liquid.Commitment{
4✔
1011
                                                        {
4✔
1012
                                                                UUID:      dbCommitment.UUID,
4✔
1013
                                                                OldStatus: Some(dbCommitment.Status),
4✔
1014
                                                                NewStatus: None[liquid.CommitmentStatus](),
4✔
1015
                                                                Amount:    dbCommitment.Amount,
4✔
1016
                                                                ConfirmBy: dbCommitment.ConfirmBy,
4✔
1017
                                                                ExpiresAt: dbCommitment.ExpiresAt,
4✔
1018
                                                        },
4✔
1019
                                                },
4✔
1020
                                        },
4✔
1021
                                },
4✔
1022
                        },
4✔
1023
                },
4✔
1024
        }, loc.ServiceType, serviceInfo, p.DB)
4✔
1025
        if respondwith.ObfuscatedErrorText(w, err) {
4✔
1026
                return
×
1027
        }
×
1028

1029
        // perform deletion
1030
        _, err = p.DB.Delete(&dbCommitment)
4✔
1031
        if respondwith.ObfuscatedErrorText(w, err) {
4✔
1032
                return
×
1033
        }
×
1034

1035
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
4✔
1036
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
4✔
1037
        p.auditor.Record(audittools.Event{
4✔
1038
                Time:       p.timeNow(),
4✔
1039
                Request:    r,
4✔
1040
                User:       token,
4✔
1041
                ReasonCode: http.StatusNoContent,
4✔
1042
                Action:     cadf.DeleteAction,
4✔
1043
                Target: commitmentEventTarget{
4✔
1044
                        DomainID:    dbDomain.UUID,
4✔
1045
                        DomainName:  dbDomain.Name,
4✔
1046
                        ProjectID:   dbProject.UUID,
4✔
1047
                        ProjectName: dbProject.Name,
4✔
1048
                        Commitments: []limesresources.Commitment{c},
4✔
1049
                },
4✔
1050
        })
4✔
1051

4✔
1052
        w.WriteHeader(http.StatusNoContent)
4✔
1053
}
1054

1055
func (p *v1Provider) canDeleteCommitment(token *gopherpolicy.Token, commitment db.ProjectCommitment) bool {
64✔
1056
        // up to 24 hours after creation of fresh commitments, future commitments can still be deleted by their creators
64✔
1057
        if commitment.Status == liquid.CommitmentStatusPlanned || commitment.Status == liquid.CommitmentStatusPending || commitment.Status == liquid.CommitmentStatusConfirmed {
128✔
1058
                var creationContext db.CommitmentWorkflowContext
64✔
1059
                err := json.Unmarshal(commitment.CreationContextJSON, &creationContext)
64✔
1060
                if err == nil && creationContext.Reason == db.CommitmentReasonCreate && p.timeNow().Before(commitment.CreatedAt.Add(24*time.Hour)) {
104✔
1061
                        if token.Check("project:edit") {
80✔
1062
                                return true
40✔
1063
                        }
40✔
1064
                }
1065
        }
1066

1067
        // afterwards, a more specific permission is required to delete it
1068
        //
1069
        // This protects cloud admins making capacity planning decisions based on future commitments
1070
        // from having their forecasts ruined by project admins suffering from buyer's remorse.
1071
        return token.Check("project:uncommit")
24✔
1072
}
1073

1074
// StartCommitmentTransfer handles POST /v1/domains/:id/projects/:id/commitments/:id/start-transfer
1075
func (p *v1Provider) StartCommitmentTransfer(w http.ResponseWriter, r *http.Request) {
8✔
1076
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/start-transfer")
8✔
1077
        token := p.CheckToken(r)
8✔
1078
        if !token.Require(w, "project:edit") {
8✔
1079
                return
×
1080
        }
×
1081
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
1082
        if dbDomain == nil {
8✔
1083
                return
×
1084
        }
×
1085
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
8✔
1086
        if dbProject == nil {
8✔
1087
                return
×
1088
        }
×
1089
        // TODO: eventually migrate this struct into go-api-declarations
1090
        var parseTarget struct {
8✔
1091
                Request struct {
8✔
1092
                        Amount         uint64                                  `json:"amount"`
8✔
1093
                        TransferStatus limesresources.CommitmentTransferStatus `json:"transfer_status,omitempty"`
8✔
1094
                } `json:"commitment"`
8✔
1095
        }
8✔
1096
        if !RequireJSON(w, r, &parseTarget) {
8✔
1097
                return
×
1098
        }
×
1099
        req := parseTarget.Request
8✔
1100

8✔
1101
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
1102
                http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest)
×
1103
                return
×
1104
        }
×
1105

1106
        if req.Amount <= 0 {
9✔
1107
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
1108
                return
1✔
1109
        }
1✔
1110

1111
        // load commitment
1112
        var dbCommitment db.ProjectCommitment
7✔
1113
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
7✔
1114
        if errors.Is(err, sql.ErrNoRows) {
7✔
1115
                http.Error(w, "no such commitment", http.StatusNotFound)
×
1116
                return
×
1117
        } else if respondwith.ObfuscatedErrorText(w, err) {
7✔
1118
                return
×
1119
        }
×
1120

1121
        // Deny requests with a greater amount than the commitment.
1122
        if req.Amount > dbCommitment.Amount {
8✔
1123
                http.Error(w, "delivered amount exceeds the commitment amount.", http.StatusBadRequest)
1✔
1124
                return
1✔
1125
        }
1✔
1126

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

1141
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
6✔
1142
        if respondwith.ObfuscatedErrorText(w, err) {
6✔
1143
                return
×
1144
        }
×
1145
        serviceInfo, ok := maybeServiceInfo.Unpack()
6✔
1146
        if !ok {
6✔
1147
                http.Error(w, "service not found", http.StatusNotFound)
×
1148
                return
×
1149
        }
×
1150

1151
        // Mark whole commitment or a newly created, splitted one as transferrable.
1152
        tx, err := p.DB.Begin()
6✔
1153
        if respondwith.ObfuscatedErrorText(w, err) {
6✔
1154
                return
×
1155
        }
×
1156
        defer sqlext.RollbackUnlessCommitted(tx)
6✔
1157
        transferToken := p.generateTransferToken()
6✔
1158

6✔
1159
        if req.Amount == dbCommitment.Amount {
10✔
1160
                dbCommitment.TransferStatus = req.TransferStatus
4✔
1161
                dbCommitment.TransferToken = Some(transferToken)
4✔
1162
                _, err = tx.Update(&dbCommitment)
4✔
1163
                if respondwith.ObfuscatedErrorText(w, err) {
4✔
1164
                        return
×
1165
                }
×
1166
        } else {
2✔
1167
                now := p.timeNow()
2✔
1168
                transferAmount := req.Amount
2✔
1169
                remainingAmount := dbCommitment.Amount - req.Amount
2✔
1170
                transferCommitment, err := p.buildSplitCommitment(dbCommitment, transferAmount)
2✔
1171
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1172
                        return
×
1173
                }
×
1174
                transferCommitment.TransferStatus = req.TransferStatus
2✔
1175
                transferCommitment.TransferToken = Some(transferToken)
2✔
1176
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
2✔
1177
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1178
                        return
×
1179
                }
×
1180
                err = tx.Insert(&transferCommitment)
2✔
1181
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1182
                        return
×
1183
                }
×
1184
                err = tx.Insert(&remainingCommitment)
2✔
1185
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1186
                        return
×
1187
                }
×
1188

1189
                _, err = p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
2✔
1190
                        AZ:          loc.AvailabilityZone,
2✔
1191
                        InfoVersion: serviceInfo.Version,
2✔
1192
                        ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
2✔
1193
                                dbProject.UUID: {
2✔
1194
                                        ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
2✔
1195
                                        ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
2✔
1196
                                                loc.ResourceName: {
2✔
1197
                                                        TotalConfirmedBefore: totalConfirmed,
2✔
1198
                                                        TotalConfirmedAfter:  totalConfirmed,
2✔
1199
                                                        // TODO: change when introducing "guaranteed" commitments
2✔
1200
                                                        TotalGuaranteedBefore: 0,
2✔
1201
                                                        TotalGuaranteedAfter:  0,
2✔
1202
                                                        Commitments: []liquid.Commitment{
2✔
1203
                                                                // old
2✔
1204
                                                                {
2✔
1205
                                                                        UUID:      dbCommitment.UUID,
2✔
1206
                                                                        OldStatus: Some(dbCommitment.Status),
2✔
1207
                                                                        NewStatus: Some(liquid.CommitmentStatusSuperseded),
2✔
1208
                                                                        Amount:    dbCommitment.Amount,
2✔
1209
                                                                        ConfirmBy: dbCommitment.ConfirmBy,
2✔
1210
                                                                        ExpiresAt: dbCommitment.ExpiresAt,
2✔
1211
                                                                },
2✔
1212
                                                                // new
2✔
1213
                                                                {
2✔
1214
                                                                        UUID:      transferCommitment.UUID,
2✔
1215
                                                                        OldStatus: None[liquid.CommitmentStatus](),
2✔
1216
                                                                        NewStatus: Some(transferCommitment.Status),
2✔
1217
                                                                        Amount:    transferCommitment.Amount,
2✔
1218
                                                                        ConfirmBy: transferCommitment.ConfirmBy,
2✔
1219
                                                                        ExpiresAt: transferCommitment.ExpiresAt,
2✔
1220
                                                                },
2✔
1221
                                                                {
2✔
1222
                                                                        UUID:      remainingCommitment.UUID,
2✔
1223
                                                                        OldStatus: None[liquid.CommitmentStatus](),
2✔
1224
                                                                        NewStatus: Some(remainingCommitment.Status),
2✔
1225
                                                                        Amount:    remainingCommitment.Amount,
2✔
1226
                                                                        ConfirmBy: remainingCommitment.ConfirmBy,
2✔
1227
                                                                        ExpiresAt: remainingCommitment.ExpiresAt,
2✔
1228
                                                                },
2✔
1229
                                                        },
2✔
1230
                                                },
2✔
1231
                                        },
2✔
1232
                                },
2✔
1233
                        },
2✔
1234
                }, loc.ServiceType, serviceInfo, tx)
2✔
1235
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1236
                        return
×
1237
                }
×
1238

1239
                supersedeContext := db.CommitmentWorkflowContext{
2✔
1240
                        Reason:                 db.CommitmentReasonSplit,
2✔
1241
                        RelatedCommitmentIDs:   []db.ProjectCommitmentID{transferCommitment.ID, remainingCommitment.ID},
2✔
1242
                        RelatedCommitmentUUIDs: []liquid.CommitmentUUID{transferCommitment.UUID, remainingCommitment.UUID},
2✔
1243
                }
2✔
1244
                buf, err := json.Marshal(supersedeContext)
2✔
1245
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1246
                        return
×
1247
                }
×
1248
                dbCommitment.Status = liquid.CommitmentStatusSuperseded
2✔
1249
                dbCommitment.SupersededAt = Some(now)
2✔
1250
                dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
1251
                _, err = tx.Update(&dbCommitment)
2✔
1252
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1253
                        return
×
1254
                }
×
1255

1256
                dbCommitment = transferCommitment
2✔
1257
        }
1258
        err = tx.Commit()
6✔
1259
        if respondwith.ObfuscatedErrorText(w, err) {
6✔
1260
                return
×
1261
        }
×
1262

1263
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
6✔
1264
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
6✔
1265
        p.auditor.Record(audittools.Event{
6✔
1266
                Time:       p.timeNow(),
6✔
1267
                Request:    r,
6✔
1268
                User:       token,
6✔
1269
                ReasonCode: http.StatusAccepted,
6✔
1270
                Action:     cadf.UpdateAction,
6✔
1271
                Target: commitmentEventTarget{
6✔
1272
                        DomainID:    dbDomain.UUID,
6✔
1273
                        DomainName:  dbDomain.Name,
6✔
1274
                        ProjectID:   dbProject.UUID,
6✔
1275
                        ProjectName: dbProject.Name,
6✔
1276
                        Commitments: []limesresources.Commitment{c},
6✔
1277
                },
6✔
1278
        })
6✔
1279
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
6✔
1280
}
1281

1282
func (p *v1Provider) buildSplitCommitment(dbCommitment db.ProjectCommitment, amount uint64) (db.ProjectCommitment, error) {
5✔
1283
        now := p.timeNow()
5✔
1284
        creationContext := db.CommitmentWorkflowContext{
5✔
1285
                Reason:                 db.CommitmentReasonSplit,
5✔
1286
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
5✔
1287
                RelatedCommitmentUUIDs: []liquid.CommitmentUUID{dbCommitment.UUID},
5✔
1288
        }
5✔
1289
        buf, err := json.Marshal(creationContext)
5✔
1290
        if err != nil {
5✔
1291
                return db.ProjectCommitment{}, err
×
1292
        }
×
1293
        return db.ProjectCommitment{
5✔
1294
                UUID:                p.generateProjectCommitmentUUID(),
5✔
1295
                ProjectID:           dbCommitment.ProjectID,
5✔
1296
                AZResourceID:        dbCommitment.AZResourceID,
5✔
1297
                Amount:              amount,
5✔
1298
                Duration:            dbCommitment.Duration,
5✔
1299
                CreatedAt:           now,
5✔
1300
                CreatorUUID:         dbCommitment.CreatorUUID,
5✔
1301
                CreatorName:         dbCommitment.CreatorName,
5✔
1302
                ConfirmBy:           dbCommitment.ConfirmBy,
5✔
1303
                ConfirmedAt:         dbCommitment.ConfirmedAt,
5✔
1304
                ExpiresAt:           dbCommitment.ExpiresAt,
5✔
1305
                CreationContextJSON: json.RawMessage(buf),
5✔
1306
                Status:              dbCommitment.Status,
5✔
1307
        }, nil
5✔
1308
}
1309

1310
func (p *v1Provider) buildConvertedCommitment(dbCommitment db.ProjectCommitment, azResourceID db.AZResourceID, amount uint64) (db.ProjectCommitment, error) {
3✔
1311
        now := p.timeNow()
3✔
1312
        creationContext := db.CommitmentWorkflowContext{
3✔
1313
                Reason:                 db.CommitmentReasonConvert,
3✔
1314
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
3✔
1315
                RelatedCommitmentUUIDs: []liquid.CommitmentUUID{dbCommitment.UUID},
3✔
1316
        }
3✔
1317
        buf, err := json.Marshal(creationContext)
3✔
1318
        if err != nil {
3✔
1319
                return db.ProjectCommitment{}, err
×
1320
        }
×
1321
        return db.ProjectCommitment{
3✔
1322
                UUID:                p.generateProjectCommitmentUUID(),
3✔
1323
                ProjectID:           dbCommitment.ProjectID,
3✔
1324
                AZResourceID:        azResourceID,
3✔
1325
                Amount:              amount,
3✔
1326
                Duration:            dbCommitment.Duration,
3✔
1327
                CreatedAt:           now,
3✔
1328
                CreatorUUID:         dbCommitment.CreatorUUID,
3✔
1329
                CreatorName:         dbCommitment.CreatorName,
3✔
1330
                ConfirmBy:           dbCommitment.ConfirmBy,
3✔
1331
                ConfirmedAt:         dbCommitment.ConfirmedAt,
3✔
1332
                ExpiresAt:           dbCommitment.ExpiresAt,
3✔
1333
                CreationContextJSON: json.RawMessage(buf),
3✔
1334
                Status:              dbCommitment.Status,
3✔
1335
        }, nil
3✔
1336
}
1337

1338
// GetCommitmentByTransferToken handles GET /v1/commitments/{token}
1339
func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http.Request) {
2✔
1340
        httpapi.IdentifyEndpoint(r, "/v1/commitments/:token")
2✔
1341
        token := p.CheckToken(r)
2✔
1342
        if !token.Require(w, "cluster:show_basic") {
2✔
1343
                return
×
1344
        }
×
1345
        transferToken := mux.Vars(r)["token"]
2✔
1346

2✔
1347
        // The token column is a unique key, so we expect only one result.
2✔
1348
        var dbCommitment db.ProjectCommitment
2✔
1349
        err := p.DB.SelectOne(&dbCommitment, findCommitmentByTransferToken, transferToken)
2✔
1350
        if errors.Is(err, sql.ErrNoRows) {
3✔
1351
                http.Error(w, "no matching commitment found.", http.StatusNotFound)
1✔
1352
                return
1✔
1353
        } else if respondwith.ObfuscatedErrorText(w, err) {
2✔
1354
                return
×
1355
        }
×
1356

1357
        var (
1✔
1358
                loc            core.AZResourceLocation
1✔
1359
                totalConfirmed uint64
1✔
1360
        )
1✔
1361
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbCommitment.ProjectID).
1✔
1362
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
1✔
1363
        if errors.Is(err, sql.ErrNoRows) {
1✔
1364
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1365
                http.Error(w, "location data not found.", http.StatusNotFound)
×
1366
                return
×
1367
        } else if respondwith.ObfuscatedErrorText(w, err) {
1✔
1368
                return
×
1369
        }
×
1370

1371
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
1372
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
1373
                return
×
1374
        }
×
1375
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
1376
        if !ok {
1✔
1377
                http.Error(w, "service not found", http.StatusNotFound)
×
1378
                return
×
1379
        }
×
1380
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
1381
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
1✔
1382
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
1383
}
1384

1385
// TransferCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}?token={token}
1386
func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request) {
5✔
1387
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/transfer-commitment/:id")
5✔
1388
        token := p.CheckToken(r)
5✔
1389
        if !token.Require(w, "project:edit") {
5✔
1390
                return
×
1391
        }
×
1392
        transferToken := r.Header.Get("Transfer-Token")
5✔
1393
        if transferToken == "" {
6✔
1394
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
1✔
1395
                return
1✔
1396
        }
1✔
1397
        commitmentID := mux.Vars(r)["id"]
4✔
1398
        if commitmentID == "" {
4✔
1399
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1400
                return
×
1401
        }
×
1402
        targetDomain := p.FindDomainFromRequest(w, r)
4✔
1403
        if targetDomain == nil {
4✔
1404
                return
×
1405
        }
×
1406
        targetProject := p.FindProjectFromRequest(w, r, targetDomain)
4✔
1407
        if targetProject == nil {
4✔
1408
                return
×
1409
        }
×
1410

1411
        // find commitment by transfer_token
1412
        var dbCommitment db.ProjectCommitment
4✔
1413
        err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, commitmentID, transferToken)
4✔
1414
        if errors.Is(err, sql.ErrNoRows) {
5✔
1415
                http.Error(w, "no matching commitment found", http.StatusNotFound)
1✔
1416
                return
1✔
1417
        } else if respondwith.ObfuscatedErrorText(w, err) {
4✔
1418
                return
×
1419
        }
×
1420

1421
        var (
3✔
1422
                loc                  core.AZResourceLocation
3✔
1423
                sourceTotalConfirmed uint64
3✔
1424
        )
3✔
1425
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbCommitment.ProjectID).
3✔
1426
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &sourceTotalConfirmed)
3✔
1427

3✔
1428
        if errors.Is(err, sql.ErrNoRows) {
3✔
1429
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1430
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1431
                return
×
1432
        } else if respondwith.ObfuscatedErrorText(w, err) {
3✔
1433
                return
×
1434
        }
×
1435

1436
        // get old project additionally
1437
        var sourceProject db.Project
3✔
1438
        err = p.DB.SelectOne(&sourceProject, `SELECT * FROM projects WHERE id = $1`, dbCommitment.ProjectID)
3✔
1439
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1440
                return
×
1441
        }
×
1442
        var sourceDomain db.Domain
3✔
1443
        err = p.DB.SelectOne(&sourceDomain, `SELECT * FROM domains WHERE id = $1`, sourceProject.DomainID)
3✔
1444
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1445
                return
×
1446
        }
×
1447

1448
        // check that the target project allows commitments at all
1449
        var (
3✔
1450
                azResourceID              db.AZResourceID
3✔
1451
                resourceAllowsCommitments bool
3✔
1452
                targetTotalConfirmed      uint64
3✔
1453
        )
3✔
1454
        err = p.DB.QueryRow(findAZResourceIDByLocationQuery, targetProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
3✔
1455
                Scan(&azResourceID, &resourceAllowsCommitments, &targetTotalConfirmed)
3✔
1456
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1457
                return
×
1458
        }
×
1459
        if !resourceAllowsCommitments {
3✔
1460
                msg := fmt.Sprintf("resource %s/%s is not enabled in the target project", loc.ServiceType, loc.ResourceName)
×
1461
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1462
                return
×
1463
        }
×
1464
        _ = azResourceID // returned by the above query, but not used in this function
3✔
1465

3✔
1466
        // validate that we have enough committable capacity on the receiving side
3✔
1467
        tx, err := p.DB.Begin()
3✔
1468
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1469
                return
×
1470
        }
×
1471
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1472

3✔
1473
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
3✔
1474
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1475
                return
×
1476
        }
×
1477
        serviceInfo, ok := maybeServiceInfo.Unpack()
3✔
1478
        if !ok {
3✔
1479
                http.Error(w, "service not found", http.StatusNotFound)
×
1480
                return
×
1481
        }
×
1482

1483
        sourceTotalConfirmedAfter := sourceTotalConfirmed
3✔
1484
        targetTotalConfirmedAfter := targetTotalConfirmed
3✔
1485
        if dbCommitment.Status == liquid.CommitmentStatusConfirmed {
6✔
1486
                sourceTotalConfirmedAfter -= dbCommitment.Amount
3✔
1487
                targetTotalConfirmedAfter += dbCommitment.Amount
3✔
1488
        }
3✔
1489

1490
        // check move is allowed
1491
        commitmentChangeResponse, err := p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
3✔
1492
                AZ:          loc.AvailabilityZone,
3✔
1493
                InfoVersion: serviceInfo.Version,
3✔
1494
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
3✔
1495
                        sourceProject.UUID: {
3✔
1496
                                ProjectMetadata: liquidProjectMetadataFromDBProject(sourceProject, sourceDomain, serviceInfo),
3✔
1497
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
3✔
1498
                                        loc.ResourceName: {
3✔
1499
                                                TotalConfirmedBefore: sourceTotalConfirmed,
3✔
1500
                                                TotalConfirmedAfter:  sourceTotalConfirmedAfter,
3✔
1501
                                                // TODO: change when introducing "guaranteed" commitments
3✔
1502
                                                TotalGuaranteedBefore: 0,
3✔
1503
                                                TotalGuaranteedAfter:  0,
3✔
1504
                                                Commitments: []liquid.Commitment{
3✔
1505
                                                        {
3✔
1506
                                                                UUID:      dbCommitment.UUID,
3✔
1507
                                                                OldStatus: Some(dbCommitment.Status),
3✔
1508
                                                                NewStatus: None[liquid.CommitmentStatus](),
3✔
1509
                                                                Amount:    dbCommitment.Amount,
3✔
1510
                                                                ConfirmBy: dbCommitment.ConfirmBy,
3✔
1511
                                                                ExpiresAt: dbCommitment.ExpiresAt,
3✔
1512
                                                        },
3✔
1513
                                                },
3✔
1514
                                        },
3✔
1515
                                },
3✔
1516
                        },
3✔
1517
                        targetProject.UUID: {
3✔
1518
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*targetProject, *targetDomain, serviceInfo),
3✔
1519
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
3✔
1520
                                        loc.ResourceName: {
3✔
1521
                                                TotalConfirmedBefore: targetTotalConfirmed,
3✔
1522
                                                TotalConfirmedAfter:  targetTotalConfirmedAfter,
3✔
1523
                                                // TODO: change when introducing "guaranteed" commitments
3✔
1524
                                                TotalGuaranteedBefore: 0,
3✔
1525
                                                TotalGuaranteedAfter:  0,
3✔
1526
                                                Commitments: []liquid.Commitment{
3✔
1527
                                                        {
3✔
1528
                                                                UUID:      dbCommitment.UUID,
3✔
1529
                                                                OldStatus: None[liquid.CommitmentStatus](),
3✔
1530
                                                                NewStatus: Some(dbCommitment.Status),
3✔
1531
                                                                Amount:    dbCommitment.Amount,
3✔
1532
                                                                ConfirmBy: dbCommitment.ConfirmBy,
3✔
1533
                                                                ExpiresAt: dbCommitment.ExpiresAt,
3✔
1534
                                                        },
3✔
1535
                                                },
3✔
1536
                                        },
3✔
1537
                                },
3✔
1538
                        },
3✔
1539
                },
3✔
1540
        }, loc.ServiceType, serviceInfo, tx)
3✔
1541
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1542
                return
×
1543
        }
×
1544
        if commitmentChangeResponse.RejectionReason != "" {
4✔
1545
                evaluateRetryHeader(commitmentChangeResponse, w)
1✔
1546
                http.Error(w, "not enough committable capacity on the receiving side", http.StatusConflict)
1✔
1547
                return
1✔
1548
        }
1✔
1549

1550
        dbCommitment.TransferStatus = ""
2✔
1551
        dbCommitment.TransferToken = None[string]()
2✔
1552
        dbCommitment.ProjectID = targetProject.ID
2✔
1553
        _, err = tx.Update(&dbCommitment)
2✔
1554
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1555
                return
×
1556
        }
×
1557
        err = tx.Commit()
2✔
1558
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1559
                return
×
1560
        }
×
1561

1562
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
1563
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
2✔
1564
        p.auditor.Record(audittools.Event{
2✔
1565
                Time:       p.timeNow(),
2✔
1566
                Request:    r,
2✔
1567
                User:       token,
2✔
1568
                ReasonCode: http.StatusAccepted,
2✔
1569
                Action:     cadf.UpdateAction,
2✔
1570
                Target: commitmentEventTarget{
2✔
1571
                        DomainID:    targetDomain.UUID,
2✔
1572
                        DomainName:  targetDomain.Name,
2✔
1573
                        ProjectID:   targetProject.UUID,
2✔
1574
                        ProjectName: targetProject.Name,
2✔
1575
                        Commitments: []limesresources.Commitment{c},
2✔
1576
                },
2✔
1577
        })
2✔
1578

2✔
1579
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1580
}
1581

1582
// GetCommitmentConversion handles GET /v1/commitment-conversion/{service_type}/{resource_name}
1583
func (p *v1Provider) GetCommitmentConversions(w http.ResponseWriter, r *http.Request) {
3✔
1584
        httpapi.IdentifyEndpoint(r, "/v1/commitment-conversion/:service_type/:resource_name")
3✔
1585
        token := p.CheckToken(r)
3✔
1586
        if !token.Require(w, "cluster:show_basic") {
3✔
1587
                return
×
1588
        }
×
1589

1590
        // TODO v2 API: This endpoint should be project-scoped in order to make it
1591
        // easier to select the correct domain scope for the CommitmentBehavior.
1592
        forTokenScope := func(behavior core.CommitmentBehavior) core.ScopedCommitmentBehavior {
25✔
1593
                name := cmp.Or(token.ProjectScopeDomainName(), token.DomainScopeName(), "")
22✔
1594
                if name != "" {
44✔
1595
                        return behavior.ForDomain(name)
22✔
1596
                }
22✔
1597
                return behavior.ForCluster()
×
1598
        }
1599

1600
        // validate request
1601
        vars := mux.Vars(r)
3✔
1602
        serviceInfos, err := p.Cluster.AllServiceInfos()
3✔
1603
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1604
                return
×
1605
        }
×
1606

1607
        nm := core.BuildResourceNameMapping(p.Cluster, serviceInfos)
3✔
1608
        sourceServiceType, sourceResourceName, exists := nm.MapFromV1API(
3✔
1609
                limes.ServiceType(vars["service_type"]),
3✔
1610
                limesresources.ResourceName(vars["resource_name"]),
3✔
1611
        )
3✔
1612
        if !exists {
3✔
1613
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", vars["service_type"], vars["resource_name"])
×
1614
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1615
                return
×
1616
        }
×
1617
        sourceBehavior := forTokenScope(p.Cluster.CommitmentBehaviorForResource(sourceServiceType, sourceResourceName))
3✔
1618

3✔
1619
        serviceInfo := core.InfoForService(serviceInfos, sourceServiceType)
3✔
1620
        sourceResInfo := core.InfoForResource(serviceInfo, sourceResourceName)
3✔
1621

3✔
1622
        // enumerate possible conversions
3✔
1623
        conversions := make([]limesresources.CommitmentConversionRule, 0)
3✔
1624
        if sourceBehavior.ConversionRule.IsSome() {
6✔
1625
                for _, targetServiceType := range slices.Sorted(maps.Keys(serviceInfos)) {
15✔
1626
                        for targetResourceName, targetResInfo := range serviceInfos[targetServiceType].Resources {
51✔
1627
                                if sourceServiceType == targetServiceType && sourceResourceName == targetResourceName {
42✔
1628
                                        continue
3✔
1629
                                }
1630
                                if sourceResInfo.Unit != targetResInfo.Unit {
53✔
1631
                                        continue
17✔
1632
                                }
1633

1634
                                targetBehavior := forTokenScope(p.Cluster.CommitmentBehaviorForResource(targetServiceType, targetResourceName))
19✔
1635
                                if rate, ok := sourceBehavior.GetConversionRateTo(targetBehavior).Unpack(); ok {
22✔
1636
                                        apiServiceType, apiResourceName, ok := nm.MapToV1API(targetServiceType, targetResourceName)
3✔
1637
                                        if ok {
6✔
1638
                                                conversions = append(conversions, limesresources.CommitmentConversionRule{
3✔
1639
                                                        FromAmount:     rate.FromAmount,
3✔
1640
                                                        ToAmount:       rate.ToAmount,
3✔
1641
                                                        TargetService:  apiServiceType,
3✔
1642
                                                        TargetResource: apiResourceName,
3✔
1643
                                                })
3✔
1644
                                        }
3✔
1645
                                }
1646
                        }
1647
                }
1648
        }
1649

1650
        // use a defined sorting to ensure deterministic behavior in tests
1651
        slices.SortFunc(conversions, func(lhs, rhs limesresources.CommitmentConversionRule) int {
4✔
1652
                result := strings.Compare(string(lhs.TargetService), string(rhs.TargetService))
1✔
1653
                if result != 0 {
1✔
1654
                        return result
×
1655
                }
×
1656
                return strings.Compare(string(lhs.TargetResource), string(rhs.TargetResource))
1✔
1657
        })
1658

1659
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
3✔
1660
}
1661

1662
// ConvertCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/convert
1663
func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
9✔
1664
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/convert")
9✔
1665
        token := p.CheckToken(r)
9✔
1666
        if !token.Require(w, "project:edit") {
9✔
1667
                return
×
1668
        }
×
1669
        commitmentID := mux.Vars(r)["commitment_id"]
9✔
1670
        if commitmentID == "" {
9✔
1671
                http.Error(w, "no commitment_id provided", http.StatusBadRequest)
×
1672
                return
×
1673
        }
×
1674
        dbDomain := p.FindDomainFromRequest(w, r)
9✔
1675
        if dbDomain == nil {
9✔
1676
                return
×
1677
        }
×
1678
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
9✔
1679
        if dbProject == nil {
9✔
1680
                return
×
1681
        }
×
1682

1683
        // section: sourceBehavior
1684
        var dbCommitment db.ProjectCommitment
9✔
1685
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
9✔
1686
        if errors.Is(err, sql.ErrNoRows) {
10✔
1687
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
1688
                return
1✔
1689
        } else if respondwith.ObfuscatedErrorText(w, err) {
9✔
1690
                return
×
1691
        }
×
1692
        var (
8✔
1693
                sourceLoc            core.AZResourceLocation
8✔
1694
                sourceTotalConfirmed uint64
8✔
1695
        )
8✔
1696
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbProject.ID).
8✔
1697
                Scan(&sourceLoc.ServiceType, &sourceLoc.ResourceName, &sourceLoc.AvailabilityZone, &sourceTotalConfirmed)
8✔
1698
        if errors.Is(err, sql.ErrNoRows) {
8✔
1699
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1700
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1701
                return
×
1702
        } else if respondwith.ObfuscatedErrorText(w, err) {
8✔
1703
                return
×
1704
        }
×
1705
        sourceBehavior := p.Cluster.CommitmentBehaviorForResource(sourceLoc.ServiceType, sourceLoc.ResourceName).ForDomain(dbDomain.Name)
8✔
1706

8✔
1707
        // section: targetBehavior
8✔
1708
        var parseTarget struct {
8✔
1709
                Request struct {
8✔
1710
                        TargetService  limes.ServiceType           `json:"target_service"`
8✔
1711
                        TargetResource limesresources.ResourceName `json:"target_resource"`
8✔
1712
                        SourceAmount   uint64                      `json:"source_amount"`
8✔
1713
                        TargetAmount   uint64                      `json:"target_amount"`
8✔
1714
                } `json:"commitment"`
8✔
1715
        }
8✔
1716
        if !RequireJSON(w, r, &parseTarget) {
8✔
1717
                return
×
1718
        }
×
1719
        req := parseTarget.Request
8✔
1720
        serviceInfos, err := p.Cluster.AllServiceInfos()
8✔
1721
        if respondwith.ObfuscatedErrorText(w, err) {
8✔
1722
                return
×
1723
        }
×
1724
        nm := core.BuildResourceNameMapping(p.Cluster, serviceInfos)
8✔
1725
        targetServiceType, targetResourceName, exists := nm.MapFromV1API(req.TargetService, req.TargetResource)
8✔
1726
        if !exists {
8✔
1727
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", req.TargetService, req.TargetResource)
×
1728
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1729
                return
×
1730
        }
×
1731
        targetBehavior := p.Cluster.CommitmentBehaviorForResource(targetServiceType, targetResourceName).ForDomain(dbDomain.Name)
8✔
1732
        if sourceLoc.ResourceName == targetResourceName && sourceLoc.ServiceType == targetServiceType {
9✔
1733
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
1✔
1734
                return
1✔
1735
        }
1✔
1736
        if len(targetBehavior.Durations) == 0 {
7✔
1737
                msg := fmt.Sprintf("commitments are not enabled for resource %s/%s", req.TargetService, req.TargetResource)
×
1738
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1739
                return
×
1740
        }
×
1741
        rate, ok := sourceBehavior.GetConversionRateTo(targetBehavior).Unpack()
7✔
1742
        if !ok {
8✔
1743
                msg := fmt.Sprintf("commitment is not convertible into resource %s/%s", req.TargetService, req.TargetResource)
1✔
1744
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1745
                return
1✔
1746
        }
1✔
1747

1748
        // section: conversion
1749
        if req.SourceAmount > dbCommitment.Amount {
6✔
1750
                msg := fmt.Sprintf("unprocessable source amount. provided: %v, commitment: %v", req.SourceAmount, dbCommitment.Amount)
×
1751
                http.Error(w, msg, http.StatusConflict)
×
1752
                return
×
1753
        }
×
1754
        conversionAmount := (req.SourceAmount / rate.FromAmount) * rate.ToAmount
6✔
1755
        remainderAmount := req.SourceAmount % rate.FromAmount
6✔
1756
        if remainderAmount > 0 {
8✔
1757
                msg := fmt.Sprintf("amount: %v does not fit into conversion rate of: %v", req.SourceAmount, rate.FromAmount)
2✔
1758
                http.Error(w, msg, http.StatusConflict)
2✔
1759
                return
2✔
1760
        }
2✔
1761
        if conversionAmount != req.TargetAmount {
5✔
1762
                msg := fmt.Sprintf("conversion mismatch. provided: %v, calculated: %v", req.TargetAmount, conversionAmount)
1✔
1763
                http.Error(w, msg, http.StatusConflict)
1✔
1764
                return
1✔
1765
        }
1✔
1766

1767
        tx, err := p.DB.Begin()
3✔
1768
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1769
                return
×
1770
        }
×
1771
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1772

3✔
1773
        var (
3✔
1774
                targetAZResourceID        db.AZResourceID
3✔
1775
                resourceAllowsCommitments bool
3✔
1776
                targetTotalConfirmed      uint64
3✔
1777
        )
3✔
1778
        err = p.DB.QueryRow(findAZResourceIDByLocationQuery, dbProject.ID, targetServiceType, targetResourceName, sourceLoc.AvailabilityZone).
3✔
1779
                Scan(&targetAZResourceID, &resourceAllowsCommitments, &targetTotalConfirmed)
3✔
1780
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1781
                return
×
1782
        }
×
1783
        // defense in depth. ServiceType and ResourceName of source and target are already checked. Here it's possible to explicitly check the ID's.
1784
        if dbCommitment.AZResourceID == targetAZResourceID {
3✔
1785
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
×
1786
                return
×
1787
        }
×
1788
        if !resourceAllowsCommitments {
3✔
1789
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", targetServiceType, targetResourceName)
×
1790
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1791
                return
×
1792
        }
×
1793
        targetLoc := core.AZResourceLocation{
3✔
1794
                ServiceType:      sourceLoc.ServiceType,
3✔
1795
                ResourceName:     targetResourceName,
3✔
1796
                AvailabilityZone: sourceLoc.AvailabilityZone,
3✔
1797
        }
3✔
1798
        serviceInfo := core.InfoForService(serviceInfos, sourceLoc.ServiceType)
3✔
1799
        remainingAmount := dbCommitment.Amount - req.SourceAmount
3✔
1800
        var remainingCommitment db.ProjectCommitment
3✔
1801

3✔
1802
        // old commitment is always superseded
3✔
1803
        sourceCommitments := []liquid.Commitment{
3✔
1804
                {
3✔
1805
                        UUID:      dbCommitment.UUID,
3✔
1806
                        OldStatus: Some(dbCommitment.Status),
3✔
1807
                        NewStatus: Some(liquid.CommitmentStatusSuperseded),
3✔
1808
                        Amount:    dbCommitment.Amount,
3✔
1809
                        ConfirmBy: dbCommitment.ConfirmBy,
3✔
1810
                        ExpiresAt: dbCommitment.ExpiresAt,
3✔
1811
                },
3✔
1812
        }
3✔
1813
        // when there is a remaining amount, we must request to add this
3✔
1814
        if remainingAmount > 0 {
4✔
1815
                remainingCommitment, err = p.buildSplitCommitment(dbCommitment, remainingAmount)
1✔
1816
                if respondwith.ObfuscatedErrorText(w, err) {
1✔
1817
                        return
×
1818
                }
×
1819
                sourceCommitments = append(sourceCommitments, liquid.Commitment{
1✔
1820
                        UUID:      remainingCommitment.UUID,
1✔
1821
                        OldStatus: None[liquid.CommitmentStatus](),
1✔
1822
                        NewStatus: Some(remainingCommitment.Status),
1✔
1823
                        Amount:    remainingCommitment.Amount,
1✔
1824
                        ConfirmBy: remainingCommitment.ConfirmBy,
1✔
1825
                        ExpiresAt: remainingCommitment.ExpiresAt,
1✔
1826
                })
1✔
1827
        }
1828
        convertedCommitment, err := p.buildConvertedCommitment(dbCommitment, targetAZResourceID, conversionAmount)
3✔
1829
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1830
                return
×
1831
        }
×
1832

1833
        sourceTotalConfirmedAfter := sourceTotalConfirmed
3✔
1834
        targetTotalConfirmedAfter := targetTotalConfirmed
3✔
1835
        if dbCommitment.ConfirmedAt.IsSome() {
5✔
1836
                sourceTotalConfirmedAfter -= req.SourceAmount
2✔
1837
                targetTotalConfirmedAfter += req.TargetAmount
2✔
1838
        }
2✔
1839

1840
        commitmentChangeRequest := liquid.CommitmentChangeRequest{
3✔
1841
                AZ:          sourceLoc.AvailabilityZone,
3✔
1842
                InfoVersion: serviceInfo.Version,
3✔
1843
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
3✔
1844
                        dbProject.UUID: {
3✔
1845
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
3✔
1846
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
3✔
1847
                                        sourceLoc.ResourceName: {
3✔
1848
                                                TotalConfirmedBefore: sourceTotalConfirmed,
3✔
1849
                                                TotalConfirmedAfter:  sourceTotalConfirmedAfter,
3✔
1850
                                                // TODO: change when introducing "guaranteed" commitments
3✔
1851
                                                TotalGuaranteedBefore: 0,
3✔
1852
                                                TotalGuaranteedAfter:  0,
3✔
1853
                                                Commitments:           sourceCommitments,
3✔
1854
                                        },
3✔
1855
                                        targetLoc.ResourceName: {
3✔
1856
                                                TotalConfirmedBefore: targetTotalConfirmed,
3✔
1857
                                                TotalConfirmedAfter:  targetTotalConfirmedAfter,
3✔
1858
                                                // TODO: change when introducing "guaranteed" commitments
3✔
1859
                                                TotalGuaranteedBefore: 0,
3✔
1860
                                                TotalGuaranteedAfter:  0,
3✔
1861
                                                Commitments: []liquid.Commitment{
3✔
1862
                                                        {
3✔
1863
                                                                UUID:      convertedCommitment.UUID,
3✔
1864
                                                                OldStatus: None[liquid.CommitmentStatus](),
3✔
1865
                                                                NewStatus: Some(convertedCommitment.Status),
3✔
1866
                                                                Amount:    convertedCommitment.Amount,
3✔
1867
                                                                ConfirmBy: convertedCommitment.ConfirmBy,
3✔
1868
                                                                ExpiresAt: convertedCommitment.ExpiresAt,
3✔
1869
                                                        },
3✔
1870
                                                },
3✔
1871
                                        },
3✔
1872
                                },
3✔
1873
                        },
3✔
1874
                },
3✔
1875
        }
3✔
1876
        commitmentChangeResponse, err := p.DelegateChangeCommitments(r.Context(), commitmentChangeRequest, sourceLoc.ServiceType, serviceInfo, tx)
3✔
1877
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1878
                return
×
1879
        }
×
1880

1881
        // only check acceptance by liquid when old commitment was confirmed, unconfirmed commitments can be moved without acceptance
1882
        if commitmentChangeRequest.RequiresConfirmation() && commitmentChangeResponse.RejectionReason != "" {
4✔
1883
                evaluateRetryHeader(commitmentChangeResponse, w)
1✔
1884
                http.Error(w, "not enough capacity to confirm the commitment", http.StatusUnprocessableEntity)
1✔
1885
                return
1✔
1886
        }
1✔
1887

1888
        auditEvent := commitmentEventTarget{
2✔
1889
                DomainID:    dbDomain.UUID,
2✔
1890
                DomainName:  dbDomain.Name,
2✔
1891
                ProjectID:   dbProject.UUID,
2✔
1892
                ProjectName: dbProject.Name,
2✔
1893
        }
2✔
1894

2✔
1895
        var (
2✔
1896
                relatedCommitmentIDs   []db.ProjectCommitmentID
2✔
1897
                relatedCommitmentUUIDs []liquid.CommitmentUUID
2✔
1898
        )
2✔
1899
        resourceInfo := core.InfoForResource(serviceInfo, sourceLoc.ResourceName)
2✔
1900
        if remainingAmount > 0 {
3✔
1901
                relatedCommitmentIDs = append(relatedCommitmentIDs, remainingCommitment.ID)
1✔
1902
                relatedCommitmentUUIDs = append(relatedCommitmentUUIDs, remainingCommitment.UUID)
1✔
1903
                err = tx.Insert(&remainingCommitment)
1✔
1904
                if respondwith.ObfuscatedErrorText(w, err) {
1✔
1905
                        return
×
1906
                }
×
1907
                auditEvent.Commitments = append(auditEvent.Commitments,
1✔
1908
                        p.convertCommitmentToDisplayForm(remainingCommitment, sourceLoc, token, resourceInfo.Unit),
1✔
1909
                )
1✔
1910
        }
1911

1912
        relatedCommitmentIDs = append(relatedCommitmentIDs, convertedCommitment.ID)
2✔
1913
        relatedCommitmentUUIDs = append(relatedCommitmentUUIDs, convertedCommitment.UUID)
2✔
1914
        err = tx.Insert(&convertedCommitment)
2✔
1915
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1916
                return
×
1917
        }
×
1918

1919
        // supersede the original commitment
1920
        now := p.timeNow()
2✔
1921
        supersedeContext := db.CommitmentWorkflowContext{
2✔
1922
                Reason:                 db.CommitmentReasonConvert,
2✔
1923
                RelatedCommitmentIDs:   relatedCommitmentIDs,
2✔
1924
                RelatedCommitmentUUIDs: relatedCommitmentUUIDs,
2✔
1925
        }
2✔
1926
        buf, err := json.Marshal(supersedeContext)
2✔
1927
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1928
                return
×
1929
        }
×
1930
        dbCommitment.Status = liquid.CommitmentStatusSuperseded
2✔
1931
        dbCommitment.SupersededAt = Some(now)
2✔
1932
        dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
1933
        _, err = tx.Update(&dbCommitment)
2✔
1934
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1935
                return
×
1936
        }
×
1937

1938
        err = tx.Commit()
2✔
1939
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1940
                return
×
1941
        }
×
1942

1943
        c := p.convertCommitmentToDisplayForm(convertedCommitment, targetLoc, token, resourceInfo.Unit)
2✔
1944
        auditEvent.Commitments = append([]limesresources.Commitment{c}, auditEvent.Commitments...)
2✔
1945
        auditEvent.WorkflowContext = Some(db.CommitmentWorkflowContext{
2✔
1946
                Reason:                 db.CommitmentReasonSplit,
2✔
1947
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1948
                RelatedCommitmentUUIDs: []liquid.CommitmentUUID{dbCommitment.UUID},
2✔
1949
        })
2✔
1950
        p.auditor.Record(audittools.Event{
2✔
1951
                Time:       p.timeNow(),
2✔
1952
                Request:    r,
2✔
1953
                User:       token,
2✔
1954
                ReasonCode: http.StatusAccepted,
2✔
1955
                Action:     cadf.UpdateAction,
2✔
1956
                Target:     auditEvent,
2✔
1957
        })
2✔
1958

2✔
1959
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1960
}
1961

1962
// ExtendCommitmentDuration handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/update-duration
1963
func (p *v1Provider) UpdateCommitmentDuration(w http.ResponseWriter, r *http.Request) {
7✔
1964
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/update-duration")
7✔
1965
        token := p.CheckToken(r)
7✔
1966
        if !token.Require(w, "project:edit") {
7✔
1967
                return
×
1968
        }
×
1969
        commitmentID := mux.Vars(r)["commitment_id"]
7✔
1970
        if commitmentID == "" {
7✔
1971
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1972
                return
×
1973
        }
×
1974
        dbDomain := p.FindDomainFromRequest(w, r)
7✔
1975
        if dbDomain == nil {
7✔
1976
                return
×
1977
        }
×
1978
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
1979
        if dbProject == nil {
7✔
1980
                return
×
1981
        }
×
1982
        var Request struct {
7✔
1983
                Duration limesresources.CommitmentDuration `json:"duration"`
7✔
1984
        }
7✔
1985
        req := Request
7✔
1986
        if !RequireJSON(w, r, &req) {
7✔
1987
                return
×
1988
        }
×
1989

1990
        var dbCommitment db.ProjectCommitment
7✔
1991
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
7✔
1992
        if errors.Is(err, sql.ErrNoRows) {
7✔
1993
                http.Error(w, "no such commitment", http.StatusNotFound)
×
1994
                return
×
1995
        } else if respondwith.ObfuscatedErrorText(w, err) {
7✔
1996
                return
×
1997
        }
×
1998

1999
        now := p.timeNow()
7✔
2000
        if dbCommitment.ExpiresAt.Before(now) || dbCommitment.ExpiresAt.Equal(now) {
8✔
2001
                http.Error(w, "unable to process expired commitment", http.StatusForbidden)
1✔
2002
                return
1✔
2003
        }
1✔
2004

2005
        if dbCommitment.Status == liquid.CommitmentStatusSuperseded {
7✔
2006
                msg := fmt.Sprintf("unable to operate on commitment with a status of %s", dbCommitment.Status)
1✔
2007
                http.Error(w, msg, http.StatusForbidden)
1✔
2008
                return
1✔
2009
        }
1✔
2010

2011
        var (
5✔
2012
                loc            core.AZResourceLocation
5✔
2013
                totalConfirmed uint64
5✔
2014
        )
5✔
2015
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbProject.ID).
5✔
2016
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
5✔
2017
        if errors.Is(err, sql.ErrNoRows) {
5✔
2018
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
2019
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
2020
                return
×
2021
        } else if respondwith.ObfuscatedErrorText(w, err) {
5✔
2022
                return
×
2023
        }
×
2024
        behavior := p.Cluster.CommitmentBehaviorForResource(loc.ServiceType, loc.ResourceName).ForDomain(dbDomain.Name)
5✔
2025
        if !slices.Contains(behavior.Durations, req.Duration) {
6✔
2026
                msg := fmt.Sprintf("provided duration: %s does not match the config %v", req.Duration, behavior.Durations)
1✔
2027
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
2028
                return
1✔
2029
        }
1✔
2030

2031
        newExpiresAt := req.Duration.AddTo(dbCommitment.ConfirmBy.UnwrapOr(dbCommitment.CreatedAt))
4✔
2032
        if newExpiresAt.Before(dbCommitment.ExpiresAt) {
5✔
2033
                msg := fmt.Sprintf("duration change from %s to %s forbidden", dbCommitment.Duration, req.Duration)
1✔
2034
                http.Error(w, msg, http.StatusForbidden)
1✔
2035
                return
1✔
2036
        }
1✔
2037

2038
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
3✔
2039
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
2040
                return
×
2041
        }
×
2042
        serviceInfo, ok := maybeServiceInfo.Unpack()
3✔
2043
        if !ok {
3✔
2044
                http.Error(w, "service not found", http.StatusNotFound)
×
2045
                return
×
2046
        }
×
2047

2048
        // might only reject in the remote-case, locally we accept extensions as limes does not know future capacity
2049
        commitmentChangeResponse, err := p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
3✔
2050
                AZ:          loc.AvailabilityZone,
3✔
2051
                InfoVersion: serviceInfo.Version,
3✔
2052
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
3✔
2053
                        dbProject.UUID: {
3✔
2054
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
3✔
2055
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
3✔
2056
                                        loc.ResourceName: {
3✔
2057
                                                TotalConfirmedBefore: totalConfirmed,
3✔
2058
                                                TotalConfirmedAfter:  totalConfirmed,
3✔
2059
                                                // TODO: change when introducing "guaranteed" commitments
3✔
2060
                                                TotalGuaranteedBefore: 0,
3✔
2061
                                                TotalGuaranteedAfter:  0,
3✔
2062
                                                Commitments: []liquid.Commitment{
3✔
2063
                                                        {
3✔
2064
                                                                UUID:         dbCommitment.UUID,
3✔
2065
                                                                OldStatus:    Some(dbCommitment.Status),
3✔
2066
                                                                NewStatus:    Some(dbCommitment.Status),
3✔
2067
                                                                Amount:       dbCommitment.Amount,
3✔
2068
                                                                ConfirmBy:    dbCommitment.ConfirmBy,
3✔
2069
                                                                ExpiresAt:    newExpiresAt,
3✔
2070
                                                                OldExpiresAt: Some(dbCommitment.ExpiresAt.Local()),
3✔
2071
                                                        },
3✔
2072
                                                },
3✔
2073
                                        },
3✔
2074
                                },
3✔
2075
                        },
3✔
2076
                },
3✔
2077
        }, loc.ServiceType, serviceInfo, p.DB)
3✔
2078
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
2079
                return
×
2080
        }
×
2081

2082
        dbCommitment.Duration = req.Duration
3✔
2083
        dbCommitment.ExpiresAt = newExpiresAt
3✔
2084
        if commitmentChangeResponse.RejectionReason != "" {
4✔
2085
                evaluateRetryHeader(commitmentChangeResponse, w)
1✔
2086
                http.Error(w, commitmentChangeResponse.RejectionReason, http.StatusConflict)
1✔
2087
                return
1✔
2088
        }
1✔
2089

2090
        _, err = p.DB.Update(&dbCommitment)
2✔
2091
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
2092
                return
×
2093
        }
×
2094

2095
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
2096
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
2✔
2097
        p.auditor.Record(audittools.Event{
2✔
2098
                Time:       p.timeNow(),
2✔
2099
                Request:    r,
2✔
2100
                User:       token,
2✔
2101
                ReasonCode: http.StatusOK,
2✔
2102
                Action:     cadf.UpdateAction,
2✔
2103
                Target: commitmentEventTarget{
2✔
2104
                        DomainID:    dbDomain.UUID,
2✔
2105
                        DomainName:  dbDomain.Name,
2✔
2106
                        ProjectID:   dbProject.UUID,
2✔
2107
                        ProjectName: dbProject.Name,
2✔
2108
                        Commitments: []limesresources.Commitment{c},
2✔
2109
                },
2✔
2110
        })
2✔
2111

2✔
2112
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitment": c})
2✔
2113
}
2114

2115
// DelegateChangeCommitments decides whether LiquidClient.ChangeCommitments() should be called,
2116
// depending on the setting of liquid.ResourceInfo.HandlesCommitments. If not, it routes the
2117
// operation to be performed locally on the database. In case the LiquidConnection is not filled,
2118
// a LiquidClient is instantiated on the fly to perform the operation. It utilizes a given ServiceInfo so that no
2119
// double retrieval is necessary caused by operations to assemble the liquid.CommitmentChange.
2120
func (p *v1Provider) DelegateChangeCommitments(ctx context.Context, req liquid.CommitmentChangeRequest, serviceType db.ServiceType, serviceInfo liquid.ServiceInfo, dbi db.Interface) (result liquid.CommitmentChangeResponse, err error) {
49✔
2121
        localCommitmentChanges := liquid.CommitmentChangeRequest{
49✔
2122
                DryRun:      req.DryRun,
49✔
2123
                AZ:          req.AZ,
49✔
2124
                InfoVersion: req.InfoVersion,
49✔
2125
                ByProject:   make(map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset),
49✔
2126
        }
49✔
2127
        remoteCommitmentChanges := liquid.CommitmentChangeRequest{
49✔
2128
                DryRun:      req.DryRun,
49✔
2129
                AZ:          req.AZ,
49✔
2130
                InfoVersion: req.InfoVersion,
49✔
2131
                ByProject:   make(map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset),
49✔
2132
        }
49✔
2133
        for projectUUID, projectCommitmentChangeset := range req.ByProject {
101✔
2134
                for resourceName, resourceCommitmentChangeset := range projectCommitmentChangeset.ByResource {
107✔
2135
                        // this is just to make the tests deterministic because time.Local != local IANA time (after parsing)
55✔
2136
                        for i, commitment := range resourceCommitmentChangeset.Commitments {
117✔
2137
                                commitment.ExpiresAt = commitment.ExpiresAt.Local()
62✔
2138
                                commitment.ConfirmBy = options.Map(commitment.ConfirmBy, time.Time.Local)
62✔
2139
                                resourceCommitmentChangeset.Commitments[i] = commitment
62✔
2140
                        }
62✔
2141

2142
                        if serviceInfo.Resources[resourceName].HandlesCommitments {
109✔
2143
                                _, exists := remoteCommitmentChanges.ByProject[projectUUID]
54✔
2144
                                if !exists {
105✔
2145
                                        remoteCommitmentChanges.ByProject[projectUUID] = liquid.ProjectCommitmentChangeset{
51✔
2146
                                                ByResource: make(map[liquid.ResourceName]liquid.ResourceCommitmentChangeset),
51✔
2147
                                        }
51✔
2148
                                }
51✔
2149
                                remoteCommitmentChanges.ByProject[projectUUID].ByResource[resourceName] = resourceCommitmentChangeset
54✔
2150
                                continue
54✔
2151
                        }
2152
                        _, exists := localCommitmentChanges.ByProject[projectUUID]
1✔
2153
                        if !exists {
2✔
2154
                                localCommitmentChanges.ByProject[projectUUID] = liquid.ProjectCommitmentChangeset{
1✔
2155
                                        ByResource: make(map[liquid.ResourceName]liquid.ResourceCommitmentChangeset),
1✔
2156
                                }
1✔
2157
                        }
1✔
2158
                        localCommitmentChanges.ByProject[projectUUID].ByResource[resourceName] = resourceCommitmentChangeset
1✔
2159
                }
2160
        }
2161
        for projectUUID, projectCommitmentChangeset := range localCommitmentChanges.ByProject {
50✔
2162
                if serviceInfo.CommitmentHandlingNeedsProjectMetadata {
1✔
2163
                        pcs := projectCommitmentChangeset
×
2164
                        pcs.ProjectMetadata = req.ByProject[projectUUID].ProjectMetadata
×
2165
                        localCommitmentChanges.ByProject[projectUUID] = pcs
×
2166
                }
×
2167
        }
2168
        for projectUUID, remoteCommitmentChangeset := range remoteCommitmentChanges.ByProject {
100✔
2169
                if serviceInfo.CommitmentHandlingNeedsProjectMetadata {
51✔
2170
                        rcs := remoteCommitmentChangeset
×
2171
                        rcs.ProjectMetadata = req.ByProject[projectUUID].ProjectMetadata
×
2172
                        remoteCommitmentChanges.ByProject[projectUUID] = rcs
×
2173
                }
×
2174
        }
2175

2176
        // check remote
2177
        if len(remoteCommitmentChanges.ByProject) != 0 {
97✔
2178
                var liquidClient core.LiquidClient
48✔
2179
                c := p.Cluster
48✔
2180
                if len(c.LiquidConnections) == 0 {
96✔
2181
                        // find the right ServiceType
48✔
2182
                        liquidClient, err = c.LiquidClientFactory(serviceType)
48✔
2183
                        if err != nil {
48✔
NEW
2184
                                return result, err
×
2185
                        }
×
2186
                } else {
×
2187
                        liquidClient = c.LiquidConnections[serviceType].LiquidClient
×
2188
                }
×
2189
                commitmentChangeResponse, err := liquidClient.ChangeCommitments(ctx, remoteCommitmentChanges)
48✔
2190
                if err != nil {
48✔
2191
                        return result, fmt.Errorf("failed to retrieve liquid ChangeCommitment response for service %s: %w", serviceType, err)
×
2192
                }
×
2193
                if commitmentChangeResponse.RejectionReason != "" {
53✔
2194
                        return commitmentChangeResponse, nil
5✔
2195
                }
5✔
2196
        }
2197

2198
        // check local
2199
        if len(localCommitmentChanges.ByProject) != 0 {
45✔
2200
                canAcceptLocally, err := datamodel.CanAcceptCommitmentChangeRequest(localCommitmentChanges, serviceType, p.Cluster, dbi)
1✔
2201
                if err != nil {
1✔
2202
                        return result, fmt.Errorf("failed to check local ChangeCommitment: %w", err)
×
2203
                }
×
2204
                if !canAcceptLocally {
1✔
2205
                        return liquid.CommitmentChangeResponse{
×
2206
                                RejectionReason: "not enough capacity!",
×
2207
                                RetryAt:         None[time.Time](),
×
2208
                        }, nil
×
2209
                }
×
2210
        }
2211

2212
        return result, nil
44✔
2213
}
2214

2215
func liquidProjectMetadataFromDBProject(dbProject db.Project, domain db.Domain, serviceInfo liquid.ServiceInfo) Option[liquid.ProjectMetadata] {
52✔
2216
        if !serviceInfo.CommitmentHandlingNeedsProjectMetadata {
104✔
2217
                return None[liquid.ProjectMetadata]()
52✔
2218
        }
52✔
2219
        return Some(core.KeystoneProjectFromDB(dbProject, core.KeystoneDomain{UUID: domain.UUID, Name: domain.Name}).ForLiquid())
×
2220
}
2221

2222
func evaluateRetryHeader(response liquid.CommitmentChangeResponse, w http.ResponseWriter) {
5✔
2223
        if retryAt, exists := response.RetryAt.Unpack(); exists && response.RejectionReason != "" {
5✔
2224
                w.Header().Set("Retry-After", retryAt.Format(time.RFC1123))
×
2225
        }
×
2226
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc