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

sapcc / limes / 16807983305

07 Aug 2025 02:52PM UTC coverage: 79.02% (+0.1%) from 78.924%
16807983305

Pull #759

github

VoigtS
resource report: assign autogrowth value directly
Pull Request #759: add endpoint: forbid-autogrowth

108 of 124 new or added lines in 4 files covered. (87.1%)

1 existing line in 1 file now uncovered.

6949 of 8794 relevant lines covered (79.02%)

59.66 hits per line

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

79.2
/internal/api/projects.go
1
// SPDX-FileCopyrightText: 2017 SAP SE or an SAP affiliate company
2
// SPDX-License-Identifier: Apache-2.0
3

4
package api
5

6
import (
7
        "fmt"
8
        "net/http"
9
        "slices"
10

11
        "github.com/gorilla/mux"
12
        . "github.com/majewsky/gg/option"
13
        "github.com/sapcc/go-api-declarations/cadf"
14
        "github.com/sapcc/go-api-declarations/limes"
15
        limesresources "github.com/sapcc/go-api-declarations/limes/resources"
16
        "github.com/sapcc/go-api-declarations/liquid"
17
        "github.com/sapcc/go-bits/audittools"
18
        "github.com/sapcc/go-bits/httpapi"
19
        "github.com/sapcc/go-bits/respondwith"
20
        "github.com/sapcc/go-bits/sqlext"
21

22
        "github.com/sapcc/limes/internal/collector"
23
        "github.com/sapcc/limes/internal/core"
24
        "github.com/sapcc/limes/internal/datamodel"
25
        "github.com/sapcc/limes/internal/db"
26
        "github.com/sapcc/limes/internal/reports"
27
        "github.com/sapcc/limes/internal/util"
28
)
29

30
// ListProjects handles GET /v1/domains/:domain_id/projects.
31
func (p *v1Provider) ListProjects(w http.ResponseWriter, r *http.Request) {
11✔
32
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects")
11✔
33
        token := p.CheckToken(r)
11✔
34
        if !token.Require(w, "project:list") {
11✔
35
                return
×
36
        }
×
37
        dbDomain := p.FindDomainFromRequest(w, r)
11✔
38
        if dbDomain == nil {
11✔
39
                return
×
40
        }
×
41

42
        // This endpoint can generate reports so large, we shouldn't be rendering
43
        // more than one at the same time in order to keep our memory usage in check.
44
        // (For example, a full project list with all resources for a domain with 2000
45
        // projects runs as large as 160 MiB for the pure JSON.)
46
        p.listProjectsMutex.Lock()
11✔
47
        defer p.listProjectsMutex.Unlock()
11✔
48

11✔
49
        serviceInfos, err := p.Cluster.AllServiceInfos()
11✔
50
        if respondwith.ErrorText(w, err) {
11✔
51
                return
×
52
        }
×
53

54
        filter := reports.ReadFilter(r, p.Cluster, serviceInfos)
11✔
55
        stream := NewJSONListStream[*limesresources.ProjectReport](w, r, "projects")
11✔
56
        stream.FinalizeDocument(reports.GetProjectResources(p.Cluster, *dbDomain, nil, p.timeNow(), p.DB, filter, serviceInfos, stream.WriteItem))
11✔
57
}
58

59
// GetProject handles GET /v1/domains/:domain_id/projects/:project_id.
60
func (p *v1Provider) GetProject(w http.ResponseWriter, r *http.Request) {
31✔
61
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id")
31✔
62
        token := p.CheckToken(r)
31✔
63
        if !token.Require(w, "project:show") {
31✔
64
                return
×
65
        }
×
66
        dbDomain := p.FindDomainFromRequest(w, r)
31✔
67
        if dbDomain == nil {
32✔
68
                return
1✔
69
        }
1✔
70
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
30✔
71
        if dbProject == nil {
31✔
72
                return
1✔
73
        }
1✔
74

75
        serviceInfos, err := p.Cluster.AllServiceInfos()
29✔
76
        if respondwith.ErrorText(w, err) {
29✔
77
                return
×
78
        }
×
79

80
        project, err := GetProjectResourceReport(p.Cluster, *dbDomain, *dbProject, p.timeNow(), p.DB, reports.ReadFilter(r, p.Cluster, serviceInfos), serviceInfos)
29✔
81
        if respondwith.ErrorText(w, err) {
29✔
82
                return
×
83
        }
×
84
        respondwith.JSON(w, 200, map[string]any{"project": project})
29✔
85
}
86

87
// DiscoverProjects handles POST /v1/domains/:domain_id/projects/discover.
88
func (p *v1Provider) DiscoverProjects(w http.ResponseWriter, r *http.Request) {
2✔
89
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/discover")
2✔
90
        token := p.CheckToken(r)
2✔
91
        if !token.Require(w, "project:discover") {
2✔
92
                return
×
93
        }
×
94
        dbDomain := p.FindDomainFromRequest(w, r)
2✔
95
        if dbDomain == nil {
2✔
96
                return
×
97
        }
×
98

99
        c := collector.NewCollector(p.Cluster)
2✔
100
        newProjectUUIDs, err := c.ScanProjects(r.Context(), dbDomain)
2✔
101
        if respondwith.ErrorText(w, err) {
2✔
102
                return
×
103
        }
×
104

105
        if len(newProjectUUIDs) == 0 {
3✔
106
                w.WriteHeader(http.StatusNoContent)
1✔
107
                return
1✔
108
        }
1✔
109
        respondwith.JSON(w, 202, map[string]any{"new_projects": util.IDsToJSON(newProjectUUIDs)})
1✔
110
}
111

112
// SyncProject handles POST /v1/domains/:domain_id/projects/:project_id/sync.
113
func (p *v1Provider) SyncProject(w http.ResponseWriter, r *http.Request) {
2✔
114
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/sync")
2✔
115
        p.doSyncProject(w, r)
2✔
116
}
2✔
117

118
func (p *v1Provider) doSyncProject(w http.ResponseWriter, r *http.Request) {
2✔
119
        token := p.CheckToken(r)
2✔
120
        if !token.Require(w, "project:show") {
2✔
121
                return
×
122
        }
×
123
        dbDomain := p.FindDomainFromRequest(w, r)
2✔
124
        if dbDomain == nil {
2✔
125
                return
×
126
        }
×
127
        dbProject, ok := p.FindProjectFromRequestIfExists(w, r, dbDomain)
2✔
128
        if !ok {
2✔
129
                return
×
130
        }
×
131

132
        // check if project needs to be discovered
133
        if dbProject == nil {
3✔
134
                c := collector.NewCollector(p.Cluster)
1✔
135
                newProjectUUIDs, err := c.ScanProjects(r.Context(), dbDomain)
1✔
136
                if respondwith.ErrorText(w, err) {
1✔
137
                        return
×
138
                }
×
139
                projectUUID := mux.Vars(r)["project_id"]
1✔
140
                found := slices.Contains(newProjectUUIDs, projectUUID)
1✔
141
                if !found {
1✔
142
                        http.Error(w, "no such project", http.StatusNotFound)
×
143
                        return
×
144
                }
×
145

146
                // now we should find it in the DB
147
                dbProject = p.FindProjectFromRequest(w, r, dbDomain)
1✔
148
                if dbProject == nil {
1✔
149
                        return // wtf
×
150
                }
×
151
        }
152

153
        // mark all project services as stale to force limes-collect to sync ASAP
154
        _, err := p.DB.Exec(`UPDATE project_services SET stale = '1' WHERE project_id = $1`, dbProject.ID)
2✔
155
        if respondwith.ErrorText(w, err) {
2✔
156
                return
×
157
        }
×
158

159
        w.WriteHeader(http.StatusAccepted)
2✔
160
}
161

162
// PutProject handles PUT /v1/domains/:domain_id/projects/:project_id.
163
func (p *v1Provider) PutProject(w http.ResponseWriter, r *http.Request) {
×
164
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id")
×
165
        http.Error(w, "support for setting quotas manually has been removed", http.StatusMethodNotAllowed)
×
166
}
×
167

168
// SimulatePutProject handles POST /v1/domains/:domain_id/projects/:project_id/simulate-put.
169
func (p *v1Provider) SimulatePutProject(w http.ResponseWriter, r *http.Request) {
×
170
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/simulate-put")
×
171
        http.Error(w, "support for setting quotas manually has been removed", http.StatusMethodNotAllowed)
×
172
}
×
173

174
// PutProjectMaxQuota handles PUT /v1/domains/:domain_id/projects/:project_id/max-quota.
175
func (p *v1Provider) PutProjectMaxQuota(w http.ResponseWriter, r *http.Request) {
10✔
176
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/max-quota")
10✔
177
        requestTime := p.timeNow()
10✔
178
        token := p.CheckToken(r)
10✔
179
        if !token.Require(w, "project:edit") {
11✔
180
                return
1✔
181
        }
1✔
182
        // domain admins have project edit rights by inheritance.
183
        domainAccess := token.Check("project:edit_as_outside_admin")
9✔
184
        dbDomain := p.FindDomainFromRequest(w, r)
9✔
185
        if dbDomain == nil {
9✔
186
                return
×
187
        }
×
188
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
9✔
189
        if dbProject == nil {
9✔
190
                return
×
191
        }
×
192

193
        // parse request body
194
        var parseTarget struct {
9✔
195
                Project struct {
9✔
196
                        Services []struct {
9✔
197
                                Type      limes.ServiceType `json:"type"`
9✔
198
                                Resources []struct {
9✔
199
                                        Name     limesresources.ResourceName `json:"name"`
9✔
200
                                        MaxQuota *uint64                     `json:"max_quota"`
9✔
201
                                        Unit     *limes.Unit                 `json:"unit"`
9✔
202
                                } `json:"resources"`
9✔
203
                        } `json:"services"`
9✔
204
                } `json:"project"`
9✔
205
        }
9✔
206
        if !RequireJSON(w, r, &parseTarget) {
9✔
207
                return
×
208
        }
×
209

210
        serviceInfos, err := p.Cluster.AllServiceInfos()
9✔
211
        if respondwith.ErrorText(w, err) {
9✔
212
                return
×
213
        }
×
214

215
        // validate request
216
        nm := core.BuildResourceNameMapping(p.Cluster, serviceInfos)
9✔
217
        requested := make(map[db.ServiceType]map[liquid.ResourceName]*maxQuotaChange)
9✔
218
        for _, srvRequest := range parseTarget.Project.Services {
18✔
219
                for _, resRequest := range srvRequest.Resources {
19✔
220
                        dbServiceType, dbResourceName, exists := nm.MapFromV1API(srvRequest.Type, resRequest.Name)
10✔
221
                        if !exists {
12✔
222
                                msg := fmt.Sprintf("no such service and/or resource: %s/%s", srvRequest.Type, resRequest.Name)
2✔
223
                                http.Error(w, msg, http.StatusUnprocessableEntity)
2✔
224
                                return
2✔
225
                        }
2✔
226

227
                        if requested[dbServiceType] == nil {
15✔
228
                                requested[dbServiceType] = make(map[liquid.ResourceName]*maxQuotaChange)
7✔
229
                        }
7✔
230
                        if resRequest.MaxQuota == nil {
10✔
231
                                requested[dbServiceType][dbResourceName] = &maxQuotaChange{NewValue: None[uint64]()}
2✔
232
                        } else {
8✔
233
                                serviceInfo := core.InfoForService(serviceInfos, dbServiceType)
6✔
234
                                resInfo := core.InfoForResource(serviceInfo, dbResourceName)
6✔
235
                                if !resInfo.HasQuota {
7✔
236
                                        msg := fmt.Sprintf("resource %s/%s does not track quota", dbServiceType, dbResourceName)
1✔
237
                                        http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
238
                                        return
1✔
239
                                }
1✔
240

241
                                // convert given value to correct unit
242
                                requestedMaxQuota := limes.ValueWithUnit{
5✔
243
                                        Unit:  limes.UnitUnspecified,
5✔
244
                                        Value: *resRequest.MaxQuota,
5✔
245
                                }
5✔
246
                                if resRequest.Unit != nil {
7✔
247
                                        requestedMaxQuota.Unit = *resRequest.Unit
2✔
248
                                }
2✔
249
                                convertedMaxQuota, err := core.ConvertUnitFor(serviceInfo, dbResourceName, requestedMaxQuota)
5✔
250
                                if err != nil {
6✔
251
                                        msg := fmt.Sprintf("invalid input for %s/%s: %s", dbServiceType, dbResourceName, err.Error())
1✔
252
                                        http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
253
                                        return
1✔
254
                                }
1✔
255
                                requested[dbServiceType][dbResourceName] = &maxQuotaChange{NewValue: Some(convertedMaxQuota)}
4✔
256
                        }
257
                }
258
        }
259

260
        // write requested values to DB
261
        tx, err := p.DB.Begin()
5✔
262
        if respondwith.ErrorText(w, err) {
5✔
263
                return
×
264
        }
×
265
        defer sqlext.RollbackUnlessCommitted(tx)
5✔
266

5✔
267
        var services []db.ClusterService
5✔
268
        _, err = tx.Select(&services,
5✔
269
                `SELECT cs.* FROM cluster_services cs JOIN project_services ps ON ps.service_id = cs.id and ps.project_id = $1 ORDER BY cs.type`, dbProject.ID)
5✔
270
        if respondwith.ErrorText(w, err) {
5✔
271
                return
×
272
        }
×
273

274
        for _, srv := range services {
15✔
275
                requestedInService, exists := requested[srv.Type]
10✔
276
                if !exists {
15✔
277
                        continue
5✔
278
                }
279

280
                _, err := datamodel.ProjectResourceUpdate{
5✔
281
                        UpdateResource: func(res *db.ProjectResource, resName liquid.ResourceName) error {
15✔
282
                                requestedChange := requestedInService[resName]
10✔
283
                                if requestedChange != nil && domainAccess {
15✔
284
                                        requestedChange.OldValue = res.MaxQuotaFromOutsideAdmin // remember for audit event
5✔
285
                                        res.MaxQuotaFromOutsideAdmin = requestedChange.NewValue
5✔
286
                                        return nil
5✔
287
                                }
5✔
288
                                if requestedChange != nil {
6✔
289
                                        requestedChange.OldValue = res.MaxQuotaFromLocalAdmin
1✔
290
                                        res.MaxQuotaFromLocalAdmin = requestedChange.NewValue
1✔
291
                                }
1✔
292
                                return nil
5✔
293
                        },
294
                }.Run(tx, serviceInfos[srv.Type], p.timeNow(), *dbDomain, *dbProject, srv)
295
                if respondwith.ErrorText(w, err) {
5✔
296
                        return
×
297
                }
×
298
        }
299

300
        err = tx.Commit()
5✔
301
        if respondwith.ErrorText(w, err) {
5✔
302
                return
×
303
        }
×
304

305
        // write audit trail
306
        for dbServiceType, requestedInService := range requested {
10✔
307
                for dbResourceName, requestedChange := range requestedInService {
11✔
308
                        apiServiceType, apiResourceName, exists := nm.MapToV1API(dbServiceType, dbResourceName)
6✔
309
                        if exists {
12✔
310
                                p.auditor.Record(audittools.Event{
6✔
311
                                        Time:       requestTime,
6✔
312
                                        Request:    r,
6✔
313
                                        User:       token,
6✔
314
                                        ReasonCode: http.StatusAccepted,
6✔
315
                                        Action:     cadf.UpdateAction,
6✔
316
                                        Target: maxQuotaEventTarget{
6✔
317
                                                DomainID:        dbDomain.UUID,
6✔
318
                                                DomainName:      dbDomain.Name,
6✔
319
                                                ProjectID:       dbProject.UUID, // is empty for domain quota updates, see above
6✔
320
                                                ProjectName:     dbProject.Name,
6✔
321
                                                ServiceType:     apiServiceType,
6✔
322
                                                ResourceName:    apiResourceName,
6✔
323
                                                RequestedChange: *requestedChange,
6✔
324
                                        },
6✔
325
                                })
6✔
326
                        }
6✔
327
                }
328
        }
329

330
        w.WriteHeader(http.StatusAccepted)
5✔
331
}
332

333
// PutQuotaAutogrowth handles PUT /v1/domains/:domain_id/projects/:project_id/forbid-autogrowth.
334
func (p *v1Provider) PutQuotaAutogrowth(w http.ResponseWriter, r *http.Request) {
9✔
335
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/forbid-autogrowth")
9✔
336
        requestTime := p.timeNow()
9✔
337
        token := p.CheckToken(r)
9✔
338
        if !token.Require(w, "project:edit") {
10✔
339
                return
1✔
340
        }
1✔
341
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
342
        if dbDomain == nil {
8✔
NEW
343
                return
×
NEW
344
        }
×
345
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
8✔
346
        if dbProject == nil {
8✔
NEW
347
                return
×
NEW
348
        }
×
349

350
        // parse request body
351
        var parseTarget struct {
8✔
352
                Project struct {
8✔
353
                        Services []struct {
8✔
354
                                Type      limes.ServiceType `json:"type"`
8✔
355
                                Resources []struct {
8✔
356
                                        Name             limesresources.ResourceName `json:"name"`
8✔
357
                                        ForbidAutogrowth *bool                       `json:"forbid_autogrowth"`
8✔
358
                                } `json:"resources"`
8✔
359
                        } `json:"services"`
8✔
360
                } `json:"project"`
8✔
361
        }
8✔
362
        if !RequireJSON(w, r, &parseTarget) {
8✔
NEW
363
                return
×
NEW
364
        }
×
365

366
        serviceInfos, err := p.Cluster.AllServiceInfos()
8✔
367
        if respondwith.ErrorText(w, err) {
8✔
NEW
368
                return
×
NEW
369
        }
×
370

371
        // validate request
372
        nm := core.BuildResourceNameMapping(p.Cluster, serviceInfos)
8✔
373
        requested := make(map[db.ServiceType]map[liquid.ResourceName]*autogrowthChange)
8✔
374
        for _, srvRequest := range parseTarget.Project.Services {
16✔
375
                for _, resRequest := range srvRequest.Resources {
17✔
376
                        dbServiceType, dbResourceName, exists := nm.MapFromV1API(srvRequest.Type, resRequest.Name)
9✔
377
                        if !exists {
11✔
378
                                msg := fmt.Sprintf("no such service and/or resource: %s/%s", srvRequest.Type, resRequest.Name)
2✔
379
                                http.Error(w, msg, http.StatusUnprocessableEntity)
2✔
380
                                return
2✔
381
                        }
2✔
382

383
                        if resRequest.ForbidAutogrowth == nil {
8✔
384
                                msg := fmt.Sprintf("malformed request body for resource: %s/%s", srvRequest.Type, resRequest.Name)
1✔
385
                                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
386
                                return
1✔
387
                        }
1✔
388

389
                        serviceInfo := core.InfoForService(serviceInfos, dbServiceType)
6✔
390
                        resInfo := core.InfoForResource(serviceInfo, dbResourceName)
6✔
391
                        if !resInfo.HasQuota {
7✔
392
                                msg := fmt.Sprintf("resource %s/%s does not track quota", dbServiceType, dbResourceName)
1✔
393
                                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
394
                                return
1✔
395
                        }
1✔
396

397
                        if requested[dbServiceType] == nil {
9✔
398
                                requested[dbServiceType] = make(map[liquid.ResourceName]*autogrowthChange)
4✔
399
                        }
4✔
400

401
                        requested[dbServiceType][dbResourceName] = &autogrowthChange{ForbidAutogrowth: *resRequest.ForbidAutogrowth}
5✔
402
                }
403
        }
404

405
        // write requested values to DB
406
        tx, err := p.DB.Begin()
4✔
407
        if respondwith.ErrorText(w, err) {
4✔
NEW
408
                return
×
NEW
409
        }
×
410
        defer sqlext.RollbackUnlessCommitted(tx)
4✔
411

4✔
412
        var services []db.ClusterService
4✔
413
        _, err = tx.Select(&services,
4✔
414
                `SELECT cs.* FROM cluster_services cs JOIN project_services ps ON ps.service_id = cs.id and ps.project_id = $1 ORDER BY cs.type`, dbProject.ID)
4✔
415
        if respondwith.ErrorText(w, err) {
4✔
NEW
416
                return
×
NEW
417
        }
×
418

419
        for _, srv := range services {
12✔
420
                requestedInService, exists := requested[srv.Type]
8✔
421
                if !exists {
12✔
422
                        continue
4✔
423
                }
424

425
                _, err := datamodel.ProjectResourceUpdate{
4✔
426
                        UpdateResource: func(res *db.ProjectResource, resName liquid.ResourceName) error {
12✔
427
                                requestedChange := requestedInService[resName]
8✔
428
                                if requestedChange != nil && requestedChange.ForbidAutogrowth != res.ForbidAutogrowth {
12✔
429
                                        res.ForbidAutogrowth = requestedChange.ForbidAutogrowth
4✔
430
                                        return nil
4✔
431
                                }
4✔
432
                                return nil
4✔
433
                        },
434
                }.Run(tx, serviceInfos[srv.Type], p.timeNow(), *dbDomain, *dbProject, srv)
435
                if respondwith.ErrorText(w, err) {
4✔
NEW
436
                        return
×
NEW
437
                }
×
438
        }
439

440
        err = tx.Commit()
4✔
441
        if respondwith.ErrorText(w, err) {
4✔
NEW
442
                return
×
NEW
443
        }
×
444

445
        // write audit trail
446
        for dbServiceType, requestedInService := range requested {
8✔
447
                for dbResourceName, requestedChange := range requestedInService {
9✔
448
                        apiServiceType, apiResourceName, exists := nm.MapToV1API(dbServiceType, dbResourceName)
5✔
449
                        if exists {
10✔
450
                                p.auditor.Record(audittools.Event{
5✔
451
                                        Time:       requestTime,
5✔
452
                                        Request:    r,
5✔
453
                                        User:       token,
5✔
454
                                        ReasonCode: http.StatusAccepted,
5✔
455
                                        Action:     cadf.UpdateAction,
5✔
456
                                        Target: maxQuotaEventTarget{
5✔
457
                                                DomainID:         dbDomain.UUID,
5✔
458
                                                DomainName:       dbDomain.Name,
5✔
459
                                                ProjectID:        dbProject.UUID,
5✔
460
                                                ProjectName:      dbProject.Name,
5✔
461
                                                ServiceType:      apiServiceType,
5✔
462
                                                ResourceName:     apiResourceName,
5✔
463
                                                AutogrowthChange: *requestedChange,
5✔
464
                                        },
5✔
465
                                })
5✔
466
                        }
5✔
467
                }
468
        }
469

470
        w.WriteHeader(http.StatusAccepted)
4✔
471
}
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