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

sapcc / limes / 16829928560

08 Aug 2025 12:03PM UTC coverage: 79.583% (+0.7%) from 78.924%
16829928560

Pull #756

github

wagnerd3
update commitment test data for path
Pull Request #756: delegate commitment acceptance

658 of 762 new or added lines in 8 files covered. (86.35%)

3 existing lines in 1 file now uncovered.

7363 of 9252 relevant lines covered (79.58%)

57.26 hits per line

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

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

4
package api
5

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

19
        "github.com/gorilla/mux"
20
        . "github.com/majewsky/gg/option"
21
        "github.com/majewsky/gg/options"
22
        "github.com/sapcc/go-api-declarations/cadf"
23
        "github.com/sapcc/go-api-declarations/limes"
24
        limesresources "github.com/sapcc/go-api-declarations/limes/resources"
25
        "github.com/sapcc/go-api-declarations/liquid"
26
        "github.com/sapcc/go-bits/audittools"
27
        "github.com/sapcc/go-bits/errext"
28
        "github.com/sapcc/go-bits/gopherpolicy"
29
        "github.com/sapcc/go-bits/httpapi"
30
        "github.com/sapcc/go-bits/liquidapi"
31
        "github.com/sapcc/go-bits/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 cluster_az_resources cazr ON pc.az_resource_id = cazr.id
46
                  JOIN cluster_resources cr ON cazr.resource_id = cr.id {{AND cr.name = $resource_name}}
47
                  JOIN cluster_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
        getClusterAZResourceLocationsQuery = sqlext.SimplifyWhitespace(`
53
                SELECT cazr.id, cs.type, cr.name, cazr.az
54
                  FROM project_az_resources pazr
55
                  JOIN cluster_az_resources cazr on pazr.az_resource_id = cazr.id
56
                  JOIN cluster_resources cr ON cazr.resource_id = cr.id {{AND cr.name = $resource_name}}
57
                  JOIN cluster_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
        findClusterAZResourceIDByLocationQuery = 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 cluster_az_resources cazr
70
                JOIN cluster_resources cr ON cazr.resource_id = cr.id
71
                JOIN cluster_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 cluster_az_resources cazr
76
                        JOIN cluster_resources cr ON cazr.resource_id = cr.id
77
                        JOIN cluster_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
        findClusterAZResourceLocationByIDQuery = sqlext.SimplifyWhitespace(fmt.Sprintf(`
85
                SELECT cs.type, cr.name, cazr.az, COALESCE(pc.total_confirmed,0) AS total_confirmed
86
                FROM cluster_az_resources cazr
87
                JOIN cluster_resources cr ON cazr.resource_id = cr.id
88
                JOIN cluster_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(getClusterAZResourceLocationsQuery)
12✔
127
        whereStr, whereArgs := db.BuildSimpleWhereClause(map[string]any{"pazr.project_id": dbProject.ID}, len(joinArgs))
12✔
128
        azResourceLocationsByID := make(map[db.ClusterAZResourceID]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.ClusterAZResourceID
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 {
92✔
177
        var status = liquid.CommitmentStatus(state)
92✔
178
        if state == "active" {
151✔
179
                status = liquid.CommitmentStatusConfirmed
59✔
180
        }
59✔
181
        if status.IsValid() {
183✔
182
                return status
91✔
183
        }
91✔
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.ClusterAZResourceID
7✔
297
                resourceAllowsCommitments bool
7✔
298
                totalConfirmed            uint64
7✔
299
        )
7✔
300
        err := p.DB.QueryRow(findClusterAZResourceIDByLocationQuery, 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
        // TODO: For this to work, we might need an indicator for the API that this request is a simulation?
334
        commitmentChangeResponse, err := p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
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
                                                                // TODO: how to handle UUID when "simulating"?
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
                if retryAt, exists := commitmentChangeResponse.RetryAt.Unpack(); exists {
2✔
NEW
368
                        w.Header().Set("Retry-After", retryAt.Format(time.RFC1123))
×
NEW
369
                }
×
370
                result = false
2✔
371
        }
372
        respondwith.JSON(w, http.StatusOK, map[string]bool{"result": result})
6✔
373
}
374

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

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

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

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

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

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

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

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

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

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

556
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": commitment})
24✔
557
}
558

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

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

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

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

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

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

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

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

1✔
670
        // Insert into database
1✔
671
        err = tx.Insert(&dbMergedCommitment)
1✔
672
        if respondwith.ErrorText(w, err) {
1✔
673
                return
×
674
        }
×
675

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

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

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

750
        err = tx.Commit()
1✔
751
        if respondwith.ErrorText(w, err) {
1✔
NEW
752
                return
×
NEW
753
        }
×
754

755
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
1✔
756

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

1✔
775
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
776
}
777

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

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

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

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

822
        if !errs.IsEmpty() {
10✔
823
                msg := "cannot renew this commitment: " + errs.Join(", ")
4✔
824
                http.Error(w, msg, http.StatusConflict)
4✔
825
                return
4✔
826
        }
4✔
827

828
        // Create renewed commitment
829
        tx, err := p.DB.Begin()
2✔
830
        if respondwith.ErrorText(w, err) {
2✔
831
                return
×
832
        }
×
833
        defer sqlext.RollbackUnlessCommitted(tx)
2✔
834

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

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

2✔
872
        err = tx.Insert(&dbRenewedCommitment)
2✔
873
        if respondwith.ErrorText(w, err) {
2✔
874
                return
×
875
        }
×
876

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

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

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

936
        err = tx.Commit()
2✔
937
        if respondwith.ErrorText(w, err) {
2✔
NEW
938
                return
×
NEW
939
        }
×
940

941
        resourceInfo := core.InfoForResource(serviceInfo, loc.ResourceName)
2✔
942

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

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

2✔
963
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
964
}
965

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

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

1005
        // check authorization for this specific commitment
1006
        if !p.canDeleteCommitment(token, dbCommitment) {
6✔
1007
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
1008
                return
1✔
1009
        }
1✔
1010

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

1021
        totalConfirmedAfter := totalConfirmed
4✔
1022
        if dbCommitment.State == db.CommitmentStateActive {
6✔
1023
                totalConfirmedAfter -= dbCommitment.Amount
2✔
1024
        }
2✔
1025

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

1058
        // perform deletion
1059
        _, err = p.DB.Delete(&dbCommitment)
4✔
1060
        if respondwith.ErrorText(w, err) {
4✔
NEW
1061
                return
×
NEW
1062
        }
×
1063

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

4✔
1081
        w.WriteHeader(http.StatusNoContent)
4✔
1082
}
1083

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

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

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

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

1135
        if req.Amount <= 0 {
9✔
1136
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
1137
                return
1✔
1138
        }
1✔
1139

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

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

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

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

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

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

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

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

1285
                dbCommitment = transferCommitment
2✔
1286
        }
1287
        err = tx.Commit()
6✔
1288
        if respondwith.ErrorText(w, err) {
6✔
1289
                return
×
1290
        }
×
1291

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

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

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

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

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

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

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

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

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

1453
        var (
3✔
1454
                loc                  core.AZResourceLocation
3✔
1455
                sourceTotalConfirmed uint64
3✔
1456
        )
3✔
1457
        err = p.DB.QueryRow(findClusterAZResourceLocationByIDQuery, dbCommitment.AZResourceID, dbCommitment.ProjectID).
3✔
1458
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone, &sourceTotalConfirmed)
3✔
1459

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

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

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

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

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

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

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

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

2✔
1603
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1604
}
1605

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

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

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

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

3✔
1643
        serviceInfo := core.InfoForService(serviceInfos, sourceServiceType)
3✔
1644
        sourceResInfo := core.InfoForResource(serviceInfo, sourceResourceName)
3✔
1645

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

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

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

1683
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
3✔
1684
}
1685

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2074
        // TODO: Should it be possible for the liquid to refuse this case?
2075
        _, err = p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
2✔
2076
                AZ:          loc.AvailabilityZone,
2✔
2077
                InfoVersion: serviceInfo.Version,
2✔
2078
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
2✔
2079
                        dbProject.UUID: {
2✔
2080
                                ProjectMetadata: LiquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
2✔
2081
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
2✔
2082
                                        loc.ResourceName: {
2✔
2083
                                                TotalConfirmedBefore: totalConfirmed,
2✔
2084
                                                TotalConfirmedAfter:  totalConfirmed,
2✔
2085
                                                // TODO: change when introducing "guaranteed" commitments
2✔
2086
                                                TotalGuaranteedBefore: 0,
2✔
2087
                                                TotalGuaranteedAfter:  0,
2✔
2088
                                                Commitments: []liquid.Commitment{
2✔
2089
                                                        {
2✔
2090
                                                                UUID:      string(dbCommitment.UUID),
2✔
2091
                                                                OldStatus: Some(p.convertCommitmentStateToDisplayForm(dbCommitment.State)),
2✔
2092
                                                                NewStatus: Some(p.convertCommitmentStateToDisplayForm(dbCommitment.State)),
2✔
2093
                                                                Amount:    dbCommitment.Amount,
2✔
2094
                                                                ConfirmBy: dbCommitment.ConfirmBy,
2✔
2095
                                                                ExpiresAt: dbCommitment.ExpiresAt,
2✔
2096
                                                        },
2✔
2097
                                                },
2✔
2098
                                        },
2✔
2099
                                },
2✔
2100
                        },
2✔
2101
                },
2✔
2102
        }, loc.ServiceType, serviceInfo, p.DB)
2✔
2103
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
NEW
2104
                return
×
NEW
2105
        }
×
2106

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

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

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

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

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

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

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

2227
        return result, nil
43✔
2228
}
2229

2230
func LiquidProjectMetadataFromDBProject(dbProject db.Project, domain db.Domain, serviceInfo liquid.ServiceInfo) Option[liquid.ProjectMetadata] {
50✔
2231
        if !serviceInfo.CommitmentHandlingNeedsProjectMetadata {
100✔
2232
                return None[liquid.ProjectMetadata]()
50✔
2233
        }
50✔
NEW
2234
        return Some(liquid.ProjectMetadata{
×
NEW
2235
                UUID: string(dbProject.UUID),
×
NEW
2236
                Name: dbProject.Name,
×
NEW
2237
                Domain: liquid.DomainMetadata{
×
NEW
2238
                        UUID: domain.UUID,
×
NEW
2239
                        Name: domain.Name,
×
NEW
2240
                },
×
NEW
2241
        })
×
2242
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc