;;
;; Default platform to get action plugins, `github` for `https://github.com`, `self` for the current Gitea instance.
;DEFAULT_ACTIONS_URL = github
+;; Default artifact retention time in days, default is 90 days
+;ARTIFACT_RETENTION_DAYS = 90
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
- `SCHEDULE`: **@midnight** : Interval as a duration between each synchronization, it will always attempt synchronization when the instance starts.
- `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.
+## Cron - Cleanup Expired Actions Assets (`cron.cleanup_actions`)
+
+- `ENABLED`: **true**: Enable cleanup expired actions assets job.
+- `RUN_AT_START`: **true**: Run job at start time (if ENABLED).
+- `SCHEDULE`: **@midnight** : Cron syntax for the job.
+
### Extended cron tasks (not enabled by default)
#### Cron - Garbage collect all repositories (`cron.git_gc_repos`)
- `DEFAULT_ACTIONS_URL`: **github**: Default platform to get action plugins, `github` for `https://github.com`, `self` for the current Gitea instance.
- `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]`
- `MINIO_BASE_PATH`: **actions_log/**: Minio base path on the bucket only available when STORAGE_TYPE is `minio`
+- `ARTIFACT_RETENTION_DAYS`: **90**: Number of days to keep artifacts. Set to 0 to disable artifact retention. Default is 90 days if not set.
`DEFAULT_ACTIONS_URL` indicates where the Gitea Actions runners should find the actions with relative path.
For example, `uses: actions/checkout@v3` means `https://github.com/actions/checkout@v3` since the value of `DEFAULT_ACTIONS_URL` is `github`.
import (
"context"
"errors"
+ "time"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
)
+// ArtifactStatus is the status of an artifact, uploading, expired or need-delete
+type ArtifactStatus int64
+
const (
- // ArtifactStatusUploadPending is the status of an artifact upload that is pending
- ArtifactStatusUploadPending = 1
- // ArtifactStatusUploadConfirmed is the status of an artifact upload that is confirmed
- ArtifactStatusUploadConfirmed = 2
- // ArtifactStatusUploadError is the status of an artifact upload that is errored
- ArtifactStatusUploadError = 3
+ ArtifactStatusUploadPending ArtifactStatus = iota + 1 // 1, ArtifactStatusUploadPending is the status of an artifact upload that is pending
+ ArtifactStatusUploadConfirmed // 2, ArtifactStatusUploadConfirmed is the status of an artifact upload that is confirmed
+ ArtifactStatusUploadError // 3, ArtifactStatusUploadError is the status of an artifact upload that is errored
+ ArtifactStatusExpired // 4, ArtifactStatusExpired is the status of an artifact that is expired
)
func init() {
Status int64 `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated index"`
+ ExpiredUnix timeutil.TimeStamp `xorm:"index"` // The time when the artifact will be expired
}
-func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPath string) (*ActionArtifact, error) {
+func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPath string, expiredDays int64) (*ActionArtifact, error) {
if err := t.LoadJob(ctx); err != nil {
return nil, err
}
RepoID: t.RepoID,
OwnerID: t.OwnerID,
CommitSHA: t.CommitSHA,
- Status: ArtifactStatusUploadPending,
+ Status: int64(ArtifactStatusUploadPending),
+ ExpiredUnix: timeutil.TimeStamp(time.Now().Unix() + 3600*24*expiredDays),
}
if _, err := db.GetEngine(ctx).Insert(artifact); err != nil {
return nil, err
type ActionArtifactMeta struct {
ArtifactName string
FileSize int64
+ Status int64
}
// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run
func ListUploadedArtifactsMeta(ctx context.Context, runID int64) ([]*ActionArtifactMeta, error) {
arts := make([]*ActionArtifactMeta, 0, 10)
return arts, db.GetEngine(ctx).Table("action_artifact").
- Where("run_id=? AND status=?", runID, ArtifactStatusUploadConfirmed).
+ Where("run_id=? AND (status=? OR status=?)", runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
GroupBy("artifact_name").
- Select("artifact_name, sum(file_size) as file_size").
+ Select("artifact_name, sum(file_size) as file_size, max(status) as status").
Find(&arts)
}
arts := make([]*ActionArtifact, 0, 10)
return arts, db.GetEngine(ctx).Where("run_id=? AND artifact_name=?", runID, name).Find(&arts)
}
+
+// ListNeedExpiredArtifacts returns all need expired artifacts but not deleted
+func ListNeedExpiredArtifacts(ctx context.Context) ([]*ActionArtifact, error) {
+ arts := make([]*ActionArtifact, 0, 10)
+ return arts, db.GetEngine(ctx).
+ Where("expired_unix < ? AND status = ?", timeutil.TimeStamp(time.Now().Unix()), ArtifactStatusUploadConfirmed).Find(&arts)
+}
+
+// SetArtifactExpired sets an artifact to expired
+func SetArtifactExpired(ctx context.Context, artifactID int64) error {
+ _, err := db.GetEngine(ctx).Where("id=? AND status = ?", artifactID, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusExpired)})
+ return err
+}
NewMigration("Add Version to ActionRun table", v1_21.AddVersionToActionRunTable),
// v273 -> v274
NewMigration("Add Action Schedule Table", v1_21.AddActionScheduleTable),
+ // v274 -> v275
+ NewMigration("Add Actions artifacts expiration date", v1_21.AddExpiredUnixColumnInActionArtifactTable),
}
// GetCurrentDBVersion returns the current db version
--- /dev/null
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_21 //nolint
+import (
+ "time"
+
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "xorm.io/xorm"
+)
+
+func AddExpiredUnixColumnInActionArtifactTable(x *xorm.Engine) error {
+ type ActionArtifact struct {
+ ExpiredUnix timeutil.TimeStamp `xorm:"index"` // time when the artifact will be expired
+ }
+ if err := x.Sync(new(ActionArtifact)); err != nil {
+ return err
+ }
+ return updateArtifactsExpiredUnixTo90Days(x)
+}
+
+func updateArtifactsExpiredUnixTo90Days(x *xorm.Engine) error {
+ sess := x.NewSession()
+ defer sess.Close()
+
+ if err := sess.Begin(); err != nil {
+ return err
+ }
+ expiredTime := time.Now().AddDate(0, 0, 90).Unix()
+ if _, err := sess.Exec(`UPDATE action_artifact SET expired_unix=? WHERE status='2' AND expired_unix is NULL`, expiredTime); err != nil {
+ return err
+ }
+
+ return sess.Commit()
+}
// Actions settings
var (
Actions = struct {
- LogStorage *Storage // how the created logs should be stored
- ArtifactStorage *Storage // how the created artifacts should be stored
- Enabled bool
- DefaultActionsURL defaultActionsURL `ini:"DEFAULT_ACTIONS_URL"`
+ LogStorage *Storage // how the created logs should be stored
+ ArtifactStorage *Storage // how the created artifacts should be stored
+ ArtifactRetentionDays int64 `ini:"ARTIFACT_RETENTION_DAYS"`
+ Enabled bool
+ DefaultActionsURL defaultActionsURL `ini:"DEFAULT_ACTIONS_URL"`
}{
Enabled: false,
DefaultActionsURL: defaultActionsURLGitHub,
Actions.ArtifactStorage, err = getStorage(rootCfg, "actions_artifacts", "", actionsSec)
+ // default to 90 days in Github Actions
+ if Actions.ArtifactRetentionDays <= 0 {
+ Actions.ArtifactRetentionDays = 90
+ }
+
return err
}
dashboard.sync_external_users = Synchronize external user data
dashboard.cleanup_hook_task_table = Cleanup hook_task table
dashboard.cleanup_packages = Cleanup expired packages
+dashboard.cleanup_actions = Cleanup actions expired logs and artifacts
dashboard.server_uptime = Server Uptime
dashboard.current_goroutine = Current Goroutines
dashboard.current_memory_usage = Current Memory Usage
}
type getUploadArtifactRequest struct {
- Type string
- Name string
+ Type string
+ Name string
+ RetentionDays int64
}
type getUploadArtifactResponse struct {
return
}
+ // set retention days
+ retentionQuery := ""
+ if req.RetentionDays > 0 {
+ retentionQuery = fmt.Sprintf("?retentionDays=%d", req.RetentionDays)
+ }
+
// use md5(artifact_name) to create upload url
artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(req.Name)))
resp := getUploadArtifactResponse{
- FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "upload"),
+ FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "upload"+retentionQuery),
}
log.Debug("[artifact] get upload url: %s", resp.FileContainerResourceURL)
ctx.JSON(http.StatusOK, resp)
return
}
+ // get artifact retention days
+ expiredDays := setting.Actions.ArtifactRetentionDays
+ if queryRetentionDays := ctx.Req.URL.Query().Get("retentionDays"); queryRetentionDays != "" {
+ expiredDays, err = strconv.ParseInt(queryRetentionDays, 10, 64)
+ if err != nil {
+ log.Error("Error parse retention days: %v", err)
+ ctx.Error(http.StatusBadRequest, "Error parse retention days")
+ return
+ }
+ }
+ log.Debug("[artifact] upload chunk, name: %s, path: %s, size: %d, retention days: %d",
+ artifactName, artifactPath, fileRealTotalSize, expiredDays)
+
// create or get artifact with name and path
- artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath)
+ artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath, expiredDays)
if err != nil {
log.Error("Error create or get artifact: %v", err)
ctx.Error(http.StatusInternalServerError, "Error create or get artifact")
// save storage path to artifact
log.Debug("[artifact] merge chunks to artifact: %d, %s", artifact.ID, storagePath)
artifact.StoragePath = storagePath
- artifact.Status = actions.ArtifactStatusUploadConfirmed
+ artifact.Status = int64(actions.ArtifactStatusUploadConfirmed)
if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
return fmt.Errorf("update artifact error: %v", err)
}
}
type ArtifactsViewItem struct {
- Name string `json:"name"`
- Size int64 `json:"size"`
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ Status string `json:"status"`
}
func ArtifactsView(ctx *context_module.Context) {
Artifacts: make([]*ArtifactsViewItem, 0, len(artifacts)),
}
for _, art := range artifacts {
+ status := "completed"
+ if art.Status == int64(actions_model.ArtifactStatusExpired) {
+ status = "expired"
+ }
artifactsResponse.Artifacts = append(artifactsResponse.Artifacts, &ArtifactsViewItem{
- Name: art.ArtifactName,
- Size: art.FileSize,
+ Name: art.ArtifactName,
+ Size: art.FileSize,
+ Status: status,
})
}
ctx.JSON(http.StatusOK, artifactsResponse)
--- /dev/null
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ "context"
+ "time"
+
+ "code.gitea.io/gitea/models/actions"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/storage"
+)
+
+// Cleanup removes expired actions logs, data and artifacts
+func Cleanup(taskCtx context.Context, olderThan time.Duration) error {
+ // TODO: clean up expired actions logs
+
+ // clean up expired artifacts
+ return CleanupArtifacts(taskCtx)
+}
+
+// CleanupArtifacts removes expired artifacts and set records expired status
+func CleanupArtifacts(taskCtx context.Context) error {
+ artifacts, err := actions.ListNeedExpiredArtifacts(taskCtx)
+ if err != nil {
+ return err
+ }
+ log.Info("Found %d expired artifacts", len(artifacts))
+ for _, artifact := range artifacts {
+ if err := storage.ActionsArtifacts.Delete(artifact.StoragePath); err != nil {
+ log.Error("Cannot delete artifact %d: %v", artifact.ID, err)
+ continue
+ }
+ if err := actions.SetArtifactExpired(taskCtx, artifact.ID); err != nil {
+ log.Error("Cannot set artifact %d expired: %v", artifact.ID, err)
+ continue
+ }
+ log.Info("Artifact %d set expired", artifact.ID)
+ }
+ return nil
+}
"code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/actions"
"code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/migrations"
mirror_service "code.gitea.io/gitea/services/mirror"
})
}
+func registerActionsCleanup() {
+ RegisterTaskFatal("cleanup_actions", &OlderThanConfig{
+ BaseConfig: BaseConfig{
+ Enabled: true,
+ RunAtStart: true,
+ Schedule: "@midnight",
+ },
+ OlderThan: 24 * time.Hour,
+ }, func(ctx context.Context, _ *user_model.User, config Config) error {
+ realConfig := config.(*OlderThanConfig)
+ return actions.Cleanup(ctx, realConfig.OlderThan)
+ })
+}
+
func initBasicTasks() {
if setting.Mirror.Enabled {
registerUpdateMirrorTask()
if setting.Packages.Enabled {
registerCleanupPackages()
}
+ if setting.Actions.Enabled {
+ registerActionsCleanup()
+ }
}
}
type getUploadArtifactRequest struct {
- Type string
- Name string
+ Type string
+ Name string
+ RetentionDays int64
}
func TestActionsArtifactUploadSingleFile(t *testing.T) {
assert.Equal(t, resp.Body.String(), body)
}
}
+
+func TestActionsArtifactUploadWithRetentionDays(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // acquire artifact upload url
+ req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
+ Type: "actions_storage",
+ Name: "artifact-retention-days",
+ RetentionDays: 9,
+ })
+ req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp := MakeRequest(t, req, http.StatusOK)
+ var uploadResp uploadArtifactResponse
+ DecodeJSON(t, resp, &uploadResp)
+ assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
+ assert.Contains(t, uploadResp.FileContainerResourceURL, "?retentionDays=9")
+
+ // get upload url
+ idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
+ url := uploadResp.FileContainerResourceURL[idx:] + "&itemPath=artifact-retention-days/abc.txt"
+
+ // upload artifact chunk
+ body := strings.Repeat("A", 1024)
+ req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body))
+ req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ req.Header.Add("Content-Range", "bytes 0-1023/1024")
+ req.Header.Add("x-tfs-filelength", "1024")
+ req.Header.Add("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body))
+ MakeRequest(t, req, http.StatusOK)
+
+ t.Logf("Create artifact confirm")
+
+ // confirm artifact upload
+ req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact-retention-days")
+ req = addTokenAuthHeader(req, "Bearer 8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ MakeRequest(t, req, http.StatusOK)
+}