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

sapcc / limes / 16743586364

04 Aug 2025 02:43PM UTC coverage: 79.344% (+0.4%) from 78.919%
16743586364

Pull #756

github

wagnerd3
delegate commitment acceptance: tests working
Pull Request #756: delegate commitment acceptance: tests working

653 of 758 new or added lines in 8 files covered. (86.15%)

22 existing lines in 2 files now uncovered.

7352 of 9266 relevant lines covered (79.34%)

56.98 hits per line

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

80.62
/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
        canConfirm, _ := behavior.CanConfirmCommitmentsAt(confirmBy.UnwrapOr(now))
7✔
320
        if !canConfirm {
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
                        liquid.ProjectUUID(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
        canConfirm, msg := behavior.CanConfirmCommitmentsAt(confirmBy.UnwrapOr(now))
26✔
419
        if !canConfirm {
27✔
420
                http.Error(w, msg, 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
                        liquid.ProjectUUID(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
                                                                // TODO: the type of UUID is db.ProjectCommitmentUUID. Why does the liquid use simple string?
24✔
480
                                                                UUID:      string(dbCommitment.UUID),
24✔
481
                                                                OldStatus: None[liquid.CommitmentStatus](),
24✔
482
                                                                NewStatus: Some(newStatus),
24✔
483
                                                                Amount:    req.Amount,
24✔
484
                                                                ConfirmBy: confirmBy,
24✔
485
                                                                ExpiresAt: req.Duration.AddTo(confirmBy.UnwrapOr(now)),
24✔
486
                                                        },
24✔
487
                                                },
24✔
488
                                        },
24✔
489
                                },
24✔
490
                        },
24✔
491
                },
24✔
492
        }, loc.ServiceType, *serviceInfo, p.DB)
24✔
493
        if respondwith.ObfuscatedErrorText(w, err) {
24✔
NEW
494
                return
×
NEW
495
        }
×
496

497
        if confirmBy.IsNone() {
42✔
498
                // if not planned for confirmation in the future, confirm immediately (or fail)
18✔
499
                if commitmentChangeResponse.RejectionReason != "" {
18✔
NEW
500
                        if retryAt, exists := commitmentChangeResponse.RetryAt.Unpack(); exists {
×
NEW
501
                                w.Header().Set("Retry-After", retryAt.Format(time.RFC1123))
×
NEW
502
                        }
×
NEW
503
                        http.Error(w, commitmentChangeResponse.RejectionReason, http.StatusConflict)
×
504
                        return
×
505
                }
506
                dbCommitment.ConfirmedAt = Some(now)
18✔
507
                dbCommitment.State = db.CommitmentStateActive
18✔
508
        } else {
6✔
509
                // TODO: I don't yet understand how the interface should work with guaranteed.
6✔
510
                // Does cortex decide whether something becomes guaranteed or do we try with "guaranteed" and accept "planned" when it does not work?
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
                        liquid.ProjectUUID(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
                        liquid.ProjectUUID(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
        // TODO: this should be informational in CommitmentChangeRequest.RequiresConfirmation() but is not right now - fix?
1027
        _, err = p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
4✔
1028
                AZ:          loc.AvailabilityZone,
4✔
1029
                InfoVersion: serviceInfo.Version,
4✔
1030
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
4✔
1031
                        liquid.ProjectUUID(dbProject.UUID): {
4✔
1032
                                ProjectMetadata: LiquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
4✔
1033
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
4✔
1034
                                        loc.ResourceName: {
4✔
1035
                                                TotalConfirmedBefore: totalConfirmed,
4✔
1036
                                                TotalConfirmedAfter:  totalConfirmedAfter,
4✔
1037
                                                // TODO: change when introducing "guaranteed" commitments
4✔
1038
                                                TotalGuaranteedBefore: 0,
4✔
1039
                                                TotalGuaranteedAfter:  0,
4✔
1040
                                                Commitments: []liquid.Commitment{
4✔
1041
                                                        {
4✔
1042
                                                                UUID:      string(dbCommitment.UUID),
4✔
1043
                                                                OldStatus: Some(p.convertCommitmentStateToDisplayForm(dbCommitment.State)),
4✔
1044
                                                                NewStatus: None[liquid.CommitmentStatus](),
4✔
1045
                                                                Amount:    dbCommitment.Amount,
4✔
1046
                                                                ConfirmBy: dbCommitment.ConfirmBy,
4✔
1047
                                                                ExpiresAt: dbCommitment.ExpiresAt,
4✔
1048
                                                        },
4✔
1049
                                                },
4✔
1050
                                        },
4✔
1051
                                },
4✔
1052
                        },
4✔
1053
                },
4✔
1054
        }, loc.ServiceType, serviceInfo, p.DB)
4✔
1055
        if respondwith.ObfuscatedErrorText(w, err) {
4✔
NEW
1056
                return
×
NEW
1057
        }
×
1058

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2✔
1647
        // enumerate possible conversions
2✔
1648
        conversions := make([]limesresources.CommitmentConversionRule, 0)
2✔
1649
        if sourceBehavior.ConversionRule.IsSome() {
4✔
1650
                for _, targetServiceType := range slices.Sorted(maps.Keys(serviceInfos)) {
10✔
1651
                        for targetResourceName, targetResInfo := range serviceInfos[targetServiceType].Resources {
34✔
1652
                                if sourceServiceType == targetServiceType && sourceResourceName == targetResourceName {
28✔
1653
                                        continue
2✔
1654
                                }
1655
                                if sourceResInfo.Unit != targetResInfo.Unit {
37✔
1656
                                        continue
13✔
1657
                                }
1658

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

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

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

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

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

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

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

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

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

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

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

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

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

1915
        auditEvent := commitmentEventTarget{
2✔
1916
                DomainID:    dbDomain.UUID,
2✔
1917
                DomainName:  dbDomain.Name,
2✔
1918
                ProjectID:   dbProject.UUID,
2✔
1919
                ProjectName: dbProject.Name,
2✔
1920
        }
2✔
1921

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

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

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

1965
        err = tx.Commit()
2✔
1966
        if respondwith.ErrorText(w, err) {
2✔
1967
                return
×
1968
        }
×
1969

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

2✔
1986
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1987
}
1988

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

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

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

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

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

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

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

2077
        // TODO: Should it be possible for the liquid to refuse this case?
2078
        _, err = p.DelegateChangeCommitments(r.Context(), liquid.CommitmentChangeRequest{
2✔
2079
                AZ:          loc.AvailabilityZone,
2✔
2080
                InfoVersion: serviceInfo.Version,
2✔
2081
                ByProject: map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset{
2✔
2082
                        liquid.ProjectUUID(dbProject.UUID): {
2✔
2083
                                ProjectMetadata: LiquidProjectMetadataFromDBProject(*dbProject, *dbDomain, serviceInfo),
2✔
2084
                                ByResource: map[liquid.ResourceName]liquid.ResourceCommitmentChangeset{
2✔
2085
                                        loc.ResourceName: {
2✔
2086
                                                TotalConfirmedBefore: totalConfirmed,
2✔
2087
                                                TotalConfirmedAfter:  totalConfirmed,
2✔
2088
                                                // TODO: change when introducing "guaranteed" commitments
2✔
2089
                                                TotalGuaranteedBefore: 0,
2✔
2090
                                                TotalGuaranteedAfter:  0,
2✔
2091
                                                Commitments: []liquid.Commitment{
2✔
2092
                                                        {
2✔
2093
                                                                // TODO: is this case properly documented in the API or should we add a comment somewhere?
2✔
2094
                                                                // It feels a bit implicit as there is no "oldExpiresAt/newExpiresAt" field.
2✔
2095
                                                                UUID:      string(dbCommitment.UUID),
2✔
2096
                                                                OldStatus: Some(p.convertCommitmentStateToDisplayForm(dbCommitment.State)),
2✔
2097
                                                                NewStatus: Some(p.convertCommitmentStateToDisplayForm(dbCommitment.State)),
2✔
2098
                                                                Amount:    dbCommitment.Amount,
2✔
2099
                                                                ConfirmBy: dbCommitment.ConfirmBy,
2✔
2100
                                                                ExpiresAt: dbCommitment.ExpiresAt,
2✔
2101
                                                        },
2✔
2102
                                                },
2✔
2103
                                        },
2✔
2104
                                },
2✔
2105
                        },
2✔
2106
                },
2✔
2107
        }, loc.ServiceType, serviceInfo, p.DB)
2✔
2108
        if respondwith.ObfuscatedErrorText(w, err) {
2✔
NEW
2109
                return
×
NEW
2110
        }
×
2111

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

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

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

2137
// DelegateCommitmentCheck decides whether LiquidClient.ChangeCommitments() should be called,
2138
// depending on the setting of liquid.ResourceInfo.HandlesCommitments. If not, it routes the
2139
// operation to be performed locally on the database. In case the LiquidConnection is not filled,
2140
// a LiquidClient is instantiated on the fly to perform the operation. It utilizes a given ServiceInfo so that no
2141
// double retrieval is necessary caused by operations to assemble the liquid.CommitmentChange.
2142
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✔
2143
        localCommitmentChanges := 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
        remoteCommitmentChanges := liquid.CommitmentChangeRequest{
47✔
2149
                AZ:          req.AZ,
47✔
2150
                InfoVersion: req.InfoVersion,
47✔
2151
                ByProject:   make(map[liquid.ProjectUUID]liquid.ProjectCommitmentChangeset),
47✔
2152
        }
47✔
2153
        for projectUUID, projectCommitmentChangeset := range req.ByProject {
97✔
2154
                for resourceName, resourceCommitmentChangeset := range projectCommitmentChangeset.ByResource {
103✔
2155
                        // TODO: this is just to make the tests deterministic. Technically we can handle local time here
53✔
2156
                        // but the implementation of the time package is BS, as you cannot convert Location=local to the IANA.
53✔
2157
                        // when a date comes from a db.ProjectCommitment, it will have the IANA location.😒
53✔
2158
                        for i, commitment := range resourceCommitmentChangeset.Commitments {
113✔
2159
                                commitment.ExpiresAt = commitment.ExpiresAt.UTC()
60✔
2160
                                confirmBy, exists := commitment.ConfirmBy.Unpack()
60✔
2161
                                if exists {
74✔
2162
                                        commitment.ConfirmBy = Some(confirmBy.UTC())
14✔
2163
                                }
14✔
2164
                                resourceCommitmentChangeset.Commitments[i] = commitment
60✔
2165
                        }
2166
                        if serviceInfo.Resources[resourceName].HandlesCommitments {
106✔
2167
                                _, exists := remoteCommitmentChanges.ByProject[projectUUID]
53✔
2168
                                if !exists {
103✔
2169
                                        remoteCommitmentChanges.ByProject[projectUUID] = liquid.ProjectCommitmentChangeset{
50✔
2170
                                                ByResource: make(map[liquid.ResourceName]liquid.ResourceCommitmentChangeset),
50✔
2171
                                        }
50✔
2172
                                }
50✔
2173
                                remoteCommitmentChanges.ByProject[projectUUID].ByResource[resourceName] = resourceCommitmentChangeset
53✔
2174
                                continue
53✔
2175
                        }
NEW
2176
                        _, exists := localCommitmentChanges.ByProject[projectUUID]
×
NEW
2177
                        if !exists {
×
NEW
2178
                                localCommitmentChanges.ByProject[projectUUID] = liquid.ProjectCommitmentChangeset{
×
NEW
2179
                                        ByResource: make(map[liquid.ResourceName]liquid.ResourceCommitmentChangeset),
×
NEW
2180
                                }
×
NEW
2181
                        }
×
NEW
2182
                        localCommitmentChanges.ByProject[projectUUID].ByResource[resourceName] = resourceCommitmentChangeset
×
2183
                }
2184
        }
2185
        for projectUUID, projectCommitmentChangeset := range localCommitmentChanges.ByProject {
47✔
NEW
2186
                if serviceInfo.CommitmentHandlingNeedsProjectMetadata {
×
NEW
2187
                        pcs := projectCommitmentChangeset
×
NEW
2188
                        pcs.ProjectMetadata = req.ByProject[projectUUID].ProjectMetadata
×
NEW
2189
                        localCommitmentChanges.ByProject[projectUUID] = pcs
×
NEW
2190
                }
×
2191
        }
2192
        for projectUUID, remoteCommitmentChangeset := range remoteCommitmentChanges.ByProject {
97✔
2193
                if serviceInfo.CommitmentHandlingNeedsProjectMetadata {
50✔
NEW
2194
                        rcs := remoteCommitmentChangeset
×
NEW
2195
                        rcs.ProjectMetadata = req.ByProject[projectUUID].ProjectMetadata
×
NEW
2196
                        remoteCommitmentChanges.ByProject[projectUUID] = rcs
×
NEW
2197
                }
×
2198
        }
2199

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

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

2233
        return result, nil
43✔
2234
}
2235

2236
func LiquidProjectMetadataFromDBProject(dbProject db.Project, domain db.Domain, serviceInfo liquid.ServiceInfo) Option[liquid.ProjectMetadata] {
50✔
2237
        if !serviceInfo.CommitmentHandlingNeedsProjectMetadata {
100✔
2238
                return None[liquid.ProjectMetadata]()
50✔
2239
        }
50✔
NEW
2240
        return Some(liquid.ProjectMetadata{
×
NEW
2241
                UUID: dbProject.UUID,
×
NEW
2242
                Name: dbProject.Name,
×
NEW
2243
                Domain: liquid.DomainMetadata{
×
NEW
2244
                        UUID: domain.UUID,
×
NEW
2245
                        Name: domain.Name,
×
NEW
2246
                },
×
NEW
2247
        })
×
2248
}
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