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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
145 changes: 145 additions & 0 deletions internal/auth/gcloud.go
Original file line number Diff line number Diff line change
@@ -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)
}
123 changes: 123 additions & 0 deletions internal/auth/gcloud_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
52 changes: 52 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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",
Expand Down
Loading