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

sapcc / limes / 16991273459

15 Aug 2025 01:43PM UTC coverage: 79.788% (+0.8%) from 78.946%
16991273459

push

github

web-flow
Merge pull request #756 from sapcc/delegate_commitment_acceptance

delegate commitment acceptance

682 of 765 new or added lines in 8 files covered. (89.15%)

2 existing lines in 1 file now uncovered.

7378 of 9247 relevant lines covered (79.79%)

57.47 hits per line

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

81.47
/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/liquidapi"
31
        "github.com/sapcc/go-bits/logg"
32
        "github.com/sapcc/go-bits/must"
33
        "github.com/sapcc/go-bits/respondwith"
34
        "github.com/sapcc/go-bits/sqlext"
35

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

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

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

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

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

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

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

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

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

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

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

174
// The state in the db can be directly mapped to the liquid.CommitmentStatus.
175
// However, the state "active" is named "confirmed" in the API. If the persisted
176
// state cannot be mapped to liquid terms, an empty string is returned.
177
func (p *v1Provider) convertCommitmentStateToDisplayForm(state db.CommitmentState) liquid.CommitmentStatus {
94✔
178
        var status = liquid.CommitmentStatus(state)
94✔
179
        if state == "active" {
153✔
180
                status = liquid.CommitmentStatusConfirmed
59✔
181
        }
59✔
182
        if status.IsValid() {
187✔
183
                return status
93✔
184
        }
93✔
185
        return "" // An empty state will be omitted when json serialized.
1✔
186
}
187

188
func (p *v1Provider) convertCommitmentToDisplayForm(c db.ProjectCommitment, loc core.AZResourceLocation, token *gopherpolicy.Token, unit limes.Unit) limesresources.Commitment {
59✔
189
        apiIdentity := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName).IdentityInV1API
59✔
190
        return limesresources.Commitment{
59✔
191
                ID:               int64(c.ID),
59✔
192
                UUID:             string(c.UUID),
59✔
193
                ServiceType:      apiIdentity.ServiceType,
59✔
194
                ResourceName:     apiIdentity.Name,
59✔
195
                AvailabilityZone: loc.AvailabilityZone,
59✔
196
                Amount:           c.Amount,
59✔
197
                Unit:             unit,
59✔
198
                Duration:         c.Duration,
59✔
199
                CreatedAt:        limes.UnixEncodedTime{Time: c.CreatedAt},
59✔
200
                CreatorUUID:      c.CreatorUUID,
59✔
201
                CreatorName:      c.CreatorName,
59✔
202
                CanBeDeleted:     p.canDeleteCommitment(token, c),
59✔
203
                ConfirmBy:        options.Map(c.ConfirmBy, intoUnixEncodedTime).AsPointer(),
59✔
204
                ConfirmedAt:      options.Map(c.ConfirmedAt, intoUnixEncodedTime).AsPointer(),
59✔
205
                ExpiresAt:        limes.UnixEncodedTime{Time: c.ExpiresAt},
59✔
206
                TransferStatus:   c.TransferStatus,
59✔
207
                TransferToken:    c.TransferToken.AsPointer(),
59✔
208
                Status:           p.convertCommitmentStateToDisplayForm(c.State),
59✔
209
                NotifyOnConfirm:  c.NotifyOnConfirm,
59✔
210
                WasRenewed:       c.RenewContextJSON.IsSome(),
59✔
211
        }
59✔
212
}
59✔
213

214
// parseAndValidateCommitmentRequest parses and validates the request body for a commitment creation or confirmation.
215
// This function in its current form should only be used if the serviceInfo is not necessary to be used outside
216
// of this validation to avoid unnecessary database queries.
217
func (p *v1Provider) parseAndValidateCommitmentRequest(w http.ResponseWriter, r *http.Request, dbDomain db.Domain) (*limesresources.CommitmentRequest, *core.AZResourceLocation, *core.ScopedCommitmentBehavior, *liquid.ServiceInfo) {
47✔
218
        // parse request
47✔
219
        var parseTarget struct {
47✔
220
                Request limesresources.CommitmentRequest `json:"commitment"`
47✔
221
        }
47✔
222
        if !RequireJSON(w, r, &parseTarget) {
48✔
223
                return nil, nil, nil, nil
1✔
224
        }
1✔
225
        req := parseTarget.Request
46✔
226

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

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

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

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

8✔
313
        // this api should always check CanConfirm at now()
8✔
314
        now := p.timeNow()
8✔
315
        if req.ConfirmBy != nil {
8✔
NEW
316
                http.Error(w, "this API can only check whether a commitment can be confirmed immediately", http.StatusUnprocessableEntity)
×
NEW
317
                return
×
NEW
318
        }
×
319
        canConfirmErrMsg := behavior.CanConfirmCommitmentsAt(now)
8✔
320
        if canConfirmErrMsg != "" {
9✔
321
                respondwith.JSON(w, http.StatusOK, map[string]bool{"result": false})
1✔
322
                return
1✔
323
        }
1✔
324

325
        // check for committable capacity
326
        newStatus := liquid.CommitmentStatusConfirmed
7✔
327
        totalConfirmedAfter := totalConfirmed + req.Amount
7✔
328

7✔
329
        commitmentChangeResponse, err := p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
7✔
330
                DryRun:      true,
7✔
331
                AZ:          loc.AvailabilityZone,
7✔
332
                InfoVersion: serviceInfo.Version,
7✔
333
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
7✔
334
                        dbProject.UUID: {
7✔
335
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, *serviceInfo),
7✔
336
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
7✔
337
                                        loc.ResourceName: {
7✔
338
                                                TotalConfirmedBefore: totalConfirmed,
7✔
339
                                                TotalConfirmedAfter:  totalConfirmedAfter,
7✔
340
                                                // TODO: change when introducing "guaranteed" commitments
7✔
341
                                                TotalGuaranteedBefore: 0,
7✔
342
                                                TotalGuaranteedAfter:  0,
7✔
343
                                                Commitments: []liquid.Commitment{
7✔
344
                                                        {
7✔
345
                                                                UUID:      liquid.CommitmentUUID(p.generateProjectCommitmentUUID()),
7✔
346
                                                                OldStatus: None[liquid.CommitmentStatus](),
7✔
347
                                                                NewStatus: Some(newStatus),
7✔
348
                                                                Amount:    req.Amount,
7✔
349
                                                                ExpiresAt: req.Duration.AddTo(now),
7✔
350
                                                        },
7✔
351
                                                },
7✔
352
                                        },
7✔
353
                                },
7✔
354
                        },
7✔
355
                },
7✔
356
        }, loc.ServiceType, *serviceInfo, p.DB)
7✔
357
        if respondwith.ObfuscatedErrorText(w, err) {
7✔
358
                return
×
359
        }
×
360
        result := true
7✔
361
        if commitmentChangeResponse.RejectionReason != "" {
9✔
362
                evaluateRetryHeader(commitmentChangeResponse, w)
2✔
363
                result = false
2✔
364
        }
2✔
365
        respondwith.JSON(w, http.StatusOK, map[string]bool{"result": result})
7✔
366
}
367

368
// CreateProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/new.
369
func (p *v1Provider) CreateProjectCommitment(w http.ResponseWriter, r *http.Request) {
42✔
370
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/new")
42✔
371
        token := p.CheckToken(r)
42✔
372
        if !token.Require(w, "project:edit") {
43✔
373
                return
1✔
374
        }
1✔
375
        dbDomain := p.FindDomainFromRequest(w, r)
41✔
376
        if dbDomain == nil {
42✔
377
                return
1✔
378
        }
1✔
379
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
40✔
380
        if dbProject == nil {
41✔
381
                return
1✔
382
        }
1✔
383
        req, loc, behavior, serviceInfo := p.parseAndValidateCommitmentRequest(w, r, *dbDomain)
39✔
384
        if req == nil {
50✔
385
                return
11✔
386
        }
11✔
387

388
        var (
28✔
389
                azResourceID              db.AZResourceID
28✔
390
                resourceAllowsCommitments bool
28✔
391
                totalConfirmed            uint64
28✔
392
        )
28✔
393
        err := p.DB.QueryRow(findAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
28✔
394
                Scan(&azResourceID, &resourceAllowsCommitments, &totalConfirmed)
28✔
395
        if respondwith.ObfuscatedErrorText(w, err) {
28✔
396
                return
×
397
        }
×
398
        if !resourceAllowsCommitments {
29✔
399
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
1✔
400
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
401
                return
1✔
402
        }
1✔
403

404
        // if given, confirm_by must definitely after time.Now(), and also after the MinConfirmDate if configured
405
        now := p.timeNow()
27✔
406
        if req.ConfirmBy != nil && req.ConfirmBy.Before(now) {
28✔
407
                http.Error(w, "confirm_by must not be set in the past", http.StatusUnprocessableEntity)
1✔
408
                return
1✔
409
        }
1✔
410
        confirmBy := options.Map(options.FromPointer(req.ConfirmBy), fromUnixEncodedTime)
26✔
411
        canConfirmErrMsg := behavior.CanConfirmCommitmentsAt(confirmBy.UnwrapOr(now))
26✔
412
        if canConfirmErrMsg != "" {
27✔
413
                http.Error(w, canConfirmErrMsg, http.StatusUnprocessableEntity)
1✔
414
                return
1✔
415
        }
1✔
416

417
        // we want to validate committable capacity in the same transaction that creates the commitment
418
        tx, err := p.DB.Begin()
25✔
419
        if respondwith.ObfuscatedErrorText(w, err) {
25✔
420
                return
×
421
        }
×
422
        defer sqlext.RollbackUnlessCommitted(tx)
25✔
423

25✔
424
        // prepare commitment
25✔
425
        creationContext := db.CommitmentWorkflowContext{Reason: db.CommitmentReasonCreate}
25✔
426
        buf, err := json.Marshal(creationContext)
25✔
427
        if respondwith.ObfuscatedErrorText(w, err) {
25✔
428
                return
×
429
        }
×
430
        dbCommitment := db.ProjectCommitment{
25✔
431
                UUID:                p.generateProjectCommitmentUUID(),
25✔
432
                AZResourceID:        azResourceID,
25✔
433
                ProjectID:           dbProject.ID,
25✔
434
                Amount:              req.Amount,
25✔
435
                Duration:            req.Duration,
25✔
436
                CreatedAt:           now,
25✔
437
                CreatorUUID:         token.UserUUID(),
25✔
438
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
25✔
439
                ConfirmBy:           confirmBy,
25✔
440
                ConfirmedAt:         None[time.Time](), // may be set below
25✔
441
                ExpiresAt:           req.Duration.AddTo(confirmBy.UnwrapOr(now)),
25✔
442
                CreationContextJSON: json.RawMessage(buf),
25✔
443
        }
25✔
444
        if req.NotifyOnConfirm && confirmBy.IsNone() {
26✔
445
                http.Error(w, "notification on confirm cannot be set for commitments with immediate confirmation", http.StatusConflict)
1✔
446
                return
1✔
447
        }
1✔
448
        dbCommitment.NotifyOnConfirm = req.NotifyOnConfirm
24✔
449

24✔
450
        // we do an information to liquid in any case, right now we only check the result when confirming immediately
24✔
451
        newStatus := liquid.CommitmentStatusPlanned
24✔
452
        totalConfirmedAfter := totalConfirmed
24✔
453
        if confirmBy.IsNone() {
42✔
454
                newStatus = liquid.CommitmentStatusConfirmed
18✔
455
                totalConfirmedAfter += req.Amount
18✔
456
        }
18✔
457
        commitmentChangeRequest := liquid.CommitmentChangeRequest{
24✔
458
                AZ:          loc.AvailabilityZone,
24✔
459
                InfoVersion: serviceInfo.Version,
24✔
460
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
24✔
461
                        dbProject.UUID: {
24✔
462
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, *serviceInfo),
24✔
463
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
24✔
464
                                        loc.ResourceName: {
24✔
465
                                                TotalConfirmedBefore: totalConfirmed,
24✔
466
                                                TotalConfirmedAfter:  totalConfirmedAfter,
24✔
467
                                                // TODO: change when introducing "guaranteed" commitments
24✔
468
                                                TotalGuaranteedBefore: 0,
24✔
469
                                                TotalGuaranteedAfter:  0,
24✔
470
                                                Commitments: []liquid.Commitment{
24✔
471
                                                        {
24✔
472
                                                                UUID:      liquid.CommitmentUUID(dbCommitment.UUID),
24✔
473
                                                                OldStatus: None[liquid.CommitmentStatus](),
24✔
474
                                                                NewStatus: Some(newStatus),
24✔
475
                                                                Amount:    req.Amount,
24✔
476
                                                                ConfirmBy: confirmBy,
24✔
477
                                                                ExpiresAt: req.Duration.AddTo(confirmBy.UnwrapOr(now)),
24✔
478
                                                        },
24✔
479
                                                },
24✔
480
                                        },
24✔
481
                                },
24✔
482
                        },
24✔
483
                },
24✔
484
        }
24✔
485
        commitmentChangeResponse, err := p.DelegateChangeCommitments(r.Context(), commitmentChangeRequest, loc.ServiceType, *serviceInfo, p.DB)
24✔
486
        if respondwith.ObfuscatedErrorText(w, err) {
24✔
NEW
487
                return
×
NEW
488
        }
×
489

490
        if commitmentChangeRequest.RequiresConfirmation() {
42✔
491
                // if not planned for confirmation in the future, confirm immediately (or fail)
18✔
492
                if commitmentChangeResponse.RejectionReason != "" {
18✔
NEW
493
                        evaluateRetryHeader(commitmentChangeResponse, w)
×
NEW
494
                        http.Error(w, commitmentChangeResponse.RejectionReason, http.StatusConflict)
×
495
                        return
×
496
                }
×
497
                dbCommitment.ConfirmedAt = Some(now)
18✔
498
                dbCommitment.State = db.CommitmentStateActive
18✔
499
        } else {
6✔
500
                // TODO: when introducing guaranteed, the customer can choose via the API signature whether he wants to create
6✔
501
                // the commitment only as guaranteed (RequestAsGuaranteed). If this request then fails, the customer could
6✔
502
                // resubmit it and get a planned commitment, which might never get confirmed.
6✔
503
                dbCommitment.State = db.CommitmentStatePlanned
6✔
504
        }
6✔
505

506
        // create commitment
507
        err = tx.Insert(&dbCommitment)
24✔
508
        if respondwith.ObfuscatedErrorText(w, err) {
24✔
509
                return
×
510
        }
×
511

512
        err = tx.Commit()
24✔
513
        if respondwith.ObfuscatedErrorText(w, err) {
24✔
514
                return
×
515
        }
×
516

517
        resourceInfo := core.InfoForResource(*serviceInfo, loc.ResourceName)
24✔
518
        commitment := p.convertCommitmentToDisplayForm(dbCommitment, *loc, token, resourceInfo.Unit)
24✔
519
        p.auditor.Record(audittools.Event{
24✔
520
                Time:       now,
24✔
521
                Request:    r,
24✔
522
                User:       token,
24✔
523
                ReasonCode: http.StatusCreated,
24✔
524
                Action:     cadf.CreateAction,
24✔
525
                Target: commitmentEventTarget{
24✔
526
                        DomainID:        dbDomain.UUID,
24✔
527
                        DomainName:      dbDomain.Name,
24✔
528
                        ProjectID:       dbProject.UUID,
24✔
529
                        ProjectName:     dbProject.Name,
24✔
530
                        Commitments:     []limesresources.Commitment{commitment},
24✔
531
                        WorkflowContext: Some(creationContext),
24✔
532
                },
24✔
533
        })
24✔
534

24✔
535
        // if the commitment is immediately confirmed, trigger a capacity scrape in
24✔
536
        // order to ApplyComputedProjectQuotas based on the new commitment
24✔
537
        if dbCommitment.ConfirmedAt.IsSome() {
42✔
538
                _, err := p.DB.Exec(`UPDATE services SET next_scrape_at = $1 WHERE type = $2`, now, loc.ServiceType)
18✔
539
                if err != nil {
18✔
540
                        logg.Error("could not trigger a new capacity scrape after creating commitment %s: %s", dbCommitment.UUID, err.Error())
×
541
                }
×
542
        }
543

544
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": commitment})
24✔
545
}
546

547
// MergeProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/merge.
548
func (p *v1Provider) MergeProjectCommitments(w http.ResponseWriter, r *http.Request) {
12✔
549
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/merge")
12✔
550
        token := p.CheckToken(r)
12✔
551
        if !token.Require(w, "project:edit") {
13✔
552
                return
1✔
553
        }
1✔
554
        dbDomain := p.FindDomainFromRequest(w, r)
11✔
555
        if dbDomain == nil {
12✔
556
                return
1✔
557
        }
1✔
558
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
10✔
559
        if dbProject == nil {
11✔
560
                return
1✔
561
        }
1✔
562
        var parseTarget struct {
9✔
563
                CommitmentIDs []db.ProjectCommitmentID `json:"commitment_ids"`
9✔
564
        }
9✔
565
        if !RequireJSON(w, r, &parseTarget) {
9✔
566
                return
×
567
        }
×
568
        commitmentIDs := parseTarget.CommitmentIDs
9✔
569
        if len(commitmentIDs) < 2 {
10✔
570
                http.Error(w, fmt.Sprintf("merging requires at least two commitments, but %d were given", len(commitmentIDs)), http.StatusBadRequest)
1✔
571
                return
1✔
572
        }
1✔
573

574
        // Load commitments
575
        dbCommitments := make([]db.ProjectCommitment, len(commitmentIDs))
8✔
576
        commitmentUUIDs := make([]db.ProjectCommitmentUUID, len(commitmentIDs))
8✔
577
        for i, commitmentID := range commitmentIDs {
24✔
578
                err := p.DB.SelectOne(&dbCommitments[i], findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
16✔
579
                if errors.Is(err, sql.ErrNoRows) {
17✔
580
                        http.Error(w, "no such commitment", http.StatusNotFound)
1✔
581
                        return
1✔
582
                } else if respondwith.ObfuscatedErrorText(w, err) {
16✔
583
                        return
×
584
                }
×
585
                commitmentUUIDs[i] = dbCommitments[i].UUID
15✔
586
        }
587

588
        // Verify that all commitments agree on resource and AZ and are active
589
        azResourceID := dbCommitments[0].AZResourceID
7✔
590
        for _, dbCommitment := range dbCommitments {
21✔
591
                if dbCommitment.AZResourceID != azResourceID {
16✔
592
                        http.Error(w, "all commitments must be on the same resource and AZ", http.StatusConflict)
2✔
593
                        return
2✔
594
                }
2✔
595
                if dbCommitment.State != db.CommitmentStateActive {
16✔
596
                        http.Error(w, "only active commitments may be merged", http.StatusConflict)
4✔
597
                        return
4✔
598
                }
4✔
599
        }
600

601
        var (
1✔
602
                loc            core.AZResourceLocation
1✔
603
                totalConfirmed uint64
1✔
604
        )
1✔
605
        err := p.DB.QueryRow(findAZResourceLocationByIDQuery, azResourceID, dbProject.ID).
1✔
606
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
1✔
607
        if errors.Is(err, sql.ErrNoRows) {
1✔
608
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
609
                return
×
610
        } else if respondwith.ObfuscatedErrorText(w, err) {
1✔
611
                return
×
612
        }
×
613

614
        // Start transaction for creating new commitment and marking merged commitments as superseded
615
        tx, err := p.DB.Begin()
1✔
616
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
617
                return
×
618
        }
×
619
        defer sqlext.RollbackUnlessCommitted(tx)
1✔
620

1✔
621
        // Create merged template
1✔
622
        now := p.timeNow()
1✔
623
        dbMergedCommitment := db.ProjectCommitment{
1✔
624
                UUID:         p.generateProjectCommitmentUUID(),
1✔
625
                ProjectID:    dbProject.ID,
1✔
626
                AZResourceID: azResourceID,
1✔
627
                Amount:       0,                                   // overwritten below
1✔
628
                Duration:     limesresources.CommitmentDuration{}, // overwritten below
1✔
629
                CreatedAt:    now,
1✔
630
                CreatorUUID:  token.UserUUID(),
1✔
631
                CreatorName:  fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
1✔
632
                ConfirmedAt:  Some(now),
1✔
633
                ExpiresAt:    time.Time{}, // overwritten below
1✔
634
                State:        db.CommitmentStateActive,
1✔
635
        }
1✔
636

1✔
637
        // Fill amount and latest expiration date
1✔
638
        for _, dbCommitment := range dbCommitments {
3✔
639
                dbMergedCommitment.Amount += dbCommitment.Amount
2✔
640
                if dbCommitment.ExpiresAt.After(dbMergedCommitment.ExpiresAt) {
4✔
641
                        dbMergedCommitment.ExpiresAt = dbCommitment.ExpiresAt
2✔
642
                        dbMergedCommitment.Duration = dbCommitment.Duration
2✔
643
                }
2✔
644
        }
645

646
        // Fill workflow context
647
        creationContext := db.CommitmentWorkflowContext{
1✔
648
                Reason:                 db.CommitmentReasonMerge,
1✔
649
                RelatedCommitmentIDs:   commitmentIDs,
1✔
650
                RelatedCommitmentUUIDs: commitmentUUIDs,
1✔
651
        }
1✔
652
        buf, err := json.Marshal(creationContext)
1✔
653
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
654
                return
×
655
        }
×
656
        dbMergedCommitment.CreationContextJSON = json.RawMessage(buf)
1✔
657

1✔
658
        // Insert into database
1✔
659
        err = tx.Insert(&dbMergedCommitment)
1✔
660
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
661
                return
×
662
        }
×
663

664
        // Mark merged commits as superseded
665
        supersedeContext := db.CommitmentWorkflowContext{
1✔
666
                Reason:                 db.CommitmentReasonMerge,
1✔
667
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbMergedCommitment.ID},
1✔
668
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbMergedCommitment.UUID},
1✔
669
        }
1✔
670
        buf, err = json.Marshal(supersedeContext)
1✔
671
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
672
                return
×
673
        }
×
674
        for _, dbCommitment := range dbCommitments {
3✔
675
                dbCommitment.SupersededAt = Some(now)
2✔
676
                dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
677
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
678
                _, err = tx.Update(&dbCommitment)
2✔
679
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
680
                        return
×
681
                }
×
682
        }
683

684
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
685
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
686
                return
×
687
        }
×
688
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
689
        if !ok {
1✔
690
                http.Error(w, "service not found", http.StatusNotFound)
×
691
                return
×
692
        }
×
693

694
        liquidCommitments := make([]liquid.Commitment, 1, len(dbCommitments)+1)
1✔
695
        // new
1✔
696
        liquidCommitments[0] = liquid.Commitment{
1✔
697
                UUID:      liquid.CommitmentUUID(dbMergedCommitment.UUID),
1✔
698
                OldStatus: None[liquid.CommitmentStatus](),
1✔
699
                NewStatus: Some(liquid.CommitmentStatusConfirmed),
1✔
700
                Amount:    dbMergedCommitment.Amount,
1✔
701
                ConfirmBy: dbMergedCommitment.ConfirmBy,
1✔
702
                ExpiresAt: dbMergedCommitment.ExpiresAt,
1✔
703
        }
1✔
704
        // old
1✔
705
        for _, dbCommitment := range dbCommitments {
3✔
706
                liquidCommitments = append(liquidCommitments, liquid.Commitment{
2✔
707
                        UUID:      liquid.CommitmentUUID(dbCommitment.UUID),
2✔
708
                        OldStatus: Some(liquid.CommitmentStatusConfirmed),
2✔
709
                        NewStatus: Some(liquid.CommitmentStatusSuperseded),
2✔
710
                        Amount:    dbCommitment.Amount,
2✔
711
                        ConfirmBy: dbCommitment.ConfirmBy,
2✔
712
                        ExpiresAt: dbCommitment.ExpiresAt,
2✔
713
                })
2✔
714
        }
2✔
715
        _, err = p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
1✔
716
                AZ:          loc.AvailabilityZone,
1✔
717
                InfoVersion: serviceInfo.Version,
1✔
718
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
1✔
719
                        dbProject.UUID: {
1✔
720
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
1✔
721
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
1✔
722
                                        loc.ResourceName: {
1✔
723
                                                TotalConfirmedBefore: totalConfirmed,
1✔
724
                                                TotalConfirmedAfter:  totalConfirmed,
1✔
725
                                                // TODO: change when introducing "guaranteed" commitments
1✔
726
                                                TotalGuaranteedBefore: 0,
1✔
727
                                                TotalGuaranteedAfter:  0,
1✔
728
                                                Commitments:           liquidCommitments,
1✔
729
                                        },
1✔
730
                                },
1✔
731
                        },
1✔
732
                },
1✔
733
        }, loc.ServiceType, serviceInfo, tx)
1✔
734
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
NEW
735
                return
×
NEW
736
        }
×
737

738
        err = tx.Commit()
1✔
739
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
740
                return
×
741
        }
×
742

743
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
744
        c := p.convertCommitmentToDisplayForm(dbMergedCommitment, loc, token, resourceInfo.Unit)
1✔
745
        auditEvent := commitmentEventTarget{
1✔
746
                DomainID:        dbDomain.UUID,
1✔
747
                DomainName:      dbDomain.Name,
1✔
748
                ProjectID:       dbProject.UUID,
1✔
749
                ProjectName:     dbProject.Name,
1✔
750
                Commitments:     []limesresources.Commitment{c},
1✔
751
                WorkflowContext: Some(creationContext),
1✔
752
        }
1✔
753
        p.auditor.Record(audittools.Event{
1✔
754
                Time:       p.timeNow(),
1✔
755
                Request:    r,
1✔
756
                User:       token,
1✔
757
                ReasonCode: http.StatusAccepted,
1✔
758
                Action:     cadf.UpdateAction,
1✔
759
                Target:     auditEvent,
1✔
760
        })
1✔
761

1✔
762
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
763
}
764

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

768
// RenewProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/:id/renew.
769
func (p *v1Provider) RenewProjectCommitments(w http.ResponseWriter, r *http.Request) {
6✔
770
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/renew")
6✔
771
        token := p.CheckToken(r)
6✔
772
        if !token.Require(w, "project:edit") {
6✔
773
                return
×
774
        }
×
775
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
776
        if dbDomain == nil {
6✔
777
                return
×
778
        }
×
779
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
780
        if dbProject == nil {
6✔
781
                return
×
782
        }
×
783

784
        // Load commitment
785
        var dbCommitment db.ProjectCommitment
6✔
786
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
787
        if errors.Is(err, sql.ErrNoRows) {
6✔
788
                http.Error(w, "no such commitment", http.StatusNotFound)
×
789
                return
×
790
        } else if respondwith.ObfuscatedErrorText(w, err) {
6✔
791
                return
×
792
        }
×
793
        now := p.timeNow()
6✔
794

6✔
795
        // Check if commitment can be renewed
6✔
796
        var errs errext.ErrorSet
6✔
797
        if dbCommitment.State != db.CommitmentStateActive {
7✔
798
                errs.Addf("invalid state %q", dbCommitment.State)
1✔
799
        } else if now.After(dbCommitment.ExpiresAt) {
7✔
800
                errs.Addf("invalid state %q", db.CommitmentStateExpired)
1✔
801
        }
1✔
802
        if now.Before(dbCommitment.ExpiresAt.Add(-commitmentRenewalPeriod)) {
7✔
803
                errs.Addf("renewal attempt too early")
1✔
804
        }
1✔
805
        if dbCommitment.RenewContextJSON.IsSome() {
7✔
806
                errs.Addf("already renewed")
1✔
807
        }
1✔
808

809
        if !errs.IsEmpty() {
10✔
810
                msg := "cannot renew this commitment: " + errs.Join(", ")
4✔
811
                http.Error(w, msg, http.StatusConflict)
4✔
812
                return
4✔
813
        }
4✔
814

815
        // Create renewed commitment
816
        tx, err := p.DB.Begin()
2✔
817
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
818
                return
×
819
        }
×
820
        defer sqlext.RollbackUnlessCommitted(tx)
2✔
821

2✔
822
        var (
2✔
823
                loc            core.AZResourceLocation
2✔
824
                totalConfirmed uint64
2✔
825
        )
2✔
826
        err = tx.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbProject.ID).
2✔
827
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
2✔
828
        if errors.Is(err, sql.ErrNoRows) {
2✔
829
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
830
                return
×
831
        } else if respondwith.ObfuscatedErrorText(w, err) {
2✔
832
                return
×
833
        }
×
834

835
        creationContext := db.CommitmentWorkflowContext{
2✔
836
                Reason:                 db.CommitmentReasonRenew,
2✔
837
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
2✔
838
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
2✔
839
        }
2✔
840
        buf, err := json.Marshal(creationContext)
2✔
841
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
842
                return
×
843
        }
×
844
        dbRenewedCommitment := db.ProjectCommitment{
2✔
845
                UUID:                p.generateProjectCommitmentUUID(),
2✔
846
                ProjectID:           dbProject.ID,
2✔
847
                AZResourceID:        dbCommitment.AZResourceID,
2✔
848
                Amount:              dbCommitment.Amount,
2✔
849
                Duration:            dbCommitment.Duration,
2✔
850
                CreatedAt:           now,
2✔
851
                CreatorUUID:         token.UserUUID(),
2✔
852
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
2✔
853
                ConfirmBy:           Some(dbCommitment.ExpiresAt),
2✔
854
                ExpiresAt:           dbCommitment.Duration.AddTo(dbCommitment.ExpiresAt),
2✔
855
                State:               db.CommitmentStatePlanned,
2✔
856
                CreationContextJSON: json.RawMessage(buf),
2✔
857
        }
2✔
858

2✔
859
        err = tx.Insert(&dbRenewedCommitment)
2✔
860
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
861
                return
×
862
        }
×
863

864
        renewContext := db.CommitmentWorkflowContext{
2✔
865
                Reason:                 db.CommitmentReasonRenew,
2✔
866
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbRenewedCommitment.ID},
2✔
867
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbRenewedCommitment.UUID},
2✔
868
        }
2✔
869
        buf, err = json.Marshal(renewContext)
2✔
870
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
871
                return
×
872
        }
×
873
        dbCommitment.RenewContextJSON = Some(json.RawMessage(buf))
2✔
874
        _, err = tx.Update(&dbCommitment)
2✔
875
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
876
                return
×
877
        }
×
878

879
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
880
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
881
                return
×
882
        }
×
883
        serviceInfo, ok := maybeServiceInfo.Unpack()
2✔
884
        if !ok {
2✔
885
                http.Error(w, "service not found", http.StatusNotFound)
×
886
                return
×
887
        }
×
888

889
        // TODO: for now, this is CommitmentChangeRequest.RequiresConfirmation() = false, because totalConfirmed stays and guaranteed is not used yet.
890
        // when we change this, we need to evaluate the response of the liquid
891
        _, err = p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
2✔
892
                AZ:          loc.AvailabilityZone,
2✔
893
                InfoVersion: serviceInfo.Version,
2✔
894
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
2✔
895
                        dbProject.UUID: {
2✔
896
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
2✔
897
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
2✔
898
                                        loc.ResourceName: {
2✔
899
                                                TotalConfirmedBefore: totalConfirmed,
2✔
900
                                                TotalConfirmedAfter:  totalConfirmed,
2✔
901
                                                // TODO: change when introducing "guaranteed" commitments
2✔
902
                                                TotalGuaranteedBefore: 0,
2✔
903
                                                TotalGuaranteedAfter:  0,
2✔
904
                                                Commitments: []liquid.Commitment{
2✔
905
                                                        {
2✔
906
                                                                UUID:      liquid.CommitmentUUID(dbRenewedCommitment.UUID),
2✔
907
                                                                OldStatus: None[liquid.CommitmentStatus](),
2✔
908
                                                                NewStatus: Some(liquid.CommitmentStatusPlanned),
2✔
909
                                                                Amount:    dbRenewedCommitment.Amount,
2✔
910
                                                                ConfirmBy: dbRenewedCommitment.ConfirmBy,
2✔
911
                                                                ExpiresAt: dbRenewedCommitment.ExpiresAt,
2✔
912
                                                        },
2✔
913
                                                },
2✔
914
                                        },
2✔
915
                                },
2✔
916
                        },
2✔
917
                },
2✔
918
        }, loc.ServiceType, serviceInfo, tx)
2✔
919
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
NEW
920
                return
×
NEW
921
        }
×
922

923
        err = tx.Commit()
2✔
924
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
925
                return
×
926
        }
×
927

928
        // Create resultset and auditlogs
929
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
930
        c := p.convertCommitmentToDisplayForm(dbRenewedCommitment, loc, token, resourceInfo.Unit)
2✔
931
        auditEvent := commitmentEventTarget{
2✔
932
                DomainID:        dbDomain.UUID,
2✔
933
                DomainName:      dbDomain.Name,
2✔
934
                ProjectID:       dbProject.UUID,
2✔
935
                ProjectName:     dbProject.Name,
2✔
936
                Commitments:     []limesresources.Commitment{c},
2✔
937
                WorkflowContext: Some(creationContext),
2✔
938
        }
2✔
939

2✔
940
        p.auditor.Record(audittools.Event{
2✔
941
                Time:       p.timeNow(),
2✔
942
                Request:    r,
2✔
943
                User:       token,
2✔
944
                ReasonCode: http.StatusAccepted,
2✔
945
                Action:     cadf.UpdateAction,
2✔
946
                Target:     auditEvent,
2✔
947
        })
2✔
948

2✔
949
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
950
}
951

952
// DeleteProjectCommitment handles DELETE /v1/domains/:domain_id/projects/:project_id/commitments/:id.
953
func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Request) {
8✔
954
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id")
8✔
955
        token := p.CheckToken(r)
8✔
956
        if !token.Require(w, "project:edit") { // NOTE: There is a more specific AuthZ check further down below.
8✔
957
                return
×
958
        }
×
959
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
960
        if dbDomain == nil {
9✔
961
                return
1✔
962
        }
1✔
963
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
964
        if dbProject == nil {
8✔
965
                return
1✔
966
        }
1✔
967

968
        // load commitment
969
        var dbCommitment db.ProjectCommitment
6✔
970
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
971
        if errors.Is(err, sql.ErrNoRows) {
7✔
972
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
973
                return
1✔
974
        } else if respondwith.ObfuscatedErrorText(w, err) {
6✔
975
                return
×
976
        }
×
977
        var (
5✔
978
                loc            core.AZResourceLocation
5✔
979
                totalConfirmed uint64
5✔
980
        )
5✔
981
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbProject.ID).
5✔
982
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
5✔
983
        if errors.Is(err, sql.ErrNoRows) {
5✔
984
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
985
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
986
                return
×
987
        } else if respondwith.ObfuscatedErrorText(w, err) {
5✔
988
                return
×
989
        }
×
990

991
        // check authorization for this specific commitment
992
        if !p.canDeleteCommitment(token, dbCommitment) {
6✔
993
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
994
                return
1✔
995
        }
1✔
996

997
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
4✔
998
        if respondwith.ObfuscatedErrorText(w, err) {
4✔
999
                return
×
1000
        }
×
1001
        serviceInfo, ok := maybeServiceInfo.Unpack()
4✔
1002
        if !ok {
4✔
1003
                http.Error(w, "service not found", http.StatusNotFound)
×
1004
                return
×
1005
        }
×
1006

1007
        totalConfirmedAfter := totalConfirmed
4✔
1008
        if dbCommitment.State == db.CommitmentStateActive {
6✔
1009
                totalConfirmedAfter -= dbCommitment.Amount
2✔
1010
        }
2✔
1011

1012
        _, err = p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
4✔
1013
                AZ:          loc.AvailabilityZone,
4✔
1014
                InfoVersion: serviceInfo.Version,
4✔
1015
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
4✔
1016
                        dbProject.UUID: {
4✔
1017
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
4✔
1018
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
4✔
1019
                                        loc.ResourceName: {
4✔
1020
                                                TotalConfirmedBefore: totalConfirmed,
4✔
1021
                                                TotalConfirmedAfter:  totalConfirmedAfter,
4✔
1022
                                                // TODO: change when introducing "guaranteed" commitments
4✔
1023
                                                TotalGuaranteedBefore: 0,
4✔
1024
                                                TotalGuaranteedAfter:  0,
4✔
1025
                                                Commitments: []liquid.Commitment{
4✔
1026
                                                        {
4✔
1027
                                                                UUID:      liquid.CommitmentUUID(dbCommitment.UUID),
4✔
1028
                                                                OldStatus: Some(p.convertCommitmentStateToDisplayForm(dbCommitment.State)),
4✔
1029
                                                                NewStatus: None[liquid.CommitmentStatus](),
4✔
1030
                                                                Amount:    dbCommitment.Amount,
4✔
1031
                                                                ConfirmBy: dbCommitment.ConfirmBy,
4✔
1032
                                                                ExpiresAt: dbCommitment.ExpiresAt,
4✔
1033
                                                        },
4✔
1034
                                                },
4✔
1035
                                        },
4✔
1036
                                },
4✔
1037
                        },
4✔
1038
                },
4✔
1039
        }, loc.ServiceType, serviceInfo, p.DB)
4✔
1040
        if respondwith.ObfuscatedErrorText(w, err) {
4✔
NEW
1041
                return
×
NEW
1042
        }
×
1043

1044
        // perform deletion
1045
        _, err = p.DB.Delete(&dbCommitment)
4✔
1046
        if respondwith.ObfuscatedErrorText(w, err) {
4✔
NEW
1047
                return
×
NEW
1048
        }
×
1049

1050
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
4✔
1051
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
4✔
1052
        p.auditor.Record(audittools.Event{
4✔
1053
                Time:       p.timeNow(),
4✔
1054
                Request:    r,
4✔
1055
                User:       token,
4✔
1056
                ReasonCode: http.StatusNoContent,
4✔
1057
                Action:     cadf.DeleteAction,
4✔
1058
                Target: commitmentEventTarget{
4✔
1059
                        DomainID:    dbDomain.UUID,
4✔
1060
                        DomainName:  dbDomain.Name,
4✔
1061
                        ProjectID:   dbProject.UUID,
4✔
1062
                        ProjectName: dbProject.Name,
4✔
1063
                        Commitments: []limesresources.Commitment{c},
4✔
1064
                },
4✔
1065
        })
4✔
1066

4✔
1067
        w.WriteHeader(http.StatusNoContent)
4✔
1068
}
1069

1070
func (p *v1Provider) canDeleteCommitment(token *gopherpolicy.Token, commitment db.ProjectCommitment) bool {
64✔
1071
        // up to 24 hours after creation of fresh commitments, future commitments can still be deleted by their creators
64✔
1072
        if commitment.State == db.CommitmentStatePlanned || commitment.State == db.CommitmentStatePending || commitment.State == db.CommitmentStateActive {
128✔
1073
                var creationContext db.CommitmentWorkflowContext
64✔
1074
                err := json.Unmarshal(commitment.CreationContextJSON, &creationContext)
64✔
1075
                if err == nil && creationContext.Reason == db.CommitmentReasonCreate && p.timeNow().Before(commitment.CreatedAt.Add(24*time.Hour)) {
104✔
1076
                        if token.Check("project:edit") {
80✔
1077
                                return true
40✔
1078
                        }
40✔
1079
                }
1080
        }
1081

1082
        // afterwards, a more specific permission is required to delete it
1083
        //
1084
        // This protects cloud admins making capacity planning decisions based on future commitments
1085
        // from having their forecasts ruined by project admins suffering from buyer's remorse.
1086
        return token.Check("project:uncommit")
24✔
1087
}
1088

1089
// StartCommitmentTransfer handles POST /v1/domains/:id/projects/:id/commitments/:id/start-transfer
1090
func (p *v1Provider) StartCommitmentTransfer(w http.ResponseWriter, r *http.Request) {
8✔
1091
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/start-transfer")
8✔
1092
        token := p.CheckToken(r)
8✔
1093
        if !token.Require(w, "project:edit") {
8✔
1094
                return
×
1095
        }
×
1096
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
1097
        if dbDomain == nil {
8✔
1098
                return
×
1099
        }
×
1100
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
8✔
1101
        if dbProject == nil {
8✔
1102
                return
×
1103
        }
×
1104
        // TODO: eventually migrate this struct into go-api-declarations
1105
        var parseTarget struct {
8✔
1106
                Request struct {
8✔
1107
                        Amount         uint64                                  `json:"amount"`
8✔
1108
                        TransferStatus limesresources.CommitmentTransferStatus `json:"transfer_status,omitempty"`
8✔
1109
                } `json:"commitment"`
8✔
1110
        }
8✔
1111
        if !RequireJSON(w, r, &parseTarget) {
8✔
1112
                return
×
1113
        }
×
1114
        req := parseTarget.Request
8✔
1115

8✔
1116
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
1117
                http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest)
×
1118
                return
×
1119
        }
×
1120

1121
        if req.Amount <= 0 {
9✔
1122
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
1123
                return
1✔
1124
        }
1✔
1125

1126
        // load commitment
1127
        var dbCommitment db.ProjectCommitment
7✔
1128
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
7✔
1129
        if errors.Is(err, sql.ErrNoRows) {
7✔
1130
                http.Error(w, "no such commitment", http.StatusNotFound)
×
1131
                return
×
1132
        } else if respondwith.ObfuscatedErrorText(w, err) {
7✔
1133
                return
×
1134
        }
×
1135

1136
        // Deny requests with a greater amount than the commitment.
1137
        if req.Amount > dbCommitment.Amount {
8✔
1138
                http.Error(w, "delivered amount exceeds the commitment amount.", http.StatusBadRequest)
1✔
1139
                return
1✔
1140
        }
1✔
1141

1142
        var (
6✔
1143
                loc            core.AZResourceLocation
6✔
1144
                totalConfirmed uint64
6✔
1145
        )
6✔
1146
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbProject.ID).
6✔
1147
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
6✔
1148
        if errors.Is(err, sql.ErrNoRows) {
6✔
NEW
1149
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
NEW
1150
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
NEW
1151
                return
×
1152
        } else if respondwith.ObfuscatedErrorText(w, err) {
6✔
NEW
1153
                return
×
NEW
1154
        }
×
1155

1156
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
6✔
1157
        if respondwith.ObfuscatedErrorText(w, err) {
6✔
NEW
1158
                return
×
NEW
1159
        }
×
1160
        serviceInfo, ok := maybeServiceInfo.Unpack()
6✔
1161
        if !ok {
6✔
NEW
1162
                http.Error(w, "service not found", http.StatusNotFound)
×
NEW
1163
                return
×
NEW
1164
        }
×
1165

1166
        // Mark whole commitment or a newly created, splitted one as transferrable.
1167
        tx, err := p.DB.Begin()
6✔
1168
        if respondwith.ObfuscatedErrorText(w, err) {
6✔
1169
                return
×
1170
        }
×
1171
        defer sqlext.RollbackUnlessCommitted(tx)
6✔
1172
        transferToken := p.generateTransferToken()
6✔
1173

6✔
1174
        if req.Amount == dbCommitment.Amount {
10✔
1175
                dbCommitment.TransferStatus = req.TransferStatus
4✔
1176
                dbCommitment.TransferToken = Some(transferToken)
4✔
1177
                _, err = tx.Update(&dbCommitment)
4✔
1178
                if respondwith.ObfuscatedErrorText(w, err) {
4✔
1179
                        return
×
1180
                }
×
1181
        } else {
2✔
1182
                now := p.timeNow()
2✔
1183
                transferAmount := req.Amount
2✔
1184
                remainingAmount := dbCommitment.Amount - req.Amount
2✔
1185
                transferCommitment, err := p.buildSplitCommitment(dbCommitment, transferAmount)
2✔
1186
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1187
                        return
×
1188
                }
×
1189
                transferCommitment.TransferStatus = req.TransferStatus
2✔
1190
                transferCommitment.TransferToken = Some(transferToken)
2✔
1191
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
2✔
1192
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1193
                        return
×
1194
                }
×
1195
                err = tx.Insert(&transferCommitment)
2✔
1196
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1197
                        return
×
1198
                }
×
1199
                err = tx.Insert(&remainingCommitment)
2✔
1200
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1201
                        return
×
1202
                }
×
1203

1204
                _, err = p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
2✔
1205
                        AZ:          loc.AvailabilityZone,
2✔
1206
                        InfoVersion: serviceInfo.Version,
2✔
1207
                        ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
2✔
1208
                                dbProject.UUID: {
2✔
1209
                                        ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
2✔
1210
                                        ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
2✔
1211
                                                loc.ResourceName: {
2✔
1212
                                                        TotalConfirmedBefore: totalConfirmed,
2✔
1213
                                                        TotalConfirmedAfter:  totalConfirmed,
2✔
1214
                                                        // TODO: change when introducing "guaranteed" commitments
2✔
1215
                                                        TotalGuaranteedBefore: 0,
2✔
1216
                                                        TotalGuaranteedAfter:  0,
2✔
1217
                                                        Commitments: []liquid.Commitment{
2✔
1218
                                                                // old
2✔
1219
                                                                {
2✔
1220
                                                                        UUID:      liquid.CommitmentUUID(dbCommitment.UUID),
2✔
1221
                                                                        OldStatus: Some(p.convertCommitmentStateToDisplayForm(dbCommitment.State)),
2✔
1222
                                                                        NewStatus: Some(liquid.CommitmentStatusSuperseded),
2✔
1223
                                                                        Amount:    dbCommitment.Amount,
2✔
1224
                                                                        ConfirmBy: dbCommitment.ConfirmBy,
2✔
1225
                                                                        ExpiresAt: dbCommitment.ExpiresAt,
2✔
1226
                                                                },
2✔
1227
                                                                // new
2✔
1228
                                                                {
2✔
1229
                                                                        UUID:      liquid.CommitmentUUID(transferCommitment.UUID),
2✔
1230
                                                                        OldStatus: None[liquid.CommitmentStatus](),
2✔
1231
                                                                        NewStatus: Some(p.convertCommitmentStateToDisplayForm(transferCommitment.State)),
2✔
1232
                                                                        Amount:    transferCommitment.Amount,
2✔
1233
                                                                        ConfirmBy: transferCommitment.ConfirmBy,
2✔
1234
                                                                        ExpiresAt: transferCommitment.ExpiresAt,
2✔
1235
                                                                },
2✔
1236
                                                                {
2✔
1237
                                                                        UUID:      liquid.CommitmentUUID(remainingCommitment.UUID),
2✔
1238
                                                                        OldStatus: None[liquid.CommitmentStatus](),
2✔
1239
                                                                        NewStatus: Some(p.convertCommitmentStateToDisplayForm(remainingCommitment.State)),
2✔
1240
                                                                        Amount:    remainingCommitment.Amount,
2✔
1241
                                                                        ConfirmBy: remainingCommitment.ConfirmBy,
2✔
1242
                                                                        ExpiresAt: remainingCommitment.ExpiresAt,
2✔
1243
                                                                },
2✔
1244
                                                        },
2✔
1245
                                                },
2✔
1246
                                        },
2✔
1247
                                },
2✔
1248
                        },
2✔
1249
                }, loc.ServiceType, serviceInfo, tx)
2✔
1250
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
NEW
1251
                        return
×
NEW
1252
                }
×
1253

1254
                supersedeContext := db.CommitmentWorkflowContext{
2✔
1255
                        Reason:                 db.CommitmentReasonSplit,
2✔
1256
                        RelatedCommitmentIDs:   []db.ProjectCommitmentID{transferCommitment.ID, remainingCommitment.ID},
2✔
1257
                        RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{transferCommitment.UUID, remainingCommitment.UUID},
2✔
1258
                }
2✔
1259
                buf, err := json.Marshal(supersedeContext)
2✔
1260
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1261
                        return
×
1262
                }
×
1263
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
1264
                dbCommitment.SupersededAt = Some(now)
2✔
1265
                dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
1266
                _, err = tx.Update(&dbCommitment)
2✔
1267
                if respondwith.ObfuscatedErrorText(w, err) {
2✔
1268
                        return
×
1269
                }
×
1270

1271
                dbCommitment = transferCommitment
2✔
1272
        }
1273
        err = tx.Commit()
6✔
1274
        if respondwith.ObfuscatedErrorText(w, err) {
6✔
1275
                return
×
1276
        }
×
1277

1278
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
6✔
1279
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
6✔
1280
        p.auditor.Record(audittools.Event{
6✔
1281
                Time:       p.timeNow(),
6✔
1282
                Request:    r,
6✔
1283
                User:       token,
6✔
1284
                ReasonCode: http.StatusAccepted,
6✔
1285
                Action:     cadf.UpdateAction,
6✔
1286
                Target: commitmentEventTarget{
6✔
1287
                        DomainID:    dbDomain.UUID,
6✔
1288
                        DomainName:  dbDomain.Name,
6✔
1289
                        ProjectID:   dbProject.UUID,
6✔
1290
                        ProjectName: dbProject.Name,
6✔
1291
                        Commitments: []limesresources.Commitment{c},
6✔
1292
                },
6✔
1293
        })
6✔
1294
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
6✔
1295
}
1296

1297
func (p *v1Provider) buildSplitCommitment(dbCommitment db.ProjectCommitment, amount uint64) (db.ProjectCommitment, error) {
5✔
1298
        now := p.timeNow()
5✔
1299
        creationContext := db.CommitmentWorkflowContext{
5✔
1300
                Reason:                 db.CommitmentReasonSplit,
5✔
1301
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
5✔
1302
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
5✔
1303
        }
5✔
1304
        buf, err := json.Marshal(creationContext)
5✔
1305
        if err != nil {
5✔
1306
                return db.ProjectCommitment{}, err
×
1307
        }
×
1308
        return db.ProjectCommitment{
5✔
1309
                UUID:                p.generateProjectCommitmentUUID(),
5✔
1310
                ProjectID:           dbCommitment.ProjectID,
5✔
1311
                AZResourceID:        dbCommitment.AZResourceID,
5✔
1312
                Amount:              amount,
5✔
1313
                Duration:            dbCommitment.Duration,
5✔
1314
                CreatedAt:           now,
5✔
1315
                CreatorUUID:         dbCommitment.CreatorUUID,
5✔
1316
                CreatorName:         dbCommitment.CreatorName,
5✔
1317
                ConfirmBy:           dbCommitment.ConfirmBy,
5✔
1318
                ConfirmedAt:         dbCommitment.ConfirmedAt,
5✔
1319
                ExpiresAt:           dbCommitment.ExpiresAt,
5✔
1320
                CreationContextJSON: json.RawMessage(buf),
5✔
1321
                State:               dbCommitment.State,
5✔
1322
        }, nil
5✔
1323
}
1324

1325
func (p *v1Provider) buildConvertedCommitment(dbCommitment db.ProjectCommitment, azResourceID db.AZResourceID, amount uint64) (db.ProjectCommitment, error) {
3✔
1326
        now := p.timeNow()
3✔
1327
        creationContext := db.CommitmentWorkflowContext{
3✔
1328
                Reason:                 db.CommitmentReasonConvert,
3✔
1329
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
3✔
1330
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
3✔
1331
        }
3✔
1332
        buf, err := json.Marshal(creationContext)
3✔
1333
        if err != nil {
3✔
1334
                return db.ProjectCommitment{}, err
×
1335
        }
×
1336
        return db.ProjectCommitment{
3✔
1337
                UUID:                p.generateProjectCommitmentUUID(),
3✔
1338
                ProjectID:           dbCommitment.ProjectID,
3✔
1339
                AZResourceID:        azResourceID,
3✔
1340
                Amount:              amount,
3✔
1341
                Duration:            dbCommitment.Duration,
3✔
1342
                CreatedAt:           now,
3✔
1343
                CreatorUUID:         dbCommitment.CreatorUUID,
3✔
1344
                CreatorName:         dbCommitment.CreatorName,
3✔
1345
                ConfirmBy:           dbCommitment.ConfirmBy,
3✔
1346
                ConfirmedAt:         dbCommitment.ConfirmedAt,
3✔
1347
                ExpiresAt:           dbCommitment.ExpiresAt,
3✔
1348
                CreationContextJSON: json.RawMessage(buf),
3✔
1349
                State:               dbCommitment.State,
3✔
1350
        }, nil
3✔
1351
}
1352

1353
// GetCommitmentByTransferToken handles GET /v1/commitments/{token}
1354
func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http.Request) {
2✔
1355
        httpapi.IdentifyEndpoint(r, "/v1/commitments/:token")
2✔
1356
        token := p.CheckToken(r)
2✔
1357
        if !token.Require(w, "cluster:show_basic") {
2✔
1358
                return
×
1359
        }
×
1360
        transferToken := mux.Vars(r)["token"]
2✔
1361

2✔
1362
        // The token column is a unique key, so we expect only one result.
2✔
1363
        var dbCommitment db.ProjectCommitment
2✔
1364
        err := p.DB.SelectOne(&dbCommitment, findCommitmentByTransferToken, transferToken)
2✔
1365
        if errors.Is(err, sql.ErrNoRows) {
3✔
1366
                http.Error(w, "no matching commitment found.", http.StatusNotFound)
1✔
1367
                return
1✔
1368
        } else if respondwith.ObfuscatedErrorText(w, err) {
2✔
1369
                return
×
1370
        }
×
1371

1372
        var (
1✔
1373
                loc            core.AZResourceLocation
1✔
1374
                totalConfirmed uint64
1✔
1375
        )
1✔
1376
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbCommitment.ProjectID).
1✔
1377
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
1✔
1378
        if errors.Is(err, sql.ErrNoRows) {
1✔
1379
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1380
                http.Error(w, "location data not found.", http.StatusNotFound)
×
1381
                return
×
1382
        } else if respondwith.ObfuscatedErrorText(w, err) {
1✔
1383
                return
×
1384
        }
×
1385

1386
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
1387
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
1388
                return
×
1389
        }
×
1390
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
1391
        if !ok {
1✔
1392
                http.Error(w, "service not found", http.StatusNotFound)
×
1393
                return
×
1394
        }
×
1395
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
1396
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
1✔
1397
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
1398
}
1399

1400
// TransferCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}?token={token}
1401
func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request) {
5✔
1402
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/transfer-commitment/:id")
5✔
1403
        token := p.CheckToken(r)
5✔
1404
        if !token.Require(w, "project:edit") {
5✔
1405
                return
×
1406
        }
×
1407
        transferToken := r.Header.Get("Transfer-Token")
5✔
1408
        if transferToken == "" {
6✔
1409
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
1✔
1410
                return
1✔
1411
        }
1✔
1412
        commitmentID := mux.Vars(r)["id"]
4✔
1413
        if commitmentID == "" {
4✔
1414
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1415
                return
×
1416
        }
×
1417
        targetDomain := p.FindDomainFromRequest(w, r)
4✔
1418
        if targetDomain == nil {
4✔
1419
                return
×
1420
        }
×
1421
        targetProject := p.FindProjectFromRequest(w, r, targetDomain)
4✔
1422
        if targetProject == nil {
4✔
1423
                return
×
1424
        }
×
1425

1426
        // find commitment by transfer_token
1427
        var dbCommitment db.ProjectCommitment
4✔
1428
        err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, commitmentID, transferToken)
4✔
1429
        if errors.Is(err, sql.ErrNoRows) {
5✔
1430
                http.Error(w, "no matching commitment found", http.StatusNotFound)
1✔
1431
                return
1✔
1432
        } else if respondwith.ObfuscatedErrorText(w, err) {
4✔
1433
                return
×
1434
        }
×
1435

1436
        var (
3✔
1437
                loc                  core.AZResourceLocation
3✔
1438
                sourceTotalConfirmed uint64
3✔
1439
        )
3✔
1440
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbCommitment.ProjectID).
3✔
1441
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &sourceTotalConfirmed)
3✔
1442

3✔
1443
        if errors.Is(err, sql.ErrNoRows) {
3✔
1444
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1445
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1446
                return
×
1447
        } else if respondwith.ObfuscatedErrorText(w, err) {
3✔
1448
                return
×
1449
        }
×
1450

1451
        // get old project additionally
1452
        var sourceProject db.Project
3✔
1453
        err = p.DB.SelectOne(&sourceProject, `SELECT * FROM projects WHERE id = $1`, dbCommitment.ProjectID)
3✔
1454
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
NEW
1455
                return
×
NEW
1456
        }
×
1457
        var sourceDomain db.Domain
3✔
1458
        err = p.DB.SelectOne(&sourceDomain, `SELECT * FROM domains WHERE id = $1`, sourceProject.DomainID)
3✔
1459
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
NEW
1460
                return
×
NEW
1461
        }
×
1462

1463
        // check that the target project allows commitments at all
1464
        var (
3✔
1465
                azResourceID              db.AZResourceID
3✔
1466
                resourceAllowsCommitments bool
3✔
1467
                targetTotalConfirmed      uint64
3✔
1468
        )
3✔
1469
        err = p.DB.QueryRow(findAZResourceIDByLocationQuery, targetProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
3✔
1470
                Scan(&azResourceID, &resourceAllowsCommitments, &targetTotalConfirmed)
3✔
1471
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1472
                return
×
1473
        }
×
1474
        if !resourceAllowsCommitments {
3✔
1475
                msg := fmt.Sprintf("resource %s/%s is not enabled in the target project", loc.ServiceType, loc.ResourceName)
×
1476
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1477
                return
×
1478
        }
×
1479
        _ = azResourceID // returned by the above query, but not used in this function
3✔
1480

3✔
1481
        // validate that we have enough committable capacity on the receiving side
3✔
1482
        tx, err := p.DB.Begin()
3✔
1483
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1484
                return
×
1485
        }
×
1486
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1487

3✔
1488
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
3✔
1489
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1490
                return
×
1491
        }
×
1492
        serviceInfo, ok := maybeServiceInfo.Unpack()
3✔
1493
        if !ok {
3✔
NEW
1494
                http.Error(w, "service not found", http.StatusNotFound)
×
NEW
1495
                return
×
NEW
1496
        }
×
1497

1498
        sourceTotalConfirmedAfter := sourceTotalConfirmed
3✔
1499
        targetTotalConfirmedAfter := targetTotalConfirmed
3✔
1500
        if dbCommitment.State == db.CommitmentStateActive {
6✔
1501
                sourceTotalConfirmedAfter -= dbCommitment.Amount
3✔
1502
                targetTotalConfirmedAfter += dbCommitment.Amount
3✔
1503
        }
3✔
1504

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

1565
        dbCommitment.TransferStatus = ""
2✔
1566
        dbCommitment.TransferToken = None[string]()
2✔
1567
        dbCommitment.ProjectID = targetProject.ID
2✔
1568
        _, err = tx.Update(&dbCommitment)
2✔
1569
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1570
                return
×
1571
        }
×
1572
        err = tx.Commit()
2✔
1573
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1574
                return
×
1575
        }
×
1576

1577
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
1578
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
2✔
1579
        p.auditor.Record(audittools.Event{
2✔
1580
                Time:       p.timeNow(),
2✔
1581
                Request:    r,
2✔
1582
                User:       token,
2✔
1583
                ReasonCode: http.StatusAccepted,
2✔
1584
                Action:     cadf.UpdateAction,
2✔
1585
                Target: commitmentEventTarget{
2✔
1586
                        DomainID:    targetDomain.UUID,
2✔
1587
                        DomainName:  targetDomain.Name,
2✔
1588
                        ProjectID:   targetProject.UUID,
2✔
1589
                        ProjectName: targetProject.Name,
2✔
1590
                        Commitments: []limesresources.Commitment{c},
2✔
1591
                },
2✔
1592
        })
2✔
1593

2✔
1594
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1595
}
1596

1597
// GetCommitmentConversion handles GET /v1/commitment-conversion/{service_type}/{resource_name}
1598
func (p *v1Provider) GetCommitmentConversions(w http.ResponseWriter, r *http.Request) {
3✔
1599
        httpapi.IdentifyEndpoint(r, "/v1/commitment-conversion/:service_type/:resource_name")
3✔
1600
        token := p.CheckToken(r)
3✔
1601
        if !token.Require(w, "cluster:show_basic") {
3✔
1602
                return
×
1603
        }
×
1604

1605
        // TODO v2 API: This endpoint should be project-scoped in order to make it
1606
        // easier to select the correct domain scope for the CommitmentBehavior.
1607
        forTokenScope := func(behavior core.CommitmentBehavior) core.ScopedCommitmentBehavior {
25✔
1608
                name := cmp.Or(token.ProjectScopeDomainName(), token.DomainScopeName(), "")
22✔
1609
                if name != "" {
44✔
1610
                        return behavior.ForDomain(name)
22✔
1611
                }
22✔
1612
                return behavior.ForCluster()
×
1613
        }
1614

1615
        // validate request
1616
        vars := mux.Vars(r)
3✔
1617
        serviceInfos, err := p.Cluster.AllServiceInfos()
3✔
1618
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1619
                return
×
1620
        }
×
1621

1622
        nm := core.BuildResourceNameMapping(p.Cluster, serviceInfos)
3✔
1623
        sourceServiceType, sourceResourceName, exists := nm.MapFromV1API(
3✔
1624
                limes.ServiceType(vars["service_type"]),
3✔
1625
                limesresources.ResourceName(vars["resource_name"]),
3✔
1626
        )
3✔
1627
        if !exists {
3✔
1628
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", vars["service_type"], vars["resource_name"])
×
1629
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1630
                return
×
1631
        }
×
1632
        sourceBehavior := forTokenScope(p.Cluster.CommitmentBehaviorForResource(sourceServiceType, sourceResourceName))
3✔
1633

3✔
1634
        serviceInfo := core.InfoForService(serviceInfos, sourceServiceType)
3✔
1635
        sourceResInfo := core.InfoForResource(serviceInfo, sourceResourceName)
3✔
1636

3✔
1637
        // enumerate possible conversions
3✔
1638
        conversions := make([]limesresources.CommitmentConversionRule, 0)
3✔
1639
        if sourceBehavior.ConversionRule.IsSome() {
6✔
1640
                for _, targetServiceType := range slices.Sorted(maps.Keys(serviceInfos)) {
15✔
1641
                        for targetResourceName, targetResInfo := range serviceInfos[targetServiceType].Resources {
51✔
1642
                                if sourceServiceType == targetServiceType && sourceResourceName == targetResourceName {
42✔
1643
                                        continue
3✔
1644
                                }
1645
                                if sourceResInfo.Unit != targetResInfo.Unit {
53✔
1646
                                        continue
17✔
1647
                                }
1648

1649
                                targetBehavior := forTokenScope(p.Cluster.CommitmentBehaviorForResource(targetServiceType, targetResourceName))
19✔
1650
                                if rate, ok := sourceBehavior.GetConversionRateTo(targetBehavior).Unpack(); ok {
22✔
1651
                                        apiServiceType, apiResourceName, ok := nm.MapToV1API(targetServiceType, targetResourceName)
3✔
1652
                                        if ok {
6✔
1653
                                                conversions = append(conversions, limesresources.CommitmentConversionRule{
3✔
1654
                                                        FromAmount:     rate.FromAmount,
3✔
1655
                                                        ToAmount:       rate.ToAmount,
3✔
1656
                                                        TargetService:  apiServiceType,
3✔
1657
                                                        TargetResource: apiResourceName,
3✔
1658
                                                })
3✔
1659
                                        }
3✔
1660
                                }
1661
                        }
1662
                }
1663
        }
1664

1665
        // use a defined sorting to ensure deterministic behavior in tests
1666
        slices.SortFunc(conversions, func(lhs, rhs limesresources.CommitmentConversionRule) int {
4✔
1667
                result := strings.Compare(string(lhs.TargetService), string(rhs.TargetService))
1✔
1668
                if result != 0 {
1✔
UNCOV
1669
                        return result
×
UNCOV
1670
                }
×
1671
                return strings.Compare(string(lhs.TargetResource), string(rhs.TargetResource))
1✔
1672
        })
1673

1674
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
3✔
1675
}
1676

1677
// ConvertCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/convert
1678
func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
9✔
1679
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/convert")
9✔
1680
        token := p.CheckToken(r)
9✔
1681
        if !token.Require(w, "project:edit") {
9✔
1682
                return
×
1683
        }
×
1684
        commitmentID := mux.Vars(r)["commitment_id"]
9✔
1685
        if commitmentID == "" {
9✔
NEW
1686
                http.Error(w, "no commitment_id provided", http.StatusBadRequest)
×
1687
                return
×
1688
        }
×
1689
        dbDomain := p.FindDomainFromRequest(w, r)
9✔
1690
        if dbDomain == nil {
9✔
1691
                return
×
1692
        }
×
1693
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
9✔
1694
        if dbProject == nil {
9✔
1695
                return
×
1696
        }
×
1697

1698
        // section: sourceBehavior
1699
        var dbCommitment db.ProjectCommitment
9✔
1700
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
9✔
1701
        if errors.Is(err, sql.ErrNoRows) {
10✔
1702
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
1703
                return
1✔
1704
        } else if respondwith.ObfuscatedErrorText(w, err) {
9✔
1705
                return
×
1706
        }
×
1707
        var (
8✔
1708
                sourceLoc            core.AZResourceLocation
8✔
1709
                sourceTotalConfirmed uint64
8✔
1710
        )
8✔
1711
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbProject.ID).
8✔
1712
                Scan(&sourceLoc.ServiceType, &sourceLoc.ResourceName, &sourceLoc.AvailabilityZone, &sourceTotalConfirmed)
8✔
1713
        if errors.Is(err, sql.ErrNoRows) {
8✔
1714
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1715
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1716
                return
×
1717
        } else if respondwith.ObfuscatedErrorText(w, err) {
8✔
1718
                return
×
1719
        }
×
1720
        sourceBehavior := p.Cluster.CommitmentBehaviorForResource(sourceLoc.ServiceType, sourceLoc.ResourceName).ForDomain(dbDomain.Name)
8✔
1721

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

1763
        // section: conversion
1764
        if req.SourceAmount > dbCommitment.Amount {
6✔
1765
                msg := fmt.Sprintf("unprocessable source amount. provided: %v, commitment: %v", req.SourceAmount, dbCommitment.Amount)
×
1766
                http.Error(w, msg, http.StatusConflict)
×
1767
                return
×
1768
        }
×
1769
        conversionAmount := (req.SourceAmount / rate.FromAmount) * rate.ToAmount
6✔
1770
        remainderAmount := req.SourceAmount % rate.FromAmount
6✔
1771
        if remainderAmount > 0 {
8✔
1772
                msg := fmt.Sprintf("amount: %v does not fit into conversion rate of: %v", req.SourceAmount, rate.FromAmount)
2✔
1773
                http.Error(w, msg, http.StatusConflict)
2✔
1774
                return
2✔
1775
        }
2✔
1776
        if conversionAmount != req.TargetAmount {
5✔
1777
                msg := fmt.Sprintf("conversion mismatch. provided: %v, calculated: %v", req.TargetAmount, conversionAmount)
1✔
1778
                http.Error(w, msg, http.StatusConflict)
1✔
1779
                return
1✔
1780
        }
1✔
1781

1782
        tx, err := p.DB.Begin()
3✔
1783
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1784
                return
×
1785
        }
×
1786
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1787

3✔
1788
        var (
3✔
1789
                targetAZResourceID        db.AZResourceID
3✔
1790
                resourceAllowsCommitments bool
3✔
1791
                targetTotalConfirmed      uint64
3✔
1792
        )
3✔
1793
        err = p.DB.QueryRow(findAZResourceIDByLocationQuery, dbProject.ID, targetServiceType, targetResourceName, sourceLoc.AvailabilityZone).
3✔
1794
                Scan(&targetAZResourceID, &resourceAllowsCommitments, &targetTotalConfirmed)
3✔
1795
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
1796
                return
×
1797
        }
×
1798
        // defense in depth. ServiceType and ResourceName of source and target are already checked. Here it's possible to explicitly check the ID's.
1799
        if dbCommitment.AZResourceID == targetAZResourceID {
3✔
1800
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
×
1801
                return
×
1802
        }
×
1803
        if !resourceAllowsCommitments {
3✔
1804
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", targetServiceType, targetResourceName)
×
1805
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1806
                return
×
1807
        }
×
1808
        targetLoc := core.AZResourceLocation{
3✔
1809
                ServiceType:      sourceLoc.ServiceType,
3✔
1810
                ResourceName:     targetResourceName,
3✔
1811
                AvailabilityZone: sourceLoc.AvailabilityZone,
3✔
1812
        }
3✔
1813
        serviceInfo := core.InfoForService(serviceInfos, sourceLoc.ServiceType)
3✔
1814
        remainingAmount := dbCommitment.Amount - req.SourceAmount
3✔
1815
        var remainingCommitment db.ProjectCommitment
3✔
1816

3✔
1817
        // old commitment is always superseded
3✔
1818
        sourceCommitments := []liquid.Commitment{
3✔
1819
                {
3✔
1820
                        UUID:      liquid.CommitmentUUID(dbCommitment.UUID),
3✔
1821
                        OldStatus: Some(p.convertCommitmentStateToDisplayForm(dbCommitment.State)),
3✔
1822
                        NewStatus: Some(liquid.CommitmentStatusSuperseded),
3✔
1823
                        Amount:    dbCommitment.Amount,
3✔
1824
                        ConfirmBy: dbCommitment.ConfirmBy,
3✔
1825
                        ExpiresAt: dbCommitment.ExpiresAt,
3✔
1826
                },
3✔
1827
        }
3✔
1828
        // when there is a remaining amount, we must request to add this
3✔
1829
        if remainingAmount > 0 {
4✔
1830
                remainingCommitment, err = p.buildSplitCommitment(dbCommitment, remainingAmount)
1✔
1831
                if respondwith.ObfuscatedErrorText(w, err) {
1✔
1832
                        return
×
1833
                }
×
1834
                sourceCommitments = append(sourceCommitments, liquid.Commitment{
1✔
1835
                        UUID:      liquid.CommitmentUUID(remainingCommitment.UUID),
1✔
1836
                        OldStatus: None[liquid.CommitmentStatus](),
1✔
1837
                        NewStatus: Some(p.convertCommitmentStateToDisplayForm(remainingCommitment.State)),
1✔
1838
                        Amount:    remainingCommitment.Amount,
1✔
1839
                        ConfirmBy: remainingCommitment.ConfirmBy,
1✔
1840
                        ExpiresAt: remainingCommitment.ExpiresAt,
1✔
1841
                })
1✔
1842
        }
1843
        convertedCommitment, err := p.buildConvertedCommitment(dbCommitment, targetAZResourceID, conversionAmount)
3✔
1844
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
NEW
1845
                return
×
NEW
1846
        }
×
1847

1848
        sourceTotalConfirmedAfter := sourceTotalConfirmed
3✔
1849
        targetTotalConfirmedAfter := targetTotalConfirmed
3✔
1850
        if dbCommitment.ConfirmedAt.IsSome() {
5✔
1851
                sourceTotalConfirmedAfter -= req.SourceAmount
2✔
1852
                targetTotalConfirmedAfter += req.TargetAmount
2✔
1853
        }
2✔
1854

1855
        commitmentChangeRequest := liquid.CommitmentChangeRequest{
3✔
1856
                AZ:          sourceLoc.AvailabilityZone,
3✔
1857
                InfoVersion: serviceInfo.Version,
3✔
1858
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
3✔
1859
                        dbProject.UUID: {
3✔
1860
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
3✔
1861
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
3✔
1862
                                        sourceLoc.ResourceName: {
3✔
1863
                                                TotalConfirmedBefore: sourceTotalConfirmed,
3✔
1864
                                                TotalConfirmedAfter:  sourceTotalConfirmedAfter,
3✔
1865
                                                // TODO: change when introducing "guaranteed" commitments
3✔
1866
                                                TotalGuaranteedBefore: 0,
3✔
1867
                                                TotalGuaranteedAfter:  0,
3✔
1868
                                                Commitments:           sourceCommitments,
3✔
1869
                                        },
3✔
1870
                                        targetLoc.ResourceName: {
3✔
1871
                                                TotalConfirmedBefore: targetTotalConfirmed,
3✔
1872
                                                TotalConfirmedAfter:  targetTotalConfirmedAfter,
3✔
1873
                                                // TODO: change when introducing "guaranteed" commitments
3✔
1874
                                                TotalGuaranteedBefore: 0,
3✔
1875
                                                TotalGuaranteedAfter:  0,
3✔
1876
                                                Commitments: []liquid.Commitment{
3✔
1877
                                                        {
3✔
1878
                                                                UUID:      liquid.CommitmentUUID(convertedCommitment.UUID),
3✔
1879
                                                                OldStatus: None[liquid.CommitmentStatus](),
3✔
1880
                                                                NewStatus: Some(p.convertCommitmentStateToDisplayForm(convertedCommitment.State)),
3✔
1881
                                                                Amount:    convertedCommitment.Amount,
3✔
1882
                                                                ConfirmBy: convertedCommitment.ConfirmBy,
3✔
1883
                                                                ExpiresAt: convertedCommitment.ExpiresAt,
3✔
1884
                                                        },
3✔
1885
                                                },
3✔
1886
                                        },
3✔
1887
                                },
3✔
1888
                        },
3✔
1889
                },
3✔
1890
        }
3✔
1891
        commitmentChangeResponse, err := p.DelegateChangeCommitments(r.Context(), commitmentChangeRequest, sourceLoc.ServiceType, serviceInfo, tx)
3✔
1892
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
NEW
1893
                return
×
NEW
1894
        }
×
1895

1896
        // only check acceptance by liquid when old commitment was confirmed, unconfirmed commitments can be moved without acceptance
1897
        if commitmentChangeRequest.RequiresConfirmation() && commitmentChangeResponse.RejectionReason != "" {
4✔
1898
                evaluateRetryHeader(commitmentChangeResponse, w)
1✔
1899
                http.Error(w, "not enough capacity to confirm the commitment", http.StatusUnprocessableEntity)
1✔
1900
                return
1✔
1901
        }
1✔
1902

1903
        auditEvent := commitmentEventTarget{
2✔
1904
                DomainID:    dbDomain.UUID,
2✔
1905
                DomainName:  dbDomain.Name,
2✔
1906
                ProjectID:   dbProject.UUID,
2✔
1907
                ProjectName: dbProject.Name,
2✔
1908
        }
2✔
1909

2✔
1910
        var (
2✔
1911
                relatedCommitmentIDs   []db.ProjectCommitmentID
2✔
1912
                relatedCommitmentUUIDs []db.ProjectCommitmentUUID
2✔
1913
        )
2✔
1914
        resourceInfo := core.InfoForResource(serviceInfo, sourceLoc.ResourceName)
2✔
1915
        if remainingAmount > 0 {
3✔
1916
                relatedCommitmentIDs = append(relatedCommitmentIDs, remainingCommitment.ID)
1✔
1917
                relatedCommitmentUUIDs = append(relatedCommitmentUUIDs, remainingCommitment.UUID)
1✔
1918
                err = tx.Insert(&remainingCommitment)
1✔
1919
                if respondwith.ObfuscatedErrorText(w, err) {
1✔
1920
                        return
×
1921
                }
×
1922
                auditEvent.Commitments = append(auditEvent.Commitments,
1✔
1923
                        p.convertCommitmentToDisplayForm(remainingCommitment, sourceLoc, token, resourceInfo.Unit),
1✔
1924
                )
1✔
1925
        }
1926

1927
        relatedCommitmentIDs = append(relatedCommitmentIDs, convertedCommitment.ID)
2✔
1928
        relatedCommitmentUUIDs = append(relatedCommitmentUUIDs, convertedCommitment.UUID)
2✔
1929
        err = tx.Insert(&convertedCommitment)
2✔
1930
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1931
                return
×
1932
        }
×
1933

1934
        // supersede the original commitment
1935
        now := p.timeNow()
2✔
1936
        supersedeContext := db.CommitmentWorkflowContext{
2✔
1937
                Reason:                 db.CommitmentReasonConvert,
2✔
1938
                RelatedCommitmentIDs:   relatedCommitmentIDs,
2✔
1939
                RelatedCommitmentUUIDs: relatedCommitmentUUIDs,
2✔
1940
        }
2✔
1941
        buf, err := json.Marshal(supersedeContext)
2✔
1942
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1943
                return
×
1944
        }
×
1945
        dbCommitment.State = db.CommitmentStateSuperseded
2✔
1946
        dbCommitment.SupersededAt = Some(now)
2✔
1947
        dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
1948
        _, err = tx.Update(&dbCommitment)
2✔
1949
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1950
                return
×
1951
        }
×
1952

1953
        err = tx.Commit()
2✔
1954
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
1955
                return
×
1956
        }
×
1957

1958
        c := p.convertCommitmentToDisplayForm(convertedCommitment, targetLoc, token, resourceInfo.Unit)
2✔
1959
        auditEvent.Commitments = append([]limesresources.Commitment{c}, auditEvent.Commitments...)
2✔
1960
        auditEvent.WorkflowContext = Some(db.CommitmentWorkflowContext{
2✔
1961
                Reason:                 db.CommitmentReasonSplit,
2✔
1962
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1963
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
2✔
1964
        })
2✔
1965
        p.auditor.Record(audittools.Event{
2✔
1966
                Time:       p.timeNow(),
2✔
1967
                Request:    r,
2✔
1968
                User:       token,
2✔
1969
                ReasonCode: http.StatusAccepted,
2✔
1970
                Action:     cadf.UpdateAction,
2✔
1971
                Target:     auditEvent,
2✔
1972
        })
2✔
1973

2✔
1974
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1975
}
1976

1977
// ExtendCommitmentDuration handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/update-duration
1978
func (p *v1Provider) UpdateCommitmentDuration(w http.ResponseWriter, r *http.Request) {
7✔
1979
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/update-duration")
7✔
1980
        token := p.CheckToken(r)
7✔
1981
        if !token.Require(w, "project:edit") {
7✔
1982
                return
×
1983
        }
×
1984
        commitmentID := mux.Vars(r)["commitment_id"]
7✔
1985
        if commitmentID == "" {
7✔
1986
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1987
                return
×
1988
        }
×
1989
        dbDomain := p.FindDomainFromRequest(w, r)
7✔
1990
        if dbDomain == nil {
7✔
1991
                return
×
1992
        }
×
1993
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
1994
        if dbProject == nil {
7✔
1995
                return
×
1996
        }
×
1997
        var Request struct {
7✔
1998
                Duration limesresources.CommitmentDuration `json:"duration"`
7✔
1999
        }
7✔
2000
        req := Request
7✔
2001
        if !RequireJSON(w, r, &req) {
7✔
2002
                return
×
2003
        }
×
2004

2005
        var dbCommitment db.ProjectCommitment
7✔
2006
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
7✔
2007
        if errors.Is(err, sql.ErrNoRows) {
7✔
2008
                http.Error(w, "no such commitment", http.StatusNotFound)
×
2009
                return
×
2010
        } else if respondwith.ObfuscatedErrorText(w, err) {
7✔
2011
                return
×
2012
        }
×
2013

2014
        now := p.timeNow()
7✔
2015
        if dbCommitment.ExpiresAt.Before(now) || dbCommitment.ExpiresAt.Equal(now) {
8✔
2016
                http.Error(w, "unable to process expired commitment", http.StatusForbidden)
1✔
2017
                return
1✔
2018
        }
1✔
2019

2020
        if dbCommitment.State == db.CommitmentStateSuperseded {
7✔
2021
                msg := fmt.Sprintf("unable to operate on commitment with a state of %s", dbCommitment.State)
1✔
2022
                http.Error(w, msg, http.StatusForbidden)
1✔
2023
                return
1✔
2024
        }
1✔
2025

2026
        var (
5✔
2027
                loc            core.AZResourceLocation
5✔
2028
                totalConfirmed uint64
5✔
2029
        )
5✔
2030
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbProject.ID).
5✔
2031
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
5✔
2032
        if errors.Is(err, sql.ErrNoRows) {
5✔
2033
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
2034
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
2035
                return
×
2036
        } else if respondwith.ObfuscatedErrorText(w, err) {
5✔
2037
                return
×
2038
        }
×
2039
        behavior := p.Cluster.CommitmentBehaviorForResource(loc.ServiceType, loc.ResourceName).ForDomain(dbDomain.Name)
5✔
2040
        if !slices.Contains(behavior.Durations, req.Duration) {
6✔
2041
                msg := fmt.Sprintf("provided duration: %s does not match the config %v", req.Duration, behavior.Durations)
1✔
2042
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
2043
                return
1✔
2044
        }
1✔
2045

2046
        newExpiresAt := req.Duration.AddTo(dbCommitment.ConfirmBy.UnwrapOr(dbCommitment.CreatedAt))
4✔
2047
        if newExpiresAt.Before(dbCommitment.ExpiresAt) {
5✔
2048
                msg := fmt.Sprintf("duration change from %s to %s forbidden", dbCommitment.Duration, req.Duration)
1✔
2049
                http.Error(w, msg, http.StatusForbidden)
1✔
2050
                return
1✔
2051
        }
1✔
2052

2053
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
3✔
2054
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
2055
                return
×
2056
        }
×
2057
        serviceInfo, ok := maybeServiceInfo.Unpack()
3✔
2058
        if !ok {
3✔
2059
                http.Error(w, "service not found", http.StatusNotFound)
×
2060
                return
×
2061
        }
×
2062

2063
        // might only reject in the remote-case, locally we accept extensions as limes does not know future capacity
2064
        commitmentChangeResponse, err := p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
3✔
2065
                AZ:          loc.AvailabilityZone,
3✔
2066
                InfoVersion: serviceInfo.Version,
3✔
2067
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
3✔
2068
                        dbProject.UUID: {
3✔
2069
                                ProjectMetadata: liquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
3✔
2070
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
3✔
2071
                                        loc.ResourceName: {
3✔
2072
                                                TotalConfirmedBefore: totalConfirmed,
3✔
2073
                                                TotalConfirmedAfter:  totalConfirmed,
3✔
2074
                                                // TODO: change when introducing "guaranteed" commitments
3✔
2075
                                                TotalGuaranteedBefore: 0,
3✔
2076
                                                TotalGuaranteedAfter:  0,
3✔
2077
                                                Commitments: []liquid.Commitment{
3✔
2078
                                                        {
3✔
2079
                                                                UUID:         liquid.CommitmentUUID(dbCommitment.UUID),
3✔
2080
                                                                OldStatus:    Some(p.convertCommitmentStateToDisplayForm(dbCommitment.State)),
3✔
2081
                                                                NewStatus:    Some(p.convertCommitmentStateToDisplayForm(dbCommitment.State)),
3✔
2082
                                                                Amount:       dbCommitment.Amount,
3✔
2083
                                                                ConfirmBy:    dbCommitment.ConfirmBy,
3✔
2084
                                                                ExpiresAt:    newExpiresAt,
3✔
2085
                                                                OldExpiresAt: Some(dbCommitment.ExpiresAt.Local()),
3✔
2086
                                                        },
3✔
2087
                                                },
3✔
2088
                                        },
3✔
2089
                                },
3✔
2090
                        },
3✔
2091
                },
3✔
2092
        }, loc.ServiceType, serviceInfo, p.DB)
3✔
2093
        if respondwith.ObfuscatedErrorText(w, err) {
3✔
NEW
2094
                return
×
NEW
2095
        }
×
2096

2097
        dbCommitment.Duration = req.Duration
3✔
2098
        dbCommitment.ExpiresAt = newExpiresAt
3✔
2099
        if commitmentChangeResponse.RejectionReason != "" {
4✔
2100
                evaluateRetryHeader(commitmentChangeResponse, w)
1✔
2101
                http.Error(w, commitmentChangeResponse.RejectionReason, http.StatusConflict)
1✔
2102
                return
1✔
2103
        }
1✔
2104

2105
        _, err = p.DB.Update(&dbCommitment)
2✔
2106
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
NEW
2107
                return
×
NEW
2108
        }
×
2109

2110
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
2111
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
2✔
2112
        p.auditor.Record(audittools.Event{
2✔
2113
                Time:       p.timeNow(),
2✔
2114
                Request:    r,
2✔
2115
                User:       token,
2✔
2116
                ReasonCode: http.StatusOK,
2✔
2117
                Action:     cadf.UpdateAction,
2✔
2118
                Target: commitmentEventTarget{
2✔
2119
                        DomainID:    dbDomain.UUID,
2✔
2120
                        DomainName:  dbDomain.Name,
2✔
2121
                        ProjectID:   dbProject.UUID,
2✔
2122
                        ProjectName: dbProject.Name,
2✔
2123
                        Commitments: []limesresources.Commitment{c},
2✔
2124
                },
2✔
2125
        })
2✔
2126

2✔
2127
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitment": c})
2✔
2128
}
2129

2130
// DelegateChangeCommitments decides whether LiquidClient.ChangeCommitments() should be called,
2131
// depending on the setting of liquid.ResourceInfo.HandlesCommitments. If not, it routes the
2132
// operation to be performed locally on the database. In case the LiquidConnection is not filled,
2133
// a LiquidClient is instantiated on the fly to perform the operation. It utilizes a given ServiceInfo so that no
2134
// double retrieval is necessary caused by operations to assemble the liquid.CommitmentChange.
2135
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✔
2136
        localCommitmentChanges := liquid.CommitmentChangeRequest{
49✔
2137
                DryRun:      req.DryRun,
49✔
2138
                AZ:          req.AZ,
49✔
2139
                InfoVersion: req.InfoVersion,
49✔
2140
                ByProject:   make(map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset),
49✔
2141
        }
49✔
2142
        remoteCommitmentChanges := liquid.CommitmentChangeRequest{
49✔
2143
                DryRun:      req.DryRun,
49✔
2144
                AZ:          req.AZ,
49✔
2145
                InfoVersion: req.InfoVersion,
49✔
2146
                ByProject:   make(map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset),
49✔
2147
        }
49✔
2148
        for projectUUID, projectCommitmentChangeset := range req.ByProject {
101✔
2149
                for resourceName, resourceCommitmentChangeset := range projectCommitmentChangeset.ByResource {
107✔
2150
                        // this is just to make the tests deterministic because time.Local != local IANA time (after parsing)
55✔
2151
                        for i, commitment := range resourceCommitmentChangeset.Commitments {
117✔
2152
                                commitment.ExpiresAt = commitment.ExpiresAt.Local()
62✔
2153
                                commitment.ConfirmBy = options.Map(commitment.ConfirmBy, time.Time.Local)
62✔
2154
                                resourceCommitmentChangeset.Commitments[i] = commitment
62✔
2155
                        }
62✔
2156

2157
                        if serviceInfo.Resources[resourceName].HandlesCommitments {
109✔
2158
                                _, exists := remoteCommitmentChanges.ByProject[projectUUID]
54✔
2159
                                if !exists {
105✔
2160
                                        remoteCommitmentChanges.ByProject[projectUUID] = liquid.ProjectCommitmentChangeset{
51✔
2161
                                                ByResource: make(map[liquid.ResourceName]liquid.ResourceCommitmentChangeset),
51✔
2162
                                        }
51✔
2163
                                }
51✔
2164
                                remoteCommitmentChanges.ByProject[projectUUID].ByResource[resourceName] = resourceCommitmentChangeset
54✔
2165
                                continue
54✔
2166
                        }
2167
                        _, exists := localCommitmentChanges.ByProject[projectUUID]
1✔
2168
                        if !exists {
2✔
2169
                                localCommitmentChanges.ByProject[projectUUID] = liquid.ProjectCommitmentChangeset{
1✔
2170
                                        ByResource: make(map[liquid.ResourceName]liquid.ResourceCommitmentChangeset),
1✔
2171
                                }
1✔
2172
                        }
1✔
2173
                        localCommitmentChanges.ByProject[projectUUID].ByResource[resourceName] = resourceCommitmentChangeset
1✔
2174
                }
2175
        }
2176
        for projectUUID, projectCommitmentChangeset := range localCommitmentChanges.ByProject {
50✔
2177
                if serviceInfo.CommitmentHandlingNeedsProjectMetadata {
1✔
NEW
2178
                        pcs := projectCommitmentChangeset
×
NEW
2179
                        pcs.ProjectMetadata = req.ByProject[projectUUID].ProjectMetadata
×
NEW
2180
                        localCommitmentChanges.ByProject[projectUUID] = pcs
×
NEW
2181
                }
×
2182
        }
2183
        for projectUUID, remoteCommitmentChangeset := range remoteCommitmentChanges.ByProject {
100✔
2184
                if serviceInfo.CommitmentHandlingNeedsProjectMetadata {
51✔
NEW
2185
                        rcs := remoteCommitmentChangeset
×
NEW
2186
                        rcs.ProjectMetadata = req.ByProject[projectUUID].ProjectMetadata
×
NEW
2187
                        remoteCommitmentChanges.ByProject[projectUUID] = rcs
×
NEW
2188
                }
×
2189
        }
2190

2191
        // check remote
2192
        if len(remoteCommitmentChanges.ByProject) != 0 {
97✔
2193
                var liquidClient core.LiquidClient
48✔
2194
                c := p.Cluster
48✔
2195
                if len(c.LiquidConnections) == 0 {
96✔
2196
                        // find the right ServiceType
48✔
2197
                        liquidServiceType := c.Config.Liquids[serviceType].LiquidServiceType
48✔
2198
                        liquidClient, err = core.NewLiquidClient(c.Provider, c.EO, liquidapi.ClientOpts{ServiceType: liquidServiceType})
48✔
2199
                        if err != nil {
48✔
NEW
2200
                                return result, fmt.Errorf("failed to create LiquidClient for service %s: %w", liquidServiceType, err)
×
NEW
2201
                        }
×
NEW
2202
                } else {
×
NEW
2203
                        liquidClient = c.LiquidConnections[serviceType].LiquidClient
×
NEW
2204
                }
×
2205
                commitmentChangeResponse, err := liquidClient.ChangeCommitments(ctx, remoteCommitmentChanges)
48✔
2206
                if err != nil {
48✔
NEW
2207
                        return result, fmt.Errorf("failed to retrieve liquid ChangeCommitment response for service %s: %w", serviceType, err)
×
NEW
2208
                }
×
2209
                if commitmentChangeResponse.RejectionReason != "" {
53✔
2210
                        return commitmentChangeResponse, nil
5✔
2211
                }
5✔
2212
        }
2213

2214
        // check local
2215
        if len(localCommitmentChanges.ByProject) != 0 {
45✔
2216
                canAcceptLocally, err := datamodel.CanAcceptCommitmentChangeRequest(localCommitmentChanges, serviceType, p.Cluster, dbi)
1✔
2217
                if err != nil {
1✔
NEW
2218
                        return result, fmt.Errorf("failed to check local ChangeCommitment: %w", err)
×
NEW
2219
                }
×
2220
                if !canAcceptLocally {
1✔
NEW
2221
                        return liquid.CommitmentChangeResponse{
×
NEW
2222
                                RejectionReason: "not enough capacity!",
×
NEW
2223
                                RetryAt:         None[time.Time](),
×
NEW
2224
                        }, nil
×
NEW
2225
                }
×
2226
        }
2227

2228
        return result, nil
44✔
2229
}
2230

2231
func liquidProjectMetadataFromDBProject(dbProject db.Project, domain db.Domain, serviceInfo liquid.ServiceInfo) Option[liquid.ProjectMetadata] {
52✔
2232
        if !serviceInfo.CommitmentHandlingNeedsProjectMetadata {
104✔
2233
                return None[liquid.ProjectMetadata]()
52✔
2234
        }
52✔
NEW
2235
        return Some(core.KeystoneProjectFromDB(dbProject, core.KeystoneDomain{UUID: domain.UUID, Name: domain.Name}).ForLiquid())
×
2236
}
2237

2238
func evaluateRetryHeader(response liquid.CommitmentChangeResponse, w http.ResponseWriter) {
5✔
2239
        if retryAt, exists := response.RetryAt.Unpack(); exists && response.RejectionReason != "" {
5✔
NEW
2240
                w.Header().Set("Retry-After", retryAt.Format(time.RFC1123))
×
NEW
2241
        }
×
2242
}
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