| package actionscache |
| |
| import ( |
| "bytes" |
| "context" |
| "crypto/sha256" |
| "encoding/hex" |
| "encoding/json" |
| "fmt" |
| "io" |
| "io/ioutil" |
| "net/http" |
| "net/url" |
| "os" |
| "os/exec" |
| "strconv" |
| "strings" |
| "sync" |
| "time" |
| |
| "github.com/dimchansky/utfbom" |
| "github.com/golang-jwt/jwt/v5" |
| "github.com/pkg/errors" |
| "golang.org/x/sync/errgroup" |
| ) |
| |
| var UploadConcurrency = 4 |
| var UploadChunkSize = 32 * 1024 * 1024 |
| var noValidateToken bool |
| |
| var Log = func(string, ...interface{}) {} |
| |
| type Blob interface { |
| io.ReaderAt |
| io.Closer |
| Size() int64 |
| } |
| |
| type bufferBlob struct { |
| io.ReaderAt |
| size int64 |
| } |
| |
| func (b *bufferBlob) Size() int64 { |
| return b.size |
| } |
| |
| func (b *bufferBlob) Close() error { |
| return nil |
| } |
| |
| func NewBlob(dt []byte) Blob { |
| return &bufferBlob{ |
| ReaderAt: bytes.NewReader(dt), |
| size: int64(len(dt)), |
| } |
| } |
| |
| func TryEnv(opt Opt) (*Cache, error) { |
| tokenEnc, ok := os.LookupEnv("GHCACHE_TOKEN_ENC") |
| if ok { |
| url, token, err := decryptToken(tokenEnc, os.Getenv("GHCACHE_TOKEN_PW")) |
| if err != nil { |
| return nil, err |
| } |
| return New(token, url, opt) |
| } |
| |
| token, ok := os.LookupEnv("ACTIONS_RUNTIME_TOKEN") |
| if !ok { |
| return nil, nil |
| } |
| |
| // ACTIONS_CACHE_URL=https://artifactcache.actions.githubusercontent.com/xxx/ |
| cacheURL, ok := os.LookupEnv("ACTIONS_CACHE_URL") |
| if !ok { |
| return nil, nil |
| } |
| |
| return New(token, cacheURL, opt) |
| } |
| |
| type Opt struct { |
| Client *http.Client |
| Timeout time.Duration |
| BackoffPool *BackoffPool |
| } |
| |
| func New(token, url string, opt Opt) (*Cache, error) { |
| tk, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{}) |
| if err != nil { |
| return nil, errors.WithStack(err) |
| } |
| claims, ok := tk.Claims.(jwt.MapClaims) |
| if !ok { |
| return nil, errors.Errorf("invalid token without claims map") |
| } |
| ac, ok := claims["ac"] |
| if !ok { |
| return nil, errors.Errorf("invalid token without access controls") |
| } |
| acs, ok := ac.(string) |
| if !ok { |
| return nil, errors.Errorf("invalid token with access controls type %T", ac) |
| } |
| |
| exp, ok := claims["exp"] |
| if !ok { |
| return nil, errors.Errorf("invalid token without expiration time") |
| } |
| expf, ok := exp.(float64) |
| if !ok { |
| return nil, errors.Errorf("invalid token with expiration time type %T", acs) |
| } |
| expt := time.Unix(int64(expf), 0) |
| |
| if !noValidateToken && time.Now().After(expt) { |
| return nil, errors.Errorf("cache token expired at %v", expt) |
| } |
| |
| nbf, ok := claims["nbf"] |
| if !ok { |
| return nil, errors.Errorf("invalid token without expiration time") |
| } |
| nbff, ok := nbf.(float64) |
| if !ok { |
| return nil, errors.Errorf("invalid token with expiration time type %T", nbf) |
| } |
| nbft := time.Unix(int64(nbff), 0) |
| |
| if !noValidateToken && time.Now().Before(nbft) { |
| return nil, errors.Errorf("invalid token with future issue time time %v", nbft) |
| } |
| |
| scopes := []Scope{} |
| if err := json.Unmarshal([]byte(acs), &scopes); err != nil { |
| return nil, errors.Wrap(err, "failed to parse token access controls") |
| } |
| Log("parsed token: scopes: %+v, issued: %v, expires: %v", scopes, nbft, expt) |
| |
| opt = optsWithDefaults(opt) |
| |
| return &Cache{ |
| opt: opt, |
| scopes: scopes, |
| URL: url, |
| Token: tk, |
| IssuedAt: nbft, |
| ExpiresAt: expt, |
| }, nil |
| } |
| |
| func optsWithDefaults(opt Opt) Opt { |
| if opt.Client == nil { |
| opt.Client = http.DefaultClient |
| } |
| if opt.Timeout == 0 { |
| opt.Timeout = 5 * time.Minute |
| } |
| if opt.BackoffPool == nil { |
| opt.BackoffPool = defaultBackoffPool |
| } |
| return opt |
| } |
| |
| type Scope struct { |
| Scope string |
| Permission Permission |
| } |
| |
| type Permission int |
| |
| const ( |
| PermissionRead = 1 << iota |
| PermissionWrite |
| ) |
| |
| func (p Permission) String() string { |
| out := make([]string, 0, 2) |
| if p&PermissionRead != 0 { |
| out = append(out, "Read") |
| } |
| if p&PermissionWrite != 0 { |
| out = append(out, "Write") |
| } |
| if p > PermissionRead|PermissionWrite { |
| return strconv.Itoa(int(p)) |
| } |
| return strings.Join(out, "|") |
| } |
| |
| type Cache struct { |
| opt Opt |
| scopes []Scope |
| URL string |
| Token *jwt.Token |
| IssuedAt time.Time |
| ExpiresAt time.Time |
| } |
| |
| func (c *Cache) Scopes() []Scope { |
| return c.scopes |
| } |
| |
| func (c *Cache) 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", version(keys[0])) |
| u.RawQuery = q.Encode() |
| |
| req := c.newRequest("GET", u.String(), nil) |
| Log("load cache %s", u.String()) |
| resp, err := c.doWithRetries(ctx, req) |
| if err != nil { |
| return nil, errors.WithStack(err) |
| } |
| var ce Entry |
| dt, err := ioutil.ReadAll(io.LimitReader(resp.Body, 32*1024)) |
| if err != nil { |
| return nil, errors.WithStack(err) |
| } |
| if len(dt) == 0 { |
| return nil, nil |
| } |
| if err := json.Unmarshal(dt, &ce); err != nil { |
| return nil, errors.WithStack(err) |
| } |
| ce.client = c.opt.Client |
| if ce.Key == "" { |
| return nil, nil |
| } |
| return &ce, nil |
| } |
| |
| func (c *Cache) reserve(ctx context.Context, key string) (int, error) { |
| dt, err := json.Marshal(ReserveCacheReq{Key: key, Version: version(key)}) |
| if err != nil { |
| return 0, errors.WithStack(err) |
| } |
| req := c.newRequest("POST", c.url("caches"), func() io.Reader { |
| return bytes.NewReader(dt) |
| }) |
| |
| req.headers["Content-Type"] = "application/json" |
| Log("save cache req %s body=%s", req.url, dt) |
| resp, err := c.doWithRetries(ctx, req) |
| if err != nil { |
| return 0, errors.WithStack(err) |
| } |
| |
| dt, err = ioutil.ReadAll(io.LimitReader(resp.Body, 32*1024)) |
| if err != nil { |
| return 0, errors.WithStack(err) |
| } |
| var cr ReserveCacheResp |
| if err := json.Unmarshal(dt, &cr); err != nil { |
| return 0, errors.Wrapf(err, "failed to unmarshal %s", dt) |
| } |
| if cr.CacheID == 0 { |
| return 0, errors.Errorf("invalid response %s", dt) |
| } |
| Log("save cache resp: %s", dt) |
| return cr.CacheID, nil |
| } |
| |
| func (c *Cache) commit(ctx context.Context, id int, size int64) error { |
| dt, err := json.Marshal(CommitCacheReq{Size: size}) |
| if err != nil { |
| return errors.WithStack(err) |
| } |
| req := c.newRequest("POST", c.url(fmt.Sprintf("caches/%d", id)), func() io.Reader { |
| return bytes.NewReader(dt) |
| }) |
| req.headers["Content-Type"] = "application/json" |
| Log("commit cache %s, size %d", req.url, size) |
| resp, err := c.doWithRetries(ctx, req) |
| if err != nil { |
| return errors.Wrapf(err, "error committing cache %d", id) |
| } |
| dt, err = ioutil.ReadAll(io.LimitReader(resp.Body, 32*1024)) |
| if err != nil { |
| return err |
| } |
| if len(dt) != 0 { |
| Log("commit response: %s", dt) |
| } |
| return resp.Body.Close() |
| } |
| |
| func (c *Cache) upload(ctx context.Context, id int, b Blob) error { |
| var mu sync.Mutex |
| eg, ctx := errgroup.WithContext(ctx) |
| offset := int64(0) |
| for i := 0; i < UploadConcurrency; i++ { |
| eg.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.uploadChunk(ctx, id, b, start, end-start); err != nil { |
| return err |
| } |
| } |
| }) |
| } |
| return eg.Wait() |
| } |
| |
| func (c *Cache) Save(ctx context.Context, key string, b Blob) error { |
| id, err := c.reserve(ctx, key) |
| if err != nil { |
| return err |
| } |
| |
| if err := c.upload(ctx, id, b); err != nil { |
| return err |
| } |
| |
| return c.commit(ctx, id, b.Size()) |
| } |
| |
| // SaveMutable stores a blob over a possibly existing key. Previous value is passed to callback |
| // that needs to return new blob. Callback may be called multiple times if two saves happen during |
| // same time window. In case of a crash a key may remain locked, preventing previous changes. Timeout |
| // can be set to force changes in this case without guaranteeing that previous value was up to date. |
| func (c *Cache) SaveMutable(ctx context.Context, key string, forceTimeout time.Duration, f func(old *Entry) (Blob, error)) error { |
| var blocked time.Duration |
| loop0: |
| for { |
| ce, err := c.Load(ctx, key+"#") |
| if err != nil { |
| return err |
| } |
| b, err := f(ce) |
| if err != nil { |
| return err |
| } |
| defer b.Close() |
| if ce != nil { |
| // check if index changed while loading |
| ce2, err := c.Load(ctx, key+"#") |
| if err != nil { |
| return err |
| } |
| if ce2 == nil || ce2.Key != ce.Key { |
| continue |
| } |
| } |
| idx := 0 |
| if ce != nil { |
| idxs := strings.TrimPrefix(ce.Key, key+"#") |
| if idxs == "" { |
| return errors.Errorf("corrupt empty index for %s", key) |
| } |
| idx, err = strconv.Atoi(idxs) |
| if err != nil { |
| return errors.Wrapf(err, "failed to parse %s index", key) |
| } |
| } |
| var cacheID int |
| for { |
| idx++ |
| cacheID, err = c.reserve(ctx, fmt.Sprintf("%s#%d", key, idx)) |
| if err != nil { |
| if errors.Is(err, os.ErrExist) { |
| if blocked <= forceTimeout { |
| blocked += 2 * time.Second |
| select { |
| case <-ctx.Done(): |
| return ctx.Err() |
| case <-time.After(2 * time.Second): |
| } |
| continue loop0 |
| } |
| continue // index has been blocked a long time, maybe crashed, skip to next number |
| } |
| return err |
| } |
| break |
| } |
| if err := c.upload(ctx, cacheID, b); err != nil { |
| return nil |
| } |
| return c.commit(ctx, cacheID, b.Size()) |
| } |
| } |
| |
| func (c *Cache) uploadChunk(ctx context.Context, id int, ra io.ReaderAt, off, n int64) error { |
| req := c.newRequest("PATCH", c.url(fmt.Sprintf("caches/%d", id)), func() io.Reader { |
| return io.NewSectionReader(ra, off, n) |
| }) |
| req.headers["Content-Type"] = "application/octet-stream" |
| req.headers["Content-Range"] = fmt.Sprintf("bytes %d-%d/*", off, off+n-1) |
| |
| Log("upload cache chunk %s, range %d-%d", req.url, off, off+n-1) |
| resp, err := c.doWithRetries(ctx, req) |
| if err != nil { |
| return errors.WithStack(err) |
| } |
| dt, err := ioutil.ReadAll(io.LimitReader(resp.Body, 32*1024)) |
| if err != nil { |
| return errors.WithStack(err) |
| } |
| if len(dt) != 0 { |
| Log("upload chunk resp: %s", dt) |
| } |
| return resp.Body.Close() |
| } |
| |
| func (c *Cache) newRequest(method, url string, body func() io.Reader) *request { |
| return &request{ |
| method: method, |
| url: url, |
| body: body, |
| headers: map[string]string{ |
| "Authorization": "Bearer " + c.Token.Raw, |
| "Accept": "application/json;api-version=6.0-preview.1", |
| }, |
| } |
| } |
| |
| func (c *Cache) doWithRetries(ctx context.Context, r *request) (*http.Response, error) { |
| var err error |
| max := time.Now().Add(c.opt.Timeout) |
| for { |
| if err1 := c.opt.BackoffPool.Wait(ctx, time.Until(max)); err1 != nil { |
| if err != nil { |
| return nil, errors.Wrapf(err, "%v", err1) |
| } |
| return nil, err1 |
| } |
| req, err := r.httpReq() |
| if err != nil { |
| return nil, err |
| } |
| req = req.WithContext(ctx) |
| |
| var resp *http.Response |
| resp, err = c.opt.Client.Do(req) |
| if err != nil { |
| return nil, errors.WithStack(err) |
| } |
| if err := checkResponse(resp); err != nil { |
| var he HTTPError |
| if errors.As(err, &he) { |
| if he.StatusCode == http.StatusTooManyRequests { |
| c.opt.BackoffPool.Delay() |
| continue |
| } |
| } |
| c.opt.BackoffPool.Reset() |
| return nil, err |
| } |
| c.opt.BackoffPool.Reset() |
| return resp, nil |
| } |
| } |
| |
| func (c *Cache) url(p string) string { |
| return c.URL + "_apis/artifactcache/" + p |
| } |
| |
| func (c *Cache) AllKeys(ctx context.Context, api *RestAPI, prefix string) (map[string]struct{}, error) { |
| m := map[string]struct{}{} |
| var mu sync.Mutex |
| eg, ctx := errgroup.WithContext(ctx) |
| for _, s := range c.scopes { |
| s := s |
| eg.Go(func() error { |
| keys, err := api.ListKeys(ctx, prefix, s.Scope) |
| if err != nil { |
| return err |
| } |
| mu.Lock() |
| for _, k := range keys { |
| m[k.Key] = struct{}{} |
| } |
| mu.Unlock() |
| return nil |
| }) |
| } |
| if err := eg.Wait(); err != nil { |
| return nil, err |
| } |
| return m, nil |
| } |
| |
| type ReserveCacheReq struct { |
| Key string `json:"key"` |
| Version string `json:"version"` |
| } |
| |
| type ReserveCacheResp struct { |
| CacheID int `json:"cacheID"` |
| } |
| |
| type CommitCacheReq struct { |
| Size int64 `json:"size"` |
| } |
| |
| type Entry struct { |
| Key string `json:"cacheKey"` |
| Scope string `json:"scope"` |
| URL string `json:"archiveLocation"` |
| |
| client *http.Client |
| } |
| |
| 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() |
| } |
| |
| // Download returns a ReaderAtCloser for pulling the data. Concurrent reads are not allowed |
| func (ce *Entry) Download(ctx context.Context) ReaderAtCloser { |
| return toReaderAtCloser(func(offset int64) (io.ReadCloser, error) { |
| req, err := http.NewRequest("GET", ce.URL, nil) |
| if err != nil { |
| return nil, errors.WithStack(err) |
| } |
| req = req.WithContext(ctx) |
| if offset != 0 { |
| req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset)) |
| } |
| client := ce.client |
| if client == nil { |
| client = http.DefaultClient |
| } |
| resp, err := client.Do(req) |
| if err != nil { |
| return nil, errors.WithStack(err) |
| } |
| if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable { |
| return nil, errors.Errorf("invalid status response %v for %s, range: %v", resp.Status, ce.URL, req.Header.Get("Range")) |
| } |
| return nil, errors.Errorf("invalid status response %v for %s", resp.Status, ce.URL) |
| } |
| if offset != 0 { |
| cr := resp.Header.Get("content-range") |
| if !strings.HasPrefix(cr, fmt.Sprintf("bytes %d-", offset)) { |
| resp.Body.Close() |
| return nil, errors.Errorf("unhandled content range in response: %v", cr) |
| } |
| } |
| return resp.Body, nil |
| }) |
| } |
| |
| type request struct { |
| method string |
| url string |
| body func() io.Reader |
| headers map[string]string |
| } |
| |
| func (r *request) httpReq() (*http.Request, error) { |
| var body io.Reader |
| if r.body != nil { |
| body = r.body() |
| } |
| req, err := http.NewRequest(r.method, r.url, body) |
| if err != nil { |
| return nil, err |
| } |
| for k, v := range r.headers { |
| req.Header.Add(k, v) |
| } |
| return req, nil |
| } |
| |
| func version(k string) string { |
| h := sha256.New() |
| // h.Write([]byte(k)) |
| // upstream uses paths in version, we don't seem to have anything that is unique like this |
| h.Write([]byte("|go-actionscache-1.0")) |
| return hex.EncodeToString(h.Sum(nil)) |
| } |
| |
| type GithubAPIError struct { |
| Message string `json:"message"` |
| TypeName string `json:"typeName"` |
| TypeKey string `json:"typeKey"` |
| ErrorCode int `json:"errorCode"` |
| } |
| |
| func (e GithubAPIError) Error() string { |
| return e.Message |
| } |
| |
| func (e GithubAPIError) Is(err error) bool { |
| if err == os.ErrExist { |
| if strings.Contains(e.TypeKey, "AlreadyExists") { |
| return true |
| } |
| // for safety, in case error gets updated |
| if strings.Contains(strings.ToLower(e.Message), "already exists") { |
| return true |
| } |
| } |
| return false |
| } |
| |
| type HTTPError struct { |
| StatusCode int |
| Err error |
| } |
| |
| func (e HTTPError) Error() string { |
| return e.Err.Error() |
| } |
| |
| func (e HTTPError) Unwrap() error { |
| return e.Err |
| } |
| |
| func checkResponse(resp *http.Response) error { |
| if resp.StatusCode >= 200 && resp.StatusCode < 300 { |
| return nil |
| } |
| dt, err := ioutil.ReadAll(utfbom.SkipOnly(io.LimitReader(resp.Body, 32*1024))) |
| if err != nil { |
| return errors.WithStack(err) |
| } |
| var gae GithubAPIError |
| if err1 := json.Unmarshal(dt, &gae); err1 != nil { |
| err = errors.Wrapf(err1, "failed to parse error response %d: %s", resp.StatusCode, dt) |
| } else if gae.Message != "" { |
| err = errors.WithStack(gae) |
| } else { |
| err = errors.Errorf("unknown error %s: %s", resp.Status, dt) |
| } |
| |
| return HTTPError{ |
| StatusCode: resp.StatusCode, |
| Err: err, |
| } |
| } |
| |
| func decryptToken(enc, pass string) (string, string, error) { |
| // openssl key derivation uses some non-standard algorithm so exec instead of using go libraries |
| // this is only used on testing anyway |
| cmd := exec.Command("openssl", "enc", "-d", "-aes-256-cbc", "-a", "-A", "-salt", "-md", "sha256", "-pass", "env:GHCACHE_TOKEN_PW") |
| cmd.Env = append(cmd.Env, fmt.Sprintf("GHCACHE_TOKEN_PW=%s", pass)) |
| cmd.Stdin = bytes.NewReader([]byte(enc)) |
| buf := &bytes.Buffer{} |
| cmd.Stdout = buf |
| cmd.Stderr = os.Stderr |
| if err := cmd.Run(); err != nil { |
| return "", "", err |
| } |
| parts := bytes.SplitN(buf.Bytes(), []byte(":::"), 2) |
| if len(parts) != 2 { |
| return "", "", errors.Errorf("invalid decrypt contents %s", buf.String()) |
| } |
| return string(parts[0]), strings.TrimSpace(string(parts[1])), nil |
| } |