// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package releases import ( "context" "fmt" "log/slog" "sort" "time" "github.com/Masterminds/semver" "github.com/hashicorp/releases-api/pkg/api" "github.com/hashicorp/releases-api/pkg/client" "github.com/hashicorp/releases-api/pkg/models" slogctx "github.com/veqryn/slog-context" ) // Client is an api.releases.hashicorp.com API client. type Client struct { Addr string } // VersionLister lists versions from the releases API. type VersionLister interface { ListVersions(ctx context.Context, product string, edition LicenseClass, ceil, floor *semver.Version) ([]string, error) } var _ VersionLister = (*Client)(nil) type LicenseClass string // These map to the licenses classes defined by the releases API. const ( LicenseClassNone LicenseClass = "" LicenseClassCE LicenseClass = "oss" LicenseClassEnt LicenseClass = "enterprise" LicenseClassHCP LicenseClass = "hcp" ) // NewClient returns a new releases API client. func NewClient() *Client { return &Client{ Addr: "api.releases.hashicorp.com", } } // GetRelease takes a context, product, edition, and version and returns the relase information. func (c *Client) GetRelease(ctx context.Context, product string, edition LicenseClass, version string) (*models.Release, error) { ctx = slogctx.Append(ctx, slog.String("product", product), slog.String("edition", string(edition)), slog.String("version", version), ) slog.Default().DebugContext(ctx, "getting release") rc := client.New(c.Addr, "", api.V1MimeType) lc := string(edition) release, _, err := rc.GetRelease(&client.GetReleaseParams{ Version: version, Product: product, LicenseClass: &lc, }) slog.Default().DebugContext(ctx, "got release", "release", release, "error", err) return release, err } // ListVersions takes a context, product, edition, a ceiling version and floor version and returns // all unique versions between the ceiling and floor range. func (c *Client) ListVersions(ctx context.Context, product string, edition LicenseClass, ceil, floor *semver.Version) ([]string, error) { select { case <-ctx.Done(): return nil, ctx.Err() default: } ctx = slogctx.Append(ctx, slog.String("product", product), slog.String("edition", string(edition)), slog.String("ceil", ceil.String()), slog.String("floor", floor.String()), slog.String("addr", c.Addr), ) slog.Default().DebugContext(ctx, "listing release versions") // The releases API lists releases by their upload order starting by most recent, not sequentially // by version. It also uses upload timestamps as a method of pagination. Since version upload // lineages are all mixed up we'll have to start by listing the latest versions and work back // until we've reached an upload timestamp at our floor version. To do so we'll get the created // time of our floor and subtract a day which ought to give us a reasonable time floor. // NOTE: This requires our floor version to actually exist. floorR, err := c.GetRelease(ctx, product, edition, floor.String()) if err != nil { return nil, fmt.Errorf("invalid floor version: %s, %w", floor.String(), err) } timeFloor := floorR.TimestampCreated.Add(time.Duration(-24) * time.Hour) // We'll do the same for our ceiling but be less restrictive as to allow an infinite ceiling. // If the ceiling versions exists we'll use it as our initial time ceiling, otherwise we'll // set it to the current time. var after time.Time ceilR, err := c.GetRelease(ctx, product, edition, ceil.String()) if err == nil { after = ceilR.TimestampCreated } else { after = time.Now() } versions := []string{} for { select { case <-ctx.Done(): return nil, ctx.Err() default: } slog.Default().DebugContext(ctx, "listing releases", "after", after, "limit", 20) rc := client.New(c.Addr, "", api.V1MimeType) releases, _, err := rc.ListReleases(&client.ListReleasesParams{ Product: product, AfterTimestamp: after, Limit: 20, // 20 is the max limit LicenseClass: string(edition), }) if err != nil { return nil, err } // We've returned all that we can return if len(releases) < 1 { break } // Select any release verions that are within our desired range releaseVersions, err := selectReleaseVersions(releases, ceil, floor) if err != nil { return nil, err } versions = append(versions, releaseVersions...) // Reset our pagination and do another round after = releases[len(releases)-1].TimestampCreated // Short circuit if we've hit our time floor. if timeFloor.After(after) { break } } versions, err = sortVersions(versions) slog.Default().DebugContext(ctx, "found release versions", "versions", versions, "error", err) return versions, err } func sortVersions(in []string) ([]string, error) { if len(in) < 2 { return in, nil } c := semver.Collection{} out := []string{} for _, ver := range in { v, err := semver.NewVersion(ver) if err != nil { return nil, err } c = append(c, v) } sort.Sort(c) for _, v := range c { out = append(out, v.String()) } return out, nil } func selectReleaseVersions(releases []*models.Release, ceil *semver.Version, floor *semver.Version) ([]string, error) { versions := []string{} if len(releases) < 1 { return versions, nil } for _, release := range releases { rv, err := semver.NewVersion(release.Version) if err != nil { return nil, err } if rv.GreaterThan(ceil) { // We've found releases that are too new continue } if rv.LessThan(floor) { // We've found a release after our floor so we can skip it. continue } // We're in-between versions = append(versions, rv.String()) } return versions, nil }