Skip to content
Draft
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
1 change: 1 addition & 0 deletions .github/workflows/publish-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ jobs:
make build \
BINARY="${OUT_DIR}/${BINARY_NAME}" \
DEFAULT_API_URL="${CLI_DEFAULT_API_URL}" \
DEFAULT_WEB_URL="${CLI_DEFAULT_WEB_URL}" \
FIRST_PARTY_DEVICE_CLIENT_ID="${CLI_FIRST_PARTY_DEVICE_CLIENT_ID}" \
VERSION="${CLI_VERSION}" \
DATE="${BUILD_DATE}"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/volcano
/dist/
/.env
/.env.local
/.env.*
*.test
*.out
Expand Down
24 changes: 23 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,44 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
DEFAULT_API_URL ?= https://api.volcano.dev
DEFAULT_WEB_URL ?= https://volcano.dev
FIRST_PARTY_DEVICE_CLIENT_ID ?=

LDFLAGS := -s -w \
-X $(VERSION_PKG).Version=$(VERSION) \
-X $(VERSION_PKG).Commit=$(COMMIT) \
-X $(VERSION_PKG).Date=$(DATE) \
-X $(CONFIG_PKG).compiledDefaultAPIURL=$(DEFAULT_API_URL) \
-X $(CONFIG_PKG).compiledDefaultWebURL=$(DEFAULT_WEB_URL) \
-X $(CONFIG_PKG).compiledFirstPartyDeviceClientID=$(FIRST_PARTY_DEVICE_CLIENT_ID)

.PHONY: all build test api-e2e-smoke api-e2e-cloud localmode-e2e lint tidy check clean help
.PHONY: all build local test api-e2e-smoke api-e2e-cloud localmode-e2e lint tidy check clean help

all: build

build: ## Build the volcano binary into ./$(BINARY)
go build -ldflags '$(LDFLAGS)' -o $(BINARY) ./cmd/volcano

local: ## Build volcano using variables loaded from .env.local
@if [ ! -f .env.local ]; then \
echo ".env.local not found. Create one with VOLCANO_WEB_URL=... and VOLCANO_API_URL=..."; \
exit 1; \
fi; \
set -a; source .env.local; set +a; \
if [ -z "$${FIRST_PARTY_DEVICE_CLIENT_ID:-}" ] && [ -n "$${VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID:-}" ]; then \
export FIRST_PARTY_DEVICE_CLIENT_ID="$${VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID}"; \
fi; \
if [ -z "$${DEFAULT_API_URL:-}" ] && [ -n "$${VOLCANO_API_URL:-}" ]; then \
export DEFAULT_API_URL="$${VOLCANO_API_URL}"; \
fi; \
if [ -z "$${DEFAULT_WEB_URL:-}" ] && [ -n "$${VOLCANO_WEB_URL:-}" ]; then \
export DEFAULT_WEB_URL="$${VOLCANO_WEB_URL}"; \
fi; \
if [ -z "$${DEFAULT_WEB_URL:-}" ] && [[ "$${VOLCANO_API_URL:-}" == http://localhost:* || "$${VOLCANO_API_URL:-}" == http://127.0.0.1:* ]]; then \
export DEFAULT_WEB_URL="http://localhost:3000"; \
fi; \
$(MAKE) build

test: ## Run unit tests
go test ./...

Expand Down
28 changes: 28 additions & 0 deletions internal/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package api

import (
"context"
"errors"
"fmt"
"net/url"
"strings"

"github.com/Kong/volcano-cli/internal/apiclient"
Expand Down Expand Up @@ -81,3 +83,29 @@ func (c *Client) ExchangePlatformToken(ctx context.Context, authAccessToken, cli
}
return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON403)
}

// WebSignupURL builds the Volcano Web signup URL used by the CLI signup flow.
func WebSignupURL(webURL, email, next string) (string, error) {
webURL = strings.TrimRight(strings.TrimSpace(webURL), "/")
if webURL == "" {
return "", errors.New("web url cannot be empty")
}
parsed, err := url.Parse(webURL)
if err != nil {
return "", fmt.Errorf("failed to parse web url: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", errors.New("web url must use http:// or https:// scheme")
}
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/signup"
query := parsed.Query()
if email = strings.TrimSpace(email); email != "" {
query.Set("email", email)
}
if next = strings.TrimSpace(next); next != "" {
query.Set("next", next)
}
query.Set("source", "cli")
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
6 changes: 6 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ func TestStartDeviceAuthorizationNormalizesOAuthError(t *testing.T) {
require.ErrorContains(t, err, "HTTP 400: client_id is required")
}

func TestWebSignupURL(t *testing.T) {
signupURL, err := WebSignupURL("http://localhost:3000", " ted@example.com ", "/device?user_code=ABCD-EFGH")
require.NoError(t, err)
assert.Equal(t, "http://localhost:3000/signup?email=ted%40example.com&next=%2Fdevice%3Fuser_code%3DABCD-EFGH&source=cli", signupURL)
}

func TestNewClientPreservesAPIURLPathPrefix(t *testing.T) {
var sawPath string
var sawQuery string
Expand Down
47 changes: 38 additions & 9 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,36 @@ func (s Service) LoginWithToken(ctx context.Context, cfg *config.Config, token s
return Credentials{Token: token}, nil
}

// Signup runs the device login flow but opens Volcano Web's signup page first.
func (s Service) Signup(ctx context.Context, cfg *config.Config, email string, w io.Writer) (Credentials, error) {
apiURL := s.apiURL(cfg)
clientID, err := resolveDeviceClientID(apiURL)
if err != nil {
return Credentials{}, err
}
client, err := s.sessions.APIClient(apiURL, "")
if err != nil {
return Credentials{}, err
}

deviceAuth, err := client.StartDeviceAuthorization(ctx, clientID)
if err != nil {
return Credentials{}, err
}

devicePath := "/device"
if userCode := strings.TrimSpace(deviceAuth.UserCode); userCode != "" {
devicePath = "/device?" + url.Values{"user_code": []string{userCode}}.Encode()
}
signupURL, err := api.WebSignupURL(cfg.WebURL(), email, devicePath)
if err != nil {
return Credentials{}, err
}

fmt.Fprintln(w, "\nInitiating browser signup...")
return s.completeBrowserLogin(ctx, client, clientID, deviceAuth, w, signupURL)
}

// LoginWithBrowser runs the OAuth device flow and returns credentials to persist.
func (s Service) LoginWithBrowser(ctx context.Context, cfg *config.Config, w io.Writer) (Credentials, error) {
apiURL := s.apiURL(cfg)
Expand All @@ -75,7 +105,11 @@ func (s Service) LoginWithBrowser(ctx context.Context, cfg *config.Config, w io.
}

fmt.Fprintln(w, "\nInitiating browser authentication...")
return s.completeBrowserLogin(ctx, client, clientID, deviceAuth, w)
verificationURL := strings.TrimSpace(deviceAuth.VerificationUriComplete)
if verificationURL == "" {
verificationURL = strings.TrimSpace(deviceAuth.VerificationUri)
}
return s.completeBrowserLogin(ctx, client, clientID, deviceAuth, w, verificationURL)
}

// resolveDeviceClientID returns the device OAuth client id for the login flow.
Expand Down Expand Up @@ -117,16 +151,11 @@ func (s Service) apiURL(cfg *config.Config) string {
return s.sessions.APIURL(cfg)
}

func (s Service) completeBrowserLogin(ctx context.Context, client *api.Client, clientID string, deviceAuth *apiclient.DeviceAuthorizationResponse, w io.Writer) (Credentials, error) {
verificationURL := strings.TrimSpace(deviceAuth.VerificationUriComplete)
if verificationURL == "" {
verificationURL = strings.TrimSpace(deviceAuth.VerificationUri)
}

func (s Service) completeBrowserLogin(ctx context.Context, client *api.Client, clientID string, deviceAuth *apiclient.DeviceAuthorizationResponse, w io.Writer, browserURL string) (Credentials, error) {
fmt.Fprintf(w, "\nCode: %s\n", deviceAuth.UserCode)
fmt.Fprintf(w, "Opening browser: %s\n", verificationURL)
fmt.Fprintf(w, "Opening browser: %s\n", browserURL)

if err := cliruntime.OpenBrowser(s.deps, verificationURL); err != nil { //nolint:contextcheck // browser launch is fire-and-forget; auth ctx would cancel the spawned browser
if err := cliruntime.OpenBrowser(s.deps, browserURL); err != nil { //nolint:contextcheck // browser launch is fire-and-forget; auth ctx would cancel the spawned browser
fmt.Fprintln(w, "\n(If browser didn't open, visit the URL above)")
}

Expand Down
97 changes: 97 additions & 0 deletions internal/cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
package auth

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/mail"
"os/exec"
"strings"

"github.com/spf13/cobra"
Expand All @@ -21,6 +25,12 @@ type loginOptions struct {
out io.Writer
}

type signupOptions struct {
deps cliruntime.Deps
in io.Reader
out io.Writer
}

// NewLogin returns the login command.
func NewLogin(deps cliruntime.Deps) *cobra.Command {
var tokenFlag string
Expand Down Expand Up @@ -84,6 +94,93 @@ func runLogin(ctx context.Context, opts loginOptions) error {
return nil
}

// NewSignup returns the signup command.
func NewSignup(deps cliruntime.Deps) *cobra.Command {
return &cobra.Command{
Use: "signup",
Short: "Create a Volcano account",
Long: `Create a Volcano account from the CLI.

The command uses your git user.email as the default email address when available,
then opens Volcano's web signup flow in your browser.`,
RunE: func(cmd *cobra.Command, _ []string) error {
return runSignup(cmd.Context(), signupOptions{
deps: deps,
in: cmd.InOrStdin(),
out: cmd.OutOrStdout(),
})
},
}
}

func runSignup(ctx context.Context, opts signupOptions) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

reader := bufio.NewReader(opts.in)
email, err := promptSignupEmail(ctx, opts.deps, reader, opts.out)
if err != nil {
return err
}

credentials, err := cliauth.NewService(opts.deps).Signup(ctx, cfg, email, opts.out)
if err != nil {
return fmt.Errorf("signup failed: %w", err)
}

cfg.UserToken = credentials.Token
cfg.UserID = credentials.UserID
if err := cfg.Save(); err != nil {
return fmt.Errorf("failed to save credentials: %w", err)
}

output.Success(opts.out, "Signed up and logged in successfully")
output.Success(opts.out, "Credentials saved to ~/.volcano/config.json")
return nil
}

func promptSignupEmail(ctx context.Context, deps cliruntime.Deps, reader *bufio.Reader, out io.Writer) (string, error) {
defaultEmail := gitConfigEmail(ctx, deps)
if defaultEmail != "" {
fmt.Fprintf(out, "Enter your email address (press enter to continue) [%s]: ", defaultEmail)
} else {
fmt.Fprint(out, "Enter your email address: ")
}

input, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
email := strings.TrimSpace(input)
if email == "" {
email = defaultEmail
}
if email == "" {
return "", errors.New("email address is required")
}
parsed, err := mail.ParseAddress(email)
if err != nil {
return "", fmt.Errorf("invalid email address: %w", err)
}
return parsed.Address, nil
}

func gitConfigEmail(ctx context.Context, deps cliruntime.Deps) string {
runner := deps.GitCommandRunner
if runner == nil {
runner = cliruntime.CommandRunnerFunc(func(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).Output() //nolint:gosec // command name and args are static below
})
}
out, err := runner.Run(ctx, "git", "config", "--global", "user.email")
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

// NewLogout returns the logout command.
func NewLogout() *cobra.Command {
return &cobra.Command{
Expand Down
Loading
Loading