jojo/services/packages/cleanup/cleanup.go
wandhydrant 9ea9582c9b fix: cleanup of multi-platform container images (#11246)
This change fixes an issue that makes Forgejo clean up too many versions of a container package even though it should keep them according to the rules set for the package.

The issue affects multi-platform container images.
Forgejo adds a package version for each platform (for example `linux/amd64`, `linux/arm64`) in addition to the actual tag (for example `0.6.0` or `latest`).

This results in rows in the table `package_version` similar to this (unimportant columns omitted for brevity):

| **lower_version** | **created_unix** |
|---|---|
| `latest` | `1768742887`|
| `0.6.0` | `1768742886` |
| `sha256:038e...` | `1768742886` |
| `sha256:fc38...` | `1768742886` |
| `0.5.0` | `1768742864` |
| `sha256:806d...` | `1768742864` |
| `sha256:0a19...` | `1768742864` |
| `0.4.0` | `1768742848` |
| `...` | `...` |

The code assumes that the first `<keep count>` entries can be ignored and considers every entry after `<keep count>` as eligible for cleanup.
That doesn't work for multi-platform container images because, for `<keep count>=5`, it considers version `0.4.0` as eligible.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

*The decision if the pull request will be shown in the release notes is up to the mergers / release team.*

The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Bug fixes
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/11246): <!--number 11246 --><!--line 0 --><!--description Y2xlYW51cCBvZiBtdWx0aS1wbGF0Zm9ybSBjb250YWluZXIgaW1hZ2Vz-->cleanup of multi-platform container images<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11246
Reviewed-by: Michael Kriese <michael.kriese@gmx.de>
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: wandhydrant <wandhydrant@noreply.codeberg.org>
Co-committed-by: wandhydrant <wandhydrant@noreply.codeberg.org>
2026-02-12 03:12:32 +01:00

241 lines
7.5 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"context"
"fmt"
"time"
"forgejo.org/models/db"
packages_model "forgejo.org/models/packages"
user_model "forgejo.org/models/user"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
packages_module "forgejo.org/modules/packages"
packages_service "forgejo.org/services/packages"
alpine_service "forgejo.org/services/packages/alpine"
alt_service "forgejo.org/services/packages/alt"
arch_service "forgejo.org/services/packages/arch"
cargo_service "forgejo.org/services/packages/cargo"
container_service "forgejo.org/services/packages/container"
debian_service "forgejo.org/services/packages/debian"
rpm_service "forgejo.org/services/packages/rpm"
)
// Task method to execute cleanup rules and cleanup expired package data
func CleanupTask(ctx context.Context, olderThan time.Duration) error {
if err := ExecuteCleanupRules(ctx); err != nil {
return err
}
return CleanupExpiredData(ctx, olderThan)
}
func ExecuteCleanupRules(outerCtx context.Context) error {
ctx, committer, err := db.TxContext(outerCtx)
if err != nil {
return err
}
defer committer.Close()
err = packages_model.IterateEnabledCleanupRules(ctx, func(ctx context.Context, pcr *packages_model.PackageCleanupRule) error {
select {
case <-outerCtx.Done():
return db.ErrCancelledf("While processing package cleanup rules")
default:
}
versionsToRemove, err := GetCleanupTargets(ctx, pcr, true)
if err != nil {
return fmt.Errorf("CleanupRule [%d]: GetCleanupTargets failed: %w", pcr.ID, err)
}
anyVersionDeleted := false
packageWithVersionDeleted := make(map[int64]bool) // set of Package.ID's where at least one package version was removed
for _, ct := range versionsToRemove {
if err := packages_service.DeletePackageVersionAndReferences(ctx, ct.PackageVersion); err != nil {
return fmt.Errorf("CleanupRule [%d]: DeletePackageVersionAndReferences failed: %w", pcr.ID, err)
}
packageWithVersionDeleted[ct.Package.ID] = true
anyVersionDeleted = true
}
if pcr.Type == packages_model.TypeCargo {
for packageID := range packageWithVersionDeleted {
owner, err := user_model.GetUserByID(ctx, pcr.OwnerID)
if err != nil {
return fmt.Errorf("GetUserByID failed: %w", err)
}
if err := cargo_service.UpdatePackageIndexIfExists(ctx, owner, owner, packageID); err != nil {
return fmt.Errorf("CleanupRule [%d]: cargo.UpdatePackageIndexIfExists failed: %w", pcr.ID, err)
}
}
}
if anyVersionDeleted {
switch pcr.Type {
case packages_model.TypeDebian:
if err := debian_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil {
return fmt.Errorf("CleanupRule [%d]: debian.BuildAllRepositoryFiles failed: %w", pcr.ID, err)
}
case packages_model.TypeAlpine:
if err := alpine_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil {
return fmt.Errorf("CleanupRule [%d]: alpine.BuildAllRepositoryFiles failed: %w", pcr.ID, err)
}
case packages_model.TypeRpm:
if err := rpm_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil {
return fmt.Errorf("CleanupRule [%d]: rpm.BuildAllRepositoryFiles failed: %w", pcr.ID, err)
}
case packages_model.TypeArch:
if err := arch_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil {
return fmt.Errorf("CleanupRule [%d]: arch.BuildAllRepositoryFiles failed: %w", pcr.ID, err)
}
case packages_model.TypeAlt:
if err := alt_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil {
return fmt.Errorf("CleanupRule [%d]: alt.BuildAllRepositoryFiles failed: %w", pcr.ID, err)
}
}
}
return nil
})
if err != nil {
return err
}
return committer.Commit()
}
type CleanupTarget struct {
Package *packages_model.Package
PackageVersion *packages_model.PackageVersion
PackageDescriptor *packages_model.PackageDescriptor
}
func GetCleanupTargets(ctx context.Context, pcr *packages_model.PackageCleanupRule, skipPackageDescriptor bool) ([]*CleanupTarget, error) {
if err := pcr.CompiledPattern(); err != nil {
return nil, err
}
olderThan := time.Now().AddDate(0, 0, -pcr.RemoveDays)
packages, err := packages_model.GetPackagesByType(ctx, pcr.OwnerID, pcr.Type)
if err != nil {
return nil, fmt.Errorf("failure to GetPackagesByType for package cleanup rule: %w", err)
}
versionsToRemove := make([]*CleanupTarget, 0, 10)
for _, p := range packages {
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
PackageID: p.ID,
IsInternal: optional.Some(false),
Sort: packages_model.SortCreatedDesc,
})
if err != nil {
return nil, fmt.Errorf("failure to SearchVersions for package cleanup rule: %w", err)
}
var keep int
for _, pv := range pvs {
if pcr.Type == packages_model.TypeContainer {
if skip := container_service.ShouldBeSkipped(pv); skip {
log.Debug("Rule[%d]: keep '%s/%s' (container)", pcr.ID, p.Name, pv.Version)
continue
}
}
keep++
if pcr.KeepCount > 0 && keep <= pcr.KeepCount {
log.Debug("Rule[%d]: keep '%s/%s' (count)", pcr.ID, p.Name, pv.Version)
continue
}
toMatch := pv.LowerVersion
if pcr.MatchFullName {
toMatch = p.LowerName + "/" + pv.LowerVersion
}
if pcr.KeepPatternMatcher != nil && pcr.KeepPatternMatcher.MatchString(toMatch) {
log.Debug("Rule[%d]: keep '%s/%s' (keep pattern)", pcr.ID, p.Name, pv.Version)
continue
}
if pv.CreatedUnix.AsLocalTime().After(olderThan) {
log.Debug("Rule[%d]: keep '%s/%s' (remove days)", pcr.ID, p.Name, pv.Version)
continue
}
if pcr.RemovePatternMatcher != nil && !pcr.RemovePatternMatcher.MatchString(toMatch) {
log.Debug("Rule[%d]: keep '%s/%s' (remove pattern)", pcr.ID, p.Name, pv.Version)
continue
}
log.Debug("Rule[%d]: remove '%s/%s'", pcr.ID, p.Name, pv.Version)
var pd *packages_model.PackageDescriptor
// GetPackageDescriptor is a bit expensive and can be skipped; only used for cleanup preview to display the package to the UI
if !skipPackageDescriptor {
pd, err = packages_model.GetPackageDescriptor(ctx, pv)
if err != nil {
return nil, fmt.Errorf("failure to GetPackageDescriptor for package cleanup rule: %w", err)
}
}
versionsToRemove = append(versionsToRemove, &CleanupTarget{
Package: p,
PackageVersion: pv,
PackageDescriptor: pd,
})
}
}
return versionsToRemove, nil
}
func CleanupExpiredData(outerCtx context.Context, olderThan time.Duration) error {
ctx, committer, err := db.TxContext(outerCtx)
if err != nil {
return err
}
defer committer.Close()
if err := container_service.Cleanup(ctx, olderThan); err != nil {
return err
}
pIDs, err := packages_model.FindUnreferencedPackages(ctx)
if err != nil {
return err
}
for _, pID := range pIDs {
if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypePackage, pID); err != nil {
return err
}
if err := packages_model.DeletePackageByID(ctx, pID); err != nil {
return err
}
}
pbs, err := packages_model.FindExpiredUnreferencedBlobs(ctx, olderThan)
if err != nil {
return err
}
for _, pb := range pbs {
if err := packages_model.DeleteBlobByID(ctx, pb.ID); err != nil {
return err
}
}
if err := committer.Commit(); err != nil {
return err
}
contentStore := packages_module.NewContentStore()
for _, pb := range pbs {
if err := contentStore.Delete(packages_module.BlobHash256Key(pb.HashSHA256)); err != nil {
log.Error("Error deleting package blob [%v]: %v", pb.ID, err)
}
}
return nil
}