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

sapcc / limes / 16930870964

13 Aug 2025 07:42AM UTC coverage: 79.62% (+0.7%) from 78.924%
16930870964

Pull #756

github

wagnerd3
adjust /commitment API to liquid spec change
Pull Request #756: delegate commitment acceptance

671 of 773 new or added lines in 8 files covered. (86.8%)

3 existing lines in 1 file now uncovered.

7376 of 9264 relevant lines covered (79.62%)

57.24 hits per line

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

80.65
/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/must"
32
        "github.com/sapcc/go-bits/respondwith"
33
        "github.com/sapcc/go-bits/sqlext"
34

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

7✔
312
        // commitments can never be confirmed immediately if we are before the min_confirm_date
7✔
313
        now := p.timeNow()
7✔
314
        if req.ConfirmBy != nil && req.ConfirmBy.Before(now) {
7✔
NEW
315
                http.Error(w, "confirm_by must not be set in the past", http.StatusUnprocessableEntity)
×
NEW
316
                return
×
NEW
317
        }
×
318
        confirmBy := options.Map(options.FromPointer(req.ConfirmBy), fromUnixEncodedTime)
7✔
319
        canConfirmErrMsg := behavior.CanConfirmCommitmentsAt(confirmBy.UnwrapOr(now))
7✔
320
        if canConfirmErrMsg != "" {
8✔
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.CommitmentStatusPlanned
6✔
327
        totalConfirmedAfter := totalConfirmed
6✔
328
        if confirmBy.IsNone() {
12✔
329
                newStatus = liquid.CommitmentStatusConfirmed
6✔
330
                totalConfirmedAfter += req.Amount
6✔
331
        }
6✔
332

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

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

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

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

422
        // we want to validate committable capacity in the same transaction that creates the commitment
423
        tx, err := p.DB.Begin()
25✔
424
        if respondwith.ErrorText(w, err) {
25✔
425
                return
×
426
        }
×
427
        defer sqlext.RollbackUnlessCommitted(tx)
25✔
428

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

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

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

510
        // create commitment
511
        err = tx.Insert(&dbCommitment)
24✔
512
        if respondwith.ErrorText(w, err) {
24✔
513
                return
×
514
        }
×
515
        err = tx.Commit()
24✔
516
        if respondwith.ErrorText(w, err) {
24✔
517
                return
×
518
        }
×
519
        resourceInfo := core.InfoForResource(*serviceInfo, loc.ResourceName)
24✔
520
        commitment := p.convertCommitmentToDisplayForm(dbCommitment, *loc, token, resourceInfo.Unit)
24✔
521
        p.auditor.Record(audittools.Event{
24✔
522
                Time:       now,
24✔
523
                Request:    r,
24✔
524
                User:       token,
24✔
525
                ReasonCode: http.StatusCreated,
24✔
526
                Action:     cadf.CreateAction,
24✔
527
                Target: commitmentEventTarget{
24✔
528
                        DomainID:        dbDomain.UUID,
24✔
529
                        DomainName:      dbDomain.Name,
24✔
530
                        ProjectID:       dbProject.UUID,
24✔
531
                        ProjectName:     dbProject.Name,
24✔
532
                        Commitments:     []limesresources.Commitment{commitment},
24✔
533
                        WorkflowContext: Some(creationContext),
24✔
534
                },
24✔
535
        })
24✔
536

24✔
537
        // if the commitment is immediately confirmed, trigger a capacity scrape in
24✔
538
        // order to ApplyComputedProjectQuotas based on the new commitment
24✔
539
        if dbCommitment.ConfirmedAt.IsSome() {
42✔
540
                _, err := p.DB.Exec(`UPDATE services SET next_scrape_at = $1 WHERE type = $2`, now, loc.ServiceType)
18✔
541
                if respondwith.ErrorText(w, err) {
18✔
542
                        return
×
543
                }
×
544
        }
545

546
        // display the possibly confirmed commitment to the user
547
        err = p.DB.SelectOne(&dbCommitment, `SELECT * FROM project_commitments WHERE id = $1`, dbCommitment.ID)
24✔
548
        if respondwith.ErrorText(w, err) {
24✔
549
                return
×
550
        }
×
551

552
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": commitment})
24✔
553
}
554

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

582
        // Load commitments
583
        dbCommitments := make([]db.ProjectCommitment, len(commitmentIDs))
8✔
584
        commitmentUUIDs := make([]db.ProjectCommitmentUUID, len(commitmentIDs))
8✔
585
        for i, commitmentID := range commitmentIDs {
24✔
586
                err := p.DB.SelectOne(&dbCommitments[i], findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
16✔
587
                if errors.Is(err, sql.ErrNoRows) {
17✔
588
                        http.Error(w, "no such commitment", http.StatusNotFound)
1✔
589
                        return
1✔
590
                } else if respondwith.ErrorText(w, err) {
16✔
591
                        return
×
592
                }
×
593
                commitmentUUIDs[i] = dbCommitments[i].UUID
15✔
594
        }
595

596
        // Verify that all commitments agree on resource and AZ and are active
597
        azResourceID := dbCommitments[0].AZResourceID
7✔
598
        for _, dbCommitment := range dbCommitments {
21✔
599
                if dbCommitment.AZResourceID != azResourceID {
16✔
600
                        http.Error(w, "all commitments must be on the same resource and AZ", http.StatusConflict)
2✔
601
                        return
2✔
602
                }
2✔
603
                if dbCommitment.State != db.CommitmentStateActive {
16✔
604
                        http.Error(w, "only active commitments may be merged", http.StatusConflict)
4✔
605
                        return
4✔
606
                }
4✔
607
        }
608

609
        var (
1✔
610
                loc            core.AZResourceLocation
1✔
611
                totalConfirmed uint64
1✔
612
        )
1✔
613
        err := p.DB.QueryRow(findAZResourceLocationByIDQuery, azResourceID, dbProject.ID).
1✔
614
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
1✔
615
        if errors.Is(err, sql.ErrNoRows) {
1✔
616
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
617
                return
×
618
        } else if respondwith.ErrorText(w, err) {
1✔
619
                return
×
620
        }
×
621

622
        // Start transaction for creating new commitment and marking merged commitments as superseded
623
        tx, err := p.DB.Begin()
1✔
624
        if respondwith.ErrorText(w, err) {
1✔
625
                return
×
626
        }
×
627
        defer sqlext.RollbackUnlessCommitted(tx)
1✔
628

1✔
629
        // Create merged template
1✔
630
        now := p.timeNow()
1✔
631
        dbMergedCommitment := db.ProjectCommitment{
1✔
632
                UUID:         p.generateProjectCommitmentUUID(),
1✔
633
                ProjectID:    dbProject.ID,
1✔
634
                AZResourceID: azResourceID,
1✔
635
                Amount:       0,                                   // overwritten below
1✔
636
                Duration:     limesresources.CommitmentDuration{}, // overwritten below
1✔
637
                CreatedAt:    now,
1✔
638
                CreatorUUID:  token.UserUUID(),
1✔
639
                CreatorName:  fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
1✔
640
                ConfirmedAt:  Some(now),
1✔
641
                ExpiresAt:    time.Time{}, // overwritten below
1✔
642
                State:        db.CommitmentStateActive,
1✔
643
        }
1✔
644

1✔
645
        // Fill amount and latest expiration date
1✔
646
        for _, dbCommitment := range dbCommitments {
3✔
647
                dbMergedCommitment.Amount += dbCommitment.Amount
2✔
648
                if dbCommitment.ExpiresAt.After(dbMergedCommitment.ExpiresAt) {
4✔
649
                        dbMergedCommitment.ExpiresAt = dbCommitment.ExpiresAt
2✔
650
                        dbMergedCommitment.Duration = dbCommitment.Duration
2✔
651
                }
2✔
652
        }
653

654
        // Fill workflow context
655
        creationContext := db.CommitmentWorkflowContext{
1✔
656
                Reason:                 db.CommitmentReasonMerge,
1✔
657
                RelatedCommitmentIDs:   commitmentIDs,
1✔
658
                RelatedCommitmentUUIDs: commitmentUUIDs,
1✔
659
        }
1✔
660
        buf, err := json.Marshal(creationContext)
1✔
661
        if respondwith.ErrorText(w, err) {
1✔
662
                return
×
663
        }
×
664
        dbMergedCommitment.CreationContextJSON = json.RawMessage(buf)
1✔
665

1✔
666
        // Insert into database
1✔
667
        err = tx.Insert(&dbMergedCommitment)
1✔
668
        if respondwith.ErrorText(w, err) {
1✔
669
                return
×
670
        }
×
671

672
        // Mark merged commits as superseded
673
        supersedeContext := db.CommitmentWorkflowContext{
1✔
674
                Reason:                 db.CommitmentReasonMerge,
1✔
675
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbMergedCommitment.ID},
1✔
676
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbMergedCommitment.UUID},
1✔
677
        }
1✔
678
        buf, err = json.Marshal(supersedeContext)
1✔
679
        if respondwith.ErrorText(w, err) {
1✔
680
                return
×
681
        }
×
682
        for _, dbCommitment := range dbCommitments {
3✔
683
                dbCommitment.SupersededAt = Some(now)
2✔
684
                dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
685
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
686
                _, err = tx.Update(&dbCommitment)
2✔
687
                if respondwith.ErrorText(w, err) {
2✔
688
                        return
×
689
                }
×
690
        }
691

692
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
693
        if respondwith.ObfuscatedErrorText(w, err) {
1✔
694
                return
×
695
        }
×
696
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
697
        if !ok {
1✔
698
                http.Error(w, "service not found", http.StatusNotFound)
×
699
                return
×
700
        }
×
701

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

746
        err = tx.Commit()
1✔
747
        if respondwith.ErrorText(w, err) {
1✔
NEW
748
                return
×
NEW
749
        }
×
750

751
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
752

1✔
753
        c := p.convertCommitmentToDisplayForm(dbMergedCommitment, loc, token, resourceInfo.Unit)
1✔
754
        auditEvent := commitmentEventTarget{
1✔
755
                DomainID:        dbDomain.UUID,
1✔
756
                DomainName:      dbDomain.Name,
1✔
757
                ProjectID:       dbProject.UUID,
1✔
758
                ProjectName:     dbProject.Name,
1✔
759
                Commitments:     []limesresources.Commitment{c},
1✔
760
                WorkflowContext: Some(creationContext),
1✔
761
        }
1✔
762
        p.auditor.Record(audittools.Event{
1✔
763
                Time:       p.timeNow(),
1✔
764
                Request:    r,
1✔
765
                User:       token,
1✔
766
                ReasonCode: http.StatusAccepted,
1✔
767
                Action:     cadf.UpdateAction,
1✔
768
                Target:     auditEvent,
1✔
769
        })
1✔
770

1✔
771
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
772
}
773

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

777
// RenewProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/:id/renew.
778
func (p *v1Provider) RenewProjectCommitments(w http.ResponseWriter, r *http.Request) {
6✔
779
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/renew")
6✔
780
        token := p.CheckToken(r)
6✔
781
        if !token.Require(w, "project:edit") {
6✔
782
                return
×
783
        }
×
784
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
785
        if dbDomain == nil {
6✔
786
                return
×
787
        }
×
788
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
789
        if dbProject == nil {
6✔
790
                return
×
791
        }
×
792

793
        // Load commitment
794
        var dbCommitment db.ProjectCommitment
6✔
795
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
796
        if errors.Is(err, sql.ErrNoRows) {
6✔
797
                http.Error(w, "no such commitment", http.StatusNotFound)
×
798
                return
×
799
        } else if respondwith.ErrorText(w, err) {
6✔
800
                return
×
801
        }
×
802
        now := p.timeNow()
6✔
803

6✔
804
        // Check if commitment can be renewed
6✔
805
        var errs errext.ErrorSet
6✔
806
        if dbCommitment.State != db.CommitmentStateActive {
7✔
807
                errs.Addf("invalid state %q", dbCommitment.State)
1✔
808
        } else if now.After(dbCommitment.ExpiresAt) {
7✔
809
                errs.Addf("invalid state %q", db.CommitmentStateExpired)
1✔
810
        }
1✔
811
        if now.Before(dbCommitment.ExpiresAt.Add(-commitmentRenewalPeriod)) {
7✔
812
                errs.Addf("renewal attempt too early")
1✔
813
        }
1✔
814
        if dbCommitment.RenewContextJSON.IsSome() {
7✔
815
                errs.Addf("already renewed")
1✔
816
        }
1✔
817

818
        if !errs.IsEmpty() {
10✔
819
                msg := "cannot renew this commitment: " + errs.Join(", ")
4✔
820
                http.Error(w, msg, http.StatusConflict)
4✔
821
                return
4✔
822
        }
4✔
823

824
        // Create renewed commitment
825
        tx, err := p.DB.Begin()
2✔
826
        if respondwith.ErrorText(w, err) {
2✔
827
                return
×
828
        }
×
829
        defer sqlext.RollbackUnlessCommitted(tx)
2✔
830

2✔
831
        var (
2✔
832
                loc            core.AZResourceLocation
2✔
833
                totalConfirmed uint64
2✔
834
        )
2✔
835
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbProject.ID).
2✔
836
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
2✔
837
        if errors.Is(err, sql.ErrNoRows) {
2✔
838
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
839
                return
×
840
        } else if respondwith.ErrorText(w, err) {
2✔
841
                return
×
842
        }
×
843

844
        creationContext := db.CommitmentWorkflowContext{
2✔
845
                Reason:                 db.CommitmentReasonRenew,
2✔
846
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
2✔
847
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
2✔
848
        }
2✔
849
        buf, err := json.Marshal(creationContext)
2✔
850
        if respondwith.ErrorText(w, err) {
2✔
851
                return
×
852
        }
×
853
        dbRenewedCommitment := db.ProjectCommitment{
2✔
854
                UUID:                p.generateProjectCommitmentUUID(),
2✔
855
                ProjectID:           dbProject.ID,
2✔
856
                AZResourceID:        dbCommitment.AZResourceID,
2✔
857
                Amount:              dbCommitment.Amount,
2✔
858
                Duration:            dbCommitment.Duration,
2✔
859
                CreatedAt:           now,
2✔
860
                CreatorUUID:         token.UserUUID(),
2✔
861
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
2✔
862
                ConfirmBy:           Some(dbCommitment.ExpiresAt),
2✔
863
                ExpiresAt:           dbCommitment.Duration.AddTo(dbCommitment.ExpiresAt),
2✔
864
                State:               db.CommitmentStatePlanned,
2✔
865
                CreationContextJSON: json.RawMessage(buf),
2✔
866
        }
2✔
867

2✔
868
        err = tx.Insert(&dbRenewedCommitment)
2✔
869
        if respondwith.ErrorText(w, err) {
2✔
870
                return
×
871
        }
×
872

873
        renewContext := db.CommitmentWorkflowContext{
2✔
874
                Reason:                 db.CommitmentReasonRenew,
2✔
875
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbRenewedCommitment.ID},
2✔
876
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbRenewedCommitment.UUID},
2✔
877
        }
2✔
878
        buf, err = json.Marshal(renewContext)
2✔
879
        if respondwith.ErrorText(w, err) {
2✔
880
                return
×
881
        }
×
882
        dbCommitment.RenewContextJSON = Some(json.RawMessage(buf))
2✔
883
        _, err = tx.Update(&dbCommitment)
2✔
884
        if respondwith.ErrorText(w, err) {
2✔
885
                return
×
886
        }
×
887

888
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
889
        if respondwith.ErrorText(w, err) {
2✔
890
                return
×
891
        }
×
892
        serviceInfo, ok := maybeServiceInfo.Unpack()
2✔
893
        if !ok {
2✔
894
                http.Error(w, "service not found", http.StatusNotFound)
×
895
                return
×
896
        }
×
897

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

932
        err = tx.Commit()
2✔
933
        if respondwith.ErrorText(w, err) {
2✔
NEW
934
                return
×
NEW
935
        }
×
936

937
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
938

2✔
939
        // Create resultset and auditlogs
2✔
940
        c := p.convertCommitmentToDisplayForm(dbRenewedCommitment, loc, token, resourceInfo.Unit)
2✔
941
        auditEvent := commitmentEventTarget{
2✔
942
                DomainID:        dbDomain.UUID,
2✔
943
                DomainName:      dbDomain.Name,
2✔
944
                ProjectID:       dbProject.UUID,
2✔
945
                ProjectName:     dbProject.Name,
2✔
946
                Commitments:     []limesresources.Commitment{c},
2✔
947
                WorkflowContext: Some(creationContext),
2✔
948
        }
2✔
949

2✔
950
        p.auditor.Record(audittools.Event{
2✔
951
                Time:       p.timeNow(),
2✔
952
                Request:    r,
2✔
953
                User:       token,
2✔
954
                ReasonCode: http.StatusAccepted,
2✔
955
                Action:     cadf.UpdateAction,
2✔
956
                Target:     auditEvent,
2✔
957
        })
2✔
958

2✔
959
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
960
}
961

962
// DeleteProjectCommitment handles DELETE /v1/domains/:domain_id/projects/:project_id/commitments/:id.
963
func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Request) {
8✔
964
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id")
8✔
965
        token := p.CheckToken(r)
8✔
966
        if !token.Require(w, "project:edit") { // NOTE: There is a more specific AuthZ check further down below.
8✔
967
                return
×
968
        }
×
969
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
970
        if dbDomain == nil {
9✔
971
                return
1✔
972
        }
1✔
973
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
974
        if dbProject == nil {
8✔
975
                return
1✔
976
        }
1✔
977

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

1001
        // check authorization for this specific commitment
1002
        if !p.canDeleteCommitment(token, dbCommitment) {
6✔
1003
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
1004
                return
1✔
1005
        }
1✔
1006

1007
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
4✔
1008
        if respondwith.ErrorText(w, err) {
4✔
1009
                return
×
1010
        }
×
1011
        serviceInfo, ok := maybeServiceInfo.Unpack()
4✔
1012
        if !ok {
4✔
1013
                http.Error(w, "service not found", http.StatusNotFound)
×
1014
                return
×
1015
        }
×
1016

1017
        totalConfirmedAfter := totalConfirmed
4✔
1018
        if dbCommitment.State == db.CommitmentStateActive {
6✔
1019
                totalConfirmedAfter -= dbCommitment.Amount
2✔
1020
        }
2✔
1021

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

1054
        // perform deletion
1055
        _, err = p.DB.Delete(&dbCommitment)
4✔
1056
        if respondwith.ErrorText(w, err) {
4✔
NEW
1057
                return
×
NEW
1058
        }
×
1059

1060
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
4✔
1061
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
4✔
1062
        p.auditor.Record(audittools.Event{
4✔
1063
                Time:       p.timeNow(),
4✔
1064
                Request:    r,
4✔
1065
                User:       token,
4✔
1066
                ReasonCode: http.StatusNoContent,
4✔
1067
                Action:     cadf.DeleteAction,
4✔
1068
                Target: commitmentEventTarget{
4✔
1069
                        DomainID:    dbDomain.UUID,
4✔
1070
                        DomainName:  dbDomain.Name,
4✔
1071
                        ProjectID:   dbProject.UUID,
4✔
1072
                        ProjectName: dbProject.Name,
4✔
1073
                        Commitments: []limesresources.Commitment{c},
4✔
1074
                },
4✔
1075
        })
4✔
1076

4✔
1077
        w.WriteHeader(http.StatusNoContent)
4✔
1078
}
1079

1080
func (p *v1Provider) canDeleteCommitment(token *gopherpolicy.Token, commitment db.ProjectCommitment) bool {
64✔
1081
        // up to 24 hours after creation of fresh commitments, future commitments can still be deleted by their creators
64✔
1082
        if commitment.State == db.CommitmentStatePlanned || commitment.State == db.CommitmentStatePending || commitment.State == db.CommitmentStateActive {
128✔
1083
                var creationContext db.CommitmentWorkflowContext
64✔
1084
                err := json.Unmarshal(commitment.CreationContextJSON, &creationContext)
64✔
1085
                if err == nil && creationContext.Reason == db.CommitmentReasonCreate && p.timeNow().Before(commitment.CreatedAt.Add(24*time.Hour)) {
104✔
1086
                        if token.Check("project:edit") {
80✔
1087
                                return true
40✔
1088
                        }
40✔
1089
                }
1090
        }
1091

1092
        // afterwards, a more specific permission is required to delete it
1093
        //
1094
        // This protects cloud admins making capacity planning decisions based on future commitments
1095
        // from having their forecasts ruined by project admins suffering from buyer's remorse.
1096
        return token.Check("project:uncommit")
24✔
1097
}
1098

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

8✔
1126
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
1127
                http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest)
×
1128
                return
×
1129
        }
×
1130

1131
        if req.Amount <= 0 {
9✔
1132
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
1133
                return
1✔
1134
        }
1✔
1135

1136
        // load commitment
1137
        var dbCommitment db.ProjectCommitment
7✔
1138
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
7✔
1139
        if errors.Is(err, sql.ErrNoRows) {
7✔
1140
                http.Error(w, "no such commitment", http.StatusNotFound)
×
1141
                return
×
1142
        } else if respondwith.ErrorText(w, err) {
7✔
1143
                return
×
1144
        }
×
1145

1146
        // Deny requests with a greater amount than the commitment.
1147
        if req.Amount > dbCommitment.Amount {
8✔
1148
                http.Error(w, "delivered amount exceeds the commitment amount.", http.StatusBadRequest)
1✔
1149
                return
1✔
1150
        }
1✔
1151

1152
        var (
6✔
1153
                loc            core.AZResourceLocation
6✔
1154
                totalConfirmed uint64
6✔
1155
        )
6✔
1156
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbProject.ID).
6✔
1157
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
6✔
1158
        if errors.Is(err, sql.ErrNoRows) {
6✔
NEW
1159
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
NEW
1160
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
NEW
1161
                return
×
1162
        } else if respondwith.ErrorText(w, err) {
6✔
NEW
1163
                return
×
NEW
1164
        }
×
1165

1166
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
6✔
1167
        if respondwith.ErrorText(w, err) {
6✔
NEW
1168
                return
×
NEW
1169
        }
×
1170
        serviceInfo, ok := maybeServiceInfo.Unpack()
6✔
1171
        if !ok {
6✔
NEW
1172
                http.Error(w, "service not found", http.StatusNotFound)
×
NEW
1173
                return
×
NEW
1174
        }
×
1175

1176
        // Mark whole commitment or a newly created, splitted one as transferrable.
1177
        tx, err := p.DB.Begin()
6✔
1178
        if respondwith.ErrorText(w, err) {
6✔
1179
                return
×
1180
        }
×
1181
        defer sqlext.RollbackUnlessCommitted(tx)
6✔
1182
        transferToken := p.generateTransferToken()
6✔
1183

6✔
1184
        if req.Amount == dbCommitment.Amount {
10✔
1185
                dbCommitment.TransferStatus = req.TransferStatus
4✔
1186
                dbCommitment.TransferToken = Some(transferToken)
4✔
1187
                _, err = tx.Update(&dbCommitment)
4✔
1188
                if respondwith.ErrorText(w, err) {
4✔
1189
                        return
×
1190
                }
×
1191
        } else {
2✔
1192
                now := p.timeNow()
2✔
1193
                transferAmount := req.Amount
2✔
1194
                remainingAmount := dbCommitment.Amount - req.Amount
2✔
1195
                transferCommitment, err := p.buildSplitCommitment(dbCommitment, transferAmount)
2✔
1196
                if respondwith.ErrorText(w, err) {
2✔
1197
                        return
×
1198
                }
×
1199
                transferCommitment.TransferStatus = req.TransferStatus
2✔
1200
                transferCommitment.TransferToken = Some(transferToken)
2✔
1201
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
2✔
1202
                if respondwith.ErrorText(w, err) {
2✔
1203
                        return
×
1204
                }
×
1205
                err = tx.Insert(&transferCommitment)
2✔
1206
                if respondwith.ErrorText(w, err) {
2✔
1207
                        return
×
1208
                }
×
1209
                err = tx.Insert(&remainingCommitment)
2✔
1210
                if respondwith.ErrorText(w, err) {
2✔
1211
                        return
×
1212
                }
×
1213

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

1264
                supersedeContext := db.CommitmentWorkflowContext{
2✔
1265
                        Reason:                 db.CommitmentReasonSplit,
2✔
1266
                        RelatedCommitmentIDs:   []db.ProjectCommitmentID{transferCommitment.ID, remainingCommitment.ID},
2✔
1267
                        RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{transferCommitment.UUID, remainingCommitment.UUID},
2✔
1268
                }
2✔
1269
                buf, err := json.Marshal(supersedeContext)
2✔
1270
                if respondwith.ErrorText(w, err) {
2✔
1271
                        return
×
1272
                }
×
1273
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
1274
                dbCommitment.SupersededAt = Some(now)
2✔
1275
                dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
1276
                _, err = tx.Update(&dbCommitment)
2✔
1277
                if respondwith.ErrorText(w, err) {
2✔
1278
                        return
×
1279
                }
×
1280

1281
                dbCommitment = transferCommitment
2✔
1282
        }
1283
        err = tx.Commit()
6✔
1284
        if respondwith.ErrorText(w, err) {
6✔
1285
                return
×
1286
        }
×
1287

1288
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
6✔
1289
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
6✔
1290
        if respondwith.ErrorText(w, err) {
6✔
1291
                return
×
1292
        }
×
1293
        p.auditor.Record(audittools.Event{
6✔
1294
                Time:       p.timeNow(),
6✔
1295
                Request:    r,
6✔
1296
                User:       token,
6✔
1297
                ReasonCode: http.StatusAccepted,
6✔
1298
                Action:     cadf.UpdateAction,
6✔
1299
                Target: commitmentEventTarget{
6✔
1300
                        DomainID:    dbDomain.UUID,
6✔
1301
                        DomainName:  dbDomain.Name,
6✔
1302
                        ProjectID:   dbProject.UUID,
6✔
1303
                        ProjectName: dbProject.Name,
6✔
1304
                        Commitments: []limesresources.Commitment{c},
6✔
1305
                },
6✔
1306
        })
6✔
1307
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
6✔
1308
}
1309

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

1338
func (p *v1Provider) buildConvertedCommitment(dbCommitment db.ProjectCommitment, azResourceID db.AZResourceID, amount uint64) (db.ProjectCommitment, error) {
3✔
1339
        now := p.timeNow()
3✔
1340
        creationContext := db.CommitmentWorkflowContext{
3✔
1341
                Reason:                 db.CommitmentReasonConvert,
3✔
1342
                RelatedCommitmentIDs:   []db.ProjectCommitmentID{dbCommitment.ID},
3✔
1343
                RelatedCommitmentUUIDs: []db.ProjectCommitmentUUID{dbCommitment.UUID},
3✔
1344
        }
3✔
1345
        buf, err := json.Marshal(creationContext)
3✔
1346
        if err != nil {
3✔
1347
                return db.ProjectCommitment{}, err
×
1348
        }
×
1349
        return db.ProjectCommitment{
3✔
1350
                UUID:                p.generateProjectCommitmentUUID(),
3✔
1351
                ProjectID:           dbCommitment.ProjectID,
3✔
1352
                AZResourceID:        azResourceID,
3✔
1353
                Amount:              amount,
3✔
1354
                Duration:            dbCommitment.Duration,
3✔
1355
                CreatedAt:           now,
3✔
1356
                CreatorUUID:         dbCommitment.CreatorUUID,
3✔
1357
                CreatorName:         dbCommitment.CreatorName,
3✔
1358
                ConfirmBy:           dbCommitment.ConfirmBy,
3✔
1359
                ConfirmedAt:         dbCommitment.ConfirmedAt,
3✔
1360
                ExpiresAt:           dbCommitment.ExpiresAt,
3✔
1361
                CreationContextJSON: json.RawMessage(buf),
3✔
1362
                State:               dbCommitment.State,
3✔
1363
        }, nil
3✔
1364
}
1365

1366
// GetCommitmentByTransferToken handles GET /v1/commitments/{token}
1367
func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http.Request) {
2✔
1368
        httpapi.IdentifyEndpoint(r, "/v1/commitments/:token")
2✔
1369
        token := p.CheckToken(r)
2✔
1370
        if !token.Require(w, "cluster:show_basic") {
2✔
1371
                return
×
1372
        }
×
1373
        transferToken := mux.Vars(r)["token"]
2✔
1374

2✔
1375
        // The token column is a unique key, so we expect only one result.
2✔
1376
        var dbCommitment db.ProjectCommitment
2✔
1377
        err := p.DB.SelectOne(&dbCommitment, findCommitmentByTransferToken, transferToken)
2✔
1378
        if errors.Is(err, sql.ErrNoRows) {
3✔
1379
                http.Error(w, "no matching commitment found.", http.StatusNotFound)
1✔
1380
                return
1✔
1381
        } else if respondwith.ErrorText(w, err) {
2✔
1382
                return
×
1383
        }
×
1384

1385
        var (
1✔
1386
                loc            core.AZResourceLocation
1✔
1387
                totalConfirmed uint64
1✔
1388
        )
1✔
1389
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbCommitment.ProjectID).
1✔
1390
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &totalConfirmed)
1✔
1391
        if errors.Is(err, sql.ErrNoRows) {
1✔
1392
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1393
                http.Error(w, "location data not found.", http.StatusNotFound)
×
1394
                return
×
1395
        } else if respondwith.ErrorText(w, err) {
1✔
1396
                return
×
1397
        }
×
1398

1399
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
1400
        if respondwith.ErrorText(w, err) {
1✔
1401
                return
×
1402
        }
×
1403
        serviceInfo, ok := maybeServiceInfo.Unpack()
1✔
1404
        if !ok {
1✔
1405
                http.Error(w, "service not found", http.StatusNotFound)
×
1406
                return
×
1407
        }
×
1408
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
1409
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
1✔
1410
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
1411
}
1412

1413
// TransferCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}?token={token}
1414
func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request) {
5✔
1415
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/transfer-commitment/:id")
5✔
1416
        token := p.CheckToken(r)
5✔
1417
        if !token.Require(w, "project:edit") {
5✔
1418
                return
×
1419
        }
×
1420
        transferToken := r.Header.Get("Transfer-Token")
5✔
1421
        if transferToken == "" {
6✔
1422
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
1✔
1423
                return
1✔
1424
        }
1✔
1425
        commitmentID := mux.Vars(r)["id"]
4✔
1426
        if commitmentID == "" {
4✔
1427
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1428
                return
×
1429
        }
×
1430
        targetDomain := p.FindDomainFromRequest(w, r)
4✔
1431
        if targetDomain == nil {
4✔
1432
                return
×
1433
        }
×
1434
        targetProject := p.FindProjectFromRequest(w, r, targetDomain)
4✔
1435
        if targetProject == nil {
4✔
1436
                return
×
1437
        }
×
1438

1439
        // find commitment by transfer_token
1440
        var dbCommitment db.ProjectCommitment
4✔
1441
        err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, commitmentID, transferToken)
4✔
1442
        if errors.Is(err, sql.ErrNoRows) {
5✔
1443
                http.Error(w, "no matching commitment found", http.StatusNotFound)
1✔
1444
                return
1✔
1445
        } else if respondwith.ErrorText(w, err) {
4✔
1446
                return
×
1447
        }
×
1448

1449
        var (
3✔
1450
                loc                  core.AZResourceLocation
3✔
1451
                sourceTotalConfirmed uint64
3✔
1452
        )
3✔
1453
        err = p.DB.QueryRow(findAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbCommitment.ProjectID).
3✔
1454
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &sourceTotalConfirmed)
3✔
1455

3✔
1456
        if errors.Is(err, sql.ErrNoRows) {
3✔
1457
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1458
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1459
                return
×
1460
        } else if respondwith.ErrorText(w, err) {
3✔
1461
                return
×
1462
        }
×
1463

1464
        // get old project additionally
1465
        var sourceProject db.Project
3✔
1466
        err = p.DB.SelectOne(&sourceProject, `SELECT * FROM projects WHERE id = $1`, dbCommitment.ProjectID)
3✔
1467
        if respondwith.ErrorText(w, err) {
3✔
NEW
1468
                return
×
NEW
1469
        }
×
1470
        var sourceDomain db.Domain
3✔
1471
        err = p.DB.SelectOne(&sourceDomain, `SELECT * FROM domains WHERE id = $1`, sourceProject.DomainID)
3✔
1472
        if respondwith.ErrorText(w, err) {
3✔
NEW
1473
                return
×
NEW
1474
        }
×
1475

1476
        // check that the target project allows commitments at all
1477
        var (
3✔
1478
                azResourceID              db.AZResourceID
3✔
1479
                resourceAllowsCommitments bool
3✔
1480
                targetTotalConfirmed      uint64
3✔
1481
        )
3✔
1482
        err = p.DB.QueryRow(findAZResourceIDByLocationQuery, targetProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
3✔
1483
                Scan(&azResourceID, &resourceAllowsCommitments, &targetTotalConfirmed)
3✔
1484
        if respondwith.ErrorText(w, err) {
3✔
1485
                return
×
1486
        }
×
1487
        if !resourceAllowsCommitments {
3✔
1488
                msg := fmt.Sprintf("resource %s/%s is not enabled in the target project", loc.ServiceType, loc.ResourceName)
×
1489
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1490
                return
×
1491
        }
×
1492
        _ = azResourceID // returned by the above query, but not used in this function
3✔
1493

3✔
1494
        // validate that we have enough committable capacity on the receiving side
3✔
1495
        tx, err := p.DB.Begin()
3✔
1496
        if respondwith.ErrorText(w, err) {
3✔
1497
                return
×
1498
        }
×
1499
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1500

3✔
1501
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
3✔
1502
        if respondwith.ErrorText(w, err) {
3✔
1503
                return
×
1504
        }
×
1505
        serviceInfo, ok := maybeServiceInfo.Unpack()
3✔
1506
        if !ok {
3✔
NEW
1507
                http.Error(w, "service not found", http.StatusNotFound)
×
NEW
1508
                return
×
NEW
1509
        }
×
1510

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

1571
        dbCommitment.TransferStatus = ""
2✔
1572
        dbCommitment.TransferToken = None[string]()
2✔
1573
        dbCommitment.ProjectID = targetProject.ID
2✔
1574
        _, err = tx.Update(&dbCommitment)
2✔
1575
        if respondwith.ErrorText(w, err) {
2✔
1576
                return
×
1577
        }
×
1578
        err = tx.Commit()
2✔
1579
        if respondwith.ErrorText(w, err) {
2✔
1580
                return
×
1581
        }
×
1582

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

2✔
1600
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1601
}
1602

1603
// GetCommitmentConversion handles GET /v1/commitment-conversion/{service_type}/{resource_name}
1604
func (p *v1Provider) GetCommitmentConversions(w http.ResponseWriter, r *http.Request) {
3✔
1605
        httpapi.IdentifyEndpoint(r, "/v1/commitment-conversion/:service_type/:resource_name")
3✔
1606
        token := p.CheckToken(r)
3✔
1607
        if !token.Require(w, "cluster:show_basic") {
3✔
1608
                return
×
1609
        }
×
1610

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

1621
        // validate request
1622
        vars := mux.Vars(r)
3✔
1623
        serviceInfos, err := p.Cluster.AllServiceInfos()
3✔
1624
        if respondwith.ErrorText(w, err) {
3✔
1625
                return
×
1626
        }
×
1627

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

3✔
1640
        serviceInfo := core.InfoForService(serviceInfos, sourceServiceType)
3✔
1641
        sourceResInfo := core.InfoForResource(serviceInfo, sourceResourceName)
3✔
1642

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

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

1671
        // use a defined sorting to ensure deterministic behavior in tests
1672
        slices.SortFunc(conversions, func(lhs, rhs limesresources.CommitmentConversionRule) int {
4✔
1673
                result := strings.Compare(string(lhs.TargetService), string(rhs.TargetService))
1✔
1674
                if result != 0 {
1✔
UNCOV
1675
                        return result
×
UNCOV
1676
                }
×
1677
                return strings.Compare(string(lhs.TargetResource), string(rhs.TargetResource))
1✔
1678
        })
1679

1680
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
3✔
1681
}
1682

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

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

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

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

1788
        tx, err := p.DB.Begin()
3✔
1789
        if respondwith.ErrorText(w, err) {
3✔
1790
                return
×
1791
        }
×
1792
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1793

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

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

1854
        sourceTotalConfirmedAfter := sourceTotalConfirmed
3✔
1855
        targetTotalConfirmedAfter := targetTotalConfirmed
3✔
1856
        if dbCommitment.ConfirmedAt.IsSome() {
5✔
1857
                sourceTotalConfirmedAfter -= req.SourceAmount
2✔
1858
                targetTotalConfirmedAfter += req.TargetAmount
2✔
1859
        }
2✔
1860

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

3✔
1898
        // only check acceptance by liquid when old commitment was confirmed, unconfirmed commitments can be moved without acceptance
3✔
1899
        if dbCommitment.ConfirmedAt.IsSome() {
5✔
1900
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
1901
                        return
×
1902
                }
×
1903
                if commitmentChangeResponse.RejectionReason != "" {
3✔
1904
                        EvaluateRetryHeader(commitmentChangeResponse, w)
1✔
1905
                        http.Error(w, "not enough capacity to confirm the commitment", http.StatusUnprocessableEntity)
1✔
1906
                        return
1✔
1907
                }
1✔
1908
        }
1909

1910
        auditEvent := commitmentEventTarget{
2✔
1911
                DomainID:    dbDomain.UUID,
2✔
1912
                DomainName:  dbDomain.Name,
2✔
1913
                ProjectID:   dbProject.UUID,
2✔
1914
                ProjectName: dbProject.Name,
2✔
1915
        }
2✔
1916

2✔
1917
        var (
2✔
1918
                relatedCommitmentIDs   []db.ProjectCommitmentID
2✔
1919
                relatedCommitmentUUIDs []db.ProjectCommitmentUUID
2✔
1920
        )
2✔
1921
        resourceInfo := core.InfoForResource(serviceInfo, sourceLoc.ResourceName)
2✔
1922
        if remainingAmount > 0 {
3✔
1923
                relatedCommitmentIDs = append(relatedCommitmentIDs, remainingCommitment.ID)
1✔
1924
                relatedCommitmentUUIDs = append(relatedCommitmentUUIDs, remainingCommitment.UUID)
1✔
1925
                err = tx.Insert(&remainingCommitment)
1✔
1926
                if respondwith.ErrorText(w, err) {
1✔
1927
                        return
×
1928
                }
×
1929
                auditEvent.Commitments = append(auditEvent.Commitments,
1✔
1930
                        p.convertCommitmentToDisplayForm(remainingCommitment, sourceLoc, token, resourceInfo.Unit),
1✔
1931
                )
1✔
1932
        }
1933

1934
        relatedCommitmentIDs = append(relatedCommitmentIDs, convertedCommitment.ID)
2✔
1935
        relatedCommitmentUUIDs = append(relatedCommitmentUUIDs, convertedCommitment.UUID)
2✔
1936
        err = tx.Insert(&convertedCommitment)
2✔
1937
        if respondwith.ErrorText(w, err) {
2✔
1938
                return
×
1939
        }
×
1940

1941
        // supersede the original commitment
1942
        now := p.timeNow()
2✔
1943
        supersedeContext := db.CommitmentWorkflowContext{
2✔
1944
                Reason:                 db.CommitmentReasonConvert,
2✔
1945
                RelatedCommitmentIDs:   relatedCommitmentIDs,
2✔
1946
                RelatedCommitmentUUIDs: relatedCommitmentUUIDs,
2✔
1947
        }
2✔
1948
        buf, err := json.Marshal(supersedeContext)
2✔
1949
        if respondwith.ErrorText(w, err) {
2✔
1950
                return
×
1951
        }
×
1952
        dbCommitment.State = db.CommitmentStateSuperseded
2✔
1953
        dbCommitment.SupersededAt = Some(now)
2✔
1954
        dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
1955
        _, err = tx.Update(&dbCommitment)
2✔
1956
        if respondwith.ErrorText(w, err) {
2✔
1957
                return
×
1958
        }
×
1959

1960
        err = tx.Commit()
2✔
1961
        if respondwith.ErrorText(w, err) {
2✔
1962
                return
×
1963
        }
×
1964

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

2✔
1981
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1982
}
1983

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

2012
        var dbCommitment db.ProjectCommitment
7✔
2013
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
7✔
2014
        if errors.Is(err, sql.ErrNoRows) {
7✔
2015
                http.Error(w, "no such commitment", http.StatusNotFound)
×
2016
                return
×
2017
        } else if respondwith.ErrorText(w, err) {
7✔
2018
                return
×
2019
        }
×
2020

2021
        now := p.timeNow()
7✔
2022
        if dbCommitment.ExpiresAt.Before(now) || dbCommitment.ExpiresAt.Equal(now) {
8✔
2023
                http.Error(w, "unable to process expired commitment", http.StatusForbidden)
1✔
2024
                return
1✔
2025
        }
1✔
2026

2027
        if dbCommitment.State == db.CommitmentStateSuperseded {
7✔
2028
                msg := fmt.Sprintf("unable to operate on commitment with a state of %s", dbCommitment.State)
1✔
2029
                http.Error(w, msg, http.StatusForbidden)
1✔
2030
                return
1✔
2031
        }
1✔
2032

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

2053
        newExpiresAt := req.Duration.AddTo(dbCommitment.ConfirmBy.UnwrapOr(dbCommitment.CreatedAt))
4✔
2054
        if newExpiresAt.Before(dbCommitment.ExpiresAt) {
5✔
2055
                msg := fmt.Sprintf("duration change from %s to %s forbidden", dbCommitment.Duration, req.Duration)
1✔
2056
                http.Error(w, msg, http.StatusForbidden)
1✔
2057
                return
1✔
2058
        }
1✔
2059
        dbCommitment.Duration = req.Duration
3✔
2060
        dbCommitment.ExpiresAt = newExpiresAt
3✔
2061

3✔
2062
        maybeServiceInfo, err := p.Cluster.InfoForService(loc.ServiceType)
3✔
2063
        if respondwith.ErrorText(w, err) {
3✔
2064
                return
×
2065
        }
×
2066
        serviceInfo, ok := maybeServiceInfo.Unpack()
3✔
2067
        if !ok {
3✔
2068
                http.Error(w, "service not found", http.StatusNotFound)
×
2069
                return
×
2070
        }
×
2071

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

2105
        if commitmentChangeResponse.RejectionReason != "" {
4✔
2106
                EvaluateRetryHeader(commitmentChangeResponse, w)
1✔
2107
                http.Error(w, commitmentChangeResponse.RejectionReason, http.StatusConflict)
1✔
2108
                return
1✔
2109
        }
1✔
2110

2111
        _, err = p.DB.Update(&dbCommitment)
2✔
2112
        if respondwith.ErrorText(w, err) {
2✔
NEW
2113
                return
×
NEW
2114
        }
×
2115

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

2✔
2133
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitment": c})
2✔
2134
}
2135

2136
// DelegateCommitmentCheck decides whether LiquidClient.ChangeCommitments() should be called,
2137
// depending on the setting of liquid.ResourceInfo.HandlesCommitments. If not, it routes the
2138
// operation to be performed locally on the database. In case the LiquidConnection is not filled,
2139
// a LiquidClient is instantiated on the fly to perform the operation. It utilizes a given ServiceInfo so that no
2140
// double retrieval is necessary caused by operations to assemble the liquid.CommitmentChange.
2141
func (p *v1Provider) DelegateChangeCommitments(ctx context.Context, req liquid.CommitmentChangeRequest, serviceType db.ServiceType, serviceInfo liquid.ServiceInfo, dbi db.Interface) (result liquid.CommitmentChangeResponse, err error) {
48✔
2142
        localCommitmentChanges := liquid.CommitmentChangeRequest{
48✔
2143
                DryRun:      req.DryRun,
48✔
2144
                AZ:          req.AZ,
48✔
2145
                InfoVersion: req.InfoVersion,
48✔
2146
                ByProject:   make(map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset),
48✔
2147
        }
48✔
2148
        remoteCommitmentChanges := liquid.CommitmentChangeRequest{
48✔
2149
                DryRun:      req.DryRun,
48✔
2150
                AZ:          req.AZ,
48✔
2151
                InfoVersion: req.InfoVersion,
48✔
2152
                ByProject:   make(map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset),
48✔
2153
        }
48✔
2154
        for projectUUID, projectCommitmentChangeset := range req.ByProject {
99✔
2155
                for resourceName, resourceCommitmentChangeset := range projectCommitmentChangeset.ByResource {
105✔
2156
                        // this is just to make the tests deterministic because time.Local != local IANA time (after parsing)
54✔
2157
                        for i, commitment := range resourceCommitmentChangeset.Commitments {
115✔
2158
                                commitment.ExpiresAt = commitment.ExpiresAt.Local()
61✔
2159
                                confirmBy, exists := commitment.ConfirmBy.Unpack()
61✔
2160
                                if exists {
76✔
2161
                                        commitment.ConfirmBy = Some(confirmBy.Local())
15✔
2162
                                }
15✔
2163
                                resourceCommitmentChangeset.Commitments[i] = commitment
61✔
2164
                        }
2165

2166
                        if serviceInfo.Resources[resourceName].HandlesCommitments {
108✔
2167
                                _, exists := remoteCommitmentChanges.ByProject[projectUUID]
54✔
2168
                                if !exists {
105✔
2169
                                        remoteCommitmentChanges.ByProject[projectUUID] = liquid.ProjectCommitmentChangeset{
51✔
2170
                                                ByResource: make(map[liquid.ResourceName]liquid.ResourceCommitmentChangeset),
51✔
2171
                                        }
51✔
2172
                                }
51✔
2173
                                remoteCommitmentChanges.ByProject[projectUUID].ByResource[resourceName] = resourceCommitmentChangeset
54✔
2174
                                continue
54✔
2175
                        }
NEW
2176
                        _, exists := localCommitmentChanges.ByProject[projectUUID]
×
NEW
2177
                        if !exists {
×
NEW
2178
                                localCommitmentChanges.ByProject[projectUUID] = liquid.ProjectCommitmentChangeset{
×
NEW
2179
                                        ByResource: make(map[liquid.ResourceName]liquid.ResourceCommitmentChangeset),
×
NEW
2180
                                }
×
NEW
2181
                        }
×
NEW
2182
                        localCommitmentChanges.ByProject[projectUUID].ByResource[resourceName] = resourceCommitmentChangeset
×
2183
                }
2184
        }
2185
        for projectUUID, projectCommitmentChangeset := range localCommitmentChanges.ByProject {
48✔
NEW
2186
                if serviceInfo.CommitmentHandlingNeedsProjectMetadata {
×
NEW
2187
                        pcs := projectCommitmentChangeset
×
NEW
2188
                        pcs.ProjectMetadata = req.ByProject[projectUUID].ProjectMetadata
×
NEW
2189
                        localCommitmentChanges.ByProject[projectUUID] = pcs
×
NEW
2190
                }
×
2191
        }
2192
        for projectUUID, remoteCommitmentChangeset := range remoteCommitmentChanges.ByProject {
99✔
2193
                if serviceInfo.CommitmentHandlingNeedsProjectMetadata {
51✔
NEW
2194
                        rcs := remoteCommitmentChangeset
×
NEW
2195
                        rcs.ProjectMetadata = req.ByProject[projectUUID].ProjectMetadata
×
NEW
2196
                        remoteCommitmentChanges.ByProject[projectUUID] = rcs
×
NEW
2197
                }
×
2198
        }
2199

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

2221
        // check local
2222
        canAcceptLocally, err := datamodel.CanAcceptCommitmentChangeRequest(localCommitmentChanges, serviceType, p.Cluster, dbi)
43✔
2223
        if err != nil {
43✔
NEW
2224
                return result, fmt.Errorf("failed to check local ChangeCommitment: %w", err)
×
NEW
2225
        }
×
2226
        if !canAcceptLocally {
43✔
NEW
2227
                return liquid.CommitmentChangeResponse{
×
NEW
2228
                        RejectionReason: "not enough capacity!",
×
NEW
2229
                        RetryAt:         None[time.Time](),
×
NEW
2230
                }, nil
×
NEW
2231
        }
×
2232

2233
        return result, nil
43✔
2234
}
2235

2236
func LiquidProjectMetadataFromDBProject(dbProject db.Project, domain db.Domain, serviceInfo liquid.ServiceInfo) Option[liquid.ProjectMetadata] {
51✔
2237
        if !serviceInfo.CommitmentHandlingNeedsProjectMetadata {
102✔
2238
                return None[liquid.ProjectMetadata]()
51✔
2239
        }
51✔
NEW
2240
        return Some(liquid.ProjectMetadata{
×
NEW
2241
                UUID: string(dbProject.UUID),
×
NEW
2242
                Name: dbProject.Name,
×
NEW
2243
                Domain: liquid.DomainMetadata{
×
NEW
2244
                        UUID: domain.UUID,
×
NEW
2245
                        Name: domain.Name,
×
NEW
2246
                },
×
NEW
2247
        })
×
2248
}
2249

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

© 2026 Coveralls, Inc