mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-14 11:53:42 +00:00
enhance: add builtin:download-artifact (#1344)
Co-authored-by: bircni <[email protected]>
This commit is contained in:
1 parent
02446620df
commit
c8b16f4792
11 files changed
+912
-4
No files matched your search
@@ -0,0 +1,117 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type REST struct {
|
||||
base *url.URL
|
||||
token string
|
||||
api *http.Client
|
||||
blob *http.Client
|
||||
Retry Retry
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
// NewREST takes the instance as the runner reaches it, never the job's GITHUB_API_URL, so the token only goes there.
|
||||
func NewREST(instance, token string, transport http.RoundTripper) (*REST, error) {
|
||||
parsed, err := url.Parse(instance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &REST{
|
||||
base: parsed.JoinPath("api", "v1"),
|
||||
token: token,
|
||||
api: &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse // the redirect target is signed and must not get the token
|
||||
}},
|
||||
blob: &http.Client{Transport: blobTransport{instance: parsed.Host, transport: transport}},
|
||||
Retry: DefaultRetry,
|
||||
Logf: func(string, ...any) {},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *REST) get(ctx context.Context, what string, endpoint *url.URL) (*http.Response, error) {
|
||||
return r.Retry.do(ctx, r.Logf, what, func() (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+r.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return r.api.Do(req)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *REST) List(ctx context.Context, owner, repo string, runID int64, name string) ([]Artifact, error) {
|
||||
if name == "" {
|
||||
r.Logf("Fetching artifact list for workflow run %d in repository %s/%s", runID, owner, repo)
|
||||
}
|
||||
var list []Artifact
|
||||
seen := 0
|
||||
for page := 1; ; page++ {
|
||||
endpoint := r.base.JoinPath("repos", owner, repo, "actions", "runs", strconv.FormatInt(runID, 10), "artifacts")
|
||||
query := url.Values{"page": {strconv.Itoa(page)}, "limit": {"50"}}
|
||||
if name != "" {
|
||||
query.Set("name", name)
|
||||
}
|
||||
endpoint.RawQuery = query.Encode()
|
||||
resp, err := r.get(ctx, "list artifacts", endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var body struct {
|
||||
TotalCount int `json:"total_count"`
|
||||
Artifacts []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SizeInBytes int64 `json:"size_in_bytes"`
|
||||
Expired bool `json:"expired"`
|
||||
Digest string `json:"digest"`
|
||||
} `json:"artifacts"`
|
||||
}
|
||||
err = json.UnmarshalRead(resp.Body, &body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list artifacts: %w", err)
|
||||
}
|
||||
for _, listed := range body.Artifacts {
|
||||
if !listed.Expired {
|
||||
list = append(list, Artifact{ID: listed.ID, Name: listed.Name, Size: listed.SizeInBytes, Digest: listed.Digest})
|
||||
}
|
||||
}
|
||||
if seen += len(body.Artifacts); len(body.Artifacts) == 0 || seen >= body.TotalCount {
|
||||
return list, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *REST) DownloadURL(ctx context.Context, owner, repo string, id int64) (string, error) {
|
||||
resp, err := r.get(ctx, "download artifact", r.base.JoinPath("repos", owner, repo, "actions", "artifacts", strconv.FormatInt(id, 10), "zip"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to download artifact: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
return "", fmt.Errorf("unable to download artifact. Unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
location, err := resp.Location()
|
||||
if err != nil {
|
||||
return "", errors.New("unable to redirect to artifact download url")
|
||||
}
|
||||
return location.String(), nil
|
||||
}
|
||||
|
||||
func (r *REST) Download(ctx context.Context, signedURL string, stall time.Duration) (*http.Response, error) {
|
||||
return (&Client{blob: r.blob}).Download(ctx, signedURL, stall)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRESTListsEveryPageAndSkipsExpired(t *testing.T) {
|
||||
var pages []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/repos/own%20er/repo/actions/runs/12/artifacts", r.URL.EscapedPath())
|
||||
assert.Equal(t, "token secret", r.Header.Get("Authorization"))
|
||||
assert.Empty(t, r.URL.Query().Get("per_page"))
|
||||
pages = append(pages, r.URL.Query().Get("page")+"/"+r.URL.Query().Get("limit"))
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
const maxResponseItems = 20
|
||||
var entries []string
|
||||
for id := (page-1)*maxResponseItems + 1; id <= min(page*maxResponseItems, 53); id++ {
|
||||
entries = append(entries, fmt.Sprintf(`{"id":%d,"name":"a%d","size_in_bytes":%d,"expired":%t}`, id, id, id, id == 2))
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"total_count":53,"artifacts":[`+strings.Join(entries, ",")+`]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
rest, err := NewREST(server.URL, "secret", nil)
|
||||
require.NoError(t, err)
|
||||
list, err := rest.List(t.Context(), "own er", "repo", 12, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"1/50", "2/50", "3/50"}, pages)
|
||||
assert.Len(t, list, 52)
|
||||
assert.Equal(t, Artifact{ID: 3, Name: "a3", Size: 3}, list[1])
|
||||
}
|
||||
Reference in new issue
Block a user