Skip to content

Remove global state#66

Open
carole-lavillonniere wants to merge 2 commits intomainfrom
deps-inj
Open

Remove global state#66
carole-lavillonniere wants to merge 2 commits intomainfrom
deps-inj

Conversation

@carole-lavillonniere
Copy link
Collaborator

@carole-lavillonniere carole-lavillonniere commented Mar 3, 2026

Switched to a more idiomatic go and stateless dependency injection:

  • All commands are now freshly created instead of package-level vars. Each test gets a fresh/isolated instance and no state is accidentally kept between tests.
  • All env vars required are passed as parameters rather than reading globals. Each test gets exactly what it needs and there is no more risk of changing an env var affecting another test.

closes PRO-226

@carole-lavillonniere carole-lavillonniere changed the title Eliminate global state: constructor-based commands and injected env config Eliminate global state Mar 3, 2026
@carole-lavillonniere carole-lavillonniere changed the title Eliminate global state Remove global state Mar 3, 2026
@carole-lavillonniere carole-lavillonniere marked this pull request as ready for review March 3, 2026 15:23
@coderabbitai
Copy link

coderabbitai bot commented Mar 3, 2026

📝 Walkthrough

Walkthrough

The PR refactors command registration from init-time global variables to constructor functions and centralizes environment configuration. Commands now receive configuration via parameters instead of relying on global state. The root command is created dynamically via NewRootCmd(cfg), which constructs all subcommands with passed configuration and executes with explicit context management.

Changes

Cohort / File(s) Summary
Command Constructor Refactoring
cmd/config.go, cmd/logout.go, cmd/logs.go, cmd/start.go, cmd/stop.go, cmd/version.go
Converted static command variables with init() registration to factory functions (newXxxCmd). Most constructors accept no parameters; newStartCmd() and newLogoutCmd() accept *env.Env for configuration injection.
Root Command Architecture
cmd/root.go
Introduced NewRootCmd(cfg *env.Env) constructor that dynamically creates and wires all subcommands. Updated runStart() signature to accept cfg *env.Env. Execute path now instantiates config via env.Init() and passes it to NewRootCmd.
Login Command and API Client
cmd/login.go, internal/api/client.go
Updated login command to use constructor newLoginCmd(cfg *env.Env). Modified NewPlatformClient() to accept apiEndpoint parameter instead of reading from global env. Extended ui.RunLogin() signature to include authToken, forceFileKeyring, and webAppURL.
Authentication Layer
internal/auth/auth.go, internal/auth/login.go, internal/auth/token_storage.go
Updated auth.New() to accept authToken and webAppURL parameters. Added webAppURL field to loginProvider and removed env dependency. Changed NewTokenStorage() to accept forceFileBackend boolean instead of reading from env.
Container and UI Integration
internal/container/start.go, internal/ui/run.go, internal/ui/run_login.go, internal/ui/run_logout.go
Extended Start and Run signatures to accept authToken, forceFileBackend, and webAppURL parameters. Updated token storage and auth initialization to use passed parameters. Expanded RunLogout to accept platformClient and configuration parameters.
Environment Centralization
internal/env/env.go
Changed Init() to return *Env instead of mutating global Vars. Added ForceFileKeyring boolean field and removed Keyring string field. Removed global Vars variable.
Test Updates
cmd/help_test.go, internal/container/start_test.go, internal/ui/run_login_test.go
Updated test instantiation to use NewRootCmd() constructor. Removed env setup boilerplate and updated function calls to match new signatures with additional parameters.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • silv-io
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Remove global state' clearly summarizes the main refactoring objective of converting package-level variables and globals to dependency injection patterns throughout the codebase.
Description check ✅ Passed The description directly relates to the changeset, explaining the switch to idiomatic Go with stateless dependency injection, commands as constructors, and env vars as parameters.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch deps-inj

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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: 5

🧹 Nitpick comments (2)
internal/api/client.go (1)

70-73: Normalize apiEndpoint in the constructor to avoid double-slash URLs.

If callers provide a trailing slash, request URLs become //v1/.... Trimming once at construction avoids that class of issues.

Proposed patch
 import (
 	"bytes"
 	"context"
 	"encoding/json"
 	"fmt"
 	"log"
 	"net/http"
+	"strings"
 	"time"

 	"github.com/localstack/lstk/internal/version"
 )
@@
 func NewPlatformClient(apiEndpoint string) *PlatformClient {
 	return &PlatformClient{
-		baseURL:    apiEndpoint,
+		baseURL:    strings.TrimRight(apiEndpoint, "/"),
 		httpClient: &http.Client{Timeout: 30 * time.Second},
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/client.go` around lines 70 - 73, The constructor
NewPlatformClient should normalize the apiEndpoint to avoid double-slash URLs;
update NewPlatformClient to trim any trailing slash from the apiEndpoint before
assigning it to PlatformClient.baseURL (e.g., use strings.TrimRight/TrimSuffix)
so subsequent request path joins do not produce `//v1/...`; change the
assignment in NewPlatformClient to set baseURL to the trimmed value and import
the strings package if needed.
cmd/logout.go (1)

32-32: Use cfg.WebAppURL instead of a hard-coded empty URL in auth.New.

Passing "" at Line 32 makes logout behavior depend on auth.New never requiring/validating that field. Supplying cfg.WebAppURL keeps constructor inputs consistent across auth flows and reduces hidden coupling.

♻️ Proposed change
-			a := auth.New(sink, platformClient, tokenStorage, cfg.AuthToken, "", false)
+			a := auth.New(sink, platformClient, tokenStorage, cfg.AuthToken, cfg.WebAppURL, false)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/logout.go` at line 32, Replace the hard-coded empty string passed into
auth.New with the configured web app URL to keep constructor inputs consistent:
update the call to auth.New (currently a := auth.New(sink, platformClient,
tokenStorage, cfg.AuthToken, "", false)) to pass cfg.WebAppURL instead of "" so
logout uses the same WebApp URL as other auth flows and avoids hidden coupling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmd/root.go`:
- Around line 25-37: The fmt.Fprintf calls that write errors to stderr after
runtime.NewDockerRuntime() and after runStart() must have their returned values
checked; locate the two fmt.Fprintf(...) usages in the Run function (surrounding
runtime.NewDockerRuntime and the runStart error handling that uses
output.IsSilent) and replace the ignored write with a checked write (capture the
returned n, err := fmt.Fprintf(...)) and handle any write error before calling
os.Exit(1) — e.g., if fmt.Fprintf returns an error, attempt a fallback write
(such as fmt.Fprintln(os.Stderr, "failed to write to stderr:", writeErr)) or log
the write failure so the process still exits with the intended status; ensure
both occurrences are updated so no write errors are ignored.
- Around line 63-64: The code calls env.Init() before config.Init(), causing cfg
to capture defaults instead of config-file overrides; update NewRootCmd/command
wiring so environment initialization runs after configuration is loaded—either
call config.Init() before env.Init() or move env.Init() into the command's
PreRunE (e.g., after initConfig runs) so runStart and any use of cfg reflect
config-file values; locate env.Init(), config.Init(), NewRootCmd, PreRunE, and
runStart in the diff and reorder or invoke env.Init() from PreRunE (after
initConfig) accordingly.

In `@cmd/stop.go`:
- Line 26: The call to fmt.Println(msg) drops its returned error; change it to
check and handle the error (e.g. if _, err := fmt.Println(msg); err != nil {
return err } or log the error and os.Exit(1) depending on the surrounding
function's error-handling pattern) so write failures (broken pipe, etc.) are not
ignored; update the statement that currently reads fmt.Println(msg) to perform
the error check and propagate or exit consistently with the caller.
- Around line 25-29: The stop command currently creates an onProgress func and
prints directly, then passes that callback into container.Stop; instead, remove
the UI callback from the domain call and route progress via the typed
internal/output event sinks at the cmd boundary: stop building an event sink (or
use the existing internal/output emitter) in the cmd/ stop handler,
subscribe/send progress events there, and change the call site so
container.Stop(ctx, rt) no longer accepts onProgress; update container.Stop (and
any downstream domain functions) to emit typed progress events via the existing
output/event interfaces rather than receiving a UI callback (references:
onProgress, container.Stop, internal/output event sinks).

In `@internal/auth/auth.go`:
- Around line 65-70: The logout path currently treats keyring deletion as a full
logout even when an injected token (a.authToken) remains set, causing a false
success report; in the blocks around token retrieval and keyring deletion
(references: a.tokenStorage.GetAuthToken, a.authToken, output.EmitNote), change
the control flow so that if a.authToken != "" you do NOT report a successful
logout — instead emit a clear warning/note that an injected
LOCALSTACK_AUTH_TOKEN remains active and return a non-success result (or an
explicit error/exit) so callers/users are informed that logout is incomplete;
apply the same fix to the other identical branch around lines 84-86.

---

Nitpick comments:
In `@cmd/logout.go`:
- Line 32: Replace the hard-coded empty string passed into auth.New with the
configured web app URL to keep constructor inputs consistent: update the call to
auth.New (currently a := auth.New(sink, platformClient, tokenStorage,
cfg.AuthToken, "", false)) to pass cfg.WebAppURL instead of "" so logout uses
the same WebApp URL as other auth flows and avoids hidden coupling.

In `@internal/api/client.go`:
- Around line 70-73: The constructor NewPlatformClient should normalize the
apiEndpoint to avoid double-slash URLs; update NewPlatformClient to trim any
trailing slash from the apiEndpoint before assigning it to
PlatformClient.baseURL (e.g., use strings.TrimRight/TrimSuffix) so subsequent
request path joins do not produce `//v1/...`; change the assignment in
NewPlatformClient to set baseURL to the trimmed value and import the strings
package if needed.

ℹ️ Review info

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3ac17a7 and fc5caa1.

📒 Files selected for processing (20)
  • cmd/config.go
  • cmd/help_test.go
  • cmd/login.go
  • cmd/logout.go
  • cmd/logs.go
  • cmd/root.go
  • cmd/start.go
  • cmd/stop.go
  • cmd/version.go
  • internal/api/client.go
  • internal/auth/auth.go
  • internal/auth/login.go
  • internal/auth/token_storage.go
  • internal/container/start.go
  • internal/container/start_test.go
  • internal/env/env.go
  • internal/ui/run.go
  • internal/ui/run_login.go
  • internal/ui/run_login_test.go
  • internal/ui/run_logout.go

Comment on lines +25 to +37
Run: func(cmd *cobra.Command, args []string) {
rt, err := runtime.NewDockerRuntime()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
os.Exit(1)
}
},
}

func init() {
rootCmd.Version = version.Version()
rootCmd.SetVersionTemplate(versionLine() + "\n")
if err := runStart(cmd.Context(), rt, cfg); err != nil {
if !output.IsSilent(err) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
os.Exit(1)
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Check stderr write errors before exiting.

fmt.Fprintf results are ignored on Line 28 and Line 34. Please check and handle write errors explicitly.

🛠️ Minimal fix
 			if err != nil {
-				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+				if _, writeErr := fmt.Fprintf(os.Stderr, "Error: %v\n", err); writeErr != nil {
+					// best-effort logging; continue exiting with original error
+				}
 				os.Exit(1)
 			}
@@
 			if err := runStart(cmd.Context(), rt, cfg); err != nil {
 				if !output.IsSilent(err) {
-					fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+					if _, writeErr := fmt.Fprintf(os.Stderr, "Error: %v\n", err); writeErr != nil {
+						// best-effort logging; continue exiting with original error
+					}
 				}
 				os.Exit(1)
 			}

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

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/root.go` around lines 25 - 37, The fmt.Fprintf calls that write errors to
stderr after runtime.NewDockerRuntime() and after runStart() must have their
returned values checked; locate the two fmt.Fprintf(...) usages in the Run
function (surrounding runtime.NewDockerRuntime and the runStart error handling
that uses output.IsSilent) and replace the ignored write with a checked write
(capture the returned n, err := fmt.Fprintf(...)) and handle any write error
before calling os.Exit(1) — e.g., if fmt.Fprintf returns an error, attempt a
fallback write (such as fmt.Fprintln(os.Stderr, "failed to write to stderr:",
writeErr)) or log the write failure so the process still exits with the intended
status; ensure both occurrences are updated so no write errors are ignored.

Comment on lines +63 to +64
cfg := env.Init()
return NewRootCmd(cfg).ExecuteContext(ctx)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n cmd/root.go | head -80

Repository: localstack/lstk

Length of output: 2558


🏁 Script executed:

cat -n internal/env/env.go | head -100

Repository: localstack/lstk

Length of output: 1185


🏁 Script executed:

rg -n 'func Init|config\.|viper\.|os\.Getenv|LookupEnv' internal/env/env.go -A 2 -B 1

Repository: localstack/lstk

Length of output: 739


🏁 Script executed:

rg -n 'func initConfig|PreRunE' cmd/root.go -A 5

Repository: localstack/lstk

Length of output: 360


🏁 Script executed:

fd -e go internal/config | head -20

Repository: localstack/lstk

Length of output: 424


🏁 Script executed:

rg -n 'func Init' internal/config -A 10 | head -50

Repository: localstack/lstk

Length of output: 666


🏁 Script executed:

rg -n 'func loadConfig|func firstExistingConfigPath' internal/config -A 15

Repository: localstack/lstk

Length of output: 1763


🏁 Script executed:

cat -n internal/config/config.go | head -80

Repository: localstack/lstk

Length of output: 2186


Move env.Init() to after config.Init() is guaranteed to complete, or restructure to wire env initialization in the command's PreRunE.

The current code captures env values at line 63 before config.Init() has loaded any config file (which happens later via PreRunE at line 24). This means cfg captures defaults set by env.Init() only, not values that may exist in the config file for api_endpoint, web_app_url, or keyring. When runStart (lines 67–72) uses these stale values, it won't reflect config-file overrides.

Either call config.Init() before env.Init(), or move env initialization into PreRunE to ensure config is loaded first—consistent with the guideline: "When adding a new command that depends on configuration, wire config initialization explicitly in that command (PreRunE: initConfig)."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/root.go` around lines 63 - 64, The code calls env.Init() before
config.Init(), causing cfg to capture defaults instead of config-file overrides;
update NewRootCmd/command wiring so environment initialization runs after
configuration is loaded—either call config.Init() before env.Init() or move
env.Init() into the command's PreRunE (e.g., after initConfig runs) so runStart
and any use of cfg reflect config-file values; locate env.Init(), config.Init(),
NewRootCmd, PreRunE, and runStart in the diff and reorder or invoke env.Init()
from PreRunE (after initConfig) accordingly.

Comment on lines +25 to +29
onProgress := func(msg string) {
fmt.Println(msg)
}

if err := container.Stop(cmd.Context(), rt, onProgress); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
},
}

func init() {
rootCmd.AddCommand(stopCmd)
if err := container.Stop(cmd.Context(), rt, onProgress); err != nil {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Route stop progress/output via typed sinks, not callback-print wiring.

This segment wires onProgress func(string) and direct stdout printing in the command path; that couples presentation to command/domain flow instead of using internal/output event sinks selected at the command boundary.

As per coding guidelines "Keep CLI business logic out of cmd/ - CLI wiring only (Cobra framework) belongs in cmd/" and "Do not pass UI callbacks like onProgress func(...) through domain layers; prefer typed output events."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/stop.go` around lines 25 - 29, The stop command currently creates an
onProgress func and prints directly, then passes that callback into
container.Stop; instead, remove the UI callback from the domain call and route
progress via the typed internal/output event sinks at the cmd boundary: stop
building an event sink (or use the existing internal/output emitter) in the cmd/
stop handler, subscribe/send progress events there, and change the call site so
container.Stop(ctx, rt) no longer accepts onProgress; update container.Stop (and
any downstream domain functions) to emit typed progress events via the existing
output/event interfaces rather than receiving a UI callback (references:
onProgress, container.Stop, internal/output event sinks).

fmt.Println(msg)
}
onProgress := func(msg string) {
fmt.Println(msg)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Check the write error from fmt.Println.

Line 26 drops the returned error from fmt.Println, which can hide broken-pipe/write failures in non-interactive usage.

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

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/stop.go` at line 26, The call to fmt.Println(msg) drops its returned
error; change it to check and handle the error (e.g. if _, err :=
fmt.Println(msg); err != nil { return err } or log the error and os.Exit(1)
depending on the surrounding function's error-handling pattern) so write
failures (broken pipe, etc.) are not ignored; update the statement that
currently reads fmt.Println(msg) to perform the error check and propagate or
exit consistently with the caller.

Comment on lines 65 to 70
_, err := a.tokenStorage.GetAuthToken()
if err != nil {
output.EmitSpinnerStop(a.sink)
if env.Vars.AuthToken != "" {
if a.authToken != "" {
output.EmitNote(a.sink, "Authenticated via LOCALSTACK_AUTH_TOKEN environment variable; unset it to log out")
return nil
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Logout can report success while injected auth token still keeps auth active.

When keyring deletion succeeds and a.authToken is non-empty, the command reports full logout even though subsequent auth still resolves from the injected token.

💡 Suggested adjustment
  if err := a.tokenStorage.DeleteAuthToken(); err != nil {
    output.EmitSpinnerStop(a.sink)
    return fmt.Errorf("failed to delete auth token: %w", err)
  }

  output.EmitSpinnerStop(a.sink)
  output.EmitSuccess(a.sink, "Logged out successfully")
+ if a.authToken != "" {
+   output.EmitNote(a.sink, "Authenticated via LOCALSTACK_AUTH_TOKEN environment variable; unset it to fully log out")
+ }
  return nil

Also applies to: 84-86

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/auth/auth.go` around lines 65 - 70, The logout path currently treats
keyring deletion as a full logout even when an injected token (a.authToken)
remains set, causing a false success report; in the blocks around token
retrieval and keyring deletion (references: a.tokenStorage.GetAuthToken,
a.authToken, output.EmitNote), change the control flow so that if a.authToken !=
"" you do NOT report a successful logout — instead emit a clear warning/note
that an injected LOCALSTACK_AUTH_TOKEN remains active and return a non-success
result (or an explicit error/exit) so callers/users are informed that logout is
incomplete; apply the same fix to the other identical branch around lines 84-86.

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 so much cleaner now! Love it.

Maybe we can add explicit guidance for claude to not use global state?

AuthToken: os.Getenv("LOCALSTACK_AUTH_TOKEN"),
APIEndpoint: viper.GetString("api_endpoint"),
WebAppURL: viper.GetString("web_app_url"),
ForceFileKeyring: viper.GetString("keyring") == "file",
Copy link
Member

Choose a reason for hiding this comment

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

nice

)

func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, interactive bool) error {
func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, platformClient api.PlatformAPI, authToken string, forceFileBackend bool, webAppURL string, interactive bool) 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: here we're using forceFileBackend and in the env forceFileKeyring. Is this intentional?

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