diff --git a/docs/api-spec.md b/docs/api-spec.md index 6741bd22d..b76da624e 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -504,6 +504,7 @@ The following fields may be returned: | `manifests[].gc_status` | object or omitted | Omitted if policy-guided garbage collection has not encountered this manifest yet. Otherwise contains a status report from the last GC run. If this object is shown, it will contain exactly one of the following attributes. | | `manifests[].gc_status.protected_by_recent_upload` | true or omitted | If true, this manifest was protected from deletion during the last GC run because it was uploaded too recently (within 10 minutes of the GC run). | | `manifests[].gc_status.protected_by_parent` | string or omitted | If shown, this manifest was protected from deletion during the last GC run because there is a parent manifest that references it. The field contains the parent manifest's digest. If the manifest is referenced by multiple parent manifests, it is not defined which parent manifest's digest will be shown. | +| `manifests[].gc_status.protected_by_subject` | string or omitted | If shown, this manifest was protected from deletion during the last GC run because the subject digest it references exists. The field contains the subject digest of the target image. | | `manifests[].gc_status.protected_by_policy` | object or omitted | If shown, this manifest was protected from deletion during the last GC run because of a matching policy with the "protect" action. The object will contain the policy definition in the same format as described above for `accounts[].gc_policies[]`. | | `manifests[].gc_status.relevant_policies` | array of objects or omitted | If shown, this manifest was not protected from deletion during the last GC run, but no deleting policy matched either. The array will contain the definitions of all deleting policies that could apply to this manifest, in the same format as described above for `accounts[].gc_policies[]`. | | `manifests[].vulnerability_status` | string | Either `Clean` (no vulnerabilities have been found in this image), `Pending` (vulnerability scanning is not enabled on this server or is still in progress for this image or has failed for this image), `Error` (vulnerability scanning failed for this image or an image referenced in this manifest), or any of the following severity strings: `Unknown`, `Low`, `Medium`, `High`, `Critical`. The full vulnerability report can be retrieved with [a separate API call](#delete-keppelv1accountsnamerepositoriesname_manifestsdigesttrivy_report). | diff --git a/internal/keppel/gc_policy.go b/internal/keppel/gc_policy.go index eafcf8c62..9d0f9b8a5 100644 --- a/internal/keppel/gc_policy.go +++ b/internal/keppel/gc_policy.go @@ -250,10 +250,13 @@ type GCStatus struct { // protected from GC. ProtectedByRecentUpload bool `json:"protected_by_recent_upload,omitempty"` // If a parent manifest references this manifest and thus protects it from GC, - // contains the parent manifest's digest. + // this contains the parent manifest's digest. ProtectedByParentManifest string `json:"protected_by_parent,omitempty"` - // If a policy with action "protect" applies to this image, contains the - // definition of the policy. + // If this manifest references a subject and is thus protected from GC, + // this contains the subject's digest. + ProtectedBySubjectManifest string `json:"protected_by_subject,omitempty"` + // If a policy with action "protect" applies to this image, + // this contains the definition of the policy. ProtectedByPolicy *GCPolicy `json:"protected_by_policy,omitempty"` // If the image is not protected, contains all policies with action "delete" // that could delete this image in the future. @@ -262,5 +265,5 @@ type GCStatus struct { // IsProtected returns whether any of the ProtectedBy... fields is filled. func (s GCStatus) IsProtected() bool { - return s.ProtectedByRecentUpload || s.ProtectedByParentManifest != "" || s.ProtectedByPolicy != nil + return s.ProtectedByRecentUpload || s.ProtectedByParentManifest != "" || s.ProtectedBySubjectManifest != "" || s.ProtectedByPolicy != nil } diff --git a/internal/tasks/image_gc.go b/internal/tasks/image_gc.go index 27ab8e78c..68efd2123 100644 --- a/internal/tasks/image_gc.go +++ b/internal/tasks/image_gc.go @@ -195,6 +195,21 @@ func (j *Janitor) executeGCPolicies(ctx context.Context, account models.ReducedA } } + // check if the subject target digest manifest exists +outer: + for _, manifest := range manifests { + if manifest.Manifest.SubjectDigest == "" { + continue + } + + for _, m := range manifests { + if m.Manifest.Digest == manifest.Manifest.SubjectDigest { + manifest.GCStatus.ProtectedBySubjectManifest = manifest.Manifest.SubjectDigest.String() + continue outer + } + } + } + // evaluate policies in order proc := j.processor() for _, policy := range policies { diff --git a/internal/tasks/image_gc_test.go b/internal/tasks/image_gc_test.go index 3c6b41cbe..0f11b998a 100644 --- a/internal/tasks/image_gc_test.go +++ b/internal/tasks/image_gc_test.go @@ -21,9 +21,11 @@ package tasks import ( "database/sql" "fmt" + "strings" "testing" "time" + imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sapcc/go-api-declarations/cadf" "github.com/sapcc/go-bits/easypg" @@ -383,3 +385,38 @@ func TestGCProtectComesTooLate(t *testing.T) { s.Clock.Now().Add(1*time.Hour).Unix(), ) } + +func TestGCProtectSubject(t *testing.T) { + j, s := setup(t) + + image := test.GenerateOCIImage(test.OCIArgs{ + ConfigMediaType: imgspecv1.MediaTypeImageManifest, + }) + image.MustUpload(t, s, fooRepoRef, "latest") + + subjectManifest := test.GenerateOCIImage(test.OCIArgs{ + ConfigMediaType: imgspecv1.MediaTypeImageManifest, + SubjectDigest: image.Manifest.Digest, + }) + subjectManifest.MustUpload(t, s, fooRepoRef, strings.ReplaceAll(image.Manifest.Digest.String(), ":", "-")) + + deletingGCPolicyJSON := `[{"match_repository":".*","time_constraint":{"on":"pushed_at","older_than":{"value":2,"unit":"h"}},"action":"delete"}]` + mustExec(t, s.DB, `UPDATE accounts SET gc_policies_json = $1`, deletingGCPolicyJSON) + + tr, _ := easypg.NewTracker(t, s.DB.Db) + garbageJob := j.ManifestGarbageCollectionJob(s.Registry) + + // skip an hour to avoid protected_by_recent_upload + s.Clock.StepBy(1 * time.Hour) + + // nothing should be deleted here + expectSuccess(t, garbageJob.ProcessOne(s.Ctx)) + expectError(t, sql.ErrNoRows.Error(), garbageJob.ProcessOne(s.Ctx)) + tr.DBChanges().AssertEqualf(` + UPDATE manifests SET gc_status_json = '{"protected_by_subject":"%[1]s"}' WHERE repo_id = 1 AND digest = '%[2]s'; + UPDATE manifests SET gc_status_json = '{"relevant_policies":%[3]s}' WHERE repo_id = 1 AND digest = '%[1]s'; + UPDATE repos SET next_gc_at = %[4]d WHERE id = 1 AND account_name = 'test1' AND name = 'foo'; + `, + image.Manifest.Digest, subjectManifest.Manifest.Digest, deletingGCPolicyJSON, s.Clock.Now().Add(1*time.Hour).Unix(), + ) +}