Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"

"github.com/localstack/lstk/internal/api"
"github.com/localstack/lstk/internal/auth"
"github.com/localstack/lstk/internal/output"
"github.com/spf13/cobra"
Expand All @@ -15,7 +16,8 @@ var loginCmd = &cobra.Command{
Long: "Authenticate with LocalStack and store credentials in system keyring",
RunE: func(cmd *cobra.Command, args []string) error {
sink := output.NewPlainSink(os.Stdout)
a, err := auth.New(sink)
platformClient := api.NewPlatformClient()
a, err := auth.New(sink, platformClient)
if err != nil {
return fmt.Errorf("failed to initialize auth: %w", err)
}
Expand Down
4 changes: 3 additions & 1 deletion cmd/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"

"github.com/localstack/lstk/internal/api"
"github.com/localstack/lstk/internal/auth"
"github.com/localstack/lstk/internal/output"
"github.com/spf13/cobra"
Expand All @@ -14,7 +15,8 @@ var logoutCmd = &cobra.Command{
Short: "Remove stored authentication token",
RunE: func(cmd *cobra.Command, args []string) error {
sink := output.NewPlainSink(os.Stdout)
a, err := auth.New(sink)
platformClient := api.NewPlatformClient()
a, err := auth.New(sink, platformClient)
if err != nil {
return fmt.Errorf("failed to initialize auth: %w", err)
}
Expand Down
3 changes: 2 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"

"github.com/localstack/lstk/internal/api"
"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/container"
"github.com/localstack/lstk/internal/output"
Expand Down Expand Up @@ -42,5 +43,5 @@ func Execute(ctx context.Context) error {
}

func runStart(ctx context.Context, rt runtime.Runtime) error {
return container.Start(ctx, rt, output.NewPlainSink(os.Stdout))
return container.Start(ctx, rt, output.NewPlainSink(os.Stdout), api.NewPlatformClient())
}
3 changes: 3 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ export LOCALSTACK_AUTH_TOKEN=ls-...

# Force file-based keyring backend (instead of system keychain)
# export KEYRING=file
#
export LOCALSTACK_API_ENDPOINT=https://api.staging.aws.localstack.cloud
export LOCALSTACK_WEB_APP_URL=https://app.staging.aws.localstack.cloud
56 changes: 56 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type PlatformAPI interface {
CheckAuthRequestConfirmed(ctx context.Context, id, exchangeToken string) (bool, error)
ExchangeAuthRequest(ctx context.Context, id, exchangeToken string) (string, error)
GetLicenseToken(ctx context.Context, bearerToken string) (string, error)
GetLicense(ctx context.Context, req *LicenseRequest) error
}

type AuthRequest struct {
Expand All @@ -37,6 +38,27 @@ type licenseTokenResponse struct {
Token string `json:"token"`
}

type LicenseRequest struct {
Product ProductInfo `json:"product"`
Credentials CredentialsInfo `json:"credentials"`
Machine MachineInfo `json:"machine"`
}

type ProductInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}

type CredentialsInfo struct {
Token string `json:"token"`
}

type MachineInfo struct {
Hostname string `json:"hostname,omitempty"`
Platform string `json:"platform,omitempty"`
PlatformRelease string `json:"platform_release,omitempty"`
}

type PlatformClient struct {
baseURL string
httpClient *http.Client
Expand Down Expand Up @@ -173,3 +195,37 @@ func (c *PlatformClient) GetLicenseToken(ctx context.Context, bearerToken string

return tokenResp.Token, nil
}

func (c *PlatformClient) GetLicense(ctx context.Context, licReq *LicenseRequest) error {
body, err := json.Marshal(licReq)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/license/request", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")

resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to request license: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Printf("failed to close response body: %v", err)
}
}()

switch resp.StatusCode {
case http.StatusOK:
return nil
case http.StatusBadRequest:
return fmt.Errorf("license validation failed: invalid token format, missing license assignment, or missing subscription")
case http.StatusForbidden:
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)
}
}
5 changes: 3 additions & 2 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"

"github.com/99designs/keyring"
"github.com/localstack/lstk/internal/api"
"github.com/localstack/lstk/internal/output"
)

Expand All @@ -16,14 +17,14 @@ type Auth struct {
sink output.Sink
}

func New(sink output.Sink) (*Auth, error) {
func New(sink output.Sink, platformClient api.PlatformAPI) (*Auth, error) {
kr, err := newSystemKeyring()
if err != nil {
return nil, err
}
return &Auth{
keyring: kr,
browserLogin: newBrowserLogin(sink),
browserLogin: newBrowserLogin(sink, platformClient),
sink: sink,
}, nil
}
Expand Down
4 changes: 2 additions & 2 deletions internal/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ type browserLogin struct {
sink output.Sink
}

func newBrowserLogin(sink output.Sink) *browserLogin {
func newBrowserLogin(sink output.Sink, platformClient api.PlatformAPI) *browserLogin {
return &browserLogin{
platformClient: api.NewPlatformClient(),
platformClient: platformClient,
sink: sink,
}
}
Expand Down
20 changes: 15 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ const (
EmulatorAWS EmulatorType = "aws"
EmulatorSnowflake EmulatorType = "snowflake"
EmulatorAzure EmulatorType = "azure"

dockerRegistry = "localstack"
)

var emulatorImages = map[EmulatorType]string{
EmulatorAWS: "localstack/localstack-pro",
EmulatorAWS: "localstack-pro",
}

var emulatorHealthPaths = map[EmulatorType]string{
Expand All @@ -36,15 +38,15 @@ type ContainerConfig struct {
}

func (c *ContainerConfig) Image() (string, error) {
baseImage, 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("%s:%s", baseImage, tag), nil
return fmt.Sprintf("%s/%s:%s", dockerRegistry, productName, tag), nil
}

// Name returns the container name: "localstack-{type}" or "localstack-{type}-{tag}" if tag != latest
Expand All @@ -64,6 +66,14 @@ func (c *ContainerConfig) HealthPath() (string, error) {
return path, nil
}

func (c *ContainerConfig) ProductName() (string, error) {
Copy link
Copy Markdown
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
Copy Markdown
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?

productName, ok := emulatorImages[c.Type]
if !ok {
return "", fmt.Errorf("%s emulator not supported yet by lstk", c.Type)
}
return productName, nil
}

func ConfigDir() (string, error) {
configHome, err := os.UserConfigDir()
if err != nil {
Expand Down
58 changes: 56 additions & 2 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ import (
"context"
"fmt"
"net/http"
"os"
stdruntime "runtime"
"time"

"github.com/containerd/errdefs"
"github.com/localstack/lstk/internal/api"
"github.com/localstack/lstk/internal/auth"
"github.com/localstack/lstk/internal/config"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
)

func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink) error {
a, err := auth.New(sink)
func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI) error {
a, err := auth.New(sink, platformClient)
if err != nil {
return fmt.Errorf("failed to initialize auth: %w", err)
}
Expand Down Expand Up @@ -50,6 +53,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink) error {
}
}

// Pull all images first
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Make the “Pull all images first” comment explain the why.

The current comment restates the code; consider adding the rationale (e.g., needed to resolve tags) so it’s not just descriptive.

✍️ Example rewording
-	// Pull all images first
+	// Pull images first so tag resolution (e.g., "latest") can read local image metadata before license validation.

As per coding guidelines, "Avoid adding comments for self-explanatory code; only comment when the 'why' isn't obvious from the code itself".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Pull all images first
// Pull images first so tag resolution (e.g., "latest") can read local image metadata before license validation.
🤖 Prompt for AI Agents
In `@internal/container/start.go` at line 56, Replace the terse comment "Pull all
images first" with a why-focused comment explaining that images are pulled
up-front to resolve image tags/digests and ensure the exact images are available
before creating containers (preventing container creation failures or
non-deterministic runs due to late tag resolution or network issues); update the
comment located at the "Pull all images first" line in
internal/container/start.go so it explicitly mentions resolving tags/digests and
avoiding create-time failures.

for _, config := range containers {
// Remove any existing stopped container with the same name
if err := rt.Remove(ctx, config.Name); err != nil && !errdefs.IsNotFound(err) {
Expand All @@ -66,7 +70,18 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink) error {
if err := rt.PullImage(ctx, config.Image, progress); err != nil {
return fmt.Errorf("failed to pull image %s: %w", config.Image, err)
}
}

// TODO validate license for tag "latest" without resolving the actual image version,
// and avoid pulling all images first
for i, c := range cfg.Containers {
if err := validateLicense(ctx, rt, sink, platformClient, containers[i], &c, token); err != nil {
return err
}
}

// Start containers
for _, config := range containers {
output.EmitStatus(sink, "starting", config.Name, "")
containerID, err := rt.Start(ctx, config)
if err != nil {
Expand All @@ -85,6 +100,45 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink) error {
return nil
}

func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, containerConfig runtime.ContainerConfig, cfgContainer *config.ContainerConfig, token string) error {
version := cfgContainer.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
}

productName, err := cfgContainer.ProductName()
if err != nil {
return err
}
output.EmitStatus(sink, "validating license", containerConfig.Name, version)

hostname, _ := os.Hostname()
licenseReq := &api.LicenseRequest{
Comment on lines +119 to +120
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Handle os.Hostname() errors instead of discarding them.

This function returns an error; it should be checked and either surfaced or downgraded to a warning with a safe fallback.

✅ Suggested handling
-	hostname, _ := os.Hostname()
+	hostname, err := os.Hostname()
+	if err != nil {
+		output.EmitWarning(sink, fmt.Sprintf("failed to read hostname: %v", err))
+		hostname = ""
+	}

As per coding guidelines, "Errors returned by functions should always be checked unless in test files".

🤖 Prompt for AI Agents
In `@internal/container/start.go` around lines 119 - 120, The call to
os.Hostname() currently ignores its error; update the code around the hostname
variable so you capture the error (e.g., hostname, err := os.Hostname()), check
err, and handle it: either return/surface the error from the current function or
log a warning (using the existing logger) and set a safe fallback like
"unknown-host" before building licenseReq; ensure the log includes the error
value and keep using hostname when constructing the LicenseRequest.

Product: api.ProductInfo{
Name: productName,
Version: version,
},
Credentials: api.CredentialsInfo{
Token: token,
},
Machine: api.MachineInfo{
Hostname: hostname,
Platform: stdruntime.GOOS,
PlatformRelease: stdruntime.GOARCH,
},
}

if err := platformClient.GetLicense(ctx, licenseReq); err != nil {
return fmt.Errorf("license validation failed for %s:%s: %w", productName, version, err)
}

return nil
}

// awaitStartup polls until one of two outcomes:
// - Success: health endpoint returns 200 (license is valid, LocalStack is ready)
// - Failure: container stops running (e.g., license activation failed), returns error with container logs
Expand Down
19 changes: 19 additions & 0 deletions internal/runtime/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"log"
"strconv"
"strings"

"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
Expand Down Expand Up @@ -148,3 +149,21 @@ func (d *DockerRuntime) Logs(ctx context.Context, containerID string, tail int)

return string(logs), nil
}

func (d *DockerRuntime) GetImageVersion(ctx context.Context, imageName string) (string, error) {
inspect, err := d.client.ImageInspect(ctx, imageName)
if err != nil {
return "", fmt.Errorf("failed to inspect image: %w", err)
}

// Get version from LOCALSTACK_BUILD_VERSION environment variable
if inspect.Config != nil && inspect.Config.Env != nil {
for _, env := range inspect.Config.Env {
if strings.HasPrefix(env, "LOCALSTACK_BUILD_VERSION=") {
return strings.TrimPrefix(env, "LOCALSTACK_BUILD_VERSION="), nil
}
}
}

return "", fmt.Errorf("LOCALSTACK_BUILD_VERSION not found in image environment")
}
1 change: 1 addition & 0 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ type Runtime interface {
Remove(ctx context.Context, containerName string) error
IsRunning(ctx context.Context, containerID string) (bool, error)
Logs(ctx context.Context, containerID string, tail int) (string, error)
GetImageVersion(ctx context.Context, imageName string) (string, error)
}
Loading
Loading