diff --git a/cmd/deploy.go b/cmd/deploy.go index 638278a3..f1b9e044 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -279,10 +279,13 @@ func runDeploy(cmd *cobra.Command, args []string) error { return fmt.Errorf("applying config patches from command line argument: %w", err) } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + if deploySettings.Roxie.Version != "" { log.Dimf("Using main image tag %s", deploySettings.Roxie.Version) } else { - mainImageTag, err := helpers.LookupMainImageTag(log) + mainImageTag, err := helpers.LookupMainImageTag(ctx, log) if err != nil { return fmt.Errorf("looking up main image tag: %w", err) } @@ -333,9 +336,6 @@ func runDeploy(cmd *cobra.Command, args []string) error { return nil } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) - defer cancel() - // If we are deploying to a local cluster and the images exist locally, then we transfer them // to the local cluster. if deploySettings.Roxie.ClusterType.IsLocal() && !deploySettings.Roxie.KonfluxImagesEnabled() { diff --git a/internal/constants/registries.go b/internal/constants/registries.go new file mode 100644 index 00000000..8affd99b --- /dev/null +++ b/internal/constants/registries.go @@ -0,0 +1,9 @@ +package constants + +const ( + DefaultRegistry = "quay.io/rhacs-eng" + GitHubStackroxRepo = "stackrox/stackrox" + + EnvRegistryUsername = "REGISTRY_USERNAME" + EnvRegistryPassword = "REGISTRY_PASSWORD" +) diff --git a/internal/deployer/acs_images.go b/internal/deployer/acs_images.go index 0a66c15a..1d2a340c 100644 --- a/internal/deployer/acs_images.go +++ b/internal/deployer/acs_images.go @@ -1,9 +1,9 @@ package deployer -import "fmt" +import ( + "fmt" -const ( - imageRegistry = "quay.io/rhacs-eng" + "github.com/stackrox/roxie/internal/constants" ) func imagesForConfig(config Config) []string { @@ -13,6 +13,7 @@ func imagesForConfig(config Config) []string { prefix = "release-" } + imageRegistry := constants.DefaultRegistry images = append(images, fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "main", config.Roxie.Version)) images = append(images, fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "central-db", config.Roxie.Version)) images = append(images, fmt.Sprintf("%s/%s%s:%s", imageRegistry, prefix, "scanner-v4-db", config.Roxie.Version)) @@ -27,6 +28,7 @@ func imagesForConfig(config Config) []string { } func OperatorBundleImage(config Config) string { + imageRegistry := constants.DefaultRegistry if config.Roxie.KonfluxImagesEnabled() { return fmt.Sprintf("%s/release-operator-bundle:v%s", imageRegistry, config.Operator.Version) } diff --git a/internal/deployer/operator.go b/internal/deployer/operator.go index f309dde2..7f0bba32 100644 --- a/internal/deployer/operator.go +++ b/internal/deployer/operator.go @@ -14,6 +14,7 @@ import ( "gopkg.in/yaml.v3" + "github.com/stackrox/roxie/internal/constants" "github.com/stackrox/roxie/internal/k8s" "github.com/stackrox/roxie/internal/ocihelper" ) @@ -203,7 +204,7 @@ func (d *Deployer) applyImageContentSourcePolicy(ctx context.Context) error { // Define repository digest mirrors as Go data structures rewrite := func(from, to string) map[string]interface{} { source := fmt.Sprintf("registry.redhat.io/advanced-cluster-security/%s", from) - mirror := fmt.Sprintf("quay.io/rhacs-eng/%s", to) + mirror := fmt.Sprintf("%s/%s", constants.DefaultRegistry, to) if d.verbose { d.logger.Dimf("Image rewriting rule: %s -> %s", source, mirror) } diff --git a/internal/deployer/operator_olm.go b/internal/deployer/operator_olm.go index c55c0fca..1dfd6833 100644 --- a/internal/deployer/operator_olm.go +++ b/internal/deployer/operator_olm.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/stackrox/roxie/internal/constants" "github.com/stackrox/roxie/internal/k8s" "gopkg.in/yaml.v3" ) @@ -17,7 +18,7 @@ const ( subscriptionName = "stackrox-operator-subscription" operatorGroupName = "all-namespaces-operator-group" operatorChannel = "latest" - operatorIndexImage = "quay.io/rhacs-eng/stackrox-operator-index" + operatorIndexImage = constants.DefaultRegistry + "/stackrox-operator-index" namespacedSubscriptionName = operatorNamespace + "/" + subscriptionName ) diff --git a/internal/dockerauth/dockerauth.go b/internal/dockerauth/dockerauth.go index f6f0284d..fde6bd07 100644 --- a/internal/dockerauth/dockerauth.go +++ b/internal/dockerauth/dockerauth.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" + "github.com/stackrox/roxie/internal/constants" "github.com/stackrox/roxie/internal/logger" ) @@ -61,14 +62,14 @@ func (d *DockerAuth) GetAndVerifyCredentials() (*Credentials, error) { var username, password string // Try environment variables first. - username = os.Getenv("REGISTRY_USERNAME") - password = os.Getenv("REGISTRY_PASSWORD") + username = os.Getenv(constants.EnvRegistryUsername) + password = os.Getenv(constants.EnvRegistryPassword) if username != "" && password == "" { - return nil, errors.New("REGISTRY_USERNAME set but REGISTRY_PASSWORD is empty") + return nil, fmt.Errorf("%s set but %s is empty", constants.EnvRegistryUsername, constants.EnvRegistryPassword) } if username == "" && password != "" { - return nil, errors.New("REGISTRY_PASSWORD set but REGISTRY_USERNAME is empty") + return nil, fmt.Errorf("%s set but %s is empty", constants.EnvRegistryPassword, constants.EnvRegistryUsername) } if username == "" { diff --git a/internal/helpers/tag.go b/internal/helpers/tag.go index 39a3cbb3..c7b8f692 100644 --- a/internal/helpers/tag.go +++ b/internal/helpers/tag.go @@ -1,19 +1,22 @@ package helpers import ( + "context" + "errors" + "fmt" + "net/http" "os" "strings" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" + "github.com/stackrox/roxie/internal/constants" "github.com/stackrox/roxie/internal/env" "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/ocihelper" + "github.com/stackrox/roxie/internal/stackroxversions" ) -const ( - // TODO(#91): Is the plan to keep bumping this on new ACS releases? - defaultMainImageTag = "4.9.2" -) - -func LookupMainImageTag(log *logger.Logger) (string, error) { +func LookupMainImageTag(ctx context.Context, log *logger.Logger) (string, error) { log.Info("Looking up main image tag") if tag := os.Getenv("MAIN_IMAGE_TAG"); tag != "" { log.Dimf("Using MAIN_IMAGE_TAG from environment: %s", tag) @@ -29,11 +32,41 @@ func LookupMainImageTag(log *logger.Logger) (string, error) { return tag, nil } - log.Warningf("No MAIN_IMAGE_TAG found in the environment, using default main image tag %s for deployment", defaultMainImageTag) + log.Warningf("No MAIN_IMAGE_TAG found in the environment, looking up latest release tag on registry") log.Warning("To use a different tag, set the MAIN_IMAGE_TAG environment variable") log.Warning("Alternatively, execute roxie from within the stackrox repository, in which case the currently checked out stackrox tag will be used") - return defaultMainImageTag, nil + latestTag, err := LookupLatestTag(ctx, log) + if err != nil { + return "", fmt.Errorf("looking up latest release tag: %w", err) + } + + return latestTag, nil +} + +// Computes the latest image tag for a pullable, released main image. +func LookupLatestTag(ctx context.Context, log *logger.Logger) (string, error) { + const atMost = 5 + + tags, err := stackroxversions.LookupLatestReleaseTagsViaGitHub(ctx, atMost) + if err != nil { + return "", fmt.Errorf("looking up latest release tags: %w", err) + } + + // Verify we have a pullable main image. + for _, tag := range tags { + mainImage := fmt.Sprintf("%s/main:%s", constants.DefaultRegistry, tag) + if err := ocihelper.VerifyImageExistence(ctx, log, mainImage); err != nil { + var te *transport.Error + if errors.As(err, &te) && te.StatusCode == http.StatusNotFound { + continue + } + return "", fmt.Errorf("verifying image %s: %w", mainImage, err) + } + return tag, nil + } + + return "", fmt.Errorf("failed to verify main image existence for tags %s", strings.Join(tags, ", ")) } func ConvertMainTagToOperatorTag(mainTag string) string { diff --git a/internal/helpers/tag_integration_test.go b/internal/helpers/tag_integration_test.go new file mode 100644 index 00000000..990ddcb2 --- /dev/null +++ b/internal/helpers/tag_integration_test.go @@ -0,0 +1,54 @@ +//go:build integration + +package helpers + +import ( + "context" + "errors" + "fmt" + "net/http" + "testing" + "time" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" + "github.com/stackrox/roxie/internal/constants" + "github.com/stackrox/roxie/internal/logger" + "github.com/stackrox/roxie/internal/ocihelper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLookupLatestTag_Integration(t *testing.T) { + log := logger.New() + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + + tag, err := LookupLatestTag(ctx, log) + require.NoError(t, err) + require.NotEmpty(t, tag) + + imageRef := fmt.Sprintf("%s/main:%s", constants.DefaultRegistry, tag) + ref, err := name.ParseReference(imageRef) + require.NoError(t, err) + + _, err = remote.Head(ref, remote.WithContext(ctx), remote.WithAuthFromKeychain(ocihelper.Keychain)) + require.NoError(t, err, "image %s is not pullable", imageRef) + + t.Logf("Latest pullable tag: %s (%s)", tag, imageRef) +} + +func TestVerifyImageExistence_NotFound_Integration(t *testing.T) { + log := logger.New() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + + madeUpImage := fmt.Sprintf("%s/main:99.99.99", constants.DefaultRegistry) + err := ocihelper.VerifyImageExistence(ctx, log, madeUpImage) + require.Error(t, err) + + var te *transport.Error + require.True(t, errors.As(err, &te), "expected transport.Error, got %T", err) + assert.Equal(t, http.StatusNotFound, te.StatusCode) +} diff --git a/internal/ocihelper/keychain.go b/internal/ocihelper/keychain.go new file mode 100644 index 00000000..c54f5101 --- /dev/null +++ b/internal/ocihelper/keychain.go @@ -0,0 +1,27 @@ +package ocihelper + +import ( + "os" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/stackrox/roxie/internal/constants" +) + +// Keychain resolves registry credentials by checking REGISTRY_USERNAME/REGISTRY_PASSWORD +// environment variables first, then falling back to the default Docker keychain +// (~/.docker/config.json, credential helpers, etc.). +var Keychain = authn.NewMultiKeychain(&envKeychain{}, authn.DefaultKeychain) + +type envKeychain struct{} + +func (e *envKeychain) Resolve(target authn.Resource) (authn.Authenticator, error) { + username := os.Getenv(constants.EnvRegistryUsername) + password := os.Getenv(constants.EnvRegistryPassword) + if username == "" || password == "" { + return authn.Anonymous, nil + } + return authn.FromConfig(authn.AuthConfig{ + Username: username, + Password: password, + }), nil +} diff --git a/internal/ocihelper/ocihelper.go b/internal/ocihelper/ocihelper.go index f2ab93ad..81856bab 100644 --- a/internal/ocihelper/ocihelper.go +++ b/internal/ocihelper/ocihelper.go @@ -5,10 +5,10 @@ import ( "context" "fmt" "io" + "net/http" "os" "path/filepath" - "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/daemon" @@ -28,10 +28,10 @@ func VerifyImageExistence(ctx context.Context, log *logger.Logger, imageRef stri return fmt.Errorf("invalid image reference: %w", err) } - // Use HEAD request to verify image exists without downloading _, err = remote.Head(ref, remote.WithContext(ctx), - remote.WithAuthFromKeychain(authn.DefaultKeychain)) + remote.WithAuthFromKeychain(Keychain), + remote.WithRetryStatusCodes(http.StatusTooManyRequests, http.StatusServiceUnavailable)) if err != nil { return fmt.Errorf("image inspection failed: %w", err) } @@ -100,7 +100,7 @@ func assureImageExistsLocally(ctx context.Context, log *logger.Logger, imageRef, img, err = remote.Image(ref, remote.WithContext(ctx), - remote.WithAuthFromKeychain(authn.DefaultKeychain), + remote.WithAuthFromKeychain(Keychain), remote.WithPlatform(platform)) if err != nil { return nil, fmt.Errorf("failed to fetch image: %w", err) diff --git a/internal/stackroxversions/release_tag.go b/internal/stackroxversions/release_tag.go new file mode 100644 index 00000000..5e452c27 --- /dev/null +++ b/internal/stackroxversions/release_tag.go @@ -0,0 +1,91 @@ +package stackroxversions + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sort" + + "github.com/Masterminds/semver/v3" + "github.com/stackrox/roxie/internal/constants" +) + +const ( + gitHubAPIBase = "https://api.github.com/repos/" + constants.GitHubStackroxRepo +) + +type ghRelease struct { + TagName string `json:"tag_name"` +} + +// LookupLatestReleaseTagsViaGitHub queries the GitHub releases API for stackrox/stackrox +// and attempts to return the newest atMost stable release tags (e.g. ["4.11.0", "4.10.4", "4.9.8"]), +// sorted by descending semver. Pre-release and RC tags are excluded. +// It is not guaranteed that the function returns exactly atMost tags, though this should be +// the case for reasonable values of atMost (0 < atMost < 10). +// The code is deliberately kept simple, i.e. it is not using pagination, which would +// smell like over-engineering for the use-case we need this for. +func LookupLatestReleaseTagsViaGitHub(ctx context.Context, atMost int) ([]string, error) { + if atMost <= 0 { + return nil, fmt.Errorf("atMost must be positive, got %d", atMost) + } + + tags, err := fetchLatestGitHubReleases(ctx, atMost) + if err != nil { + return nil, err + } + + var versions semver.Collection + for _, tag := range tags { + version, err := semver.NewVersion(tag) + if err != nil || version.Prerelease() != "" { + continue + } + versions = append(versions, version) + } + sort.Sort(sort.Reverse(versions)) + + n := min(atMost, len(versions)) + if n == 0 { + return nil, errors.New("failed to obtain any release tags parsing as semantic versions") + } + + sortedReleaseTags := make([]string, n) + for i := range n { + sortedReleaseTags[i] = versions[i].Original() + } + + return sortedReleaseTags, nil +} + +func fetchLatestGitHubReleases(ctx context.Context, atMost int) ([]string, error) { + releasesToFetch := min(100, atMost*10) // Reasonable estimate for how many tags we intend to fetch. + url := fmt.Sprintf("%s?per_page=%d", gitHubAPIBase+"/releases", releasesToFetch) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching releases: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + var releasesResponse []ghRelease + if err := json.NewDecoder(resp.Body).Decode(&releasesResponse); err != nil { + return nil, fmt.Errorf("decoding releases response: %w", err) + } + + // Convert to string slice. + tags := make([]string, len(releasesResponse)) + for i, release := range releasesResponse { + tags[i] = release.TagName + } + return tags, nil +} diff --git a/internal/stackroxversions/release_tag_integration_test.go b/internal/stackroxversions/release_tag_integration_test.go new file mode 100644 index 00000000..c54a5511 --- /dev/null +++ b/internal/stackroxversions/release_tag_integration_test.go @@ -0,0 +1,26 @@ +//go:build integration + +package stackroxversions + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLookupLatestReleaseTags_Integration(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + + tags, err := LookupLatestReleaseTagsViaGitHub(ctx, 3) + require.NoError(t, err) + assert.NotEmpty(t, tags, "no tags") + + t.Log("Latest release tags") + for i, tag := range tags { + t.Logf("%v. %s", i+1, tag) + } +} diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index fa68225b..1f9434a5 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -3,9 +3,14 @@ package e2e import ( + "context" "fmt" "os" "testing" + "time" + + "github.com/stackrox/roxie/internal/helpers" + "github.com/stackrox/roxie/internal/logger" ) func TestMain(m *testing.M) { @@ -15,9 +20,14 @@ func TestMain(m *testing.M) { os.Exit(1) } - // Set default MAIN_IMAGE_TAG if not set + // Use the most recent released ACS version if MAIN_IMAGE_TAG is not set. if os.Getenv("MAIN_IMAGE_TAG") == "" { - os.Setenv("MAIN_IMAGE_TAG", defaultMainImageTag) + mainImageTag, err := lookupLatestTag() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to lookup latest tag: %v\n", err) + os.Exit(1) + } + os.Setenv("MAIN_IMAGE_TAG", mainImageTag) } // Verify kubectl context @@ -38,6 +48,17 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } +func lookupLatestTag() (string, error) { + log := logger.New() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + tag, err := helpers.LookupLatestTag(ctx, log) + if err != nil { + return "", err + } + return tag, nil +} + func TestDeployBothComponentsTogetherInSingleNamespace(t *testing.T) { dumpClusterStateOnFailure(t) diff --git a/tests/e2e/helpers.go b/tests/e2e/helpers.go index d7489dd3..a4471704 100644 --- a/tests/e2e/helpers.go +++ b/tests/e2e/helpers.go @@ -21,11 +21,8 @@ import ( ) const ( - // TODO(#91): We should come up with some auto-updating of this on ACS releases. - // Don't think we should directly inject nightlies here. - defaultMainImageTag = "4.10.1" - deployTimeout = 30 * time.Minute - teardownTimeout = 10 * time.Minute + deployTimeout = 30 * time.Minute + teardownTimeout = 10 * time.Minute ) var (