github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/pkg/registry/registry.go (about) 1 package registry 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "io" 9 "net/http" 10 "net/url" 11 "path" 12 "sort" 13 "strconv" 14 "strings" 15 "time" 16 17 "github.com/cozy/cozy-stack/pkg/couchdb" 18 "github.com/cozy/cozy-stack/pkg/logger" 19 "github.com/cozy/httpcache" 20 "github.com/labstack/echo/v4" 21 ) 22 23 const defaultLimit = 100 24 25 // A Version describes a specific release of an application. 26 type Version struct { 27 Slug string `json:"slug"` 28 Version string `json:"version"` 29 URL string `json:"url"` 30 Sha256 string `json:"sha256"` 31 CreatedAt time.Time `json:"created_at"` 32 Size string `json:"size"` 33 Manifest json.RawMessage `json:"manifest"` 34 TarPrefix string `json:"tar_prefix"` 35 } 36 37 // A MaintenanceOptions defines options about a maintenance 38 type MaintenanceOptions struct { 39 FlagInfraMaintenance bool `json:"flag_infra_maintenance"` 40 FlagShortMaintenance bool `json:"flag_short_maintenance"` 41 FlagDisallowManualExec bool `json:"flag_disallow_manual_exec"` 42 } 43 44 // An Application describe an application on the registry 45 type Application struct { 46 Slug string `json:"slug"` 47 Type string `json:"type"` 48 MaintenanceActivated bool `json:"maintenance_activated,omitempty"` 49 MaintenanceOptions MaintenanceOptions `json:"maintenance_options"` 50 } 51 52 var errVersionNotFound = errors.New("registry: version not found") 53 var errApplicationNotFound = errors.New("registry: application not found") 54 55 var ( 56 proxyClient = &http.Client{ 57 Timeout: 10 * time.Second, 58 Transport: httpcache.NewMemoryCacheTransport(32), 59 } 60 61 maintenanceClient = &http.Client{ 62 Timeout: 10 * time.Second, 63 Transport: httpcache.NewMemoryCacheTransport(32), 64 } 65 66 appClient = &http.Client{ 67 Timeout: 5 * time.Second, 68 Transport: httpcache.NewMemoryCacheTransport(256), 69 } 70 71 latestVersionClient = &http.Client{ 72 Timeout: 5 * time.Second, 73 Transport: httpcache.NewMemoryCacheTransport(256), 74 } 75 ) 76 77 // CacheControl defines whether or not to use caching for the request made to 78 // the registries. 79 type CacheControl int 80 81 const ( 82 // WithCache specify caching 83 WithCache CacheControl = iota 84 // NoCache disables any caching 85 NoCache 86 ) 87 88 // GetVersion returns a specific version from a slug name 89 func GetVersion(slug, version string, registries []*url.URL) (*Version, error) { 90 requestURI := fmt.Sprintf("/registry/%s/%s", 91 url.PathEscape(slug), 92 url.PathEscape(version)) 93 resp, ok, err := fetchUntilFound(latestVersionClient, registries, requestURI, WithCache) 94 if err != nil { 95 return nil, err 96 } 97 if !ok { 98 return nil, errVersionNotFound 99 } 100 defer resp.Body.Close() 101 var v *Version 102 if err = json.NewDecoder(resp.Body).Decode(&v); err != nil { 103 return nil, err 104 } 105 return v, nil 106 } 107 108 // GetLatestVersion returns the latest version available from the list of 109 // registries by resolving them in sequence using the specified application 110 // slug and channel name. 111 func GetLatestVersion(slug, channel string, registries []*url.URL) (*Version, error) { 112 requestURI := fmt.Sprintf("/registry/%s/%s/latest", 113 url.PathEscape(slug), 114 url.PathEscape(channel)) 115 resp, ok, err := fetchUntilFound(latestVersionClient, registries, requestURI, WithCache) 116 if err != nil { 117 return nil, err 118 } 119 if !ok { 120 return nil, errVersionNotFound 121 } 122 defer resp.Body.Close() 123 var v *Version 124 if err = json.NewDecoder(resp.Body).Decode(&v); err != nil { 125 return nil, err 126 } 127 return v, nil 128 } 129 130 // GetApplication returns an application from his slug 131 func GetApplication(slug string, registries []*url.URL) (*Application, error) { 132 requestURI := fmt.Sprintf("/registry/%s/", slug) 133 resp, ok, err := fetchUntilFound(appClient, registries, requestURI, WithCache) 134 if err != nil { 135 return nil, err 136 } 137 if !ok { 138 return nil, errApplicationNotFound 139 } 140 defer resp.Body.Close() 141 var app *Application 142 if err = json.NewDecoder(resp.Body).Decode(&app); err != nil { 143 return nil, err 144 } 145 return app, nil 146 } 147 148 // Proxy will proxy the given request to the registries in sequence and return 149 // the response as io.ReadCloser when finding a registry returning a HTTP 200OK 150 // response. 151 func Proxy(req *http.Request, registries []*url.URL, cache CacheControl) (*http.Response, error) { 152 resp, ok, err := fetchUntilFound(proxyClient, registries, req.RequestURI, cache) 153 if err != nil { 154 return nil, err 155 } 156 if !ok { 157 return nil, echo.NewHTTPError(http.StatusNotFound) 158 } 159 return resp, nil 160 } 161 162 // ListMaintenance will proxy the given request to the registries to fetch all 163 // the apps in maintenance. It takes care to ignore maintenance for apps 164 // present in another registry space with higher priority. 165 func ListMaintenance(registries []*url.URL) ([]couchdb.JSONDoc, error) { 166 maskedSlugs := make(map[string]struct{}) 167 apps := make([]couchdb.JSONDoc, 0) 168 for i, r := range registries { 169 if i != 0 { 170 prev := registries[i-1] 171 ref := &url.URL{Path: "/registry/slugs"} 172 resp, ok, err := fetch(maintenanceClient, prev, ref, WithCache) 173 if err != nil { 174 return nil, err 175 } 176 if ok { 177 var slugs []string 178 if err = json.NewDecoder(resp.Body).Decode(&slugs); err != nil { 179 return nil, err 180 } 181 for _, slug := range slugs { 182 maskedSlugs[slug] = struct{}{} 183 } 184 } 185 } 186 187 ref := &url.URL{Path: "/registry/maintenance"} 188 resp, ok, err := fetch(maintenanceClient, r, ref, WithCache) 189 if err != nil { 190 return nil, err 191 } 192 if !ok { 193 continue 194 } 195 var docs []couchdb.JSONDoc 196 if err = json.NewDecoder(resp.Body).Decode(&docs); err != nil { 197 return nil, err 198 } 199 for _, doc := range docs { 200 slug, _ := doc.M["slug"].(string) 201 if _, masked := maskedSlugs[slug]; !masked { 202 apps = append(apps, doc) 203 } 204 } 205 } 206 return apps, nil 207 } 208 209 // ProxyList will proxy the given request to the registries by aggregating the 210 // results along the way. It should be used for list endpoints. 211 func ProxyList(req *http.Request, registries []*url.URL) (*AppsPaginated, error) { 212 ref, err := url.Parse(req.RequestURI) 213 if err != nil { 214 return nil, err 215 } 216 217 var sortBy string 218 var sortReverse bool 219 var limit int 220 221 cursors := make([]int, len(registries)) 222 223 q := ref.Query() 224 if v, ok := q["cursor"]; ok { 225 splits := strings.Split(v[0], "|") 226 for i, s := range splits { 227 if i >= len(registries) { 228 break 229 } 230 cursors[i], _ = strconv.Atoi(s) 231 } 232 } 233 if v, ok := q["sort"]; ok { 234 sortBy = v[0] 235 } 236 if len(sortBy) > 0 && sortBy[0] == '-' { 237 sortReverse = true 238 sortBy = sortBy[1:] 239 } 240 if sortBy == "" { 241 sortBy = "slug" 242 } 243 if v, ok := q["limit"]; ok { 244 limit, _ = strconv.Atoi(v[0]) 245 } 246 if limit <= 0 { 247 limit = defaultLimit 248 } 249 250 list := newAppsList(ref, registries, cursors, limit) 251 if err := list.FetchAll(); err != nil { 252 return nil, err 253 } 254 return list.Paginated(sortBy, sortReverse, limit), nil 255 } 256 257 type appsList struct { 258 ref *url.URL 259 list []map[string]json.RawMessage 260 registries []*registryFetchState 261 slugs map[string][]int 262 limit int 263 } 264 265 // PageInfo is the metadata for pagination. 266 type PageInfo struct { 267 Count int `json:"count"` 268 NextCursor string `json:"next_cursor,omitempty"` 269 } 270 271 // AppsPaginated is a struct for listing apps manifest from the registry, with 272 // pagination. 273 type AppsPaginated struct { 274 Apps []map[string]json.RawMessage `json:"data"` 275 PageInfo PageInfo `json:"meta"` 276 } 277 278 type registryFetchState struct { 279 url *url.URL 280 index int // index in the registries array 281 cursor int // cursor used to fetch the registry 282 ended int // cursor of the last element in the regitry (-1 if unknown) 283 } 284 285 func newAppsList(ref *url.URL, registries []*url.URL, cursors []int, limit int) *appsList { 286 if len(registries) != len(cursors) { 287 panic("should have same length") 288 } 289 regStates := make([]*registryFetchState, len(registries)) 290 for i := range regStates { 291 regStates[i] = ®istryFetchState{ 292 index: i, 293 url: registries[i], 294 cursor: cursors[i], 295 ended: -1, 296 } 297 } 298 return &appsList{ 299 ref: ref, 300 limit: limit, 301 list: make([]map[string]json.RawMessage, 0), 302 slugs: make(map[string][]int), 303 registries: regStates, 304 } 305 } 306 307 func (a *appsList) FetchAll() error { 308 l := len(a.registries) 309 for i, r := range a.registries { 310 // We fetch the entire registry except for the last one. In practice, the 311 // "high-priority" registries should be small and the last one contain the 312 // vast majority of the applications. 313 fetchAll := i < l-1 314 if err := a.fetch(r, fetchAll); err != nil { 315 return err 316 } 317 } 318 return nil 319 } 320 321 func (a *appsList) fetch(r *registryFetchState, fetchAll bool) error { 322 slugs := a.slugs 323 minCursor := r.cursor 324 maxCursor := r.cursor + a.limit 325 326 var cursor, limit int 327 if fetchAll { 328 cursor = 0 329 limit = defaultLimit 330 } else { 331 cursor = r.cursor 332 limit = a.limit 333 } 334 335 // A negative dimension of the cursor means we already reached the end of the 336 // list. There is no need to fetch anymore in that case. 337 if !fetchAll && r.cursor < 0 { 338 return nil 339 } 340 341 added := 0 342 for { 343 ref := addQueries(removeQueries(a.ref, "cursor", "limit"), 344 "cursor", strconv.Itoa(cursor), 345 "limit", strconv.Itoa(limit), 346 ) 347 resp, ok, err := fetch(proxyClient, r.url, ref, NoCache) 348 if err != nil { 349 return err 350 } 351 if !ok { 352 return nil 353 } 354 defer resp.Body.Close() 355 var page AppsPaginated 356 if err = json.NewDecoder(resp.Body).Decode(&page); err != nil { 357 return err 358 } 359 360 for i, obj := range page.Apps { 361 objCursor := cursor + i 362 363 objInRange := r.cursor >= 0 && 364 objCursor >= minCursor && 365 objCursor <= maxCursor 366 367 // if an object with same slug has already been fetched, we skip it 368 slug := ParseSlug(obj["slug"]) 369 offsets, ok := slugs[slug] 370 if !ok { 371 offsets = make([]int, len(a.registries)) 372 slugs[slug] = offsets 373 } 374 if objInRange { 375 offsets[r.index] = objCursor + 1 376 if !ok { 377 a.list = append(a.list, obj) 378 added++ 379 } 380 } 381 } 382 383 nextCursor := page.PageInfo.NextCursor 384 if nextCursor == "" { 385 r.ended = cursor + len(page.Apps) 386 break 387 } 388 389 cursor, _ = strconv.Atoi(nextCursor) 390 if !fetchAll && limit-added <= 0 { 391 break 392 } 393 } 394 395 return nil 396 } 397 398 func ParseSlug(raw json.RawMessage) string { 399 var slug string 400 if err := json.Unmarshal(raw, &slug); err != nil { 401 return "" 402 } 403 return slug 404 } 405 406 func (a *appsList) Paginated(sortBy string, reverse bool, limit int) *AppsPaginated { 407 sort.Slice(a.list, func(i, j int) bool { 408 valA := a.list[i][sortBy] 409 valB := a.list[j][sortBy] 410 cmp := bytes.Compare([]byte(valA), []byte(valB)) 411 equal := cmp == 0 412 less := cmp < 0 413 if equal { 414 slugA := ParseSlug(a.list[i]["slug"]) 415 slugB := ParseSlug(a.list[j]["slug"]) 416 less = slugA < slugB 417 } 418 if reverse { 419 return !less 420 } 421 return less 422 }) 423 424 if limit > len(a.list) { 425 limit = len(a.list) 426 } 427 428 list := a.list[:limit] 429 430 // Copy the original cursor 431 cursors := make([]int, len(a.registries)) 432 for i, reg := range a.registries { 433 cursors[i] = reg.cursor 434 } 435 436 // Calculation of the next multi-cursor by iterating through the sorted and 437 // truncated list and incrementing the dimension of the multi-cursor 438 // associated with the objects registry. 439 // 440 // In the end, we also check if the end value of each dimensions of the 441 // cursor reached the end of the list. If so, the dimension is set to -1. 442 l := len(a.registries) 443 for _, o := range list { 444 slug := ParseSlug(o["slug"]) 445 offsets := a.slugs[slug] 446 447 i := 0 448 // This first loop checks the first element >= 0 in the offsets associated 449 // to the object. This first non null element is set as the cursor of the 450 // dimension. 451 for ; i < l; i++ { 452 if c := offsets[i]; c > 0 { 453 cursors[i] = c 454 break 455 } 456 } 457 // We continue the iteration to the next lower-priority dimensions and for 458 // non-null ones, we can increment their value by at-most one. This 459 // correspond to values that where rejected by having the same slugs as 460 // prioritized objects. 461 i++ 462 for ; i < l; i++ { 463 if c := offsets[i]; c > 0 && cursors[i] == c-1 { 464 cursors[i] = c 465 } 466 } 467 } 468 469 for i, reg := range a.registries { 470 if e := reg.ended; e >= 0 && cursors[i] >= e { 471 cursors[i] = -1 472 } 473 } 474 475 return &AppsPaginated{ 476 Apps: list, 477 PageInfo: PageInfo{ 478 Count: len(list), 479 NextCursor: printMutliCursor(cursors), 480 }, 481 } 482 } 483 484 func fetchUntilFound(client *http.Client, registries []*url.URL, requestURI string, cache CacheControl) (resp *http.Response, ok bool, err error) { 485 ref, err := url.Parse(requestURI) 486 if err != nil { 487 return 488 } 489 for _, registry := range registries { 490 resp, ok, err = fetch(client, registry, ref, cache) 491 if err != nil { 492 return 493 } 494 if !ok { 495 continue 496 } 497 return 498 } 499 return nil, false, nil 500 } 501 502 func fetch(client *http.Client, registry, ref *url.URL, cache CacheControl) (resp *http.Response, ok bool, err error) { 503 u := registry.ResolveReference(ref) 504 u.Path = path.Join(registry.Path, ref.Path) 505 req, err := http.NewRequest(http.MethodGet, u.String(), nil) 506 if err != nil { 507 return 508 } 509 if cache == NoCache { 510 req.Header.Set("cache-control", "no-cache") 511 } 512 start := time.Now() 513 resp, err = client.Do(req) 514 if err != nil { 515 return 516 } 517 elapsed := time.Since(start) 518 defer func() { 519 if !ok { 520 // Flush the body, so that the connection can be reused by keep-alive 521 _, _ = io.Copy(io.Discard, resp.Body) 522 resp.Body.Close() 523 } 524 }() 525 if elapsed.Seconds() >= 3 { 526 log := logger.WithNamespace("registry") 527 log.Infof("slow request on %s (%s)", u.String(), elapsed) 528 } 529 if resp.StatusCode == 404 { 530 return 531 } 532 if resp.StatusCode != 200 { 533 var msg struct { 534 Message string `json:"message"` 535 } 536 if err = json.NewDecoder(resp.Body).Decode(&msg); err != nil { 537 err = echo.NewHTTPError(resp.StatusCode) 538 } else { 539 err = echo.NewHTTPError(resp.StatusCode, msg.Message) 540 } 541 return 542 } 543 return resp, true, nil 544 } 545 546 func printMutliCursor(c []int) string { 547 // if all dimensions of the multi-cursor are -1, we print the empty string 548 sum := 0 549 for _, i := range c { 550 sum += i 551 } 552 if sum == -len(c) { 553 return "" 554 } 555 var a []string 556 for _, i := range c { 557 a = append(a, strconv.Itoa(i)) 558 } 559 return strings.Join(a, "|") 560 } 561 562 func removeQueries(u *url.URL, filter ...string) *url.URL { 563 u, _ = url.Parse(u.String()) 564 q1 := u.Query() 565 q2 := make(url.Values) 566 for k, v := range q1 { 567 if len(v) == 0 { 568 continue 569 } 570 var remove bool 571 for _, f := range filter { 572 if f == k { 573 remove = true 574 break 575 } 576 } 577 if !remove { 578 q2.Add(k, v[0]) 579 } 580 } 581 u.RawQuery = q2.Encode() 582 return u 583 } 584 585 func addQueries(u *url.URL, queries ...string) *url.URL { 586 u, _ = url.Parse(u.String()) 587 q := u.Query() 588 for i := 0; i < len(queries); i += 2 { 589 q.Add(queries[i], queries[i+1]) 590 } 591 u.RawQuery = q.Encode() 592 return u 593 }