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 }