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

sapcc / limes / 17071453655

19 Aug 2025 01:41PM UTC coverage: 80.263% (+0.08%) from 80.182%
17071453655

push

github

web-flow
Merge pull request #759 from sapcc/max_quota

add endpoint: forbid-autogrowth

122 of 138 new or added lines in 5 files covered. (88.41%)

311 existing lines in 6 files now uncovered.

7519 of 9368 relevant lines covered (80.26%)

57.6 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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
266
                return
×
267
        }
×
268
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
269
        if dbDomain == nil {
8✔
UNCOV
270
                return
×
271
        }
×
272
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
8✔
273
        if dbProject == nil {
8✔
UNCOV
274
                return
×
275
        }
×
276
        req, loc, behavior, serviceInfo := p.parseAndValidateCommitmentRequest(w, r, *dbDomain)
8✔
277
        if req == nil {
8✔
UNCOV
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✔
UNCOV
289
                return
×
290
        }
×
291
        if !resourceAllowsCommitments {
8✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
494
                return
×
495
        }
×
496

497
        err = tx.Commit()
24✔
498
        if respondwith.ObfuscatedErrorText(w, err) {
24✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
593
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
594
                return
×
595
        } else if respondwith.ObfuscatedErrorText(w, err) {
1✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
665
                        return
×
666
                }
×
667
        }
668

669
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
670
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
UNCOV
671
                return
×
672
        }
×
673
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
674
        if !ok {
1✔
UNCOV
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✔
UNCOV
720
                return
×
721
        }
×
722

723
        err = tx.Commit()
1✔
724
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
UNCOV
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✔
UNCOV
758
                return
×
759
        }
×
760
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
761
        if dbDomain == nil {
6✔
UNCOV
762
                return
×
763
        }
×
764
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
765
        if dbProject == nil {
6✔
UNCOV
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✔
UNCOV
773
                http.Error(w, "no such commitment", http.StatusNotFound)
×
774
                return
×
775
        } else if respondwith.ObfuscatedErrorText(w, err) {
6✔
UNCOV
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✔
UNCOV
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✔
UNCOV
814
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
815
                return
×
816
        } else if respondwith.ObfuscatedErrorText(w, err) {
2✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
856
                return
×
857
        }
×
858
        dbCommitment.RenewContextJSON = Some(json.RawMessage(buf))
2✔
859
        _, err = tx.Update(&dbCommitment)
2✔
860
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
UNCOV
861
                return
×
862
        }
×
863

864
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
865
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
UNCOV
866
                return
×
867
        }
×
868
        serviceInfo, ok := maybeServiceInfo.Unpack()
2✔
869
        if !ok {
2✔
UNCOV
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✔
UNCOV
905
                return
×
906
        }
×
907

908
        err = tx.Commit()
2✔
909
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
984
                return
×
985
        }
×
986
        serviceInfo, ok := maybeServiceInfo.Unpack()
4✔
987
        if !ok {
4✔
UNCOV
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✔
UNCOV
1026
                return
×
1027
        }
×
1028

1029
        // perform deletion
1030
        _, err = p.DB.Delete(&dbCommitment)
4✔
1031
        if respondwith.ObfuscatedErrorText(w, err) {
4✔
UNCOV
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✔
UNCOV
1079
                return
×
1080
        }
×
1081
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
1082
        if dbDomain == nil {
8✔
UNCOV
1083
                return
×
1084
        }
×
1085
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
8✔
1086
        if dbProject == nil {
8✔
UNCOV
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✔
UNCOV
1097
                return
×
1098
        }
×
1099
        req := parseTarget.Request
8✔
1100

8✔
1101
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
UNCOV
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✔
UNCOV
1115
                http.Error(w, "no such commitment", http.StatusNotFound)
×
1116
                return
×
1117
        } else if respondwith.ObfuscatedErrorText(w, err) {
7✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1138
                return
×
1139
        }
×
1140

1141
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
6✔
1142
        if respondwith.ObfuscatedErrorText(w, err) {
6✔
UNCOV
1143
                return
×
1144
        }
×
1145
        serviceInfo, ok := maybeServiceInfo.Unpack()
6✔
1146
        if !ok {
6✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1178
                        return
×
1179
                }
×
1180
                err = tx.Insert(&transferCommitment)
2✔
1181
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
UNCOV
1182
                        return
×
1183
                }
×
1184
                err = tx.Insert(&remainingCommitment)
2✔
1185
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1253
                        return
×
1254
                }
×
1255

1256
                dbCommitment = transferCommitment
2✔
1257
        }
1258
        err = tx.Commit()
6✔
1259
        if respondwith.ObfuscatedErrorText(w, err) {
6✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1368
                return
×
1369
        }
×
1370

1371
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
1372
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
UNCOV
1373
                return
×
1374
        }
×
1375
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
1376
        if !ok {
1✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1404
                return
×
1405
        }
×
1406
        targetProject := p.FindProjectFromRequest(w, r, targetDomain)
4✔
1407
        if targetProject == nil {
4✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1457
                return
×
1458
        }
×
1459
        if !resourceAllowsCommitments {
3✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1475
                return
×
1476
        }
×
1477
        serviceInfo, ok := maybeServiceInfo.Unpack()
3✔
1478
        if !ok {
3✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1555
                return
×
1556
        }
×
1557
        err = tx.Commit()
2✔
1558
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1667
                return
×
1668
        }
×
1669
        commitmentID := mux.Vars(r)["commitment_id"]
9✔
1670
        if commitmentID == "" {
9✔
UNCOV
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✔
UNCOV
1676
                return
×
1677
        }
×
1678
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
9✔
1679
        if dbProject == nil {
9✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1717
                return
×
1718
        }
×
1719
        req := parseTarget.Request
8✔
1720
        serviceInfos, err := p.Cluster.AllServiceInfos()
8✔
1721
        if respondwith.ObfuscatedErrorText(w, err) {
8✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1785
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
×
1786
                return
×
1787
        }
×
1788
        if !resourceAllowsCommitments {
3✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1935
                return
×
1936
        }
×
1937

1938
        err = tx.Commit()
2✔
1939
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
UNCOV
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✔
UNCOV
1967
                return
×
1968
        }
×
1969
        commitmentID := mux.Vars(r)["commitment_id"]
7✔
1970
        if commitmentID == "" {
7✔
UNCOV
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✔
UNCOV
1976
                return
×
1977
        }
×
1978
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
1979
        if dbProject == nil {
7✔
UNCOV
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✔
UNCOV
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✔
UNCOV
1993
                http.Error(w, "no such commitment", http.StatusNotFound)
×
1994
                return
×
1995
        } else if respondwith.ObfuscatedErrorText(w, err) {
7✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
2040
                return
×
2041
        }
×
2042
        serviceInfo, ok := maybeServiceInfo.Unpack()
3✔
2043
        if !ok {
3✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
2184
                                return result, err
×
UNCOV
2185
                        }
×
2186
                } else {
×
2187
                        liquidClient = c.LiquidConnections[serviceType].LiquidClient
×
2188
                }
×
2189
                commitmentChangeResponse, err := liquidClient.ChangeCommitments(ctx, remoteCommitmentChanges)
48✔
2190
                if err != nil {
48✔
UNCOV
2191
                        return result, fmt.Errorf("failed to retrieve liquid ChangeCommitment response for service %s: %w", serviceType, err)
×
UNCOV
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✔
UNCOV
2202
                        return result, fmt.Errorf("failed to check local ChangeCommitment: %w", err)
×
UNCOV
2203
                }
×
2204
                if !canAcceptLocally {
1✔
2205
                        return liquid.CommitmentChangeResponse{
×
UNCOV
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✔
UNCOV
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✔
UNCOV
2224
                w.Header().Set("Retry-After", retryAt.Format(time.RFC1123))
×
UNCOV
2225
        }
×
2226
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc