@@ -263,3 +263,219 @@ func TestQuotasAPI(t *testing.T) {
263263 ).ExpectText (t , http .StatusUnprocessableEntity , "requested manifest quota (5) is below usage (10)\n " )
264264 s .Auditor .ExpectEvents (t /*, nothing */ )
265265}
266+
267+ func TestQuotasAPIWithBytes (t * testing.T ) {
268+ // NOTE: This tests both the Keppel-native quota API and the LIQUID API which accesses the same logic.
269+ s := test .NewSetup (t ,
270+ test .WithKeppelAPI ,
271+ test .WithBytesQuotas ,
272+ test .WithAccount (models.Account {Name : "test1" , AuthTenantID : "tenant1" }),
273+ )
274+ ctx := t .Context ()
275+
276+ var infoVersion int64
277+
278+ s .RespondTo (ctx , "GET /liquid/v1/info" ,
279+ withPerms ("viewquota:tenant1" ),
280+ ).ExpectJSON (t , http .StatusOK , jsonmatch.Object {
281+ "capacityMetricFamilies" : nil ,
282+ "categories" : nil ,
283+ "displayName" : "Container Image Registry" ,
284+ "rates" : nil ,
285+ "resources" : jsonmatch.Object {
286+ "capacity" : jsonmatch.Object {
287+ "displayName" : "Capacity" ,
288+ "hasCapacity" : false ,
289+ "hasQuota" : true ,
290+ "needsResourceDemand" : false ,
291+ "topology" : "flat" ,
292+ "unit" : "B" ,
293+ },
294+ "images" : jsonmatch.Object {
295+ "displayName" : "Images" ,
296+ "hasCapacity" : false ,
297+ "hasQuota" : true ,
298+ "needsResourceDemand" : false ,
299+ "topology" : "flat" ,
300+ },
301+ },
302+ "usageMetricFamilies" : nil ,
303+ "version" : jsonmatch .CaptureField (& infoVersion ),
304+ })
305+
306+ // GET on auth tenant without more specific configuration shows default values
307+ s .RespondTo (ctx , "GET /keppel/v1/quotas/tenant1" , withPerms ("viewquota:tenant1" )).
308+ ExpectJSON (t , http .StatusOK , jsonmatch.Object {
309+ "bytes" : jsonmatch.Object {"quota" : 0 , "usage" : 0 },
310+ "manifests" : jsonmatch.Object {"quota" : 0 , "usage" : 0 },
311+ })
312+ buildLiquidResponse := func (capacityQuota , capacityUsage , imagesQuota , imagesUsage uint64 ) jsonmatch.Object {
313+ return jsonmatch.Object {
314+ "infoVersion" : infoVersion ,
315+ "resources" : jsonmatch.Object {
316+ "capacity" : jsonmatch.Object {
317+ "forbidden" : false ,
318+ "quota" : capacityQuota ,
319+ "perAZ" : jsonmatch.Object {
320+ "any" : jsonmatch.Object {
321+ "usage" : capacityUsage ,
322+ },
323+ },
324+ },
325+ "images" : jsonmatch.Object {
326+ "forbidden" : false ,
327+ "quota" : imagesQuota ,
328+ "perAZ" : jsonmatch.Object {
329+ "any" : jsonmatch.Object {
330+ "usage" : imagesUsage ,
331+ },
332+ },
333+ },
334+ },
335+ }
336+ }
337+
338+ s .RespondTo (ctx , "POST /liquid/v1/projects/tenant1/report-usage" ,
339+ withPerms ("viewquota:tenant1" ),
340+ httptest .WithJSONBody (map [string ]any {"allAZs" : []string {"dummy" }}),
341+ ).ExpectJSON (t , http .StatusOK , buildLiquidResponse (0 , 0 , 0 , 0 ))
342+
343+ // PUT happy case with native API
344+ for _ , pass := range []int {1 , 2 , 3 } {
345+ s .RespondTo (ctx , "PUT /keppel/v1/quotas/tenant1" ,
346+ withPerms ("changequota:tenant1" ),
347+ httptest .WithJSONBody (map [string ]any {
348+ "bytes" : map [string ]any {"quota" : 5_000_000 },
349+ "manifests" : map [string ]any {"quota" : 50 },
350+ }),
351+ ).ExpectJSON (t , http .StatusOK , jsonmatch.Object {
352+ "bytes" : jsonmatch.Object {"quota" : 5_000_000 , "usage" : 0 },
353+ "manifests" : jsonmatch.Object {"quota" : 50 , "usage" : 0 },
354+ })
355+
356+ // only the first pass should generate an audit event
357+ if pass == 1 {
358+ s .Auditor .ExpectEvents (t , cadf.Event {
359+ RequestPath : "/keppel/v1/quotas/tenant1" ,
360+ Action : cadf .UpdateAction ,
361+ Outcome : "success" ,
362+ Reason : test .CADFReasonOK ,
363+ Target : cadf.Resource {
364+ TypeURI : "docker-registry/project-quota" ,
365+ ID : "tenant1" ,
366+ ProjectID : "tenant1" ,
367+ Attachments : []cadf.Attachment {
368+ {
369+ Name : "payload-before" ,
370+ TypeURI : "mime:application/json" ,
371+ Content : `{"manifests":0}` ,
372+ },
373+ {
374+ Name : "payload" ,
375+ TypeURI : "mime:application/json" ,
376+ Content : `{"bytes":5000000,"manifests":50}` ,
377+ },
378+ },
379+ },
380+ })
381+ } else {
382+ s .Auditor .ExpectEvents (t /*, nothing */ )
383+ }
384+ }
385+
386+ // PUT happy case with LIQUID API
387+ for _ , pass := range []int {1 , 2 , 3 } {
388+ s .RespondTo (ctx , "PUT /liquid/v1/projects/tenant1/quota" ,
389+ withPerms ("changequota:tenant1" ),
390+ httptest .WithJSONBody (map [string ]any {
391+ "resources" : map [string ]any {
392+ "capacity" : map [string ]any {"quota" : 50_000_000 },
393+ "images" : map [string ]any {"quota" : 100 },
394+ },
395+ }),
396+ ).ExpectStatus (t , http .StatusNoContent )
397+
398+ // only the first pass should generate an audit event
399+ if pass == 1 {
400+ s .Auditor .ExpectEvents (t , cadf.Event {
401+ RequestPath : "/liquid/v1/projects/tenant1/quota" ,
402+ Action : cadf .UpdateAction ,
403+ Outcome : "success" ,
404+ Reason : test .CADFReasonOK ,
405+ Target : cadf.Resource {
406+ TypeURI : "docker-registry/project-quota" ,
407+ ID : "tenant1" ,
408+ ProjectID : "tenant1" ,
409+ Attachments : []cadf.Attachment {
410+ {
411+ Name : "payload-before" ,
412+ TypeURI : "mime:application/json" ,
413+ Content : `{"bytes":5000000,"manifests":50}` ,
414+ },
415+ {
416+ Name : "payload" ,
417+ TypeURI : "mime:application/json" ,
418+ Content : `{"bytes":50000000,"manifests":100}` ,
419+ },
420+ },
421+ },
422+ })
423+ } else {
424+ s .Auditor .ExpectEvents (t /*, nothing */ )
425+ }
426+ }
427+
428+ // reflects changes
429+ s .RespondTo (ctx , "GET /keppel/v1/quotas/tenant1" , withPerms ("viewquota:tenant1" )).
430+ ExpectJSON (t , http .StatusOK , jsonmatch.Object {
431+ "bytes" : jsonmatch.Object {"quota" : 50_000_000 , "usage" : 0 },
432+ "manifests" : jsonmatch.Object {"quota" : 100 , "usage" : 0 },
433+ })
434+ s .RespondTo (ctx , "POST /liquid/v1/projects/tenant1/report-usage" ,
435+ withPerms ("viewquota:tenant1" ),
436+ httptest .WithJSONBody (map [string ]any {"allAZs" : []string {"dummy" }}),
437+ ).ExpectJSON (t , http .StatusOK , buildLiquidResponse (50_000_000 , 0 , 100 , 0 ))
438+
439+ // put some blobs in the DB, check that GET reflects higher usage
440+ repo := models.Repository {
441+ AccountName : "test1" ,
442+ Name : "foo" ,
443+ }
444+ for idx := 1 ; idx <= 10 ; idx ++ {
445+ image := test .GenerateImage (
446+ test .GenerateExampleLayer (int64 (idx )),
447+ test .GenerateExampleLayer (int64 (idx + 1 )),
448+ )
449+ image .MustUpload (t , s , repo , "latest" )
450+ }
451+ s .Auditor .IgnoreEventsUntilNow ()
452+
453+ s .RespondTo (ctx , "GET /keppel/v1/quotas/tenant1" , withPerms ("viewquota:tenant1" )).
454+ ExpectJSON (t , http .StatusOK , jsonmatch.Object {
455+ "bytes" : jsonmatch.Object {"quota" : 50_000_000 , "usage" : 11558149 },
456+ "manifests" : jsonmatch.Object {"quota" : 100 , "usage" : 10 },
457+ })
458+ s .RespondTo (ctx , "POST /liquid/v1/projects/tenant1/report-usage" ,
459+ withPerms ("viewquota:tenant1" ),
460+ httptest .WithJSONBody (map [string ]any {"allAZs" : []string {"dummy" }}),
461+ ).ExpectJSON (t , http .StatusOK , buildLiquidResponse (50000000 , 11558149 , 100 , 10 ))
462+
463+ // PUT error cases
464+ s .RespondTo (ctx , "PUT /keppel/v1/quotas/tenant1" ,
465+ withPerms ("viewquota:tenant1" ),
466+ httptest .WithJSONBody (map [string ]any {
467+ "bytes" : map [string ]any {"bytes" : 50_000_000 , "quota" : 5_000_000 },
468+ "manifests" : map [string ]any {"bytes" : 10000 , "quota" : 100 },
469+ }),
470+ ).ExpectStatus (t , http .StatusForbidden )
471+ s .Auditor .ExpectEvents (t /*, nothing */ )
472+
473+ s .RespondTo (ctx , "PUT /keppel/v1/quotas/tenant1" ,
474+ withPerms ("changequota:tenant1" ),
475+ httptest .WithJSONBody (map [string ]any {
476+ "bytes" : map [string ]any {"quota" : 5_000_000 },
477+ "manifests" : map [string ]any {"quota" : 5 },
478+ }),
479+ ).ExpectText (t , http .StatusUnprocessableEntity , "requested manifest quota (5) is below usage (10)\n " )
480+ s .Auditor .ExpectEvents (t /*, nothing */ )
481+ }
0 commit comments