Skip to content

Commit 0443d70

Browse files
committed
feat(packages): clean up old Maven snapshot builds
Add configurable retention and dry-run settings for Maven snapshot artifacts. Parse snapshot metadata and remove obsolete build files during package cleanup. Assisted-by: GitHub Copilot: unspecified
1 parent fa50ad2 commit 0443d70

7 files changed

Lines changed: 620 additions & 3 deletions

File tree

‎custom/conf/app.example.ini‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2817,6 +2817,10 @@ LEVEL = Info
28172817
;LIMIT_SIZE_HELM = -1
28182818
;; Maximum size of a Maven upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
28192819
;LIMIT_SIZE_MAVEN = -1
2820+
;; Number of recent Maven snapshot builds to retain. Set to `-1` to disable cleanup; `1` retains only the latest build.
2821+
;RETAIN_MAVEN_SNAPSHOT_BUILDS = -1
2822+
;; Run Maven snapshot cleanup without deleting files and log the files that would be deleted.
2823+
;DEBUG_MAVEN_CLEANUP = true
28202824
;; Maximum size of a npm upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
28212825
;LIMIT_SIZE_NPM = -1
28222826
;; Maximum size of a NuGet upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)

‎models/packages/package_file.go‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ package packages
55

66
import (
77
"context"
8+
"errors"
9+
"fmt"
10+
"sort"
811
"strconv"
912
"strings"
1013
"time"
@@ -21,6 +24,8 @@ func init() {
2124
}
2225

2326
var (
27+
// ErrMetadataFile indicates a metadata file
28+
ErrMetadataFile = errors.New("metadata file")
2429
// ErrDuplicatePackageFile indicates a duplicated package file error
2530
ErrDuplicatePackageFile = util.NewAlreadyExistErrorf("package file already exists")
2631
// ErrPackageFileNotExist indicates a package file not exist error
@@ -245,6 +250,80 @@ func HasFiles(ctx context.Context, opts *PackageFileSearchOptions) (bool, error)
245250
return db.Exist[PackageFile](ctx, opts.toConds())
246251
}
247252

253+
// GetFilesBelowBuildNumber retrieves all files for a Maven snapshot version where the build number is <= maxBuildNumber.
254+
// Returns two slices: one for filtered files and one for skipped files.
255+
func GetFilesBelowBuildNumber(ctx context.Context, versionID int64, maxBuildNumber int, classifiers ...string) ([]*PackageFile, []*PackageFile, error) {
256+
if maxBuildNumber <= 0 {
257+
return nil, nil, errors.New("maxBuildNumber must be a positive integer")
258+
}
259+
260+
files, err := GetFilesByVersionID(ctx, versionID)
261+
if err != nil {
262+
return nil, nil, fmt.Errorf("failed to retrieve files: %w", err)
263+
}
264+
265+
// Sort classifiers by length (longest first) once per call
266+
sort.SliceStable(classifiers, func(i, j int) bool {
267+
return len(classifiers[i]) > len(classifiers[j])
268+
})
269+
270+
var filteredFiles, skippedFiles []*PackageFile
271+
for _, file := range files {
272+
buildNumber, err := extractBuildNumberFromFileName(file.Name, classifiers...)
273+
if err != nil {
274+
if !errors.Is(err, ErrMetadataFile) {
275+
skippedFiles = append(skippedFiles, file)
276+
}
277+
continue
278+
}
279+
280+
if buildNumber <= maxBuildNumber {
281+
filteredFiles = append(filteredFiles, file)
282+
}
283+
}
284+
285+
return filteredFiles, skippedFiles, nil
286+
}
287+
288+
// extractBuildNumberFromFileName extracts the build number from a Maven snapshot file name.
289+
// Expected formats:
290+
//
291+
// "artifact-1.0.0-20250311.083409-9.tgz" returns 9
292+
// "artifact-to-test-2.0.0-20250311.083409-10-sources.tgz" returns 10
293+
func extractBuildNumberFromFileName(filename string, classifiers ...string) (int, error) {
294+
if strings.Contains(filename, "maven-metadata.xml") {
295+
return 0, ErrMetadataFile
296+
}
297+
298+
dotIdx := strings.LastIndex(filename, ".")
299+
if dotIdx == -1 {
300+
return 0, fmt.Errorf("extract build number from filename: no file extension found in '%s'", filename)
301+
}
302+
base := filename[:dotIdx]
303+
304+
// Remove classifier suffix if present.
305+
for _, classifier := range classifiers {
306+
suffix := "-" + classifier
307+
if strings.HasSuffix(base, suffix) {
308+
base = base[:len(base)-len(suffix)]
309+
break
310+
}
311+
}
312+
313+
// The build number should be the token after the last dash.
314+
lastDash := strings.LastIndex(base, "-")
315+
if lastDash == -1 {
316+
return 0, fmt.Errorf("extract build number from filename: invalid file name format in '%s'", filename)
317+
}
318+
buildNumberStr := base[lastDash+1:]
319+
buildNumber, err := strconv.Atoi(buildNumberStr)
320+
if err != nil {
321+
return 0, fmt.Errorf("extract build number from filename: failed to convert build number '%s' to integer in '%s': %v", buildNumberStr, filename, err)
322+
}
323+
324+
return buildNumber, nil
325+
}
326+
248327
// CalculateFileSize sums up all blob sizes matching the search options.
249328
// It does NOT respect the deduplication of blobs.
250329
func CalculateFileSize(ctx context.Context, opts *PackageFileSearchOptions) (int64, error) {

‎modules/packages/maven/metadata.go‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ package maven
55

66
import (
77
"encoding/xml"
8+
"errors"
89
"io"
10+
"strconv"
911

1012
"gitea.dev/modules/util"
1113
"gitea.dev/modules/validation"
@@ -31,6 +33,12 @@ type Dependency struct {
3133
Version string `json:"version,omitempty"`
3234
}
3335

36+
// SnapshotMetadata struct holds the build number and the list of classifiers for a snapshot version
37+
type SnapshotMetadata struct {
38+
BuildNumber int `json:"build_number,omitempty"`
39+
Classifiers []string `json:"classifiers,omitempty"`
40+
}
41+
3442
type pomStruct struct {
3543
XMLName xml.Name `xml:"project"`
3644

@@ -61,6 +69,26 @@ type pomStruct struct {
6169
} `xml:"dependencies>dependency"`
6270
}
6371

72+
type snapshotMetadataStruct struct {
73+
XMLName xml.Name `xml:"metadata"`
74+
GroupID string `xml:"groupId"`
75+
ArtifactID string `xml:"artifactId"`
76+
Version string `xml:"version"`
77+
Versioning struct {
78+
LastUpdated string `xml:"lastUpdated"`
79+
Snapshot struct {
80+
Timestamp string `xml:"timestamp"`
81+
BuildNumber string `xml:"buildNumber"`
82+
} `xml:"snapshot"`
83+
SnapshotVersions []struct {
84+
Extension string `xml:"extension"`
85+
Classifier string `xml:"classifier"`
86+
Value string `xml:"value"`
87+
Updated string `xml:"updated"`
88+
} `xml:"snapshotVersions>snapshotVersion"`
89+
} `xml:"versioning"`
90+
}
91+
6492
// ParsePackageMetaData parses the metadata of a pom file
6593
func ParsePackageMetaData(r io.Reader) (*Metadata, error) {
6694
var pom pomStruct
@@ -109,3 +137,31 @@ func ParsePackageMetaData(r io.Reader) (*Metadata, error) {
109137
Dependencies: dependencies,
110138
}, nil
111139
}
140+
141+
// ParseSnapshotVersionMetadata parses the Maven Snapshot Version metadata to extract the build number and list of available classifiers.
142+
func ParseSnapshotVersionMetadata(r io.Reader) (*SnapshotMetadata, error) {
143+
var metadata snapshotMetadataStruct
144+
145+
dec := xml.NewDecoder(r)
146+
dec.CharsetReader = charset.NewReaderLabel
147+
if err := dec.Decode(&metadata); err != nil {
148+
return nil, err
149+
}
150+
151+
buildNumber, err := strconv.Atoi(metadata.Versioning.Snapshot.BuildNumber)
152+
if err != nil {
153+
return nil, errors.New("invalid or missing build number in snapshot metadata")
154+
}
155+
156+
var classifiers []string
157+
for _, snapshotVersion := range metadata.Versioning.SnapshotVersions {
158+
if snapshotVersion.Classifier != "" {
159+
classifiers = append(classifiers, snapshotVersion.Classifier)
160+
}
161+
}
162+
163+
return &SnapshotMetadata{
164+
BuildNumber: buildNumber,
165+
Classifiers: classifiers,
166+
}, nil
167+
}

‎modules/setting/packages.go‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,14 @@ var (
4242
LimitSizeTerraformState int64
4343
LimitSizeVagrant int64
4444

45-
DefaultRPMSignEnabled bool
45+
DefaultRPMSignEnabled bool
46+
RetainMavenSnapshotBuilds int
47+
DebugMavenCleanup bool
4648
}{
47-
Enabled: true,
48-
LimitTotalOwnerCount: -1,
49+
Enabled: true,
50+
LimitTotalOwnerCount: -1,
51+
RetainMavenSnapshotBuilds: -1,
52+
DebugMavenCleanup: true,
4953
}
5054
)
5155

@@ -90,6 +94,8 @@ func loadPackagesFrom(rootCfg ConfigProvider) (err error) {
9094
Packages.LimitSizeTerraformState = mustBytes(sec, "LIMIT_SIZE_TERRAFORM_STATE")
9195
Packages.LimitSizeVagrant = mustBytes(sec, "LIMIT_SIZE_VAGRANT")
9296
Packages.DefaultRPMSignEnabled = sec.Key("DEFAULT_RPM_SIGN_ENABLED").MustBool(false)
97+
Packages.RetainMavenSnapshotBuilds = sec.Key("RETAIN_MAVEN_SNAPSHOT_BUILDS").MustInt(Packages.RetainMavenSnapshotBuilds)
98+
Packages.DebugMavenCleanup = sec.Key("DEBUG_MAVEN_CLEANUP").MustBool(Packages.DebugMavenCleanup)
9399
return nil
94100
}
95101

‎services/packages/cleanup/cleanup.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
cargo_service "gitea.dev/services/packages/cargo"
2121
container_service "gitea.dev/services/packages/container"
2222
debian_service "gitea.dev/services/packages/debian"
23+
maven_service "gitea.dev/services/packages/maven"
2324
rpm_service "gitea.dev/services/packages/rpm"
2425
)
2526

@@ -171,6 +172,10 @@ func CleanupExpiredData(ctx context.Context, olderThan time.Duration) error {
171172
return err
172173
}
173174

175+
if err := maven_service.CleanupSnapshotVersions(ctx); err != nil {
176+
return fmt.Errorf("maven.CleanupSnapshotVersions failed: %w", err)
177+
}
178+
174179
ps, err := packages_model.FindUnreferencedPackages(ctx)
175180
if err != nil {
176181
return err

‎services/packages/maven/cleanup.go‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package maven
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
"strings"
11+
12+
"gitea.dev/models/packages"
13+
"gitea.dev/modules/json"
14+
"gitea.dev/modules/log"
15+
"gitea.dev/modules/packages/maven"
16+
"gitea.dev/modules/setting"
17+
packages_service "gitea.dev/services/packages"
18+
)
19+
20+
// CleanupSnapshotVersions removes outdated files for snapshot versions of all Maven packages.
21+
func CleanupSnapshotVersions(ctx context.Context) error {
22+
retainBuilds := setting.Packages.RetainMavenSnapshotBuilds
23+
debugSession := setting.Packages.DebugMavenCleanup
24+
log.Debug("Maven Cleanup: starting with retainBuilds: %d, debugSession: %t", retainBuilds, debugSession)
25+
26+
if retainBuilds < 1 {
27+
log.Warn("Maven Cleanup: skipped as value for retainBuilds less than 1: %d. Minimum 1 build should be retained", retainBuilds)
28+
return nil
29+
}
30+
31+
versions, err := packages.GetVersionsByPackageType(ctx, 0, packages.TypeMaven)
32+
if err != nil {
33+
return fmt.Errorf("maven cleanup: failed to retrieve Maven package versions: %w", err)
34+
}
35+
36+
var errs []error
37+
var metadataErrors []error
38+
39+
for _, version := range versions {
40+
if !isSnapshotVersion(version.Version) {
41+
continue
42+
}
43+
44+
var artifactID, groupID string
45+
if version.MetadataJSON != "" {
46+
var metadata map[string]any
47+
if err := json.Unmarshal([]byte(version.MetadataJSON), &metadata); err != nil {
48+
log.Warn("Maven Cleanup: error during cleanup: failed to unmarshal metadataJSON for package version ID: %d: %v", version.ID, err)
49+
} else {
50+
artifactID, _ = metadata["artifact_id"].(string)
51+
groupID, _ = metadata["group_id"].(string)
52+
log.Debug("Maven Cleanup: processing package version with ID: %d, Group ID: %s, Artifact ID: %s, Version: %s", version.ID, groupID, artifactID, version.Version)
53+
}
54+
}
55+
56+
if err := cleanSnapshotFiles(ctx, version.ID, retainBuilds, debugSession); err != nil {
57+
formattedErr := fmt.Errorf("version '%s' (ID: %d, Group ID: %s, Artifact ID: %s): %w",
58+
version.Version, version.ID, groupID, artifactID, err)
59+
60+
if errors.Is(err, packages.ErrMetadataFile) {
61+
metadataErrors = append(metadataErrors, formattedErr)
62+
} else {
63+
errs = append(errs, formattedErr)
64+
}
65+
}
66+
}
67+
68+
for _, err := range metadataErrors {
69+
log.Warn("Maven Cleanup: error during cleanup: %v", err)
70+
}
71+
72+
if len(errs) > 0 {
73+
for _, err := range errs {
74+
log.Error("Maven Cleanup: error during cleanup: %v", err)
75+
}
76+
return fmt.Errorf("maven cleanup: completed with errors: %v", errs)
77+
}
78+
79+
log.Debug("Maven Cleanup: completed")
80+
return nil
81+
}
82+
83+
func isSnapshotVersion(version string) bool {
84+
return strings.HasSuffix(version, "-SNAPSHOT")
85+
}
86+
87+
func cleanSnapshotFiles(ctx context.Context, versionID int64, retainBuilds int, debugSession bool) error {
88+
log.Debug("Maven Cleanup: starting cleanSnapshotFiles for versionID: %d with retainBuilds: %d, debugSession: %t", versionID, retainBuilds, debugSession)
89+
90+
metadataFile, err := packages.GetFileForVersionByName(ctx, versionID, "maven-metadata.xml", packages.EmptyFileKey)
91+
if err != nil {
92+
return fmt.Errorf("%w: failed to retrieve maven-metadata.xml: %w", packages.ErrMetadataFile, err)
93+
}
94+
95+
maxBuildNumber, classifiers, err := extractMaxBuildNumber(ctx, metadataFile)
96+
if err != nil {
97+
return fmt.Errorf("%w: failed to extract max build number from maven-metadata.xml: %w", packages.ErrMetadataFile, err)
98+
}
99+
100+
thresholdBuildNumber := maxBuildNumber - retainBuilds
101+
if thresholdBuildNumber <= 0 {
102+
log.Debug("Maven Cleanup: no files to clean up, as the threshold build number is less than or equal to zero for versionID %d", versionID)
103+
return nil
104+
}
105+
106+
filesToRemove, skippedFiles, err := packages.GetFilesBelowBuildNumber(ctx, versionID, thresholdBuildNumber, classifiers...)
107+
if err != nil {
108+
return fmt.Errorf("cleanSnapshotFiles: failed to retrieve files for version: %w", err)
109+
}
110+
111+
if debugSession {
112+
var fileNamesToRemove, skippedFileNames []string
113+
114+
for _, file := range filesToRemove {
115+
fileNamesToRemove = append(fileNamesToRemove, file.Name)
116+
}
117+
118+
for _, file := range skippedFiles {
119+
skippedFileNames = append(skippedFileNames, file.Name)
120+
}
121+
122+
log.Debug("Maven Cleanup: debug session active. Files to remove: %v, Skipped files: %v", fileNamesToRemove, skippedFileNames)
123+
return nil
124+
}
125+
126+
for _, file := range filesToRemove {
127+
log.Debug("Maven Cleanup: removing file '%s' below threshold %d", file.Name, thresholdBuildNumber)
128+
if err := packages_service.DeletePackageFile(ctx, file); err != nil {
129+
return fmt.Errorf("cleanSnapshotFiles: failed to delete file '%s': %w", file.Name, err)
130+
}
131+
}
132+
133+
return nil
134+
}
135+
136+
func extractMaxBuildNumber(ctx context.Context, metadataFile *packages.PackageFile) (int, []string, error) {
137+
pb, err := packages.GetBlobByID(ctx, metadataFile.BlobID)
138+
if err != nil {
139+
return 0, nil, fmt.Errorf("failed to get package blob: %w", err)
140+
}
141+
142+
content, err := packages_service.OpenBlobStream(pb)
143+
if err != nil {
144+
return 0, nil, fmt.Errorf("failed to get package file stream: %w", err)
145+
}
146+
defer content.Close()
147+
148+
snapshotMetadata, err := maven.ParseSnapshotVersionMetadata(content)
149+
if err != nil {
150+
return 0, nil, fmt.Errorf("failed to parse maven-metadata.xml: %w", err)
151+
}
152+
153+
buildNumber := snapshotMetadata.BuildNumber
154+
classifiers := snapshotMetadata.Classifiers
155+
156+
return buildNumber, classifiers, nil
157+
}

0 commit comments

Comments
 (0)