diff --git a/cmd/root.go b/cmd/root.go index dde76d25..9330d357 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -205,7 +205,8 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t }) } update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken}) - return container.Start(ctx, rt, sink, opts, false) + _, err = container.Start(ctx, rt, sink, opts, false) + return err } // instrumentCommands walks the Cobra command tree and wraps every RunE with telemetry emission. diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 26f9826d..ae3e05ba 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -63,7 +63,8 @@ func newSnapshotCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cob func buildStarter(cfg *env.Env, rt runtime.Runtime, appConfig *config.Config, logger log.Logger, tel *telemetry.Client) snapshot.Starter { return func(ctx context.Context, sink output.Sink) error { opts := buildStartOptions(cfg, appConfig, logger, tel, false) - return container.Start(ctx, rt, sink, opts, false) + _, err := container.Start(ctx, rt, sink, opts, false) + return err } } diff --git a/internal/api/catalog_version_test.go b/internal/api/catalog_version_test.go deleted file mode 100644 index 63f32e7a..00000000 --- a/internal/api/catalog_version_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/localstack/lstk/internal/log" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetLatestCatalogVersion_Success(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/v1/license/catalog/aws/version", r.URL.Path) - assert.Equal(t, http.MethodGet, r.Method) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "emulator_type": "aws", - "version": "4.14.0", - }) - })) - defer srv.Close() - - client := NewPlatformClient(srv.URL, log.Nop()) - version, err := client.GetLatestCatalogVersion(context.Background(), "aws") - - require.NoError(t, err) - assert.Equal(t, "4.14.0", version) -} - -func TestGetLatestCatalogVersion_NonOKStatus(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer srv.Close() - - client := NewPlatformClient(srv.URL, log.Nop()) - _, err := client.GetLatestCatalogVersion(context.Background(), "aws") - - require.Error(t, err) - assert.Contains(t, err.Error(), "status 503") -} - -func TestGetLatestCatalogVersion_EmptyVersion(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "emulator_type": "aws", - "version": "", - }) - })) - defer srv.Close() - - client := NewPlatformClient(srv.URL, log.Nop()) - _, err := client.GetLatestCatalogVersion(context.Background(), "aws") - - require.Error(t, err) - assert.Contains(t, err.Error(), "incomplete catalog response") -} - -func TestGetLatestCatalogVersion_MissingEmulatorType(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "version": "4.14.0", - }) - })) - defer srv.Close() - - client := NewPlatformClient(srv.URL, log.Nop()) - _, err := client.GetLatestCatalogVersion(context.Background(), "aws") - - require.Error(t, err) - assert.Contains(t, err.Error(), "incomplete catalog response") -} - -func TestGetLatestCatalogVersion_MismatchedEmulatorType(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "emulator_type": "azure", - "version": "4.14.0", - }) - })) - defer srv.Close() - - client := NewPlatformClient(srv.URL, log.Nop()) - _, err := client.GetLatestCatalogVersion(context.Background(), "aws") - - require.Error(t, err) - assert.Contains(t, err.Error(), "unexpected emulator_type") -} - -func TestGetLatestCatalogVersion_Timeout(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - <-r.Context().Done() - })) - defer srv.Close() - - client := NewPlatformClient(srv.URL, log.Nop()) - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - _, err := client.GetLatestCatalogVersion(ctx, "aws") - - require.Error(t, err) -} - -func TestGetLatestCatalogVersion_ServerDown(t *testing.T) { - client := NewPlatformClient("http://127.0.0.1:1", log.Nop()) - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - - _, err := client.GetLatestCatalogVersion(ctx, "aws") - - require.Error(t, err) -} diff --git a/internal/api/client.go b/internal/api/client.go index fa797fdc..5a6ceb82 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strings" "time" @@ -25,7 +24,6 @@ type PlatformAPI interface { ExchangeAuthRequest(ctx context.Context, id, exchangeToken string) (string, error) GetLicenseToken(ctx context.Context, bearerToken string) (string, error) GetLicense(ctx context.Context, req *LicenseRequest) (*LicenseResponse, error) - GetLatestCatalogVersion(ctx context.Context, emulatorType string) (string, error) } type AuthRequest struct { @@ -341,44 +339,3 @@ func (c *PlatformClient) GetLicense(ctx context.Context, licReq *LicenseRequest) } } -type catalogVersionResponse struct { - EmulatorType string `json:"emulator_type"` - Version string `json:"version"` -} - -func (c *PlatformClient) GetLatestCatalogVersion(ctx context.Context, emulatorType string) (string, error) { - reqURL := fmt.Sprintf("%s/v1/license/catalog/%s/version", c.baseURL, url.PathEscape(emulatorType)) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to get catalog version: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - c.logger.Error("failed to close response body: %v", err) - } - }() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to get catalog version: status %d", resp.StatusCode) - } - - var versionResp catalogVersionResponse - if err := json.NewDecoder(resp.Body).Decode(&versionResp); err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) - } - - if versionResp.EmulatorType == "" || versionResp.Version == "" { - return "", fmt.Errorf("incomplete catalog response: emulator_type=%q version=%q", versionResp.EmulatorType, versionResp.Version) - } - - if versionResp.EmulatorType != emulatorType { - return "", fmt.Errorf("unexpected emulator_type: got=%q want=%q", versionResp.EmulatorType, emulatorType) - } - - return versionResp.Version, nil -} diff --git a/internal/container/label.go b/internal/container/label.go index 8be1622b..acde5357 100644 --- a/internal/container/label.go +++ b/internal/container/label.go @@ -11,8 +11,11 @@ import ( "github.com/localstack/lstk/internal/log" ) -func ResolveAndCacheLabel(ctx context.Context, opts StartOptions, labelCh chan<- string) { - label, ok := ResolveEmulatorLabel(ctx, opts.PlatformClient, opts.Containers, opts.AuthToken, opts.Logger) +// ResolveAndCacheLabel resolves the plan label using the version returned by Start +// and caches it for subsequent runs. resolvedVersion is the version extracted from +// image inspection; it may be empty if Start returned early (e.g. already running). +func ResolveAndCacheLabel(ctx context.Context, opts StartOptions, resolvedVersion string, labelCh chan<- string) { + label, ok := ResolveEmulatorLabel(ctx, opts.PlatformClient, opts.Containers, opts.AuthToken, resolvedVersion, opts.Logger) if ok { config.CachePlanLabel(label) } @@ -25,7 +28,8 @@ const NoLicenseLabel = "LocalStack (No license)" // to build a label like "LocalStack Ultimate". Falls back to // NoLicenseLabel when the plan cannot be determined. The returned bool // is true only when a real plan was resolved (i.e. the result is worth caching). -func ResolveEmulatorLabel(ctx context.Context, client api.PlatformAPI, containers []config.ContainerConfig, token string, logger log.Logger) (string, bool) { +// resolvedVersion is the version from post-pull image inspection for "latest" containers. +func ResolveEmulatorLabel(ctx context.Context, client api.PlatformAPI, containers []config.ContainerConfig, token, resolvedVersion string, logger log.Logger) (string, bool) { if len(containers) == 0 || token == "" { return NoLicenseLabel, false } @@ -42,14 +46,10 @@ func ResolveEmulatorLabel(ctx context.Context, client api.PlatformAPI, container if c.Type == config.EmulatorSnowflake { return "LocalStack", false } - apiCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - v, err := client.GetLatestCatalogVersion(apiCtx, string(c.Type)) - cancel() - if err != nil { - logger.Info("could not resolve catalog version for header: %v", err) + if resolvedVersion == "" { return NoLicenseLabel, false } - tag = v + tag = resolvedVersion } hostname, _ := os.Hostname() diff --git a/internal/container/restart.go b/internal/container/restart.go index d7fcd870..afb5a606 100644 --- a/internal/container/restart.go +++ b/internal/container/restart.go @@ -11,5 +11,6 @@ func Restart(ctx context.Context, rt runtime.Runtime, sink output.Sink, stopOpts if err := Stop(ctx, rt, sink, startOpts.Containers, stopOpts); err != nil { return err } - return Start(ctx, rt, sink, startOpts, interactive) + _, err := Start(ctx, rt, sink, startOpts, interactive) + return err } diff --git a/internal/container/start.go b/internal/container/start.go index 83b93388..5938ab19 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -46,21 +46,21 @@ type StartOptions struct { Telemetry *telemetry.Client } -func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool) error { +func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool) (string, error) { if err := rt.IsHealthy(ctx); err != nil { rt.EmitUnhealthyError(sink, err) - return output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) + return "", output.NewSilentError(fmt.Errorf("runtime not healthy: %w", err)) } tokenStorage, err := auth.NewTokenStorage(opts.ForceFileKeyring, opts.Logger) if err != nil { - return fmt.Errorf("failed to initialize token storage: %w", err) + return "", fmt.Errorf("failed to initialize token storage: %w", err) } a := auth.New(sink, opts.PlatformClient, tokenStorage, opts.AuthToken, opts.WebAppURL, interactive, "") token, err := a.GetToken(ctx) if err != nil { - return err + return "", err } opts.Telemetry.SetAuthToken(token) @@ -77,25 +77,25 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start for i, c := range opts.Containers { image, err := c.Image() if err != nil { - return err + return "", err } healthPath, err := c.HealthPath() if err != nil { - return err + return "", err } productName, err := c.ProductName() if err != nil { - return err + return "", err } containerPort, err := c.ContainerPort() if err != nil { - return err + return "", err } resolvedEnv, err := c.ResolvedEnv(opts.Env) if err != nil { - return err + return "", err } containerName := c.Name() @@ -120,10 +120,10 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start volumeDir, err := c.VolumeDir() if err != nil { - return err + return "", err } if err := os.MkdirAll(volumeDir, 0755); err != nil { - return fmt.Errorf("failed to create volume directory %s: %w", volumeDir, err) + return "", fmt.Errorf("failed to create volume directory %s: %w", volumeDir, err) } binds = append(binds, runtime.BindMount{HostPath: volumeDir, ContainerPath: "/var/lib/localstack"}) @@ -144,32 +144,43 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start containers, err = selectContainersToStart(ctx, rt, sink, tel, containers, opts.LocalStackHost, opts.WebAppURL) if err != nil { - return err + return "", err } if len(containers) == 0 { - return nil + return "", nil } licenseFilePath, err := config.LicenseFilePath() if err != nil { - return fmt.Errorf("failed to determine license file path: %w", err) + return "", fmt.Errorf("failed to determine license file path: %w", err) } - // Validate licenses before pulling where possible (pinned tags always; "latest" tags via catalog API). - // Returns containers that still need post-pull validation (catalog unavailable). + // Validate licenses before pulling. Pinned tags are validated immediately; + // "latest" tags are deferred to post-pull validation via image inspection. postPullContainers, err := tryPrePullLicenseValidation(ctx, sink, opts, containers, token, licenseFilePath) if err != nil { - return err + return "", err } pulled, err := pullImages(ctx, rt, sink, tel, containers) if err != nil { - return err + return "", err } - // Catalog was unavailable for these; fall back to image inspection. - if err := validateLicensesFromImages(ctx, rt, sink, opts, postPullContainers, token, licenseFilePath); err != nil { - return err + // Validate "latest" containers by inspecting the pulled image for its version. + resolvedVersion, err := validateLicensesFromImages(ctx, rt, sink, opts, postPullContainers, token, licenseFilePath) + if err != nil { + return "", err + } + + // For pinned containers (postPullContainers was empty), use the tag directly. + if resolvedVersion == "" { + for _, c := range containers { + if c.EmulatorType != config.EmulatorSnowflake && c.Tag != "" && c.Tag != "latest" { + resolvedVersion = c.Tag + break + } + } } // Mount the cached license file into each container if available. @@ -184,7 +195,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start } if err := startContainers(ctx, rt, sink, tel, containers, pulled); err != nil { - return err + return "", err } // Maps emulator types to their post-start setup functions. @@ -192,7 +203,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start setups := map[config.EmulatorType]postStartSetupFunc{ config.EmulatorAWS: awsconfig.EnsureProfile, } - return runPostStartSetups(ctx, rt, sink, opts.Containers, interactive, opts.LocalStackHost, opts.WebAppURL, setups) + return resolvedVersion, runPostStartSetups(ctx, rt, sink, opts.Containers, interactive, opts.LocalStackHost, opts.WebAppURL, setups) } func runPostStartSetups(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []config.ContainerConfig, interactive bool, localStackHost, webAppURL string, setups map[config.EmulatorType]postStartSetupFunc) error { @@ -309,9 +320,8 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel * return pulled, nil } -// Validates licenses before pulling where the version is known. -// Pinned tags are validated immediately; "latest" tags are resolved via the catalog API. -// Returns containers that couldn't be resolved (catalog unavailable) for post-pull validation. +// Validates licenses before pulling for containers with pinned tags. +// "latest" and empty tags are deferred to post-pull validation via image inspection. func tryPrePullLicenseValidation(ctx context.Context, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) ([]runtime.ContainerConfig, error) { var needsPostPull []runtime.ContainerConfig for _, c := range containers { @@ -326,26 +336,15 @@ func tryPrePullLicenseValidation(ctx context.Context, sink output.Sink, opts Sta continue } - apiCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - v, err := opts.PlatformClient.GetLatestCatalogVersion(apiCtx, string(c.EmulatorType)) - cancel() - - if err != nil { - needsPostPull = append(needsPostPull, c) - continue - } - - cWithVersion := c - cWithVersion.Tag = v - if err := validateLicense(ctx, sink, opts, cWithVersion, token, licenseFilePath); err != nil { - return nil, err - } + needsPostPull = append(needsPostPull, c) } return needsPostPull, nil } -// Fallback path: inspects each pulled image for its version, then validates the license. -func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) error { +// Inspects each pulled image for its version, then validates the license. +// Returns the resolved version of the first validated container, empty string if none. +func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string) (string, error) { + var firstVersion string for _, c := range containers { if c.EmulatorType == config.EmulatorSnowflake { continue @@ -353,14 +352,17 @@ func validateLicensesFromImages(ctx context.Context, rt runtime.Runtime, sink ou v, err := rt.GetImageVersion(ctx, c.Image) if err != nil { - return fmt.Errorf("could not resolve version from image %s: %w", c.Image, err) + return "", fmt.Errorf("could not resolve version from image %s: %w", c.Image, err) } c.Tag = v + if firstVersion == "" { + firstVersion = v + } if err := validateLicense(ctx, sink, opts, c, token, licenseFilePath); err != nil { - return err + return "", err } } - return nil + return firstVersion, nil } func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, pulled map[string]bool) error { diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 9d77ce54..09755d99 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -27,7 +27,7 @@ func TestStart_ReturnsEarlyIfRuntimeUnhealthy(t *testing.T) { sink := output.NewPlainSink(io.Discard) - err := Start(context.Background(), mockRT, sink, StartOptions{Logger: log.Nop()}, false) + _, err := Start(context.Background(), mockRT, sink, StartOptions{Logger: log.Nop()}, false) require.Error(t, err) assert.Contains(t, err.Error(), "runtime not healthy") diff --git a/internal/ui/run.go b/internal/ui/run.go index d2825783..44870af1 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/localstack/lstk/internal/auth" + "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/container" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" @@ -66,13 +67,8 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { var err error defer func() { runErrCh <- err }() sink := output.NewTUISink(programSender{p: p}) - // Start label resolution immediately when no emulator selection is needed, so - // headerLabelMsg always arrives even if NotifyUpdate returns early (update case). - // When emulator selection is needed, resolution starts after the user picks. - if !runOpts.NeedsEmulatorSelection { - go container.ResolveAndCacheLabel(ctx, runOpts.StartOptions, labelCh) - } if update.NotifyUpdate(ctx, sink, runOpts.NotifyOptions) { + p.Send(headerLabelMsg{}) p.Send(runDoneMsg{}) return } @@ -99,9 +95,9 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { return } runOpts.StartOptions.Containers = newContainers - go container.ResolveAndCacheLabel(ctx, runOpts.StartOptions, labelCh) } - err = container.Start(ctx, runOpts.Runtime, sink, runOpts.StartOptions, true) + var resolvedVersion string + resolvedVersion, err = container.Start(ctx, runOpts.Runtime, sink, runOpts.StartOptions, true) if err != nil { if errors.Is(err, context.Canceled) { return @@ -109,6 +105,13 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { p.Send(runErrMsg{err: err}) return } + // Empty resolvedVersion means the container was already running and Start + // returned early — use the cached label rather than re-resolving. + if resolvedVersion == "" { + go func() { labelCh <- config.CachedPlanLabel() }() + } else { + go container.ResolveAndCacheLabel(ctx, runOpts.StartOptions, resolvedVersion, labelCh) + } p.Send(runDoneMsg{}) }() diff --git a/test/integration/version_resolution_test.go b/test/integration/version_resolution_test.go index d90c8803..2d0306fa 100644 --- a/test/integration/version_resolution_test.go +++ b/test/integration/version_resolution_test.go @@ -2,10 +2,11 @@ package integration_test import ( "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -14,22 +15,13 @@ import ( "github.com/stretchr/testify/require" ) -// returns a mock server for catalog and license endpoints. -// Empty catalogVersion → 503. The returned *string captures the product version from license requests. -func createVersionResolutionMockServer(t *testing.T, catalogVersion string, licenseSuccess bool) (*httptest.Server, *string) { +// returns a mock license server that captures the product version from license requests. +// licenseSuccess controls whether the server returns 200 or 403. +func createLicenseMockServer(t *testing.T, licenseSuccess bool) (*httptest.Server, *string) { t.Helper() capturedVersion := new(string) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/license/catalog/"): - if catalogVersion == "" { - w.WriteHeader(http.StatusServiceUnavailable) - return - } - parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - emulatorType := parts[len(parts)-2] - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"emulator_type":%q,"version":%q}`, emulatorType, catalogVersion) case r.Method == http.MethodPost && r.URL.Path == "/v1/license/request": body, _ := io.ReadAll(r.Body) var req struct { @@ -54,65 +46,72 @@ func createVersionResolutionMockServer(t *testing.T, catalogVersion string, lice return srv, capturedVersion } -// Verifies that when the catalog API returns a version, the license request uses -// that version (not "latest"), allowing license validation to happen before pulling the image. -func TestVersionResolvedViaCatalog(t *testing.T) { +// Verifies that when "latest" is configured, the version is resolved by inspecting +// the pulled image and the license request carries that resolved version (not "latest"). +func TestVersionResolvedViaImageInspection(t *testing.T) { requireDocker(t) _ = env.Require(t, env.AuthToken) cleanup() t.Cleanup(cleanup) - mockServer, capturedVersion := createVersionResolutionMockServer(t, "4.14.0", true) + mockServer, capturedVersion := createLicenseMockServer(t, true) ctx := testContext(t) stdout, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "start") require.NoError(t, err, "lstk start failed:\nstdout: %s\nstderr: %s", stdout, stderr) - assert.Equal(t, "4.14.0", *capturedVersion, - "license request should carry the version returned by the catalog API") - assert.NotEqual(t, "latest", *capturedVersion, - "license request should not use the unresolved 'latest' tag") + assert.NotEmpty(t, *capturedVersion, "license request should carry a version resolved from image inspection") + assert.NotEqual(t, "latest", *capturedVersion, "resolved version should not be the unresolved 'latest' tag") assert.Contains(t, stdout, "Checking license") - assert.NotContains(t, stdout, "(4.14.0)") + + semverLike := strings.Contains(*capturedVersion, ".") || strings.Contains(*capturedVersion, "-") + assert.True(t, semverLike, "resolved version %q should look like a real version", *capturedVersion) } -// Verifies that when the catalog endpoint is unavailable, the version is resolved -// by inspecting the pulled image and used for licensing. -func TestVersionFallsBackToImageInspectionWhenCatalogFails(t *testing.T) { +// Verifies that when the license check fails after image inspection, the command +// exits with a clear error message. +func TestCommandFailsNicelyWhenLicenseCheckFails(t *testing.T) { requireDocker(t) _ = env.Require(t, env.AuthToken) cleanup() t.Cleanup(cleanup) - // Catalog returns 503; license accepts all requests - mockServer, capturedVersion := createVersionResolutionMockServer(t, "", true) + mockServer, _ := createLicenseMockServer(t, false) ctx := testContext(t) - stdout, stderr, err := runLstk(t, ctx, "", env.With(env.APIEndpoint, mockServer.URL), "start") - require.NoError(t, err, "lstk start should succeed via image inspection fallback:\nstdout: %s\nstderr: %s", stdout, stderr) - - assert.NotEmpty(t, *capturedVersion, "license request should carry a version resolved from image inspection") - assert.NotEqual(t, "latest", *capturedVersion, "resolved version should not be the unresolved 'latest' tag") + stdout, stderr, err := runLstk(t, ctx, "", + env.With(env.APIEndpoint, mockServer.URL), "start") + require.Error(t, err, "expected lstk start to fail when license check fails") + assert.Contains(t, stderr, "license validation failed", + "stdout: %s", stdout) } -// Verifies that when both the catalog endpoint is unavailable and the license -// validation fails in the image inspection fallback path, the command exits with a clear error. -func TestCommandFailsNicelyWhenCatalogAndLicenseBothFail(t *testing.T) { +// Verifies that pinned tags are validated before pulling (fail-fast path). +func TestPinnedTagValidatedPrePull(t *testing.T) { requireDocker(t) _ = env.Require(t, env.AuthToken) cleanup() t.Cleanup(cleanup) - // Catalog unavailable; license rejects all requests - mockServer, _ := createVersionResolutionMockServer(t, "", false) + // License rejects all requests — with a pinned tag, failure should happen before any pull + mockServer, capturedVersion := createLicenseMockServer(t, false) + + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(` +[[containers]] +type = "aws" +tag = "4.0.0" +port = "4566" +`), 0644)) ctx := testContext(t) stdout, stderr, err := runLstk(t, ctx, "", - env.With(env.APIEndpoint, mockServer.URL), "start") - require.Error(t, err, "expected lstk start to fail when catalog is down and license check fails") + env.With(env.APIEndpoint, mockServer.URL), "--config", configFile, "start") + require.Error(t, err, "expected lstk start to fail when license check fails") assert.Contains(t, stderr, "license validation failed", "stdout: %s", stdout) + assert.Equal(t, "4.0.0", *capturedVersion, "pinned tag should be sent directly to the license API without image inspection") }