Skip to content

Validate license before starting container#22

Open
carole-lavillonniere wants to merge 2 commits intomainfrom
carole/drg-444
Open

Validate license before starting container#22
carole-lavillonniere wants to merge 2 commits intomainfrom
carole/drg-444

Conversation

@carole-lavillonniere
Copy link
Collaborator

@carole-lavillonniere carole-lavillonniere commented Feb 12, 2026

The CLI now validates licenses upfront via the platform API, providing clear error messages if validation fails, rather than waiting for the container to fail during startup.
This should be further improved in the future by resolving the version for the latest tag without having to pull the images (DRG-504, DRG-508).

@coderabbitai
Copy link

coderabbitai bot commented Feb 12, 2026

📝 Walkthrough

Walkthrough

Commands now construct and pass a PlatformClient into auth and container startup. Container.Start inspects image versions, validates licenses via PlatformAPI.GetLicense before starting containers. New API types and license request flow added; tests and env.example updated for LOCALSTACK_API_ENDPOINT and license checks.

Changes

Cohort / File(s) Summary
Command plumbing
cmd/logout.go, cmd/root.go, cmd/start.go, cmd/login.go
Construct platformClient with api.NewPlatformClient() and pass it into auth.New(...) and container.Start(...); imports updated.
Platform API & types
internal/api/client.go
Added PlatformAPI.GetLicense(ctx, *LicenseRequest) error, implemented on PlatformClient; introduced exported types LicenseRequest, ProductInfo, CredentialsInfo, MachineInfo.
Auth integration
internal/auth/auth.go, internal/auth/login.go
auth.New signature changed to accept api.PlatformAPI; browser login constructor updated to accept injected platformClient.
Container startup & license flow
internal/container/start.go
Start signature updated to accept api.PlatformAPI; pulls images, resolves image versions, and calls GetLicense (via new validateLicense(...)) before starting containers; auth initialized with injected platform client.
Config image naming
internal/config/config.go
Added dockerRegistry constant and ContainerConfig.ProductName(); image construction changed to use registry/productName:tag.
Runtime image introspection
internal/runtime/runtime.go, internal/runtime/docker.go
Added Runtime.GetImageVersion(ctx,imageName) (string,error) to interface; DockerRuntime.GetImageVersion inspects image ENV for LOCALSTACK_BUILD_VERSION.
Environment example
env.example
Added LOCALSTACK_API_ENDPOINT and LOCALSTACK_WEB_APP_URL.
Integration tests & helpers
test/integration/...
license_test.go, login_browser_flow_test.go, login_device_flow_test.go, main_test.go, start_test.go
Added license validation integration tests and mock license endpoints; updated mock API server helpers and env helpers (envWithout), and wired LOCALSTACK_API_ENDPOINT into tests.
Other changes
internal/auth/*, internal/runtime/*
Signatures updated to accept PlatformAPI where appropriate; small supporting imports and error handling additions.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as CLI/Command
    participant Container as Container.Start()
    participant Platform as PlatformClient
    participant API as License API

    CLI->>Container: Start(ctx, rt, platformClient, onProgress)
    Container->>Container: Pull images
    Container->>Container: Resolve image versions (GetImageVersion)
    Container->>Platform: GetLicense(ctx, LicenseRequest)
    Platform->>API: POST /v1/license/request
    API-->>Platform: 200 / 400 / 403
    alt 200 OK
        Platform-->>Container: nil
        Container->>Container: Start containers
        Container-->>CLI: Initialization complete
    else 400/403
        Platform-->>Container: error
        Container-->>CLI: Initialization failed
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Validate license before starting container' directly and clearly summarizes the main change—license validation now occurs upfront before container startup.
Description check ✅ Passed The description is directly related to the changeset, explaining that the CLI now validates licenses upfront via the platform API with clear error messages, and notes a future improvement opportunity.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch carole/drg-444

No actionable comments were generated in the recent review. 🎉


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@test/integration/license_test.go`:
- Around line 40-45: The HTTP handler uses assert calls and type assertions that
can panic in a separate goroutine; create a validationErrors channel (e.g.,
validationErrors := make(chan error, 1)) and inside the handler replace assert.*
calls and direct type assertions on req with safe checks (use the comma-ok form
or type switches for req["product"] and req["credentials"], compare values
against authToken, and on any mismatch send a descriptive error to
validationErrors instead of calling assert). After the request completes in the
test, perform a non-blocking select on validationErrors (e.g., select { case err
:= <-validationErrors: t.Fatalf("request validation failed: %v", err) default:
}) to fail the test if the handler reported an error.

In `@test/integration/login_device_flow_test.go`:
- Line 146: The test currently removes LOCALSTACK_API_ENDPOINT causing
NewPlatformClient() to hit the real https://api.localstack.cloud; instead create
a local mock HTTP server (e.g., httptest.Server) that returns {"confirmed":
false} for the platform check, then set cmd.Env to include
LOCALSTACK_API_ENDPOINT pointing to that mock server URL (rather than removing
it) so NewPlatformClient() uses the local endpoint; update the test to
start/close the mock server around the test and ensure the mock handler returns
the deterministic failure response referenced by NewPlatformClient().
🧹 Nitpick comments (5)
env.example (1)

2-3: Consider adding comments to document these variables.

The new environment variables lack documentation explaining their purpose and when they're needed. Adding brief comments would improve clarity for users setting up the environment.

📝 Suggested documentation enhancement
 export LOCALSTACK_AUTH_TOKEN=ls-...
+# Platform API endpoint for license validation
 export LOCALSTACK_API_ENDPOINT=https://api.staging.aws.localstack.cloud
+# Web application URL for the LocalStack platform
 export LOCALSTACK_WEB_APP_URL=https://app.staging.aws.localstack.cloud
internal/config/config.go (1)

67-73: Consider reusing ProductName() inside Image() to avoid duplicate lookups.

♻️ Suggested refactor
 func (c *ContainerConfig) Image() (string, error) {
-	productName, ok := emulatorImages[c.Type]
-	if !ok {
-		return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type)
-	}
+	productName, err := c.ProductName()
+	if err != nil {
+		return "", err
+	}
 	tag := c.Tag
 	if tag == "" {
 		tag = "latest"
 	}
 	return fmt.Sprintf("localstack/%s:%s", productName, tag), nil
 }
internal/auth/auth.go (1)

18-26: Guard against nil platformClient to avoid runtime panics.

🛡️ Suggested guard
 func New(platformClient api.PlatformAPI) (*Auth, error) {
+	if platformClient == nil {
+		return nil, errors.New("platform client is required")
+	}
 	kr, err := newSystemKeyring()
 	if err != nil {
 		return nil, err
 	}
test/integration/login_browser_flow_test.go (1)

63-64: Hardcoded sleep is fragile for synchronization.

Using time.Sleep(1 * time.Second) to wait for the callback server may cause flaky tests if startup is delayed. Consider polling the endpoint until it responds or using a ready signal.

♻️ Suggested improvement using polling
-	// Wait for callback server to be ready
-	time.Sleep(1 * time.Second)
+	// Wait for callback server to be ready by polling
+	for i := 0; i < 10; i++ {
+		conn, err := net.Dial("tcp", "127.0.0.1:45678")
+		if err == nil {
+			conn.Close()
+			break
+		}
+		time.Sleep(100 * time.Millisecond)
+	}
internal/api/client.go (1)

221-230: Consider including server error details in failure messages.

The hardcoded error messages for 400/403 don't include any details from the response body. If the server provides diagnostic information, it would be helpful for debugging.

♻️ Optional: Read error response body
 	switch resp.StatusCode {
 	case http.StatusOK:
 		return nil
 	case http.StatusBadRequest:
+		body, _ := io.ReadAll(resp.Body)
+		if len(body) > 0 {
+			return fmt.Errorf("license validation failed: %s", string(body))
+		}
 		return fmt.Errorf("license validation failed: invalid token format, missing license assignment, or missing subscription")
 	case http.StatusForbidden:
+		body, _ := io.ReadAll(resp.Body)
+		if len(body) > 0 {
+			return fmt.Errorf("license validation failed: %s", string(body))
+		}
 		return fmt.Errorf("license validation failed: invalid, inactive, or expired authentication token or subscription")
 	default:
 		return fmt.Errorf("license request failed with status %d", resp.StatusCode)
 	}

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
test/integration/login_browser_flow_test.go (1)

73-76: ⚠️ Potential issue | 🟠 Major

Test design conflates login flow with container startup, causing CI failure.

The pipeline failure indicates the test fails because localstack/localstack-pro:latest cannot be pulled in the CI environment. The test expects both "Login successful" and "License activation failed" messages, but the actual failure point is during image pulling, not license validation.

If the goal is to test the browser login flow and token storage, consider stopping after verifying the token is stored, rather than letting the flow proceed to container startup. Alternatively, mock the runtime/container layer to avoid real image pulls.

🛠️ Suggested approach: Focus the test on login flow only

Consider restructuring the test to either:

  1. Mock the container runtime to avoid image pulls, or
  2. End the test after login succeeds and token is stored, before container startup begins
-	// Login should succeed, but container will fail with invalid token
-	assert.Contains(t, string(out), "Login successful")
-	assert.Contains(t, string(out), "License activation failed")
+	// Verify login succeeded (container startup may fail in CI without images)
+	assert.Contains(t, string(out), "Login successful")
test/integration/login_device_flow_test.go (1)

121-129: ⚠️ Potential issue | 🟠 Major

Add retry loop before asserting container is running. The container creation is asynchronous; the CLI command completes before the container is fully started, causing the "No such container: localstack-aws" failure. The context already has a 2-minute timeout available, so a short retry with 30-second deadline and 500ms polling is appropriate.

🔧 Suggested retry to reduce flakiness
-        inspect, err := dockerClient.ContainerInspect(ctx, containerName)
-        require.NoError(t, err)
-        assert.True(t, inspect.State.Running, "container should be running")
+        deadline := time.Now().Add(30 * time.Second)
+        for {
+            inspect, err := dockerClient.ContainerInspect(ctx, containerName)
+            if err == nil && inspect.State.Running {
+                break
+            }
+            if time.Now().After(deadline) {
+                t.Fatalf("container should be running; last error: %v", err)
+            }
+            time.Sleep(500 * time.Millisecond)
+        }
🤖 Fix all issues with AI agents
In `@test/integration/license_test.go`:
- Around line 21-84: Tests fail because images are pulled in the startup path
before license validation; move or gate the image-pull logic so license
validation occurs first. In the startup flow in start.go, change the sequence
where images are pulled (the pullImages / ensureImagesPulled call inside the
Start/StartContainers flow) to first call the license validation routine
(validateLicenseForTag or the existing license validation code path) for each
relevant image tag (including resolving 'latest' to a check without attempting a
full pull), or add a runtime flag/mocker hook that skips real pulls during
tests; update the start sequence to only pull images after licenses are
validated or when a skip-pull/test-mode flag is not set so tests can run without
network image pulls.
🧹 Nitpick comments (4)
internal/config/config.go (1)

67-73: Consider extracting the common lookup logic.

ProductName() and Image() both perform identical map lookups with the same error handling. This is minor duplication but could be consolidated.

♻️ Optional: Extract common lookup
+func (c *ContainerConfig) productName() (string, error) {
+	name, ok := emulatorImages[c.Type]
+	if !ok {
+		return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type)
+	}
+	return name, nil
+}
+
 func (c *ContainerConfig) Image() (string, error) {
-	productName, ok := emulatorImages[c.Type]
-	if !ok {
-		return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type)
+	productName, err := c.productName()
+	if err != nil {
+		return "", err
 	}
 	tag := c.Tag
 	if tag == "" {
 		tag = "latest"
 	}
 	return fmt.Sprintf("localstack/%s:%s", productName, tag), nil
 }
 
 func (c *ContainerConfig) ProductName() (string, error) {
-	productName, ok := emulatorImages[c.Type]
-	if !ok {
-		return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type)
-	}
-	return productName, nil
+	return c.productName()
 }
internal/container/start.go (1)

122-122: Silently ignoring os.Hostname() error.

The error from os.Hostname() is discarded, resulting in an empty hostname if it fails. This may be intentional for simplicity, but consider logging the error for debugging purposes.

♻️ Optional: Log hostname retrieval failure
-	hostname, _ := os.Hostname()
+	hostname, err := os.Hostname()
+	if err != nil {
+		log.Printf("Could not retrieve hostname: %v", err)
+	}
test/integration/login_browser_flow_test.go (1)

25-39: Mock server is incomplete for the browser login flow.

The mock server returns an exchange_token in the auth request response, but doesn't handle the token exchange endpoint (/v1/auth/request/{id}/exchange). The browser callback stores the token directly via the callback URL, bypassing this, but having a more complete mock would improve test reliability and documentation of the expected flow.

Additionally, the license endpoint returns StatusOK, which contradicts the test's expectation of "License activation failed" at line 75.

cmd/root.go (1)

22-38: Code duplication between rootCmd.Run and startCmd.Run.

The Run function here is nearly identical to startCmd.Run in cmd/start.go (lines 17-33). Both create a Docker runtime, platform client, progress callback, and call container.Start. Consider extracting this shared logic into a helper function to reduce duplication and ensure both entry points stay in sync.

♻️ Suggested refactor to extract shared startup logic
// In a shared location, e.g., cmd/helpers.go
func runStart(ctx context.Context) error {
    rt, err := runtime.NewDockerRuntime()
    if err != nil {
        return err
    }
    
    platformClient := api.NewPlatformClient()
    
    onProgress := func(msg string) {
        fmt.Println(msg)
    }
    
    return container.Start(ctx, rt, platformClient, onProgress)
}

Then both commands can call:

if err := runStart(cmd.Context()); err != nil {
    fmt.Fprintf(os.Stderr, "Error: %v\n", err)
    os.Exit(1)
}

@carole-lavillonniere carole-lavillonniere force-pushed the carole/drg-444 branch 3 times, most recently from 42b7cf1 to 8adb3bc Compare February 12, 2026 14:50
@carole-lavillonniere carole-lavillonniere marked this pull request as ready for review February 12, 2026 14:50
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@internal/auth/auth.go`:
- Around line 18-26: The constructor New should guard against a nil
platformClient to fail fast and avoid panics when browserLogin is later used;
update New to check if platformClient == nil and return a descriptive error
(instead of creating browserLogin) so callers get an immediate error,
referencing the New function, the Auth struct, browserLogin field, and
newBrowserLogin helper when implementing the check.

In `@internal/container/start.go`:
- Around line 122-135: The license request currently sets
Machine.PlatformRelease to stdruntime.GOARCH (CPU arch) instead of the OS
release; change it to call a helper that returns the OS release string (e.g.,
getOSRelease()) and assign that to api.MachineInfo.PlatformRelease when building
licenseReq. Implement getOSRelease() to detect runtime.GOOS and return the OS
release appropriately (for Unix-like systems run "uname -r" or use syscall/unix
uname, for Windows use the appropriate Windows API/registry call), and ensure
any errors fall back to an empty string or a safe default; update the
construction in start.go to use getOSRelease() instead of stdruntime.GOARCH.

In `@test/integration/login_browser_flow_test.go`:
- Around line 43-46: The child process may receive a duplicate
LOCALSTACK_API_ENDPOINT if the parent env already contains it; update the
envWithout call used to build cmd.Env so it removes both keys before appending
the single override. Replace envWithout("LOCALSTACK_AUTH_TOKEN") with
envWithout("LOCALSTACK_AUTH_TOKEN", "LOCALSTACK_API_ENDPOINT") so cmd.Env =
append(envWithout(...), "LOCALSTACK_API_ENDPOINT="+mockServer.URL) to ensure
only the intended endpoint is present.

@carole-lavillonniere carole-lavillonniere marked this pull request as draft February 12, 2026 15:04
@carole-lavillonniere carole-lavillonniere marked this pull request as ready for review February 12, 2026 15:25
Copy link
Member

@silv-io silv-io left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, just some minor nits :)

tag = "latest"
}
return fmt.Sprintf("%s:%s", baseImage, tag), nil
return fmt.Sprintf("localstack/%s:%s", productName, tag), nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe lets put the "localstack" somewhere as a const named vendor or just localstack to avoid typos

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this look? 9e84b5d

return path, nil
}

func (c *ContainerConfig) ProductName() (string, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: did we check that the other products are actually binding their product name to the image name? I think for snowflake it might not be just snowflake

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we do this tiny refactoring later when we need to?

if err != nil {
log.Printf("Could not resolve version from image %s: %v", containerConfig.Image, err)
if version == "" {
version = "latest"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: does this now work after all or is this just a placeholder until it does?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No that's just me committing old code 🙈 9e84b5d

Comment on lines +23 to +24
authToken := os.Getenv("LOCALSTACK_AUTH_TOKEN")
require.NotEmpty(t, authToken, "LOCALSTACK_AUTH_TOKEN must be set to run this test")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: would swap those two lines to have all the preconditions together at the start :D

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They cannot be swapped since line 24 relies on line 23, or did you mean something else?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤦🏼 sorry, misread it

Anyways, we could use os.LookupEnv here instead because it doesn't swallow away the result of the syscall. Would be more accurate I guess. I'll leave it up to you

// Getenv retrieves the value of the environment variable named by the key.
// It returns the value, which will be empty if the variable is not present.
// To distinguish between an empty value and an unset value, use [LookupEnv].
func Getenv(key string) string {
	testlog.Getenv(key)
	v, _ := syscall.Getenv(key)
	return v
}

// LookupEnv retrieves the value of the environment variable named
// by the key. If the variable is present in the environment the
// value (which may be empty) is returned and the boolean is true.
// Otherwise the returned value will be empty and the boolean will
// be false.
func LookupEnv(key string) (string, bool) {
	testlog.Getenv(key)
	return syscall.Getenv(key)
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would you suggest we do with the bool returned?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think with the bool we know specifically if it's not set, with getting it and checking it could either be not set or just set to empty. probably irrelevant

validationErrors := make(chan error, 1)

// Mock platform API that returns success
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's verbose but I like it

}

func envWithoutAuthToken() []string {
func envWithout(keys ...string) []string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: nice

Copy link
Member

@silv-io silv-io left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Just need to address the merge conflicts now 😬

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants