Compare commits

..
Author SHA1 Message Date
09ef963ca3
wip: needs tests 2024-10-20 14:55:16 +02:00
3d43ff4758
feat: add cache helpers 2024-07-22 21:13:54 +02:00
db684e73f8
feat: add initial helper methods 2024-07-21 12:15:55 +02:00
30 changed files with 739 additions and 2811 deletions

View file

@ -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 ./...

View file

@ -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"

View file

@ -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)
} }

View file

@ -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))
}
}

View file

@ -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)
}
})
}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
}

View file

@ -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