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] = &registryFetchState{
   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  }