From 13446822b17720c888096ed750a264910c3d5148 Mon Sep 17 00:00:00 2001 From: Vardan Manucharyan Date: Tue, 16 Jun 2026 16:23:15 +0400 Subject: [PATCH] Add BigQuery auth command to the CLI --- README.md | 7 ++ internal/auth/gcloud.go | 145 +++++++++++++++++++++++++++++++++++ internal/auth/gcloud_test.go | 123 +++++++++++++++++++++++++++++ internal/cli/root.go | 52 +++++++++++++ internal/cli/root_test.go | 48 ++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 internal/auth/gcloud.go create mode 100644 internal/auth/gcloud_test.go diff --git a/README.md b/README.md index 18bdc1b..3dacadc 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,17 @@ If `$HOME/.local/bin` already exists, is writable, and is on PATH, the installer ```sh segmentstream version +segmentstream auth bigquery segmentstream update segmentstream update --check ``` +`segmentstream auth bigquery` uses the installed Google Cloud SDK to open Google +authentication in the browser. It runs gcloud with an isolated SegmentStream +config directory and stores BigQuery ADC credentials at +`$HOME/.segmentstream/gcloud/application_default_credentials.json`. The gcloud +ADC login requests the required `cloud-platform` scope plus BigQuery scope. + ## Release Publish a GitHub Release with a semver tag to build and attach release assets: diff --git a/internal/auth/gcloud.go b/internal/auth/gcloud.go new file mode 100644 index 0000000..69e0ad0 --- /dev/null +++ b/internal/auth/gcloud.go @@ -0,0 +1,145 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + CloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform" + BigQueryScope = "https://www.googleapis.com/auth/bigquery" + gcloudConfigDirName = "gcloud" + gcloudADCFileName = "application_default_credentials.json" + gcloudConfigEnv = "CLOUDSDK_CONFIG" + defaultGCloudCommand = "gcloud" +) + +var bigQueryAuthScopes = []string{CloudPlatformScope, BigQueryScope} + +type Store struct { + HomeDir string +} + +type GCloudAuthenticator struct { + Store Store + Command string + Runner CommandRunner + Out io.Writer + ErrOut io.Writer +} + +type CommandRunner func(ctx context.Context, command string, args []string, env []string, stdout, stderr io.Writer) error + +func NewGCloudAuthenticator(out, errOut io.Writer) GCloudAuthenticator { + return GCloudAuthenticator{ + Runner: defaultCommandRunner, + Out: out, + ErrOut: errOut, + } +} + +func (authenticator GCloudAuthenticator) AuthenticateBigQuery(ctx context.Context) (string, error) { + configDir, err := authenticator.Store.GCloudConfigDir() + if err != nil { + return "", err + } + if err := os.MkdirAll(configDir, 0o700); err != nil { + return "", fmt.Errorf("create gcloud config directory: %w", err) + } + + credentialsPath, err := authenticator.Store.GCloudADCPath() + if err != nil { + return "", err + } + + out := authenticator.Out + if out == nil { + out = io.Discard + } + errOut := authenticator.ErrOut + if errOut == nil { + errOut = io.Discard + } + + command := authenticator.Command + if command == "" { + command = defaultGCloudCommand + } + runner := authenticator.Runner + if runner == nil { + runner = defaultCommandRunner + } + + fmt.Fprintln(out, "Opening Google authentication with gcloud.") + err = runner(ctx, command, []string{ + "auth", + "application-default", + "login", + "--scopes=" + strings.Join(bigQueryAuthScopes, ","), + }, []string{gcloudConfigEnv + "=" + configDir}, out, errOut) + if err != nil { + return "", explainGCloudError(err) + } + + if _, err := os.Stat(credentialsPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("gcloud completed but ADC credentials were not found at %s", credentialsPath) + } + return "", fmt.Errorf("check ADC credentials: %w", err) + } + + fmt.Fprintf(out, "Saved BigQuery credentials to %s\n", credentialsPath) + return credentialsPath, nil +} + +func (store Store) GCloudConfigDir() (string, error) { + home, err := store.homeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".segmentstream", gcloudConfigDirName), nil +} + +func (store Store) GCloudADCPath() (string, error) { + configDir, err := store.GCloudConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, gcloudADCFileName), nil +} + +func (store Store) homeDir() (string, error) { + if store.HomeDir != "" { + return store.HomeDir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("find home directory: %w", err) + } + return home, nil +} + +func defaultCommandRunner(ctx context.Context, command string, args []string, env []string, stdout, stderr io.Writer) error { + cmd := exec.CommandContext(ctx, command, args...) + cmd.Env = append(os.Environ(), env...) + cmd.Stdout = stdout + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + return err + } + return nil +} + +func explainGCloudError(err error) error { + var execErr *exec.Error + if errors.As(err, &execErr) && errors.Is(execErr.Err, exec.ErrNotFound) { + return errors.New("gcloud was not found on PATH; install Google Cloud SDK before running segmentstream auth bigquery") + } + return fmt.Errorf("run gcloud application default login: %w", err) +} diff --git a/internal/auth/gcloud_test.go b/internal/auth/gcloud_test.go new file mode 100644 index 0000000..364273e --- /dev/null +++ b/internal/auth/gcloud_test.go @@ -0,0 +1,123 @@ +package auth + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestStoreGCloudADCPath(t *testing.T) { + home := t.TempDir() + got, err := (Store{HomeDir: home}).GCloudADCPath() + if err != nil { + t.Fatalf("GCloudADCPath failed: %v", err) + } + + want := filepath.Join(home, ".segmentstream", "gcloud", "application_default_credentials.json") + if got != want { + t.Fatalf("path = %q, want %q", got, want) + } +} + +func TestAuthenticateBigQueryRunsGCloudWithIsolatedConfig(t *testing.T) { + home := t.TempDir() + var gotCommand string + var gotArgs []string + var gotEnv []string + + authenticator := GCloudAuthenticator{ + Store: Store{HomeDir: home}, + Command: "gcloud-test", + Runner: func(ctx context.Context, command string, args []string, env []string, stdout, stderr io.Writer) error { + gotCommand = command + gotArgs = append([]string(nil), args...) + gotEnv = append([]string(nil), env...) + + credentialsPath, err := (Store{HomeDir: home}).GCloudADCPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(credentialsPath), 0o700); err != nil { + return err + } + return os.WriteFile(credentialsPath, []byte(`{"type":"authorized_user"}`), 0o600) + }, + } + + path, err := authenticator.AuthenticateBigQuery(context.Background()) + if err != nil { + t.Fatalf("AuthenticateBigQuery failed: %v", err) + } + + wantPath := filepath.Join(home, ".segmentstream", "gcloud", "application_default_credentials.json") + if path != wantPath { + t.Fatalf("path = %q, want %q", path, wantPath) + } + if gotCommand != "gcloud-test" { + t.Fatalf("command = %q, want gcloud-test", gotCommand) + } + wantArgs := []string{"auth", "application-default", "login", "--scopes=" + strings.Join(bigQueryAuthScopes, ",")} + if strings.Join(gotArgs, "\x00") != strings.Join(wantArgs, "\x00") { + t.Fatalf("args = %#v, want %#v", gotArgs, wantArgs) + } + wantEnv := gcloudConfigEnv + "=" + filepath.Join(home, ".segmentstream", "gcloud") + if len(gotEnv) != 1 || gotEnv[0] != wantEnv { + t.Fatalf("env = %#v, want %#v", gotEnv, []string{wantEnv}) + } +} + +func TestAuthenticateBigQueryRequiresGeneratedCredentials(t *testing.T) { + authenticator := GCloudAuthenticator{ + Store: Store{HomeDir: t.TempDir()}, + Runner: func(ctx context.Context, command string, args []string, env []string, stdout, stderr io.Writer) error { + return nil + }, + } + + _, err := authenticator.AuthenticateBigQuery(context.Background()) + if err == nil { + t.Fatal("expected missing credentials error") + } + if !strings.Contains(err.Error(), "ADC credentials were not found") { + t.Fatalf("error = %v, want missing credentials", err) + } +} + +func TestAuthenticateBigQueryExplainsMissingGCloud(t *testing.T) { + authenticator := GCloudAuthenticator{ + Store: Store{HomeDir: t.TempDir()}, + Runner: func(ctx context.Context, command string, args []string, env []string, stdout, stderr io.Writer) error { + return &exec.Error{Name: command, Err: exec.ErrNotFound} + }, + } + + _, err := authenticator.AuthenticateBigQuery(context.Background()) + if err == nil { + t.Fatal("expected gcloud missing error") + } + if !strings.Contains(err.Error(), "gcloud was not found") { + t.Fatalf("error = %v, want gcloud guidance", err) + } +} + +func TestAuthenticateBigQueryWrapsRunnerError(t *testing.T) { + authenticator := GCloudAuthenticator{ + Store: Store{HomeDir: t.TempDir()}, + Runner: func(ctx context.Context, command string, args []string, env []string, stdout, stderr io.Writer) error { + return errors.New("boom") + }, + } + + _, err := authenticator.AuthenticateBigQuery(context.Background()) + if err == nil { + t.Fatal("expected runner error") + } + if !strings.Contains(err.Error(), "run gcloud application default login") { + t.Fatalf("error = %v, want wrapped runner error", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index d5c2153..4b45d62 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,10 +1,12 @@ package cli import ( + "context" "fmt" "io" "os" + "github.com/segmentstream/segmentstream-cli/internal/auth" "github.com/segmentstream/segmentstream-cli/internal/update" "github.com/segmentstream/segmentstream-cli/internal/version" "github.com/spf13/cobra" @@ -15,19 +17,69 @@ func Execute() error { } func NewRootCommand(out, errOut io.Writer) *cobra.Command { + return newRootCommand(out, errOut, cliOptions{}) +} + +type bigQueryAuthenticator interface { + AuthenticateBigQuery(context.Context) (string, error) +} + +type cliOptions struct { + NewBigQueryAuthenticator func(io.Writer, io.Writer) bigQueryAuthenticator +} + +func newRootCommand(out, errOut io.Writer, options cliOptions) *cobra.Command { root := &cobra.Command{ Use: "segmentstream", Short: "CLI for SegmentStream marketing analytics", SilenceUsage: true, SilenceErrors: true, } + if out != nil { + root.SetOut(out) + } + if errOut != nil { + root.SetErr(errOut) + } root.AddCommand(newVersionCommand(out)) root.AddCommand(newUpdateCommand(out, errOut)) + root.AddCommand(newAuthCommand(out, errOut, options)) return root } +func newAuthCommand(out io.Writer, errOut io.Writer, options cliOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Authenticate data source credentials", + } + + cmd.AddCommand(newAuthBigQueryCommand(out, errOut, options)) + + return cmd +} + +func newAuthBigQueryCommand(out io.Writer, errOut io.Writer, options cliOptions) *cobra.Command { + return &cobra.Command{ + Use: "bigquery", + Short: "Authenticate BigQuery credentials", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + factory := options.NewBigQueryAuthenticator + if factory == nil { + factory = func(out, errOut io.Writer) bigQueryAuthenticator { + authenticator := auth.NewGCloudAuthenticator(out, errOut) + return authenticator + } + } + + _, err := factory(out, errOut).AuthenticateBigQuery(cmd.Context()) + return err + }, + } +} + func newVersionCommand(out io.Writer) *cobra.Command { return &cobra.Command{ Use: "version", diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 6cdecab..56e5b6b 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -2,6 +2,8 @@ package cli import ( "bytes" + "context" + "io" "strings" "testing" ) @@ -29,3 +31,49 @@ func TestVersionCommand(t *testing.T) { } } } + +func TestAuthCommandIncludesBigQuery(t *testing.T) { + var out bytes.Buffer + var errOut bytes.Buffer + + cmd := NewRootCommand(&out, &errOut) + cmd.SetArgs([]string{"auth", "--help"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("auth help failed: %v", err) + } + if !strings.Contains(out.String(), "bigquery") { + t.Fatalf("auth help %q does not include bigquery", out.String()) + } +} + +func TestAuthBigQueryCommandRunsAuthenticator(t *testing.T) { + var out bytes.Buffer + var errOut bytes.Buffer + authenticator := &fakeBigQueryAuthenticator{path: "/tmp/google.json"} + + cmd := newRootCommand(&out, &errOut, cliOptions{ + NewBigQueryAuthenticator: func(io.Writer, io.Writer) bigQueryAuthenticator { + return authenticator + }, + }) + cmd.SetArgs([]string{"auth", "bigquery"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("auth bigquery failed: %v", err) + } + if !authenticator.called { + t.Fatal("authenticator was not called") + } +} + +type fakeBigQueryAuthenticator struct { + called bool + path string + err error +} + +func (authenticator *fakeBigQueryAuthenticator) AuthenticateBigQuery(context.Context) (string, error) { + authenticator.called = true + return authenticator.path, authenticator.err +}