diff --git a/internal/artifacts/artifacts.go b/internal/artifacts/artifacts.go index df895c8..e27256e 100644 --- a/internal/artifacts/artifacts.go +++ b/internal/artifacts/artifacts.go @@ -56,3 +56,6 @@ const ( ) const tmpSuffix = "-tmp" + +// ErrNotFoundTag tags the errors when the artifact is not found. +type ErrNotFoundTag = struct{} diff --git a/internal/artifacts/manager.go b/internal/artifacts/manager.go index 6b730d7..c431d6d 100644 --- a/internal/artifacts/manager.go +++ b/internal/artifacts/manager.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sync" "time" @@ -16,6 +17,7 @@ import ( "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/siderolabs/gen/xerrors" "go.uber.org/zap" "golang.org/x/sync/singleflight" ) @@ -96,6 +98,19 @@ func (m *Manager) Close() error { return os.RemoveAll(m.storagePath) } +func (m *Manager) validateTalosVersion(ctx context.Context, version semver.Version) error { + availableVersion, err := m.GetTalosVersions(ctx) + if err != nil { + return fmt.Errorf("failed to get available Talos versions: %w", err) + } + + if !slices.ContainsFunc(availableVersion, version.Equals) { + return xerrors.NewTaggedf[ErrNotFoundTag]("version %s is not available", version) + } + + return nil +} + // Get returns the artifact path for the given version, arch and kind. func (m *Manager) Get(ctx context.Context, versionString string, arch Arch, kind Kind) (string, error) { version, err := semver.Parse(versionString) @@ -103,8 +118,8 @@ func (m *Manager) Get(ctx context.Context, versionString string, arch Arch, kind return "", fmt.Errorf("failed to parse version: %w", err) } - if version.LT(m.options.MinVersion) { - return "", fmt.Errorf("version %s is not supported, minimum is %s", version, m.options.MinVersion) + if err = m.validateTalosVersion(ctx, version); err != nil { + return "", err } tag := "v" + version.String() @@ -172,8 +187,8 @@ func (m *Manager) GetOfficialExtensions(ctx context.Context, versionString strin return nil, fmt.Errorf("failed to parse version: %w", err) } - if version.LT(m.options.MinVersion) { - return nil, fmt.Errorf("version %s is not supported, minimum is %s", version, m.options.MinVersion) + if err = m.validateTalosVersion(ctx, version); err != nil { + return nil, err } tag := "v" + version.String() @@ -213,8 +228,8 @@ func (m *Manager) GetInstallerImage(ctx context.Context, arch Arch, versionStrin return "", fmt.Errorf("failed to parse version: %w", err) } - if version.LT(m.options.MinVersion) { - return "", fmt.Errorf("version %s is not supported, minimum is %s", version, m.options.MinVersion) + if err = m.validateTalosVersion(ctx, version); err != nil { + return "", err } tag := "v" + version.String() diff --git a/internal/artifacts/versions.go b/internal/artifacts/versions.go index 1a4cec4..39875f6 100644 --- a/internal/artifacts/versions.go +++ b/internal/artifacts/versions.go @@ -17,6 +17,7 @@ import ( "github.com/blang/semver/v4" "github.com/google/go-containerregistry/pkg/name" + "github.com/siderolabs/gen/xslices" "go.uber.org/zap" ) @@ -41,32 +42,41 @@ func (m *Manager) fetchTalosVersions() (any, error) { continue // ignore invalid versions } - if version.LT(m.options.MinVersion) { - continue // ignore versions below minimum - } - - // filter out intermediate versions - if len(version.Pre) > 0 { - if len(version.Pre) != 2 { - continue - } - - if !(version.Pre[0].VersionStr == "alpha" || version.Pre[0].VersionStr == "beta") { - continue - } - - if !version.Pre[1].IsNumeric() { - continue - } - } - versions = append(versions, version) } - slices.SortFunc(versions, func(a, b semver.Version) int { - return a.Compare(b) + // find "current" maximum version + maxVersion := slices.MaxFunc(versions, semver.Version.Compare) + + // allow non-prerelease versions, and allow pre-release for the "latest" release (maxVersion) + versions = xslices.Filter(versions, func(version semver.Version) bool { + if version.LT(m.options.MinVersion) { + return false // ignore versions below minimum + } + + if len(version.Pre) > 0 { + if !(version.Major == maxVersion.Major && version.Minor == maxVersion.Minor) { + return false // ignore pre-releases for older versions + } + + if len(version.Pre) != 2 { + return false + } + + if !(version.Pre[0].VersionStr == "alpha" || version.Pre[0].VersionStr == "beta") { + return false + } + + if !version.Pre[1].IsNumeric() { + return false + } + } + + return true }) + slices.SortFunc(versions, semver.Version.Compare) + m.talosVersionsMu.Lock() m.talosVersions, m.talosVersionsTimestamp = versions, time.Now() m.talosVersionsMu.Unlock() diff --git a/internal/frontend/http/ui.go b/internal/frontend/http/ui.go index 0fc3ae9..9a88780 100644 --- a/internal/frontend/http/ui.go +++ b/internal/frontend/http/ui.go @@ -16,7 +16,6 @@ import ( "github.com/blang/semver/v4" "github.com/julienschmidt/httprouter" - "github.com/siderolabs/gen/xslices" "github.com/siderolabs/image-factory/internal/artifacts" "github.com/siderolabs/image-factory/internal/version" @@ -61,10 +60,6 @@ func (f *Frontend) handleUIVersions(ctx context.Context, w http.ResponseWriter, return err } - versions = xslices.Filter(versions, func(v semver.Version) bool { - return len(v.Pre) == 0 - }) - slices.Reverse(versions) return templates.ExecuteTemplate(w, "versions.html", struct { diff --git a/internal/integration/meta_test.go b/internal/integration/meta_test.go index 37813dd..b87c586 100644 --- a/internal/integration/meta_test.go +++ b/internal/integration/meta_test.go @@ -8,6 +8,7 @@ package integration_test import ( "context" + "net/http" "testing" "github.com/siderolabs/gen/xslices" @@ -17,24 +18,6 @@ import ( "github.com/siderolabs/image-factory/pkg/client" ) -func getVersions(ctx context.Context, t *testing.T, c *client.Client) []string { - t.Helper() - - versions, err := c.Versions(ctx) - require.NoError(t, err) - - return versions -} - -func getExtensions(ctx context.Context, t *testing.T, c *client.Client, talosVersion string) []client.ExtensionInfo { - t.Helper() - - versions, err := c.ExtensionsVersions(ctx, talosVersion) - require.NoError(t, err) - - return versions -} - func testMetaFrontend(ctx context.Context, t *testing.T, baseURL string) { c, err := client.New(baseURL) require.NoError(t, err) @@ -42,7 +25,8 @@ func testMetaFrontend(ctx context.Context, t *testing.T, baseURL string) { t.Run("versions", func(t *testing.T) { t.Parallel() - versions := getVersions(ctx, t, c) + versions, err := c.Versions(ctx) + require.NoError(t, err) assert.Greater(t, len(versions), 10) }) @@ -53,13 +37,15 @@ func testMetaFrontend(ctx context.Context, t *testing.T, baseURL string) { talosVersions := []string{ "v1.5.0", "v1.5.1", + "v1.6.0", } for _, talosVersion := range talosVersions { t.Run(talosVersion, func(t *testing.T) { t.Parallel() - extensions := getExtensions(ctx, t, c, talosVersion) + extensions, err := c.ExtensionsVersions(ctx, talosVersion) + require.NoError(t, err) names := xslices.Map(extensions, func(ext client.ExtensionInfo) string { return ext.Name @@ -70,5 +56,17 @@ func testMetaFrontend(ctx context.Context, t *testing.T, baseURL string) { assert.Contains(t, names, "siderolabs/nvidia-open-gpu-kernel-modules") }) } + + t.Run("invalid version", func(t *testing.T) { + t.Parallel() + + _, err := c.ExtensionsVersions(ctx, "v1.5.0-alpha.0") + require.Error(t, err) + + var httpError *client.HTTPError + require.ErrorAs(t, err, &httpError) + + assert.Equal(t, http.StatusNotFound, httpError.Code) + }) }) } diff --git a/pkg/client/client.go b/pkg/client/client.go index 7389820..08ccfc8 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -14,26 +14,9 @@ import ( "net/http" "net/url" - "github.com/siderolabs/gen/xerrors" - "github.com/siderolabs/image-factory/pkg/schematic" ) -// InvalidSchematicError is parsed from 400 response from the server. -type InvalidSchematicError struct { - Details string -} - -// Error implements error interface. -func (e *InvalidSchematicError) Error() string { - return fmt.Sprintf("invalid schematic: %s", e.Details) -} - -// IsInvalidSchematicError checks if the error is invalid schematic. -func IsInvalidSchematicError(err error) bool { - return xerrors.TypeIs[*InvalidSchematicError](err) -} - // ExtensionInfo defines extensions versions list response item. type ExtensionInfo struct { Name string `json:"name"` @@ -143,20 +126,27 @@ func (c *Client) do(ctx context.Context, method, uri string, requestData []byte, } func (c *Client) checkError(resp *http.Response) error { + const maxErrorBody = 8192 + + if resp.StatusCode < http.StatusBadRequest { + return nil + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody)) + if err != nil { + return err + } + + err = &HTTPError{ + Code: resp.StatusCode, + Message: string(body), + } + if resp.StatusCode == http.StatusBadRequest { - details, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - return &InvalidSchematicError{ - Details: string(details), + e: err, } } - if resp.StatusCode >= http.StatusBadRequest { - return fmt.Errorf("request failed, code %d", resp.StatusCode) - } - - return nil + return err } diff --git a/pkg/client/errors.go b/pkg/client/errors.go new file mode 100644 index 0000000..7e186f5 --- /dev/null +++ b/pkg/client/errors.go @@ -0,0 +1,45 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package client + +import ( + "errors" + "fmt" + + "github.com/siderolabs/gen/xerrors" +) + +// HTTPError is a generic HTTP error wrapper. +type HTTPError struct { + Message string + Code int +} + +// Error implements error interface. +func (e *HTTPError) Error() string { + return fmt.Sprintf("HTTP %d: %s", e.Code, e.Message) +} + +// IsHTTPErrorCode checks if the error is HTTP error with a specific doe. +func IsHTTPErrorCode(err error, code int) bool { + var expected *HTTPError + + return errors.As(err, &expected) && expected.Code == code +} + +// InvalidSchematicError is parsed from 400 response from the server. +type InvalidSchematicError struct { + e error +} + +// Error implements error interface. +func (e *InvalidSchematicError) Error() string { + return fmt.Sprintf("invalid schematic: %s", e.e) +} + +// IsInvalidSchematicError checks if the error is invalid schematic. +func IsInvalidSchematicError(err error) bool { + return xerrors.TypeIs[*InvalidSchematicError](err) +}