dev-tools/mage/downloads/releases.go (384 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License 2.0;
// you may not use this file except in compliance with the Elastic License 2.0.
package downloads
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"github.com/Jeffail/gabs/v2"
"github.com/cenkalti/backoff/v4"
)
// DownloadURLResolver interface to resolve URLs for downloadable artifacts
type DownloadURLResolver interface {
Resolve() (url string, shaURL string, err error)
Kind() string
}
// ArtifactURLResolver type to resolve the URL of artifacts that are currently in development, from the artifacts API
type ArtifactURLResolver struct {
FullName string
Name string
Version string
}
// NewArtifactURLResolver creates a new resolver for artifacts that are currently in development, from the artifacts API
func NewArtifactURLResolver(fullName string, name string, version string) DownloadURLResolver {
return &ArtifactURLResolver{
FullName: fullName,
Name: name,
Version: version,
}
}
func (r *ArtifactURLResolver) Kind() string {
return fmt.Sprintf("Unified snapshot resolver: %s", r.FullName)
}
// Resolve returns the URL of a released artifact, which its full name is defined in the first argument,
// from Elastic's artifact repository, building the JSON path query based on the full name
func (r *ArtifactURLResolver) Resolve() (string, string, error) {
resolvedVersion, err := GetElasticArtifactVersion(r.Version)
if err != nil {
return "", "", fmt.Errorf("failed to get version %s: %w", r.Version, err)
}
r.Version = resolvedVersion
fullName := strings.ReplaceAll(r.FullName, r.Version, resolvedVersion)
r.FullName = fullName
artifactName := r.FullName
artifact := r.Name
version := r.Version
exp := getExponentialBackoff(time.Minute)
retryCount := 1
body := []byte{}
tmpVersion := version
hasCommit := SnapshotHasCommit(version)
if hasCommit {
logger.Log(context.Background(), TraceLevel, "Removing SNAPSHOT from version including commit",
slog.String("resolver", r.Kind()),
slog.String("version", version),
)
// remove the SNAPSHOT from the VERSION as the artifacts API supports commits in the version, but without the snapshot suffix
tmpVersion = GetCommitVersion(version)
}
apiStatus := func() error {
url := fmt.Sprintf("https://artifacts-api.elastic.co/v1/search/%s/%s?x-elastic-no-kpi=true", tmpVersion, artifact)
req := httpRequest{
URL: url,
}
bodyStr, err := get(req)
if err != nil {
logger.Warn("Resolver failed",
slog.String("kind", r.Kind()),
slog.String("artifact", artifact),
slog.String("artifactName", artifactName),
slog.String("version", tmpVersion),
slog.String("error", err.Error()),
slog.Int("retry", retryCount),
slog.String("statusEndpoint", url),
slog.Duration("elapsedTime", exp.GetElapsedTime()),
slog.String("resp", bodyStr),
)
retryCount++
return err
}
body = []byte(bodyStr)
return nil
}
err = backoff.Retry(apiStatus, exp)
if err != nil {
logger.Error("Failed to get artifact",
slog.String("resolver", r.Kind()),
slog.String("artifact", artifact),
slog.String("artifactName", artifactName),
slog.String("version", tmpVersion),
)
return "", "", err
}
jsonParsed, err := gabs.ParseJSON(body)
if err != nil {
logger.Error("Could not parse the response body for the artifact",
slog.String("resolver", r.Kind()),
slog.String("artifact", artifact),
slog.String("artifactName", artifactName),
slog.String("version", tmpVersion),
)
return "", "", err
}
logger.Log(context.Background(), TraceLevel, "Resolver succeeded",
slog.String("resolver", r.Kind()),
slog.Int("retries", retryCount),
slog.String("artifact", artifact),
slog.String("artifactName", artifactName),
slog.Duration("elapsedTime", exp.GetElapsedTime()),
slog.String("version", tmpVersion),
)
if hasCommit {
// remove commit from the artifact as it comes like this: elastic-agent-8.0.0-abcdef-SNAPSHOT-darwin-x86_64.tar.gz
artifactName = RemoveCommitFromSnapshot(artifactName)
}
packagesObject := jsonParsed.Path("packages")
// we need to get keys with dots using Search instead of Path
downloadObject := packagesObject.Search(artifactName)
if downloadObject == nil {
logger.Error("ArtifactURLResolver object not found in Artifact API",
slog.String("artifact", artifact),
slog.String("name", artifactName),
slog.String("version", version),
)
return "", "", fmt.Errorf("object not found in Artifact API")
}
downloadURL, ok := downloadObject.Path("url").Data().(string)
if !ok {
return "", "", fmt.Errorf("key 'url' does not exist for artifact %s", artifact)
}
downloadshaURL, ok := downloadObject.Path("sha_url").Data().(string)
if !ok {
return "", "", fmt.Errorf("key 'sha_url' does not exist for artifact %s", artifact)
}
return downloadURL, downloadshaURL, nil
}
type ArtifactsSnapshotVersion struct {
Host string
}
func newArtifactsSnapshotCustom(host string) *ArtifactsSnapshotVersion {
return &ArtifactsSnapshotVersion{
Host: host,
}
}
// GetSnapshotArtifactVersion returns the current version:
// Uses artifacts-snapshot.elastic.co to retrieve the latest version of a SNAPSHOT artifact
// 1. Elastic's artifact repository, building the JSON path query based
// If the version is a SNAPSHOT including a commit, then it will directly use the version without checking the artifacts API
// i.e. GetSnapshotArtifactVersion("$VERSION-abcdef-SNAPSHOT")
func (as *ArtifactsSnapshotVersion) GetSnapshotArtifactVersion(project string, version string) (string, error) {
cacheKey := fmt.Sprintf("%s/%s/latest/%s.json", as.Host, project, version)
elasticVersionsMutex.RLock()
val, ok := elasticVersionsCache[cacheKey]
elasticVersionsMutex.RUnlock()
if ok {
logger.Debug("ArtifactsSnapshotVersion Retrieving version from local cache",
slog.String("URL", cacheKey),
slog.String("version", val),
)
return val, nil
}
if SnapshotHasCommit(version) {
elasticVersionsMutex.Lock()
elasticVersionsCache[cacheKey] = version
elasticVersionsMutex.Unlock()
return version, nil
}
exp := getExponentialBackoff(time.Minute)
retryCount := 1
body := []byte{}
apiStatus := func() error {
url := cacheKey
r := httpRequest{
URL: url,
}
bodyStr, err := get(r)
if err != nil {
logger.Warn("ArtifactsSnapshotVersion failed",
slog.String("version", version),
slog.String("error", err.Error()),
slog.Int("retry", retryCount),
slog.String("statusEndpoint", url),
slog.Duration("elapsedTime", exp.GetElapsedTime()),
slog.String("resp", bodyStr),
)
retryCount++
return err
}
body = []byte(bodyStr)
return nil
}
err := backoff.Retry(apiStatus, exp)
if err != nil {
return "", err
}
type ArtifactsSnapshotResponse struct {
Version string `json:"version"` // example value: "8.8.3-SNAPSHOT"
BuildID string `json:"build_id"` // example value: "8.8.3-b1d8691a"
ManifestURL string `json:"manifest_url"` // example value: https://artifacts-snapshot.elastic.co/beats/8.8.3-b1d8691a/manifest-8.8.3-SNAPSHOT.json
SummaryURL string `json:"summary_url"` // example value: https://artifacts-snapshot.elastic.co/beats/8.8.3-b1d8691a/summary-8.8.3-SNAPSHOT.html
}
response := ArtifactsSnapshotResponse{}
err = json.Unmarshal(body, &response)
if err != nil {
logger.Error("ArtifactsSnapshotVersion Could not parse the response body to retrieve the version",
slog.String("error", err.Error()),
slog.String("version", version),
slog.String("body", string(body)),
)
return "", fmt.Errorf("could not parse the response body to retrieve the version: %w", err)
}
hashParts := strings.Split(response.BuildID, "-")
if (len(hashParts) < 2) || (hashParts[1] == "") {
logger.Error("ArtifactsSnapshotVersion Could not parse the build_id to retrieve the version hash", slog.String("buildId", response.BuildID))
return "", fmt.Errorf("could not parse the build_id to retrieve the version hash: %s", response.BuildID)
}
hash := hashParts[1]
parsedVersion := hashParts[0]
latestVersion := fmt.Sprintf("%s-%s-SNAPSHOT", parsedVersion, hash)
logger.Debug("ArtifactsSnapshotVersion got latest version for current version",
slog.String("alias", version),
slog.String("version", latestVersion),
)
elasticVersionsMutex.Lock()
elasticVersionsCache[cacheKey] = latestVersion
elasticVersionsMutex.Unlock()
return latestVersion, nil
}
// NewArtifactSnapshotURLResolver creates a new resolver for artifacts that are currently in development, from the artifacts API
func NewArtifactSnapshotURLResolver(fullName string, name string, project string, version string) DownloadURLResolver {
return newCustomSnapshotURLResolver(fullName, name, project, version, "https://artifacts-snapshot.elastic.co")
}
// For testing purposes
func newCustomSnapshotURLResolver(fullName string, name string, project string, version string, host string) DownloadURLResolver {
// resolve version alias
resolvedVersion, err := newArtifactsSnapshotCustom(host).GetSnapshotArtifactVersion(project, version)
if err != nil {
return nil
}
return &ArtifactsSnapshotURLResolver{
FullName: fullName,
Name: name,
Project: project,
Version: resolvedVersion,
SnapshotApiHost: host,
}
}
// ArtifactsSnapshotURLResolver type to resolve the URL of artifacts that are currently in development, from the artifacts API
// Takes the artifacts staged for inclusion in the next unified snapshot, before one is available.
type ArtifactsSnapshotURLResolver struct {
FullName string
Name string
Version string
Project string
SnapshotApiHost string
}
func (asur *ArtifactsSnapshotURLResolver) Kind() string {
return fmt.Sprintf("Project snapshot resolver: %s", asur.FullName)
}
func (asur *ArtifactsSnapshotURLResolver) Resolve() (string, string, error) {
artifactName := asur.FullName
artifact := asur.Name
version := asur.Version
commit, err := ExtractCommitHash(version)
semVer := GetVersion(version)
if err != nil {
logger.Info("ArtifactsSnapshotURLResolver version does not contain a commit hash, it is not a snapshot",
slog.String("artifact", artifact),
slog.String("artifactName", artifactName),
slog.String("project", asur.Project),
slog.String("version", version),
)
return "", "", err
}
exp := getExponentialBackoff(time.Minute)
retryCount := 1
body := []byte{}
apiStatus := func() error {
// https://artifacts-snapshot.elastic.co/beats/8.9.0-d1b14479/manifest-8.9.0-SNAPSHOT.json
url := fmt.Sprintf("%s/%s/%s-%s/manifest-%s-SNAPSHOT.json", asur.SnapshotApiHost, asur.Project, semVer, commit, semVer)
r := httpRequest{URL: url}
bodyStr, err := get(r)
if err != nil {
logger.Warn("resolver failed",
slog.String("kind", asur.Kind()),
slog.String("artifact", artifact),
slog.String("artifactName", artifactName),
slog.String("version", version),
slog.String("error", err.Error()),
slog.Int("retry", retryCount),
slog.String("statusEndpoint", url),
slog.Duration("elapsedTime", exp.GetElapsedTime()),
slog.String("resp", bodyStr),
)
retryCount++
return err
}
body = []byte(bodyStr)
return nil
}
err = backoff.Retry(apiStatus, exp)
if err != nil {
return "", "", err
}
var jsonParsed map[string]interface{}
err = json.Unmarshal(body, &jsonParsed)
if err != nil {
logger.Error("Could not parse the response body for the artifact",
slog.String("kind", asur.Kind()),
slog.String("artifact", artifact),
slog.String("artifactName", artifactName),
slog.String("project", asur.Project),
slog.String("version", version),
)
return "", "", err
}
url, shaURL, err := findSnapshotPackage(jsonParsed, artifactName)
if err != nil {
return "", "", err
}
logger.Log(context.Background(), TraceLevel, "Resolver succeeded",
slog.String("kind", asur.Kind()),
slog.Int("retries", retryCount),
slog.String("artifact", artifact),
slog.String("artifactName", artifactName),
slog.Duration("elapsedTime", exp.GetElapsedTime()),
slog.String("project", asur.Project),
slog.String("version", version),
)
return url, shaURL, nil
}
func findSnapshotPackage(jsonParsed map[string]interface{}, fullName string) (string, string, error) {
projects, ok := jsonParsed["projects"].(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("key 'projects' does not exist")
}
for _, project := range projects {
projectPackages, ok := project.(map[string]interface{})["packages"].(map[string]interface{})
if !ok {
continue
}
pack, ok := projectPackages[fullName].(map[string]interface{})
if !ok {
continue
}
return pack["url"].(string), pack["sha_url"].(string), nil
}
return "", "", fmt.Errorf("package %s not found", fullName)
}
// ReleaseURLResolver type to resolve the URL of downloads that are currently published in elastic.co/downloads
type ReleaseURLResolver struct {
Project string
FullName string
Name string
}
// NewReleaseURLResolver creates a new resolver for downloads that are currently published in elastic.co/downloads
func NewReleaseURLResolver(project string, fullName string, name string) *ReleaseURLResolver {
return &ReleaseURLResolver{
FullName: fullName,
Name: name,
Project: project,
}
}
func (r *ReleaseURLResolver) Kind() string {
return fmt.Sprintf("Official release resolver: %s", r.FullName)
}
// Resolve resolves the URL of a download, which is located in the Elastic. It will use a HEAD request
// and if it returns a 200 OK it will return the URL of both file and its SHA512 file
func (r *ReleaseURLResolver) Resolve() (string, string, error) {
url := fmt.Sprintf("https://artifacts.elastic.co/downloads/%s/%s/%s", r.Project, r.Name, r.FullName)
shaURL := fmt.Sprintf("%s.sha512", url)
exp := getExponentialBackoff(time.Minute)
retryCount := 1
found := false
apiStatus := func() error {
req := httpRequest{URL: url}
bodyStr, err := head(req)
if err != nil {
logger.Debug("Resolver failed",
slog.String("kind", r.Kind()),
slog.String("error", err.Error()),
slog.Int("retry", retryCount),
slog.String("statusEndpoint", url),
slog.Duration("elapsedTime", exp.GetElapsedTime()),
slog.String("resp", bodyStr),
)
retryCount++
return err
}
found = true
logger.Info("Download was found in the Elastic downloads API",
slog.String("kind", r.Kind()),
slog.Int("retries", retryCount),
slog.String("statusEndpoint", url),
slog.Duration("elapsedTime", exp.GetElapsedTime()),
)
return nil
}
err := backoff.Retry(apiStatus, exp)
if err != nil {
return "", "", err
}
if !found {
return "", "", fmt.Errorf("download could not be found at the Elastic downloads API")
}
return url, shaURL, nil
}