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

sapcc / limes / 13592819378

28 Feb 2025 04:58PM UTC coverage: 79.437% (+0.01%) from 79.426%
13592819378

Pull #673

github

VoigtS
commitments: Add notify on confirm (mail) support
Pull Request #673: commitments: Add notify on confirm (mail) support

7 of 7 new or added lines in 1 file covered. (100.0%)

163 existing lines in 1 file now uncovered.

5926 of 7460 relevant lines covered (79.44%)

62.27 hits per line

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

79.41
/internal/api/commitment.go
1
/******************************************************************************
2
*
3
*  Copyright 2023 SAP SE
4
*
5
*  Licensed under the Apache License, Version 2.0 (the "License");
6
*  you may not use this file except in compliance with the License.
7
*  You may obtain a copy of the License at
8
*
9
*      http://www.apache.org/licenses/LICENSE-2.0
10
*
11
*  Unless required by applicable law or agreed to in writing, software
12
*  distributed under the License is distributed on an "AS IS" BASIS,
13
*  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
*  See the License for the specific language governing permissions and
15
*  limitations under the License.
16
*
17
******************************************************************************/
18

19
package api
20

21
import (
22
        "database/sql"
23
        "encoding/json"
24
        "errors"
25
        "fmt"
26
        "net/http"
27
        "slices"
28
        "strings"
29
        "time"
30

31
        "github.com/gorilla/mux"
32
        "github.com/sapcc/go-api-declarations/cadf"
33
        "github.com/sapcc/go-api-declarations/limes"
34
        limesresources "github.com/sapcc/go-api-declarations/limes/resources"
35
        "github.com/sapcc/go-api-declarations/liquid"
36
        "github.com/sapcc/go-bits/audittools"
37
        "github.com/sapcc/go-bits/gopherpolicy"
38
        "github.com/sapcc/go-bits/httpapi"
39
        "github.com/sapcc/go-bits/must"
40
        "github.com/sapcc/go-bits/respondwith"
41
        "github.com/sapcc/go-bits/sqlext"
42

43
        "github.com/sapcc/limes/internal/core"
44
        "github.com/sapcc/limes/internal/datamodel"
45
        "github.com/sapcc/limes/internal/db"
46
        "github.com/sapcc/limes/internal/liquids"
47
        "github.com/sapcc/limes/internal/reports"
48
)
49

50
var (
51
        getProjectCommitmentsQuery = sqlext.SimplifyWhitespace(`
52
                SELECT pc.*
53
                  FROM project_commitments pc
54
                  JOIN project_az_resources par ON pc.az_resource_id = par.id
55
                  JOIN project_resources pr ON par.resource_id = pr.id {{AND pr.name = $resource_name}}
56
                  JOIN project_services ps ON pr.service_id = ps.id {{AND ps.type = $service_type}}
57
                 WHERE %s AND pc.state NOT IN ('superseded', 'expired')
58
                 ORDER BY pc.id
59
        `)
60

61
        getProjectAZResourceLocationsQuery = sqlext.SimplifyWhitespace(`
62
                SELECT par.id, ps.type, pr.name, par.az
63
                  FROM project_az_resources par
64
                  JOIN project_resources pr ON par.resource_id = pr.id {{AND pr.name = $resource_name}}
65
                  JOIN project_services ps ON pr.service_id = ps.id {{AND ps.type = $service_type}}
66
                 WHERE %s
67
        `)
68

69
        findProjectCommitmentByIDQuery = sqlext.SimplifyWhitespace(`
70
                SELECT pc.*
71
                  FROM project_commitments pc
72
                  JOIN project_az_resources par ON pc.az_resource_id = par.id
73
                  JOIN project_resources pr ON par.resource_id = pr.id
74
                  JOIN project_services ps ON pr.service_id = ps.id
75
                 WHERE pc.id = $1 AND ps.project_id = $2
76
        `)
77

78
        // NOTE: The third output column is `resourceAllowsCommitments`.
79
        // We should be checking for `ResourceUsageReport.Forbidden == true`, but
80
        // since the `Forbidden` field is not persisted in the DB, we need to use
81
        // `max_quota_from_backend` as a proxy.
82
        findProjectAZResourceIDByLocationQuery = sqlext.SimplifyWhitespace(`
83
                SELECT pr.id, par.id, pr.max_quota_from_backend IS NULL
84
                  FROM project_az_resources par
85
                  JOIN project_resources pr ON par.resource_id = pr.id
86
                  JOIN project_services ps ON pr.service_id = ps.id
87
                 WHERE ps.project_id = $1 AND ps.type = $2 AND pr.name = $3 AND par.az = $4
88
        `)
89

90
        findProjectAZResourceLocationByIDQuery = sqlext.SimplifyWhitespace(`
91
                SELECT ps.type, pr.name, par.az
92
                  FROM project_az_resources par
93
                  JOIN project_resources pr ON par.resource_id = pr.id
94
                  JOIN project_services ps ON pr.service_id = ps.id
95
                 WHERE par.id = $1
96
        `)
97
        getCommitmentWithMatchingTransferTokenQuery = sqlext.SimplifyWhitespace(`
98
                SELECT * FROM project_commitments WHERE id = $1 AND transfer_token = $2
99
        `)
100
        findCommitmentByTransferToken = sqlext.SimplifyWhitespace(`
101
                SELECT * FROM project_commitments WHERE transfer_token = $1
102
        `)
103
        findTargetAZResourceIDBySourceIDQuery = sqlext.SimplifyWhitespace(`
104
                WITH source as (
105
                SELECT pr.id AS resource_id, ps.type, pr.name, par.az
106
                  FROM project_az_resources as par
107
                  JOIN project_resources pr ON par.resource_id = pr.id
108
                  JOIN project_services ps ON pr.service_id = ps.id
109
                 WHERE par.id = $1
110
                )
111
                SELECT s.resource_id, pr.id, par.id
112
                  FROM project_az_resources as par
113
                  JOIN project_resources pr ON par.resource_id = pr.id
114
                  JOIN project_services ps ON pr.service_id = ps.id
115
                  JOIN source s ON ps.type = s.type AND pr.name = s.name AND par.az = s.az
116
                 WHERE ps.project_id = $2
117
        `)
118
        findTargetAZResourceByTargetProjectQuery = sqlext.SimplifyWhitespace(`
119
                SELECT pr.id, par.id
120
                  FROM project_az_resources par
121
                  JOIN project_resources pr ON par.resource_id = pr.id
122
                  JOIN project_services ps ON pr.service_id = ps.id
123
                 WHERE ps.project_id = $1 AND ps.type = $2 AND pr.name = $3 AND par.az = $4
124
        `)
125
        forceImmediateCapacityScrapeQuery = sqlext.SimplifyWhitespace(`
126
                UPDATE cluster_capacitors SET next_scrape_at = $1 WHERE capacitor_id = (
127
                        SELECT capacitor_id FROM cluster_services cs JOIN cluster_resources cr ON cs.id = cr.service_id
128
                        WHERE cs.type = $2 AND cr.name = $3
129
                )
130
        `)
131
)
132

133
// GetProjectCommitments handles GET /v1/domains/:domain_id/projects/:project_id/commitments.
134
func (p *v1Provider) GetProjectCommitments(w http.ResponseWriter, r *http.Request) {
15✔
135
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments")
15✔
136
        token := p.CheckToken(r)
15✔
137
        if !token.Require(w, "project:show") {
16✔
138
                return
1✔
139
        }
1✔
140
        dbDomain := p.FindDomainFromRequest(w, r)
14✔
141
        if dbDomain == nil {
15✔
142
                return
1✔
143
        }
1✔
144
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
13✔
145
        if dbProject == nil {
14✔
146
                return
1✔
147
        }
1✔
148

149
        // enumerate project AZ resources
150
        filter := reports.ReadFilter(r, p.Cluster)
12✔
151
        queryStr, joinArgs := filter.PrepareQuery(getProjectAZResourceLocationsQuery)
12✔
152
        whereStr, whereArgs := db.BuildSimpleWhereClause(map[string]any{"ps.project_id": dbProject.ID}, len(joinArgs))
12✔
153
        azResourceLocationsByID := make(map[db.ProjectAZResourceID]core.AZResourceLocation)
12✔
154
        err := sqlext.ForeachRow(p.DB, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...), func(rows *sql.Rows) error {
137✔
155
                var (
125✔
156
                        id  db.ProjectAZResourceID
125✔
157
                        loc core.AZResourceLocation
125✔
158
                )
125✔
159
                err := rows.Scan(&id, &loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
125✔
160
                if err != nil {
125✔
161
                        return err
×
162
                }
×
163
                // this check is defense in depth (the DB should be consistent with our config)
164
                if p.Cluster.HasResource(loc.ServiceType, loc.ResourceName) {
250✔
165
                        azResourceLocationsByID[id] = loc
125✔
166
                }
125✔
167
                return nil
125✔
168
        })
169
        if respondwith.ErrorText(w, err) {
12✔
170
                return
×
171
        }
×
172

173
        // enumerate relevant project commitments
174
        queryStr, joinArgs = filter.PrepareQuery(getProjectCommitmentsQuery)
12✔
175
        whereStr, whereArgs = db.BuildSimpleWhereClause(map[string]any{"ps.project_id": dbProject.ID}, len(joinArgs))
12✔
176
        var dbCommitments []db.ProjectCommitment
12✔
177
        _, err = p.DB.Select(&dbCommitments, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...)...)
12✔
178
        if respondwith.ErrorText(w, err) {
12✔
179
                return
×
180
        }
×
181

182
        // render response
183
        result := make([]limesresources.Commitment, 0, len(dbCommitments))
12✔
184
        for _, c := range dbCommitments {
26✔
185
                loc, exists := azResourceLocationsByID[c.AZResourceID]
14✔
186
                if !exists {
14✔
187
                        // defense in depth (the DB should not change that much between those two queries above)
×
188
                        continue
×
189
                }
190
                result = append(result, p.convertCommitmentToDisplayForm(c, loc, token))
14✔
191
        }
192

193
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitments": result})
12✔
194
}
195

196
func (p *v1Provider) convertCommitmentToDisplayForm(c db.ProjectCommitment, loc core.AZResourceLocation, token *gopherpolicy.Token) limesresources.Commitment {
77✔
197
        resInfo := p.Cluster.InfoForResource(loc.ServiceType, loc.ResourceName)
77✔
198
        apiIdentity := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName).IdentityInV1API
77✔
199
        return limesresources.Commitment{
77✔
200
                ID:               int64(c.ID),
77✔
201
                ServiceType:      apiIdentity.ServiceType,
77✔
202
                ResourceName:     apiIdentity.Name,
77✔
203
                AvailabilityZone: loc.AvailabilityZone,
77✔
204
                Amount:           c.Amount,
77✔
205
                Unit:             resInfo.Unit,
77✔
206
                Duration:         c.Duration,
77✔
207
                CreatedAt:        limes.UnixEncodedTime{Time: c.CreatedAt},
77✔
208
                CreatorUUID:      c.CreatorUUID,
77✔
209
                CreatorName:      c.CreatorName,
77✔
210
                CanBeDeleted:     p.canDeleteCommitment(token, c),
77✔
211
                ConfirmBy:        maybeUnixEncodedTime(c.ConfirmBy),
77✔
212
                ConfirmedAt:      maybeUnixEncodedTime(c.ConfirmedAt),
77✔
213
                ExpiresAt:        limes.UnixEncodedTime{Time: c.ExpiresAt},
77✔
214
                TransferStatus:   c.TransferStatus,
77✔
215
                TransferToken:    c.TransferToken,
77✔
216
                NotifyOnConfirm:  c.NotifyOnConfirm,
77✔
217
        }
77✔
218
}
77✔
219

220
func (p *v1Provider) parseAndValidateCommitmentRequest(w http.ResponseWriter, r *http.Request) (*limesresources.CommitmentRequest, *core.AZResourceLocation, *core.ResourceBehavior) {
43✔
221
        // parse request
43✔
222
        var parseTarget struct {
43✔
223
                Request limesresources.CommitmentRequest `json:"commitment"`
43✔
224
        }
43✔
225
        if !RequireJSON(w, r, &parseTarget) {
44✔
226
                return nil, nil, nil
1✔
227
        }
1✔
228
        req := parseTarget.Request
42✔
229

42✔
230
        // validate request
42✔
231
        nm := core.BuildResourceNameMapping(p.Cluster)
42✔
232
        dbServiceType, dbResourceName, ok := nm.MapFromV1API(req.ServiceType, req.ResourceName)
42✔
233
        if !ok {
44✔
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
2✔
237
        }
2✔
238
        behavior := p.Cluster.BehaviorForResource(dbServiceType, dbResourceName)
40✔
239
        resInfo := p.Cluster.InfoForResource(dbServiceType, dbResourceName)
40✔
240
        if len(behavior.CommitmentDurations) == 0 {
41✔
241
                http.Error(w, "commitments are not enabled for this resource", http.StatusUnprocessableEntity)
1✔
242
                return nil, nil, nil
1✔
243
        }
1✔
244
        if resInfo.Topology == liquid.FlatResourceTopology {
42✔
245
                if req.AvailabilityZone != limes.AvailabilityZoneAny {
4✔
246
                        http.Error(w, `resource does not accept AZ-aware commitments, so the AZ must be set to "any"`, http.StatusUnprocessableEntity)
1✔
247
                        return nil, nil, nil
1✔
248
                }
1✔
249
        } else {
36✔
250
                if !slices.Contains(p.Cluster.Config.AvailabilityZones, req.AvailabilityZone) {
40✔
251
                        http.Error(w, "no such availability zone", http.StatusUnprocessableEntity)
4✔
252
                        return nil, nil, nil
4✔
253
                }
4✔
254
        }
255
        if !slices.Contains(behavior.CommitmentDurations, req.Duration) {
35✔
256
                buf := must.Return(json.Marshal(behavior.CommitmentDurations)) // panic on error is acceptable here, marshals should never fail
1✔
257
                msg := "unacceptable commitment duration for this resource, acceptable values: " + string(buf)
1✔
258
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
259
                return nil, nil, nil
1✔
260
        }
1✔
261
        if req.Amount == 0 {
34✔
262
                http.Error(w, "amount of committed resource must be greater than zero", http.StatusUnprocessableEntity)
1✔
263
                return nil, nil, nil
1✔
264
        }
1✔
265

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

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

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

7✔
311
        // commitments can never be confirmed immediately if we are before the min_confirm_date
7✔
312
        now := p.timeNow()
7✔
313
        if behavior.CommitmentMinConfirmDate != nil && behavior.CommitmentMinConfirmDate.After(now) {
8✔
314
                respondwith.JSON(w, http.StatusOK, map[string]bool{"result": false})
1✔
315
                return
1✔
316
        }
1✔
317

318
        // check for committable capacity
319
        result, err := datamodel.CanConfirmNewCommitment(*loc, resourceID, req.Amount, p.Cluster, p.DB)
6✔
320
        if respondwith.ErrorText(w, err) {
6✔
321
                return
×
322
        }
×
323
        respondwith.JSON(w, http.StatusOK, map[string]bool{"result": result})
6✔
324
}
325

326
// CreateProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/new.
327
func (p *v1Provider) CreateProjectCommitment(w http.ResponseWriter, r *http.Request) {
39✔
328
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/new")
39✔
329
        token := p.CheckToken(r)
39✔
330
        if !token.Require(w, "project:edit") {
40✔
331
                return
1✔
332
        }
1✔
333
        dbDomain := p.FindDomainFromRequest(w, r)
38✔
334
        if dbDomain == nil {
39✔
335
                return
1✔
336
        }
1✔
337
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
37✔
338
        if dbProject == nil {
38✔
339
                return
1✔
340
        }
1✔
341
        req, loc, behavior := p.parseAndValidateCommitmentRequest(w, r)
36✔
342
        if req == nil {
47✔
343
                return
11✔
344
        }
11✔
345

346
        var (
25✔
347
                resourceID                db.ProjectResourceID
25✔
348
                azResourceID              db.ProjectAZResourceID
25✔
349
                resourceAllowsCommitments bool
25✔
350
        )
25✔
351
        err := p.DB.QueryRow(findProjectAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
25✔
352
                Scan(&resourceID, &azResourceID, &resourceAllowsCommitments)
25✔
353
        if respondwith.ErrorText(w, err) {
25✔
354
                return
×
355
        }
×
356
        if !resourceAllowsCommitments {
26✔
357
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
1✔
358
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
359
                return
1✔
360
        }
1✔
361

362
        // if given, confirm_by must definitely after time.Now(), and also after the MinConfirmDate if configured
363
        now := p.timeNow()
24✔
364
        if req.ConfirmBy != nil && req.ConfirmBy.Before(now) {
25✔
365
                http.Error(w, "confirm_by must not be set in the past", http.StatusUnprocessableEntity)
1✔
366
                return
1✔
367
        }
1✔
368
        if minConfirmBy := behavior.CommitmentMinConfirmDate; minConfirmBy != nil && minConfirmBy.After(now) {
28✔
369
                if req.ConfirmBy == nil || req.ConfirmBy.Before(*minConfirmBy) {
6✔
370
                        msg := "this commitment needs a `confirm_by` timestamp at or after " + behavior.CommitmentMinConfirmDate.Format(time.RFC3339)
1✔
371
                        http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
372
                        return
1✔
373
                }
1✔
374
        }
375

376
        // we want to validate committable capacity in the same transaction that creates the commitment
377
        tx, err := p.DB.Begin()
22✔
378
        if respondwith.ErrorText(w, err) {
22✔
379
                return
×
380
        }
×
381
        defer sqlext.RollbackUnlessCommitted(tx)
22✔
382

22✔
383
        // prepare commitment
22✔
384
        confirmBy := maybeUnpackUnixEncodedTime(req.ConfirmBy)
22✔
385
        creationContext := db.CommitmentWorkflowContext{Reason: db.CommitmentReasonCreate}
22✔
386
        buf, err := json.Marshal(creationContext)
22✔
387
        if respondwith.ErrorText(w, err) {
22✔
388
                return
×
389
        }
×
390
        dbCommitment := db.ProjectCommitment{
22✔
391
                AZResourceID:        azResourceID,
22✔
392
                Amount:              req.Amount,
22✔
393
                Duration:            req.Duration,
22✔
394
                CreatedAt:           now,
22✔
395
                CreatorUUID:         token.UserUUID(),
22✔
396
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
22✔
397
                ConfirmBy:           confirmBy,
22✔
398
                ConfirmedAt:         nil, // may be set below
22✔
399
                ExpiresAt:           req.Duration.AddTo(unwrapOrDefault(confirmBy, now)),
22✔
400
                CreationContextJSON: json.RawMessage(buf),
22✔
401
        }
22✔
402
        if req.ConfirmBy != nil {
28✔
403
                dbCommitment.NotifyOnConfirm = req.NotifyOnConfirm
6✔
404
        }
6✔
405
        if req.ConfirmBy == nil {
38✔
406
                // if not planned for confirmation in the future, confirm immediately (or fail)
16✔
407
                ok, err := datamodel.CanConfirmNewCommitment(*loc, resourceID, req.Amount, p.Cluster, tx)
16✔
408
                if respondwith.ErrorText(w, err) {
16✔
UNCOV
409
                        return
×
UNCOV
410
                }
×
411
                if !ok {
16✔
412
                        http.Error(w, "not enough capacity available for immediate confirmation", http.StatusConflict)
×
413
                        return
×
UNCOV
414
                }
×
415
                dbCommitment.ConfirmedAt = &now
16✔
416
                dbCommitment.State = db.CommitmentStateActive
16✔
417
        } else {
6✔
418
                dbCommitment.State = db.CommitmentStatePlanned
6✔
419
        }
6✔
420

421
        // create commitment
422
        err = tx.Insert(&dbCommitment)
22✔
423
        if respondwith.ErrorText(w, err) {
22✔
UNCOV
424
                return
×
UNCOV
425
        }
×
426
        err = tx.Commit()
22✔
427
        if respondwith.ErrorText(w, err) {
22✔
428
                return
×
UNCOV
429
        }
×
430
        p.auditor.Record(audittools.Event{
22✔
431
                Time:       now,
22✔
432
                Request:    r,
22✔
433
                User:       token,
22✔
434
                ReasonCode: http.StatusCreated,
22✔
435
                Action:     cadf.CreateAction,
22✔
436
                Target: commitmentEventTarget{
22✔
437
                        DomainID:        dbDomain.UUID,
22✔
438
                        DomainName:      dbDomain.Name,
22✔
439
                        ProjectID:       dbProject.UUID,
22✔
440
                        ProjectName:     dbProject.Name,
22✔
441
                        Commitments:     []limesresources.Commitment{p.convertCommitmentToDisplayForm(dbCommitment, *loc, token)},
22✔
442
                        WorkflowContext: &creationContext,
22✔
443
                },
22✔
444
        })
22✔
445

22✔
446
        // if the commitment is immediately confirmed, trigger a capacity scrape in
22✔
447
        // order to ApplyComputedProjectQuotas based on the new commitment
22✔
448
        if dbCommitment.ConfirmedAt != nil {
38✔
449
                _, err := p.DB.Exec(forceImmediateCapacityScrapeQuery, now, loc.ServiceType, loc.ResourceName)
16✔
450
                if respondwith.ErrorText(w, err) {
16✔
UNCOV
451
                        return
×
UNCOV
452
                }
×
453
        }
454

455
        // display the possibly confirmed commitment to the user
456
        err = p.DB.SelectOne(&dbCommitment, `SELECT * FROM project_commitments WHERE id = $1`, dbCommitment.ID)
22✔
457
        if respondwith.ErrorText(w, err) {
22✔
UNCOV
458
                return
×
UNCOV
459
        }
×
460

461
        c := p.convertCommitmentToDisplayForm(dbCommitment, *loc, token)
22✔
462
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": c})
22✔
463
}
464

465
// MergeProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/merge.
466
func (p *v1Provider) MergeProjectCommitments(w http.ResponseWriter, r *http.Request) {
12✔
467
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/merge")
12✔
468
        token := p.CheckToken(r)
12✔
469
        if !token.Require(w, "project:edit") {
13✔
470
                return
1✔
471
        }
1✔
472
        dbDomain := p.FindDomainFromRequest(w, r)
11✔
473
        if dbDomain == nil {
12✔
474
                return
1✔
475
        }
1✔
476
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
10✔
477
        if dbProject == nil {
11✔
478
                return
1✔
479
        }
1✔
480
        var parseTarget struct {
9✔
481
                CommitmentIDs []db.ProjectCommitmentID `json:"commitment_ids"`
9✔
482
        }
9✔
483
        if !RequireJSON(w, r, &parseTarget) {
9✔
UNCOV
484
                return
×
UNCOV
485
        }
×
486
        commitmentIDs := parseTarget.CommitmentIDs
9✔
487
        if len(commitmentIDs) < 2 {
10✔
488
                http.Error(w, fmt.Sprintf("merging requires at least two commitments, but %d were given", len(commitmentIDs)), http.StatusBadRequest)
1✔
489
                return
1✔
490
        }
1✔
491

492
        // Load commitments
493
        dbCommitments := make([]db.ProjectCommitment, len(commitmentIDs))
8✔
494
        for i, commitmentID := range commitmentIDs {
24✔
495
                err := p.DB.SelectOne(&dbCommitments[i], findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
16✔
496
                if errors.Is(err, sql.ErrNoRows) {
17✔
497
                        http.Error(w, "no such commitment", http.StatusNotFound)
1✔
498
                        return
1✔
499
                } else if respondwith.ErrorText(w, err) {
16✔
UNCOV
500
                        return
×
UNCOV
501
                }
×
502
        }
503

504
        // Verify that all commitments agree on resource and AZ and are active
505
        azResourceID := dbCommitments[0].AZResourceID
7✔
506
        for _, dbCommitment := range dbCommitments {
21✔
507
                if dbCommitment.AZResourceID != azResourceID {
16✔
508
                        http.Error(w, "all commitments must be on the same resource and AZ", http.StatusConflict)
2✔
509
                        return
2✔
510
                }
2✔
511
                if dbCommitment.State != db.CommitmentStateActive {
16✔
512
                        http.Error(w, "only active commitments may be merged", http.StatusConflict)
4✔
513
                        return
4✔
514
                }
4✔
515
        }
516

517
        var loc core.AZResourceLocation
1✔
518
        err := p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, azResourceID).
1✔
519
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
520
        if errors.Is(err, sql.ErrNoRows) {
1✔
UNCOV
521
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
522
                return
×
523
        } else if respondwith.ErrorText(w, err) {
1✔
524
                return
×
525
        }
×
526

527
        // Start transaction for creating new commitment and marking merged commitments as superseded
528
        tx, err := p.DB.Begin()
1✔
529
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
530
                return
×
UNCOV
531
        }
×
532
        defer sqlext.RollbackUnlessCommitted(tx)
1✔
533

1✔
534
        // Create merged template
1✔
535
        now := p.timeNow()
1✔
536
        dbMergedCommitment := db.ProjectCommitment{
1✔
537
                AZResourceID: azResourceID,
1✔
538
                Amount:       0,                                   // overwritten below
1✔
539
                Duration:     limesresources.CommitmentDuration{}, // overwritten below
1✔
540
                CreatedAt:    now,
1✔
541
                CreatorUUID:  token.UserUUID(),
1✔
542
                CreatorName:  fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
1✔
543
                ConfirmedAt:  &now,
1✔
544
                ExpiresAt:    time.Time{}, // overwritten below
1✔
545
                State:        db.CommitmentStateActive,
1✔
546
        }
1✔
547

1✔
548
        // Fill amount and latest expiration date
1✔
549
        for _, dbCommitment := range dbCommitments {
3✔
550
                dbMergedCommitment.Amount += dbCommitment.Amount
2✔
551
                if dbCommitment.ExpiresAt.After(dbMergedCommitment.ExpiresAt) {
4✔
552
                        dbMergedCommitment.ExpiresAt = dbCommitment.ExpiresAt
2✔
553
                        dbMergedCommitment.Duration = dbCommitment.Duration
2✔
554
                }
2✔
555
        }
556

557
        // Fill workflow context
558
        creationContext := db.CommitmentWorkflowContext{
1✔
559
                Reason:               db.CommitmentReasonMerge,
1✔
560
                RelatedCommitmentIDs: commitmentIDs,
1✔
561
        }
1✔
562
        buf, err := json.Marshal(creationContext)
1✔
563
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
564
                return
×
UNCOV
565
        }
×
566
        dbMergedCommitment.CreationContextJSON = json.RawMessage(buf)
1✔
567

1✔
568
        // Insert into database
1✔
569
        err = tx.Insert(&dbMergedCommitment)
1✔
570
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
571
                return
×
UNCOV
572
        }
×
573

574
        // Mark merged commits as superseded
575
        supersedeContext := db.CommitmentWorkflowContext{
1✔
576
                Reason:               db.CommitmentReasonMerge,
1✔
577
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbMergedCommitment.ID},
1✔
578
        }
1✔
579
        buf, err = json.Marshal(supersedeContext)
1✔
580
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
581
                return
×
UNCOV
582
        }
×
583
        for _, dbCommitment := range dbCommitments {
3✔
584
                dbCommitment.SupersededAt = &now
2✔
585
                dbCommitment.SupersedeContextJSON = liquids.PointerTo(json.RawMessage(buf))
2✔
586
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
587
                _, err = tx.Update(&dbCommitment)
2✔
588
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
589
                        return
×
UNCOV
590
                }
×
591
        }
592

593
        err = tx.Commit()
1✔
594
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
595
                return
×
UNCOV
596
        }
×
597

598
        c := p.convertCommitmentToDisplayForm(dbMergedCommitment, loc, token)
1✔
599
        auditEvent := commitmentEventTarget{
1✔
600
                DomainID:        dbDomain.UUID,
1✔
601
                DomainName:      dbDomain.Name,
1✔
602
                ProjectID:       dbProject.UUID,
1✔
603
                ProjectName:     dbProject.Name,
1✔
604
                Commitments:     []limesresources.Commitment{c},
1✔
605
                WorkflowContext: &creationContext,
1✔
606
        }
1✔
607
        p.auditor.Record(audittools.Event{
1✔
608
                Time:       p.timeNow(),
1✔
609
                Request:    r,
1✔
610
                User:       token,
1✔
611
                ReasonCode: http.StatusAccepted,
1✔
612
                Action:     cadf.UpdateAction,
1✔
613
                Target:     auditEvent,
1✔
614
        })
1✔
615

1✔
616
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
617
}
618

619
// DeleteProjectCommitment handles DELETE /v1/domains/:domain_id/projects/:project_id/commitments/:id.
620
func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Request) {
8✔
621
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id")
8✔
622
        token := p.CheckToken(r)
8✔
623
        if !token.Require(w, "project:edit") { //NOTE: There is a more specific AuthZ check further down below.
8✔
UNCOV
624
                return
×
UNCOV
625
        }
×
626
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
627
        if dbDomain == nil {
9✔
628
                return
1✔
629
        }
1✔
630
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
631
        if dbProject == nil {
8✔
632
                return
1✔
633
        }
1✔
634

635
        // load commitment
636
        var dbCommitment db.ProjectCommitment
6✔
637
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
638
        if errors.Is(err, sql.ErrNoRows) {
7✔
639
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
640
                return
1✔
641
        } else if respondwith.ErrorText(w, err) {
6✔
UNCOV
642
                return
×
UNCOV
643
        }
×
644
        var loc core.AZResourceLocation
5✔
645
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
5✔
646
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
5✔
647
        if errors.Is(err, sql.ErrNoRows) {
5✔
UNCOV
648
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
649
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
650
                return
×
651
        } else if respondwith.ErrorText(w, err) {
5✔
652
                return
×
653
        }
×
654

655
        // check authorization for this specific commitment
656
        if !p.canDeleteCommitment(token, dbCommitment) {
6✔
657
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
658
                return
1✔
659
        }
1✔
660

661
        // perform deletion
662
        _, err = p.DB.Delete(&dbCommitment)
4✔
663
        if respondwith.ErrorText(w, err) {
4✔
UNCOV
664
                return
×
UNCOV
665
        }
×
666
        p.auditor.Record(audittools.Event{
4✔
667
                Time:       p.timeNow(),
4✔
668
                Request:    r,
4✔
669
                User:       token,
4✔
670
                ReasonCode: http.StatusNoContent,
4✔
671
                Action:     cadf.DeleteAction,
4✔
672
                Target: commitmentEventTarget{
4✔
673
                        DomainID:    dbDomain.UUID,
4✔
674
                        DomainName:  dbDomain.Name,
4✔
675
                        ProjectID:   dbProject.UUID,
4✔
676
                        ProjectName: dbProject.Name,
4✔
677
                        Commitments: []limesresources.Commitment{p.convertCommitmentToDisplayForm(dbCommitment, loc, token)},
4✔
678
                },
4✔
679
        })
4✔
680

4✔
681
        w.WriteHeader(http.StatusNoContent)
4✔
682
}
683

684
func (p *v1Provider) canDeleteCommitment(token *gopherpolicy.Token, commitment db.ProjectCommitment) bool {
82✔
685
        // up to 24 hours after creation of fresh commitments, future commitments can still be deleted by their creators
82✔
686
        if commitment.State == db.CommitmentStatePlanned || commitment.State == db.CommitmentStatePending || commitment.State == db.CommitmentStateActive {
164✔
687
                var creationContext db.CommitmentWorkflowContext
82✔
688
                err := json.Unmarshal(commitment.CreationContextJSON, &creationContext)
82✔
689
                if err == nil && creationContext.Reason == db.CommitmentReasonCreate && p.timeNow().Before(commitment.CreatedAt.Add(24*time.Hour)) {
142✔
690
                        if token.Check("project:edit") {
120✔
691
                                return true
60✔
692
                        }
60✔
693
                }
694
        }
695

696
        // afterwards, a more specific permission is required to delete it
697
        //
698
        // This protects cloud admins making capacity planning decisions based on future commitments
699
        // from having their forecasts ruined by project admins suffering from buyer's remorse.
700
        return token.Check("project:uncommit")
22✔
701
}
702

703
// StartCommitmentTransfer handles POST /v1/domains/:id/projects/:id/commitments/:id/start-transfer
704
func (p *v1Provider) StartCommitmentTransfer(w http.ResponseWriter, r *http.Request) {
8✔
705
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/start-transfer")
8✔
706
        token := p.CheckToken(r)
8✔
707
        if !token.Require(w, "project:edit") {
8✔
UNCOV
708
                return
×
UNCOV
709
        }
×
710
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
711
        if dbDomain == nil {
8✔
712
                return
×
UNCOV
713
        }
×
714
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
8✔
715
        if dbProject == nil {
8✔
716
                return
×
UNCOV
717
        }
×
718
        // TODO: eventually migrate this struct into go-api-declarations
719
        var parseTarget struct {
8✔
720
                Request struct {
8✔
721
                        Amount         uint64                                  `json:"amount"`
8✔
722
                        TransferStatus limesresources.CommitmentTransferStatus `json:"transfer_status,omitempty"`
8✔
723
                } `json:"commitment"`
8✔
724
        }
8✔
725
        if !RequireJSON(w, r, &parseTarget) {
8✔
UNCOV
726
                return
×
UNCOV
727
        }
×
728
        req := parseTarget.Request
8✔
729

8✔
730
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
UNCOV
731
                http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest)
×
UNCOV
732
                return
×
UNCOV
733
        }
×
734

735
        if req.Amount <= 0 {
9✔
736
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
737
                return
1✔
738
        }
1✔
739

740
        // load commitment
741
        var dbCommitment db.ProjectCommitment
7✔
742
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
7✔
743
        if errors.Is(err, sql.ErrNoRows) {
7✔
UNCOV
744
                http.Error(w, "no such commitment", http.StatusNotFound)
×
UNCOV
745
                return
×
746
        } else if respondwith.ErrorText(w, err) {
7✔
747
                return
×
748
        }
×
749

750
        // Mark whole commitment or a newly created, splitted one as transferrable.
751
        tx, err := p.DB.Begin()
7✔
752
        if respondwith.ErrorText(w, err) {
7✔
UNCOV
753
                return
×
UNCOV
754
        }
×
755
        defer sqlext.RollbackUnlessCommitted(tx)
7✔
756
        transferToken := p.generateTransferToken()
7✔
757

7✔
758
        // Deny requests with a greater amount than the commitment.
7✔
759
        if req.Amount > dbCommitment.Amount {
8✔
760
                http.Error(w, "delivered amount exceeds the commitment amount.", http.StatusBadRequest)
1✔
761
                return
1✔
762
        }
1✔
763

764
        if req.Amount == dbCommitment.Amount {
10✔
765
                dbCommitment.TransferStatus = req.TransferStatus
4✔
766
                dbCommitment.TransferToken = &transferToken
4✔
767
                _, err = tx.Update(&dbCommitment)
4✔
768
                if respondwith.ErrorText(w, err) {
4✔
UNCOV
769
                        return
×
UNCOV
770
                }
×
771
        } else {
2✔
772
                now := p.timeNow()
2✔
773
                transferAmount := req.Amount
2✔
774
                remainingAmount := dbCommitment.Amount - req.Amount
2✔
775
                transferCommitment, err := p.buildSplitCommitment(dbCommitment, transferAmount)
2✔
776
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
777
                        return
×
UNCOV
778
                }
×
779
                transferCommitment.TransferStatus = req.TransferStatus
2✔
780
                transferCommitment.TransferToken = &transferToken
2✔
781
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
2✔
782
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
783
                        return
×
UNCOV
784
                }
×
785
                err = tx.Insert(&transferCommitment)
2✔
786
                if respondwith.ErrorText(w, err) {
2✔
787
                        return
×
UNCOV
788
                }
×
789
                err = tx.Insert(&remainingCommitment)
2✔
790
                if respondwith.ErrorText(w, err) {
2✔
791
                        return
×
UNCOV
792
                }
×
793
                supersedeContext := db.CommitmentWorkflowContext{
2✔
794
                        Reason:               db.CommitmentReasonSplit,
2✔
795
                        RelatedCommitmentIDs: []db.ProjectCommitmentID{transferCommitment.ID, remainingCommitment.ID},
2✔
796
                }
2✔
797
                buf, err := json.Marshal(supersedeContext)
2✔
798
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
799
                        return
×
UNCOV
800
                }
×
801
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
802
                dbCommitment.SupersededAt = &now
2✔
803
                dbCommitment.SupersedeContextJSON = liquids.PointerTo(json.RawMessage(buf))
2✔
804
                _, err = tx.Update(&dbCommitment)
2✔
805
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
806
                        return
×
UNCOV
807
                }
×
808
                dbCommitment = transferCommitment
2✔
809
        }
810
        err = tx.Commit()
6✔
811
        if respondwith.ErrorText(w, err) {
6✔
UNCOV
812
                return
×
UNCOV
813
        }
×
814

815
        var loc core.AZResourceLocation
6✔
816
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
6✔
817
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
6✔
818
        if errors.Is(err, sql.ErrNoRows) {
6✔
UNCOV
819
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
820
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
821
                return
×
822
        } else if respondwith.ErrorText(w, err) {
6✔
823
                return
×
824
        }
×
825

826
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
6✔
827
        p.auditor.Record(audittools.Event{
6✔
828
                Time:       p.timeNow(),
6✔
829
                Request:    r,
6✔
830
                User:       token,
6✔
831
                ReasonCode: http.StatusAccepted,
6✔
832
                Action:     cadf.UpdateAction,
6✔
833
                Target: commitmentEventTarget{
6✔
834
                        DomainID:    dbDomain.UUID,
6✔
835
                        DomainName:  dbDomain.Name,
6✔
836
                        ProjectID:   dbProject.UUID,
6✔
837
                        ProjectName: dbProject.Name,
6✔
838
                        Commitments: []limesresources.Commitment{c},
6✔
839
                },
6✔
840
        })
6✔
841
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
6✔
842
}
843

844
func (p *v1Provider) buildSplitCommitment(dbCommitment db.ProjectCommitment, amount uint64) (db.ProjectCommitment, error) {
5✔
845
        now := p.timeNow()
5✔
846
        creationContext := db.CommitmentWorkflowContext{
5✔
847
                Reason:               db.CommitmentReasonSplit,
5✔
848
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
5✔
849
        }
5✔
850
        buf, err := json.Marshal(creationContext)
5✔
851
        if err != nil {
5✔
UNCOV
852
                return db.ProjectCommitment{}, err
×
UNCOV
853
        }
×
854
        return db.ProjectCommitment{
5✔
855
                AZResourceID:        dbCommitment.AZResourceID,
5✔
856
                Amount:              amount,
5✔
857
                Duration:            dbCommitment.Duration,
5✔
858
                CreatedAt:           now,
5✔
859
                CreatorUUID:         dbCommitment.CreatorUUID,
5✔
860
                CreatorName:         dbCommitment.CreatorName,
5✔
861
                ConfirmBy:           dbCommitment.ConfirmBy,
5✔
862
                ConfirmedAt:         dbCommitment.ConfirmedAt,
5✔
863
                ExpiresAt:           dbCommitment.ExpiresAt,
5✔
864
                CreationContextJSON: json.RawMessage(buf),
5✔
865
                State:               dbCommitment.State,
5✔
866
        }, nil
5✔
867
}
868

869
func (p *v1Provider) buildConvertedCommitment(dbCommitment db.ProjectCommitment, azResourceID db.ProjectAZResourceID, amount uint64) (db.ProjectCommitment, error) {
2✔
870
        now := p.timeNow()
2✔
871
        creationContext := db.CommitmentWorkflowContext{
2✔
872
                Reason:               db.CommitmentReasonConvert,
2✔
873
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
2✔
874
        }
2✔
875
        buf, err := json.Marshal(creationContext)
2✔
876
        if err != nil {
2✔
UNCOV
877
                return db.ProjectCommitment{}, err
×
UNCOV
878
        }
×
879
        return db.ProjectCommitment{
2✔
880
                AZResourceID:        azResourceID,
2✔
881
                Amount:              amount,
2✔
882
                Duration:            dbCommitment.Duration,
2✔
883
                CreatedAt:           now,
2✔
884
                CreatorUUID:         dbCommitment.CreatorUUID,
2✔
885
                CreatorName:         dbCommitment.CreatorName,
2✔
886
                ConfirmBy:           dbCommitment.ConfirmBy,
2✔
887
                ConfirmedAt:         dbCommitment.ConfirmedAt,
2✔
888
                ExpiresAt:           dbCommitment.ExpiresAt,
2✔
889
                CreationContextJSON: json.RawMessage(buf),
2✔
890
                State:               dbCommitment.State,
2✔
891
        }, nil
2✔
892
}
893

894
// GetCommitmentByTransferToken handles GET /v1/commitments/{token}
895
func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http.Request) {
2✔
896
        httpapi.IdentifyEndpoint(r, "/v1/commitments/:token")
2✔
897
        token := p.CheckToken(r)
2✔
898
        if !token.Require(w, "cluster:show_basic") {
2✔
UNCOV
899
                return
×
UNCOV
900
        }
×
901
        transferToken := mux.Vars(r)["token"]
2✔
902

2✔
903
        // The token column is a unique key, so we expect only one result.
2✔
904
        var dbCommitment db.ProjectCommitment
2✔
905
        err := p.DB.SelectOne(&dbCommitment, findCommitmentByTransferToken, transferToken)
2✔
906
        if errors.Is(err, sql.ErrNoRows) {
3✔
907
                http.Error(w, "no matching commitment found.", http.StatusNotFound)
1✔
908
                return
1✔
909
        } else if respondwith.ErrorText(w, err) {
2✔
UNCOV
910
                return
×
UNCOV
911
        }
×
912

913
        var loc core.AZResourceLocation
1✔
914
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
1✔
915
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
916
        if errors.Is(err, sql.ErrNoRows) {
1✔
UNCOV
917
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
918
                http.Error(w, "location data not found.", http.StatusNotFound)
×
UNCOV
919
                return
×
920
        } else if respondwith.ErrorText(w, err) {
1✔
921
                return
×
922
        }
×
923

924
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
1✔
925
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
926
}
927

928
// TransferCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}?token={token}
929
func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request) {
5✔
930
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/transfer-commitment/:id")
5✔
931
        token := p.CheckToken(r)
5✔
932
        if !token.Require(w, "project:edit") {
5✔
UNCOV
933
                return
×
UNCOV
934
        }
×
935
        transferToken := r.Header.Get("Transfer-Token")
5✔
936
        if transferToken == "" {
6✔
937
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
1✔
938
                return
1✔
939
        }
1✔
940
        commitmentID := mux.Vars(r)["id"]
4✔
941
        if commitmentID == "" {
4✔
UNCOV
942
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
UNCOV
943
                return
×
UNCOV
944
        }
×
945
        dbDomain := p.FindDomainFromRequest(w, r)
4✔
946
        if dbDomain == nil {
4✔
947
                return
×
UNCOV
948
        }
×
949
        targetProject := p.FindProjectFromRequest(w, r, dbDomain)
4✔
950
        if targetProject == nil {
4✔
951
                return
×
UNCOV
952
        }
×
953

954
        // find commitment by transfer_token
955
        var dbCommitment db.ProjectCommitment
4✔
956
        err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, commitmentID, transferToken)
4✔
957
        if errors.Is(err, sql.ErrNoRows) {
5✔
958
                http.Error(w, "no matching commitment found", http.StatusNotFound)
1✔
959
                return
1✔
960
        } else if respondwith.ErrorText(w, err) {
4✔
UNCOV
961
                return
×
UNCOV
962
        }
×
963

964
        var loc core.AZResourceLocation
3✔
965
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
3✔
966
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
3✔
967
        if errors.Is(err, sql.ErrNoRows) {
3✔
UNCOV
968
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
969
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
970
                return
×
971
        } else if respondwith.ErrorText(w, err) {
3✔
972
                return
×
973
        }
×
974

975
        // get target service and AZ resource
976
        var (
3✔
977
                sourceResourceID   db.ProjectResourceID
3✔
978
                targetResourceID   db.ProjectResourceID
3✔
979
                targetAZResourceID db.ProjectAZResourceID
3✔
980
        )
3✔
981
        err = p.DB.QueryRow(findTargetAZResourceIDBySourceIDQuery, dbCommitment.AZResourceID, targetProject.ID).
3✔
982
                Scan(&sourceResourceID, &targetResourceID, &targetAZResourceID)
3✔
983
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
984
                return
×
UNCOV
985
        }
×
986

987
        // validate that we have enough committable capacity on the receiving side
988
        tx, err := p.DB.Begin()
3✔
989
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
990
                return
×
UNCOV
991
        }
×
992
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
993
        ok, err := datamodel.CanMoveExistingCommitment(dbCommitment.Amount, loc, sourceResourceID, targetResourceID, p.Cluster, tx)
3✔
994
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
995
                return
×
UNCOV
996
        }
×
997
        if !ok {
4✔
998
                http.Error(w, "not enough committable capacity on the receiving side", http.StatusConflict)
1✔
999
                return
1✔
1000
        }
1✔
1001

1002
        dbCommitment.TransferStatus = ""
2✔
1003
        dbCommitment.TransferToken = nil
2✔
1004
        dbCommitment.AZResourceID = targetAZResourceID
2✔
1005
        _, err = tx.Update(&dbCommitment)
2✔
1006
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1007
                return
×
UNCOV
1008
        }
×
1009
        err = tx.Commit()
2✔
1010
        if respondwith.ErrorText(w, err) {
2✔
1011
                return
×
UNCOV
1012
        }
×
1013

1014
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
2✔
1015
        p.auditor.Record(audittools.Event{
2✔
1016
                Time:       p.timeNow(),
2✔
1017
                Request:    r,
2✔
1018
                User:       token,
2✔
1019
                ReasonCode: http.StatusAccepted,
2✔
1020
                Action:     cadf.UpdateAction,
2✔
1021
                Target: commitmentEventTarget{
2✔
1022
                        DomainID:    dbDomain.UUID,
2✔
1023
                        DomainName:  dbDomain.Name,
2✔
1024
                        ProjectID:   targetProject.UUID,
2✔
1025
                        ProjectName: targetProject.Name,
2✔
1026
                        Commitments: []limesresources.Commitment{c},
2✔
1027
                },
2✔
1028
        })
2✔
1029

2✔
1030
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1031
}
1032

1033
// GetCommitmentConversion handles GET /v1/commitment-conversion/{service_type}/{resource_name}
1034
func (p *v1Provider) GetCommitmentConversions(w http.ResponseWriter, r *http.Request) {
2✔
1035
        httpapi.IdentifyEndpoint(r, "/v1/commitment-conversion/:service_type/:resource_name")
2✔
1036
        token := p.CheckToken(r)
2✔
1037
        if !token.Require(w, "cluster:show_basic") {
2✔
UNCOV
1038
                return
×
UNCOV
1039
        }
×
1040

1041
        // validate request
1042
        vars := mux.Vars(r)
2✔
1043
        nm := core.BuildResourceNameMapping(p.Cluster)
2✔
1044
        sourceServiceType, sourceResourceName, exists := nm.MapFromV1API(
2✔
1045
                limes.ServiceType(vars["service_type"]),
2✔
1046
                limesresources.ResourceName(vars["resource_name"]),
2✔
1047
        )
2✔
1048
        if !exists {
2✔
UNCOV
1049
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", vars["service_type"], vars["resource_name"])
×
UNCOV
1050
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
UNCOV
1051
                return
×
1052
        }
×
1053
        sourceBehavior := p.Cluster.BehaviorForResource(sourceServiceType, sourceResourceName)
2✔
1054
        sourceResInfo := p.Cluster.InfoForResource(sourceServiceType, sourceResourceName)
2✔
1055

2✔
1056
        // enumerate possible conversions
2✔
1057
        conversions := make([]limesresources.CommitmentConversionRule, 0)
2✔
1058
        for targetServiceType, quotaPlugin := range p.Cluster.QuotaPlugins {
8✔
1059
                for targetResourceName, targetResInfo := range quotaPlugin.Resources() {
28✔
1060
                        targetBehavior := p.Cluster.BehaviorForResource(targetServiceType, targetResourceName)
22✔
1061
                        if targetBehavior.CommitmentConversion == (core.CommitmentConversion{}) {
30✔
1062
                                continue
8✔
1063
                        }
1064
                        if sourceServiceType == targetServiceType && sourceResourceName == targetResourceName {
16✔
1065
                                continue
2✔
1066
                        }
1067
                        if sourceResInfo.Unit != targetResInfo.Unit {
19✔
1068
                                continue
7✔
1069
                        }
1070
                        if sourceBehavior.CommitmentConversion.Identifier != targetBehavior.CommitmentConversion.Identifier {
6✔
1071
                                continue
1✔
1072
                        }
1073

1074
                        fromAmount, toAmount := p.getCommitmentConversionRate(sourceBehavior, targetBehavior)
4✔
1075
                        apiServiceType, apiResourceName, ok := nm.MapToV1API(targetServiceType, targetResourceName)
4✔
1076
                        if ok {
8✔
1077
                                conversions = append(conversions, limesresources.CommitmentConversionRule{
4✔
1078
                                        FromAmount:     fromAmount,
4✔
1079
                                        ToAmount:       toAmount,
4✔
1080
                                        TargetService:  apiServiceType,
4✔
1081
                                        TargetResource: apiResourceName,
4✔
1082
                                })
4✔
1083
                        }
4✔
1084
                }
1085
        }
1086

1087
        // use a defined sorting to ensure deterministic behavior in tests
1088
        slices.SortFunc(conversions, func(lhs, rhs limesresources.CommitmentConversionRule) int {
6✔
1089
                result := strings.Compare(string(lhs.TargetService), string(rhs.TargetService))
4✔
1090
                if result != 0 {
7✔
1091
                        return result
3✔
1092
                }
3✔
1093
                return strings.Compare(string(lhs.TargetResource), string(rhs.TargetResource))
1✔
1094
        })
1095

1096
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
2✔
1097
}
1098

1099
// ConvertCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/convert
1100
func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
9✔
1101
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/convert")
9✔
1102
        token := p.CheckToken(r)
9✔
1103
        if !token.Require(w, "project:edit") {
9✔
UNCOV
1104
                return
×
UNCOV
1105
        }
×
1106
        commitmentID := mux.Vars(r)["commitment_id"]
9✔
1107
        if commitmentID == "" {
9✔
1108
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
UNCOV
1109
                return
×
UNCOV
1110
        }
×
1111
        dbDomain := p.FindDomainFromRequest(w, r)
9✔
1112
        if dbDomain == nil {
9✔
1113
                return
×
UNCOV
1114
        }
×
1115
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
9✔
1116
        if dbProject == nil {
9✔
1117
                return
×
UNCOV
1118
        }
×
1119

1120
        // section: sourceBehavior
1121
        var dbCommitment db.ProjectCommitment
9✔
1122
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
9✔
1123
        if errors.Is(err, sql.ErrNoRows) {
10✔
1124
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
1125
                return
1✔
1126
        } else if respondwith.ErrorText(w, err) {
9✔
UNCOV
1127
                return
×
UNCOV
1128
        }
×
1129
        var sourceLoc core.AZResourceLocation
8✔
1130
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
8✔
1131
                Scan(&sourceLoc.ServiceType, &sourceLoc.ResourceName, &sourceLoc.AvailabilityZone)
8✔
1132
        if errors.Is(err, sql.ErrNoRows) {
8✔
UNCOV
1133
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
1134
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
1135
                return
×
1136
        } else if respondwith.ErrorText(w, err) {
8✔
1137
                return
×
1138
        }
×
1139
        sourceBehavior := p.Cluster.BehaviorForResource(sourceLoc.ServiceType, sourceLoc.ResourceName)
8✔
1140

8✔
1141
        // section: targetBehavior
8✔
1142
        var parseTarget struct {
8✔
1143
                Request struct {
8✔
1144
                        TargetService  limes.ServiceType           `json:"target_service"`
8✔
1145
                        TargetResource limesresources.ResourceName `json:"target_resource"`
8✔
1146
                        SourceAmount   uint64                      `json:"source_amount"`
8✔
1147
                        TargetAmount   uint64                      `json:"target_amount"`
8✔
1148
                } `json:"commitment"`
8✔
1149
        }
8✔
1150
        if !RequireJSON(w, r, &parseTarget) {
8✔
UNCOV
1151
                return
×
UNCOV
1152
        }
×
1153
        req := parseTarget.Request
8✔
1154
        nm := core.BuildResourceNameMapping(p.Cluster)
8✔
1155
        targetServiceType, targetResourceName, exists := nm.MapFromV1API(req.TargetService, req.TargetResource)
8✔
1156
        if !exists {
8✔
UNCOV
1157
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", req.TargetService, req.TargetResource)
×
UNCOV
1158
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
UNCOV
1159
                return
×
1160
        }
×
1161
        targetBehavior := p.Cluster.BehaviorForResource(targetServiceType, targetResourceName)
8✔
1162
        if sourceLoc.ResourceName == targetResourceName && sourceLoc.ServiceType == targetServiceType {
9✔
1163
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
1✔
1164
                return
1✔
1165
        }
1✔
1166
        if len(targetBehavior.CommitmentDurations) == 0 {
7✔
UNCOV
1167
                msg := fmt.Sprintf("commitments are not enabled for resource %s/%s", req.TargetService, req.TargetResource)
×
UNCOV
1168
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
UNCOV
1169
                return
×
1170
        }
×
1171
        if sourceBehavior.CommitmentConversion.Identifier == "" || sourceBehavior.CommitmentConversion.Identifier != targetBehavior.CommitmentConversion.Identifier {
8✔
1172
                msg := fmt.Sprintf("commitment is not convertible into resource %s/%s", req.TargetService, req.TargetResource)
1✔
1173
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1174
                return
1✔
1175
        }
1✔
1176

1177
        // section: conversion
1178
        if req.SourceAmount > dbCommitment.Amount {
6✔
UNCOV
1179
                msg := fmt.Sprintf("unprocessable source amount. provided: %v, commitment: %v", req.SourceAmount, dbCommitment.Amount)
×
UNCOV
1180
                http.Error(w, msg, http.StatusConflict)
×
UNCOV
1181
                return
×
1182
        }
×
1183
        fromAmount, toAmount := p.getCommitmentConversionRate(sourceBehavior, targetBehavior)
6✔
1184
        conversionAmount := (req.SourceAmount / fromAmount) * toAmount
6✔
1185
        remainderAmount := req.SourceAmount % fromAmount
6✔
1186
        if remainderAmount > 0 {
8✔
1187
                msg := fmt.Sprintf("amount: %v does not fit into conversion rate of: %v", req.SourceAmount, fromAmount)
2✔
1188
                http.Error(w, msg, http.StatusConflict)
2✔
1189
                return
2✔
1190
        }
2✔
1191
        if conversionAmount != req.TargetAmount {
5✔
1192
                msg := fmt.Sprintf("conversion mismatch. provided: %v, calculated: %v", req.TargetAmount, conversionAmount)
1✔
1193
                http.Error(w, msg, http.StatusConflict)
1✔
1194
                return
1✔
1195
        }
1✔
1196

1197
        tx, err := p.DB.Begin()
3✔
1198
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
1199
                return
×
UNCOV
1200
        }
×
1201
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1202

3✔
1203
        var (
3✔
1204
                targetResourceID   db.ProjectResourceID
3✔
1205
                targetAZResourceID db.ProjectAZResourceID
3✔
1206
        )
3✔
1207
        err = p.DB.QueryRow(findTargetAZResourceByTargetProjectQuery, dbProject.ID, targetServiceType, targetResourceName, sourceLoc.AvailabilityZone).
3✔
1208
                Scan(&targetResourceID, &targetAZResourceID)
3✔
1209
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
1210
                return
×
UNCOV
1211
        }
×
1212
        // defense in depth. ServiceType and ResourceName of source and target are already checked. Here it's possible to explicitly check the ID's.
1213
        if dbCommitment.AZResourceID == targetAZResourceID {
3✔
1214
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
×
UNCOV
1215
                return
×
UNCOV
1216
        }
×
1217
        targetLoc := core.AZResourceLocation{
3✔
1218
                ServiceType:      targetServiceType,
3✔
1219
                ResourceName:     targetResourceName,
3✔
1220
                AvailabilityZone: sourceLoc.AvailabilityZone,
3✔
1221
        }
3✔
1222
        // The commitment at the source resource was already confirmed and checked.
3✔
1223
        // Therefore only the addition to the target resource has to be checked against.
3✔
1224
        if dbCommitment.ConfirmedAt != nil {
5✔
1225
                ok, err := datamodel.CanConfirmNewCommitment(targetLoc, targetResourceID, conversionAmount, p.Cluster, p.DB)
2✔
1226
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
1227
                        return
×
UNCOV
1228
                }
×
1229
                if !ok {
3✔
1230
                        http.Error(w, "not enough capacity to confirm the commitment", http.StatusUnprocessableEntity)
1✔
1231
                        return
1✔
1232
                }
1✔
1233
        }
1234

1235
        auditEvent := commitmentEventTarget{
2✔
1236
                DomainID:    dbDomain.UUID,
2✔
1237
                DomainName:  dbDomain.Name,
2✔
1238
                ProjectID:   dbProject.UUID,
2✔
1239
                ProjectName: dbProject.Name,
2✔
1240
        }
2✔
1241

2✔
1242
        relatedCommitmentIDs := make([]db.ProjectCommitmentID, 0)
2✔
1243
        remainingAmount := dbCommitment.Amount - req.SourceAmount
2✔
1244
        if remainingAmount > 0 {
3✔
1245
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
1✔
1246
                if respondwith.ErrorText(w, err) {
1✔
UNCOV
1247
                        return
×
UNCOV
1248
                }
×
1249
                relatedCommitmentIDs = append(relatedCommitmentIDs, remainingCommitment.ID)
1✔
1250
                err = tx.Insert(&remainingCommitment)
1✔
1251
                if respondwith.ErrorText(w, err) {
1✔
UNCOV
1252
                        return
×
UNCOV
1253
                }
×
1254
                auditEvent.Commitments = append(auditEvent.Commitments,
1✔
1255
                        p.convertCommitmentToDisplayForm(remainingCommitment, sourceLoc, token),
1✔
1256
                )
1✔
1257
        }
1258

1259
        convertedCommitment, err := p.buildConvertedCommitment(dbCommitment, targetAZResourceID, conversionAmount)
2✔
1260
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1261
                return
×
UNCOV
1262
        }
×
1263
        relatedCommitmentIDs = append(relatedCommitmentIDs, convertedCommitment.ID)
2✔
1264
        err = tx.Insert(&convertedCommitment)
2✔
1265
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1266
                return
×
UNCOV
1267
        }
×
1268

1269
        // supersede the original commitment
1270
        now := p.timeNow()
2✔
1271
        supersedeContext := db.CommitmentWorkflowContext{
2✔
1272
                Reason:               db.CommitmentReasonConvert,
2✔
1273
                RelatedCommitmentIDs: relatedCommitmentIDs,
2✔
1274
        }
2✔
1275
        buf, err := json.Marshal(supersedeContext)
2✔
1276
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1277
                return
×
UNCOV
1278
        }
×
1279
        dbCommitment.State = db.CommitmentStateSuperseded
2✔
1280
        dbCommitment.SupersededAt = &now
2✔
1281
        dbCommitment.SupersedeContextJSON = liquids.PointerTo(json.RawMessage(buf))
2✔
1282
        _, err = tx.Update(&dbCommitment)
2✔
1283
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1284
                return
×
UNCOV
1285
        }
×
1286

1287
        err = tx.Commit()
2✔
1288
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1289
                return
×
UNCOV
1290
        }
×
1291

1292
        c := p.convertCommitmentToDisplayForm(convertedCommitment, targetLoc, token)
2✔
1293
        auditEvent.Commitments = append([]limesresources.Commitment{c}, auditEvent.Commitments...)
2✔
1294
        auditEvent.WorkflowContext = &db.CommitmentWorkflowContext{
2✔
1295
                Reason:               db.CommitmentReasonSplit,
2✔
1296
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1297
        }
2✔
1298
        p.auditor.Record(audittools.Event{
2✔
1299
                Time:       p.timeNow(),
2✔
1300
                Request:    r,
2✔
1301
                User:       token,
2✔
1302
                ReasonCode: http.StatusAccepted,
2✔
1303
                Action:     cadf.UpdateAction,
2✔
1304
                Target:     auditEvent,
2✔
1305
        })
2✔
1306

2✔
1307
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1308
}
1309

1310
func (p *v1Provider) getCommitmentConversionRate(source, target core.ResourceBehavior) (fromAmount, toAmount uint64) {
10✔
1311
        divisor := GetGreatestCommonDivisor(source.CommitmentConversion.Weight, target.CommitmentConversion.Weight)
10✔
1312
        fromAmount = target.CommitmentConversion.Weight / divisor
10✔
1313
        toAmount = source.CommitmentConversion.Weight / divisor
10✔
1314
        return fromAmount, toAmount
10✔
1315
}
10✔
1316

1317
// ExtendCommitmentDuration handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/update-duration
1318
func (p *v1Provider) UpdateCommitmentDuration(w http.ResponseWriter, r *http.Request) {
6✔
1319
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/update-duration")
6✔
1320
        token := p.CheckToken(r)
6✔
1321
        if !token.Require(w, "project:edit") {
6✔
UNCOV
1322
                return
×
UNCOV
1323
        }
×
1324
        commitmentID := mux.Vars(r)["commitment_id"]
6✔
1325
        if commitmentID == "" {
6✔
1326
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
UNCOV
1327
                return
×
UNCOV
1328
        }
×
1329
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
1330
        if dbDomain == nil {
6✔
1331
                return
×
UNCOV
1332
        }
×
1333
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
1334
        if dbProject == nil {
6✔
1335
                return
×
UNCOV
1336
        }
×
1337
        var Request struct {
6✔
1338
                Duration limesresources.CommitmentDuration `json:"duration"`
6✔
1339
        }
6✔
1340
        req := Request
6✔
1341
        if !RequireJSON(w, r, &req) {
6✔
UNCOV
1342
                return
×
UNCOV
1343
        }
×
1344

1345
        var dbCommitment db.ProjectCommitment
6✔
1346
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
6✔
1347
        if errors.Is(err, sql.ErrNoRows) {
6✔
UNCOV
1348
                http.Error(w, "no such commitment", http.StatusNotFound)
×
UNCOV
1349
                return
×
1350
        } else if respondwith.ErrorText(w, err) {
6✔
1351
                return
×
1352
        }
×
1353

1354
        now := p.timeNow()
6✔
1355
        if dbCommitment.ExpiresAt.Before(now) || dbCommitment.ExpiresAt.Equal(now) {
7✔
1356
                http.Error(w, "unable to process expired commitment", http.StatusForbidden)
1✔
1357
                return
1✔
1358
        }
1✔
1359

1360
        if dbCommitment.State == db.CommitmentStateSuperseded {
6✔
1361
                msg := fmt.Sprintf("unable to operate on commitment with a state of %s", dbCommitment.State)
1✔
1362
                http.Error(w, msg, http.StatusForbidden)
1✔
1363
                return
1✔
1364
        }
1✔
1365

1366
        var loc core.AZResourceLocation
4✔
1367
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
4✔
1368
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
4✔
1369
        if errors.Is(err, sql.ErrNoRows) {
4✔
UNCOV
1370
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
1371
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
1372
                return
×
1373
        } else if respondwith.ErrorText(w, err) {
4✔
1374
                return
×
1375
        }
×
1376
        behavior := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName)
4✔
1377
        if !slices.Contains(behavior.CommitmentDurations, req.Duration) {
5✔
1378
                msg := fmt.Sprintf("provided duration: %s does not match the config %v", req.Duration, behavior.CommitmentDurations)
1✔
1379
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1380
                return
1✔
1381
        }
1✔
1382

1383
        newExpiresAt := req.Duration.AddTo(unwrapOrDefault(dbCommitment.ConfirmBy, dbCommitment.CreatedAt))
3✔
1384
        if newExpiresAt.Before(dbCommitment.ExpiresAt) {
4✔
1385
                msg := fmt.Sprintf("duration change from %s to %s forbidden", dbCommitment.Duration, req.Duration)
1✔
1386
                http.Error(w, msg, http.StatusForbidden)
1✔
1387
                return
1✔
1388
        }
1✔
1389

1390
        dbCommitment.Duration = req.Duration
2✔
1391
        dbCommitment.ExpiresAt = newExpiresAt
2✔
1392
        _, err = p.DB.Update(&dbCommitment)
2✔
1393
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1394
                return
×
UNCOV
1395
        }
×
1396

1397
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
2✔
1398
        p.auditor.Record(audittools.Event{
2✔
1399
                Time:       p.timeNow(),
2✔
1400
                Request:    r,
2✔
1401
                User:       token,
2✔
1402
                ReasonCode: http.StatusOK,
2✔
1403
                Action:     cadf.UpdateAction,
2✔
1404
                Target: commitmentEventTarget{
2✔
1405
                        DomainID:    dbDomain.UUID,
2✔
1406
                        DomainName:  dbDomain.Name,
2✔
1407
                        ProjectID:   dbProject.UUID,
2✔
1408
                        ProjectName: dbProject.Name,
2✔
1409
                        Commitments: []limesresources.Commitment{c},
2✔
1410
                },
2✔
1411
        })
2✔
1412

2✔
1413
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitment": c})
2✔
1414
}
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