From c5a54270e35a8795c12c20dc6ad9e906068fe818 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 10 Mar 2026 19:35:05 +0100 Subject: [PATCH 1/3] Use catalog endpoint to resolve localstack-pro latest version --- internal/api/catalog_version_test.go | 122 +++++++++++++++++++++++++ internal/api/client.go | 42 +++++++++ internal/api/mock_platform_api.go | 130 +++++++++++++++++++++++++++ internal/container/start.go | 32 +++++-- internal/container/start_test.go | 69 ++++++++++++++ 5 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 internal/api/catalog_version_test.go create mode 100644 internal/api/mock_platform_api.go diff --git a/internal/api/catalog_version_test.go b/internal/api/catalog_version_test.go new file mode 100644 index 0000000..4fb9321 --- /dev/null +++ b/internal/api/catalog_version_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "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) + 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.StatusBadRequest) + })) + defer srv.Close() + + client := NewPlatformClient(srv.URL) + _, err := client.GetLatestCatalogVersion(context.Background(), "aws") + + require.Error(t, err) + assert.Contains(t, err.Error(), "status 400") +} + +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) + _, err := client.GetLatestCatalogVersion(context.Background(), "aws") + + require.Error(t, err) + assert.Contains(t, err.Error(), "incomplete catalog response") +} + +func TestGetLatestCatalogVersion_MissingVersion(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", + }) + })) + defer srv.Close() + + client := NewPlatformClient(srv.URL) + _, err := client.GetLatestCatalogVersion(context.Background(), "aws") + + require.Error(t, err) + assert.Contains(t, err.Error(), "incomplete catalog response") +} + +func TestGetLatestCatalogVersion_EmptyEmulatorType(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": "", + "version": "4.14.0", + }) + })) + defer srv.Close() + + client := NewPlatformClient(srv.URL) + _, err := client.GetLatestCatalogVersion(context.Background(), "aws") + + require.Error(t, err) + assert.Contains(t, err.Error(), "incomplete catalog response") +} + +func TestGetLatestCatalogVersion_Timeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // hang until request context is cancelled + <-r.Context().Done() + })) + defer srv.Close() + + client := NewPlatformClient(srv.URL) + 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) { + // use a URL with no server behind it + client := NewPlatformClient("http://127.0.0.1:1") + 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 b1b078a..d0f5198 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -1,5 +1,7 @@ package api +//go:generate mockgen -source=client.go -destination=mock_platform_api.go -package=api + import ( "bytes" "context" @@ -7,6 +9,7 @@ import ( "fmt" "log" "net/http" + "net/url" "time" "github.com/localstack/lstk/internal/version" @@ -20,6 +23,7 @@ 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) error + GetLatestCatalogVersion(ctx context.Context, emulatorType string) (string, error) } type AuthRequest struct { @@ -238,3 +242,41 @@ func (c *PlatformClient) GetLicense(ctx context.Context, licReq *LicenseRequest) return fmt.Errorf("license request failed with status %d", resp.StatusCode) } } + +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 { + log.Printf("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) + } + + return versionResp.Version, nil +} diff --git a/internal/api/mock_platform_api.go b/internal/api/mock_platform_api.go new file mode 100644 index 0000000..82fa9bd --- /dev/null +++ b/internal/api/mock_platform_api.go @@ -0,0 +1,130 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go +// +// Generated by this command: +// +// mockgen -source=client.go -destination=mock_platform_api.go -package=api +// + +// Package api is a generated GoMock package. +package api + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockPlatformAPI is a mock of PlatformAPI interface. +type MockPlatformAPI struct { + ctrl *gomock.Controller + recorder *MockPlatformAPIMockRecorder + isgomock struct{} +} + +// MockPlatformAPIMockRecorder is the mock recorder for MockPlatformAPI. +type MockPlatformAPIMockRecorder struct { + mock *MockPlatformAPI +} + +// NewMockPlatformAPI creates a new mock instance. +func NewMockPlatformAPI(ctrl *gomock.Controller) *MockPlatformAPI { + mock := &MockPlatformAPI{ctrl: ctrl} + mock.recorder = &MockPlatformAPIMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPlatformAPI) EXPECT() *MockPlatformAPIMockRecorder { + return m.recorder +} + +// CheckAuthRequestConfirmed mocks base method. +func (m *MockPlatformAPI) CheckAuthRequestConfirmed(ctx context.Context, id, exchangeToken string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CheckAuthRequestConfirmed", ctx, id, exchangeToken) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CheckAuthRequestConfirmed indicates an expected call of CheckAuthRequestConfirmed. +func (mr *MockPlatformAPIMockRecorder) CheckAuthRequestConfirmed(ctx, id, exchangeToken any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckAuthRequestConfirmed", reflect.TypeOf((*MockPlatformAPI)(nil).CheckAuthRequestConfirmed), ctx, id, exchangeToken) +} + +// CreateAuthRequest mocks base method. +func (m *MockPlatformAPI) CreateAuthRequest(ctx context.Context) (*AuthRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAuthRequest", ctx) + ret0, _ := ret[0].(*AuthRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateAuthRequest indicates an expected call of CreateAuthRequest. +func (mr *MockPlatformAPIMockRecorder) CreateAuthRequest(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAuthRequest", reflect.TypeOf((*MockPlatformAPI)(nil).CreateAuthRequest), ctx) +} + +// ExchangeAuthRequest mocks base method. +func (m *MockPlatformAPI) ExchangeAuthRequest(ctx context.Context, id, exchangeToken string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExchangeAuthRequest", ctx, id, exchangeToken) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExchangeAuthRequest indicates an expected call of ExchangeAuthRequest. +func (mr *MockPlatformAPIMockRecorder) ExchangeAuthRequest(ctx, id, exchangeToken any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExchangeAuthRequest", reflect.TypeOf((*MockPlatformAPI)(nil).ExchangeAuthRequest), ctx, id, exchangeToken) +} + +// GetLatestCatalogVersion mocks base method. +func (m *MockPlatformAPI) GetLatestCatalogVersion(ctx context.Context, emulatorType string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLatestCatalogVersion", ctx, emulatorType) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLatestCatalogVersion indicates an expected call of GetLatestCatalogVersion. +func (mr *MockPlatformAPIMockRecorder) GetLatestCatalogVersion(ctx, emulatorType any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestCatalogVersion", reflect.TypeOf((*MockPlatformAPI)(nil).GetLatestCatalogVersion), ctx, emulatorType) +} + +// GetLicense mocks base method. +func (m *MockPlatformAPI) GetLicense(ctx context.Context, req *LicenseRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLicense", ctx, req) + ret0, _ := ret[0].(error) + return ret0 +} + +// GetLicense indicates an expected call of GetLicense. +func (mr *MockPlatformAPIMockRecorder) GetLicense(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLicense", reflect.TypeOf((*MockPlatformAPI)(nil).GetLicense), ctx, req) +} + +// GetLicenseToken mocks base method. +func (m *MockPlatformAPI) GetLicenseToken(ctx context.Context, bearerToken string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLicenseToken", ctx, bearerToken) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLicenseToken indicates an expected call of GetLicenseToken. +func (mr *MockPlatformAPIMockRecorder) GetLicenseToken(ctx, bearerToken any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLicenseToken", reflect.TypeOf((*MockPlatformAPI)(nil).GetLicenseToken), ctx, bearerToken) +} diff --git a/internal/container/start.go b/internal/container/start.go index 60a14e2..cbd0f64 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -3,6 +3,7 @@ package container import ( "context" "fmt" + "log" "net/http" "os" stdruntime "runtime" @@ -234,14 +235,31 @@ func emitPortInUseError(sink output.Sink, port string) { }) } +// resolveVersion determines the image version to use for license validation. +// It tries the platform catalog API first (with a short timeout), then falls back +// to inspecting the local Docker image for its build version. +func resolveVersion(ctx context.Context, rt runtime.Runtime, platformClient api.PlatformAPI, containerConfig runtime.ContainerConfig) (string, error) { + if containerConfig.Tag != "" && containerConfig.Tag != "latest" { + return containerConfig.Tag, nil + } + + apiCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + v, err := platformClient.GetLatestCatalogVersion(apiCtx, containerConfig.ProductName) + if err == nil { + return v, nil + } + + // API unreachable or timed out — fall back to local image inspection + log.Printf("catalog API unavailable (%v), falling back to image inspection", err) + return rt.GetImageVersion(ctx, containerConfig.Image) +} + func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, containerConfig runtime.ContainerConfig, token string) error { - version := containerConfig.Tag - if version == "" || version == "latest" { - actualVersion, err := rt.GetImageVersion(ctx, containerConfig.Image) - if err != nil { - return fmt.Errorf("could not resolve version from image %s: %w", containerConfig.Image, err) - } - version = actualVersion + version, err := resolveVersion(ctx, rt, platformClient, containerConfig) + if err != nil { + return fmt.Errorf("could not resolve version from image %s: %w", containerConfig.Image, err) } output.EmitStatus(sink, "validating license", containerConfig.Name, version) diff --git a/internal/container/start_test.go b/internal/container/start_test.go index f422baa..1e7ce79 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -6,6 +6,7 @@ import ( "io" "testing" + "github.com/localstack/lstk/internal/api" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" "github.com/stretchr/testify/assert" @@ -13,6 +14,74 @@ import ( "go.uber.org/mock/gomock" ) +func TestResolveVersion_PinnedVersionSkipsAPIAndDockerLookup(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockPlatform := api.NewMockPlatformAPI(ctrl) + // Neither API nor Docker should be called when an explicit non-latest tag is set + cfg := runtime.ContainerConfig{Tag: "4.14.0", ProductName: "aws", Image: "localstack/localstack-pro:4.14.0"} + + version, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) + + require.NoError(t, err) + assert.Equal(t, "4.14.0", version) +} + +func TestResolveVersion_UsesCatalogAPIForLatestTag(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockPlatform := api.NewMockPlatformAPI(ctrl) + mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("4.14.0", nil) + cfg := runtime.ContainerConfig{Tag: "latest", ProductName: "aws", Image: "localstack/localstack-pro:latest"} + + version, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) + + require.NoError(t, err) + assert.Equal(t, "4.14.0", version) +} + +func TestResolveVersion_UsesCatalogAPIWhenTagEmpty(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockPlatform := api.NewMockPlatformAPI(ctrl) + mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("4.14.0", nil) + cfg := runtime.ContainerConfig{Tag: "", ProductName: "aws", Image: "localstack/localstack-pro"} + + version, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) + + require.NoError(t, err) + assert.Equal(t, "4.14.0", version) +} + +func TestResolveVersion_FallsBackToDockerWhenAPIFails(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().GetImageVersion(gomock.Any(), "localstack/localstack-pro:latest").Return("4.14.0", nil) + mockPlatform := api.NewMockPlatformAPI(ctrl) + mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("", errors.New("connection refused")) + cfg := runtime.ContainerConfig{Tag: "latest", ProductName: "aws", Image: "localstack/localstack-pro:latest"} + + version, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) + + require.NoError(t, err) + assert.Equal(t, "4.14.0", version) +} + +func TestResolveVersion_ReturnsErrorWhenBothFail(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().GetImageVersion(gomock.Any(), "localstack/localstack-pro:latest"). + Return("", errors.New("image not found")) + mockPlatform := api.NewMockPlatformAPI(ctrl) + mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("", errors.New("api down")) + cfg := runtime.ContainerConfig{Tag: "latest", ProductName: "aws", Image: "localstack/localstack-pro:latest"} + + _, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) + + require.Error(t, err) + assert.Contains(t, err.Error(), "image not found") +} + func TestStart_ReturnsEarlyIfRuntimeUnhealthy(t *testing.T) { ctrl := gomock.NewController(t) mockRT := runtime.NewMockRuntime(ctrl) From 527f96e472eb715b17a96b6417aea365e29c8fdb Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 11 Mar 2026 18:12:29 +0100 Subject: [PATCH 2/3] Address comments --- internal/api/catalog_version_test.go | 17 +++++++++++++++++ internal/api/client.go | 4 ++++ internal/container/start.go | 21 ++++++++++----------- internal/container/start_test.go | 10 +++++----- internal/runtime/runtime.go | 15 ++++++++------- 5 files changed, 44 insertions(+), 23 deletions(-) diff --git a/internal/api/catalog_version_test.go b/internal/api/catalog_version_test.go index 4fb9321..b1c02ab 100644 --- a/internal/api/catalog_version_test.go +++ b/internal/api/catalog_version_test.go @@ -94,6 +94,23 @@ func TestGetLatestCatalogVersion_EmptyEmulatorType(t *testing.T) { 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) + _, 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) { // hang until request context is cancelled diff --git a/internal/api/client.go b/internal/api/client.go index d0f5198..8596a95 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -278,5 +278,9 @@ func (c *PlatformClient) GetLatestCatalogVersion(ctx context.Context, emulatorTy 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/start.go b/internal/container/start.go index cbd0f64..6ce95d3 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -3,7 +3,6 @@ package container import ( "context" "fmt" - "log" "net/http" "os" stdruntime "runtime" @@ -79,13 +78,14 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start } env := append(resolvedEnv, "LOCALSTACK_AUTH_TOKEN="+token) containers[i] = runtime.ContainerConfig{ - Image: image, - Name: c.Name(), - Port: c.Port, - HealthPath: healthPath, - Env: env, - Tag: c.Tag, - ProductName: productName, + Image: image, + Name: c.Name(), + Port: c.Port, + HealthPath: healthPath, + Env: env, + Tag: c.Tag, + ProductName: productName, + EmulatorType: string(c.Type), } } @@ -246,13 +246,12 @@ func resolveVersion(ctx context.Context, rt runtime.Runtime, platformClient api. apiCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() - v, err := platformClient.GetLatestCatalogVersion(apiCtx, containerConfig.ProductName) - if err == nil { + v, err := platformClient.GetLatestCatalogVersion(apiCtx, containerConfig.EmulatorType) + if err == nil && v != "" { return v, nil } // API unreachable or timed out — fall back to local image inspection - log.Printf("catalog API unavailable (%v), falling back to image inspection", err) return rt.GetImageVersion(ctx, containerConfig.Image) } diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 1e7ce79..218090b 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -19,7 +19,7 @@ func TestResolveVersion_PinnedVersionSkipsAPIAndDockerLookup(t *testing.T) { mockRT := runtime.NewMockRuntime(ctrl) mockPlatform := api.NewMockPlatformAPI(ctrl) // Neither API nor Docker should be called when an explicit non-latest tag is set - cfg := runtime.ContainerConfig{Tag: "4.14.0", ProductName: "aws", Image: "localstack/localstack-pro:4.14.0"} + cfg := runtime.ContainerConfig{Tag: "4.14.0", ProductName: "localstack-pro", EmulatorType: "aws", Image: "localstack/localstack-pro:4.14.0"} version, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) @@ -32,7 +32,7 @@ func TestResolveVersion_UsesCatalogAPIForLatestTag(t *testing.T) { mockRT := runtime.NewMockRuntime(ctrl) mockPlatform := api.NewMockPlatformAPI(ctrl) mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("4.14.0", nil) - cfg := runtime.ContainerConfig{Tag: "latest", ProductName: "aws", Image: "localstack/localstack-pro:latest"} + cfg := runtime.ContainerConfig{Tag: "latest", ProductName: "localstack-pro", EmulatorType: "aws", Image: "localstack/localstack-pro:latest"} version, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) @@ -45,7 +45,7 @@ func TestResolveVersion_UsesCatalogAPIWhenTagEmpty(t *testing.T) { mockRT := runtime.NewMockRuntime(ctrl) mockPlatform := api.NewMockPlatformAPI(ctrl) mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("4.14.0", nil) - cfg := runtime.ContainerConfig{Tag: "", ProductName: "aws", Image: "localstack/localstack-pro"} + cfg := runtime.ContainerConfig{Tag: "", ProductName: "localstack-pro", EmulatorType: "aws", Image: "localstack/localstack-pro"} version, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) @@ -59,7 +59,7 @@ func TestResolveVersion_FallsBackToDockerWhenAPIFails(t *testing.T) { mockRT.EXPECT().GetImageVersion(gomock.Any(), "localstack/localstack-pro:latest").Return("4.14.0", nil) mockPlatform := api.NewMockPlatformAPI(ctrl) mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("", errors.New("connection refused")) - cfg := runtime.ContainerConfig{Tag: "latest", ProductName: "aws", Image: "localstack/localstack-pro:latest"} + cfg := runtime.ContainerConfig{Tag: "latest", ProductName: "localstack-pro", EmulatorType: "aws", Image: "localstack/localstack-pro:latest"} version, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) @@ -74,7 +74,7 @@ func TestResolveVersion_ReturnsErrorWhenBothFail(t *testing.T) { Return("", errors.New("image not found")) mockPlatform := api.NewMockPlatformAPI(ctrl) mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("", errors.New("api down")) - cfg := runtime.ContainerConfig{Tag: "latest", ProductName: "aws", Image: "localstack/localstack-pro:latest"} + cfg := runtime.ContainerConfig{Tag: "latest", ProductName: "localstack-pro", EmulatorType: "aws", Image: "localstack/localstack-pro:latest"} _, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 7a54b5d..cfe70b2 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -8,13 +8,14 @@ import ( ) type ContainerConfig struct { - Image string - Name string - Port string - HealthPath string - Env []string // e.g., ["KEY=value", "FOO=bar"] - Tag string - ProductName string + Image string + Name string + Port string + HealthPath string + Env []string // e.g., ["KEY=value", "FOO=bar"] + Tag string + ProductName string + EmulatorType string } type PullProgress struct { From 564bd84e4a89ed158fb975a917d80abcfb7d2596 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 12 Mar 2026 10:14:46 +0100 Subject: [PATCH 3/3] Move to right place --- internal/container/start.go | 78 ++++++++++++++++++++---------- internal/container/start_test.go | 81 +++++++++++++++++++------------- 2 files changed, 103 insertions(+), 56 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index 6ce95d3..2c6b704 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -7,6 +7,7 @@ import ( "os" stdruntime "runtime" "slices" + "strings" "time" "github.com/containerd/errdefs" @@ -97,13 +98,18 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start return nil } - // TODO validate license for tag "latest" without resolving the actual image version, - // and avoid pulling all images first + containers = resolveContainerVersions(ctx, opts.PlatformClient, containers) + if err := pullImages(ctx, rt, sink, containers); err != nil { return err } - if err := validateLicenses(ctx, rt, sink, opts.PlatformClient, containers, token); err != nil { + containers, err = resolveVersionsFromImages(ctx, rt, containers) + if err != nil { + return err + } + + if err := validateLicenses(ctx, sink, opts.PlatformClient, containers, token); err != nil { return err } @@ -172,9 +178,9 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, conta return nil } -func validateLicenses(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, containers []runtime.ContainerConfig, token string) error { +func validateLicenses(ctx context.Context, sink output.Sink, platformClient api.PlatformAPI, containers []runtime.ContainerConfig, token string) error { for _, c := range containers { - if err := validateLicense(ctx, rt, sink, platformClient, c, token); err != nil { + if err := validateLicense(ctx, sink, platformClient, c, token); err != nil { return err } } @@ -235,31 +241,55 @@ func emitPortInUseError(sink output.Sink, port string) { }) } -// resolveVersion determines the image version to use for license validation. -// It tries the platform catalog API first (with a short timeout), then falls back -// to inspecting the local Docker image for its build version. -func resolveVersion(ctx context.Context, rt runtime.Runtime, platformClient api.PlatformAPI, containerConfig runtime.ContainerConfig) (string, error) { - if containerConfig.Tag != "" && containerConfig.Tag != "latest" { - return containerConfig.Tag, nil - } +// resolveContainerVersions replaces "latest" image tags with a specific version +// resolved from the catalog API, so the subsequent pull targets a pinned version. +// If the API is unreachable for a given container, its original image reference is preserved. +func resolveContainerVersions(ctx context.Context, platformClient api.PlatformAPI, containers []runtime.ContainerConfig) []runtime.ContainerConfig { + resolved := make([]runtime.ContainerConfig, len(containers)) + copy(resolved, containers) + for i, c := range resolved { + if c.Tag != "" && c.Tag != "latest" { + continue + } - apiCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() + apiCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + v, err := platformClient.GetLatestCatalogVersion(apiCtx, c.EmulatorType) + cancel() - v, err := platformClient.GetLatestCatalogVersion(apiCtx, containerConfig.EmulatorType) - if err == nil && v != "" { - return v, nil - } + if err != nil || v == "" { + continue + } - // API unreachable or timed out — fall back to local image inspection - return rt.GetImageVersion(ctx, containerConfig.Image) + resolved[i].Tag = v + if idx := strings.LastIndex(c.Image, ":"); idx != -1 { + resolved[i].Image = c.Image[:idx+1] + v + } else { + resolved[i].Image = c.Image + ":" + v + } + } + return resolved } -func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, containerConfig runtime.ContainerConfig, token string) error { - version, err := resolveVersion(ctx, rt, platformClient, containerConfig) - if err != nil { - return fmt.Errorf("could not resolve version from image %s: %w", containerConfig.Image, err) +// resolveVersionsFromImages inspects pulled images to resolve any remaining "latest" tags +// that the pre-pull catalog API call could not resolve (e.g. due to network unavailability). +func resolveVersionsFromImages(ctx context.Context, rt runtime.Runtime, containers []runtime.ContainerConfig) ([]runtime.ContainerConfig, error) { + resolved := make([]runtime.ContainerConfig, len(containers)) + copy(resolved, containers) + for i, c := range resolved { + if c.Tag != "" && c.Tag != "latest" { + continue + } + v, err := rt.GetImageVersion(ctx, c.Image) + if err != nil { + return nil, fmt.Errorf("could not resolve version from image %s: %w", c.Image, err) + } + resolved[i].Tag = v } + return resolved, nil +} + +func validateLicense(ctx context.Context, sink output.Sink, platformClient api.PlatformAPI, containerConfig runtime.ContainerConfig, token string) error { + version := containerConfig.Tag output.EmitStatus(sink, "validating license", containerConfig.Name, version) diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 218090b..e557d8c 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -14,69 +14,86 @@ import ( "go.uber.org/mock/gomock" ) -func TestResolveVersion_PinnedVersionSkipsAPIAndDockerLookup(t *testing.T) { +func TestResolveContainerVersions_PinnedTagIsUnchanged(t *testing.T) { ctrl := gomock.NewController(t) - mockRT := runtime.NewMockRuntime(ctrl) mockPlatform := api.NewMockPlatformAPI(ctrl) - // Neither API nor Docker should be called when an explicit non-latest tag is set - cfg := runtime.ContainerConfig{Tag: "4.14.0", ProductName: "localstack-pro", EmulatorType: "aws", Image: "localstack/localstack-pro:4.14.0"} + // API must not be called for pinned tags + containers := []runtime.ContainerConfig{ + {Tag: "3.8.1", Image: "localstack/localstack-pro:3.8.1", EmulatorType: "aws"}, + } - version, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) + result := resolveContainerVersions(context.Background(), mockPlatform, containers) - require.NoError(t, err) - assert.Equal(t, "4.14.0", version) + assert.Equal(t, "3.8.1", result[0].Tag) + assert.Equal(t, "localstack/localstack-pro:3.8.1", result[0].Image) } -func TestResolveVersion_UsesCatalogAPIForLatestTag(t *testing.T) { +func TestResolveContainerVersions_ResolvesLatestToSpecificVersion(t *testing.T) { ctrl := gomock.NewController(t) - mockRT := runtime.NewMockRuntime(ctrl) mockPlatform := api.NewMockPlatformAPI(ctrl) - mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("4.14.0", nil) - cfg := runtime.ContainerConfig{Tag: "latest", ProductName: "localstack-pro", EmulatorType: "aws", Image: "localstack/localstack-pro:latest"} + mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("3.8.1", nil) + containers := []runtime.ContainerConfig{ + {Tag: "latest", Image: "localstack/localstack-pro:latest", EmulatorType: "aws"}, + } - version, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) + result := resolveContainerVersions(context.Background(), mockPlatform, containers) - require.NoError(t, err) - assert.Equal(t, "4.14.0", version) + assert.Equal(t, "3.8.1", result[0].Tag) + assert.Equal(t, "localstack/localstack-pro:3.8.1", result[0].Image) } -func TestResolveVersion_UsesCatalogAPIWhenTagEmpty(t *testing.T) { +func TestResolveContainerVersions_KeepsLatestWhenAPIFails(t *testing.T) { ctrl := gomock.NewController(t) - mockRT := runtime.NewMockRuntime(ctrl) mockPlatform := api.NewMockPlatformAPI(ctrl) - mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("4.14.0", nil) - cfg := runtime.ContainerConfig{Tag: "", ProductName: "localstack-pro", EmulatorType: "aws", Image: "localstack/localstack-pro"} + mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("", errors.New("api down")) + containers := []runtime.ContainerConfig{ + {Tag: "latest", Image: "localstack/localstack-pro:latest", EmulatorType: "aws"}, + } - version, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) + result := resolveContainerVersions(context.Background(), mockPlatform, containers) + + assert.Equal(t, "latest", result[0].Tag) + assert.Equal(t, "localstack/localstack-pro:latest", result[0].Image) +} + +func TestResolveVersionsFromImages_PinnedTagIsUnchanged(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + // GetImageVersion must not be called for pinned tags + containers := []runtime.ContainerConfig{ + {Tag: "3.8.1", Image: "localstack/localstack-pro:3.8.1"}, + } + + result, err := resolveVersionsFromImages(context.Background(), mockRT, containers) require.NoError(t, err) - assert.Equal(t, "4.14.0", version) + assert.Equal(t, "3.8.1", result[0].Tag) } -func TestResolveVersion_FallsBackToDockerWhenAPIFails(t *testing.T) { +func TestResolveVersionsFromImages_ResolvesLatestViaImageInspection(t *testing.T) { ctrl := gomock.NewController(t) mockRT := runtime.NewMockRuntime(ctrl) - mockRT.EXPECT().GetImageVersion(gomock.Any(), "localstack/localstack-pro:latest").Return("4.14.0", nil) - mockPlatform := api.NewMockPlatformAPI(ctrl) - mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("", errors.New("connection refused")) - cfg := runtime.ContainerConfig{Tag: "latest", ProductName: "localstack-pro", EmulatorType: "aws", Image: "localstack/localstack-pro:latest"} + mockRT.EXPECT().GetImageVersion(gomock.Any(), "localstack/localstack-pro:latest").Return("3.8.1", nil) + containers := []runtime.ContainerConfig{ + {Tag: "latest", Image: "localstack/localstack-pro:latest"}, + } - version, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) + result, err := resolveVersionsFromImages(context.Background(), mockRT, containers) require.NoError(t, err) - assert.Equal(t, "4.14.0", version) + assert.Equal(t, "3.8.1", result[0].Tag) } -func TestResolveVersion_ReturnsErrorWhenBothFail(t *testing.T) { +func TestResolveVersionsFromImages_ReturnsErrorWhenImageInspectionFails(t *testing.T) { ctrl := gomock.NewController(t) mockRT := runtime.NewMockRuntime(ctrl) mockRT.EXPECT().GetImageVersion(gomock.Any(), "localstack/localstack-pro:latest"). Return("", errors.New("image not found")) - mockPlatform := api.NewMockPlatformAPI(ctrl) - mockPlatform.EXPECT().GetLatestCatalogVersion(gomock.Any(), "aws").Return("", errors.New("api down")) - cfg := runtime.ContainerConfig{Tag: "latest", ProductName: "localstack-pro", EmulatorType: "aws", Image: "localstack/localstack-pro:latest"} + containers := []runtime.ContainerConfig{ + {Tag: "latest", Image: "localstack/localstack-pro:latest"}, + } - _, err := resolveVersion(context.Background(), mockRT, mockPlatform, cfg) + _, err := resolveVersionsFromImages(context.Background(), mockRT, containers) require.Error(t, err) assert.Contains(t, err.Error(), "image not found")