github.com/nektos/act@v0.2.83/pkg/artifactcache/handler.go (about)

     1  package artifactcache
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"net"
     9  	"net/http"
    10  	"os"
    11  	"path/filepath"
    12  	"regexp"
    13  	"strconv"
    14  	"strings"
    15  	"sync/atomic"
    16  	"time"
    17  
    18  	"github.com/julienschmidt/httprouter"
    19  	"github.com/sirupsen/logrus"
    20  	"github.com/timshannon/bolthold"
    21  	"go.etcd.io/bbolt"
    22  
    23  	"github.com/nektos/act/pkg/common"
    24  )
    25  
    26  const (
    27  	urlBase = "/_apis/artifactcache"
    28  )
    29  
    30  type Handler struct {
    31  	dir      string
    32  	storage  *Storage
    33  	router   *httprouter.Router
    34  	listener net.Listener
    35  	server   *http.Server
    36  	logger   logrus.FieldLogger
    37  
    38  	gcing atomic.Bool
    39  	gcAt  time.Time
    40  
    41  	outboundIP        string
    42  	customExternalURL string
    43  }
    44  
    45  func StartHandler(dir, customExternalURL string, outboundIP string, port uint16, logger logrus.FieldLogger) (*Handler, error) {
    46  	h := &Handler{}
    47  
    48  	if logger == nil {
    49  		discard := logrus.New()
    50  		discard.Out = io.Discard
    51  		logger = discard
    52  	}
    53  	logger = logger.WithField("module", "artifactcache")
    54  	h.logger = logger
    55  
    56  	if dir == "" {
    57  		home, err := os.UserHomeDir()
    58  		if err != nil {
    59  			return nil, err
    60  		}
    61  		dir = filepath.Join(home, ".cache", "actcache")
    62  	}
    63  	if err := os.MkdirAll(dir, 0o755); err != nil {
    64  		return nil, err
    65  	}
    66  
    67  	h.dir = dir
    68  
    69  	storage, err := NewStorage(filepath.Join(dir, "cache"))
    70  	if err != nil {
    71  		return nil, err
    72  	}
    73  	h.storage = storage
    74  
    75  	if customExternalURL != "" {
    76  		h.customExternalURL = customExternalURL
    77  	}
    78  
    79  	if outboundIP != "" {
    80  		h.outboundIP = outboundIP
    81  	} else if ip := common.GetOutboundIP(); ip == nil {
    82  		return nil, fmt.Errorf("unable to determine outbound IP address")
    83  	} else {
    84  		h.outboundIP = ip.String()
    85  	}
    86  
    87  	router := httprouter.New()
    88  	router.GET(urlBase+"/cache", h.middleware(h.find))
    89  	router.POST(urlBase+"/caches", h.middleware(h.reserve))
    90  	router.PATCH(urlBase+"/caches/:id", h.middleware(h.upload))
    91  	router.POST(urlBase+"/caches/:id", h.middleware(h.commit))
    92  	router.GET(urlBase+"/artifacts/:id", h.middleware(h.get))
    93  	router.POST(urlBase+"/clean", h.middleware(h.clean))
    94  
    95  	h.router = router
    96  
    97  	h.gcCache()
    98  
    99  	listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) // listen on all interfaces
   100  	if err != nil {
   101  		return nil, err
   102  	}
   103  
   104  	server := &http.Server{
   105  		ReadHeaderTimeout: 2 * time.Second,
   106  		Handler:           router,
   107  	}
   108  	go func() {
   109  		if err := server.Serve(listener); err != nil && errors.Is(err, net.ErrClosed) {
   110  			logger.Errorf("http serve: %v", err)
   111  		}
   112  	}()
   113  	h.listener = listener
   114  	h.server = server
   115  
   116  	return h, nil
   117  }
   118  
   119  func (h *Handler) GetActualPort() int {
   120  	return h.listener.Addr().(*net.TCPAddr).Port
   121  }
   122  
   123  func (h *Handler) ExternalURL() string {
   124  	if h.customExternalURL != "" {
   125  		return h.customExternalURL
   126  	}
   127  	return fmt.Sprintf("http://%s:%d", h.outboundIP, h.GetActualPort())
   128  }
   129  
   130  func (h *Handler) Close() error {
   131  	if h == nil {
   132  		return nil
   133  	}
   134  	var retErr error
   135  	if h.server != nil {
   136  		err := h.server.Close()
   137  		if err != nil {
   138  			retErr = err
   139  		}
   140  		h.server = nil
   141  	}
   142  	if h.listener != nil {
   143  		err := h.listener.Close()
   144  		if errors.Is(err, net.ErrClosed) {
   145  			err = nil
   146  		}
   147  		if err != nil {
   148  			retErr = err
   149  		}
   150  		h.listener = nil
   151  	}
   152  	return retErr
   153  }
   154  
   155  func (h *Handler) openDB() (*bolthold.Store, error) {
   156  	return bolthold.Open(filepath.Join(h.dir, "bolt.db"), 0o644, &bolthold.Options{
   157  		Encoder: json.Marshal,
   158  		Decoder: json.Unmarshal,
   159  		Options: &bbolt.Options{
   160  			Timeout:      5 * time.Second,
   161  			NoGrowSync:   bbolt.DefaultOptions.NoGrowSync,
   162  			FreelistType: bbolt.DefaultOptions.FreelistType,
   163  		},
   164  	})
   165  }
   166  
   167  // GET /_apis/artifactcache/cache
   168  func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
   169  	keys := strings.Split(r.URL.Query().Get("keys"), ",")
   170  	// cache keys are case insensitive
   171  	for i, key := range keys {
   172  		keys[i] = strings.ToLower(key)
   173  	}
   174  	version := r.URL.Query().Get("version")
   175  
   176  	db, err := h.openDB()
   177  	if err != nil {
   178  		h.responseJSON(w, r, 500, err)
   179  		return
   180  	}
   181  	defer db.Close()
   182  
   183  	cache, err := findCache(db, keys, version)
   184  	if err != nil {
   185  		h.responseJSON(w, r, 500, err)
   186  		return
   187  	}
   188  	if cache == nil {
   189  		h.responseJSON(w, r, 204)
   190  		return
   191  	}
   192  
   193  	if ok, err := h.storage.Exist(cache.ID); err != nil {
   194  		h.responseJSON(w, r, 500, err)
   195  		return
   196  	} else if !ok {
   197  		_ = db.Delete(cache.ID, cache)
   198  		h.responseJSON(w, r, 204)
   199  		return
   200  	}
   201  	h.responseJSON(w, r, 200, map[string]any{
   202  		"result":          "hit",
   203  		"archiveLocation": fmt.Sprintf("%s%s/artifacts/%d", h.ExternalURL(), urlBase, cache.ID),
   204  		"cacheKey":        cache.Key,
   205  	})
   206  }
   207  
   208  // POST /_apis/artifactcache/caches
   209  func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
   210  	api := &Request{}
   211  	if err := json.NewDecoder(r.Body).Decode(api); err != nil {
   212  		h.responseJSON(w, r, 400, err)
   213  		return
   214  	}
   215  	// cache keys are case insensitive
   216  	api.Key = strings.ToLower(api.Key)
   217  
   218  	cache := api.ToCache()
   219  	db, err := h.openDB()
   220  	if err != nil {
   221  		h.responseJSON(w, r, 500, err)
   222  		return
   223  	}
   224  	defer db.Close()
   225  
   226  	now := time.Now().Unix()
   227  	cache.CreatedAt = now
   228  	cache.UsedAt = now
   229  	if err := insertCache(db, cache); err != nil {
   230  		h.responseJSON(w, r, 500, err)
   231  		return
   232  	}
   233  	h.responseJSON(w, r, 200, map[string]any{
   234  		"cacheId": cache.ID,
   235  	})
   236  }
   237  
   238  // PATCH /_apis/artifactcache/caches/:id
   239  func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
   240  	id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
   241  	if err != nil {
   242  		h.responseJSON(w, r, 400, err)
   243  		return
   244  	}
   245  
   246  	cache := &Cache{}
   247  	db, err := h.openDB()
   248  	if err != nil {
   249  		h.responseJSON(w, r, 500, err)
   250  		return
   251  	}
   252  	defer db.Close()
   253  	if err := db.Get(id, cache); err != nil {
   254  		if errors.Is(err, bolthold.ErrNotFound) {
   255  			h.responseJSON(w, r, 400, fmt.Errorf("cache %d: not reserved", id))
   256  			return
   257  		}
   258  		h.responseJSON(w, r, 500, err)
   259  		return
   260  	}
   261  
   262  	if cache.Complete {
   263  		h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
   264  		return
   265  	}
   266  	db.Close()
   267  	start, _, err := parseContentRange(r.Header.Get("Content-Range"))
   268  	if err != nil {
   269  		h.responseJSON(w, r, 400, err)
   270  		return
   271  	}
   272  	if err := h.storage.Write(cache.ID, start, r.Body); err != nil {
   273  		h.responseJSON(w, r, 500, err)
   274  	}
   275  	h.useCache(id)
   276  	h.responseJSON(w, r, 200)
   277  }
   278  
   279  // POST /_apis/artifactcache/caches/:id
   280  func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
   281  	id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
   282  	if err != nil {
   283  		h.responseJSON(w, r, 400, err)
   284  		return
   285  	}
   286  
   287  	cache := &Cache{}
   288  	db, err := h.openDB()
   289  	if err != nil {
   290  		h.responseJSON(w, r, 500, err)
   291  		return
   292  	}
   293  	defer db.Close()
   294  	if err := db.Get(id, cache); err != nil {
   295  		if errors.Is(err, bolthold.ErrNotFound) {
   296  			h.responseJSON(w, r, 400, fmt.Errorf("cache %d: not reserved", id))
   297  			return
   298  		}
   299  		h.responseJSON(w, r, 500, err)
   300  		return
   301  	}
   302  
   303  	if cache.Complete {
   304  		h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
   305  		return
   306  	}
   307  
   308  	db.Close()
   309  
   310  	size, err := h.storage.Commit(cache.ID, cache.Size)
   311  	if err != nil {
   312  		h.responseJSON(w, r, 500, err)
   313  		return
   314  	}
   315  	// write real size back to cache, it may be different from the current value when the request doesn't specify it.
   316  	cache.Size = size
   317  
   318  	db, err = h.openDB()
   319  	if err != nil {
   320  		h.responseJSON(w, r, 500, err)
   321  		return
   322  	}
   323  	defer db.Close()
   324  
   325  	cache.Complete = true
   326  	if err := db.Update(cache.ID, cache); err != nil {
   327  		h.responseJSON(w, r, 500, err)
   328  		return
   329  	}
   330  
   331  	h.responseJSON(w, r, 200)
   332  }
   333  
   334  // GET /_apis/artifactcache/artifacts/:id
   335  func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
   336  	id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
   337  	if err != nil {
   338  		h.responseJSON(w, r, 400, err)
   339  		return
   340  	}
   341  	h.useCache(id)
   342  	h.storage.Serve(w, r, id)
   343  }
   344  
   345  // POST /_apis/artifactcache/clean
   346  func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
   347  	// TODO: don't support force deleting cache entries
   348  	// see: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
   349  
   350  	h.responseJSON(w, r, 200)
   351  }
   352  
   353  func (h *Handler) middleware(handler httprouter.Handle) httprouter.Handle {
   354  	return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
   355  		h.logger.Debugf("%s %s", r.Method, r.RequestURI)
   356  		handler(w, r, params)
   357  		go h.gcCache()
   358  	}
   359  }
   360  
   361  // if not found, return (nil, nil) instead of an error.
   362  func findCache(db *bolthold.Store, keys []string, version string) (*Cache, error) {
   363  	cache := &Cache{}
   364  	for _, prefix := range keys {
   365  		// if a key in the list matches exactly, don't return partial matches
   366  		if err := db.FindOne(cache,
   367  			bolthold.Where("Key").Eq(prefix).
   368  				And("Version").Eq(version).
   369  				And("Complete").Eq(true).
   370  				SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
   371  			if err != nil {
   372  				return nil, fmt.Errorf("find cache: %w", err)
   373  			}
   374  			return cache, nil
   375  		}
   376  		prefixPattern := fmt.Sprintf("^%s", regexp.QuoteMeta(prefix))
   377  		re, err := regexp.Compile(prefixPattern)
   378  		if err != nil {
   379  			continue
   380  		}
   381  		if err := db.FindOne(cache,
   382  			bolthold.Where("Key").RegExp(re).
   383  				And("Version").Eq(version).
   384  				And("Complete").Eq(true).
   385  				SortBy("CreatedAt").Reverse()); err != nil {
   386  			if errors.Is(err, bolthold.ErrNotFound) {
   387  				continue
   388  			}
   389  			return nil, fmt.Errorf("find cache: %w", err)
   390  		}
   391  		return cache, nil
   392  	}
   393  	return nil, nil
   394  }
   395  
   396  func insertCache(db *bolthold.Store, cache *Cache) error {
   397  	if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
   398  		return fmt.Errorf("insert cache: %w", err)
   399  	}
   400  	// write back id to db
   401  	if err := db.Update(cache.ID, cache); err != nil {
   402  		return fmt.Errorf("write back id to db: %w", err)
   403  	}
   404  	return nil
   405  }
   406  
   407  func (h *Handler) useCache(id uint64) {
   408  	db, err := h.openDB()
   409  	if err != nil {
   410  		return
   411  	}
   412  	defer db.Close()
   413  	cache := &Cache{}
   414  	if err := db.Get(id, cache); err != nil {
   415  		return
   416  	}
   417  	cache.UsedAt = time.Now().Unix()
   418  	_ = db.Update(cache.ID, cache)
   419  }
   420  
   421  const (
   422  	keepUsed   = 30 * 24 * time.Hour
   423  	keepUnused = 7 * 24 * time.Hour
   424  	keepTemp   = 5 * time.Minute
   425  	keepOld    = 5 * time.Minute
   426  )
   427  
   428  func (h *Handler) gcCache() {
   429  	if h.gcing.Load() {
   430  		return
   431  	}
   432  	if !h.gcing.CompareAndSwap(false, true) {
   433  		return
   434  	}
   435  	defer h.gcing.Store(false)
   436  
   437  	if time.Since(h.gcAt) < time.Hour {
   438  		h.logger.Debugf("skip gc: %v", h.gcAt.String())
   439  		return
   440  	}
   441  	h.gcAt = time.Now()
   442  	h.logger.Debugf("gc: %v", h.gcAt.String())
   443  
   444  	db, err := h.openDB()
   445  	if err != nil {
   446  		return
   447  	}
   448  	defer db.Close()
   449  
   450  	// Remove the caches which are not completed for a while, they are most likely to be broken.
   451  	var caches []*Cache
   452  	if err := db.Find(&caches, bolthold.
   453  		Where("UsedAt").Lt(time.Now().Add(-keepTemp).Unix()).
   454  		And("Complete").Eq(false),
   455  	); err != nil {
   456  		h.logger.Warnf("find caches: %v", err)
   457  	} else {
   458  		for _, cache := range caches {
   459  			h.storage.Remove(cache.ID)
   460  			if err := db.Delete(cache.ID, cache); err != nil {
   461  				h.logger.Warnf("delete cache: %v", err)
   462  				continue
   463  			}
   464  			h.logger.Infof("deleted cache: %+v", cache)
   465  		}
   466  	}
   467  
   468  	// Remove the old caches which have not been used recently.
   469  	caches = caches[:0]
   470  	if err := db.Find(&caches, bolthold.
   471  		Where("UsedAt").Lt(time.Now().Add(-keepUnused).Unix()),
   472  	); err != nil {
   473  		h.logger.Warnf("find caches: %v", err)
   474  	} else {
   475  		for _, cache := range caches {
   476  			h.storage.Remove(cache.ID)
   477  			if err := db.Delete(cache.ID, cache); err != nil {
   478  				h.logger.Warnf("delete cache: %v", err)
   479  				continue
   480  			}
   481  			h.logger.Infof("deleted cache: %+v", cache)
   482  		}
   483  	}
   484  
   485  	// Remove the old caches which are too old.
   486  	caches = caches[:0]
   487  	if err := db.Find(&caches, bolthold.
   488  		Where("CreatedAt").Lt(time.Now().Add(-keepUsed).Unix()),
   489  	); err != nil {
   490  		h.logger.Warnf("find caches: %v", err)
   491  	} else {
   492  		for _, cache := range caches {
   493  			h.storage.Remove(cache.ID)
   494  			if err := db.Delete(cache.ID, cache); err != nil {
   495  				h.logger.Warnf("delete cache: %v", err)
   496  				continue
   497  			}
   498  			h.logger.Infof("deleted cache: %+v", cache)
   499  		}
   500  	}
   501  
   502  	// Remove the old caches with the same key and version, keep the latest one.
   503  	// Also keep the olds which have been used recently for a while in case of the cache is still in use.
   504  	if results, err := db.FindAggregate(
   505  		&Cache{},
   506  		bolthold.Where("Complete").Eq(true),
   507  		"Key", "Version",
   508  	); err != nil {
   509  		h.logger.Warnf("find aggregate caches: %v", err)
   510  	} else {
   511  		for _, result := range results {
   512  			if result.Count() <= 1 {
   513  				continue
   514  			}
   515  			result.Sort("CreatedAt")
   516  			caches = caches[:0]
   517  			result.Reduction(&caches)
   518  			for _, cache := range caches[:len(caches)-1] {
   519  				if time.Since(time.Unix(cache.UsedAt, 0)) < keepOld {
   520  					// Keep it since it has been used recently, even if it's old.
   521  					// Or it could break downloading in process.
   522  					continue
   523  				}
   524  				h.storage.Remove(cache.ID)
   525  				if err := db.Delete(cache.ID, cache); err != nil {
   526  					h.logger.Warnf("delete cache: %v", err)
   527  					continue
   528  				}
   529  				h.logger.Infof("deleted cache: %+v", cache)
   530  			}
   531  		}
   532  	}
   533  }
   534  
   535  func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int, v ...any) {
   536  	w.Header().Set("Content-Type", "application/json; charset=utf-8")
   537  	var data []byte
   538  	if len(v) == 0 || v[0] == nil {
   539  		data, _ = json.Marshal(struct{}{})
   540  	} else if err, ok := v[0].(error); ok {
   541  		h.logger.Errorf("%v %v: %v", r.Method, r.RequestURI, err)
   542  		data, _ = json.Marshal(map[string]any{
   543  			"error": err.Error(),
   544  		})
   545  	} else {
   546  		data, _ = json.Marshal(v[0])
   547  	}
   548  	w.WriteHeader(code)
   549  	_, _ = w.Write(data)
   550  }
   551  
   552  func parseContentRange(s string) (int64, int64, error) {
   553  	// support the format like "bytes 11-22/*" only
   554  	s, _, _ = strings.Cut(strings.TrimPrefix(s, "bytes "), "/")
   555  	s1, s2, _ := strings.Cut(s, "-")
   556  
   557  	start, err := strconv.ParseInt(s1, 10, 64)
   558  	if err != nil {
   559  		return 0, 0, fmt.Errorf("parse %q: %w", s, err)
   560  	}
   561  	stop, err := strconv.ParseInt(s2, 10, 64)
   562  	if err != nil {
   563  		return 0, 0, fmt.Errorf("parse %q: %w", s, err)
   564  	}
   565  	return start, stop, nil
   566  }