package main import ( "context" "log" "path" "sync" "time" ) // A PackageCache holds information about Go packages and their upstream // repositories. // // Given a package name, it looks for a similarly named repository in one of // the upstream hosts and holds on to the result for quicker subsequent access. type PackageCache struct { // ExpireAfter is how long to consider a found repository valid for. As // repositories do not frequently move around, this could be rather long, // though it also determines how long a user might have to wait for a new // repository to become available if they previously got a 404. ExpireAfter time.Duration // UpstreamTimeout is how long we should allow HTTP requests to upstream // hosts to carry on before they are canceled. UpstreamTimeout time.Duration // Hosts are the RepoHosts which should be checked for a repository matching // the package name. Hosts are checked in order; if two hosts both have a // repo with the desired name, the repo host which is listed first will // "win". Hosts []RepoHost // Logger is a logger for diagnostics Logger *log.Logger // CanonicalPrefix is the package prefix to use when naming packages. E.g., // when looking for a package named "conf", we'll treat the package name as // CanonicalPrefix + "/conf", e.g. burwell.io/conf. CanonicalPrefix string entries sync.Map // concurrency-safe map[string]entry } type entry struct { t time.Time pkg *Package } // A Package represents a Go package's canonical name and its source git // repository. type Package struct { Name string Repo string } // Get loads package name from the cache, consulting upstream repositories to // find a matching repo if not present. As this method is meant to be called in // the critical path of an incoming HTTP request, it can also be cancelled with // the supplied context. func (c *PackageCache) Get(ctx context.Context, name string) (Package, bool) { val, ok := c.entries.Load(name) if !ok { return c.load(ctx, name) } ent := val.(entry) if time.Now().After(ent.t.Add(c.ExpireAfter)) { return c.load(ctx, name) } if ent.pkg == nil { return Package{}, false } return *ent.pkg, true } // load looks for a package in the upstream repos, and if found, stores it in // the cache. If no matching repo is found, a nil entry is stored in the map so // we don't immediately re-check the upstreams for subsequent requests. func (c *PackageCache) load(ctx context.Context, name string) (Package, bool) { pkg, ok := c.find(ctx, name) if !ok { c.entries.Store(name, entry{t: time.Now()}) return Package{}, false } c.entries.Store(name, entry{t: time.Now(), pkg: &pkg}) return pkg, true } // requestContext creates a context for making HTTP requests to upstream hosts. func (c *PackageCache) requestContext(ctx context.Context) (context.Context, context.CancelFunc) { if c.UpstreamTimeout == 0 { return ctx, func() {} } return context.WithTimeout(ctx, c.UpstreamTimeout) } // logf logs a message using the configured logger, if any. func (c *PackageCache) logf(msg string, args ...interface{}) { if c.Logger == nil { return } c.Logger.Printf(msg, args...) } // find consults the configured upstream hosts to try to find a matching repo // for package name. func (c *PackageCache) find(ctx context.Context, name string) (Package, bool) { c.logf("loading %s", name) for _, h := range c.Hosts { reqCtx, cancel := c.requestContext(ctx) defer cancel() repo, ok, err := h.HostsRepo(reqCtx, name) if err != nil { c.logf("error checking upstream for repo %s: %v", name, err) return Package{}, false } if ok { c.logf("found repo %s: %s", name, repo) return Package{ Name: path.Join(c.CanonicalPrefix, name), Repo: repo, }, true } } c.logf("could not find repo %s on any host", name) return Package{}, false }