Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
09ef963ca3 |
|||
|
3d43ff4758 |
|||
|
db684e73f8 |
30 changed files with 739 additions and 2811 deletions
|
|
@ -1,19 +0,0 @@
|
||||||
name: default
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
default:
|
|
||||||
name: ensure tests work
|
|
||||||
runs-on: debian-latest
|
|
||||||
container: docker.io/golang:1.24-alpine
|
|
||||||
steps:
|
|
||||||
- name: checkout
|
|
||||||
uses: https://code.geekeey.de/actions/checkout@1
|
|
||||||
- name: go test
|
|
||||||
run: |
|
|
||||||
go mod tidy
|
|
||||||
go mod verify
|
|
||||||
go test ./...
|
|
||||||
|
|
@ -17,7 +17,7 @@ const (
|
||||||
|
|
||||||
// https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
|
// https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
|
||||||
multiLineFileDelim = "234baa68-d26f-4bf9-996d-45ec3520cb95"
|
multiLineFileDelim = "234baa68-d26f-4bf9-996d-45ec3520cb95"
|
||||||
multilineFileCmd = "%s<<" + multiLineFileDelim + "\n%s\n" + multiLineFileDelim + "\n" // ${name}<<${delimiter}${os.EOL}${convertedVal}${os.EOL}${delimiter}${os.EOL}
|
multilineFileCmd = "%s<<" + multiLineFileDelim + "\n%s\n" + multiLineFileDelim // ${name}<<${delimiter}${os.EOL}${convertedVal}${os.EOL}${delimiter}
|
||||||
|
|
||||||
addMatcherCmd = "add-matcher"
|
addMatcherCmd = "add-matcher"
|
||||||
removeMatcherCmd = "remove-matcher"
|
removeMatcherCmd = "remove-matcher"
|
||||||
|
|
|
||||||
|
|
@ -322,8 +322,8 @@ func TestAction_SaveState(t *testing.T) {
|
||||||
t.Errorf("unable to read temp env file: %s", err)
|
t.Errorf("unable to read temp env file: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := fmt.Sprintf("key<<%s\nvalue\n%s\n", multiLineFileDelim, multiLineFileDelim)
|
want := fmt.Sprintf("key<<%s\nvalue\n%s", multiLineFileDelim, multiLineFileDelim)
|
||||||
want += fmt.Sprintf("key2<<%s\nvalue2\n%s\n", multiLineFileDelim, multiLineFileDelim)
|
want += fmt.Sprintf("key2<<%s\nvalue2\n%s", multiLineFileDelim, multiLineFileDelim)
|
||||||
if got := string(data); got != want {
|
if got := string(data); got != want {
|
||||||
t.Errorf("expected %q to be %q", got, want)
|
t.Errorf("expected %q to be %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
@ -399,8 +399,8 @@ func TestAction_SetEnv(t *testing.T) {
|
||||||
t.Errorf("unable to read temp env file: %s", err)
|
t.Errorf("unable to read temp env file: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := fmt.Sprintf("key<<%s\nvalue\n%s\n", multiLineFileDelim, multiLineFileDelim)
|
want := fmt.Sprintf("key<<%s\nvalue\n%s", multiLineFileDelim, multiLineFileDelim)
|
||||||
want += fmt.Sprintf("key2<<%s\nvalue2\n%s\n", multiLineFileDelim, multiLineFileDelim)
|
want += fmt.Sprintf("key2<<%s\nvalue2\n%s", multiLineFileDelim, multiLineFileDelim)
|
||||||
if got := string(data); got != want {
|
if got := string(data); got != want {
|
||||||
t.Errorf("expected %q to be %q", got, want)
|
t.Errorf("expected %q to be %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
@ -435,8 +435,8 @@ func TestAction_SetOutput(t *testing.T) {
|
||||||
t.Errorf("unable to read temp env file: %s", err)
|
t.Errorf("unable to read temp env file: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := fmt.Sprintf("key<<%s\nvalue\n%s\n", multiLineFileDelim, multiLineFileDelim)
|
want := fmt.Sprintf("key<<%s\nvalue\n%s", multiLineFileDelim, multiLineFileDelim)
|
||||||
want += fmt.Sprintf("key2<<%s\nvalue2\n%s\n", multiLineFileDelim, multiLineFileDelim)
|
want += fmt.Sprintf("key2<<%s\nvalue2\n%s", multiLineFileDelim, multiLineFileDelim)
|
||||||
if got := string(data); got != want {
|
if got := string(data); got != want {
|
||||||
t.Errorf("expected %q to be %q", got, want)
|
t.Errorf("expected %q to be %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
package artifact
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.geekeey.de/actions/sdk"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CreateArtifactRequest struct {
|
|
||||||
RunID string `json:"workflowRunBackendId"`
|
|
||||||
JobRunID string `json:"workflowJobRunBackendId"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Version int32 `json:"version"`
|
|
||||||
|
|
||||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateArtifactResponse struct {
|
|
||||||
Ok bool `json:"ok"`
|
|
||||||
SignedUploadUrl string `json:"signedUploadUrl"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type FinalizeArtifactRequest struct {
|
|
||||||
RunID string `json:"workflowRunBackendId"`
|
|
||||||
JobRunID string `json:"workflowJobRunBackendId"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
Hash string `json:"hash,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type FinalizeArtifactResponse struct {
|
|
||||||
Ok bool `json:"ok"`
|
|
||||||
ArtifactId string `json:"artifactId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListArtifactsRequest struct {
|
|
||||||
RunID string `json:"workflowRunBackendId"`
|
|
||||||
JobRunID string `json:"workflowJobRunBackendId"`
|
|
||||||
NameFilter string `json:"nameFilter"`
|
|
||||||
IdFilter string `json:"idFilter"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Artifact struct {
|
|
||||||
RunID string `json:"workflowRunBackendId"`
|
|
||||||
JobRunID string `json:"workflowJobRunBackendId"`
|
|
||||||
Id string `json:"databaseId"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Size string `json:"size"`
|
|
||||||
CreatedAt *time.Time `json:"createdAt"`
|
|
||||||
Digest string `json:"digest"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListArtifactsResponse struct {
|
|
||||||
Artifacts []Artifact `json:"artifacts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetSignedArtifactURLRequest struct {
|
|
||||||
RunID string `json:"workflowRunBackendId"`
|
|
||||||
JobRunID string `json:"workflowJobRunBackendId"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetSignedArtifactURLResponse struct {
|
|
||||||
SignedUrl string `json:"signedUrl"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeleteArtifactRequest struct {
|
|
||||||
RunID string `json:"workflowRunBackendId"`
|
|
||||||
JobRunID string `json:"workflowJobRunBackendId"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeleteArtifactResponse struct {
|
|
||||||
Ok bool `json:"ok"`
|
|
||||||
ArtifactId string `json:"artifactId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) CreateArtifact(ctx context.Context, req CreateArtifactRequest) (*CreateArtifactResponse, error) {
|
|
||||||
endpoint := "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact"
|
|
||||||
var result CreateArtifactResponse
|
|
||||||
if err := c.twirp(ctx, endpoint, req, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) FinalizeArtifact(ctx context.Context, req FinalizeArtifactRequest) (*FinalizeArtifactResponse, error) {
|
|
||||||
endpoint := "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact"
|
|
||||||
var result FinalizeArtifactResponse
|
|
||||||
if err := c.twirp(ctx, endpoint, req, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) ListArtifacts(ctx context.Context, req ListArtifactsRequest) (*ListArtifactsResponse, error) {
|
|
||||||
endpoint := "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts"
|
|
||||||
var result ListArtifactsResponse
|
|
||||||
if err := c.twirp(ctx, endpoint, req, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetSignedArtifactURL(ctx context.Context, req GetSignedArtifactURLRequest) (*GetSignedArtifactURLResponse, error) {
|
|
||||||
endpoint := "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL"
|
|
||||||
var result GetSignedArtifactURLResponse
|
|
||||||
if err := c.twirp(ctx, endpoint, req, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) DeleteArtifact(ctx context.Context, req DeleteArtifactRequest) (*DeleteArtifactResponse, error) {
|
|
||||||
endpoint := "/twirp/github.actions.results.api.v1.ArtifactService/DeleteArtifact"
|
|
||||||
var result DeleteArtifactResponse
|
|
||||||
if err := c.twirp(ctx, endpoint, req, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) twirp(ctx context.Context, url string, in any, out any) error {
|
|
||||||
var b bytes.Buffer
|
|
||||||
|
|
||||||
if err := json.NewEncoder(&b).Encode(in); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &b)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("content-type", "application/json")
|
|
||||||
req.Header.Set("accept", "application/json")
|
|
||||||
|
|
||||||
res, err := c.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
if !sdk.HasContentType(res, "application/json") {
|
|
||||||
return fmt.Errorf("content-type: %q should be %q",
|
|
||||||
res.Header.Get("content-type"), "application/json")
|
|
||||||
}
|
|
||||||
dec := json.NewDecoder(io.LimitReader(res.Body, 32*1024))
|
|
||||||
|
|
||||||
if res.StatusCode >= 200 && res.StatusCode < 300 {
|
|
||||||
return dec.Decode(out)
|
|
||||||
} else {
|
|
||||||
body, _ := io.ReadAll(res.Body)
|
|
||||||
return fmt.Errorf("twirp call %s failed: %d: %s", url, res.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
package artifact
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
type capturedRequest struct {
|
|
||||||
path string
|
|
||||||
auth string
|
|
||||||
body []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestServer(t *testing.T, status int, contentType, response string) (*httptest.Server, *capturedRequest) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
got := &capturedRequest{}
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
got.path = r.URL.Path
|
|
||||||
got.auth = r.Header.Get("Authorization")
|
|
||||||
got.body, _ = io.ReadAll(r.Body)
|
|
||||||
w.Header().Set("content-type", contentType)
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_, _ = io.WriteString(w, response)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
|
|
||||||
return server, got
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClient_CreateArtifact(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server, got := newTestServer(t, http.StatusOK, "application/json",
|
|
||||||
`{"ok":true,"signedUploadUrl":"https://example.test/upload"}`)
|
|
||||||
|
|
||||||
c := NewClient(server.URL, "secret")
|
|
||||||
res, err := c.CreateArtifact(context.Background(), CreateArtifactRequest{RunID: "run", JobRunID: "job", Name: "foo", Version: 4})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !res.Ok || res.SignedUploadUrl != "https://example.test/upload" {
|
|
||||||
t.Errorf("unexpected response: %+v", res)
|
|
||||||
}
|
|
||||||
if got, want := got.path, "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact"; got != want {
|
|
||||||
t.Errorf("expected path %q to be %q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := got.auth, "Bearer secret"; got != want {
|
|
||||||
t.Errorf("expected Authorization %q to be %q", got, want)
|
|
||||||
}
|
|
||||||
var req CreateArtifactRequest
|
|
||||||
if err := json.Unmarshal(got.body, &req); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if req != (CreateArtifactRequest{RunID: "run", JobRunID: "job", Name: "foo", Version: 4}) {
|
|
||||||
t.Errorf("unexpected request: %+v", req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClient_FinalizeArtifact(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server, got := newTestServer(t, http.StatusOK, "application/json",
|
|
||||||
`{"ok":true,"artifactId":"42"}`)
|
|
||||||
|
|
||||||
c := NewClient(server.URL, "secret")
|
|
||||||
res, err := c.FinalizeArtifact(context.Background(), FinalizeArtifactRequest{RunID: "run", JobRunID: "job", Name: "foo", Size: 10, Hash: "abc"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !res.Ok || res.ArtifactId != "42" {
|
|
||||||
t.Errorf("unexpected response: %+v", res)
|
|
||||||
}
|
|
||||||
if got, want := got.path, "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact"; got != want {
|
|
||||||
t.Errorf("expected path %q to be %q", got, want)
|
|
||||||
}
|
|
||||||
var req FinalizeArtifactRequest
|
|
||||||
if err := json.Unmarshal(got.body, &req); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if req.Size != 10 || req.Hash != "abc" {
|
|
||||||
t.Errorf("unexpected request: %+v", req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClient_ListArtifacts(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server, got := newTestServer(t, http.StatusOK, "application/json",
|
|
||||||
`{"artifacts":[{"databaseId":"7","name":"foo"}]}`)
|
|
||||||
|
|
||||||
c := NewClient(server.URL, "secret")
|
|
||||||
res, err := c.ListArtifacts(context.Background(), ListArtifactsRequest{NameFilter: "foo"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(res.Artifacts) != 1 || res.Artifacts[0].Id != "7" || res.Artifacts[0].Name != "foo" {
|
|
||||||
t.Errorf("unexpected response: %+v", res)
|
|
||||||
}
|
|
||||||
if got, want := got.path, "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts"; got != want {
|
|
||||||
t.Errorf("expected path %q to be %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClient_GetSignedArtifactURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server, got := newTestServer(t, http.StatusOK, "application/json",
|
|
||||||
`{"signedUrl":"https://example.test/download"}`)
|
|
||||||
|
|
||||||
c := NewClient(server.URL, "secret")
|
|
||||||
res, err := c.GetSignedArtifactURL(context.Background(), GetSignedArtifactURLRequest{RunID: "run", JobRunID: "job", Name: "foo"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if res.SignedUrl != "https://example.test/download" {
|
|
||||||
t.Errorf("unexpected response: %+v", res)
|
|
||||||
}
|
|
||||||
if got, want := got.path, "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL"; got != want {
|
|
||||||
t.Errorf("expected path %q to be %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClient_DeleteArtifact(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server, got := newTestServer(t, http.StatusOK, "application/json",
|
|
||||||
`{"ok":true,"artifactId":"7"}`)
|
|
||||||
|
|
||||||
c := NewClient(server.URL, "secret")
|
|
||||||
res, err := c.DeleteArtifact(context.Background(), DeleteArtifactRequest{RunID: "run", JobRunID: "job", Name: "foo"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !res.Ok || res.ArtifactId != "7" {
|
|
||||||
t.Errorf("unexpected response: %+v", res)
|
|
||||||
}
|
|
||||||
if got, want := got.path, "/twirp/github.actions.results.api.v1.ArtifactService/DeleteArtifact"; got != want {
|
|
||||||
t.Errorf("expected path %q to be %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClient_twirp(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
status int
|
|
||||||
contentType string
|
|
||||||
body string
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{"bad content type", http.StatusOK, "text/plain", "nope", "content-type"},
|
|
||||||
{"server error", http.StatusInternalServerError, "application/json", `{"message":"boom"}`, "twirp call"},
|
|
||||||
{"bad json", http.StatusOK, "application/json", "{", "unexpected EOF"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server, _ := newTestServer(t, tc.status, tc.contentType, tc.body)
|
|
||||||
|
|
||||||
c := NewClient(server.URL, "secret")
|
|
||||||
_, err := c.CreateArtifact(context.Background(), CreateArtifactRequest{Name: "foo"})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
||||||
t.Errorf("expected error containing %q, got %v", tc.wantErr, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Package artifact provides a client for the GitHub Actions results service.
|
|
||||||
// The results service exposes the ArtifactService twirp API and the Azure
|
|
||||||
// block-blob storage used to upload and download workflow artifacts.
|
|
||||||
package artifact
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"code.geekeey.de/actions/sdk"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client talks to the results service. It authorizes every request with the
|
|
||||||
// action runtime token and resolves relative URLs against the results URL.
|
|
||||||
type Client struct {
|
|
||||||
http *sdk.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient returns a client that authenticates against the results service at
|
|
||||||
// base using the given runtime token.
|
|
||||||
func NewClient(base, runtimeToken string) *Client {
|
|
||||||
return &Client{http: sdk.NewClient(base, runtimeToken)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClientFromEnv returns a client configured from the ACTIONS_RESULTS_URL and
|
|
||||||
// ACTIONS_RUNTIME_TOKEN environment variables.
|
|
||||||
func NewClientFromEnv(env func(string) string) *Client {
|
|
||||||
return NewClient(env("ACTIONS_RESULTS_URL"), env("ACTIONS_RUNTIME_TOKEN"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do implements the request executor used by the twirp methods.
|
|
||||||
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
|
||||||
return c.http.Do(req)
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
package artifact
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestE2E_ArtifactRoundTrip exercises the real results service. It only runs
|
|
||||||
// inside a CI pipeline (GITHUB_ACTIONS=true) and is skipped locally so that
|
|
||||||
// `go test ./...` stays hermetic. Its purpose is to detect drift between the
|
|
||||||
// results service the runner talks to and the API this client expects.
|
|
||||||
func TestE2E_ArtifactRoundTrip(t *testing.T) {
|
|
||||||
if os.Getenv("GITHUB_ACTIONS") != "true" {
|
|
||||||
t.Skip("e2e test only runs in CI")
|
|
||||||
}
|
|
||||||
|
|
||||||
resultsURL := os.Getenv("ACTIONS_RESULTS_URL")
|
|
||||||
runtimeToken := os.Getenv("ACTIONS_RUNTIME_TOKEN")
|
|
||||||
if resultsURL == "" || runtimeToken == "" {
|
|
||||||
t.Skip("ACTIONS_RESULTS_URL and ACTIONS_RUNTIME_TOKEN are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
run, job, err := JobInfo()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("JobInfo: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client := NewClient(resultsURL, runtimeToken)
|
|
||||||
name := fmt.Sprintf("e2e-%d-%d", os.Getpid(), time.Now().UnixNano())
|
|
||||||
|
|
||||||
expire := time.Now().Add(1 * time.Hour)
|
|
||||||
create, err := client.CreateArtifact(ctx, CreateArtifactRequest{
|
|
||||||
RunID: run,
|
|
||||||
JobRunID: job,
|
|
||||||
Name: name,
|
|
||||||
Version: 4,
|
|
||||||
ExpiresAt: &expire,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateArtifact: %v", err)
|
|
||||||
}
|
|
||||||
if !create.Ok {
|
|
||||||
t.Fatalf("CreateArtifact not ok: %+v", create)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if _, err := client.DeleteArtifact(context.Background(), DeleteArtifactRequest{RunID: run, JobRunID: job, Name: name}); err != nil {
|
|
||||||
t.Logf("cleanup DeleteArtifact: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
content := []byte("artifact e2e " + name)
|
|
||||||
pushed, err := PushBlob(ctx, nil, bytes.NewReader(content), create.SignedUploadUrl, 0, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PushBlob: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
finalize, err := client.FinalizeArtifact(ctx, FinalizeArtifactRequest{
|
|
||||||
RunID: run,
|
|
||||||
JobRunID: job,
|
|
||||||
Name: name,
|
|
||||||
Size: pushed.Size,
|
|
||||||
Hash: pushed.SHA256Sum,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("FinalizeArtifact: %v", err)
|
|
||||||
}
|
|
||||||
if !finalize.Ok {
|
|
||||||
t.Fatalf("FinalizeArtifact not ok: %+v", finalize)
|
|
||||||
}
|
|
||||||
|
|
||||||
signed := getSignedArtifactURL(t, ctx, client, run, job, name)
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
pulled, err := PullBlob(ctx, nil, &buf, signed.SignedUrl)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PullBlob: %v", err)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(buf.Bytes(), content) {
|
|
||||||
t.Errorf("round trip mismatch: got %q want %q", buf.String(), content)
|
|
||||||
}
|
|
||||||
if pulled.SHA256Sum != pushed.SHA256Sum {
|
|
||||||
t.Errorf("hash mismatch: got %q want %q", pulled.SHA256Sum, pushed.SHA256Sum)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSignedArtifactURL retries briefly because a freshly finalized artifact may
|
|
||||||
// not be signed yet.
|
|
||||||
func getSignedArtifactURL(t *testing.T, ctx context.Context, client *Client, run, job, name string) *GetSignedArtifactURLResponse {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt < 5; attempt++ {
|
|
||||||
signed, err := client.GetSignedArtifactURL(ctx, GetSignedArtifactURLRequest{RunID: run, JobRunID: job, Name: name})
|
|
||||||
if err == nil && signed.SignedUrl != "" {
|
|
||||||
return signed
|
|
||||||
}
|
|
||||||
lastErr = err
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
t.Fatalf("GetSignedArtifactURL: %v", ctx.Err())
|
|
||||||
case <-time.After(time.Duration(attempt+1) * time.Second):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatalf("GetSignedArtifactURL: %v", lastErr)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
package artifact
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JobInfo returns the backend workflow run and job identifiers encoded in the
|
|
||||||
// ACTIONS_RUNTIME_TOKEN environment variable. The token is only available from
|
|
||||||
// the context of an action.
|
|
||||||
func JobInfo() (run, job string, err error) {
|
|
||||||
return jobInfo(os.Getenv)
|
|
||||||
}
|
|
||||||
|
|
||||||
func jobInfo(env func(string) string) (string, string, error) {
|
|
||||||
var empty string
|
|
||||||
rt := env("ACTIONS_RUNTIME_TOKEN")
|
|
||||||
if rt == "" {
|
|
||||||
return empty, empty, fmt.Errorf("env ACTIONS_RUNTIME_TOKEN is only available from the context of an action")
|
|
||||||
}
|
|
||||||
jt, err := newJwtDecode(rt)
|
|
||||||
if err != nil {
|
|
||||||
return empty, empty, err
|
|
||||||
}
|
|
||||||
scp, ok := jt.Payload["scp"].(string)
|
|
||||||
if !ok {
|
|
||||||
return empty, empty, fmt.Errorf("no scp in ACTIONS_RUNTIME_TOKEN")
|
|
||||||
}
|
|
||||||
for _, scopes := range strings.Split(scp, " ") {
|
|
||||||
parts := strings.Split(scopes, ":")
|
|
||||||
if parts[0] != "Actions.Results" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(parts) != 3 {
|
|
||||||
return empty, empty, fmt.Errorf("invalid scp in ACTIONS_RUNTIME_TOKEN")
|
|
||||||
}
|
|
||||||
return parts[1], parts[2], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return empty, empty, fmt.Errorf("no backend ids found")
|
|
||||||
}
|
|
||||||
|
|
||||||
type jwt struct {
|
|
||||||
Header map[string]any
|
|
||||||
Payload map[string]any
|
|
||||||
Signature string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newJwtDecode(token string) (*jwt, error) {
|
|
||||||
parts := strings.Split(token, ".")
|
|
||||||
if len(parts) != 3 {
|
|
||||||
return nil, fmt.Errorf("invalid JWT: expected 3 segments")
|
|
||||||
}
|
|
||||||
|
|
||||||
decode := func(seg string) ([]byte, error) {
|
|
||||||
if l := len(seg) % 4; l != 0 {
|
|
||||||
seg += strings.Repeat("=", 4-l)
|
|
||||||
}
|
|
||||||
return base64.URLEncoding.DecodeString(seg)
|
|
||||||
}
|
|
||||||
|
|
||||||
headerBytes, err := decode(parts[0])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT header: %v", err)
|
|
||||||
}
|
|
||||||
var header map[string]any
|
|
||||||
if err := json.Unmarshal(headerBytes, &header); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT header json: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
payloadBytes, err := decode(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT payload: %v", err)
|
|
||||||
}
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid JWT payload json: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &jwt{Header: header, Payload: payload, Signature: parts[2]}, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
package artifact
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func makeToken(t *testing.T, payload map[string]any) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
|
|
||||||
body, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return header + "." + base64.RawURLEncoding.EncodeToString(body) + ".signature"
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJobInfo(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
token string
|
|
||||||
run string
|
|
||||||
job string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "single scope",
|
|
||||||
token: makeToken(t, map[string]any{"scp": "Actions.Results:123:456"}),
|
|
||||||
run: "123",
|
|
||||||
job: "456",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "scope among others",
|
|
||||||
token: makeToken(t, map[string]any{"scp": "Actions.Cache:x:y Actions.Results:123:456"}),
|
|
||||||
run: "123",
|
|
||||||
job: "456",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing token",
|
|
||||||
token: "",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing scp",
|
|
||||||
token: makeToken(t, map[string]any{"foo": "bar"}),
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no results scope",
|
|
||||||
token: makeToken(t, map[string]any{"scp": "Actions.Cache:x:y"}),
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "malformed scope",
|
|
||||||
token: makeToken(t, map[string]any{"scp": "Actions.Results:123"}),
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "not a jwt",
|
|
||||||
token: "not-a-jwt",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := func(key string) string {
|
|
||||||
if key == "ACTIONS_RUNTIME_TOKEN" {
|
|
||||||
return tc.token
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
run, job, err := jobInfo(env)
|
|
||||||
if tc.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error, got run=%q job=%q", run, job)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if run != tc.run || job != tc.job {
|
|
||||||
t.Errorf("expected run=%q job=%q, got run=%q job=%q", tc.run, tc.job, run, job)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
package artifact
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"code.geekeey.de/actions/sdk"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RepositoryArtifact is an artifact as returned by the repository-scoped
|
|
||||||
// actions API (compatible with the GitHub artifact REST schema).
|
|
||||||
type RepositoryArtifact struct {
|
|
||||||
Id int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
SizeInBytes int64 `json:"size_in_bytes"`
|
|
||||||
ArchiveDownloadURL string `json:"archive_download_url"`
|
|
||||||
Expired bool `json:"expired"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListRunArtifactsResponse struct {
|
|
||||||
TotalCount int64 `json:"total_count"`
|
|
||||||
Artifacts []RepositoryArtifact `json:"artifacts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RepositoryClient talks to the repository-scoped actions artifacts API. Unlike
|
|
||||||
// Client, which is bound to the current run by the runtime token, this client
|
|
||||||
// can address any run in any repository the token has access to.
|
|
||||||
type RepositoryClient struct {
|
|
||||||
http *sdk.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRepositoryClient returns a client for the actions API at base using the
|
|
||||||
// given token. base is typically the value of GITHUB_API_URL.
|
|
||||||
func NewRepositoryClient(base, token string) *RepositoryClient {
|
|
||||||
return &RepositoryClient{http: sdk.NewClient(base, token)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *RepositoryClient) ListRunArtifacts(ctx context.Context, owner, repo string, runID int64) (*ListRunArtifactsResponse, error) {
|
|
||||||
endpoint := fmt.Sprintf("/repos/%s/%s/actions/runs/%d/artifacts", owner, repo, runID)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("accept", "application/json")
|
|
||||||
|
|
||||||
res, err := c.http.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if !sdk.HasContentType(res, "application/json") {
|
|
||||||
return nil, fmt.Errorf("content-type: %q should be %q",
|
|
||||||
res.Header.Get("content-type"), "application/json")
|
|
||||||
}
|
|
||||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(res.Body, 32*1024))
|
|
||||||
return nil, fmt.Errorf("list artifacts of %s/%s run %d failed: %s: %s",
|
|
||||||
owner, repo, runID, res.Status, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var out ListRunArtifactsResponse
|
|
||||||
if err := json.NewDecoder(io.LimitReader(res.Body, 1024*1024)).Decode(&out); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DownloadArtifact downloads the zip archive of an artifact. The server
|
|
||||||
// redirects to the signed blob URL, which the underlying client follows.
|
|
||||||
func (c *RepositoryClient) DownloadArtifact(ctx context.Context, owner, repo string, artifactID int64) (io.ReadCloser, error) {
|
|
||||||
endpoint := fmt.Sprintf("/repos/%s/%s/actions/artifacts/%d/zip", owner, repo, artifactID)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := c.http.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(res.Body, 32*1024))
|
|
||||||
res.Body.Close()
|
|
||||||
return nil, fmt.Errorf("download artifact %d failed: %s: %s", artifactID, res.Status, string(body))
|
|
||||||
}
|
|
||||||
return res.Body, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
package artifact
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRepositoryClient_ListRunArtifacts(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var gotPath, gotAuth string
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gotPath = r.URL.Path
|
|
||||||
gotAuth = r.Header.Get("Authorization")
|
|
||||||
w.Header().Set("content-type", "application/json")
|
|
||||||
_, _ = io.WriteString(w, `{"total_count":1,"artifacts":[{"id":7,"name":"foo","size_in_bytes":10,"expired":false}]}`)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
c := NewRepositoryClient(server.URL, "secret")
|
|
||||||
res, err := c.ListRunArtifacts(context.Background(), "owner", "repo", 42)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got, want := gotPath, "/repos/owner/repo/actions/runs/42/artifacts"; got != want {
|
|
||||||
t.Errorf("expected path %q to be %q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := gotAuth, "Bearer secret"; got != want {
|
|
||||||
t.Errorf("expected Authorization %q to be %q", got, want)
|
|
||||||
}
|
|
||||||
if len(res.Artifacts) != 1 || res.Artifacts[0].Id != 7 || res.Artifacts[0].Name != "foo" {
|
|
||||||
t.Errorf("unexpected response: %+v", res)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRepositoryClient_ListRunArtifacts_Error(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("content-type", "application/json")
|
|
||||||
http.Error(w, `{"message":"not found"}`, http.StatusNotFound)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
c := NewRepositoryClient(server.URL, "secret")
|
|
||||||
if _, err := c.ListRunArtifacts(context.Background(), "owner", "repo", 42); err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRepositoryClient_DownloadArtifact(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const zip = "zip-bytes"
|
|
||||||
var gotPath string
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gotPath = r.URL.Path
|
|
||||||
_, _ = io.WriteString(w, zip)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
c := NewRepositoryClient(server.URL, "secret")
|
|
||||||
body, err := c.DownloadArtifact(context.Background(), "owner", "repo", 7)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer body.Close()
|
|
||||||
|
|
||||||
if got, want := gotPath, "/repos/owner/repo/actions/artifacts/7/zip"; got != want {
|
|
||||||
t.Errorf("expected path %q to be %q", got, want)
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if string(data) != zip {
|
|
||||||
t.Errorf("expected %q, got %q", zip, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRepositoryClient_DownloadArtifact_Redirect(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
blob := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, _ = io.WriteString(w, "redirected-zip")
|
|
||||||
}))
|
|
||||||
defer blob.Close()
|
|
||||||
|
|
||||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Redirect(w, r, blob.URL+"/blob", http.StatusFound)
|
|
||||||
}))
|
|
||||||
defer api.Close()
|
|
||||||
|
|
||||||
c := NewRepositoryClient(api.URL, "secret")
|
|
||||||
body, err := c.DownloadArtifact(context.Background(), "owner", "repo", 7)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer body.Close()
|
|
||||||
|
|
||||||
data, err := io.ReadAll(body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if string(data) != "redirected-zip" {
|
|
||||||
t.Errorf("expected %q, got %q", "redirected-zip", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRepositoryClient_DownloadArtifact_Error(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Error(w, "nope", http.StatusNotFound)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
c := NewRepositoryClient(server.URL, "secret")
|
|
||||||
_, err := c.DownloadArtifact(context.Background(), "owner", "repo", 7)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "download artifact") {
|
|
||||||
t.Fatalf("expected download error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
package artifact
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/xml"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PullResult struct {
|
|
||||||
SHA256Sum string
|
|
||||||
Size int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type PushResult struct {
|
|
||||||
SHA256Sum string
|
|
||||||
Size int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func httpClientOrDefault(c *http.Client) *http.Client {
|
|
||||||
if c == nil {
|
|
||||||
return http.DefaultClient
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// PushBlob uploads the data read from r to the Azure block blob at blobURL as a
|
|
||||||
// series of blocks which are committed in order. The blobURL is expected to be
|
|
||||||
// a pre-signed URL. It returns the SHA-256 sum and size of the uploaded data.
|
|
||||||
func PushBlob(ctx context.Context, client *http.Client, r io.Reader, blobURL string, chunkSize int64, parallelism int) (*PushResult, error) {
|
|
||||||
client = httpClientOrDefault(client)
|
|
||||||
if chunkSize <= 0 {
|
|
||||||
chunkSize = 4 * 1024 * 1024 // 4MB default
|
|
||||||
}
|
|
||||||
if parallelism <= 0 {
|
|
||||||
parallelism = 4
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
type chunk struct {
|
|
||||||
index int
|
|
||||||
data []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
wrkinp := make(chan chunk)
|
|
||||||
|
|
||||||
var firstErr error
|
|
||||||
var errOnce sync.Once
|
|
||||||
fail := func(err error) {
|
|
||||||
errOnce.Do(func() {
|
|
||||||
firstErr = err
|
|
||||||
cancel()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var blockMu sync.Mutex
|
|
||||||
blocks := map[int]string{}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i := 0; i < parallelism; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for c := range wrkinp {
|
|
||||||
id := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%06d", c.index))
|
|
||||||
address := fmt.Sprintf("%s&comp=block&blockid=%s", blobURL, id)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, address, bytes.NewReader(c.data))
|
|
||||||
if err != nil {
|
|
||||||
fail(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.ContentLength = int64(len(c.data))
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
fail(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
|
||||||
fail(fmt.Errorf("chunk %d upload failed: %s body: %s", c.index, resp.Status, string(body)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
blockMu.Lock()
|
|
||||||
blocks[c.index] = id
|
|
||||||
blockMu.Unlock()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
index := 0
|
|
||||||
hash := sha256.New()
|
|
||||||
var size int64
|
|
||||||
for {
|
|
||||||
buf := make([]byte, chunkSize)
|
|
||||||
n, err := io.ReadFull(r, buf)
|
|
||||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
|
||||||
if n > 0 {
|
|
||||||
size += int64(n)
|
|
||||||
hash.Write(buf[:n])
|
|
||||||
select {
|
|
||||||
case wrkinp <- chunk{index: index, data: buf[:n]}:
|
|
||||||
index++
|
|
||||||
case <-ctx.Done():
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fail(err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
size += int64(n)
|
|
||||||
hash.Write(buf)
|
|
||||||
select {
|
|
||||||
case wrkinp <- chunk{index: index, data: buf}:
|
|
||||||
index++
|
|
||||||
case <-ctx.Done():
|
|
||||||
close(wrkinp)
|
|
||||||
wg.Wait()
|
|
||||||
return nil, firstErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
close(wrkinp)
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if firstErr != nil {
|
|
||||||
return nil, firstErr
|
|
||||||
}
|
|
||||||
if len(blocks) == 0 {
|
|
||||||
return nil, fmt.Errorf("no blocks uploaded")
|
|
||||||
}
|
|
||||||
|
|
||||||
type Latest struct {
|
|
||||||
Value string `xml:",chardata"`
|
|
||||||
}
|
|
||||||
type BlockList struct {
|
|
||||||
Latest []Latest `xml:"Latest"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// The order of the block list determines the order of the resulting blob, so
|
|
||||||
// the blocks must be committed by ascending index.
|
|
||||||
indexes := make([]int, 0, len(blocks))
|
|
||||||
for i := range blocks {
|
|
||||||
indexes = append(indexes, i)
|
|
||||||
}
|
|
||||||
sort.Ints(indexes)
|
|
||||||
|
|
||||||
blockList := &BlockList{}
|
|
||||||
for _, i := range indexes {
|
|
||||||
blockList.Latest = append(blockList.Latest, Latest{Value: blocks[i]})
|
|
||||||
}
|
|
||||||
blockListData, err := xml.Marshal(blockList)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, fmt.Sprintf("%s&comp=blocklist", blobURL), bytes.NewReader(blockListData))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/xml")
|
|
||||||
req.ContentLength = int64(len(blockListData))
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("blocklist commit failed: %s body: %s", resp.Status, string(body))
|
|
||||||
}
|
|
||||||
return &PushResult{SHA256Sum: hex.EncodeToString(hash.Sum(nil)), Size: size}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PullBlob downloads the blob at blobURL, writing its contents to w. The
|
|
||||||
// blobURL is expected to be a pre-signed URL. It returns the SHA-256 sum and
|
|
||||||
// size of the downloaded data.
|
|
||||||
func PullBlob(ctx context.Context, client *http.Client, w io.Writer, blobURL string) (*PullResult, error) {
|
|
||||||
client = httpClientOrDefault(client)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, blobURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024))
|
|
||||||
return nil, fmt.Errorf("pull blob failed: %s body: %s", resp.Status, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := sha256.New()
|
|
||||||
size, err := io.Copy(io.MultiWriter(w, hash), resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &PullResult{SHA256Sum: hex.EncodeToString(hash.Sum(nil)), Size: size}, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
package artifact
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/xml"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func sha256Hex(data string) string {
|
|
||||||
sum := sha256.Sum256([]byte(data))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPushBlob(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const data = "helloworld" // 10 bytes, chunked into 4+4+2
|
|
||||||
|
|
||||||
var mu sync.Mutex
|
|
||||||
received := map[string][]byte{}
|
|
||||||
var blockList []string
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.URL.Query().Get("comp") {
|
|
||||||
case "block":
|
|
||||||
id := r.URL.Query().Get("blockid")
|
|
||||||
// Delay the first block so it completes after later ones; the
|
|
||||||
// committed block list must still be in ascending index order.
|
|
||||||
raw, _ := base64.StdEncoding.DecodeString(id)
|
|
||||||
if string(raw) == "000000" {
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
mu.Lock()
|
|
||||||
received[id] = body
|
|
||||||
mu.Unlock()
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
case "blocklist":
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
var bl struct {
|
|
||||||
Latest []struct {
|
|
||||||
Value string `xml:",chardata"`
|
|
||||||
} `xml:"Latest"`
|
|
||||||
}
|
|
||||||
if err := xml.Unmarshal(body, &bl); err != nil {
|
|
||||||
t.Errorf("invalid block list xml: %v", err)
|
|
||||||
}
|
|
||||||
for _, l := range bl.Latest {
|
|
||||||
blockList = append(blockList, l.Value)
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
default:
|
|
||||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
res, err := PushBlob(context.Background(), nil, strings.NewReader(data), server.URL+"?sv=test", 4, 3)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if res.Size != int64(len(data)) {
|
|
||||||
t.Errorf("expected size %d, got %d", len(data), res.Size)
|
|
||||||
}
|
|
||||||
if res.SHA256Sum != sha256Hex(data) {
|
|
||||||
t.Errorf("expected sha %q, got %q", sha256Hex(data), res.SHA256Sum)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(received) != 3 {
|
|
||||||
t.Fatalf("expected 3 blocks, got %d", len(received))
|
|
||||||
}
|
|
||||||
|
|
||||||
wantChunks := []string{"hell", "owor", "ld"}
|
|
||||||
for i, want := range wantChunks {
|
|
||||||
id := base64.StdEncoding.EncodeToString([]byte(chunkID(i)))
|
|
||||||
if got := string(received[id]); got != want {
|
|
||||||
t.Errorf("block %d: expected %q, got %q", i, want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(blockList) != 3 {
|
|
||||||
t.Fatalf("expected 3 block list entries, got %d", len(blockList))
|
|
||||||
}
|
|
||||||
for i, id := range blockList {
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(id)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("invalid block id %q: %v", id, err)
|
|
||||||
}
|
|
||||||
if got := string(raw); got != chunkID(i) {
|
|
||||||
t.Errorf("block list position %d: expected %q, got %q", i, chunkID(i), got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPushBlob_Empty(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
if _, err := PushBlob(context.Background(), nil, strings.NewReader(""), server.URL+"?sv=test", 4, 3); err == nil {
|
|
||||||
t.Fatal("expected error for empty input")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPushBlob_Error(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Error(w, "boom", http.StatusInternalServerError)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
_, err := PushBlob(context.Background(), nil, strings.NewReader("helloworld"), server.URL+"?sv=test", 4, 3)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "upload failed") {
|
|
||||||
t.Fatalf("expected upload error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPullBlob(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const data = "helloworld"
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, _ = io.WriteString(w, data)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
res, err := PullBlob(context.Background(), nil, &buf, server.URL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := buf.String(); got != data {
|
|
||||||
t.Errorf("expected %q, got %q", data, got)
|
|
||||||
}
|
|
||||||
if res.Size != int64(len(data)) {
|
|
||||||
t.Errorf("expected size %d, got %d", len(data), res.Size)
|
|
||||||
}
|
|
||||||
if res.SHA256Sum != sha256Hex(data) {
|
|
||||||
t.Errorf("expected sha %q, got %q", sha256Hex(data), res.SHA256Sum)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPullBlob_Error(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Error(w, "nope", http.StatusNotFound)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
if _, err := PullBlob(context.Background(), nil, io.Discard, server.URL); err == nil {
|
|
||||||
t.Fatal("expected error for non-2xx response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func chunkID(i int) string {
|
|
||||||
return fmt.Sprintf("%06d", i)
|
|
||||||
}
|
|
||||||
9
cache.go
Normal file
9
cache.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
package sdk
|
||||||
|
|
||||||
|
import "git.geekeey.de/actions/sdk/cache"
|
||||||
|
|
||||||
|
func (c *Action) Cache() *cache.Client {
|
||||||
|
token := c.env("ACTIONS_RUNTIME_TOKEN")
|
||||||
|
url := c.env("ACTIONS_CACHE_URL")
|
||||||
|
return cache.New(token, url)
|
||||||
|
}
|
||||||
56
cache/blob.go
vendored
Normal file
56
cache/blob.go
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Blob interface {
|
||||||
|
io.ReaderAt
|
||||||
|
io.Closer
|
||||||
|
Size() int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type byteBlob struct {
|
||||||
|
buf *bytes.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewByteBlob(b []byte) Blob {
|
||||||
|
return &byteBlob{buf: bytes.NewReader(b)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (blob *byteBlob) ReadAt(p []byte, off int64) (n int, err error) {
|
||||||
|
return blob.buf.ReadAt(p, off)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (blob *byteBlob) Size() int64 {
|
||||||
|
return blob.buf.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (blob *byteBlob) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileBlob struct {
|
||||||
|
buf *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileBlob(f *os.File) Blob {
|
||||||
|
return &fileBlob{buf: f}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (blob *fileBlob) ReadAt(p []byte, off int64) (n int, err error) {
|
||||||
|
return blob.buf.ReadAt(p, off)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (blob *fileBlob) Size() int64 {
|
||||||
|
if i, err := blob.buf.Stat(); err != nil {
|
||||||
|
return i.Size()
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (blob *fileBlob) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
329
cache/cache.go
vendored
Normal file
329
cache/cache.go
vendored
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
var UploadConcurrency = 4
|
||||||
|
var UploadChunkSize = 32 * 1024 * 1024
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
base string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type auth struct {
|
||||||
|
transport http.RoundTripper
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *auth) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", t.token))
|
||||||
|
return t.transport.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(token, url string) *Client {
|
||||||
|
t := &auth{transport: &retry{transport: &http.Transport{}}, token: token}
|
||||||
|
return &Client{
|
||||||
|
base: url,
|
||||||
|
http: &http.Client{Transport: t},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) url(p string) string {
|
||||||
|
return path.Join(c.base, "_apis/artifactcache", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) version(k string) string {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte("|go-actionscache-1.0"))
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiError struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
TypeName string `json:"typeName"`
|
||||||
|
TypeKey string `json:"typeKey"`
|
||||||
|
ErrorCode int `json:"errorCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ApiError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ApiError) Is(err error) bool {
|
||||||
|
if err == os.ErrExist {
|
||||||
|
if strings.Contains(e.TypeKey, "AlreadyExists") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkApiError(res *http.Response) error {
|
||||||
|
if res.StatusCode >= 200 && res.StatusCode < 300 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(io.LimitReader(res.Body, 32*1024))
|
||||||
|
|
||||||
|
var details ApiError
|
||||||
|
if err := dec.Decode(&details); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if details.Message != "" {
|
||||||
|
return details
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("unknown error %s", res.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Load(ctx context.Context, keys ...string) (*Entry, error) {
|
||||||
|
u, err := url.Parse(c.url("cache"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("keys", strings.Join(keys, ","))
|
||||||
|
q.Set("version", c.version(keys[0]))
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Add("Accept", "application/json;api-version=6.0-preview.1")
|
||||||
|
|
||||||
|
res, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
err = checkApiError(res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := json.NewDecoder(io.LimitReader(res.Body, 32*1024))
|
||||||
|
|
||||||
|
var ce Entry
|
||||||
|
if err = dec.Decode(&ce); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ce.http = c.http
|
||||||
|
return &ce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Save(ctx context.Context, key string, b Blob) error {
|
||||||
|
id, err := c.reserve(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = c.upload(ctx, id, b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.commit(ctx, id, b.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReserveCacheReq struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReserveCacheRes struct {
|
||||||
|
CacheID int `json:"cacheID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) reserve(ctx context.Context, key string) (int, error) {
|
||||||
|
payload := ReserveCacheReq{Key: key, Version: c.version(key)}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if err := json.NewEncoder(buf).Encode(payload); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := c.url("caches")
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, buf)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
|
||||||
|
res, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
err = checkApiError(res)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := json.NewDecoder(io.LimitReader(res.Body, 32*1024))
|
||||||
|
|
||||||
|
var cr ReserveCacheRes
|
||||||
|
if err = dec.Decode(&cr); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cr.CacheID == 0 {
|
||||||
|
return 0, fmt.Errorf("invalid response (cache id is 0)")
|
||||||
|
}
|
||||||
|
return cr.CacheID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommitCacheReq struct {
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) commit(ctx context.Context, id int, size int64) error {
|
||||||
|
payload := CommitCacheReq{Size: size}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if err := json.NewEncoder(buf).Encode(payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := c.url(fmt.Sprintf("caches/%d", id))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
|
||||||
|
res, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
err = checkApiError(res)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) upload(ctx context.Context, id int, b Blob) error {
|
||||||
|
var mu sync.Mutex
|
||||||
|
grp, ctx := errgroup.WithContext(ctx)
|
||||||
|
offset := int64(0)
|
||||||
|
for i := 0; i < UploadConcurrency; i++ {
|
||||||
|
grp.Go(func() error {
|
||||||
|
for {
|
||||||
|
mu.Lock()
|
||||||
|
start := offset
|
||||||
|
if start >= b.Size() {
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
end := start + int64(UploadChunkSize)
|
||||||
|
if end > b.Size() {
|
||||||
|
end = b.Size()
|
||||||
|
}
|
||||||
|
offset = end
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if err := c.create(ctx, id, b, start, end-start); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return grp.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) create(ctx context.Context, id int, ra io.ReaderAt, off, n int64) error {
|
||||||
|
url := c.url(fmt.Sprintf("caches/%d", id))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, io.NewSectionReader(ra, off, n))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Add("Content-Type", "application/octet-stream")
|
||||||
|
req.Header.Add("Content-Range", fmt.Sprintf("bytes %d-%d/*", off, off+n-1))
|
||||||
|
|
||||||
|
res, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
err = checkApiError(res)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
Key string `json:"cacheKey"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
URL string `json:"archiveLocation"`
|
||||||
|
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download returns a ReaderAtCloser for pulling the data. Concurrent reads are not allowed
|
||||||
|
func (ce *Entry) Download(ctx context.Context) ReaderAtCloser {
|
||||||
|
return NewReaderAtCloser(func(offset int64) (io.ReadCloser, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", ce.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if offset != 0 {
|
||||||
|
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
|
||||||
|
}
|
||||||
|
client := ce.http
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
|
if res.StatusCode == http.StatusRequestedRangeNotSatisfiable {
|
||||||
|
return nil, fmt.Errorf("invalid status response %v for %s, range: %v", res.Status, ce.URL, req.Header.Get("Range"))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("invalid status response %v for %s", res.Status, ce.URL)
|
||||||
|
}
|
||||||
|
if offset != 0 {
|
||||||
|
cr := res.Header.Get("content-range")
|
||||||
|
if !strings.HasPrefix(cr, fmt.Sprintf("bytes %d-", offset)) {
|
||||||
|
res.Body.Close()
|
||||||
|
return nil, fmt.Errorf("unhandled content range in response: %v", cr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.Body, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ce *Entry) WriteTo(ctx context.Context, w io.Writer) error {
|
||||||
|
rac := ce.Download(ctx)
|
||||||
|
if _, err := io.Copy(w, &rc{ReaderAt: rac}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rac.Close()
|
||||||
|
}
|
||||||
89
cache/reader.go
vendored
Normal file
89
cache/reader.go
vendored
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReaderAtCloser interface {
|
||||||
|
io.ReaderAt
|
||||||
|
io.Closer
|
||||||
|
}
|
||||||
|
|
||||||
|
type readerAtCloser struct {
|
||||||
|
offset int64
|
||||||
|
rc io.ReadCloser
|
||||||
|
ra io.ReaderAt
|
||||||
|
open func(offset int64) (io.ReadCloser, error)
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReaderAtCloser(open func(offset int64) (io.ReadCloser, error)) ReaderAtCloser {
|
||||||
|
return &readerAtCloser{
|
||||||
|
open: open,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hrs *readerAtCloser) ReadAt(p []byte, off int64) (n int, err error) {
|
||||||
|
if hrs.closed {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if hrs.ra != nil {
|
||||||
|
return hrs.ra.ReadAt(p, off)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hrs.rc == nil || off != hrs.offset {
|
||||||
|
if hrs.rc != nil {
|
||||||
|
hrs.rc.Close()
|
||||||
|
hrs.rc = nil
|
||||||
|
}
|
||||||
|
rc, err := hrs.open(off)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
hrs.rc = rc
|
||||||
|
}
|
||||||
|
if ra, ok := hrs.rc.(io.ReaderAt); ok {
|
||||||
|
hrs.ra = ra
|
||||||
|
n, err = ra.ReadAt(p, off)
|
||||||
|
} else {
|
||||||
|
for {
|
||||||
|
var nn int
|
||||||
|
nn, err = hrs.rc.Read(p)
|
||||||
|
n += nn
|
||||||
|
p = p[nn:]
|
||||||
|
if nn == len(p) || err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hrs.offset += int64(n)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hrs *readerAtCloser) Close() error {
|
||||||
|
if hrs.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hrs.closed = true
|
||||||
|
if hrs.rc != nil {
|
||||||
|
return hrs.rc.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type rc struct {
|
||||||
|
io.ReaderAt
|
||||||
|
offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rc) Read(b []byte) (int, error) {
|
||||||
|
n, err := r.ReadAt(b, int64(r.offset))
|
||||||
|
r.offset += n
|
||||||
|
if n > 0 && err == io.EOF {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
42
cache/retry.go
vendored
Normal file
42
cache/retry.go
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type retry struct {
|
||||||
|
transport http.RoundTripper
|
||||||
|
retry int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *retry) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
var body []byte
|
||||||
|
if req.Body != nil {
|
||||||
|
body, _ = io.ReadAll(req.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
for count := 0; count < t.retry; count++ {
|
||||||
|
req.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||||
|
res, err := t.transport.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if t.check(res) {
|
||||||
|
if res.Body != nil {
|
||||||
|
io.Copy(io.Discard, res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("too many retries")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *retry) check(res *http.Response) bool {
|
||||||
|
return res.StatusCode > 399
|
||||||
|
}
|
||||||
115
cache/tar.go
vendored
Normal file
115
cache/tar.go
vendored
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tar takes a source and variable writers and walks 'source' writing each file
|
||||||
|
// found to the tar writer; the purpose for accepting multiple writers is to allow
|
||||||
|
// for multiple outputs (for example a file, or md5 hash)
|
||||||
|
func Tar(src string, writers ...io.Writer) error {
|
||||||
|
if _, err := os.Stat(src); err != nil {
|
||||||
|
return fmt.Errorf("unable to tar files - %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
mw := io.MultiWriter(writers...)
|
||||||
|
|
||||||
|
gzw := gzip.NewWriter(mw)
|
||||||
|
defer gzw.Close()
|
||||||
|
|
||||||
|
tw := tar.NewWriter(gzw)
|
||||||
|
defer tw.Close()
|
||||||
|
|
||||||
|
// walk path
|
||||||
|
return filepath.Walk(src, func(file string, fi os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fi.Mode().IsRegular() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
header, err := tar.FileInfoHeader(fi, fi.Name())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the name to correctly reflect the desired destination when untaring
|
||||||
|
header.Name = strings.TrimPrefix(strings.Replace(file, src, "", -1), string(filepath.Separator))
|
||||||
|
|
||||||
|
if err := tw.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(tw, f); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untar takes a destination path and a reader; a tar reader loops over the tarfile
|
||||||
|
// creating the file structure at 'dst' along the way, and writing any files
|
||||||
|
func Untar(dst string, r io.Reader) error {
|
||||||
|
|
||||||
|
gzr, err := gzip.NewReader(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer gzr.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gzr)
|
||||||
|
|
||||||
|
for {
|
||||||
|
header, err := tr.Next()
|
||||||
|
|
||||||
|
if errors.Is(err, io.EOF) || header == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(dst, header.Name)
|
||||||
|
|
||||||
|
switch header.Typeflag {
|
||||||
|
|
||||||
|
// if its a dir and it doesn't exist create it
|
||||||
|
case tar.TypeDir:
|
||||||
|
if _, err := os.Stat(target); err != nil {
|
||||||
|
if err := os.MkdirAll(target, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if it's a file create it
|
||||||
|
case tar.TypeReg:
|
||||||
|
f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(f, tr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
62
client.go
62
client.go
|
|
@ -1,62 +0,0 @@
|
||||||
package sdk
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"mime"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewClient returns an HTTP client that resolves relative request URLs against
|
|
||||||
// base and sets the Authorization header to "Bearer token" on every request.
|
|
||||||
func NewClient(base, token string) *Client {
|
|
||||||
return &Client{
|
|
||||||
Client: &http.Client{},
|
|
||||||
base: base,
|
|
||||||
token: fmt.Sprintf("Bearer %s", token),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Action) Client() *Client {
|
|
||||||
context := a.Context()
|
|
||||||
return NewClient(context.APIURL, context.Token)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
*http.Client
|
|
||||||
base string
|
|
||||||
token string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
|
||||||
req.Header.Set("Authorization", c.token)
|
|
||||||
if !req.URL.IsAbs() {
|
|
||||||
u, err := url.Parse(fmt.Sprintf("%s%s", c.base, req.URL))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.URL = u
|
|
||||||
}
|
|
||||||
return c.Client.Do(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasContentType reports whether the response content-type matches the given
|
|
||||||
// media type. An empty content-type is treated as application/octet-stream.
|
|
||||||
func HasContentType(r *http.Response, mimetype string) bool {
|
|
||||||
contentType := r.Header.Get("content-type")
|
|
||||||
if contentType == "" {
|
|
||||||
return mimetype == "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, v := range strings.Split(contentType, ",") {
|
|
||||||
t, _, err := mime.ParseMediaType(v)
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if t == mimetype {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue