mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
Add the ability to remove workflow runs, either using the UI or the HTTP API. Workflow runs can only be removed once a workflow run has completed. For security reasons, only a repository administrator or a token with `write:repository` permissions can remove runs. Resolves https://codeberg.org/forgejo/forgejo/issues/2184. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). 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 for Go changes (can be removed for JavaScript changes) - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [x] `make pr-go` before pushing ### Tests for JavaScript changes (can be removed for Go changes) - 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--> - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/12478): <!--number 12478 --><!--line 0 --><!--description bWFrZSBpdCBwb3NzaWJsZSB0byByZW1vdmUgd29ya2Zsb3cgcnVucw==-->make it possible to remove workflow runs<!--description--> <!--end release-notes-assistant--> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12478 Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
314 lines
12 KiB
Go
314 lines
12 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
// This artifact server is inspired by https://github.com/nektos/act/blob/master/pkg/artifacts/server.go.
|
||
// It updates url setting and uses ObjectStore to handle artifacts persistence.
|
||
|
||
package actions
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"forgejo.org/models/db"
|
||
"forgejo.org/modules/timeutil"
|
||
"forgejo.org/modules/util"
|
||
|
||
"xorm.io/builder"
|
||
)
|
||
|
||
// ArtifactStatus is the status of an artifact, uploading, expired or need-delete
|
||
type ArtifactStatus int64
|
||
|
||
const (
|
||
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
|
||
ArtifactStatusPendingDeletion // 5, ArtifactStatusPendingDeletion is the status of an artifact that is pending deletion
|
||
ArtifactStatusDeleted // 6, ArtifactStatusDeleted is the status of an artifact that is deleted
|
||
)
|
||
|
||
func init() {
|
||
db.RegisterModel(new(ActionArtifact))
|
||
}
|
||
|
||
// ActionArtifact is a file that is stored in the artifact storage.
|
||
type ActionArtifact struct {
|
||
ID int64 `xorm:"pk autoincr"`
|
||
RunID int64 `xorm:"index unique(runid_name_path)"` // The run id of the artifact
|
||
RunnerID int64
|
||
RepoID int64 `xorm:"index"`
|
||
OwnerID int64
|
||
CommitSHA string
|
||
StoragePath string // The path to the artifact in the storage
|
||
FileSize int64 // The size of the artifact in bytes
|
||
FileCompressedSize int64 // The size of the artifact in bytes after gzip compression
|
||
ContentEncoding string // The content encoding of the artifact
|
||
ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it
|
||
ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it
|
||
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, expiredDays int64) (*ActionArtifact, error) {
|
||
if err := t.LoadJob(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
artifact, err := getArtifactByNameAndPath(ctx, t.Job.RunID, artifactName, artifactPath)
|
||
if errors.Is(err, util.ErrNotExist) {
|
||
artifact := &ActionArtifact{
|
||
ArtifactName: artifactName,
|
||
ArtifactPath: artifactPath,
|
||
RunID: t.Job.RunID,
|
||
RunnerID: t.RunnerID,
|
||
RepoID: t.RepoID,
|
||
OwnerID: t.OwnerID,
|
||
CommitSHA: t.CommitSHA,
|
||
Status: int64(ArtifactStatusUploadPending),
|
||
ExpiredUnix: timeutil.TimeStamp(time.Now().Unix() + timeutil.Day*expiredDays),
|
||
}
|
||
if _, err := db.GetEngine(ctx).Insert(artifact); err != nil {
|
||
return nil, err
|
||
}
|
||
return artifact, nil
|
||
} else if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if _, err := db.GetEngine(ctx).ID(artifact.ID).Cols("expired_unix").Update(&ActionArtifact{
|
||
ExpiredUnix: timeutil.TimeStamp(time.Now().Unix() + timeutil.Day*expiredDays),
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return artifact, nil
|
||
}
|
||
|
||
// IsV4 reports whether the artifact was uploaded via the v4 backend.
|
||
// The v4 backend stores the whole artifact as a single zip file;
|
||
// v1-v3 stores each file as a separate row.
|
||
func (a *ActionArtifact) IsV4() bool {
|
||
return a.ArtifactName+".zip" == a.ArtifactPath && a.ContentEncoding == "application/zip"
|
||
}
|
||
|
||
func getArtifactByNameAndPath(ctx context.Context, runID int64, name, fpath string) (*ActionArtifact, error) {
|
||
var art ActionArtifact
|
||
has, err := db.GetEngine(ctx).Where("run_id = ? AND artifact_name = ? AND artifact_path = ?", runID, name, fpath).Get(&art)
|
||
if err != nil {
|
||
return nil, err
|
||
} else if !has {
|
||
return nil, util.ErrNotExist
|
||
}
|
||
return &art, nil
|
||
}
|
||
|
||
// UpdateArtifactByID updates an artifact by id
|
||
func UpdateArtifactByID(ctx context.Context, id int64, art *ActionArtifact) error {
|
||
art.ID = id
|
||
_, err := db.GetEngine(ctx).ID(id).AllCols().Update(art)
|
||
return err
|
||
}
|
||
|
||
type FindArtifactsOptions struct {
|
||
db.ListOptions
|
||
ID int64
|
||
RepoID int64
|
||
RunID int64
|
||
ArtifactName string
|
||
Status int
|
||
}
|
||
|
||
func (opts FindArtifactsOptions) ToConds() builder.Cond {
|
||
cond := builder.NewCond()
|
||
if opts.ID > 0 {
|
||
cond = cond.And(builder.Eq{"id": opts.ID})
|
||
}
|
||
if opts.RepoID > 0 {
|
||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
|
||
}
|
||
if opts.RunID > 0 {
|
||
cond = cond.And(builder.Eq{"run_id": opts.RunID})
|
||
}
|
||
if opts.ArtifactName != "" {
|
||
cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName})
|
||
}
|
||
if opts.Status > 0 {
|
||
cond = cond.And(builder.Eq{"status": opts.Status})
|
||
}
|
||
|
||
return cond
|
||
}
|
||
|
||
var _ db.FindOptionsOrder = FindArtifactsOptions{}
|
||
|
||
// ToOrders implements db.FindOptionsOrder, to have a stable order
|
||
func (opts FindArtifactsOptions) ToOrders() string {
|
||
return "id"
|
||
}
|
||
|
||
// ActionArtifactMeta is the meta data of an artifact
|
||
type ActionArtifactMeta struct {
|
||
ArtifactName string
|
||
FileSize int64
|
||
Status ArtifactStatus
|
||
}
|
||
|
||
// AggregatedArtifact is the aggregated view of a logical artifact
|
||
// (one or more rows sharing the same run_id + artifact_name), used by the
|
||
// public API to represent a single artifact to clients.
|
||
type AggregatedArtifact struct {
|
||
ID int64 `xorm:"id"`
|
||
RunID int64 `xorm:"run_id"`
|
||
RepoID int64 `xorm:"-"`
|
||
ArtifactName string `xorm:"artifact_name"`
|
||
FileSize int64 `xorm:"file_size"`
|
||
Status ArtifactStatus `xorm:"status"`
|
||
CreatedUnix timeutil.TimeStamp `xorm:"created_unix"`
|
||
UpdatedUnix timeutil.TimeStamp `xorm:"updated_unix"`
|
||
ExpiredUnix timeutil.TimeStamp `xorm:"expired_unix"`
|
||
}
|
||
|
||
// APIDownloadURL returns the download URL for this artifact under the given
|
||
// repository API URL prefix (e.g. "https://host/api/v1/repos/owner/name").
|
||
func (a *AggregatedArtifact) APIDownloadURL(repoAPIURL string) string {
|
||
return fmt.Sprintf("%s/actions/artifacts/%d/zip", repoAPIURL, a.ID)
|
||
}
|
||
|
||
// 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(builder.Eq{"run_id": runID}.And(builder.In("status", ArtifactStatusUploadConfirmed, ArtifactStatusExpired))).
|
||
GroupBy("artifact_name").
|
||
Select("artifact_name, sum(file_size) as file_size, max(status) as status").
|
||
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)
|
||
}
|
||
|
||
// ListPendingDeleteArtifacts returns all artifacts in pending-delete status.
|
||
// limit is the max number of artifacts to return.
|
||
func ListPendingDeleteArtifacts(ctx context.Context, limit int) ([]*ActionArtifact, error) {
|
||
arts := make([]*ActionArtifact, 0, limit)
|
||
return arts, db.GetEngine(ctx).
|
||
Where("status = ?", ArtifactStatusPendingDeletion).Limit(limit).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
|
||
}
|
||
|
||
// SetArtifactNeedDelete sets an artifact to need-delete, cron job will delete it
|
||
func SetArtifactNeedDelete(ctx context.Context, runID int64, name string) error {
|
||
_, err := db.GetEngine(ctx).Where("run_id=? AND artifact_name=? AND status = ?", runID, name, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusPendingDeletion)})
|
||
return err
|
||
}
|
||
|
||
// SetArtifactDeleted sets an artifact to deleted
|
||
func SetArtifactDeleted(ctx context.Context, artifactID int64) error {
|
||
_, err := db.GetEngine(ctx).ID(artifactID).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusDeleted)})
|
||
return err
|
||
}
|
||
|
||
// SetArtifactsOfRunDeleted marks all artifacts of the given run as deleted.
|
||
func SetArtifactsOfRunDeleted(ctx context.Context, runID int64) error {
|
||
_, err := db.GetEngine(ctx).
|
||
Where("run_id=?", runID).
|
||
Cols("status").
|
||
Update(&ActionArtifact{Status: int64(ArtifactStatusPendingDeletion)})
|
||
return err
|
||
}
|
||
|
||
// aggregatedArtifactConds returns the common WHERE clause used by aggregated
|
||
// artifact queries: restrict to visible statuses and apply the caller's filters.
|
||
// The Status field on opts is ignored — visibility is fixed to UploadConfirmed/Expired.
|
||
func aggregatedArtifactConds(opts FindArtifactsOptions) builder.Cond {
|
||
opts.Status = 0
|
||
return opts.ToConds().And(builder.In("status", ArtifactStatusUploadConfirmed, ArtifactStatusExpired))
|
||
}
|
||
|
||
const aggregatedArtifactSelect = "min(id) as id, run_id, artifact_name, sum(file_size) as file_size, max(status) as status, min(created_unix) as created_unix, max(updated_unix) as updated_unix, max(expired_unix) as expired_unix"
|
||
|
||
// ListAggregatedArtifacts returns paginated aggregated artifacts.
|
||
// Each result represents one logical artifact: a (run_id, artifact_name) group,
|
||
// with ID = MIN(id), FileSize = SUM(file_size), Status = MAX(status), and
|
||
// timestamps aggregated accordingly. Status filter in opts is ignored; results
|
||
// are always restricted to UploadConfirmed and Expired statuses.
|
||
func ListAggregatedArtifacts(ctx context.Context, opts FindArtifactsOptions) ([]*AggregatedArtifact, int64, error) {
|
||
cond := aggregatedArtifactConds(opts)
|
||
|
||
var countKeys []struct {
|
||
ID int64 `xorm:"id"`
|
||
}
|
||
if err := db.GetEngine(ctx).Table("action_artifact").
|
||
Where(cond).
|
||
GroupBy("run_id, artifact_name").
|
||
Select("min(id) as id").
|
||
Find(&countKeys); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
total := int64(len(countKeys))
|
||
|
||
sess := db.GetEngine(ctx).Table("action_artifact").
|
||
Where(cond).
|
||
GroupBy("run_id, artifact_name").
|
||
Select(aggregatedArtifactSelect).
|
||
OrderBy("id DESC")
|
||
|
||
capacity := 10
|
||
if opts.PageSize > 0 {
|
||
sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
|
||
capacity = opts.PageSize
|
||
}
|
||
|
||
arts := make([]*AggregatedArtifact, 0, capacity)
|
||
return arts, total, sess.Find(&arts)
|
||
}
|
||
|
||
// GetAggregatedArtifactByID returns the aggregated artifact by its canonical ID
|
||
// (MIN(id) of the group), scoped to the given repository. Returns util.ErrNotExist
|
||
// when the ID does not exist, is not canonical for its group, or does not belong to repoID.
|
||
// The repoID scoping is performed in the query so callers don't need a follow-up check.
|
||
func GetAggregatedArtifactByID(ctx context.Context, repoID, artifactID int64) (*AggregatedArtifact, error) {
|
||
var art ActionArtifact
|
||
has, err := db.GetEngine(ctx).Where(builder.Eq{"id": artifactID, "repo_id": repoID}).Get(&art)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !has {
|
||
return nil, util.ErrNotExist
|
||
}
|
||
|
||
cond := aggregatedArtifactConds(FindArtifactsOptions{
|
||
RunID: art.RunID,
|
||
ArtifactName: art.ArtifactName,
|
||
})
|
||
|
||
meta := new(AggregatedArtifact)
|
||
has, err = db.GetEngine(ctx).Table("action_artifact").
|
||
Where(cond).
|
||
GroupBy("run_id, artifact_name").
|
||
Select(aggregatedArtifactSelect).
|
||
Get(meta)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !has || meta.ID != artifactID {
|
||
return nil, util.ErrNotExist
|
||
}
|
||
|
||
meta.RepoID = art.RepoID
|
||
return meta, nil
|
||
}
|