Skip to content

Commit 460a2b0

Browse files
authored
Artifacts retention and auto clean up (#26131)
Currently, Artifact does not have an expiration and automatic cleanup mechanism, and this feature needs to be added. It contains the following key points: - [x] add global artifact retention days option in config file. Default value is 90 days. - [x] add cron task to clean up expired artifacts. It should run once a day. - [x] support custom retention period from `retention-days: 5` in `upload-artifact@v3`. - [x] artifacts link in actions view should be non-clickable text when expired.
1 parent 113eb5f commit 460a2b0

File tree

13 files changed

+221
-25
lines changed

13 files changed

+221
-25
lines changed

custom/conf/app.example.ini

+2
Original file line numberDiff line numberDiff line change
@@ -2564,6 +2564,8 @@ LEVEL = Info
25642564
;;
25652565
;; Default platform to get action plugins, `github` for `https://github.com`, `self` for the current Gitea instance.
25662566
;DEFAULT_ACTIONS_URL = github
2567+
;; Default artifact retention time in days, default is 90 days
2568+
;ARTIFACT_RETENTION_DAYS = 90
25672569

25682570
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
25692571
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

docs/content/administration/config-cheat-sheet.en-us.md

+7
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,12 @@ Default templates for project boards:
955955
- `SCHEDULE`: **@midnight** : Interval as a duration between each synchronization, it will always attempt synchronization when the instance starts.
956956
- `UPDATE_EXISTING`: **true**: Create new users, update existing user data and disable users that are not in external source anymore (default) or only create new users if UPDATE_EXISTING is set to false.
957957

958+
## Cron - Cleanup Expired Actions Assets (`cron.cleanup_actions`)
959+
960+
- `ENABLED`: **true**: Enable cleanup expired actions assets job.
961+
- `RUN_AT_START`: **true**: Run job at start time (if ENABLED).
962+
- `SCHEDULE`: **@midnight** : Cron syntax for the job.
963+
958964
### Extended cron tasks (not enabled by default)
959965

960966
#### Cron - Garbage collect all repositories (`cron.git_gc_repos`)
@@ -1381,6 +1387,7 @@ PROXY_HOSTS = *.github.com
13811387
- `DEFAULT_ACTIONS_URL`: **github**: Default platform to get action plugins, `github` for `https://github.com`, `self` for the current Gitea instance.
13821388
- `STORAGE_TYPE`: **local**: Storage type for actions logs, `local` for local disk or `minio` for s3 compatible object storage service, default is `local` or other name defined with `[storage.xxx]`
13831389
- `MINIO_BASE_PATH`: **actions_log/**: Minio base path on the bucket only available when STORAGE_TYPE is `minio`
1390+
- `ARTIFACT_RETENTION_DAYS`: **90**: Number of days to keep artifacts. Set to 0 to disable artifact retention. Default is 90 days if not set.
13841391

13851392
`DEFAULT_ACTIONS_URL` indicates where the Gitea Actions runners should find the actions with relative path.
13861393
For example, `uses: actions/checkout@v3` means `https://github.com/actions/checkout@v3` since the value of `DEFAULT_ACTIONS_URL` is `github`.

models/actions/artifact.go

+28-10
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,21 @@ package actions
99
import (
1010
"context"
1111
"errors"
12+
"time"
1213

1314
"code.gitea.io/gitea/models/db"
1415
"code.gitea.io/gitea/modules/timeutil"
1516
"code.gitea.io/gitea/modules/util"
1617
)
1718

19+
// ArtifactStatus is the status of an artifact, uploading, expired or need-delete
20+
type ArtifactStatus int64
21+
1822
const (
19-
// ArtifactStatusUploadPending is the status of an artifact upload that is pending
20-
ArtifactStatusUploadPending = 1
21-
// ArtifactStatusUploadConfirmed is the status of an artifact upload that is confirmed
22-
ArtifactStatusUploadConfirmed = 2
23-
// ArtifactStatusUploadError is the status of an artifact upload that is errored
24-
ArtifactStatusUploadError = 3
23+
ArtifactStatusUploadPending ArtifactStatus = iota + 1 // 1, ArtifactStatusUploadPending is the status of an artifact upload that is pending
24+
ArtifactStatusUploadConfirmed // 2, ArtifactStatusUploadConfirmed is the status of an artifact upload that is confirmed
25+
ArtifactStatusUploadError // 3, ArtifactStatusUploadError is the status of an artifact upload that is errored
26+
ArtifactStatusExpired // 4, ArtifactStatusExpired is the status of an artifact that is expired
2527
)
2628

2729
func init() {
@@ -45,9 +47,10 @@ type ActionArtifact struct {
4547
Status int64 `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
4648
CreatedUnix timeutil.TimeStamp `xorm:"created"`
4749
UpdatedUnix timeutil.TimeStamp `xorm:"updated index"`
50+
ExpiredUnix timeutil.TimeStamp `xorm:"index"` // The time when the artifact will be expired
4851
}
4952

50-
func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPath string) (*ActionArtifact, error) {
53+
func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPath string, expiredDays int64) (*ActionArtifact, error) {
5154
if err := t.LoadJob(ctx); err != nil {
5255
return nil, err
5356
}
@@ -61,7 +64,8 @@ func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPa
6164
RepoID: t.RepoID,
6265
OwnerID: t.OwnerID,
6366
CommitSHA: t.CommitSHA,
64-
Status: ArtifactStatusUploadPending,
67+
Status: int64(ArtifactStatusUploadPending),
68+
ExpiredUnix: timeutil.TimeStamp(time.Now().Unix() + 3600*24*expiredDays),
6569
}
6670
if _, err := db.GetEngine(ctx).Insert(artifact); err != nil {
6771
return nil, err
@@ -126,15 +130,16 @@ func ListUploadedArtifactsByRunID(ctx context.Context, runID int64) ([]*ActionAr
126130
type ActionArtifactMeta struct {
127131
ArtifactName string
128132
FileSize int64
133+
Status int64
129134
}
130135

131136
// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run
132137
func ListUploadedArtifactsMeta(ctx context.Context, runID int64) ([]*ActionArtifactMeta, error) {
133138
arts := make([]*ActionArtifactMeta, 0, 10)
134139
return arts, db.GetEngine(ctx).Table("action_artifact").
135-
Where("run_id=? AND status=?", runID, ArtifactStatusUploadConfirmed).
140+
Where("run_id=? AND (status=? OR status=?)", runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
136141
GroupBy("artifact_name").
137-
Select("artifact_name, sum(file_size) as file_size").
142+
Select("artifact_name, sum(file_size) as file_size, max(status) as status").
138143
Find(&arts)
139144
}
140145

@@ -149,3 +154,16 @@ func ListArtifactsByRunIDAndName(ctx context.Context, runID int64, name string)
149154
arts := make([]*ActionArtifact, 0, 10)
150155
return arts, db.GetEngine(ctx).Where("run_id=? AND artifact_name=?", runID, name).Find(&arts)
151156
}
157+
158+
// ListNeedExpiredArtifacts returns all need expired artifacts but not deleted
159+
func ListNeedExpiredArtifacts(ctx context.Context) ([]*ActionArtifact, error) {
160+
arts := make([]*ActionArtifact, 0, 10)
161+
return arts, db.GetEngine(ctx).
162+
Where("expired_unix < ? AND status = ?", timeutil.TimeStamp(time.Now().Unix()), ArtifactStatusUploadConfirmed).Find(&arts)
163+
}
164+
165+
// SetArtifactExpired sets an artifact to expired
166+
func SetArtifactExpired(ctx context.Context, artifactID int64) error {
167+
_, err := db.GetEngine(ctx).Where("id=? AND status = ?", artifactID, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusExpired)})
168+
return err
169+
}

models/migrations/migrations.go

+2
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,8 @@ var migrations = []Migration{
528528
NewMigration("Add Version to ActionRun table", v1_21.AddVersionToActionRunTable),
529529
// v273 -> v274
530530
NewMigration("Add Action Schedule Table", v1_21.AddActionScheduleTable),
531+
// v274 -> v275
532+
NewMigration("Add Actions artifacts expiration date", v1_21.AddExpiredUnixColumnInActionArtifactTable),
531533
}
532534

533535
// GetCurrentDBVersion returns the current db version

models/migrations/v1_21/v274.go

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package v1_21 //nolint
5+
import (
6+
"time"
7+
8+
"code.gitea.io/gitea/modules/timeutil"
9+
10+
"xorm.io/xorm"
11+
)
12+
13+
func AddExpiredUnixColumnInActionArtifactTable(x *xorm.Engine) error {
14+
type ActionArtifact struct {
15+
ExpiredUnix timeutil.TimeStamp `xorm:"index"` // time when the artifact will be expired
16+
}
17+
if err := x.Sync(new(ActionArtifact)); err != nil {
18+
return err
19+
}
20+
return updateArtifactsExpiredUnixTo90Days(x)
21+
}
22+
23+
func updateArtifactsExpiredUnixTo90Days(x *xorm.Engine) error {
24+
sess := x.NewSession()
25+
defer sess.Close()
26+
27+
if err := sess.Begin(); err != nil {
28+
return err
29+
}
30+
expiredTime := time.Now().AddDate(0, 0, 90).Unix()
31+
if _, err := sess.Exec(`UPDATE action_artifact SET expired_unix=? WHERE status='2' AND expired_unix is NULL`, expiredTime); err != nil {
32+
return err
33+
}
34+
35+
return sess.Commit()
36+
}

modules/setting/actions.go

+10-4
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ import (
1313
// Actions settings
1414
var (
1515
Actions = struct {
16-
LogStorage *Storage // how the created logs should be stored
17-
ArtifactStorage *Storage // how the created artifacts should be stored
18-
Enabled bool
19-
DefaultActionsURL defaultActionsURL `ini:"DEFAULT_ACTIONS_URL"`
16+
LogStorage *Storage // how the created logs should be stored
17+
ArtifactStorage *Storage // how the created artifacts should be stored
18+
ArtifactRetentionDays int64 `ini:"ARTIFACT_RETENTION_DAYS"`
19+
Enabled bool
20+
DefaultActionsURL defaultActionsURL `ini:"DEFAULT_ACTIONS_URL"`
2021
}{
2122
Enabled: false,
2223
DefaultActionsURL: defaultActionsURLGitHub,
@@ -76,5 +77,10 @@ func loadActionsFrom(rootCfg ConfigProvider) error {
7677

7778
Actions.ArtifactStorage, err = getStorage(rootCfg, "actions_artifacts", "", actionsSec)
7879

80+
// default to 90 days in Github Actions
81+
if Actions.ArtifactRetentionDays <= 0 {
82+
Actions.ArtifactRetentionDays = 90
83+
}
84+
7985
return err
8086
}

options/locale/locale_en-US.ini

+1
Original file line numberDiff line numberDiff line change
@@ -2731,6 +2731,7 @@ dashboard.reinit_missing_repos = Reinitialize all missing Git repositories for w
27312731
dashboard.sync_external_users = Synchronize external user data
27322732
dashboard.cleanup_hook_task_table = Cleanup hook_task table
27332733
dashboard.cleanup_packages = Cleanup expired packages
2734+
dashboard.cleanup_actions = Cleanup actions expired logs and artifacts
27342735
dashboard.server_uptime = Server Uptime
27352736
dashboard.current_goroutine = Current Goroutines
27362737
dashboard.current_memory_usage = Current Memory Usage

routers/api/actions/artifacts.go

+24-4
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,9 @@ func (ar artifactRoutes) buildArtifactURL(runID int64, artifactHash, suffix stri
170170
}
171171

172172
type getUploadArtifactRequest struct {
173-
Type string
174-
Name string
173+
Type string
174+
Name string
175+
RetentionDays int64
175176
}
176177

177178
type getUploadArtifactResponse struct {
@@ -192,10 +193,16 @@ func (ar artifactRoutes) getUploadArtifactURL(ctx *ArtifactContext) {
192193
return
193194
}
194195

196+
// set retention days
197+
retentionQuery := ""
198+
if req.RetentionDays > 0 {
199+
retentionQuery = fmt.Sprintf("?retentionDays=%d", req.RetentionDays)
200+
}
201+
195202
// use md5(artifact_name) to create upload url
196203
artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(req.Name)))
197204
resp := getUploadArtifactResponse{
198-
FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "upload"),
205+
FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "upload"+retentionQuery),
199206
}
200207
log.Debug("[artifact] get upload url: %s", resp.FileContainerResourceURL)
201208
ctx.JSON(http.StatusOK, resp)
@@ -219,8 +226,21 @@ func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) {
219226
return
220227
}
221228

229+
// get artifact retention days
230+
expiredDays := setting.Actions.ArtifactRetentionDays
231+
if queryRetentionDays := ctx.Req.URL.Query().Get("retentionDays"); queryRetentionDays != "" {
232+
expiredDays, err = strconv.ParseInt(queryRetentionDays, 10, 64)
233+
if err != nil {
234+
log.Error("Error parse retention days: %v", err)
235+
ctx.Error(http.StatusBadRequest, "Error parse retention days")
236+
return
237+
}
238+
}
239+
log.Debug("[artifact] upload chunk, name: %s, path: %s, size: %d, retention days: %d",
240+
artifactName, artifactPath, fileRealTotalSize, expiredDays)
241+
222242
// create or get artifact with name and path
223-
artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath)
243+
artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath, expiredDays)
224244
if err != nil {
225245
log.Error("Error create or get artifact: %v", err)
226246
ctx.Error(http.StatusInternalServerError, "Error create or get artifact")

routers/api/actions/artifacts_chunks.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ func mergeChunksForArtifact(ctx *ArtifactContext, chunks []*chunkFileItem, st st
179179
// save storage path to artifact
180180
log.Debug("[artifact] merge chunks to artifact: %d, %s", artifact.ID, storagePath)
181181
artifact.StoragePath = storagePath
182-
artifact.Status = actions.ArtifactStatusUploadConfirmed
182+
artifact.Status = int64(actions.ArtifactStatusUploadConfirmed)
183183
if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
184184
return fmt.Errorf("update artifact error: %v", err)
185185
}

routers/web/repo/actions/view.go

+10-4
Original file line numberDiff line numberDiff line change
@@ -486,8 +486,9 @@ type ArtifactsViewResponse struct {
486486
}
487487

488488
type ArtifactsViewItem struct {
489-
Name string `json:"name"`
490-
Size int64 `json:"size"`
489+
Name string `json:"name"`
490+
Size int64 `json:"size"`
491+
Status string `json:"status"`
491492
}
492493

493494
func ArtifactsView(ctx *context_module.Context) {
@@ -510,9 +511,14 @@ func ArtifactsView(ctx *context_module.Context) {
510511
Artifacts: make([]*ArtifactsViewItem, 0, len(artifacts)),
511512
}
512513
for _, art := range artifacts {
514+
status := "completed"
515+
if art.Status == int64(actions_model.ArtifactStatusExpired) {
516+
status = "expired"
517+
}
513518
artifactsResponse.Artifacts = append(artifactsResponse.Artifacts, &ArtifactsViewItem{
514-
Name: art.ArtifactName,
515-
Size: art.FileSize,
519+
Name: art.ArtifactName,
520+
Size: art.FileSize,
521+
Status: status,
516522
})
517523
}
518524
ctx.JSON(http.StatusOK, artifactsResponse)

services/actions/cleanup.go

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package actions
5+
6+
import (
7+
"context"
8+
"time"
9+
10+
"code.gitea.io/gitea/models/actions"
11+
"code.gitea.io/gitea/modules/log"
12+
"code.gitea.io/gitea/modules/storage"
13+
)
14+
15+
// Cleanup removes expired actions logs, data and artifacts
16+
func Cleanup(taskCtx context.Context, olderThan time.Duration) error {
17+
// TODO: clean up expired actions logs
18+
19+
// clean up expired artifacts
20+
return CleanupArtifacts(taskCtx)
21+
}
22+
23+
// CleanupArtifacts removes expired artifacts and set records expired status
24+
func CleanupArtifacts(taskCtx context.Context) error {
25+
artifacts, err := actions.ListNeedExpiredArtifacts(taskCtx)
26+
if err != nil {
27+
return err
28+
}
29+
log.Info("Found %d expired artifacts", len(artifacts))
30+
for _, artifact := range artifacts {
31+
if err := storage.ActionsArtifacts.Delete(artifact.StoragePath); err != nil {
32+
log.Error("Cannot delete artifact %d: %v", artifact.ID, err)
33+
continue
34+
}
35+
if err := actions.SetArtifactExpired(taskCtx, artifact.ID); err != nil {
36+
log.Error("Cannot set artifact %d expired: %v", artifact.ID, err)
37+
continue
38+
}
39+
log.Info("Artifact %d set expired", artifact.ID)
40+
}
41+
return nil
42+
}

services/cron/tasks_basic.go

+18
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"code.gitea.io/gitea/models/webhook"
1414
"code.gitea.io/gitea/modules/git"
1515
"code.gitea.io/gitea/modules/setting"
16+
"code.gitea.io/gitea/services/actions"
1617
"code.gitea.io/gitea/services/auth"
1718
"code.gitea.io/gitea/services/migrations"
1819
mirror_service "code.gitea.io/gitea/services/mirror"
@@ -156,6 +157,20 @@ func registerCleanupPackages() {
156157
})
157158
}
158159

160+
func registerActionsCleanup() {
161+
RegisterTaskFatal("cleanup_actions", &OlderThanConfig{
162+
BaseConfig: BaseConfig{
163+
Enabled: true,
164+
RunAtStart: true,
165+
Schedule: "@midnight",
166+
},
167+
OlderThan: 24 * time.Hour,
168+
}, func(ctx context.Context, _ *user_model.User, config Config) error {
169+
realConfig := config.(*OlderThanConfig)
170+
return actions.Cleanup(ctx, realConfig.OlderThan)
171+
})
172+
}
173+
159174
func initBasicTasks() {
160175
if setting.Mirror.Enabled {
161176
registerUpdateMirrorTask()
@@ -172,4 +187,7 @@ func initBasicTasks() {
172187
if setting.Packages.Enabled {
173188
registerCleanupPackages()
174189
}
190+
if setting.Actions.Enabled {
191+
registerActionsCleanup()
192+
}
175193
}

0 commit comments

Comments
 (0)