From 3a7928661a7579573a9134605cf79c0d33baad44 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 14 Jul 2025 13:43:31 +0300 Subject: [PATCH 01/23] feat: add commands --- go/cmd/cli/app/cli.go | 92 ++++++++++++++++++++ go/cmd/cli/commands/deploy.go | 129 +++++++++++++++++++++++++++ go/cmd/cli/commands/init.go | 38 ++++++++ go/cmd/cli/commands/versions.go | 149 ++++++++++++++++++++++++++++++++ go/cmd/cli/main.go | 19 ++++ 5 files changed, 427 insertions(+) create mode 100644 go/cmd/cli/app/cli.go create mode 100644 go/cmd/cli/commands/deploy.go create mode 100644 go/cmd/cli/commands/init.go create mode 100644 go/cmd/cli/commands/versions.go create mode 100644 go/cmd/cli/main.go diff --git a/go/cmd/cli/app/cli.go b/go/cmd/cli/app/cli.go new file mode 100644 index 00000000000..03fdeb74413 --- /dev/null +++ b/go/cmd/cli/app/cli.go @@ -0,0 +1,92 @@ +package app + +import ( + "context" + "fmt" + + "github.com/unkeyed/unkey/go/cmd/cli/commands" +) + +// CLI represents our command line interface +type CLI struct { + args []string +} + +// New creates a new CLI instance +func New(args []string) *CLI { + return &CLI{args: args} +} + +// Run executes the CLI +func (c *CLI) Run(ctx context.Context) error { + if len(c.args) < 2 { + PrintUsage() + return nil // Don't return error for help case + } + + command := c.args[1] + + switch command { + case "init": + return commands.Init(c.args[2:]) + case "deploy": + return commands.Deploy(ctx, c.args[2:]) + case "version": + return commands.Version(ctx, c.args[2:]) + case "help", "-h", "--help": + return c.runHelp() + default: + PrintUsage() + return fmt.Errorf("unknown command: %s", command) + } +} + +// runHelp handles the help command +func (c *CLI) runHelp() error { + if len(c.args) < 3 { + // General help + PrintUsage() + return nil + } + + // Help for specific command + helpTopic := c.args[2] + switch helpTopic { + case "init": + commands.PrintInitHelp() + case "deploy": + commands.PrintDeployHelp() + case "version": + commands.PrintVersionHelp() + default: + fmt.Printf("No help available for '%s'\n", helpTopic) + PrintUsage() + } + + return nil +} + +// PrintUsage prints general usage information +func PrintUsage() { + fmt.Println("unkey - Deploy and manage your API versions") + fmt.Println("") + fmt.Println("USAGE:") + fmt.Println(" unkey [flags]") + fmt.Println("") + fmt.Println("COMMANDS:") + fmt.Println(" init Initialize configuration file") + fmt.Println(" deploy Deploy a new version") + fmt.Println(" version Manage API versions") + fmt.Println(" help Show help information") + fmt.Println("") + fmt.Println("FLAGS:") + fmt.Println(" -h, --help Show help") + fmt.Println("") + fmt.Println("EXAMPLES:") + fmt.Println(" unkey help") + fmt.Println(" unkey help deploy") + fmt.Println(" unkey init") + fmt.Println(" unkey deploy --workspace-id=ws_123 --project-id=proj_456") + fmt.Println("") + fmt.Println("For detailed help on a command, use 'unkey help '") +} diff --git a/go/cmd/cli/commands/deploy.go b/go/cmd/cli/commands/deploy.go new file mode 100644 index 00000000000..59752ebb393 --- /dev/null +++ b/go/cmd/cli/commands/deploy.go @@ -0,0 +1,129 @@ +package commands + +import ( + "context" + "flag" + "fmt" +) + +// DeployOptions holds all deployment configuration +type DeployOptions struct { + WorkspaceID string + ProjectID string + Context string + Branch string + DockerImage string + Dockerfile string + Commit string + ControlPlaneURL string + AuthToken string +} + +// Deploy handles the deploy command +func Deploy(ctx context.Context, args []string) error { + opts, err := parseDeployFlags("deploy", args) + if err != nil { + return err + } + + return executeDeploy(ctx, opts) +} + +// executeDeploy performs the actual deployment +func executeDeploy(ctx context.Context, opts *DeployOptions) error { + fmt.Println("Starting deployment...") + fmt.Printf(" Workspace: %s\n", opts.WorkspaceID) + fmt.Printf(" Project: %s\n", opts.ProjectID) + fmt.Printf(" Context: %s\n", opts.Context) + fmt.Printf(" Branch: %s\n", opts.Branch) + + // TODO: Add git integration for auto-detecting branch/commit + + // TODO: Add Docker build logic + + // TODO: Add control plane API calls + + // For now, just simulate deployment + fmt.Println("✓ Deployment completed successfully!") + + return nil +} + +// parseDeployFlags parses flags for deploy/version create commands +func parseDeployFlags(commandName string, args []string) (*DeployOptions, error) { + fs := flag.NewFlagSet(commandName, flag.ExitOnError) + + opts := &DeployOptions{} + + // Required flags + fs.StringVar(&opts.WorkspaceID, "workspace-id", "", "Workspace ID (required)") + fs.StringVar(&opts.ProjectID, "project-id", "", "Project ID (required)") + + // Optional flags with defaults + fs.StringVar(&opts.Context, "context", ".", "Docker context path") + fs.StringVar(&opts.Branch, "branch", "main", "Git branch") + fs.StringVar(&opts.DockerImage, "docker-image", "", "Pre-built docker image") + fs.StringVar(&opts.Dockerfile, "dockerfile", "Dockerfile", "Path to Dockerfile") + fs.StringVar(&opts.Commit, "commit", "", "Git commit SHA") + + // Control plane flags (internal) + fs.StringVar(&opts.ControlPlaneURL, "control-plane-url", "http://localhost:7091", "Control plane URL") + fs.StringVar(&opts.AuthToken, "auth-token", "ctrl-secret-token", "Control plane auth token") + + if err := fs.Parse(args); err != nil { + return nil, fmt.Errorf("failed to parse %s flags: %w", commandName, err) + } + + // Validate required fields + if opts.WorkspaceID == "" { + return nil, fmt.Errorf("--workspace-id is required") + } + if opts.ProjectID == "" { + return nil, fmt.Errorf("--project-id is required") + } + + return opts, nil +} + +// PrintDeployHelp prints detailed help for deploy command +func PrintDeployHelp() { + fmt.Println("unkey deploy - Deploy a new version") + fmt.Println("") + fmt.Println("USAGE:") + fmt.Println(" unkey deploy [FLAGS]") + fmt.Println("") + fmt.Println("DESCRIPTION:") + fmt.Println(" Build and deploy a new version of your application.") + fmt.Println(" Builds a Docker image from the specified context and") + fmt.Println(" deploys it to the Unkey platform.") + fmt.Println("") + fmt.Println("REQUIRED FLAGS:") + fmt.Println(" --workspace-id Workspace ID") + fmt.Println(" --project-id Project ID") + fmt.Println("") + fmt.Println("OPTIONAL FLAGS:") + fmt.Println(" --context Docker context path (default: .)") + fmt.Println(" --branch Git branch (default: main)") + fmt.Println(" --docker-image Pre-built docker image") + fmt.Println(" --dockerfile Path to Dockerfile (default: Dockerfile)") + fmt.Println(" --commit Git commit SHA") + fmt.Println("") + fmt.Println("EXAMPLES:") + fmt.Println(" # Basic deployment") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --context=./demo_api") + fmt.Println("") + fmt.Println(" # Deploy specific branch") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --branch=feature") + fmt.Println("") + fmt.Println(" # Deploy pre-built image") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --docker-image=ghcr.io/user/app:v1.0.0") +} diff --git a/go/cmd/cli/commands/init.go b/go/cmd/cli/commands/init.go new file mode 100644 index 00000000000..3127d9cdcfa --- /dev/null +++ b/go/cmd/cli/commands/init.go @@ -0,0 +1,38 @@ +package commands + +import ( + "fmt" +) + +// Init handles the init command +func Init(args []string) error { + fmt.Println("Init command - config file support coming soon!") + fmt.Println("For now, use flags directly:") + fmt.Println("") + fmt.Println("Example:") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --context=./demo_api") + + return nil +} + +// PrintInitHelp prints detailed help for init command +func PrintInitHelp() { + fmt.Println("unkey init - Initialize configuration") + fmt.Println("") + fmt.Println("USAGE:") + fmt.Println(" unkey init [FLAGS]") + fmt.Println("") + fmt.Println("DESCRIPTION:") + fmt.Println(" Initialize a configuration file to store default values for") + fmt.Println(" workspace ID, project ID, and docker context path.") + fmt.Println("") + fmt.Println("FLAGS:") + fmt.Println(" --config Configuration file path (default: ./unkey.json)") + fmt.Println("") + fmt.Println("EXAMPLES:") + fmt.Println(" unkey init") + fmt.Println(" unkey init --config=./my-project.json") +} diff --git a/go/cmd/cli/commands/versions.go b/go/cmd/cli/commands/versions.go new file mode 100644 index 00000000000..add184c4a1e --- /dev/null +++ b/go/cmd/cli/commands/versions.go @@ -0,0 +1,149 @@ +package commands + +import ( + "context" + "flag" + "fmt" +) + +// VersionListOptions holds options for version list command +type VersionListOptions struct { + Branch string + Status string + Limit int +} + +// Version handles the version command and its subcommands +func Version(ctx context.Context, args []string) error { + if len(args) < 1 { + PrintVersionCommandHelp() + return fmt.Errorf("version command requires a subcommand") + } + + subcommand := args[0] + switch subcommand { + case "create": + return VersionCreate(ctx, args[1:]) + case "list": + return VersionList(args[1:]) + case "get": + return VersionGet(args[1:]) + case "help", "-h", "--help": + PrintVersionHelp() + return nil + default: + PrintVersionCommandHelp() + return fmt.Errorf("unknown version subcommand: %s", subcommand) + } +} + +// VersionCreate handles version create (same as deploy) +func VersionCreate(ctx context.Context, args []string) error { + opts, err := parseDeployFlags("version create", args) + if err != nil { + return err + } + + // Reuse deploy logic + return executeDeploy(ctx, opts) +} + +// VersionList handles version list command +func VersionList(args []string) error { + opts, err := parseVersionListFlags(args) + if err != nil { + return err + } + + fmt.Printf("Listing versions (branch=%s, status=%s, limit=%d)\n", + opts.Branch, opts.Status, opts.Limit) + + // TODO: Add actual version listing logic + fmt.Println("ID STATUS BRANCH CREATED") + fmt.Println("v_abc123def456 ACTIVE main 2024-01-01 12:00:00") + fmt.Println("v_def456ghi789 ACTIVE feature 2024-01-01 11:00:00") + + return nil +} + +// parseVersionListFlags parses flags for version list command +func parseVersionListFlags(args []string) (*VersionListOptions, error) { + fs := flag.NewFlagSet("version list", flag.ExitOnError) + + opts := &VersionListOptions{} + + fs.StringVar(&opts.Branch, "branch", "", "Filter by branch") + fs.StringVar(&opts.Status, "status", "", "Filter by status") + fs.IntVar(&opts.Limit, "limit", 10, "Number of versions to show") + + if err := fs.Parse(args); err != nil { + return nil, fmt.Errorf("failed to parse version list flags: %w", err) + } + + return opts, nil +} + +// VersionGet handles version get command +func VersionGet(args []string) error { + if len(args) == 0 { + return fmt.Errorf("version get requires a version ID") + } + + versionID := args[0] + fmt.Printf("Getting version: %s\n", versionID) + + // TODO: Add actual version get logic + fmt.Printf("Version: %s\n", versionID) + fmt.Println("Status: ACTIVE") + fmt.Println("Branch: main") + fmt.Println("Created: 2024-01-01 12:00:00") + + return nil +} + +// PrintVersionCommandHelp shows help for version subcommands +func PrintVersionCommandHelp() { + fmt.Println("'version' requires a subcommand.") + fmt.Println("") + fmt.Println("Valid subcommands for 'version':") + fmt.Println(" create Create a new version") + fmt.Println(" list List versions") + fmt.Println(" get Get version details") + fmt.Println("") + fmt.Println("For detailed help: unkey help version") +} + +// PrintVersionHelp prints detailed help for version command +func PrintVersionHelp() { + fmt.Println("unkey version - Manage API versions") + fmt.Println("") + fmt.Println("USAGE:") + fmt.Println(" unkey version [FLAGS]") + fmt.Println("") + fmt.Println("SUBCOMMANDS:") + fmt.Println(" create Create a new version (same as deploy)") + fmt.Println(" list List versions") + fmt.Println(" get Get version details") + fmt.Println("") + fmt.Println("VERSION CREATE:") + fmt.Println(" Same as 'unkey deploy'. See 'unkey help deploy' for details.") + fmt.Println("") + fmt.Println("VERSION LIST FLAGS:") + fmt.Println(" --branch Filter by branch") + fmt.Println(" --status Filter by status (pending, building, active, failed)") + fmt.Println(" --limit Number of versions to show (default: 10)") + fmt.Println("") + fmt.Println("VERSION GET:") + fmt.Println(" unkey version get ") + fmt.Println("") + fmt.Println("EXAMPLES:") + fmt.Println(" # Create new version") + fmt.Println(" unkey version create --workspace-id=ws_123 --project-id=proj_456") + fmt.Println("") + fmt.Println(" # List versions") + fmt.Println(" unkey version list") + fmt.Println(" unkey version list --branch=main --limit=20") + fmt.Println("") + fmt.Println(" # Get specific version") + fmt.Println(" unkey version get v_abc123def456") +} diff --git a/go/cmd/cli/main.go b/go/cmd/cli/main.go new file mode 100644 index 00000000000..25f02320f52 --- /dev/null +++ b/go/cmd/cli/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/unkeyed/unkey/go/cmd/cli/app" +) + +func main() { + c := app.New(os.Args) + ctx := context.Background() + + if err := c.Run(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} From 30531e7e2249c5d41aac8157881eabe5c88d4a48 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 14 Jul 2025 13:58:09 +0300 Subject: [PATCH 02/23] feat: allow configuring name,desc and version --- go/cmd/cli/app/cli.go | 53 ++++++++++++++++++++++++++----------------- go/cmd/cli/main.go | 9 ++++++-- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/go/cmd/cli/app/cli.go b/go/cmd/cli/app/cli.go index 03fdeb74413..41e7b6fe5a9 100644 --- a/go/cmd/cli/app/cli.go +++ b/go/cmd/cli/app/cli.go @@ -9,23 +9,30 @@ import ( // CLI represents our command line interface type CLI struct { - args []string + args []string + name string + usage string + version string } // New creates a new CLI instance -func New(args []string) *CLI { - return &CLI{args: args} +func New(args []string, name, usage, version string) *CLI { + return &CLI{ + args: args, + name: name, + usage: usage, + version: version, + } } // Run executes the CLI func (c *CLI) Run(ctx context.Context) error { if len(c.args) < 2 { - PrintUsage() - return nil // Don't return error for help case + c.PrintUsage() + return nil } command := c.args[1] - switch command { case "init": return commands.Init(c.args[2:]) @@ -35,8 +42,11 @@ func (c *CLI) Run(ctx context.Context) error { return commands.Version(ctx, c.args[2:]) case "help", "-h", "--help": return c.runHelp() + case "-v", "--version": + fmt.Println(c.version) + return nil default: - PrintUsage() + c.PrintUsage() return fmt.Errorf("unknown command: %s", command) } } @@ -44,12 +54,10 @@ func (c *CLI) Run(ctx context.Context) error { // runHelp handles the help command func (c *CLI) runHelp() error { if len(c.args) < 3 { - // General help - PrintUsage() + c.PrintUsage() return nil } - // Help for specific command helpTopic := c.args[2] switch helpTopic { case "init": @@ -60,18 +68,20 @@ func (c *CLI) runHelp() error { commands.PrintVersionHelp() default: fmt.Printf("No help available for '%s'\n", helpTopic) - PrintUsage() + c.PrintUsage() } - return nil } // PrintUsage prints general usage information -func PrintUsage() { - fmt.Println("unkey - Deploy and manage your API versions") +func (c *CLI) PrintUsage() { + fmt.Printf("%s - %s\n", c.name, c.usage) fmt.Println("") fmt.Println("USAGE:") - fmt.Println(" unkey [flags]") + fmt.Printf(" %s [flags]\n", c.name) + fmt.Println("") + fmt.Println("VERSION:") + fmt.Printf(" %s\n", c.version) fmt.Println("") fmt.Println("COMMANDS:") fmt.Println(" init Initialize configuration file") @@ -80,13 +90,14 @@ func PrintUsage() { fmt.Println(" help Show help information") fmt.Println("") fmt.Println("FLAGS:") - fmt.Println(" -h, --help Show help") + fmt.Println(" -h, --help Show help") + fmt.Println(" -v, --version Show version") fmt.Println("") fmt.Println("EXAMPLES:") - fmt.Println(" unkey help") - fmt.Println(" unkey help deploy") - fmt.Println(" unkey init") - fmt.Println(" unkey deploy --workspace-id=ws_123 --project-id=proj_456") + fmt.Printf(" %s help\n", c.name) + fmt.Printf(" %s help deploy\n", c.name) + fmt.Printf(" %s init\n", c.name) + fmt.Printf(" %s deploy --workspace-id=ws_123 --project-id=proj_456\n", c.name) fmt.Println("") - fmt.Println("For detailed help on a command, use 'unkey help '") + fmt.Printf("For detailed help on a command, use '%s help '\n", c.name) } diff --git a/go/cmd/cli/main.go b/go/cmd/cli/main.go index 25f02320f52..abb39b2a593 100644 --- a/go/cmd/cli/main.go +++ b/go/cmd/cli/main.go @@ -6,12 +6,17 @@ import ( "os" "github.com/unkeyed/unkey/go/cmd/cli/app" + "github.com/unkeyed/unkey/go/pkg/version" ) func main() { - c := app.New(os.Args) + c := app.New( + os.Args, + "unkey", + "Deploy and manage your API versions", + version.Version, + ) ctx := context.Background() - if err := c.Run(ctx); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) From 53e33f9e3e4f423b612f911a978c867cb0419c1e Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 14 Jul 2025 14:33:40 +0300 Subject: [PATCH 03/23] feat: pass env to cli --- go/cmd/cli/app/cli.go | 22 +++++++++++++++++++--- go/cmd/cli/commands/deploy.go | 18 ++++++++++-------- go/cmd/cli/commands/init.go | 2 +- go/cmd/cli/commands/versions.go | 8 ++++---- 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/go/cmd/cli/app/cli.go b/go/cmd/cli/app/cli.go index 41e7b6fe5a9..a287bf53ecc 100644 --- a/go/cmd/cli/app/cli.go +++ b/go/cmd/cli/app/cli.go @@ -3,6 +3,7 @@ package app import ( "context" "fmt" + "os" "github.com/unkeyed/unkey/go/cmd/cli/commands" ) @@ -13,15 +14,22 @@ type CLI struct { name string usage string version string + env map[string]string } // New creates a new CLI instance func New(args []string, name, usage, version string) *CLI { + env := map[string]string{ + "UNKEY_WORKSPACE_ID": os.Getenv("UNKEY_WORKSPACE_ID"), + "UNKEY_PROJECT_ID": os.Getenv("UNKEY_PROJECT_ID"), + } + return &CLI{ args: args, name: name, usage: usage, version: version, + env: env, } } @@ -35,11 +43,11 @@ func (c *CLI) Run(ctx context.Context) error { command := c.args[1] switch command { case "init": - return commands.Init(c.args[2:]) + return commands.Init(c.args[2:], c.env) case "deploy": - return commands.Deploy(ctx, c.args[2:]) + return commands.Deploy(ctx, c.args[2:], c.env) case "version": - return commands.Version(ctx, c.args[2:]) + return commands.Version(ctx, c.args[2:], c.env) case "help", "-h", "--help": return c.runHelp() case "-v", "--version": @@ -93,11 +101,19 @@ func (c *CLI) PrintUsage() { fmt.Println(" -h, --help Show help") fmt.Println(" -v, --version Show version") fmt.Println("") + fmt.Println("ENVIRONMENT VARIABLES:") + fmt.Println(" UNKEY_WORKSPACE_ID Workspace ID (can be overridden by --workspace-id)") + fmt.Println(" UNKEY_PROJECT_ID Project ID (can be overridden by --project-id)") + fmt.Println(" UNKEY_API_KEY API key for authentication") + fmt.Println(" UNKEY_BASE_URL Base URL for API calls") + fmt.Println("") fmt.Println("EXAMPLES:") fmt.Printf(" %s help\n", c.name) fmt.Printf(" %s help deploy\n", c.name) fmt.Printf(" %s init\n", c.name) fmt.Printf(" %s deploy --workspace-id=ws_123 --project-id=proj_456\n", c.name) + fmt.Printf(" UNKEY_WORKSPACE_ID=ws_123 %s deploy\n", c.name) + fmt.Printf(" UNKEY_WORKSPACE_ID=ws_123 UNKEY_PROJECT_ID=proj_456 %s deploy\n", c.name) fmt.Println("") fmt.Printf("For detailed help on a command, use '%s help '\n", c.name) } diff --git a/go/cmd/cli/commands/deploy.go b/go/cmd/cli/commands/deploy.go index 59752ebb393..b5e9da77eed 100644 --- a/go/cmd/cli/commands/deploy.go +++ b/go/cmd/cli/commands/deploy.go @@ -20,8 +20,8 @@ type DeployOptions struct { } // Deploy handles the deploy command -func Deploy(ctx context.Context, args []string) error { - opts, err := parseDeployFlags("deploy", args) +func Deploy(ctx context.Context, args []string, env map[string]string) error { + opts, err := parseDeployFlags("deploy", args, env) if err != nil { return err } @@ -50,14 +50,16 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { } // parseDeployFlags parses flags for deploy/version create commands -func parseDeployFlags(commandName string, args []string) (*DeployOptions, error) { +func parseDeployFlags(commandName string, args []string, env map[string]string) (*DeployOptions, error) { fs := flag.NewFlagSet(commandName, flag.ExitOnError) - opts := &DeployOptions{} + defaultWorkspaceID := env["UNKEY_WORKSPACE_ID"] + defaultProjectID := env["UNKEY_PROJECT_ID"] + // Required flags - fs.StringVar(&opts.WorkspaceID, "workspace-id", "", "Workspace ID (required)") - fs.StringVar(&opts.ProjectID, "project-id", "", "Project ID (required)") + fs.StringVar(&opts.WorkspaceID, "workspace-id", defaultWorkspaceID, "Workspace ID (required)") + fs.StringVar(&opts.ProjectID, "project-id", defaultProjectID, "Project ID (required)") // Optional flags with defaults fs.StringVar(&opts.Context, "context", ".", "Docker context path") @@ -76,10 +78,10 @@ func parseDeployFlags(commandName string, args []string) (*DeployOptions, error) // Validate required fields if opts.WorkspaceID == "" { - return nil, fmt.Errorf("--workspace-id is required") + return nil, fmt.Errorf("--workspace-id is required (or set UNKEY_WORKSPACE_ID)") } if opts.ProjectID == "" { - return nil, fmt.Errorf("--project-id is required") + return nil, fmt.Errorf("--project-id is required (or set UNKEY_PROJECT_ID)") } return opts, nil diff --git a/go/cmd/cli/commands/init.go b/go/cmd/cli/commands/init.go index 3127d9cdcfa..7bbe10cd95c 100644 --- a/go/cmd/cli/commands/init.go +++ b/go/cmd/cli/commands/init.go @@ -5,7 +5,7 @@ import ( ) // Init handles the init command -func Init(args []string) error { +func Init(args []string, env map[string]string) error { fmt.Println("Init command - config file support coming soon!") fmt.Println("For now, use flags directly:") fmt.Println("") diff --git a/go/cmd/cli/commands/versions.go b/go/cmd/cli/commands/versions.go index add184c4a1e..0794c8b35e1 100644 --- a/go/cmd/cli/commands/versions.go +++ b/go/cmd/cli/commands/versions.go @@ -14,7 +14,7 @@ type VersionListOptions struct { } // Version handles the version command and its subcommands -func Version(ctx context.Context, args []string) error { +func Version(ctx context.Context, args []string, env map[string]string) error { if len(args) < 1 { PrintVersionCommandHelp() return fmt.Errorf("version command requires a subcommand") @@ -23,7 +23,7 @@ func Version(ctx context.Context, args []string) error { subcommand := args[0] switch subcommand { case "create": - return VersionCreate(ctx, args[1:]) + return VersionCreate(ctx, args[1:], env) case "list": return VersionList(args[1:]) case "get": @@ -38,8 +38,8 @@ func Version(ctx context.Context, args []string) error { } // VersionCreate handles version create (same as deploy) -func VersionCreate(ctx context.Context, args []string) error { - opts, err := parseDeployFlags("version create", args) +func VersionCreate(ctx context.Context, args []string, env map[string]string) error { + opts, err := parseDeployFlags("version create", args, env) if err != nil { return err } From cc55b1d6c01fef2d1f6f9f2ab4e07b18da0a372b Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 14 Jul 2025 16:37:53 +0300 Subject: [PATCH 04/23] feat: match the initial impl --- go/cmd/cli/commands/deploy.go | 415 ++++++++++++++++++++++++++++++++-- 1 file changed, 400 insertions(+), 15 deletions(-) diff --git a/go/cmd/cli/commands/deploy.go b/go/cmd/cli/commands/deploy.go index b5e9da77eed..ecfb5084640 100644 --- a/go/cmd/cli/commands/deploy.go +++ b/go/cmd/cli/commands/deploy.go @@ -1,9 +1,29 @@ package commands import ( + "bufio" "context" + "errors" "flag" "fmt" + "io" + "net/http" + "os/exec" + "strings" + "time" + + "connectrpc.com/connect" + ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" + "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect" + "github.com/unkeyed/unkey/go/pkg/codes" + "github.com/unkeyed/unkey/go/pkg/fault" + "github.com/unkeyed/unkey/go/pkg/git" + "github.com/unkeyed/unkey/go/pkg/otel/logging" +) + +var ( + ErrDockerNotFound = errors.New("docker command not found - please install Docker") + ErrDockerBuildFailed = errors.New("docker build failed") ) // DeployOptions holds all deployment configuration @@ -15,6 +35,8 @@ type DeployOptions struct { DockerImage string Dockerfile string Commit string + Registry string + SkipPush bool ControlPlaneURL string AuthToken string } @@ -25,30 +47,374 @@ func Deploy(ctx context.Context, args []string, env map[string]string) error { if err != nil { return err } - return executeDeploy(ctx, opts) } -// executeDeploy performs the actual deployment +// executeDeploy performs the actual deployment with Docker building and Git integration func executeDeploy(ctx context.Context, opts *DeployOptions) error { - fmt.Println("Starting deployment...") - fmt.Printf(" Workspace: %s\n", opts.WorkspaceID) - fmt.Printf(" Project: %s\n", opts.ProjectID) - fmt.Printf(" Context: %s\n", opts.Context) - fmt.Printf(" Branch: %s\n", opts.Branch) + logger := logging.New() + + // Get Git info for enhanced deployment tracking + gitInfo := git.GetInfo() + + // Auto-detect Git values if not provided + if opts.Branch == "main" && gitInfo.IsRepo && gitInfo.Branch != "" { + opts.Branch = gitInfo.Branch + } + if opts.Commit == "" && gitInfo.CommitSHA != "" { + opts.Commit = gitInfo.CommitSHA + } + + // Print source information + printDeploymentSource(gitInfo, opts) + + // Build or use existing Docker image + dockerImage := opts.DockerImage + if dockerImage == "" { + var err error + dockerImage, err = buildDockerImage(ctx, opts, gitInfo) + if err != nil { + return fmt.Errorf("docker build failed: %w", err) + } + } - // TODO: Add git integration for auto-detecting branch/commit + // Create control plane client and deploy + if err := deployToControlPlane(ctx, logger, opts, dockerImage); err != nil { + return fmt.Errorf("deployment failed: %w", err) + } + + return nil +} - // TODO: Add Docker build logic +func deployToControlPlane(ctx context.Context, logger logging.Logger, opts *DeployOptions, dockerImage string) error { + // Create control plane client + httpClient := &http.Client{} + client := ctrlv1connect.NewVersionServiceClient(httpClient, opts.ControlPlaneURL) - // TODO: Add control plane API calls + // Create version request + createReq := connect.NewRequest(&ctrlv1.CreateVersionRequest{ + WorkspaceId: opts.WorkspaceID, + ProjectId: opts.ProjectID, + Branch: opts.Branch, + SourceType: ctrlv1.SourceType_SOURCE_TYPE_CLI_UPLOAD, + GitCommitSha: opts.Commit, + EnvironmentId: "env_prod", + DockerImageTag: dockerImage, + }) + + // Add auth header + createReq.Header().Set("Authorization", "Bearer "+opts.AuthToken) + + // Call the API + createResp, err := client.CreateVersion(ctx, createReq) + if err != nil { + // Check if it's a connection error + if strings.Contains(err.Error(), "connection refused") { + return fault.Wrap(err, + fault.Code(codes.UnkeyAppErrorsInternalServiceUnavailable), + fault.Internal(fmt.Sprintf("Failed to connect to control plane at %s", opts.ControlPlaneURL)), + fault.Public("Unable to connect to control plane. Is it running?"), + ) + } - // For now, just simulate deployment - fmt.Println("✓ Deployment completed successfully!") + // Check if it's an auth error + if connectErr := new(connect.Error); errors.As(err, &connectErr) { + if connectErr.Code() == connect.CodeUnauthenticated { + return fault.Wrap(err, + fault.Code(codes.UnkeyAuthErrorsAuthenticationMalformed), + fault.Internal(fmt.Sprintf("Authentication failed with token: %s", opts.AuthToken)), + fault.Public("Authentication failed. Check your auth token."), + ) + } + } + + // Generic API error + return fault.Wrap(err, + fault.Code(codes.UnkeyAppErrorsInternalUnexpectedError), + fault.Internal(fmt.Sprintf("CreateVersion API call failed: %v", err)), + fault.Public("Failed to create version. Please try again."), + ) + } + + versionId := createResp.Msg.GetVersionId() + if versionId != "" { + fmt.Printf(" Version ID: %s\n", versionId) + } + + // Poll for version status updates + if err := pollVersionStatus(ctx, logger, client, opts.AuthToken, versionId); err != nil { + return fmt.Errorf("deployment failed: %w", err) + } + printDeploymentComplete(versionId, opts.WorkspaceID, opts.Branch) return nil } +// pollVersionStatus polls the control plane API and displays deployment steps as they occur +func pollVersionStatus(ctx context.Context, logger logging.Logger, client ctrlv1connect.VersionServiceClient, authToken, versionId string) error { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + timeout := time.NewTimer(300 * time.Second) // 5 minute timeout for full deployment + defer timeout.Stop() + + displayedSteps := make(map[string]bool) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeout.C: + fmt.Printf("Error: Deployment timeout after 5 minutes\n") + return fmt.Errorf("deployment timeout") + case <-ticker.C: + // Poll version status + getReq := connect.NewRequest(&ctrlv1.GetVersionRequest{ + VersionId: versionId, + }) + getReq.Header().Set("Authorization", "Bearer "+authToken) + + getResp, err := client.GetVersion(ctx, getReq) + if err != nil { + logger.Debug("Failed to get version status", "error", err, "version_id", versionId) + continue + } + + version := getResp.Msg.GetVersion() + + // Display version steps in real-time + steps := version.GetSteps() + for _, step := range steps { + stepKey := step.GetStatus() + if !displayedSteps[stepKey] { + displayVersionStep(step) + displayedSteps[stepKey] = true + } + } + + // Check if deployment is complete + if version.GetStatus() == ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE { + return nil + } + + // Check if deployment failed + if version.GetStatus() == ctrlv1.VersionStatus_VERSION_STATUS_FAILED { + return fmt.Errorf("deployment failed") + } + } + } +} + +// displayVersionStep shows a version step with appropriate formatting +func displayVersionStep(step *ctrlv1.VersionStep) { + message := step.GetMessage() + // Display only the actual message from the database, indented under "Creating Version" + if message != "" { + fmt.Printf(" %s\n", message) + } + + // Show error message if present + if step.GetErrorMessage() != "" { + fmt.Printf(" Error: %s\n", step.GetErrorMessage()) + } +} + +func printDeploymentSource(gitInfo git.Info, opts *DeployOptions) { + fmt.Println("Source") + fmt.Printf(" Branch: %s\n", opts.Branch) + + if gitInfo.IsRepo && gitInfo.CommitSHA != "" { + shortSHA := gitInfo.CommitSHA + if len(shortSHA) > 7 { + shortSHA = shortSHA[:7] + } + fmt.Printf(" Commit: %s\n", shortSHA) + + if gitInfo.IsDirty { + fmt.Printf(" Status: Working directory has uncommitted changes\n") + } + } else if !gitInfo.IsRepo { + fmt.Printf(" Status: Not a git repository\n") + } + + fmt.Printf(" Context: %s\n", opts.Context) + if opts.DockerImage != "" { + fmt.Printf(" Docker Image: %s\n", opts.DockerImage) + } + fmt.Println() +} + +func buildDockerImage(ctx context.Context, opts *DeployOptions, gitInfo git.Info) (string, error) { + // Check if Docker is available + if !isDockerAvailable() { + return "", ErrDockerNotFound + } + + // Generate image tag using Git info when available + var imageTag string + if gitInfo.ShortSHA != "" { + imageTag = fmt.Sprintf("%s-%s", opts.Branch, gitInfo.ShortSHA) + } else { + // Fallback to timestamp if no Git info + timestamp := time.Now().Unix() + imageTag = fmt.Sprintf("%s-%d", opts.Branch, timestamp) + } + + dockerImage := fmt.Sprintf("%s:%s", opts.Registry, imageTag) + + fmt.Printf("Building Docker image %s...\n", dockerImage) + + // Build the Docker image + var buildArgs []string + buildArgs = append(buildArgs, "build") + + // Only add -f flag if dockerfile is not the default "Dockerfile" + if opts.Dockerfile != "Dockerfile" { + buildArgs = append(buildArgs, "-f", opts.Dockerfile) + } + + buildArgs = append(buildArgs, + "-t", dockerImage, + "--build-arg", fmt.Sprintf("VERSION=%s", opts.Commit), + opts.Context, + ) + + buildCmd := exec.CommandContext(ctx, "docker", buildArgs...) + + // Create pipes to capture output + stdout, err := buildCmd.StdoutPipe() + if err != nil { + return "", fmt.Errorf("failed to create stdout pipe: %w", err) + } + stderr, err := buildCmd.StderrPipe() + if err != nil { + return "", fmt.Errorf("failed to create stderr pipe: %w", err) + } + + // Start the build command + if err := buildCmd.Start(); err != nil { + return "", fmt.Errorf("failed to start docker build: %w", err) + } + + // Capture all output for error reporting + var allOutput strings.Builder + combinedOutput := io.MultiReader(stdout, stderr) + scanner := bufio.NewScanner(combinedOutput) + + // Process output line by line + for scanner.Scan() { + line := scanner.Text() + allOutput.WriteString(line + "\n") + fmt.Printf(" %s\n", line) + } + + // Wait for the build to complete + if err := buildCmd.Wait(); err != nil { + fmt.Printf("Docker build failed\n") + // Show build output on failure + for line := range strings.SplitSeq(allOutput.String(), "\n") { + if strings.TrimSpace(line) != "" { + fmt.Printf(" %s\n", line) + } + } + return "", ErrDockerBuildFailed + } + + // Skip push if requested + if opts.SkipPush { + fmt.Printf("Skipping Docker push (--skip-push enabled)\n") + return dockerImage, nil + } + + fmt.Printf("\nPublishing Docker image...\n") + + // Push the image + pushCmd := exec.CommandContext(ctx, "docker", "push", dockerImage) + var pushOutput strings.Builder + pushCmd.Stdout = &pushOutput + pushCmd.Stderr = &pushOutput + + if err := pushCmd.Run(); err != nil { + // Parse the error output to provide better messages + outputStr := strings.TrimSpace(pushOutput.String()) + + if strings.Contains(outputStr, "denied") { + fmt.Printf("Docker push failed: Registry access denied\n") + fmt.Printf(" Registry: %s\n", opts.Registry) + fmt.Printf(" \n") + fmt.Printf(" This usually means:\n") + fmt.Printf(" • You're not logged into the registry: docker login %s\n", getRegistryHost(opts.Registry)) + fmt.Printf(" • You don't have push permissions to this repository\n") + fmt.Printf(" • The repository doesn't exist or is private\n") + fmt.Printf(" \n") + fmt.Printf(" For development, you can:\n") + fmt.Printf(" • Use your own registry: --registry=your-registry/your-app\n") + fmt.Printf(" • Use a pre-built image: --docker-image=nginx:alpine\n") + fmt.Printf(" • Skip the push: --skip-push\n\n") + return dockerImage, nil // Continue for development + } + + if strings.Contains(outputStr, "not found") || strings.Contains(outputStr, "404") { + fmt.Printf("Docker push failed: Registry not found\n") + fmt.Printf(" Registry: %s\n", opts.Registry) + fmt.Printf(" \n") + fmt.Printf(" The repository may not exist. Try:\n") + fmt.Printf(" • Creating the repository first\n") + fmt.Printf(" • Using a different registry: --registry=your-registry/your-app\n") + fmt.Printf(" • Skip the push: --skip-push\n\n") + return dockerImage, nil // Continue for development + } + + if strings.Contains(outputStr, "unauthorized") { + fmt.Printf("Docker push failed: Authentication required\n") + fmt.Printf(" Run: docker login %s\n", getRegistryHost(opts.Registry)) + fmt.Printf(" Or skip the push: --skip-push\n\n") + return dockerImage, nil // Continue for development + } + + // Generic push error + fmt.Printf("Docker push failed (continuing anyway for development)\n") + fmt.Printf(" %s\n", outputStr) + return dockerImage, nil + } + + return dockerImage, nil +} + +func isDockerAvailable() bool { + cmd := exec.Command("docker", "--version") + return cmd.Run() == nil +} + +func getRegistryHost(registry string) string { + parts := strings.Split(registry, "/") + if len(parts) > 0 { + return parts[0] + } + return registry +} + +func printDeploymentComplete(versionId, workspace, branch string) { + // Use Git info for hostname generation + gitInfo := git.GetInfo() + identifier := versionId + if gitInfo.ShortSHA != "" { + identifier = gitInfo.ShortSHA + } + + fmt.Println() + fmt.Println("Deployment Complete") + fmt.Printf(" Version ID: %s\n", versionId) + fmt.Printf(" Status: Ready\n") + fmt.Printf(" Environment: Production\n") + fmt.Println() + fmt.Println("Domains") + + // Replace underscores with dashes for valid hostname format + cleanIdentifier := strings.ReplaceAll(identifier, "_", "-") + fmt.Printf(" https://%s-%s-%s.unkey.app\n", branch, cleanIdentifier, workspace) + fmt.Printf(" https://api.acme.com\n") +} + // parseDeployFlags parses flags for deploy/version create commands func parseDeployFlags(commandName string, args []string, env map[string]string) (*DeployOptions, error) { fs := flag.NewFlagSet(commandName, flag.ExitOnError) @@ -56,6 +422,10 @@ func parseDeployFlags(commandName string, args []string, env map[string]string) defaultWorkspaceID := env["UNKEY_WORKSPACE_ID"] defaultProjectID := env["UNKEY_PROJECT_ID"] + defaultRegistry := env["UNKEY_DOCKER_REGISTRY"] + if defaultRegistry == "" { + defaultRegistry = "ghcr.io/unkeyed/deploy" + } // Required flags fs.StringVar(&opts.WorkspaceID, "workspace-id", defaultWorkspaceID, "Workspace ID (required)") @@ -67,6 +437,8 @@ func parseDeployFlags(commandName string, args []string, env map[string]string) fs.StringVar(&opts.DockerImage, "docker-image", "", "Pre-built docker image") fs.StringVar(&opts.Dockerfile, "dockerfile", "Dockerfile", "Path to Dockerfile") fs.StringVar(&opts.Commit, "commit", "", "Git commit SHA") + fs.StringVar(&opts.Registry, "registry", defaultRegistry, "Docker registry") + fs.BoolVar(&opts.SkipPush, "skip-push", false, "Skip pushing to registry (for local testing)") // Control plane flags (internal) fs.StringVar(&opts.ControlPlaneURL, "control-plane-url", "http://localhost:7091", "Control plane URL") @@ -105,10 +477,17 @@ func PrintDeployHelp() { fmt.Println("") fmt.Println("OPTIONAL FLAGS:") fmt.Println(" --context Docker context path (default: .)") - fmt.Println(" --branch Git branch (default: main)") + fmt.Println(" --branch Git branch (default: main)") fmt.Println(" --docker-image Pre-built docker image") fmt.Println(" --dockerfile Path to Dockerfile (default: Dockerfile)") fmt.Println(" --commit Git commit SHA") + fmt.Println(" --registry Docker registry (default: ghcr.io/unkeyed/deploy)") + fmt.Println(" --skip-push Skip pushing to registry") + fmt.Println("") + fmt.Println("ENVIRONMENT VARIABLES:") + fmt.Println(" UNKEY_WORKSPACE_ID Default workspace ID") + fmt.Println(" UNKEY_PROJECT_ID Default project ID") + fmt.Println(" UNKEY_DOCKER_REGISTRY Default Docker registry") fmt.Println("") fmt.Println("EXAMPLES:") fmt.Println(" # Basic deployment") @@ -117,11 +496,17 @@ func PrintDeployHelp() { fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") fmt.Println(" --context=./demo_api") fmt.Println("") - fmt.Println(" # Deploy specific branch") + fmt.Println(" # Deploy with your own registry") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --registry=docker.io/mycompany/myapp") + fmt.Println("") + fmt.Println(" # Local development (skip push)") fmt.Println(" unkey deploy \\") fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --branch=feature") + fmt.Println(" --skip-push") fmt.Println("") fmt.Println(" # Deploy pre-built image") fmt.Println(" unkey deploy \\") From 82d3393f6689394abc64bb095a6c8ef945e1f173 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 14 Jul 2025 17:20:00 +0300 Subject: [PATCH 05/23] feat: add new progress aniamtion --- go/cmd/cli/app/cli.go | 5 +- go/cmd/cli/commands/deploy.go | 516 --------------------- go/cmd/cli/commands/deploy/build-docker.go | 132 ++++++ go/cmd/cli/commands/deploy/deploy.go | 157 +++++++ go/cmd/cli/commands/deploy/notify-ctrl.go | 103 ++++ go/cmd/cli/commands/deploy/parse-flags.go | 52 +++ go/cmd/cli/commands/deploy/poll-ctrl.go | 80 ++++ go/cmd/cli/commands/versions.go | 10 +- go/cmd/cli/progress/progress.go | 495 ++++++++++++++++++++ 9 files changed, 1025 insertions(+), 525 deletions(-) delete mode 100644 go/cmd/cli/commands/deploy.go create mode 100644 go/cmd/cli/commands/deploy/build-docker.go create mode 100644 go/cmd/cli/commands/deploy/deploy.go create mode 100644 go/cmd/cli/commands/deploy/notify-ctrl.go create mode 100644 go/cmd/cli/commands/deploy/parse-flags.go create mode 100644 go/cmd/cli/commands/deploy/poll-ctrl.go create mode 100644 go/cmd/cli/progress/progress.go diff --git a/go/cmd/cli/app/cli.go b/go/cmd/cli/app/cli.go index a287bf53ecc..f4c461f249b 100644 --- a/go/cmd/cli/app/cli.go +++ b/go/cmd/cli/app/cli.go @@ -6,6 +6,7 @@ import ( "os" "github.com/unkeyed/unkey/go/cmd/cli/commands" + "github.com/unkeyed/unkey/go/cmd/cli/commands/deploy" ) // CLI represents our command line interface @@ -45,7 +46,7 @@ func (c *CLI) Run(ctx context.Context) error { case "init": return commands.Init(c.args[2:], c.env) case "deploy": - return commands.Deploy(ctx, c.args[2:], c.env) + return deploy.Deploy(ctx, c.args[2:], c.env) case "version": return commands.Version(ctx, c.args[2:], c.env) case "help", "-h", "--help": @@ -71,7 +72,7 @@ func (c *CLI) runHelp() error { case "init": commands.PrintInitHelp() case "deploy": - commands.PrintDeployHelp() + deploy.PrintDeployHelp() case "version": commands.PrintVersionHelp() default: diff --git a/go/cmd/cli/commands/deploy.go b/go/cmd/cli/commands/deploy.go deleted file mode 100644 index ecfb5084640..00000000000 --- a/go/cmd/cli/commands/deploy.go +++ /dev/null @@ -1,516 +0,0 @@ -package commands - -import ( - "bufio" - "context" - "errors" - "flag" - "fmt" - "io" - "net/http" - "os/exec" - "strings" - "time" - - "connectrpc.com/connect" - ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" - "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect" - "github.com/unkeyed/unkey/go/pkg/codes" - "github.com/unkeyed/unkey/go/pkg/fault" - "github.com/unkeyed/unkey/go/pkg/git" - "github.com/unkeyed/unkey/go/pkg/otel/logging" -) - -var ( - ErrDockerNotFound = errors.New("docker command not found - please install Docker") - ErrDockerBuildFailed = errors.New("docker build failed") -) - -// DeployOptions holds all deployment configuration -type DeployOptions struct { - WorkspaceID string - ProjectID string - Context string - Branch string - DockerImage string - Dockerfile string - Commit string - Registry string - SkipPush bool - ControlPlaneURL string - AuthToken string -} - -// Deploy handles the deploy command -func Deploy(ctx context.Context, args []string, env map[string]string) error { - opts, err := parseDeployFlags("deploy", args, env) - if err != nil { - return err - } - return executeDeploy(ctx, opts) -} - -// executeDeploy performs the actual deployment with Docker building and Git integration -func executeDeploy(ctx context.Context, opts *DeployOptions) error { - logger := logging.New() - - // Get Git info for enhanced deployment tracking - gitInfo := git.GetInfo() - - // Auto-detect Git values if not provided - if opts.Branch == "main" && gitInfo.IsRepo && gitInfo.Branch != "" { - opts.Branch = gitInfo.Branch - } - if opts.Commit == "" && gitInfo.CommitSHA != "" { - opts.Commit = gitInfo.CommitSHA - } - - // Print source information - printDeploymentSource(gitInfo, opts) - - // Build or use existing Docker image - dockerImage := opts.DockerImage - if dockerImage == "" { - var err error - dockerImage, err = buildDockerImage(ctx, opts, gitInfo) - if err != nil { - return fmt.Errorf("docker build failed: %w", err) - } - } - - // Create control plane client and deploy - if err := deployToControlPlane(ctx, logger, opts, dockerImage); err != nil { - return fmt.Errorf("deployment failed: %w", err) - } - - return nil -} - -func deployToControlPlane(ctx context.Context, logger logging.Logger, opts *DeployOptions, dockerImage string) error { - // Create control plane client - httpClient := &http.Client{} - client := ctrlv1connect.NewVersionServiceClient(httpClient, opts.ControlPlaneURL) - - // Create version request - createReq := connect.NewRequest(&ctrlv1.CreateVersionRequest{ - WorkspaceId: opts.WorkspaceID, - ProjectId: opts.ProjectID, - Branch: opts.Branch, - SourceType: ctrlv1.SourceType_SOURCE_TYPE_CLI_UPLOAD, - GitCommitSha: opts.Commit, - EnvironmentId: "env_prod", - DockerImageTag: dockerImage, - }) - - // Add auth header - createReq.Header().Set("Authorization", "Bearer "+opts.AuthToken) - - // Call the API - createResp, err := client.CreateVersion(ctx, createReq) - if err != nil { - // Check if it's a connection error - if strings.Contains(err.Error(), "connection refused") { - return fault.Wrap(err, - fault.Code(codes.UnkeyAppErrorsInternalServiceUnavailable), - fault.Internal(fmt.Sprintf("Failed to connect to control plane at %s", opts.ControlPlaneURL)), - fault.Public("Unable to connect to control plane. Is it running?"), - ) - } - - // Check if it's an auth error - if connectErr := new(connect.Error); errors.As(err, &connectErr) { - if connectErr.Code() == connect.CodeUnauthenticated { - return fault.Wrap(err, - fault.Code(codes.UnkeyAuthErrorsAuthenticationMalformed), - fault.Internal(fmt.Sprintf("Authentication failed with token: %s", opts.AuthToken)), - fault.Public("Authentication failed. Check your auth token."), - ) - } - } - - // Generic API error - return fault.Wrap(err, - fault.Code(codes.UnkeyAppErrorsInternalUnexpectedError), - fault.Internal(fmt.Sprintf("CreateVersion API call failed: %v", err)), - fault.Public("Failed to create version. Please try again."), - ) - } - - versionId := createResp.Msg.GetVersionId() - if versionId != "" { - fmt.Printf(" Version ID: %s\n", versionId) - } - - // Poll for version status updates - if err := pollVersionStatus(ctx, logger, client, opts.AuthToken, versionId); err != nil { - return fmt.Errorf("deployment failed: %w", err) - } - - printDeploymentComplete(versionId, opts.WorkspaceID, opts.Branch) - return nil -} - -// pollVersionStatus polls the control plane API and displays deployment steps as they occur -func pollVersionStatus(ctx context.Context, logger logging.Logger, client ctrlv1connect.VersionServiceClient, authToken, versionId string) error { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - timeout := time.NewTimer(300 * time.Second) // 5 minute timeout for full deployment - defer timeout.Stop() - - displayedSteps := make(map[string]bool) - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-timeout.C: - fmt.Printf("Error: Deployment timeout after 5 minutes\n") - return fmt.Errorf("deployment timeout") - case <-ticker.C: - // Poll version status - getReq := connect.NewRequest(&ctrlv1.GetVersionRequest{ - VersionId: versionId, - }) - getReq.Header().Set("Authorization", "Bearer "+authToken) - - getResp, err := client.GetVersion(ctx, getReq) - if err != nil { - logger.Debug("Failed to get version status", "error", err, "version_id", versionId) - continue - } - - version := getResp.Msg.GetVersion() - - // Display version steps in real-time - steps := version.GetSteps() - for _, step := range steps { - stepKey := step.GetStatus() - if !displayedSteps[stepKey] { - displayVersionStep(step) - displayedSteps[stepKey] = true - } - } - - // Check if deployment is complete - if version.GetStatus() == ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE { - return nil - } - - // Check if deployment failed - if version.GetStatus() == ctrlv1.VersionStatus_VERSION_STATUS_FAILED { - return fmt.Errorf("deployment failed") - } - } - } -} - -// displayVersionStep shows a version step with appropriate formatting -func displayVersionStep(step *ctrlv1.VersionStep) { - message := step.GetMessage() - // Display only the actual message from the database, indented under "Creating Version" - if message != "" { - fmt.Printf(" %s\n", message) - } - - // Show error message if present - if step.GetErrorMessage() != "" { - fmt.Printf(" Error: %s\n", step.GetErrorMessage()) - } -} - -func printDeploymentSource(gitInfo git.Info, opts *DeployOptions) { - fmt.Println("Source") - fmt.Printf(" Branch: %s\n", opts.Branch) - - if gitInfo.IsRepo && gitInfo.CommitSHA != "" { - shortSHA := gitInfo.CommitSHA - if len(shortSHA) > 7 { - shortSHA = shortSHA[:7] - } - fmt.Printf(" Commit: %s\n", shortSHA) - - if gitInfo.IsDirty { - fmt.Printf(" Status: Working directory has uncommitted changes\n") - } - } else if !gitInfo.IsRepo { - fmt.Printf(" Status: Not a git repository\n") - } - - fmt.Printf(" Context: %s\n", opts.Context) - if opts.DockerImage != "" { - fmt.Printf(" Docker Image: %s\n", opts.DockerImage) - } - fmt.Println() -} - -func buildDockerImage(ctx context.Context, opts *DeployOptions, gitInfo git.Info) (string, error) { - // Check if Docker is available - if !isDockerAvailable() { - return "", ErrDockerNotFound - } - - // Generate image tag using Git info when available - var imageTag string - if gitInfo.ShortSHA != "" { - imageTag = fmt.Sprintf("%s-%s", opts.Branch, gitInfo.ShortSHA) - } else { - // Fallback to timestamp if no Git info - timestamp := time.Now().Unix() - imageTag = fmt.Sprintf("%s-%d", opts.Branch, timestamp) - } - - dockerImage := fmt.Sprintf("%s:%s", opts.Registry, imageTag) - - fmt.Printf("Building Docker image %s...\n", dockerImage) - - // Build the Docker image - var buildArgs []string - buildArgs = append(buildArgs, "build") - - // Only add -f flag if dockerfile is not the default "Dockerfile" - if opts.Dockerfile != "Dockerfile" { - buildArgs = append(buildArgs, "-f", opts.Dockerfile) - } - - buildArgs = append(buildArgs, - "-t", dockerImage, - "--build-arg", fmt.Sprintf("VERSION=%s", opts.Commit), - opts.Context, - ) - - buildCmd := exec.CommandContext(ctx, "docker", buildArgs...) - - // Create pipes to capture output - stdout, err := buildCmd.StdoutPipe() - if err != nil { - return "", fmt.Errorf("failed to create stdout pipe: %w", err) - } - stderr, err := buildCmd.StderrPipe() - if err != nil { - return "", fmt.Errorf("failed to create stderr pipe: %w", err) - } - - // Start the build command - if err := buildCmd.Start(); err != nil { - return "", fmt.Errorf("failed to start docker build: %w", err) - } - - // Capture all output for error reporting - var allOutput strings.Builder - combinedOutput := io.MultiReader(stdout, stderr) - scanner := bufio.NewScanner(combinedOutput) - - // Process output line by line - for scanner.Scan() { - line := scanner.Text() - allOutput.WriteString(line + "\n") - fmt.Printf(" %s\n", line) - } - - // Wait for the build to complete - if err := buildCmd.Wait(); err != nil { - fmt.Printf("Docker build failed\n") - // Show build output on failure - for line := range strings.SplitSeq(allOutput.String(), "\n") { - if strings.TrimSpace(line) != "" { - fmt.Printf(" %s\n", line) - } - } - return "", ErrDockerBuildFailed - } - - // Skip push if requested - if opts.SkipPush { - fmt.Printf("Skipping Docker push (--skip-push enabled)\n") - return dockerImage, nil - } - - fmt.Printf("\nPublishing Docker image...\n") - - // Push the image - pushCmd := exec.CommandContext(ctx, "docker", "push", dockerImage) - var pushOutput strings.Builder - pushCmd.Stdout = &pushOutput - pushCmd.Stderr = &pushOutput - - if err := pushCmd.Run(); err != nil { - // Parse the error output to provide better messages - outputStr := strings.TrimSpace(pushOutput.String()) - - if strings.Contains(outputStr, "denied") { - fmt.Printf("Docker push failed: Registry access denied\n") - fmt.Printf(" Registry: %s\n", opts.Registry) - fmt.Printf(" \n") - fmt.Printf(" This usually means:\n") - fmt.Printf(" • You're not logged into the registry: docker login %s\n", getRegistryHost(opts.Registry)) - fmt.Printf(" • You don't have push permissions to this repository\n") - fmt.Printf(" • The repository doesn't exist or is private\n") - fmt.Printf(" \n") - fmt.Printf(" For development, you can:\n") - fmt.Printf(" • Use your own registry: --registry=your-registry/your-app\n") - fmt.Printf(" • Use a pre-built image: --docker-image=nginx:alpine\n") - fmt.Printf(" • Skip the push: --skip-push\n\n") - return dockerImage, nil // Continue for development - } - - if strings.Contains(outputStr, "not found") || strings.Contains(outputStr, "404") { - fmt.Printf("Docker push failed: Registry not found\n") - fmt.Printf(" Registry: %s\n", opts.Registry) - fmt.Printf(" \n") - fmt.Printf(" The repository may not exist. Try:\n") - fmt.Printf(" • Creating the repository first\n") - fmt.Printf(" • Using a different registry: --registry=your-registry/your-app\n") - fmt.Printf(" • Skip the push: --skip-push\n\n") - return dockerImage, nil // Continue for development - } - - if strings.Contains(outputStr, "unauthorized") { - fmt.Printf("Docker push failed: Authentication required\n") - fmt.Printf(" Run: docker login %s\n", getRegistryHost(opts.Registry)) - fmt.Printf(" Or skip the push: --skip-push\n\n") - return dockerImage, nil // Continue for development - } - - // Generic push error - fmt.Printf("Docker push failed (continuing anyway for development)\n") - fmt.Printf(" %s\n", outputStr) - return dockerImage, nil - } - - return dockerImage, nil -} - -func isDockerAvailable() bool { - cmd := exec.Command("docker", "--version") - return cmd.Run() == nil -} - -func getRegistryHost(registry string) string { - parts := strings.Split(registry, "/") - if len(parts) > 0 { - return parts[0] - } - return registry -} - -func printDeploymentComplete(versionId, workspace, branch string) { - // Use Git info for hostname generation - gitInfo := git.GetInfo() - identifier := versionId - if gitInfo.ShortSHA != "" { - identifier = gitInfo.ShortSHA - } - - fmt.Println() - fmt.Println("Deployment Complete") - fmt.Printf(" Version ID: %s\n", versionId) - fmt.Printf(" Status: Ready\n") - fmt.Printf(" Environment: Production\n") - fmt.Println() - fmt.Println("Domains") - - // Replace underscores with dashes for valid hostname format - cleanIdentifier := strings.ReplaceAll(identifier, "_", "-") - fmt.Printf(" https://%s-%s-%s.unkey.app\n", branch, cleanIdentifier, workspace) - fmt.Printf(" https://api.acme.com\n") -} - -// parseDeployFlags parses flags for deploy/version create commands -func parseDeployFlags(commandName string, args []string, env map[string]string) (*DeployOptions, error) { - fs := flag.NewFlagSet(commandName, flag.ExitOnError) - opts := &DeployOptions{} - - defaultWorkspaceID := env["UNKEY_WORKSPACE_ID"] - defaultProjectID := env["UNKEY_PROJECT_ID"] - defaultRegistry := env["UNKEY_DOCKER_REGISTRY"] - if defaultRegistry == "" { - defaultRegistry = "ghcr.io/unkeyed/deploy" - } - - // Required flags - fs.StringVar(&opts.WorkspaceID, "workspace-id", defaultWorkspaceID, "Workspace ID (required)") - fs.StringVar(&opts.ProjectID, "project-id", defaultProjectID, "Project ID (required)") - - // Optional flags with defaults - fs.StringVar(&opts.Context, "context", ".", "Docker context path") - fs.StringVar(&opts.Branch, "branch", "main", "Git branch") - fs.StringVar(&opts.DockerImage, "docker-image", "", "Pre-built docker image") - fs.StringVar(&opts.Dockerfile, "dockerfile", "Dockerfile", "Path to Dockerfile") - fs.StringVar(&opts.Commit, "commit", "", "Git commit SHA") - fs.StringVar(&opts.Registry, "registry", defaultRegistry, "Docker registry") - fs.BoolVar(&opts.SkipPush, "skip-push", false, "Skip pushing to registry (for local testing)") - - // Control plane flags (internal) - fs.StringVar(&opts.ControlPlaneURL, "control-plane-url", "http://localhost:7091", "Control plane URL") - fs.StringVar(&opts.AuthToken, "auth-token", "ctrl-secret-token", "Control plane auth token") - - if err := fs.Parse(args); err != nil { - return nil, fmt.Errorf("failed to parse %s flags: %w", commandName, err) - } - - // Validate required fields - if opts.WorkspaceID == "" { - return nil, fmt.Errorf("--workspace-id is required (or set UNKEY_WORKSPACE_ID)") - } - if opts.ProjectID == "" { - return nil, fmt.Errorf("--project-id is required (or set UNKEY_PROJECT_ID)") - } - - return opts, nil -} - -// PrintDeployHelp prints detailed help for deploy command -func PrintDeployHelp() { - fmt.Println("unkey deploy - Deploy a new version") - fmt.Println("") - fmt.Println("USAGE:") - fmt.Println(" unkey deploy [FLAGS]") - fmt.Println("") - fmt.Println("DESCRIPTION:") - fmt.Println(" Build and deploy a new version of your application.") - fmt.Println(" Builds a Docker image from the specified context and") - fmt.Println(" deploys it to the Unkey platform.") - fmt.Println("") - fmt.Println("REQUIRED FLAGS:") - fmt.Println(" --workspace-id Workspace ID") - fmt.Println(" --project-id Project ID") - fmt.Println("") - fmt.Println("OPTIONAL FLAGS:") - fmt.Println(" --context Docker context path (default: .)") - fmt.Println(" --branch Git branch (default: main)") - fmt.Println(" --docker-image Pre-built docker image") - fmt.Println(" --dockerfile Path to Dockerfile (default: Dockerfile)") - fmt.Println(" --commit Git commit SHA") - fmt.Println(" --registry Docker registry (default: ghcr.io/unkeyed/deploy)") - fmt.Println(" --skip-push Skip pushing to registry") - fmt.Println("") - fmt.Println("ENVIRONMENT VARIABLES:") - fmt.Println(" UNKEY_WORKSPACE_ID Default workspace ID") - fmt.Println(" UNKEY_PROJECT_ID Default project ID") - fmt.Println(" UNKEY_DOCKER_REGISTRY Default Docker registry") - fmt.Println("") - fmt.Println("EXAMPLES:") - fmt.Println(" # Basic deployment") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --context=./demo_api") - fmt.Println("") - fmt.Println(" # Deploy with your own registry") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --registry=docker.io/mycompany/myapp") - fmt.Println("") - fmt.Println(" # Local development (skip push)") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --skip-push") - fmt.Println("") - fmt.Println(" # Deploy pre-built image") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --docker-image=ghcr.io/user/app:v1.0.0") -} diff --git a/go/cmd/cli/commands/deploy/build-docker.go b/go/cmd/cli/commands/deploy/build-docker.go new file mode 100644 index 00000000000..90fe51db17a --- /dev/null +++ b/go/cmd/cli/commands/deploy/build-docker.go @@ -0,0 +1,132 @@ +package deploy + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/unkeyed/unkey/go/pkg/git" +) + +func buildDockerImage(ctx context.Context, opts *DeployOptions, gitInfo git.Info) (string, error) { + if !isDockerAvailable() { + return "", ErrDockerNotFound + } + + imageTag := generateImageTag(opts, gitInfo) + dockerImage := fmt.Sprintf("%s:%s", opts.Registry, imageTag) + + if err := buildImage(ctx, opts, dockerImage); err != nil { + return "", err + } + + if opts.SkipPush { + fmt.Printf("Skipping Docker push (--skip-push enabled)\n") + return dockerImage, nil + } + + // Push failure shouldn't be fatal in development + if err := pushImage(ctx, dockerImage, opts.Registry); err != nil { + fmt.Printf("Push failed but continuing: %v\n", err) + } + + return dockerImage, nil +} + +func generateImageTag(opts *DeployOptions, gitInfo git.Info) string { + if gitInfo.ShortSHA != "" { + return fmt.Sprintf("%s-%s", opts.Branch, gitInfo.ShortSHA) + } + return fmt.Sprintf("%s-%d", opts.Branch, time.Now().Unix()) +} + +func buildImage(ctx context.Context, opts *DeployOptions, dockerImage string) error { + fmt.Printf("Building Docker image %s...\n", dockerImage) + + buildArgs := []string{"build"} + if opts.Dockerfile != "Dockerfile" { + buildArgs = append(buildArgs, "-f", opts.Dockerfile) + } + buildArgs = append(buildArgs, + "-t", dockerImage, + "--build-arg", fmt.Sprintf("VERSION=%s", opts.Commit), + opts.Context, + ) + + cmd := exec.CommandContext(ctx, "docker", buildArgs...) + + // Stream output directly instead of complex pipe handling + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Docker build failed:\n%s\n", string(output)) + return ErrDockerBuildFailed + } + + fmt.Printf("%s\n", string(output)) + return nil +} + +func pushImage(ctx context.Context, dockerImage, registry string) error { + fmt.Printf("\nPublishing Docker image...\n") + + cmd := exec.CommandContext(ctx, "docker", "push", dockerImage) + output, err := cmd.CombinedOutput() + if err != nil { + return classifyPushError(string(output), registry) + } + + fmt.Printf("%s\n", string(output)) + return nil +} + +func classifyPushError(output, registry string) error { + output = strings.TrimSpace(output) + registryHost := getRegistryHost(registry) + + switch { + case strings.Contains(output, "denied"): + fmt.Printf("Docker push failed: Registry access denied\n") + fmt.Printf(" Registry: %s\n", registry) + fmt.Printf(" Solutions:\n") + fmt.Printf(" • Login: docker login %s\n", registryHost) + fmt.Printf(" • Use your own registry: --registry=your-registry/your-app\n") + fmt.Printf(" • Skip push: --skip-push\n") + return ErrDockerPushFailed + + case strings.Contains(output, "not found") || strings.Contains(output, "404"): + fmt.Printf("Docker push failed: Registry not found\n") + fmt.Printf(" Registry: %s\n", registry) + fmt.Printf(" Solutions:\n") + fmt.Printf(" • Create repository first\n") + fmt.Printf(" • Use different registry: --registry=your-registry/your-app\n") + fmt.Printf(" • Skip push: --skip-push\n") + return ErrDockerPushFailed + + case strings.Contains(output, "unauthorized"): + fmt.Printf("Docker push failed: Authentication required\n") + fmt.Printf(" Run: docker login %s\n", registryHost) + fmt.Printf(" Or skip: --skip-push\n") + return ErrDockerPushFailed + + default: + fmt.Printf("Docker push failed: %s\n", output) + return ErrDockerPushFailed + } +} + +// ## HELPERS + +func getRegistryHost(registry string) string { + parts := strings.Split(registry, "/") + if len(parts) > 0 { + return parts[0] + } + return "docker.io" +} + +func isDockerAvailable() bool { + cmd := exec.Command("docker", "--version") + return cmd.Run() == nil +} diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go new file mode 100644 index 00000000000..44882c96a4b --- /dev/null +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -0,0 +1,157 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + + "github.com/unkeyed/unkey/go/pkg/git" + "github.com/unkeyed/unkey/go/pkg/otel/logging" +) + +var ( + ErrDockerNotFound = errors.New("docker command not found - please install Docker") + ErrDockerBuildFailed = errors.New("docker build failed") + ErrDockerPushFailed = errors.New("docker push failed") + ErrInvalidImageTag = errors.New("invalid image tag generated") +) + +// DeployOptions holds all deployment configuration +type DeployOptions struct { + WorkspaceID string + ProjectID string + Context string + Branch string + DockerImage string + Dockerfile string + Commit string + Registry string + SkipPush bool + ControlPlaneURL string + AuthToken string +} + +// Deploy handles the deploy command +func Deploy(ctx context.Context, args []string, env map[string]string) error { + opts, err := parseDeployFlags(args, env) + if err != nil { + return err + } + return executeDeploy(ctx, opts) +} + +// executeDeploy performs the actual deployment with Docker building and Git integration +func executeDeploy(ctx context.Context, opts *DeployOptions) error { + logger := logging.New() + + // Get Git info for enhanced deployment tracking + gitInfo := git.GetInfo() + + // Auto-detect Git values if not provided + if opts.Branch == "main" && gitInfo.IsRepo && gitInfo.Branch != "" { + opts.Branch = gitInfo.Branch + } + if opts.Commit == "" && gitInfo.CommitSHA != "" { + opts.Commit = gitInfo.CommitSHA + } + + // Print source information + printDeploymentSource(gitInfo, opts) + + // Build or use existing Docker image + dockerImage := opts.DockerImage + if dockerImage == "" { + var err error + dockerImage, err = buildDockerImage(ctx, opts, gitInfo) + if err != nil { + return fmt.Errorf("docker build failed: %w", err) + } + } + + if err := notifyControlPlane(ctx, logger, opts, dockerImage); err != nil { + return fmt.Errorf("deployment failed: %w", err) + } + + return nil +} + +func printDeploymentSource(gitInfo git.Info, opts *DeployOptions) { + fmt.Println("Source") + fmt.Printf(" Branch: %s\n", opts.Branch) + + if gitInfo.IsRepo && gitInfo.CommitSHA != "" { + shortSHA := gitInfo.CommitSHA + if len(shortSHA) > 7 { + shortSHA = shortSHA[:7] + } + fmt.Printf(" Commit: %s\n", shortSHA) + + if gitInfo.IsDirty { + fmt.Printf(" Status: Working directory has uncommitted changes\n") + } + } else if !gitInfo.IsRepo { + fmt.Printf(" Status: Not a git repository\n") + } + + fmt.Printf(" Context: %s\n", opts.Context) + if opts.DockerImage != "" { + fmt.Printf(" Docker Image: %s\n", opts.DockerImage) + } + fmt.Println() +} + +// PrintDeployHelp prints detailed help for deploy command +func PrintDeployHelp() { + fmt.Println("unkey deploy - Deploy a new version") + fmt.Println("") + fmt.Println("USAGE:") + fmt.Println(" unkey deploy [FLAGS]") + fmt.Println("") + fmt.Println("DESCRIPTION:") + fmt.Println(" Build and deploy a new version of your application.") + fmt.Println(" Builds a Docker image from the specified context and") + fmt.Println(" deploys it to the Unkey platform.") + fmt.Println("") + fmt.Println("REQUIRED FLAGS:") + fmt.Println(" --workspace-id Workspace ID") + fmt.Println(" --project-id Project ID") + fmt.Println("") + fmt.Println("OPTIONAL FLAGS:") + fmt.Println(" --context Docker context path (default: .)") + fmt.Println(" --branch Git branch (default: main)") + fmt.Println(" --docker-image Pre-built docker image") + fmt.Println(" --dockerfile Path to Dockerfile (default: Dockerfile)") + fmt.Println(" --commit Git commit SHA") + fmt.Println(" --registry Docker registry (default: ghcr.io/unkeyed/deploy)") + fmt.Println(" --skip-push Skip pushing to registry") + fmt.Println("") + fmt.Println("ENVIRONMENT VARIABLES:") + fmt.Println(" UNKEY_WORKSPACE_ID Default workspace ID") + fmt.Println(" UNKEY_PROJECT_ID Default project ID") + fmt.Println(" UNKEY_DOCKER_REGISTRY Default Docker registry") + fmt.Println("") + fmt.Println("EXAMPLES:") + fmt.Println(" # Basic deployment") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --context=./demo_api") + fmt.Println("") + fmt.Println(" # Deploy with your own registry") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --registry=docker.io/mycompany/myapp") + fmt.Println("") + fmt.Println(" # Local development (skip push)") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --skip-push") + fmt.Println("") + fmt.Println(" # Deploy pre-built image") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --docker-image=ghcr.io/user/app:v1.0.0") +} diff --git a/go/cmd/cli/commands/deploy/notify-ctrl.go b/go/cmd/cli/commands/deploy/notify-ctrl.go new file mode 100644 index 00000000000..5c2bdae2612 --- /dev/null +++ b/go/cmd/cli/commands/deploy/notify-ctrl.go @@ -0,0 +1,103 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "connectrpc.com/connect" + ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" + "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect" + "github.com/unkeyed/unkey/go/pkg/codes" + "github.com/unkeyed/unkey/go/pkg/fault" + "github.com/unkeyed/unkey/go/pkg/git" + "github.com/unkeyed/unkey/go/pkg/otel/logging" +) + +func notifyControlPlane(ctx context.Context, logger logging.Logger, opts *DeployOptions, dockerImage string) error { + // Create control plane client + httpClient := &http.Client{} + client := ctrlv1connect.NewVersionServiceClient(httpClient, opts.ControlPlaneURL) + + // Create version request + createReq := connect.NewRequest(&ctrlv1.CreateVersionRequest{ + WorkspaceId: opts.WorkspaceID, + ProjectId: opts.ProjectID, + Branch: opts.Branch, + SourceType: ctrlv1.SourceType_SOURCE_TYPE_CLI_UPLOAD, + GitCommitSha: opts.Commit, + EnvironmentId: "env_prod", + DockerImageTag: dockerImage, + }) + + // Add auth header + createReq.Header().Set("Authorization", "Bearer "+opts.AuthToken) + + // Call the API + createResp, err := client.CreateVersion(ctx, createReq) + if err != nil { + // Check if it's a connection error + if strings.Contains(err.Error(), "connection refused") { + return fault.Wrap(err, + fault.Code(codes.UnkeyAppErrorsInternalServiceUnavailable), + fault.Internal(fmt.Sprintf("Failed to connect to control plane at %s", opts.ControlPlaneURL)), + fault.Public("Unable to connect to control plane. Is it running?"), + ) + } + + // Check if it's an auth error + if connectErr := new(connect.Error); errors.As(err, &connectErr) { + if connectErr.Code() == connect.CodeUnauthenticated { + return fault.Wrap(err, + fault.Code(codes.UnkeyAuthErrorsAuthenticationMalformed), + fault.Internal(fmt.Sprintf("Authentication failed with token: %s", opts.AuthToken)), + fault.Public("Authentication failed. Check your auth token."), + ) + } + } + + // Generic API error + return fault.Wrap(err, + fault.Code(codes.UnkeyAppErrorsInternalUnexpectedError), + fault.Internal(fmt.Sprintf("CreateVersion API call failed: %v", err)), + fault.Public("Failed to create version. Please try again."), + ) + } + + versionId := createResp.Msg.GetVersionId() + if versionId != "" { + fmt.Printf(" Version ID: %s\n", versionId) + } + + // Poll for version status updates + if err := pollVersionStatus(ctx, logger, client, opts.AuthToken, versionId); err != nil { + return fmt.Errorf("deployment failed: %w", err) + } + + printDeploymentComplete(versionId, opts.WorkspaceID, opts.Branch) + return nil +} + +func printDeploymentComplete(versionId, workspace, branch string) { + // Use Git info for hostname generation + gitInfo := git.GetInfo() + identifier := versionId + if gitInfo.ShortSHA != "" { + identifier = gitInfo.ShortSHA + } + + fmt.Println() + fmt.Println("Deployment Complete") + fmt.Printf(" Version ID: %s\n", versionId) + fmt.Printf(" Status: Ready\n") + fmt.Printf(" Environment: Production\n") + fmt.Println() + fmt.Println("Domains") + + // Replace underscores with dashes for valid hostname format + cleanIdentifier := strings.ReplaceAll(identifier, "_", "-") + fmt.Printf(" https://%s-%s-%s.unkey.app\n", branch, cleanIdentifier, workspace) + fmt.Printf(" https://api.acme.com\n") +} diff --git a/go/cmd/cli/commands/deploy/parse-flags.go b/go/cmd/cli/commands/deploy/parse-flags.go new file mode 100644 index 00000000000..57e29d641f4 --- /dev/null +++ b/go/cmd/cli/commands/deploy/parse-flags.go @@ -0,0 +1,52 @@ +package deploy + +import ( + "flag" + "fmt" +) + +var commandName = "deploy" + +// parseDeployFlags parses flags for deploy/version create commands +func parseDeployFlags(args []string, env map[string]string) (*DeployOptions, error) { + fs := flag.NewFlagSet(commandName, flag.ExitOnError) + opts := &DeployOptions{} + + defaultWorkspaceID := env["UNKEY_WORKSPACE_ID"] + defaultProjectID := env["UNKEY_PROJECT_ID"] + defaultRegistry := env["UNKEY_DOCKER_REGISTRY"] + if defaultRegistry == "" { + defaultRegistry = "ghcr.io/unkeyed/deploy" + } + + // Required flags + fs.StringVar(&opts.WorkspaceID, "workspace-id", defaultWorkspaceID, "Workspace ID (required)") + fs.StringVar(&opts.ProjectID, "project-id", defaultProjectID, "Project ID (required)") + + // Optional flags with defaults + fs.StringVar(&opts.Context, "context", ".", "Docker context path") + fs.StringVar(&opts.Branch, "branch", "main", "Git branch") + fs.StringVar(&opts.DockerImage, "docker-image", "", "Pre-built docker image") + fs.StringVar(&opts.Dockerfile, "dockerfile", "Dockerfile", "Path to Dockerfile") + fs.StringVar(&opts.Commit, "commit", "", "Git commit SHA") + fs.StringVar(&opts.Registry, "registry", defaultRegistry, "Docker registry") + fs.BoolVar(&opts.SkipPush, "skip-push", false, "Skip pushing to registry (for local testing)") + + // Control plane flags (internal) + fs.StringVar(&opts.ControlPlaneURL, "control-plane-url", "http://localhost:7091", "Control plane URL") + fs.StringVar(&opts.AuthToken, "auth-token", "ctrl-secret-token", "Control plane auth token") + + if err := fs.Parse(args); err != nil { + return nil, fmt.Errorf("failed to parse %s flags: %w", commandName, err) + } + + // Validate required fields + if opts.WorkspaceID == "" { + return nil, fmt.Errorf("--workspace-id is required (or set UNKEY_WORKSPACE_ID)") + } + if opts.ProjectID == "" { + return nil, fmt.Errorf("--project-id is required (or set UNKEY_PROJECT_ID)") + } + + return opts, nil +} diff --git a/go/cmd/cli/commands/deploy/poll-ctrl.go b/go/cmd/cli/commands/deploy/poll-ctrl.go new file mode 100644 index 00000000000..53037e4f478 --- /dev/null +++ b/go/cmd/cli/commands/deploy/poll-ctrl.go @@ -0,0 +1,80 @@ +package deploy + +import ( + "context" + "fmt" + "time" + + "connectrpc.com/connect" + ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" + "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect" + "github.com/unkeyed/unkey/go/pkg/otel/logging" +) + +// pollVersionStatus polls the control plane API and displays deployment steps as they occur +func pollVersionStatus(ctx context.Context, logger logging.Logger, client ctrlv1connect.VersionServiceClient, authToken, versionId string) error { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + timeout := time.NewTimer(300 * time.Second) // 5 minute timeout for full deployment + defer timeout.Stop() + + displayedSteps := make(map[string]bool) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeout.C: + fmt.Printf("Error: Deployment timeout after 5 minutes\n") + return fmt.Errorf("deployment timeout") + case <-ticker.C: + // Poll version status + getReq := connect.NewRequest(&ctrlv1.GetVersionRequest{ + VersionId: versionId, + }) + getReq.Header().Set("Authorization", "Bearer "+authToken) + + getResp, err := client.GetVersion(ctx, getReq) + if err != nil { + logger.Debug("Failed to get version status", "error", err, "version_id", versionId) + continue + } + + version := getResp.Msg.GetVersion() + + // Display version steps in real-time + steps := version.GetSteps() + for _, step := range steps { + stepKey := step.GetStatus() + if !displayedSteps[stepKey] { + displayVersionStep(step) + displayedSteps[stepKey] = true + } + } + + // Check if deployment is complete + if version.GetStatus() == ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE { + return nil + } + + // Check if deployment failed + if version.GetStatus() == ctrlv1.VersionStatus_VERSION_STATUS_FAILED { + return fmt.Errorf("deployment failed") + } + } + } +} + +// displayVersionStep shows a version step with appropriate formatting +func displayVersionStep(step *ctrlv1.VersionStep) { + message := step.GetMessage() + // Display only the actual message from the database, indented under "Creating Version" + if message != "" { + fmt.Printf(" %s\n", message) + } + + // Show error message if present + if step.GetErrorMessage() != "" { + fmt.Printf(" Error: %s\n", step.GetErrorMessage()) + } +} diff --git a/go/cmd/cli/commands/versions.go b/go/cmd/cli/commands/versions.go index 0794c8b35e1..3cd79e0a54b 100644 --- a/go/cmd/cli/commands/versions.go +++ b/go/cmd/cli/commands/versions.go @@ -4,6 +4,8 @@ import ( "context" "flag" "fmt" + + "github.com/unkeyed/unkey/go/cmd/cli/commands/deploy" ) // VersionListOptions holds options for version list command @@ -39,13 +41,7 @@ func Version(ctx context.Context, args []string, env map[string]string) error { // VersionCreate handles version create (same as deploy) func VersionCreate(ctx context.Context, args []string, env map[string]string) error { - opts, err := parseDeployFlags("version create", args, env) - if err != nil { - return err - } - - // Reuse deploy logic - return executeDeploy(ctx, opts) + return deploy.Deploy(ctx, args, env) } // VersionList handles version list command diff --git a/go/cmd/cli/progress/progress.go b/go/cmd/cli/progress/progress.go new file mode 100644 index 00000000000..0496acbef11 --- /dev/null +++ b/go/cmd/cli/progress/progress.go @@ -0,0 +1,495 @@ +// Package progress provides reusable animated progress tracking for CLI operations +package progress + +import ( + "fmt" + "strings" + "sync" + "time" +) + +// Colors for different states +const ( + ColorReset = "\033[0m" + ColorGreen = "\033[32m" + ColorYellow = "\033[33m" + ColorRed = "\033[31m" + ColorBlue = "\033[34m" + ColorCyan = "\033[36m" + ColorGray = "\033[90m" +) + +// Animation characters +var ( + SpinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + DotsChars = []string{"", ".", "..", "..."} + ProgressChars = []string{"▱", "▰"} + PulseChars = []string{"◐", "◓", "◑", "◒"} +) + +// Status represents the state of a tracked item +type Status string + +const ( + StatusPending Status = "pending" + StatusRunning Status = "running" + StatusCompleted Status = "completed" + StatusFailed Status = "failed" + StatusSkipped Status = "skipped" +) + +// Step represents a single step in a process +type Step struct { + ID string + Name string + Status Status + Message string + Error string + StartTime time.Time + EndTime time.Time + Active bool + Progress float64 // 0.0 to 1.0 for progress bars + metadata map[string]interface{} + mu sync.RWMutex +} + +// SetMetadata sets custom metadata for the step +func (s *Step) SetMetadata(key string, value interface{}) { + s.mu.Lock() + defer s.mu.Unlock() + if s.metadata == nil { + s.metadata = make(map[string]interface{}) + } + s.metadata[key] = value +} + +// GetMetadata gets custom metadata for the step +func (s *Step) GetMetadata(key string) (interface{}, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + if s.metadata == nil { + return nil, false + } + val, exists := s.metadata[key] + return val, exists +} + +// Duration returns the duration of the step +func (s *Step) Duration() time.Duration { + s.mu.RLock() + defer s.mu.RUnlock() + if s.EndTime.IsZero() { + if s.StartTime.IsZero() { + return 0 + } + return time.Since(s.StartTime) + } + return s.EndTime.Sub(s.StartTime) +} + +// Tracker manages animated progress tracking +type Tracker struct { + title string + steps map[string]*Step + stepOrder []string + animation animationState + done chan struct{} + running bool + mu sync.RWMutex + options TrackerOptions +} + +type animationState struct { + frame int + lastUpdate time.Time +} + +// TrackerOptions configures the tracker behavior +type TrackerOptions struct { + ShowElapsed bool // Show elapsed time for running steps + ShowDuration bool // Show duration for completed steps + ShowProgress bool // Show progress bars when available + AnimationSpeed time.Duration // Animation update interval + ClearOnDone bool // Clear screen when done + Compact bool // Use compact display + NoColor bool // Disable colors +} + +// DefaultOptions returns sensible default options +func DefaultOptions() TrackerOptions { + return TrackerOptions{ + ShowElapsed: true, + ShowDuration: true, + ShowProgress: true, + AnimationSpeed: 100 * time.Millisecond, + ClearOnDone: false, + Compact: false, + NoColor: false, + } +} + +// NewTracker creates a new progress tracker +func NewTracker(title string, opts ...TrackerOptions) *Tracker { + options := DefaultOptions() + if len(opts) > 0 { + options = opts[0] + } + + return &Tracker{ + title: title, + steps: make(map[string]*Step), + stepOrder: make([]string, 0), + animation: animationState{lastUpdate: time.Now()}, + done: make(chan struct{}), + options: options, + } +} + +// AddStep adds a new step to track +func (t *Tracker) AddStep(id, name string) *Step { + t.mu.Lock() + defer t.mu.Unlock() + + if _, exists := t.steps[id]; !exists { + t.stepOrder = append(t.stepOrder, id) + } + + step := &Step{ + ID: id, + Name: name, + Status: StatusPending, + StartTime: time.Now(), + Active: false, + } + + t.steps[id] = step + return step +} + +// GetStep returns a step by ID +func (t *Tracker) GetStep(id string) (*Step, bool) { + t.mu.RLock() + defer t.mu.RUnlock() + step, exists := t.steps[id] + return step, exists +} + +// StartStep marks a step as running +func (t *Tracker) StartStep(id string, message ...string) { + t.mu.Lock() + defer t.mu.Unlock() + + if step, exists := t.steps[id]; exists { + step.Status = StatusRunning + step.StartTime = time.Now() + step.Active = true + if len(message) > 0 { + step.Message = message[0] + } + } +} + +// UpdateStep updates a step's message and optionally progress +func (t *Tracker) UpdateStep(id, message string, progress ...float64) { + t.mu.Lock() + defer t.mu.Unlock() + + if step, exists := t.steps[id]; exists { + step.Message = message + if len(progress) > 0 { + step.Progress = progress[0] + } + } +} + +// CompleteStep marks a step as completed +func (t *Tracker) CompleteStep(id string, message ...string) { + t.mu.Lock() + defer t.mu.Unlock() + + if step, exists := t.steps[id]; exists { + step.Status = StatusCompleted + step.EndTime = time.Now() + step.Active = false + if len(message) > 0 { + step.Message = message[0] + } + } +} + +// FailStep marks a step as failed +func (t *Tracker) FailStep(id, errorMsg string) { + t.mu.Lock() + defer t.mu.Unlock() + + if step, exists := t.steps[id]; exists { + step.Status = StatusFailed + step.Error = errorMsg + step.EndTime = time.Now() + step.Active = false + } +} + +// SkipStep marks a step as skipped +func (t *Tracker) SkipStep(id string, reason ...string) { + t.mu.Lock() + defer t.mu.Unlock() + + if step, exists := t.steps[id]; exists { + step.Status = StatusSkipped + step.EndTime = time.Now() + step.Active = false + if len(reason) > 0 { + step.Message = reason[0] + } + } +} + +// Start begins the animation loop +func (t *Tracker) Start() { + t.mu.Lock() + if t.running { + t.mu.Unlock() + return + } + t.running = true + t.mu.Unlock() + + go t.animationLoop() +} + +// Stop stops the animation and shows final state +func (t *Tracker) Stop() { + t.mu.Lock() + if !t.running { + t.mu.Unlock() + return + } + t.running = false + t.mu.Unlock() + + close(t.done) + + if t.options.ClearOnDone { + t.renderFinalState() + } else { + t.render(true) // Render final state without animation + } +} + +// animationLoop runs the animation updates +func (t *Tracker) animationLoop() { + ticker := time.NewTicker(t.options.AnimationSpeed) + defer ticker.Stop() + + for { + select { + case <-t.done: + return + case <-ticker.C: + t.updateAnimation() + t.render(false) + } + } +} + +// updateAnimation updates the animation frame +func (t *Tracker) updateAnimation() { + t.mu.Lock() + defer t.mu.Unlock() + + now := time.Now() + if now.Sub(t.animation.lastUpdate) >= t.options.AnimationSpeed { + t.animation.frame++ + t.animation.lastUpdate = now + } +} + +// render displays the current state +func (t *Tracker) render(final bool) { + if !final { + // Clear screen and move cursor to top for live updates + fmt.Print("\033[H\033[J") + } + + // Title + titleColor := t.color(ColorBlue) + fmt.Printf("%s%s%s\n", titleColor, t.title, t.colorReset()) + + if !t.options.Compact { + fmt.Println(strings.Repeat("─", 50)) + } + + // Render steps + t.mu.RLock() + for _, stepID := range t.stepOrder { + step := t.steps[stepID] + t.renderStep(step, final) + } + t.mu.RUnlock() + + fmt.Println() +} + +// renderStep renders a single step +func (t *Tracker) renderStep(step *Step, final bool) { + step.mu.RLock() + defer step.mu.RUnlock() + + icon, color := t.getStepIcon(step, final) + + // Step name with icon + fmt.Printf("%s %s%s%s", icon, color, step.Name, t.colorReset()) + + // Show elapsed time for running steps + if t.options.ShowElapsed && step.Status == StatusRunning && step.Active && !final { + elapsed := time.Since(step.StartTime).Truncate(time.Second) + fmt.Printf(" %s(%s)%s", t.color(ColorGray), elapsed, t.colorReset()) + } + + // Show duration for completed steps + if t.options.ShowDuration && step.Status == StatusCompleted && !step.EndTime.IsZero() { + duration := step.EndTime.Sub(step.StartTime).Truncate(time.Millisecond) + fmt.Printf(" %s(%s)%s", t.color(ColorGreen), duration, t.colorReset()) + } + + fmt.Println() + + // Show message + if step.Message != "" { + indent := " " + message := step.Message + + // Add animated dots for running steps + if step.Status == StatusRunning && step.Active && !final { + dots := DotsChars[t.animation.frame%len(DotsChars)] + message = message + dots + } + + fmt.Printf("%s%s\n", indent, message) + } + + // Show progress bar if available + if t.options.ShowProgress && step.Progress > 0 && step.Status == StatusRunning { + t.renderProgressBar(step.Progress) + } + + // Show error if present + if step.Error != "" { + fmt.Printf(" %sError: %s%s\n", t.color(ColorRed), step.Error, t.colorReset()) + } +} + +// getStepIcon returns the appropriate icon and color for a step +func (t *Tracker) getStepIcon(step *Step, final bool) (string, string) { + switch step.Status { + case StatusPending: + return t.colorize("⏳", ColorYellow), t.color(ColorYellow) + case StatusRunning: + if step.Active && !final { + char := SpinnerChars[t.animation.frame%len(SpinnerChars)] + return t.colorize(char, ColorCyan), t.color(ColorCyan) + } + return t.colorize("⚙️", ColorCyan), t.color(ColorCyan) + case StatusCompleted: + return t.colorize("✅", ColorGreen), t.color(ColorGreen) + case StatusFailed: + return t.colorize("❌", ColorRed), t.color(ColorRed) + case StatusSkipped: + return t.colorize("⏭️", ColorGray), t.color(ColorGray) + default: + return t.colorize("⏳", ColorYellow), t.color(ColorYellow) + } +} + +// renderProgressBar renders a progress bar +func (t *Tracker) renderProgressBar(progress float64) { + if progress < 0 { + progress = 0 + } + if progress > 1 { + progress = 1 + } + + width := 30 + filled := int(progress * float64(width)) + bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + percentage := int(progress * 100) + + fmt.Printf(" %s[%s%s%s] %d%%\n", + t.color(ColorCyan), + t.color(ColorGreen), + bar, + t.colorReset(), + percentage) +} + +// renderFinalState shows the final state +func (t *Tracker) renderFinalState() { + fmt.Print("\033[H\033[J") + fmt.Printf("%s%s - Complete%s\n", t.color(ColorGreen), t.title, t.colorReset()) + fmt.Println(strings.Repeat("─", 50)) + + t.mu.RLock() + for _, stepID := range t.stepOrder { + step := t.steps[stepID] + t.renderStep(step, true) + } + t.mu.RUnlock() + + fmt.Println() +} + +// color returns color code if colors are enabled +func (t *Tracker) color(color string) string { + if t.options.NoColor { + return "" + } + return color +} + +// colorReset returns reset code if colors are enabled +func (t *Tracker) colorReset() string { + if t.options.NoColor { + return "" + } + return ColorReset +} + +// colorize wraps text in color if colors are enabled +func (t *Tracker) colorize(text, color string) string { + if t.options.NoColor { + return text + } + return color + text + ColorReset +} + +func BuildProgress(target string) *Tracker { + opts := DefaultOptions() + opts.ClearOnDone = false // Don't clear screen when done + opts.ShowElapsed = true + opts.ShowDuration = true + + tracker := NewTracker(fmt.Sprintf("Building %s", target), opts) + tracker.AddStep("prepare", "Preparing build environment") + tracker.AddStep("dependencies", "Installing dependencies") + tracker.AddStep("compile", "Compiling") + tracker.AddStep("package", "Packaging") + tracker.AddStep("verify", "Verifying build") + tracker.Start() + return tracker +} + +func DeployProgress() *Tracker { + opts := DefaultOptions() + opts.ShowElapsed = true + opts.ShowDuration = true + + tracker := NewTracker("Deployment Progress", opts) + tracker.AddStep("pending", "Version queued") + tracker.AddStep("building", "Building deployment") + tracker.AddStep("deploying", "Deploying to infrastructure") + tracker.AddStep("active", "Activation complete") + tracker.Start() + return tracker +} From d05bce4dbdb4a3ed9ab15e498e8a1c6a7e3732e1 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 14 Jul 2025 18:23:38 +0300 Subject: [PATCH 06/23] feat: add tracker step for each phase --- go/cmd/cli/commands/deploy/build-docker.go | 30 ----- go/cmd/cli/commands/deploy/deploy.go | 98 +++++++++++++--- go/cmd/cli/commands/deploy/notify-ctrl.go | 127 +++++++++++++-------- go/cmd/cli/commands/deploy/poll-ctrl.go | 113 ++++++++++++------ go/cmd/cli/progress/progress.go | 69 +++-------- 5 files changed, 262 insertions(+), 175 deletions(-) diff --git a/go/cmd/cli/commands/deploy/build-docker.go b/go/cmd/cli/commands/deploy/build-docker.go index 90fe51db17a..9598d186c59 100644 --- a/go/cmd/cli/commands/deploy/build-docker.go +++ b/go/cmd/cli/commands/deploy/build-docker.go @@ -10,31 +10,6 @@ import ( "github.com/unkeyed/unkey/go/pkg/git" ) -func buildDockerImage(ctx context.Context, opts *DeployOptions, gitInfo git.Info) (string, error) { - if !isDockerAvailable() { - return "", ErrDockerNotFound - } - - imageTag := generateImageTag(opts, gitInfo) - dockerImage := fmt.Sprintf("%s:%s", opts.Registry, imageTag) - - if err := buildImage(ctx, opts, dockerImage); err != nil { - return "", err - } - - if opts.SkipPush { - fmt.Printf("Skipping Docker push (--skip-push enabled)\n") - return dockerImage, nil - } - - // Push failure shouldn't be fatal in development - if err := pushImage(ctx, dockerImage, opts.Registry); err != nil { - fmt.Printf("Push failed but continuing: %v\n", err) - } - - return dockerImage, nil -} - func generateImageTag(opts *DeployOptions, gitInfo git.Info) string { if gitInfo.ShortSHA != "" { return fmt.Sprintf("%s-%s", opts.Branch, gitInfo.ShortSHA) @@ -43,8 +18,6 @@ func generateImageTag(opts *DeployOptions, gitInfo git.Info) string { } func buildImage(ctx context.Context, opts *DeployOptions, dockerImage string) error { - fmt.Printf("Building Docker image %s...\n", dockerImage) - buildArgs := []string{"build"} if opts.Dockerfile != "Dockerfile" { buildArgs = append(buildArgs, "-f", opts.Dockerfile) @@ -64,13 +37,10 @@ func buildImage(ctx context.Context, opts *DeployOptions, dockerImage string) er return ErrDockerBuildFailed } - fmt.Printf("%s\n", string(output)) return nil } func pushImage(ctx context.Context, dockerImage, registry string) error { - fmt.Printf("\nPublishing Docker image...\n") - cmd := exec.CommandContext(ctx, "docker", "push", dockerImage) output, err := cmd.CombinedOutput() if err != nil { diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index 44882c96a4b..f4eed2a536d 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "strings" + "github.com/unkeyed/unkey/go/cmd/cli/progress" "github.com/unkeyed/unkey/go/pkg/git" "github.com/unkeyed/unkey/go/pkg/otel/logging" ) @@ -55,49 +57,111 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { opts.Commit = gitInfo.CommitSHA } - // Print source information - printDeploymentSource(gitInfo, opts) + tracker := progress.NewTracker("Unkey Deploy Progress") + tracker.AddStep("source", "Source information") + tracker.AddStep("prepare", "Preparing deployment") + tracker.AddStep("build", "Building Docker image") + tracker.AddStep("push", "Publishing to registry") + tracker.AddStep("deploy", "Deploying to infrastructure") + tracker.AddStep("activate", "Activating version") + tracker.AddStep("complete", "Deployment summary") + tracker.Start() + defer tracker.Stop() + + tracker.StartStep("source", "Gathering source information") + sourceInfo := buildSourceInfo(gitInfo, opts) + tracker.CompleteStep("source", sourceInfo) + + // Step 1: Prepare - validate environment and get Docker image + tracker.StartStep("prepare", "Validating deployment environment") - // Build or use existing Docker image dockerImage := opts.DockerImage if dockerImage == "" { - var err error - dockerImage, err = buildDockerImage(ctx, opts, gitInfo) - if err != nil { + if !isDockerAvailable() { + tracker.FailStep("prepare", "Docker command not found - please install Docker") + return ErrDockerNotFound + } + + imageTag := generateImageTag(opts, gitInfo) + dockerImage = fmt.Sprintf("%s:%s", opts.Registry, imageTag) + } + + tracker.CompleteStep("prepare", "Environment validated") + + // Step 2: Build - only if we need to build an image + if opts.DockerImage == "" { + tracker.StartStep("build", fmt.Sprintf("Building %s", dockerImage)) + + if err := buildImage(ctx, opts, dockerImage); err != nil { + tracker.FailStep("build", fmt.Sprintf("Build failed: %v", err)) return fmt.Errorf("docker build failed: %w", err) } + + tracker.CompleteStep("build", "Docker image built successfully") + } else { + tracker.SkipStep("build", "Using pre-built Docker image") } - if err := notifyControlPlane(ctx, logger, opts, dockerImage); err != nil { + // Step 3: Push - publish to registry + if opts.SkipPush { + tracker.SkipStep("push", "Push skipped (--skip-push enabled)") + } else if opts.DockerImage == "" { // Only push if we built the image + tracker.StartStep("push", "Publishing to registry") + + if err := pushImage(ctx, dockerImage, opts.Registry); err != nil { + // Push failure shouldn't be fatal in development + tracker.FailStep("push", fmt.Sprintf("Push failed: %v", err)) + fmt.Printf("Push failed but continuing with deployment\n") + } else { + tracker.CompleteStep("push", "Image published successfully") + } + } else { + tracker.SkipStep("push", "Using external Docker image") + } + + // Step 4: Deploy - notify control plane and start deployment + tracker.StartStep("deploy", "Starting deployment") + + if err := notifyControlPlane(ctx, logger, opts, dockerImage, tracker); err != nil { + tracker.FailStep("deploy", fmt.Sprintf("Deployment failed: %v", err)) return fmt.Errorf("deployment failed: %w", err) } + // Step 5: Activate - this will be completed by notifyControlPlane + // when the version becomes active return nil } -func printDeploymentSource(gitInfo git.Info, opts *DeployOptions) { - fmt.Println("Source") - fmt.Printf(" Branch: %s\n", opts.Branch) +func buildSourceInfo(gitInfo git.Info, opts *DeployOptions) string { + var parts []string + // Branch + parts = append(parts, fmt.Sprintf("Branch: %s", opts.Branch)) + + // Commit info if gitInfo.IsRepo && gitInfo.CommitSHA != "" { shortSHA := gitInfo.CommitSHA if len(shortSHA) > 7 { shortSHA = shortSHA[:7] } - fmt.Printf(" Commit: %s\n", shortSHA) - + commitInfo := fmt.Sprintf("Commit: %s", shortSHA) if gitInfo.IsDirty { - fmt.Printf(" Status: Working directory has uncommitted changes\n") + commitInfo += " (dirty)" } + parts = append(parts, commitInfo) } else if !gitInfo.IsRepo { - fmt.Printf(" Status: Not a git repository\n") + parts = append(parts, "Not a git repository") } - fmt.Printf(" Context: %s\n", opts.Context) + // Context + parts = append(parts, fmt.Sprintf("Context: %s", opts.Context)) + + // Docker image if pre-built if opts.DockerImage != "" { - fmt.Printf(" Docker Image: %s\n", opts.DockerImage) + parts = append(parts, fmt.Sprintf("Image: %s", opts.DockerImage)) } - fmt.Println() + + return strings.Join(parts, " | ") } // PrintDeployHelp prints detailed help for deploy command diff --git a/go/cmd/cli/commands/deploy/notify-ctrl.go b/go/cmd/cli/commands/deploy/notify-ctrl.go index 5c2bdae2612..fd53066f7cd 100644 --- a/go/cmd/cli/commands/deploy/notify-ctrl.go +++ b/go/cmd/cli/commands/deploy/notify-ctrl.go @@ -6,8 +6,10 @@ import ( "fmt" "net/http" "strings" + "time" "connectrpc.com/connect" + "github.com/unkeyed/unkey/go/cmd/cli/progress" ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect" "github.com/unkeyed/unkey/go/pkg/codes" @@ -16,7 +18,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/otel/logging" ) -func notifyControlPlane(ctx context.Context, logger logging.Logger, opts *DeployOptions, dockerImage string) error { +func notifyControlPlane(ctx context.Context, logger logging.Logger, opts *DeployOptions, dockerImage string, tracker *progress.Tracker) error { // Create control plane client httpClient := &http.Client{} client := ctrlv1connect.NewVersionServiceClient(httpClient, opts.ControlPlaneURL) @@ -38,66 +40,99 @@ func notifyControlPlane(ctx context.Context, logger logging.Logger, opts *Deploy // Call the API createResp, err := client.CreateVersion(ctx, createReq) if err != nil { - // Check if it's a connection error - if strings.Contains(err.Error(), "connection refused") { - return fault.Wrap(err, - fault.Code(codes.UnkeyAppErrorsInternalServiceUnavailable), - fault.Internal(fmt.Sprintf("Failed to connect to control plane at %s", opts.ControlPlaneURL)), - fault.Public("Unable to connect to control plane. Is it running?"), - ) - } - - // Check if it's an auth error - if connectErr := new(connect.Error); errors.As(err, &connectErr) { - if connectErr.Code() == connect.CodeUnauthenticated { - return fault.Wrap(err, - fault.Code(codes.UnkeyAuthErrorsAuthenticationMalformed), - fault.Internal(fmt.Sprintf("Authentication failed with token: %s", opts.AuthToken)), - fault.Public("Authentication failed. Check your auth token."), - ) - } - } - - // Generic API error - return fault.Wrap(err, - fault.Code(codes.UnkeyAppErrorsInternalUnexpectedError), - fault.Internal(fmt.Sprintf("CreateVersion API call failed: %v", err)), - fault.Public("Failed to create version. Please try again."), - ) + return handleCreateVersionError(err, opts) } versionId := createResp.Msg.GetVersionId() if versionId != "" { - fmt.Printf(" Version ID: %s\n", versionId) + tracker.UpdateStep("deploy", fmt.Sprintf("Version created: %s", versionId)) } - // Poll for version status updates - if err := pollVersionStatus(ctx, logger, client, opts.AuthToken, versionId); err != nil { - return fmt.Errorf("deployment failed: %w", err) + // Poll for version status updates with integrated progress tracking + if err := pollVersionStatus(ctx, logger, client, opts.AuthToken, versionId, tracker); err != nil { + return err } - printDeploymentComplete(versionId, opts.WorkspaceID, opts.Branch) + tracker.StartStep("complete", "Generating deployment summary") + gitInfo := git.GetInfo() + completionInfo := buildCompletionInfo(versionId, opts.WorkspaceID, opts.Branch, gitInfo) + tracker.CompleteStep("complete", completionInfo) + + // Give the animation loop time to render the completed state + // TODO: Improve this later + select { + case <-time.After(200 * time.Millisecond): + case <-ctx.Done(): + return ctx.Err() + } return nil } -func printDeploymentComplete(versionId, workspace, branch string) { - // Use Git info for hostname generation - gitInfo := git.GetInfo() +func buildCompletionInfo(versionId, workspace, branch string, gitInfo git.Info) string { + var parts []string + + // Version ID + parts = append(parts, fmt.Sprintf("Version: %s", versionId)) + + // Status + parts = append(parts, "Status: Ready") + + // Environment + parts = append(parts, "Env: Production") + + // Main domain identifier := versionId if gitInfo.ShortSHA != "" { identifier = gitInfo.ShortSHA } + cleanIdentifier := strings.ReplaceAll(identifier, "_", "-") + domain := fmt.Sprintf("https://%s-%s-%s.unkey.app", branch, cleanIdentifier, workspace) + parts = append(parts, fmt.Sprintf("URL: %s", domain)) - fmt.Println() - fmt.Println("Deployment Complete") - fmt.Printf(" Version ID: %s\n", versionId) - fmt.Printf(" Status: Ready\n") - fmt.Printf(" Environment: Production\n") - fmt.Println() - fmt.Println("Domains") + return strings.Join(parts, " | ") +} - // Replace underscores with dashes for valid hostname format - cleanIdentifier := strings.ReplaceAll(identifier, "_", "-") - fmt.Printf(" https://%s-%s-%s.unkey.app\n", branch, cleanIdentifier, workspace) - fmt.Printf(" https://api.acme.com\n") +func handleCreateVersionError(err error, opts *DeployOptions) error { + // Check if it's a connection error + if strings.Contains(err.Error(), "connection refused") { + return fault.Wrap(err, + fault.Code(codes.UnkeyAppErrorsInternalServiceUnavailable), + fault.Internal(fmt.Sprintf("Failed to connect to control plane at %s", opts.ControlPlaneURL)), + fault.Public("Unable to connect to control plane. Is it running?"), + ) + } + + // Check if it's an auth error + if connectErr := new(connect.Error); errors.As(err, &connectErr) { + if connectErr.Code() == connect.CodeUnauthenticated { + return fault.Wrap(err, + fault.Code(codes.UnkeyAuthErrorsAuthenticationMalformed), + fault.Internal(fmt.Sprintf("Authentication failed with token: %s", opts.AuthToken)), + fault.Public("Authentication failed. Check your auth token."), + ) + } + } + + // Generic API error + return fault.Wrap(err, + fault.Code(codes.UnkeyAppErrorsInternalUnexpectedError), + fault.Internal(fmt.Sprintf("CreateVersion API call failed: %v", err)), + fault.Public("Failed to create version. Please try again."), + ) +} + +// getFailureMessage extracts failure message from version +func getFailureMessage(version *ctrlv1.Version) string { + if version.GetErrorMessage() != "" { + return version.GetErrorMessage() + } + + // Check for error in steps + for _, step := range version.GetSteps() { + if step.GetErrorMessage() != "" { + return step.GetErrorMessage() + } + } + + return "Unknown deployment error" } diff --git a/go/cmd/cli/commands/deploy/poll-ctrl.go b/go/cmd/cli/commands/deploy/poll-ctrl.go index 53037e4f478..8363fbfee0d 100644 --- a/go/cmd/cli/commands/deploy/poll-ctrl.go +++ b/go/cmd/cli/commands/deploy/poll-ctrl.go @@ -6,29 +6,31 @@ import ( "time" "connectrpc.com/connect" + "github.com/unkeyed/unkey/go/cmd/cli/progress" ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect" "github.com/unkeyed/unkey/go/pkg/otel/logging" ) // pollVersionStatus polls the control plane API and displays deployment steps as they occur -func pollVersionStatus(ctx context.Context, logger logging.Logger, client ctrlv1connect.VersionServiceClient, authToken, versionId string) error { +func pollVersionStatus(ctx context.Context, logger logging.Logger, client ctrlv1connect.VersionServiceClient, authToken, versionId string, tracker *progress.Tracker) error { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() - timeout := time.NewTimer(300 * time.Second) // 5 minute timeout for full deployment + timeout := time.NewTimer(300 * time.Second) defer timeout.Stop() - displayedSteps := make(map[string]bool) + processedSteps := make(map[string]bool) + lastStatus := ctrlv1.VersionStatus_VERSION_STATUS_UNSPECIFIED + deployStepStarted := false for { select { case <-ctx.Done(): return ctx.Err() case <-timeout.C: - fmt.Printf("Error: Deployment timeout after 5 minutes\n") + tracker.FailStep("activate", "Deployment timeout after 5 minutes") return fmt.Errorf("deployment timeout") case <-ticker.C: - // Poll version status getReq := connect.NewRequest(&ctrlv1.GetVersionRequest{ VersionId: versionId, }) @@ -41,40 +43,87 @@ func pollVersionStatus(ctx context.Context, logger logging.Logger, client ctrlv1 } version := getResp.Msg.GetVersion() + currentStatus := version.GetStatus() - // Display version steps in real-time + // Handle version status changes + if currentStatus != lastStatus { + switch currentStatus { + case ctrlv1.VersionStatus_VERSION_STATUS_PENDING: + tracker.UpdateStep("deploy", "Version queued and ready to start") + + case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: + tracker.UpdateStep("deploy", "Building deployment image") + + case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: + if !deployStepStarted { + tracker.CompleteStep("deploy", "Deployment initiated") + tracker.StartStep("activate", "Deploying to unkey") + deployStepStarted = true + } else { + tracker.UpdateStep("activate", "Deploying to unkey") + } + + case ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE: + if deployStepStarted { + tracker.CompleteStep("activate", "Version is now active") + } else { + tracker.CompleteStep("deploy", "Deployment completed") + tracker.CompleteStep("activate", "Version is now active") + } + + // Give the animation loop time to render the completed state + // TODO: Improve this later + select { + case <-time.After(200 * time.Millisecond): + case <-ctx.Done(): + return ctx.Err() + } + + return nil + + case ctrlv1.VersionStatus_VERSION_STATUS_FAILED: + errorMsg := getFailureMessage(version) + if deployStepStarted { + tracker.FailStep("activate", errorMsg) + } else { + tracker.FailStep("deploy", errorMsg) + } + return fmt.Errorf("deployment failed: %s", errorMsg) + } + lastStatus = currentStatus + } + + // Process deployment steps for additional detail steps := version.GetSteps() for _, step := range steps { stepKey := step.GetStatus() - if !displayedSteps[stepKey] { - displayVersionStep(step) - displayedSteps[stepKey] = true - } - } + if !processedSteps[stepKey] && step.GetMessage() != "" { + // Update the current step with detailed messages + switch currentStatus { + case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: + tracker.UpdateStep("deploy", step.GetMessage()) + case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: + if deployStepStarted { + tracker.UpdateStep("activate", step.GetMessage()) + } else { + tracker.UpdateStep("deploy", step.GetMessage()) + } + } - // Check if deployment is complete - if version.GetStatus() == ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE { - return nil - } + // Handle step errors + if step.GetErrorMessage() != "" { + errorMsg := step.GetErrorMessage() + if deployStepStarted { + tracker.FailStep("activate", errorMsg) + } else { + tracker.FailStep("deploy", errorMsg) + } + return fmt.Errorf("deployment failed: %s", errorMsg) + } - // Check if deployment failed - if version.GetStatus() == ctrlv1.VersionStatus_VERSION_STATUS_FAILED { - return fmt.Errorf("deployment failed") + processedSteps[stepKey] = true + } } } } } - -// displayVersionStep shows a version step with appropriate formatting -func displayVersionStep(step *ctrlv1.VersionStep) { - message := step.GetMessage() - // Display only the actual message from the database, indented under "Creating Version" - if message != "" { - fmt.Printf(" %s\n", message) - } - - // Show error message if present - if step.GetErrorMessage() != "" { - fmt.Printf(" Error: %s\n", step.GetErrorMessage()) - } -} diff --git a/go/cmd/cli/progress/progress.go b/go/cmd/cli/progress/progress.go index 0496acbef11..ade7b51e3a0 100644 --- a/go/cmd/cli/progress/progress.go +++ b/go/cmd/cli/progress/progress.go @@ -21,10 +21,8 @@ const ( // Animation characters var ( - SpinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - DotsChars = []string{"", ".", "..", "..."} - ProgressChars = []string{"▱", "▰"} - PulseChars = []string{"◐", "◓", "◑", "◒"} + SpinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + DotsChars = []string{"", ".", "..", "..."} ) // Status represents the state of a tracked item @@ -49,22 +47,22 @@ type Step struct { EndTime time.Time Active bool Progress float64 // 0.0 to 1.0 for progress bars - metadata map[string]interface{} + metadata map[string]any mu sync.RWMutex } // SetMetadata sets custom metadata for the step -func (s *Step) SetMetadata(key string, value interface{}) { +func (s *Step) SetMetadata(key string, value any) { s.mu.Lock() defer s.mu.Unlock() if s.metadata == nil { - s.metadata = make(map[string]interface{}) + s.metadata = make(map[string]any) } s.metadata[key] = value } // GetMetadata gets custom metadata for the step -func (s *Step) GetMetadata(key string) (interface{}, bool) { +func (s *Step) GetMetadata(key string) (any, bool) { s.mu.RLock() defer s.mu.RUnlock() if s.metadata == nil { @@ -269,12 +267,6 @@ func (t *Tracker) Stop() { t.mu.Unlock() close(t.done) - - if t.options.ClearOnDone { - t.renderFinalState() - } else { - t.render(true) // Render final state without animation - } } // animationLoop runs the animation updates @@ -285,6 +277,12 @@ func (t *Tracker) animationLoop() { for { select { case <-t.done: + // Render final state before exiting + if t.options.ClearOnDone { + t.renderFinalState() + } else { + t.render(true) // Final render without animation + } return case <-ticker.C: t.updateAnimation() @@ -381,24 +379,25 @@ func (t *Tracker) renderStep(step *Step, final bool) { } // getStepIcon returns the appropriate icon and color for a step + func (t *Tracker) getStepIcon(step *Step, final bool) (string, string) { switch step.Status { case StatusPending: - return t.colorize("⏳", ColorYellow), t.color(ColorYellow) + return t.colorize("[ ]", ColorYellow), t.color(ColorYellow) case StatusRunning: if step.Active && !final { char := SpinnerChars[t.animation.frame%len(SpinnerChars)] return t.colorize(char, ColorCyan), t.color(ColorCyan) } - return t.colorize("⚙️", ColorCyan), t.color(ColorCyan) + return t.colorize("[*]", ColorCyan), t.color(ColorCyan) case StatusCompleted: - return t.colorize("✅", ColorGreen), t.color(ColorGreen) + return t.colorize("[✔]", ColorGreen), t.color(ColorGreen) case StatusFailed: - return t.colorize("❌", ColorRed), t.color(ColorRed) + return t.colorize("[✘]", ColorRed), t.color(ColorRed) case StatusSkipped: - return t.colorize("⏭️", ColorGray), t.color(ColorGray) + return t.colorize("[-]", ColorGray), t.color(ColorGray) default: - return t.colorize("⏳", ColorYellow), t.color(ColorYellow) + return t.colorize("[ ]", ColorYellow), t.color(ColorYellow) } } @@ -463,33 +462,3 @@ func (t *Tracker) colorize(text, color string) string { } return color + text + ColorReset } - -func BuildProgress(target string) *Tracker { - opts := DefaultOptions() - opts.ClearOnDone = false // Don't clear screen when done - opts.ShowElapsed = true - opts.ShowDuration = true - - tracker := NewTracker(fmt.Sprintf("Building %s", target), opts) - tracker.AddStep("prepare", "Preparing build environment") - tracker.AddStep("dependencies", "Installing dependencies") - tracker.AddStep("compile", "Compiling") - tracker.AddStep("package", "Packaging") - tracker.AddStep("verify", "Verifying build") - tracker.Start() - return tracker -} - -func DeployProgress() *Tracker { - opts := DefaultOptions() - opts.ShowElapsed = true - opts.ShowDuration = true - - tracker := NewTracker("Deployment Progress", opts) - tracker.AddStep("pending", "Version queued") - tracker.AddStep("building", "Building deployment") - tracker.AddStep("deploying", "Deploying to infrastructure") - tracker.AddStep("active", "Activation complete") - tracker.Start() - return tracker -} From 9e1b02a220f249c673ee6da0a3f2c81eab98ce39 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 15 Jul 2025 12:43:01 +0300 Subject: [PATCH 07/23] refactor: improve animations and errors --- go/cmd/cli/commands/deploy/build-docker.go | 32 +--- go/cmd/cli/commands/deploy/deploy.go | 6 +- go/cmd/cli/commands/deploy/notify-ctrl.go | 33 ++-- go/cmd/cli/commands/deploy/poll-ctrl.go | 167 +++++++++--------- go/cmd/cli/progress/progress.go | 188 +++++++++++++++------ 5 files changed, 258 insertions(+), 168 deletions(-) diff --git a/go/cmd/cli/commands/deploy/build-docker.go b/go/cmd/cli/commands/deploy/build-docker.go index 9598d186c59..7bca6c6f57e 100644 --- a/go/cmd/cli/commands/deploy/build-docker.go +++ b/go/cmd/cli/commands/deploy/build-docker.go @@ -44,50 +44,34 @@ func pushImage(ctx context.Context, dockerImage, registry string) error { cmd := exec.CommandContext(ctx, "docker", "push", dockerImage) output, err := cmd.CombinedOutput() if err != nil { - return classifyPushError(string(output), registry) + detailedMsg := classifyPushError(string(output), registry) + fmt.Printf("Docker push failed: %s\n", detailedMsg) + return fmt.Errorf("%s", detailedMsg) } - fmt.Printf("%s\n", string(output)) return nil } -func classifyPushError(output, registry string) error { +func classifyPushError(output, registry string) string { output = strings.TrimSpace(output) registryHost := getRegistryHost(registry) switch { case strings.Contains(output, "denied"): - fmt.Printf("Docker push failed: Registry access denied\n") - fmt.Printf(" Registry: %s\n", registry) - fmt.Printf(" Solutions:\n") - fmt.Printf(" • Login: docker login %s\n", registryHost) - fmt.Printf(" • Use your own registry: --registry=your-registry/your-app\n") - fmt.Printf(" • Skip push: --skip-push\n") - return ErrDockerPushFailed + return fmt.Sprintf("registry access denied. Try: docker login %s", registryHost) case strings.Contains(output, "not found") || strings.Contains(output, "404"): - fmt.Printf("Docker push failed: Registry not found\n") - fmt.Printf(" Registry: %s\n", registry) - fmt.Printf(" Solutions:\n") - fmt.Printf(" • Create repository first\n") - fmt.Printf(" • Use different registry: --registry=your-registry/your-app\n") - fmt.Printf(" • Skip push: --skip-push\n") - return ErrDockerPushFailed + return "registry not found. Create repository or use --registry=your-registry/your-app" case strings.Contains(output, "unauthorized"): - fmt.Printf("Docker push failed: Authentication required\n") - fmt.Printf(" Run: docker login %s\n", registryHost) - fmt.Printf(" Or skip: --skip-push\n") - return ErrDockerPushFailed + return fmt.Sprintf("authentication required. Run: docker login %s", registryHost) default: - fmt.Printf("Docker push failed: %s\n", output) - return ErrDockerPushFailed + return output } } // ## HELPERS - func getRegistryHost(registry string) string { parts := strings.Split(registry, "/") if len(parts) > 0 { diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index f4eed2a536d..2fa0c93ba1d 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -62,7 +62,7 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { tracker.AddStep("prepare", "Preparing deployment") tracker.AddStep("build", "Building Docker image") tracker.AddStep("push", "Publishing to registry") - tracker.AddStep("deploy", "Deploying to infrastructure") + tracker.AddStep("deploy", "Deploying to Unkey") tracker.AddStep("activate", "Activating version") tracker.AddStep("complete", "Deployment summary") tracker.Start() @@ -110,7 +110,7 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { if err := pushImage(ctx, dockerImage, opts.Registry); err != nil { // Push failure shouldn't be fatal in development - tracker.FailStep("push", fmt.Sprintf("Push failed: %v", err)) + tracker.FailStep("push", fmt.Sprintf("push failed: %v", err)) fmt.Printf("Push failed but continuing with deployment\n") } else { tracker.CompleteStep("push", "Image published successfully") @@ -123,7 +123,7 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { tracker.StartStep("deploy", "Starting deployment") if err := notifyControlPlane(ctx, logger, opts, dockerImage, tracker); err != nil { - tracker.FailStep("deploy", fmt.Sprintf("Deployment failed: %v", err)) + tracker.FailStep("deploy", fmt.Sprintf("deployment failed: %v", err)) return fmt.Errorf("deployment failed: %w", err) } diff --git a/go/cmd/cli/commands/deploy/notify-ctrl.go b/go/cmd/cli/commands/deploy/notify-ctrl.go index fd53066f7cd..4c771654f50 100644 --- a/go/cmd/cli/commands/deploy/notify-ctrl.go +++ b/go/cmd/cli/commands/deploy/notify-ctrl.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" "strings" - "time" "connectrpc.com/connect" "github.com/unkeyed/unkey/go/cmd/cli/progress" @@ -30,7 +29,7 @@ func notifyControlPlane(ctx context.Context, logger logging.Logger, opts *Deploy Branch: opts.Branch, SourceType: ctrlv1.SourceType_SOURCE_TYPE_CLI_UPLOAD, GitCommitSha: opts.Commit, - EnvironmentId: "env_prod", + EnvironmentId: "env_prod", // TODO: Make this configurable DockerImageTag: dockerImage, }) @@ -44,10 +43,17 @@ func notifyControlPlane(ctx context.Context, logger logging.Logger, opts *Deploy } versionId := createResp.Msg.GetVersionId() - if versionId != "" { - tracker.UpdateStep("deploy", fmt.Sprintf("Version created: %s", versionId)) + if versionId == "" { + return fault.Wrap( + fmt.Errorf("empty version ID returned from control plane"), + fault.Code(codes.UnkeyAppErrorsInternalUnexpectedError), + fault.Internal("CreateVersion API returned empty version ID"), + fault.Public("Failed to create version. Please try again."), + ) } + tracker.UpdateStep("deploy", fmt.Sprintf("Version created: %s", versionId)) + // Poll for version status updates with integrated progress tracking if err := pollVersionStatus(ctx, logger, client, opts.AuthToken, versionId, tracker); err != nil { return err @@ -56,15 +62,20 @@ func notifyControlPlane(ctx context.Context, logger logging.Logger, opts *Deploy tracker.StartStep("complete", "Generating deployment summary") gitInfo := git.GetInfo() completionInfo := buildCompletionInfo(versionId, opts.WorkspaceID, opts.Branch, gitInfo) - tracker.CompleteStep("complete", completionInfo) - // Give the animation loop time to render the completed state - // TODO: Improve this later - select { - case <-time.After(200 * time.Millisecond): - case <-ctx.Done(): - return ctx.Err() + if completionInfo == "" { + tracker.FailStep("complete", "failed to generate deployment summary") + return fault.Wrap( + fmt.Errorf("empty completion info generated"), + fault.Code(codes.UnkeyAppErrorsInternalUnexpectedError), + fault.Internal("buildCompletionInfo returned empty string"), + fault.Public("Failed to generate deployment summary"), + ) } + + tracker.CompleteStep("complete", completionInfo) + + // Remove this sleep hack - fix it in the tracker instead return nil } diff --git a/go/cmd/cli/commands/deploy/poll-ctrl.go b/go/cmd/cli/commands/deploy/poll-ctrl.go index 8363fbfee0d..f6534ea9910 100644 --- a/go/cmd/cli/commands/deploy/poll-ctrl.go +++ b/go/cmd/cli/commands/deploy/poll-ctrl.go @@ -19,23 +19,22 @@ func pollVersionStatus(ctx context.Context, logger logging.Logger, client ctrlv1 timeout := time.NewTimer(300 * time.Second) defer timeout.Stop() - processedSteps := make(map[string]bool) + // Track processed steps by creation time to avoid duplicates + processedSteps := make(map[int64]bool) lastStatus := ctrlv1.VersionStatus_VERSION_STATUS_UNSPECIFIED - deployStepStarted := false for { select { case <-ctx.Done(): return ctx.Err() case <-timeout.C: - tracker.FailStep("activate", "Deployment timeout after 5 minutes") + tracker.FailStep("activate", "deployment timeout after 5 minutes") return fmt.Errorf("deployment timeout") case <-ticker.C: getReq := connect.NewRequest(&ctrlv1.GetVersionRequest{ VersionId: versionId, }) getReq.Header().Set("Authorization", "Bearer "+authToken) - getResp, err := client.GetVersion(ctx, getReq) if err != nil { logger.Debug("Failed to get version status", "error", err, "version_id", versionId) @@ -47,83 +46,99 @@ func pollVersionStatus(ctx context.Context, logger logging.Logger, client ctrlv1 // Handle version status changes if currentStatus != lastStatus { - switch currentStatus { - case ctrlv1.VersionStatus_VERSION_STATUS_PENDING: - tracker.UpdateStep("deploy", "Version queued and ready to start") - - case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: - tracker.UpdateStep("deploy", "Building deployment image") - - case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: - if !deployStepStarted { - tracker.CompleteStep("deploy", "Deployment initiated") - tracker.StartStep("activate", "Deploying to unkey") - deployStepStarted = true - } else { - tracker.UpdateStep("activate", "Deploying to unkey") - } - - case ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE: - if deployStepStarted { - tracker.CompleteStep("activate", "Version is now active") - } else { - tracker.CompleteStep("deploy", "Deployment completed") - tracker.CompleteStep("activate", "Version is now active") - } - - // Give the animation loop time to render the completed state - // TODO: Improve this later - select { - case <-time.After(200 * time.Millisecond): - case <-ctx.Done(): - return ctx.Err() - } - - return nil - - case ctrlv1.VersionStatus_VERSION_STATUS_FAILED: - errorMsg := getFailureMessage(version) - if deployStepStarted { - tracker.FailStep("activate", errorMsg) - } else { - tracker.FailStep("deploy", errorMsg) - } - return fmt.Errorf("deployment failed: %s", errorMsg) + if err := handleStatusTransition(tracker, lastStatus, currentStatus, version); err != nil { + return err } lastStatus = currentStatus } - // Process deployment steps for additional detail - steps := version.GetSteps() - for _, step := range steps { - stepKey := step.GetStatus() - if !processedSteps[stepKey] && step.GetMessage() != "" { - // Update the current step with detailed messages - switch currentStatus { - case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: - tracker.UpdateStep("deploy", step.GetMessage()) - case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: - if deployStepStarted { - tracker.UpdateStep("activate", step.GetMessage()) - } else { - tracker.UpdateStep("deploy", step.GetMessage()) - } - } - - // Handle step errors - if step.GetErrorMessage() != "" { - errorMsg := step.GetErrorMessage() - if deployStepStarted { - tracker.FailStep("activate", errorMsg) - } else { - tracker.FailStep("deploy", errorMsg) - } - return fmt.Errorf("deployment failed: %s", errorMsg) - } - - processedSteps[stepKey] = true - } + // Process new step updates + if err := processNewSteps(tracker, version.GetSteps(), processedSteps, currentStatus); err != nil { + return err + } + + // Check for completion + if currentStatus == ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE { + return nil + } + } + } +} + +func handleStatusTransition(tracker *progress.Tracker, lastStatus, currentStatus ctrlv1.VersionStatus, version *ctrlv1.Version) error { + switch currentStatus { + case ctrlv1.VersionStatus_VERSION_STATUS_PENDING: + tracker.UpdateStep("deploy", "Version queued and ready to start") + + case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: + tracker.UpdateStep("deploy", "Building deployment image") + + case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: + tracker.CompleteStep("deploy", "Deployment initiated") + tracker.StartStep("activate", "Deploying to unkey") + + case ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE: + if lastStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { + tracker.CompleteStep("activate", "Version is now active") + } else { + tracker.CompleteStep("deploy", "Deployment completed") + tracker.CompleteStep("activate", "Version is now active") + } + + case ctrlv1.VersionStatus_VERSION_STATUS_FAILED: + errorMsg := getFailureMessage(version) + if lastStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { + tracker.FailStep("activate", errorMsg) + } else { + tracker.FailStep("deploy", errorMsg) + } + return fmt.Errorf("deployment failed: %s", errorMsg) + } + return nil +} + +func processNewSteps(tracker *progress.Tracker, steps []*ctrlv1.VersionStep, processedSteps map[int64]bool, currentStatus ctrlv1.VersionStatus) error { + for _, step := range steps { + // Creation timestamp as unique identifier + stepTimestamp := step.GetCreatedAt() + + if processedSteps[stepTimestamp] { + continue // Already processed this step + } + + // Handle step errors first + if step.GetErrorMessage() != "" { + errorMsg := step.GetErrorMessage() + if currentStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { + tracker.FailStep("activate", errorMsg) + } else { + tracker.FailStep("deploy", errorMsg) } + return fmt.Errorf("deployment failed: %s", errorMsg) } + + // Show step updates to user + if step.GetMessage() != "" { + message := step.GetMessage() + + // Add status context if helpful + if step.GetStatus() != "" { + message = fmt.Sprintf("[%s] %s", step.GetStatus(), message) + } + + switch currentStatus { + case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: + tracker.UpdateStep("deploy", message) + case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: + tracker.UpdateStep("activate", message) + default: + // For other statuses, show on deploy step + tracker.UpdateStep("deploy", message) + } + } + + // Mark this step as processed + processedSteps[stepTimestamp] = true } + return nil } diff --git a/go/cmd/cli/progress/progress.go b/go/cmd/cli/progress/progress.go index ade7b51e3a0..765e3308e98 100644 --- a/go/cmd/cli/progress/progress.go +++ b/go/cmd/cli/progress/progress.go @@ -87,14 +87,16 @@ func (s *Step) Duration() time.Duration { // Tracker manages animated progress tracking type Tracker struct { - title string - steps map[string]*Step - stepOrder []string - animation animationState - done chan struct{} - running bool - mu sync.RWMutex - options TrackerOptions + title string + steps map[string]*Step + stepOrder []string + animation animationState + done chan struct{} + running bool + mu sync.RWMutex + options TrackerOptions + renderState renderState + firstRender bool } type animationState struct { @@ -102,6 +104,11 @@ type animationState struct { lastUpdate time.Time } +type renderState struct { + linesRendered int + lastContent []string +} + // TrackerOptions configures the tracker behavior type TrackerOptions struct { ShowElapsed bool // Show elapsed time for running steps @@ -134,12 +141,13 @@ func NewTracker(title string, opts ...TrackerOptions) *Tracker { } return &Tracker{ - title: title, - steps: make(map[string]*Step), - stepOrder: make([]string, 0), - animation: animationState{lastUpdate: time.Now()}, - done: make(chan struct{}), - options: options, + title: title, + steps: make(map[string]*Step), + stepOrder: make([]string, 0), + animation: animationState{lastUpdate: time.Now()}, + done: make(chan struct{}), + options: options, + firstRender: true, } } @@ -253,6 +261,9 @@ func (t *Tracker) Start() { t.running = true t.mu.Unlock() + // Do initial render immediately to avoid race conditions + t.render(false) + go t.animationLoop() } @@ -267,6 +278,12 @@ func (t *Tracker) Stop() { t.mu.Unlock() close(t.done) + + // Wait a brief moment for the animation loop to finish + time.Sleep(50 * time.Millisecond) + + // Ensure final state is rendered + t.render(true) } // animationLoop runs the animation updates @@ -303,55 +320,82 @@ func (t *Tracker) updateAnimation() { } } -// render displays the current state +// render displays the current state with minimal layout shift func (t *Tracker) render(final bool) { - if !final { - // Clear screen and move cursor to top for live updates - fmt.Print("\033[H\033[J") + // Build the complete content first + content := t.buildContent(final) + + if t.firstRender { + // First render - just print everything + for _, line := range content { + fmt.Println(line) + } + t.firstRender = false + t.renderState.linesRendered = len(content) + t.renderState.lastContent = make([]string, len(content)) + copy(t.renderState.lastContent, content) + return } + // Update only changed lines + t.updateChangedLines(content) + + // Store current content for next comparison + t.renderState.lastContent = make([]string, len(content)) + copy(t.renderState.lastContent, content) +} + +// buildContent builds the complete content as slice of lines +func (t *Tracker) buildContent(final bool) []string { + var content []string + // Title titleColor := t.color(ColorBlue) - fmt.Printf("%s%s%s\n", titleColor, t.title, t.colorReset()) + content = append(content, fmt.Sprintf("%s%s%s", titleColor, t.title, t.colorReset())) if !t.options.Compact { - fmt.Println(strings.Repeat("─", 50)) + content = append(content, strings.Repeat("─", 50)) } - // Render steps + // Build step content t.mu.RLock() for _, stepID := range t.stepOrder { step := t.steps[stepID] - t.renderStep(step, final) + stepLines := t.buildStepContent(step, final) + content = append(content, stepLines...) } t.mu.RUnlock() - fmt.Println() + content = append(content, "") // Empty line at the end + + return content } -// renderStep renders a single step -func (t *Tracker) renderStep(step *Step, final bool) { +// buildStepContent builds content for a single step +func (t *Tracker) buildStepContent(step *Step, final bool) []string { + var lines []string + step.mu.RLock() defer step.mu.RUnlock() icon, color := t.getStepIcon(step, final) // Step name with icon - fmt.Printf("%s %s%s%s", icon, color, step.Name, t.colorReset()) + stepLine := fmt.Sprintf("%s %s%s%s", icon, color, step.Name, t.colorReset()) // Show elapsed time for running steps if t.options.ShowElapsed && step.Status == StatusRunning && step.Active && !final { elapsed := time.Since(step.StartTime).Truncate(time.Second) - fmt.Printf(" %s(%s)%s", t.color(ColorGray), elapsed, t.colorReset()) + stepLine += fmt.Sprintf(" %s(%s)%s", t.color(ColorGray), elapsed, t.colorReset()) } // Show duration for completed steps if t.options.ShowDuration && step.Status == StatusCompleted && !step.EndTime.IsZero() { duration := step.EndTime.Sub(step.StartTime).Truncate(time.Millisecond) - fmt.Printf(" %s(%s)%s", t.color(ColorGreen), duration, t.colorReset()) + stepLine += fmt.Sprintf(" %s(%s)%s", t.color(ColorGreen), duration, t.colorReset()) } - fmt.Println() + lines = append(lines, stepLine) // Show message if step.Message != "" { @@ -364,45 +408,56 @@ func (t *Tracker) renderStep(step *Step, final bool) { message = message + dots } - fmt.Printf("%s%s\n", indent, message) + lines = append(lines, fmt.Sprintf("%s%s", indent, message)) } // Show progress bar if available if t.options.ShowProgress && step.Progress > 0 && step.Status == StatusRunning { - t.renderProgressBar(step.Progress) + progressLine := t.buildProgressBar(step.Progress) + lines = append(lines, progressLine) } // Show error if present if step.Error != "" { - fmt.Printf(" %sError: %s%s\n", t.color(ColorRed), step.Error, t.colorReset()) + errorLine := fmt.Sprintf(" %s -> Error: %s%s", t.color(ColorRed), step.Error, t.colorReset()) + lines = append(lines, errorLine) } + + return lines } -// getStepIcon returns the appropriate icon and color for a step +// updateChangedLines updates only the lines that have changed +func (t *Tracker) updateChangedLines(newContent []string) { + maxLines := max(len(t.renderState.lastContent), len(newContent)) -func (t *Tracker) getStepIcon(step *Step, final bool) (string, string) { - switch step.Status { - case StatusPending: - return t.colorize("[ ]", ColorYellow), t.color(ColorYellow) - case StatusRunning: - if step.Active && !final { - char := SpinnerChars[t.animation.frame%len(SpinnerChars)] - return t.colorize(char, ColorCyan), t.color(ColorCyan) + for i := range maxLines { + var newLine, oldLine string + + if i < len(newContent) { + newLine = newContent[i] + } + if i < len(t.renderState.lastContent) { + oldLine = t.renderState.lastContent[i] + } + + if newLine != oldLine { + // Move cursor to the line and clear it + fmt.Printf("\033[%d;1H\033[K%s", i+1, newLine) + } + } + + // If we have fewer lines now, clear the remaining ones + if len(newContent) < len(t.renderState.lastContent) { + for i := len(newContent); i < len(t.renderState.lastContent); i++ { + fmt.Printf("\033[%d;1H\033[K", i+1) } - return t.colorize("[*]", ColorCyan), t.color(ColorCyan) - case StatusCompleted: - return t.colorize("[✔]", ColorGreen), t.color(ColorGreen) - case StatusFailed: - return t.colorize("[✘]", ColorRed), t.color(ColorRed) - case StatusSkipped: - return t.colorize("[-]", ColorGray), t.color(ColorGray) - default: - return t.colorize("[ ]", ColorYellow), t.color(ColorYellow) } + + t.renderState.linesRendered = len(newContent) } -// renderProgressBar renders a progress bar -func (t *Tracker) renderProgressBar(progress float64) { +// buildProgressBar builds a progress bar string +func (t *Tracker) buildProgressBar(progress float64) string { if progress < 0 { progress = 0 } @@ -415,7 +470,7 @@ func (t *Tracker) renderProgressBar(progress float64) { bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) percentage := int(progress * 100) - fmt.Printf(" %s[%s%s%s] %d%%\n", + return fmt.Sprintf(" %s[%s%s%s] %d%%", t.color(ColorCyan), t.color(ColorGreen), bar, @@ -423,6 +478,28 @@ func (t *Tracker) renderProgressBar(progress float64) { percentage) } +// getStepIcon returns the appropriate icon and color for a step +func (t *Tracker) getStepIcon(step *Step, final bool) (string, string) { + switch step.Status { + case StatusPending: + return t.colorize("○", ColorYellow), t.color(ColorYellow) + case StatusRunning: + if step.Active && !final { + char := SpinnerChars[t.animation.frame%len(SpinnerChars)] + return t.colorize(char, ColorCyan), t.color(ColorCyan) + } + return t.colorize("●", ColorCyan), t.color(ColorCyan) + case StatusCompleted: + return t.colorize("✓", ColorGreen), t.color(ColorGreen) + case StatusFailed: + return t.colorize("✗", ColorRed), t.color(ColorRed) + case StatusSkipped: + return t.colorize("⊘", ColorGray), t.color(ColorGray) + default: + return t.colorize("○", ColorYellow), t.color(ColorYellow) + } +} + // renderFinalState shows the final state func (t *Tracker) renderFinalState() { fmt.Print("\033[H\033[J") @@ -432,7 +509,10 @@ func (t *Tracker) renderFinalState() { t.mu.RLock() for _, stepID := range t.stepOrder { step := t.steps[stepID] - t.renderStep(step, true) + stepLines := t.buildStepContent(step, true) + for _, line := range stepLines { + fmt.Println(line) + } } t.mu.RUnlock() From aa7cde2d00ed8fff4abaa7cebb5c1f0691c68396 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 15 Jul 2025 14:13:23 +0300 Subject: [PATCH 08/23] feat: use proper orchestrafor managing steps and trackers --- .../{build-docker.go => build_docker.go} | 0 go/cmd/cli/commands/deploy/control_plane.go | 260 ++++++++++++++ go/cmd/cli/commands/deploy/deploy.go | 326 +++++++++++------- .../deploy/{parse-flags.go => flags.go} | 0 go/cmd/cli/commands/deploy/help.go | 58 ++++ go/cmd/cli/commands/deploy/notify-ctrl.go | 149 -------- go/cmd/cli/commands/deploy/poll-ctrl.go | 144 -------- go/cmd/cli/orchestrator/orchestrator.go | 130 +++++++ go/cmd/cli/orchestrator/state.go | 167 +++++++++ go/cmd/cli/orchestrator/step.go | 90 +++++ 10 files changed, 905 insertions(+), 419 deletions(-) rename go/cmd/cli/commands/deploy/{build-docker.go => build_docker.go} (100%) create mode 100644 go/cmd/cli/commands/deploy/control_plane.go rename go/cmd/cli/commands/deploy/{parse-flags.go => flags.go} (100%) create mode 100644 go/cmd/cli/commands/deploy/help.go delete mode 100644 go/cmd/cli/commands/deploy/notify-ctrl.go delete mode 100644 go/cmd/cli/commands/deploy/poll-ctrl.go create mode 100644 go/cmd/cli/orchestrator/orchestrator.go create mode 100644 go/cmd/cli/orchestrator/state.go create mode 100644 go/cmd/cli/orchestrator/step.go diff --git a/go/cmd/cli/commands/deploy/build-docker.go b/go/cmd/cli/commands/deploy/build_docker.go similarity index 100% rename from go/cmd/cli/commands/deploy/build-docker.go rename to go/cmd/cli/commands/deploy/build_docker.go diff --git a/go/cmd/cli/commands/deploy/control_plane.go b/go/cmd/cli/commands/deploy/control_plane.go new file mode 100644 index 00000000000..adc88f0ede5 --- /dev/null +++ b/go/cmd/cli/commands/deploy/control_plane.go @@ -0,0 +1,260 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "connectrpc.com/connect" + "github.com/unkeyed/unkey/go/cmd/cli/progress" + ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" + "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect" + "github.com/unkeyed/unkey/go/pkg/codes" + "github.com/unkeyed/unkey/go/pkg/fault" + "github.com/unkeyed/unkey/go/pkg/otel/logging" +) + +type ControlPlaneClient struct { + client ctrlv1connect.VersionServiceClient + opts *DeployOptions +} + +// NewControlPlaneClient creates a new control plane client +func NewControlPlaneClient(opts *DeployOptions) *ControlPlaneClient { + httpClient := &http.Client{} + client := ctrlv1connect.NewVersionServiceClient(httpClient, opts.ControlPlaneURL) + + return &ControlPlaneClient{ + client: client, + opts: opts, + } +} + +// CreateVersion creates a new version in the control plane +func (c *ControlPlaneClient) CreateVersion(ctx context.Context, dockerImage string) (string, error) { + createReq := connect.NewRequest(&ctrlv1.CreateVersionRequest{ + WorkspaceId: c.opts.WorkspaceID, + ProjectId: c.opts.ProjectID, + Branch: c.opts.Branch, + SourceType: ctrlv1.SourceType_SOURCE_TYPE_CLI_UPLOAD, + GitCommitSha: c.opts.Commit, + EnvironmentId: "env_prod", // TODO: Make this configurable + DockerImageTag: dockerImage, + }) + + createReq.Header().Set("Authorization", "Bearer "+c.opts.AuthToken) + + createResp, err := c.client.CreateVersion(ctx, createReq) + if err != nil { + return "", c.handleCreateVersionError(err) + } + + versionId := createResp.Msg.GetVersionId() + if versionId == "" { + return "", fmt.Errorf("empty version ID returned from control plane") + } + + return versionId, nil +} + +// GetVersion retrieves version information from the control plane +func (c *ControlPlaneClient) GetVersion(ctx context.Context, versionId string) (*ctrlv1.Version, error) { + getReq := connect.NewRequest(&ctrlv1.GetVersionRequest{ + VersionId: versionId, + }) + getReq.Header().Set("Authorization", "Bearer "+c.opts.AuthToken) + + getResp, err := c.client.GetVersion(ctx, getReq) + if err != nil { + return nil, err + } + + return getResp.Msg.GetVersion(), nil +} + +// PollVersionStatus polls the control plane API and displays deployment steps as they occur +func (c *ControlPlaneClient) PollVersionStatus(ctx context.Context, logger logging.Logger, versionId string, tracker *progress.Tracker) error { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + timeout := time.NewTimer(300 * time.Second) + defer timeout.Stop() + + // Track processed steps by creation time to avoid duplicates + processedSteps := make(map[int64]bool) + lastStatus := ctrlv1.VersionStatus_VERSION_STATUS_UNSPECIFIED + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeout.C: + if tracker != nil { + tracker.FailStep("activate", "deployment timeout after 5 minutes") + } + return fmt.Errorf("deployment timeout") + case <-ticker.C: + version, err := c.GetVersion(ctx, versionId) + if err != nil { + logger.Debug("Failed to get version status", "error", err, "version_id", versionId) + continue + } + + currentStatus := version.GetStatus() + + // Handle version status changes + if currentStatus != lastStatus { + if err := c.handleStatusTransition(tracker, lastStatus, currentStatus, version); err != nil { + return err + } + lastStatus = currentStatus + } + + // Process new step updates + if err := c.processNewSteps(tracker, version.GetSteps(), processedSteps, currentStatus); err != nil { + return err + } + + // Check for completion + if currentStatus == ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE { + return nil + } + } + } +} + +// handleStatusTransition handles version status changes and updates the tracker +func (c *ControlPlaneClient) handleStatusTransition(tracker *progress.Tracker, lastStatus, currentStatus ctrlv1.VersionStatus, version *ctrlv1.Version) error { + if tracker == nil { + return nil // Gracefully handle nil tracker + } + + switch currentStatus { + case ctrlv1.VersionStatus_VERSION_STATUS_PENDING: + tracker.UpdateStep("deploy", "Version queued and ready to start") + + case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: + tracker.UpdateStep("deploy", "Building deployment image") + + case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: + tracker.CompleteStep("deploy", "Deployment initiated") + tracker.StartStep("activate", "Deploying to unkey") + + case ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE: + if lastStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { + tracker.CompleteStep("activate", "Version is now active") + } else { + tracker.CompleteStep("deploy", "Deployment completed") + tracker.CompleteStep("activate", "Version is now active") + } + + case ctrlv1.VersionStatus_VERSION_STATUS_FAILED: + errorMsg := c.getFailureMessage(version) + if lastStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { + tracker.FailStep("activate", errorMsg) + } else { + tracker.FailStep("deploy", errorMsg) + } + return fmt.Errorf("deployment failed: %s", errorMsg) + } + return nil +} + +// processNewSteps processes new deployment steps and updates the tracker +func (c *ControlPlaneClient) processNewSteps(tracker *progress.Tracker, steps []*ctrlv1.VersionStep, processedSteps map[int64]bool, currentStatus ctrlv1.VersionStatus) error { + if tracker == nil { + return nil // Gracefully handle nil tracker + } + + for _, step := range steps { + // Creation timestamp as unique identifier + stepTimestamp := step.GetCreatedAt() + + if processedSteps[stepTimestamp] { + continue // Already processed this step + } + + // Handle step errors first + if step.GetErrorMessage() != "" { + errorMsg := step.GetErrorMessage() + if currentStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { + tracker.FailStep("activate", errorMsg) + } else { + tracker.FailStep("deploy", errorMsg) + } + return fmt.Errorf("deployment failed: %s", errorMsg) + } + + // Show step updates to user + if step.GetMessage() != "" { + message := step.GetMessage() + + // Add status context if helpful + if step.GetStatus() != "" { + message = fmt.Sprintf("[%s] %s", step.GetStatus(), message) + } + + switch currentStatus { + case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: + tracker.UpdateStep("deploy", message) + case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: + tracker.UpdateStep("activate", message) + default: + // For other statuses, show on deploy step + tracker.UpdateStep("deploy", message) + } + } + + // Mark this step as processed + processedSteps[stepTimestamp] = true + } + return nil +} + +// getFailureMessage extracts failure message from version +func (c *ControlPlaneClient) getFailureMessage(version *ctrlv1.Version) string { + if version.GetErrorMessage() != "" { + return version.GetErrorMessage() + } + + // Check for error in steps + for _, step := range version.GetSteps() { + if step.GetErrorMessage() != "" { + return step.GetErrorMessage() + } + } + + return "Unknown deployment error" +} + +// handleCreateVersionError provides specific error handling for version creation +func (c *ControlPlaneClient) handleCreateVersionError(err error) error { + // Check if it's a connection error + if strings.Contains(err.Error(), "connection refused") { + return fault.Wrap(err, + fault.Code(codes.UnkeyAppErrorsInternalServiceUnavailable), + fault.Internal(fmt.Sprintf("Failed to connect to control plane at %s", c.opts.ControlPlaneURL)), + fault.Public("Unable to connect to control plane. Is it running?"), + ) + } + + // Check if it's an auth error + if connectErr := new(connect.Error); errors.As(err, &connectErr) { + if connectErr.Code() == connect.CodeUnauthenticated { + return fault.Wrap(err, + fault.Code(codes.UnkeyAuthErrorsAuthenticationMalformed), + fault.Internal(fmt.Sprintf("Authentication failed with token: %s", c.opts.AuthToken)), + fault.Public("Authentication failed. Check your auth token."), + ) + } + } + + // Generic API error + return fault.Wrap(err, + fault.Code(codes.UnkeyAppErrorsInternalUnexpectedError), + fault.Internal(fmt.Sprintf("CreateVersion API call failed: %v", err)), + fault.Public("Failed to create version. Please try again."), + ) +} diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index 2fa0c93ba1d..19bd399cbd4 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/unkeyed/unkey/go/cmd/cli/progress" + "github.com/unkeyed/unkey/go/cmd/cli/orchestrator" "github.com/unkeyed/unkey/go/pkg/git" "github.com/unkeyed/unkey/go/pkg/otel/logging" ) @@ -42,103 +42,211 @@ func Deploy(ctx context.Context, args []string, env map[string]string) error { return executeDeploy(ctx, opts) } -// executeDeploy performs the actual deployment with Docker building and Git integration +// executeDeploy - clean and focused using the orchestrator pattern func executeDeploy(ctx context.Context, opts *DeployOptions) error { logger := logging.New() - // Get Git info for enhanced deployment tracking + // Create and execute deployment orchestrator + orchestrator := NewDeploymentOrchestrator(ctx, opts, logger) + return orchestrator.Execute() +} + +// DeploymentOrchestrator wraps the generic orchestrator with deployment-specific logic +type DeploymentOrchestrator struct { + *orchestrator.Orchestrator + opts *DeployOptions + logger logging.Logger + controlPlane *ControlPlaneClient +} + +// NewDeploymentOrchestrator creates a new deployment orchestrator +func NewDeploymentOrchestrator(ctx context.Context, opts *DeployOptions, logger logging.Logger) *DeploymentOrchestrator { + orch := orchestrator.New(ctx, "Unkey Deploy Progress") + + do := &DeploymentOrchestrator{ + Orchestrator: orch, + opts: opts, + logger: logger, + controlPlane: NewControlPlaneClient(opts), + } + + // Build the deployment pipeline + do.buildPipeline() + + return do +} + +// buildPipeline constructs the deployment steps +func (do *DeploymentOrchestrator) buildPipeline() { gitInfo := git.GetInfo() // Auto-detect Git values if not provided - if opts.Branch == "main" && gitInfo.IsRepo && gitInfo.Branch != "" { - opts.Branch = gitInfo.Branch + if do.opts.Branch == "main" && gitInfo.IsRepo && gitInfo.Branch != "" { + do.opts.Branch = gitInfo.Branch } - if opts.Commit == "" && gitInfo.CommitSHA != "" { - opts.Commit = gitInfo.CommitSHA + if do.opts.Commit == "" && gitInfo.CommitSHA != "" { + do.opts.Commit = gitInfo.CommitSHA } - tracker := progress.NewTracker("Unkey Deploy Progress") - tracker.AddStep("source", "Source information") - tracker.AddStep("prepare", "Preparing deployment") - tracker.AddStep("build", "Building Docker image") - tracker.AddStep("push", "Publishing to registry") - tracker.AddStep("deploy", "Deploying to Unkey") - tracker.AddStep("activate", "Activating version") - tracker.AddStep("complete", "Deployment summary") - tracker.Start() - defer tracker.Stop() - - tracker.StartStep("source", "Gathering source information") - sourceInfo := buildSourceInfo(gitInfo, opts) - tracker.CompleteStep("source", sourceInfo) - - // Step 1: Prepare - validate environment and get Docker image - tracker.StartStep("prepare", "Validating deployment environment") - - dockerImage := opts.DockerImage - if dockerImage == "" { + do.AddSteps( + // Step 1: Gather source information + orchestrator.NewStep("source", "Source information"). + Execute(func(ctx context.Context) error { + return nil // Just gathering info + }). + OnSuccess(func() string { + return do.buildSourceInfo(gitInfo) + }). + Build(), + + // Step 2: Prepare deployment environment + orchestrator.NewStep("prepare", "Preparing deployment"). + Execute(do.prepareDeployment). + OnSuccess(func() string { + return "Environment validated" + }). + OnError(func(err error) string { + if err == ErrDockerNotFound { + return "docker command not found - please install Docker" + } + return fmt.Sprintf("Preparation failed: %v", err) + }). + Build(), + + // Step 3: Build Docker image (conditional) + orchestrator.ConditionalStep( + "build", + "Building Docker image", + do.buildImage, + func() bool { return do.opts.DockerImage != "" }, // Skip if using pre-built image + func() string { return "Using pre-built Docker image" }, + ), + + // Step 4: Push to registry (conditional) + orchestrator.ConditionalStep( + "push", + "Publishing to registry", + do.pushImage, + func() bool { return do.opts.SkipPush || do.opts.DockerImage != "" }, + func() string { + if do.opts.SkipPush { + return "Push skipped (--skip-push enabled)" + } + return "Using external Docker image" + }, + ), + + // Step 5: Deploy to Unkey + orchestrator.NewStep("deploy", "Deploying to Unkey"). + Execute(do.deployToUnkey). + OnError(func(err error) string { + return fmt.Sprintf("Deployment failed: %v", err) + }). + Build(), + + // Step 6: Activate version (managed by polling) + orchestrator.NewStep("activate", "Activating version"). + Execute(func(ctx context.Context) error { + return nil // Managed by polling in deployToUnkey + }). + Build(), + + // Step 7: Generate completion summary + orchestrator.NewStep("complete", "Deployment summary"). + Execute(do.generateSummary). + OnSuccess(func() string { + return do.buildCompletionInfo(gitInfo) + }). + OnError(func(err error) string { + return "Failed to generate deployment summary" + }). + Build(), + ) +} + +// Step implementations +func (do *DeploymentOrchestrator) prepareDeployment(ctx context.Context) error { + if do.opts.DockerImage == "" { if !isDockerAvailable() { - tracker.FailStep("prepare", "Docker command not found - please install Docker") return ErrDockerNotFound } - imageTag := generateImageTag(opts, gitInfo) - dockerImage = fmt.Sprintf("%s:%s", opts.Registry, imageTag) + gitInfo := git.GetInfo() + imageTag := generateImageTag(do.opts, gitInfo) + dockerImage := fmt.Sprintf("%s:%s", do.opts.Registry, imageTag) + do.SetState("dockerImage", dockerImage) + } else { + do.SetState("dockerImage", do.opts.DockerImage) } - tracker.CompleteStep("prepare", "Environment validated") + return nil +} - // Step 2: Build - only if we need to build an image - if opts.DockerImage == "" { - tracker.StartStep("build", fmt.Sprintf("Building %s", dockerImage)) +func (do *DeploymentOrchestrator) buildImage(ctx context.Context) error { + dockerImage := orchestrator.MustStateAs[string](do.Orchestrator, "dockerImage") - if err := buildImage(ctx, opts, dockerImage); err != nil { - tracker.FailStep("build", fmt.Sprintf("Build failed: %v", err)) - return fmt.Errorf("docker build failed: %w", err) - } + do.UpdateStepMessage("build", fmt.Sprintf("Building %s", dockerImage)) - tracker.CompleteStep("build", "Docker image built successfully") - } else { - tracker.SkipStep("build", "Using pre-built Docker image") + if err := buildImage(ctx, do.opts, dockerImage); err != nil { + return fmt.Errorf("docker build failed: %w", err) } - // Step 3: Push - publish to registry - if opts.SkipPush { - tracker.SkipStep("push", "Push skipped (--skip-push enabled)") - } else if opts.DockerImage == "" { // Only push if we built the image - tracker.StartStep("push", "Publishing to registry") - - if err := pushImage(ctx, dockerImage, opts.Registry); err != nil { - // Push failure shouldn't be fatal in development - tracker.FailStep("push", fmt.Sprintf("push failed: %v", err)) - fmt.Printf("Push failed but continuing with deployment\n") - } else { - tracker.CompleteStep("push", "Image published successfully") - } - } else { - tracker.SkipStep("push", "Using external Docker image") + return nil +} + +func (do *DeploymentOrchestrator) pushImage(ctx context.Context) error { + dockerImage := orchestrator.MustStateAs[string](do.Orchestrator, "dockerImage") + + do.UpdateStepMessage("push", "Publishing to registry") + + if err := pushImage(ctx, dockerImage, do.opts.Registry); err != nil { + // For push failures, we continue deployment but log the error + fmt.Printf("Push failed but continuing with deployment\n") + return nil // Don't fail the step } - // Step 4: Deploy - notify control plane and start deployment - tracker.StartStep("deploy", "Starting deployment") + return nil +} + +func (do *DeploymentOrchestrator) deployToUnkey(ctx context.Context) error { + dockerImage := orchestrator.MustStateAs[string](do.Orchestrator, "dockerImage") + + do.UpdateStepMessage("deploy", "Starting deployment") - if err := notifyControlPlane(ctx, logger, opts, dockerImage, tracker); err != nil { - tracker.FailStep("deploy", fmt.Sprintf("deployment failed: %v", err)) - return fmt.Errorf("deployment failed: %w", err) + // Create version + versionId, err := do.controlPlane.CreateVersion(ctx, dockerImage) + if err != nil { + return fmt.Errorf("failed to create version: %w", err) + } + + do.SetState("versionId", versionId) + do.UpdateStepMessage("deploy", fmt.Sprintf("Version created: %s", versionId)) + + // Poll for completion - this will update the activate step + if err := do.controlPlane.PollVersionStatus(ctx, do.logger, versionId, do.GetTracker()); err != nil { + return fmt.Errorf("deployment polling failed: %w", err) } - // Step 5: Activate - this will be completed by notifyControlPlane - // when the version becomes active return nil } -func buildSourceInfo(gitInfo git.Info, opts *DeployOptions) string { +func (do *DeploymentOrchestrator) generateSummary(ctx context.Context) error { + versionId, ok := orchestrator.StateAs[string](do.Orchestrator, "versionId") + if !ok { + return fmt.Errorf("no version ID available for summary") + } + if versionId == "" { + return fmt.Errorf("empty version ID") + } + return nil +} + +// Helper methods for building display information +func (do *DeploymentOrchestrator) buildSourceInfo(gitInfo git.Info) string { var parts []string - // Branch - parts = append(parts, fmt.Sprintf("Branch: %s", opts.Branch)) + parts = append(parts, fmt.Sprintf("Branch: %s", do.opts.Branch)) - // Commit info if gitInfo.IsRepo && gitInfo.CommitSHA != "" { shortSHA := gitInfo.CommitSHA if len(shortSHA) > 7 { @@ -153,69 +261,35 @@ func buildSourceInfo(gitInfo git.Info, opts *DeployOptions) string { parts = append(parts, "Not a git repository") } - // Context - parts = append(parts, fmt.Sprintf("Context: %s", opts.Context)) + parts = append(parts, fmt.Sprintf("Context: %s", do.opts.Context)) - // Docker image if pre-built - if opts.DockerImage != "" { - parts = append(parts, fmt.Sprintf("Image: %s", opts.DockerImage)) + if do.opts.DockerImage != "" { + parts = append(parts, fmt.Sprintf("Image: %s", do.opts.DockerImage)) } return strings.Join(parts, " | ") } -// PrintDeployHelp prints detailed help for deploy command -func PrintDeployHelp() { - fmt.Println("unkey deploy - Deploy a new version") - fmt.Println("") - fmt.Println("USAGE:") - fmt.Println(" unkey deploy [FLAGS]") - fmt.Println("") - fmt.Println("DESCRIPTION:") - fmt.Println(" Build and deploy a new version of your application.") - fmt.Println(" Builds a Docker image from the specified context and") - fmt.Println(" deploys it to the Unkey platform.") - fmt.Println("") - fmt.Println("REQUIRED FLAGS:") - fmt.Println(" --workspace-id Workspace ID") - fmt.Println(" --project-id Project ID") - fmt.Println("") - fmt.Println("OPTIONAL FLAGS:") - fmt.Println(" --context Docker context path (default: .)") - fmt.Println(" --branch Git branch (default: main)") - fmt.Println(" --docker-image Pre-built docker image") - fmt.Println(" --dockerfile Path to Dockerfile (default: Dockerfile)") - fmt.Println(" --commit Git commit SHA") - fmt.Println(" --registry Docker registry (default: ghcr.io/unkeyed/deploy)") - fmt.Println(" --skip-push Skip pushing to registry") - fmt.Println("") - fmt.Println("ENVIRONMENT VARIABLES:") - fmt.Println(" UNKEY_WORKSPACE_ID Default workspace ID") - fmt.Println(" UNKEY_PROJECT_ID Default project ID") - fmt.Println(" UNKEY_DOCKER_REGISTRY Default Docker registry") - fmt.Println("") - fmt.Println("EXAMPLES:") - fmt.Println(" # Basic deployment") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --context=./demo_api") - fmt.Println("") - fmt.Println(" # Deploy with your own registry") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --registry=docker.io/mycompany/myapp") - fmt.Println("") - fmt.Println(" # Local development (skip push)") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --skip-push") - fmt.Println("") - fmt.Println(" # Deploy pre-built image") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --docker-image=ghcr.io/user/app:v1.0.0") +func (do *DeploymentOrchestrator) buildCompletionInfo(gitInfo git.Info) string { + versionId := orchestrator.MustStateAs[string](do.Orchestrator, "versionId") + + if versionId == "" || do.opts.WorkspaceID == "" || do.opts.Branch == "" { + return "" + } + + var parts []string + + parts = append(parts, fmt.Sprintf("Version: %s", versionId)) + parts = append(parts, "Status: Ready") + parts = append(parts, "Env: Production") + + identifier := versionId + if gitInfo.ShortSHA != "" { + identifier = gitInfo.ShortSHA + } + + domain := fmt.Sprintf("https://%s-%s-%s.unkey.app", do.opts.Branch, identifier, do.opts.WorkspaceID) + parts = append(parts, fmt.Sprintf("URL: %s", domain)) + + return strings.Join(parts, " | ") } diff --git a/go/cmd/cli/commands/deploy/parse-flags.go b/go/cmd/cli/commands/deploy/flags.go similarity index 100% rename from go/cmd/cli/commands/deploy/parse-flags.go rename to go/cmd/cli/commands/deploy/flags.go diff --git a/go/cmd/cli/commands/deploy/help.go b/go/cmd/cli/commands/deploy/help.go new file mode 100644 index 00000000000..1cf1ec37e7f --- /dev/null +++ b/go/cmd/cli/commands/deploy/help.go @@ -0,0 +1,58 @@ +package deploy + +import "fmt" + +func PrintDeployHelp() { + fmt.Println("unkey deploy - Deploy a new version") + fmt.Println("") + fmt.Println("USAGE:") + fmt.Println(" unkey deploy [FLAGS]") + fmt.Println("") + fmt.Println("DESCRIPTION:") + fmt.Println(" Build and deploy a new version of your application.") + fmt.Println(" Builds a Docker image from the specified context and") + fmt.Println(" deploys it to the Unkey platform.") + fmt.Println("") + fmt.Println("REQUIRED FLAGS:") + fmt.Println(" --workspace-id Workspace ID") + fmt.Println(" --project-id Project ID") + fmt.Println("") + fmt.Println("OPTIONAL FLAGS:") + fmt.Println(" --context Docker context path (default: .)") + fmt.Println(" --branch Git branch (default: main)") + fmt.Println(" --docker-image Pre-built docker image") + fmt.Println(" --dockerfile Path to Dockerfile (default: Dockerfile)") + fmt.Println(" --commit Git commit SHA") + fmt.Println(" --registry Docker registry (default: ghcr.io/unkeyed/deploy)") + fmt.Println(" --skip-push Skip pushing to registry") + fmt.Println("") + fmt.Println("ENVIRONMENT VARIABLES:") + fmt.Println(" UNKEY_WORKSPACE_ID Default workspace ID") + fmt.Println(" UNKEY_PROJECT_ID Default project ID") + fmt.Println(" UNKEY_DOCKER_REGISTRY Default Docker registry") + fmt.Println("") + fmt.Println("EXAMPLES:") + fmt.Println(" # Basic deployment") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --context=./demo_api") + fmt.Println("") + fmt.Println(" # Deploy with your own registry") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --registry=docker.io/mycompany/myapp") + fmt.Println("") + fmt.Println(" # Local development (skip push)") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --skip-push") + fmt.Println("") + fmt.Println(" # Deploy pre-built image") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --docker-image=ghcr.io/user/app:v1.0.0") +} diff --git a/go/cmd/cli/commands/deploy/notify-ctrl.go b/go/cmd/cli/commands/deploy/notify-ctrl.go deleted file mode 100644 index 4c771654f50..00000000000 --- a/go/cmd/cli/commands/deploy/notify-ctrl.go +++ /dev/null @@ -1,149 +0,0 @@ -package deploy - -import ( - "context" - "errors" - "fmt" - "net/http" - "strings" - - "connectrpc.com/connect" - "github.com/unkeyed/unkey/go/cmd/cli/progress" - ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" - "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect" - "github.com/unkeyed/unkey/go/pkg/codes" - "github.com/unkeyed/unkey/go/pkg/fault" - "github.com/unkeyed/unkey/go/pkg/git" - "github.com/unkeyed/unkey/go/pkg/otel/logging" -) - -func notifyControlPlane(ctx context.Context, logger logging.Logger, opts *DeployOptions, dockerImage string, tracker *progress.Tracker) error { - // Create control plane client - httpClient := &http.Client{} - client := ctrlv1connect.NewVersionServiceClient(httpClient, opts.ControlPlaneURL) - - // Create version request - createReq := connect.NewRequest(&ctrlv1.CreateVersionRequest{ - WorkspaceId: opts.WorkspaceID, - ProjectId: opts.ProjectID, - Branch: opts.Branch, - SourceType: ctrlv1.SourceType_SOURCE_TYPE_CLI_UPLOAD, - GitCommitSha: opts.Commit, - EnvironmentId: "env_prod", // TODO: Make this configurable - DockerImageTag: dockerImage, - }) - - // Add auth header - createReq.Header().Set("Authorization", "Bearer "+opts.AuthToken) - - // Call the API - createResp, err := client.CreateVersion(ctx, createReq) - if err != nil { - return handleCreateVersionError(err, opts) - } - - versionId := createResp.Msg.GetVersionId() - if versionId == "" { - return fault.Wrap( - fmt.Errorf("empty version ID returned from control plane"), - fault.Code(codes.UnkeyAppErrorsInternalUnexpectedError), - fault.Internal("CreateVersion API returned empty version ID"), - fault.Public("Failed to create version. Please try again."), - ) - } - - tracker.UpdateStep("deploy", fmt.Sprintf("Version created: %s", versionId)) - - // Poll for version status updates with integrated progress tracking - if err := pollVersionStatus(ctx, logger, client, opts.AuthToken, versionId, tracker); err != nil { - return err - } - - tracker.StartStep("complete", "Generating deployment summary") - gitInfo := git.GetInfo() - completionInfo := buildCompletionInfo(versionId, opts.WorkspaceID, opts.Branch, gitInfo) - - if completionInfo == "" { - tracker.FailStep("complete", "failed to generate deployment summary") - return fault.Wrap( - fmt.Errorf("empty completion info generated"), - fault.Code(codes.UnkeyAppErrorsInternalUnexpectedError), - fault.Internal("buildCompletionInfo returned empty string"), - fault.Public("Failed to generate deployment summary"), - ) - } - - tracker.CompleteStep("complete", completionInfo) - - // Remove this sleep hack - fix it in the tracker instead - return nil -} - -func buildCompletionInfo(versionId, workspace, branch string, gitInfo git.Info) string { - var parts []string - - // Version ID - parts = append(parts, fmt.Sprintf("Version: %s", versionId)) - - // Status - parts = append(parts, "Status: Ready") - - // Environment - parts = append(parts, "Env: Production") - - // Main domain - identifier := versionId - if gitInfo.ShortSHA != "" { - identifier = gitInfo.ShortSHA - } - cleanIdentifier := strings.ReplaceAll(identifier, "_", "-") - domain := fmt.Sprintf("https://%s-%s-%s.unkey.app", branch, cleanIdentifier, workspace) - parts = append(parts, fmt.Sprintf("URL: %s", domain)) - - return strings.Join(parts, " | ") -} - -func handleCreateVersionError(err error, opts *DeployOptions) error { - // Check if it's a connection error - if strings.Contains(err.Error(), "connection refused") { - return fault.Wrap(err, - fault.Code(codes.UnkeyAppErrorsInternalServiceUnavailable), - fault.Internal(fmt.Sprintf("Failed to connect to control plane at %s", opts.ControlPlaneURL)), - fault.Public("Unable to connect to control plane. Is it running?"), - ) - } - - // Check if it's an auth error - if connectErr := new(connect.Error); errors.As(err, &connectErr) { - if connectErr.Code() == connect.CodeUnauthenticated { - return fault.Wrap(err, - fault.Code(codes.UnkeyAuthErrorsAuthenticationMalformed), - fault.Internal(fmt.Sprintf("Authentication failed with token: %s", opts.AuthToken)), - fault.Public("Authentication failed. Check your auth token."), - ) - } - } - - // Generic API error - return fault.Wrap(err, - fault.Code(codes.UnkeyAppErrorsInternalUnexpectedError), - fault.Internal(fmt.Sprintf("CreateVersion API call failed: %v", err)), - fault.Public("Failed to create version. Please try again."), - ) -} - -// getFailureMessage extracts failure message from version -func getFailureMessage(version *ctrlv1.Version) string { - if version.GetErrorMessage() != "" { - return version.GetErrorMessage() - } - - // Check for error in steps - for _, step := range version.GetSteps() { - if step.GetErrorMessage() != "" { - return step.GetErrorMessage() - } - } - - return "Unknown deployment error" -} diff --git a/go/cmd/cli/commands/deploy/poll-ctrl.go b/go/cmd/cli/commands/deploy/poll-ctrl.go deleted file mode 100644 index f6534ea9910..00000000000 --- a/go/cmd/cli/commands/deploy/poll-ctrl.go +++ /dev/null @@ -1,144 +0,0 @@ -package deploy - -import ( - "context" - "fmt" - "time" - - "connectrpc.com/connect" - "github.com/unkeyed/unkey/go/cmd/cli/progress" - ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" - "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect" - "github.com/unkeyed/unkey/go/pkg/otel/logging" -) - -// pollVersionStatus polls the control plane API and displays deployment steps as they occur -func pollVersionStatus(ctx context.Context, logger logging.Logger, client ctrlv1connect.VersionServiceClient, authToken, versionId string, tracker *progress.Tracker) error { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - timeout := time.NewTimer(300 * time.Second) - defer timeout.Stop() - - // Track processed steps by creation time to avoid duplicates - processedSteps := make(map[int64]bool) - lastStatus := ctrlv1.VersionStatus_VERSION_STATUS_UNSPECIFIED - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-timeout.C: - tracker.FailStep("activate", "deployment timeout after 5 minutes") - return fmt.Errorf("deployment timeout") - case <-ticker.C: - getReq := connect.NewRequest(&ctrlv1.GetVersionRequest{ - VersionId: versionId, - }) - getReq.Header().Set("Authorization", "Bearer "+authToken) - getResp, err := client.GetVersion(ctx, getReq) - if err != nil { - logger.Debug("Failed to get version status", "error", err, "version_id", versionId) - continue - } - - version := getResp.Msg.GetVersion() - currentStatus := version.GetStatus() - - // Handle version status changes - if currentStatus != lastStatus { - if err := handleStatusTransition(tracker, lastStatus, currentStatus, version); err != nil { - return err - } - lastStatus = currentStatus - } - - // Process new step updates - if err := processNewSteps(tracker, version.GetSteps(), processedSteps, currentStatus); err != nil { - return err - } - - // Check for completion - if currentStatus == ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE { - return nil - } - } - } -} - -func handleStatusTransition(tracker *progress.Tracker, lastStatus, currentStatus ctrlv1.VersionStatus, version *ctrlv1.Version) error { - switch currentStatus { - case ctrlv1.VersionStatus_VERSION_STATUS_PENDING: - tracker.UpdateStep("deploy", "Version queued and ready to start") - - case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: - tracker.UpdateStep("deploy", "Building deployment image") - - case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: - tracker.CompleteStep("deploy", "Deployment initiated") - tracker.StartStep("activate", "Deploying to unkey") - - case ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE: - if lastStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { - tracker.CompleteStep("activate", "Version is now active") - } else { - tracker.CompleteStep("deploy", "Deployment completed") - tracker.CompleteStep("activate", "Version is now active") - } - - case ctrlv1.VersionStatus_VERSION_STATUS_FAILED: - errorMsg := getFailureMessage(version) - if lastStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { - tracker.FailStep("activate", errorMsg) - } else { - tracker.FailStep("deploy", errorMsg) - } - return fmt.Errorf("deployment failed: %s", errorMsg) - } - return nil -} - -func processNewSteps(tracker *progress.Tracker, steps []*ctrlv1.VersionStep, processedSteps map[int64]bool, currentStatus ctrlv1.VersionStatus) error { - for _, step := range steps { - // Creation timestamp as unique identifier - stepTimestamp := step.GetCreatedAt() - - if processedSteps[stepTimestamp] { - continue // Already processed this step - } - - // Handle step errors first - if step.GetErrorMessage() != "" { - errorMsg := step.GetErrorMessage() - if currentStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { - tracker.FailStep("activate", errorMsg) - } else { - tracker.FailStep("deploy", errorMsg) - } - return fmt.Errorf("deployment failed: %s", errorMsg) - } - - // Show step updates to user - if step.GetMessage() != "" { - message := step.GetMessage() - - // Add status context if helpful - if step.GetStatus() != "" { - message = fmt.Sprintf("[%s] %s", step.GetStatus(), message) - } - - switch currentStatus { - case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: - tracker.UpdateStep("deploy", message) - case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: - tracker.UpdateStep("activate", message) - default: - // For other statuses, show on deploy step - tracker.UpdateStep("deploy", message) - } - } - - // Mark this step as processed - processedSteps[stepTimestamp] = true - } - return nil -} diff --git a/go/cmd/cli/orchestrator/orchestrator.go b/go/cmd/cli/orchestrator/orchestrator.go new file mode 100644 index 00000000000..598d3850820 --- /dev/null +++ b/go/cmd/cli/orchestrator/orchestrator.go @@ -0,0 +1,130 @@ +// Package orchestrator provides a reusable pattern for executing multi-step operations +// with progress tracking and error handling. +package orchestrator + +import ( + "context" + "fmt" + + "github.com/unkeyed/unkey/go/cmd/cli/progress" +) + +// Orchestrator manages the execution of multiple steps with progress tracking +type Orchestrator struct { + title string + steps []*Step + tracker *progress.Tracker + ctx context.Context + state map[string]any // Shared state between steps +} + +// New creates a new orchestrator with the given title +func New(ctx context.Context, title string) *Orchestrator { + return &Orchestrator{ + title: title, + steps: make([]*Step, 0), + ctx: ctx, + state: make(map[string]any), + } +} + +// AddStep adds a step to the orchestrator +func (o *Orchestrator) AddStep(step *Step) *Orchestrator { + o.steps = append(o.steps, step) + return o +} + +// AddSteps adds multiple steps to the orchestrator +func (o *Orchestrator) AddSteps(steps ...*Step) *Orchestrator { + for _, step := range steps { + o.AddStep(step) + } + return o +} + +// Execute runs all the steps in sequence with progress tracking +func (o *Orchestrator) Execute() error { + // Initialize tracker + o.tracker = progress.NewTracker(o.title) + + // Add all steps to tracker + for _, step := range o.steps { + o.tracker.AddStep(step.ID, step.Name) + } + + o.tracker.Start() + defer o.tracker.Stop() + + // Execute steps + for _, step := range o.steps { + if err := o.executeStep(step); err != nil { + return err + } + } + + return nil +} + +// executeStep executes a single step with proper error handling +func (o *Orchestrator) executeStep(step *Step) error { + // Safety check for nil Execute function + if step.Execute == nil { + return fmt.Errorf("step '%s' has no execution function", step.Name) + } + + // Check if step should be skipped + if step.SkipIf != nil && step.SkipIf() { + reason := fmt.Sprintf("Skipped: %s", step.Name) + if step.SkipReason != nil { + reason = step.SkipReason() + } + o.tracker.SkipStep(step.ID, reason) + return nil + } + + // Start the step + o.tracker.StartStep(step.ID, fmt.Sprintf("Executing %s", step.Name)) + + // Execute the step + err := step.Execute(o.ctx) + if err != nil { + // Handle error + errorMsg := err.Error() + if step.OnError != nil { + errorMsg = step.OnError(err) + } + + o.tracker.FailStep(step.ID, errorMsg) + + // If step is required, stop execution + if step.Required { + return fmt.Errorf("required step '%s' failed: %w", step.Name, err) + } + + // For non-required steps, continue execution + return nil + } + + // Handle success + successMsg := fmt.Sprintf("%s completed", step.Name) + if step.OnSuccess != nil { + successMsg = step.OnSuccess() + } + + o.tracker.CompleteStep(step.ID, successMsg) + return nil +} + +// UpdateStepMessage updates the message for a currently running step +func (o *Orchestrator) UpdateStepMessage(stepID, message string) { + if o.tracker != nil { + o.tracker.UpdateStep(stepID, message) + } +} + +// GetTracker returns the underlying progress tracker for advanced usage +// This should be used sparingly and only when the orchestrator pattern +// doesn't cover your specific use case +func (o *Orchestrator) GetTracker() *progress.Tracker { + return o.tracker +} diff --git a/go/cmd/cli/orchestrator/state.go b/go/cmd/cli/orchestrator/state.go new file mode 100644 index 00000000000..51c50baaf10 --- /dev/null +++ b/go/cmd/cli/orchestrator/state.go @@ -0,0 +1,167 @@ +package orchestrator + +import ( + "fmt" + "reflect" +) + +// SetState sets a value in the shared state +func (o *Orchestrator) SetState(key string, value any) { + o.state[key] = value +} + +// State gets a value from the shared state +func (o *Orchestrator) State(key string) (any, bool) { + value, exists := o.state[key] + return value, exists +} + +// MustState gets a value and panics if not found +func (o *Orchestrator) MustState(key string) any { + value, exists := o.state[key] + if !exists { + panic(fmt.Sprintf("required state key '%s' not found", key)) + } + return value +} + +// StateAs gets a value with automatic type assertion +func StateAs[T any](o *Orchestrator, key string) (T, bool) { + var zero T + value, exists := o.state[key] + if !exists { + return zero, false + } + + // Direct type assertion + if typed, ok := value.(T); ok { + return typed, true + } + + // Try reflection-based conversion for compatible types + return convertValue[T](value) +} + +// MustStateAs gets a value with type assertion and panics if not found or wrong type +func MustStateAs[T any](o *Orchestrator, key string) T { + value, ok := StateAs[T](o, key) + if !ok { + var zero T + panic(fmt.Sprintf("required state key '%s' not found or cannot convert to %T", key, zero)) + } + return value +} + +// convertValue attempts to convert a value to the target type using reflection +func convertValue[T any](value any) (T, bool) { + var zero T + targetType := reflect.TypeOf(zero) + sourceValue := reflect.ValueOf(value) + + // If source is nil, return zero value + if !sourceValue.IsValid() { + return zero, false + } + + sourceType := sourceValue.Type() + + // Same type - should have been caught by direct assertion, but just in case + if sourceType == targetType { + return value.(T), true + } + + // Check if source is convertible to target + if sourceType.ConvertibleTo(targetType) { + converted := sourceValue.Convert(targetType) + return converted.Interface().(T), true + } + + // Handle pointer/non-pointer conversions + if targetType.Kind() == reflect.Ptr && sourceType == targetType.Elem() { + // Converting value to pointer + ptr := reflect.New(sourceType) + ptr.Elem().Set(sourceValue) + return ptr.Interface().(T), true + } + + if sourceType.Kind() == reflect.Ptr && sourceType.Elem() == targetType { + // Converting pointer to value + if sourceValue.IsNil() { + return zero, false + } + return sourceValue.Elem().Interface().(T), true + } + + // String conversions + if targetType.Kind() == reflect.String { + return reflect.ValueOf(fmt.Sprintf("%v", value)).Interface().(T), true + } + + return zero, false +} + +// HasState checks if a key exists in state +func (o *Orchestrator) HasState(key string) bool { + _, exists := o.state[key] + return exists +} + +// RemoveState removes a specific key from state +func (o *Orchestrator) RemoveState(key string) { + delete(o.state, key) +} + +// ClearState clears all state +func (o *Orchestrator) ClearState() { + o.state = make(map[string]any) +} + +// StateKeys returns all keys in the state +func (o *Orchestrator) StateKeys() []string { + keys := make([]string, 0, len(o.state)) + for key := range o.state { + keys = append(keys, key) + } + return keys +} + +// StateCount returns the number of items in state +func (o *Orchestrator) StateCount() int { + return len(o.state) +} + +// StateSnapshot returns a copy of the current state +func (o *Orchestrator) StateSnapshot() map[string]any { + snapshot := make(map[string]any, len(o.state)) + for k, v := range o.state { + snapshot[k] = v + } + return snapshot +} + +// SetStateIf sets a value only if the key doesn't exist +func (o *Orchestrator) SetStateIf(key string, value any) bool { + if !o.HasState(key) { + o.SetState(key, value) + return true + } + return false +} + +// UpdateState updates a value using a function if the key exists +func (o *Orchestrator) UpdateState(key string, fn func(any) any) bool { + if value, exists := o.state[key]; exists { + o.state[key] = fn(value) + return true + } + return false +} + +// UpdateStateAs updates a value with type safety +func UpdateStateAs[T any](o *Orchestrator, key string, fn func(T) T) bool { + if value, ok := StateAs[T](o, key); ok { + o.SetState(key, fn(value)) + return true + } + return false +} diff --git a/go/cmd/cli/orchestrator/step.go b/go/cmd/cli/orchestrator/step.go new file mode 100644 index 00000000000..29777c0b752 --- /dev/null +++ b/go/cmd/cli/orchestrator/step.go @@ -0,0 +1,90 @@ +package orchestrator + +import "context" + +// StepFunc represents a function that executes a single step +type StepFunc func(ctx context.Context) error + +// Step represents a single operation in a multi-step process +type Step struct { + ID string + Name string + Execute StepFunc + OnSuccess func() string // Optional: custom success message + OnError func(error) string // Optional: custom error message + SkipIf func() bool // Optional: condition to skip this step + SkipReason func() string // Optional: reason for skipping + Required bool // If true, failure stops the entire process +} + +// StepBuilder provides a fluent interface for building steps +type StepBuilder struct { + step *Step +} + +// NewStep creates a new step builder +func NewStep(id, name string) *StepBuilder { + return &StepBuilder{ + step: &Step{ + ID: id, + Name: name, + Required: true, // Default to required + }, + } +} + +// Execute sets the execution function +func (sb *StepBuilder) Execute(fn StepFunc) *StepBuilder { + sb.step.Execute = fn + return sb +} + +// OnSuccess sets the success message function +func (sb *StepBuilder) OnSuccess(fn func() string) *StepBuilder { + sb.step.OnSuccess = fn + return sb +} + +// OnError sets the error message function +func (sb *StepBuilder) OnError(fn func(error) string) *StepBuilder { + sb.step.OnError = fn + return sb +} + +// SkipIf sets the skip condition +func (sb *StepBuilder) SkipIf(fn func() bool) *StepBuilder { + sb.step.SkipIf = fn + return sb +} + +// SkipReason sets the skip reason function +func (sb *StepBuilder) SkipReason(fn func() string) *StepBuilder { + sb.step.SkipReason = fn + return sb +} + +// Required sets whether the step is required (default: true) +func (sb *StepBuilder) Required(required bool) *StepBuilder { + sb.step.Required = required + return sb +} + +// Optional marks the step as optional (failure won't stop execution) +func (sb *StepBuilder) Optional() *StepBuilder { + sb.step.Required = false + return sb +} + +// Build returns the constructed step +func (sb *StepBuilder) Build() *Step { + return sb.step +} + +// ConditionalStep creates a step that can be skipped based on a condition +func ConditionalStep(id, name string, fn StepFunc, skipIf func() bool, skipReason func() string) *Step { + return NewStep(id, name). + Execute(fn). + SkipIf(skipIf). + SkipReason(skipReason). + Build() +} From 454828e277cf499cf3510b270e3c82310c88b983 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 15 Jul 2025 14:18:10 +0300 Subject: [PATCH 09/23] refactor: rename build to run --- go/cmd/cli/commands/deploy/deploy.go | 10 +++++----- go/cmd/cli/orchestrator/step.go | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index 19bd399cbd4..8a544f3e043 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -97,7 +97,7 @@ func (do *DeploymentOrchestrator) buildPipeline() { OnSuccess(func() string { return do.buildSourceInfo(gitInfo) }). - Build(), + Run(), // Step 2: Prepare deployment environment orchestrator.NewStep("prepare", "Preparing deployment"). @@ -111,7 +111,7 @@ func (do *DeploymentOrchestrator) buildPipeline() { } return fmt.Sprintf("Preparation failed: %v", err) }). - Build(), + Run(), // Step 3: Build Docker image (conditional) orchestrator.ConditionalStep( @@ -142,14 +142,14 @@ func (do *DeploymentOrchestrator) buildPipeline() { OnError(func(err error) string { return fmt.Sprintf("Deployment failed: %v", err) }). - Build(), + Run(), // Step 6: Activate version (managed by polling) orchestrator.NewStep("activate", "Activating version"). Execute(func(ctx context.Context) error { return nil // Managed by polling in deployToUnkey }). - Build(), + Run(), // Step 7: Generate completion summary orchestrator.NewStep("complete", "Deployment summary"). @@ -160,7 +160,7 @@ func (do *DeploymentOrchestrator) buildPipeline() { OnError(func(err error) string { return "Failed to generate deployment summary" }). - Build(), + Run(), ) } diff --git a/go/cmd/cli/orchestrator/step.go b/go/cmd/cli/orchestrator/step.go index 29777c0b752..6deb4dd2db9 100644 --- a/go/cmd/cli/orchestrator/step.go +++ b/go/cmd/cli/orchestrator/step.go @@ -75,8 +75,8 @@ func (sb *StepBuilder) Optional() *StepBuilder { return sb } -// Build returns the constructed step -func (sb *StepBuilder) Build() *Step { +// Run returns the constructed step +func (sb *StepBuilder) Run() *Step { return sb.step } @@ -86,5 +86,5 @@ func ConditionalStep(id, name string, fn StepFunc, skipIf func() bool, skipReaso Execute(fn). SkipIf(skipIf). SkipReason(skipReason). - Build() + Run() } From f058067ebd1e609ded797a16f75990e8a10fa90c Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 15 Jul 2025 14:30:08 +0300 Subject: [PATCH 10/23] refactor: remove UI logic from api --- go/cmd/cli/commands/deploy/control_plane.go | 123 ++++++++------------ go/cmd/cli/commands/deploy/deploy.go | 73 +++++++++++- 2 files changed, 121 insertions(+), 75 deletions(-) diff --git a/go/cmd/cli/commands/deploy/control_plane.go b/go/cmd/cli/commands/deploy/control_plane.go index adc88f0ede5..1418c6979f7 100644 --- a/go/cmd/cli/commands/deploy/control_plane.go +++ b/go/cmd/cli/commands/deploy/control_plane.go @@ -9,7 +9,6 @@ import ( "time" "connectrpc.com/connect" - "github.com/unkeyed/unkey/go/cmd/cli/progress" ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1/ctrlv1connect" "github.com/unkeyed/unkey/go/pkg/codes" @@ -17,6 +16,22 @@ import ( "github.com/unkeyed/unkey/go/pkg/otel/logging" ) +// VersionStatusEvent represents a status change event +type VersionStatusEvent struct { + VersionID string + PreviousStatus ctrlv1.VersionStatus + CurrentStatus ctrlv1.VersionStatus + Version *ctrlv1.Version +} + +// VersionStepEvent represents a step update event +type VersionStepEvent struct { + VersionID string + Step *ctrlv1.VersionStep + Status ctrlv1.VersionStatus +} + +// ControlPlaneClient handles API operations with the control plane type ControlPlaneClient struct { client ctrlv1connect.VersionServiceClient opts *DeployOptions @@ -75,8 +90,14 @@ func (c *ControlPlaneClient) GetVersion(ctx context.Context, versionId string) ( return getResp.Msg.GetVersion(), nil } -// PollVersionStatus polls the control plane API and displays deployment steps as they occur -func (c *ControlPlaneClient) PollVersionStatus(ctx context.Context, logger logging.Logger, versionId string, tracker *progress.Tracker) error { +// PollVersionStatus polls for version changes and calls event handlers +func (c *ControlPlaneClient) PollVersionStatus( + ctx context.Context, + logger logging.Logger, + versionId string, + onStatusChange func(VersionStatusEvent) error, + onStepUpdate func(VersionStepEvent) error, +) error { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() timeout := time.NewTimer(300 * time.Second) @@ -91,10 +112,7 @@ func (c *ControlPlaneClient) PollVersionStatus(ctx context.Context, logger loggi case <-ctx.Done(): return ctx.Err() case <-timeout.C: - if tracker != nil { - tracker.FailStep("activate", "deployment timeout after 5 minutes") - } - return fmt.Errorf("deployment timeout") + return fmt.Errorf("deployment timeout after 5 minutes") case <-ticker.C: version, err := c.GetVersion(ctx, versionId) if err != nil { @@ -106,14 +124,21 @@ func (c *ControlPlaneClient) PollVersionStatus(ctx context.Context, logger loggi // Handle version status changes if currentStatus != lastStatus { - if err := c.handleStatusTransition(tracker, lastStatus, currentStatus, version); err != nil { + event := VersionStatusEvent{ + VersionID: versionId, + PreviousStatus: lastStatus, + CurrentStatus: currentStatus, + Version: version, + } + + if err := onStatusChange(event); err != nil { return err } lastStatus = currentStatus } // Process new step updates - if err := c.processNewSteps(tracker, version.GetSteps(), processedSteps, currentStatus); err != nil { + if err := c.processNewSteps(versionId, version.GetSteps(), processedSteps, currentStatus, onStepUpdate); err != nil { return err } @@ -125,49 +150,14 @@ func (c *ControlPlaneClient) PollVersionStatus(ctx context.Context, logger loggi } } -// handleStatusTransition handles version status changes and updates the tracker -func (c *ControlPlaneClient) handleStatusTransition(tracker *progress.Tracker, lastStatus, currentStatus ctrlv1.VersionStatus, version *ctrlv1.Version) error { - if tracker == nil { - return nil // Gracefully handle nil tracker - } - - switch currentStatus { - case ctrlv1.VersionStatus_VERSION_STATUS_PENDING: - tracker.UpdateStep("deploy", "Version queued and ready to start") - - case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: - tracker.UpdateStep("deploy", "Building deployment image") - - case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: - tracker.CompleteStep("deploy", "Deployment initiated") - tracker.StartStep("activate", "Deploying to unkey") - - case ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE: - if lastStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { - tracker.CompleteStep("activate", "Version is now active") - } else { - tracker.CompleteStep("deploy", "Deployment completed") - tracker.CompleteStep("activate", "Version is now active") - } - - case ctrlv1.VersionStatus_VERSION_STATUS_FAILED: - errorMsg := c.getFailureMessage(version) - if lastStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { - tracker.FailStep("activate", errorMsg) - } else { - tracker.FailStep("deploy", errorMsg) - } - return fmt.Errorf("deployment failed: %s", errorMsg) - } - return nil -} - -// processNewSteps processes new deployment steps and updates the tracker -func (c *ControlPlaneClient) processNewSteps(tracker *progress.Tracker, steps []*ctrlv1.VersionStep, processedSteps map[int64]bool, currentStatus ctrlv1.VersionStatus) error { - if tracker == nil { - return nil // Gracefully handle nil tracker - } - +// processNewSteps processes new deployment steps and calls the event handler +func (c *ControlPlaneClient) processNewSteps( + versionId string, + steps []*ctrlv1.VersionStep, + processedSteps map[int64]bool, + currentStatus ctrlv1.VersionStatus, + onStepUpdate func(VersionStepEvent) error, +) error { for _, step := range steps { // Creation timestamp as unique identifier stepTimestamp := step.GetCreatedAt() @@ -178,32 +168,19 @@ func (c *ControlPlaneClient) processNewSteps(tracker *progress.Tracker, steps [] // Handle step errors first if step.GetErrorMessage() != "" { - errorMsg := step.GetErrorMessage() - if currentStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { - tracker.FailStep("activate", errorMsg) - } else { - tracker.FailStep("deploy", errorMsg) - } - return fmt.Errorf("deployment failed: %s", errorMsg) + return fmt.Errorf("deployment failed: %s", step.GetErrorMessage()) } - // Show step updates to user + // Call step update handler if step.GetMessage() != "" { - message := step.GetMessage() - - // Add status context if helpful - if step.GetStatus() != "" { - message = fmt.Sprintf("[%s] %s", step.GetStatus(), message) + event := VersionStepEvent{ + VersionID: versionId, + Step: step, + Status: currentStatus, } - switch currentStatus { - case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: - tracker.UpdateStep("deploy", message) - case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: - tracker.UpdateStep("activate", message) - default: - // For other statuses, show on deploy step - tracker.UpdateStep("deploy", message) + if err := onStepUpdate(event); err != nil { + return err } } diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index 8a544f3e043..22ac899d271 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -5,12 +5,18 @@ import ( "errors" "fmt" "strings" + "time" "github.com/unkeyed/unkey/go/cmd/cli/orchestrator" + ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" "github.com/unkeyed/unkey/go/pkg/git" "github.com/unkeyed/unkey/go/pkg/otel/logging" ) +// INFO: I'll get rid of this in the following iterations now its required for debugging. +// Don't judge my uppercase constant +const DEBUG_DELAY = 1500 + var ( ErrDockerNotFound = errors.New("docker command not found - please install Docker") ErrDockerBuildFailed = errors.New("docker build failed") @@ -222,14 +228,77 @@ func (do *DeploymentOrchestrator) deployToUnkey(ctx context.Context) error { do.SetState("versionId", versionId) do.UpdateStepMessage("deploy", fmt.Sprintf("Version created: %s", versionId)) - // Poll for completion - this will update the activate step - if err := do.controlPlane.PollVersionStatus(ctx, do.logger, versionId, do.GetTracker()); err != nil { + // Poll with event handlers + onStatusChange := func(event VersionStatusEvent) error { + return do.handleStatusChange(event) + } + + onStepUpdate := func(event VersionStepEvent) error { + return do.handleStepUpdate(event) + } + + if err := do.controlPlane.PollVersionStatus(ctx, do.logger, versionId, onStatusChange, onStepUpdate); err != nil { return fmt.Errorf("deployment polling failed: %w", err) } return nil } +func (do *DeploymentOrchestrator) handleStatusChange(event VersionStatusEvent) error { + tracker := do.GetTracker() + + switch event.CurrentStatus { + case ctrlv1.VersionStatus_VERSION_STATUS_PENDING: + tracker.UpdateStep("deploy", "Version queued and ready to start") + case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: + tracker.UpdateStep("deploy", "Building deployment image") + case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: + tracker.CompleteStep("deploy", "Deployment initiated") + tracker.StartStep("activate", "Deploying to unkey") + case ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE: + if event.PreviousStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { + tracker.CompleteStep("activate", "Version is now active") + } else { + tracker.CompleteStep("deploy", "Deployment completed") + tracker.CompleteStep("activate", "Version is now active") + } + case ctrlv1.VersionStatus_VERSION_STATUS_FAILED: + errorMsg := do.controlPlane.getFailureMessage(event.Version) + if event.PreviousStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { + tracker.FailStep("activate", errorMsg) + } else { + tracker.FailStep("deploy", errorMsg) + } + return fmt.Errorf("deployment failed: %s", errorMsg) + } + + return nil +} + +func (do *DeploymentOrchestrator) handleStepUpdate(event VersionStepEvent) error { + tracker := do.GetTracker() + message := event.Step.GetMessage() + + // Add status context if helpful + if event.Step.GetStatus() != "" { + message = fmt.Sprintf("[%s] %s", event.Step.GetStatus(), message) + } + + switch event.Status { + case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: + tracker.UpdateStep("deploy", message) + case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: + tracker.UpdateStep("activate", message) + default: + tracker.UpdateStep("deploy", message) + } + + if DEBUG_DELAY > 0 { + time.Sleep(DEBUG_DELAY * time.Millisecond) + } + return nil +} + func (do *DeploymentOrchestrator) generateSummary(ctx context.Context) error { versionId, ok := orchestrator.StateAs[string](do.Orchestrator, "versionId") if !ok { From 7e1a3b484175fa9f2a6dac4901a9dc1a1d4b7315 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 15 Jul 2025 14:45:11 +0300 Subject: [PATCH 11/23] chore: remove redundant commands --- go/cmd/cli/app/cli.go | 2 -- go/cmd/cli/commands/deploy/deploy.go | 5 +++++ go/cmd/cli/commands/deploy/help.go | 1 - go/cmd/cli/commands/versions.go | 3 --- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/go/cmd/cli/app/cli.go b/go/cmd/cli/app/cli.go index f4c461f249b..df83e0918e1 100644 --- a/go/cmd/cli/app/cli.go +++ b/go/cmd/cli/app/cli.go @@ -105,8 +105,6 @@ func (c *CLI) PrintUsage() { fmt.Println("ENVIRONMENT VARIABLES:") fmt.Println(" UNKEY_WORKSPACE_ID Workspace ID (can be overridden by --workspace-id)") fmt.Println(" UNKEY_PROJECT_ID Project ID (can be overridden by --project-id)") - fmt.Println(" UNKEY_API_KEY API key for authentication") - fmt.Println(" UNKEY_BASE_URL Base URL for API calls") fmt.Println("") fmt.Println("EXAMPLES:") fmt.Printf(" %s help\n", c.name) diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index 22ac899d271..1d9a24cdfd6 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -41,6 +41,11 @@ type DeployOptions struct { // Deploy handles the deploy command func Deploy(ctx context.Context, args []string, env map[string]string) error { + if len(args) < 1 { + PrintDeployHelp() + return fmt.Errorf("deploy command requires a subcommand") + } + opts, err := parseDeployFlags(args, env) if err != nil { return err diff --git a/go/cmd/cli/commands/deploy/help.go b/go/cmd/cli/commands/deploy/help.go index 1cf1ec37e7f..b5671920401 100644 --- a/go/cmd/cli/commands/deploy/help.go +++ b/go/cmd/cli/commands/deploy/help.go @@ -29,7 +29,6 @@ func PrintDeployHelp() { fmt.Println("ENVIRONMENT VARIABLES:") fmt.Println(" UNKEY_WORKSPACE_ID Default workspace ID") fmt.Println(" UNKEY_PROJECT_ID Default project ID") - fmt.Println(" UNKEY_DOCKER_REGISTRY Default Docker registry") fmt.Println("") fmt.Println("EXAMPLES:") fmt.Println(" # Basic deployment") diff --git a/go/cmd/cli/commands/versions.go b/go/cmd/cli/commands/versions.go index 3cd79e0a54b..40a3981f10e 100644 --- a/go/cmd/cli/commands/versions.go +++ b/go/cmd/cli/commands/versions.go @@ -30,9 +30,6 @@ func Version(ctx context.Context, args []string, env map[string]string) error { return VersionList(args[1:]) case "get": return VersionGet(args[1:]) - case "help", "-h", "--help": - PrintVersionHelp() - return nil default: PrintVersionCommandHelp() return fmt.Errorf("unknown version subcommand: %s", subcommand) From 238c658a96588bee644c9097f6e94d760e05a12a Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 15 Jul 2025 16:43:37 +0300 Subject: [PATCH 12/23] refactor: remove ui bloat --- go/cmd/cli/commands/deploy/build_docker.go | 1 - go/cmd/cli/commands/deploy/deploy.go | 354 ++++---------- go/cmd/cli/commands/deploy/ui.go | 95 ++++ go/cmd/cli/orchestrator/orchestrator.go | 130 ----- go/cmd/cli/orchestrator/state.go | 167 ------- go/cmd/cli/orchestrator/step.go | 90 ---- go/cmd/cli/progress/progress.go | 544 --------------------- 7 files changed, 197 insertions(+), 1184 deletions(-) create mode 100644 go/cmd/cli/commands/deploy/ui.go delete mode 100644 go/cmd/cli/orchestrator/orchestrator.go delete mode 100644 go/cmd/cli/orchestrator/state.go delete mode 100644 go/cmd/cli/orchestrator/step.go delete mode 100644 go/cmd/cli/progress/progress.go diff --git a/go/cmd/cli/commands/deploy/build_docker.go b/go/cmd/cli/commands/deploy/build_docker.go index 7bca6c6f57e..a3b24cacaa6 100644 --- a/go/cmd/cli/commands/deploy/build_docker.go +++ b/go/cmd/cli/commands/deploy/build_docker.go @@ -45,7 +45,6 @@ func pushImage(ctx context.Context, dockerImage, registry string) error { output, err := cmd.CombinedOutput() if err != nil { detailedMsg := classifyPushError(string(output), registry) - fmt.Printf("Docker push failed: %s\n", detailedMsg) return fmt.Errorf("%s", detailedMsg) } fmt.Printf("%s\n", string(output)) diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index 1d9a24cdfd6..a4670895d31 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -4,18 +4,14 @@ import ( "context" "errors" "fmt" - "strings" "time" - "github.com/unkeyed/unkey/go/cmd/cli/orchestrator" ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" "github.com/unkeyed/unkey/go/pkg/git" "github.com/unkeyed/unkey/go/pkg/otel/logging" ) -// INFO: I'll get rid of this in the following iterations now its required for debugging. -// Don't judge my uppercase constant -const DEBUG_DELAY = 1500 +const DEBUG_DELAY = 250 var ( ErrDockerNotFound = errors.New("docker command not found - please install Docker") @@ -24,7 +20,6 @@ var ( ErrInvalidImageTag = errors.New("invalid image tag generated") ) -// DeployOptions holds all deployment configuration type DeployOptions struct { WorkspaceID string ProjectID string @@ -39,331 +34,186 @@ type DeployOptions struct { AuthToken string } -// Deploy handles the deploy command func Deploy(ctx context.Context, args []string, env map[string]string) error { if len(args) < 1 { PrintDeployHelp() - return fmt.Errorf("deploy command requires a subcommand") + return fmt.Errorf("deploy command requires arguments") } opts, err := parseDeployFlags(args, env) if err != nil { return err } + return executeDeploy(ctx, opts) } -// executeDeploy - clean and focused using the orchestrator pattern func executeDeploy(ctx context.Context, opts *DeployOptions) error { + ui := NewUI() logger := logging.New() - - // Create and execute deployment orchestrator - orchestrator := NewDeploymentOrchestrator(ctx, opts, logger) - return orchestrator.Execute() -} - -// DeploymentOrchestrator wraps the generic orchestrator with deployment-specific logic -type DeploymentOrchestrator struct { - *orchestrator.Orchestrator - opts *DeployOptions - logger logging.Logger - controlPlane *ControlPlaneClient -} - -// NewDeploymentOrchestrator creates a new deployment orchestrator -func NewDeploymentOrchestrator(ctx context.Context, opts *DeployOptions, logger logging.Logger) *DeploymentOrchestrator { - orch := orchestrator.New(ctx, "Unkey Deploy Progress") - - do := &DeploymentOrchestrator{ - Orchestrator: orch, - opts: opts, - logger: logger, - controlPlane: NewControlPlaneClient(opts), - } - - // Build the deployment pipeline - do.buildPipeline() - - return do -} - -// buildPipeline constructs the deployment steps -func (do *DeploymentOrchestrator) buildPipeline() { gitInfo := git.GetInfo() - // Auto-detect Git values if not provided - if do.opts.Branch == "main" && gitInfo.IsRepo && gitInfo.Branch != "" { - do.opts.Branch = gitInfo.Branch + if opts.Branch == "main" && gitInfo.IsRepo && gitInfo.Branch != "" { + opts.Branch = gitInfo.Branch } - if do.opts.Commit == "" && gitInfo.CommitSHA != "" { - do.opts.Commit = gitInfo.CommitSHA + if opts.Commit == "" && gitInfo.CommitSHA != "" { + opts.Commit = gitInfo.CommitSHA } - do.AddSteps( - // Step 1: Gather source information - orchestrator.NewStep("source", "Source information"). - Execute(func(ctx context.Context) error { - return nil // Just gathering info - }). - OnSuccess(func() string { - return do.buildSourceInfo(gitInfo) - }). - Run(), - - // Step 2: Prepare deployment environment - orchestrator.NewStep("prepare", "Preparing deployment"). - Execute(do.prepareDeployment). - OnSuccess(func() string { - return "Environment validated" - }). - OnError(func(err error) string { - if err == ErrDockerNotFound { - return "docker command not found - please install Docker" - } - return fmt.Sprintf("Preparation failed: %v", err) - }). - Run(), - - // Step 3: Build Docker image (conditional) - orchestrator.ConditionalStep( - "build", - "Building Docker image", - do.buildImage, - func() bool { return do.opts.DockerImage != "" }, // Skip if using pre-built image - func() string { return "Using pre-built Docker image" }, - ), - - // Step 4: Push to registry (conditional) - orchestrator.ConditionalStep( - "push", - "Publishing to registry", - do.pushImage, - func() bool { return do.opts.SkipPush || do.opts.DockerImage != "" }, - func() string { - if do.opts.SkipPush { - return "Push skipped (--skip-push enabled)" - } - return "Using external Docker image" - }, - ), - - // Step 5: Deploy to Unkey - orchestrator.NewStep("deploy", "Deploying to Unkey"). - Execute(do.deployToUnkey). - OnError(func(err error) string { - return fmt.Sprintf("Deployment failed: %v", err) - }). - Run(), - - // Step 6: Activate version (managed by polling) - orchestrator.NewStep("activate", "Activating version"). - Execute(func(ctx context.Context) error { - return nil // Managed by polling in deployToUnkey - }). - Run(), - - // Step 7: Generate completion summary - orchestrator.NewStep("complete", "Deployment summary"). - Execute(do.generateSummary). - OnSuccess(func() string { - return do.buildCompletionInfo(gitInfo) - }). - OnError(func(err error) string { - return "Failed to generate deployment summary" - }). - Run(), - ) -} + fmt.Printf("Unkey Deploy Progress\n") + fmt.Printf("──────────────────────────────────────────────────\n") + printSourceInfo(opts, gitInfo) + + ui.Print("Preparing deployment") -// Step implementations -func (do *DeploymentOrchestrator) prepareDeployment(ctx context.Context) error { - if do.opts.DockerImage == "" { + var dockerImage string + if opts.DockerImage == "" { if !isDockerAvailable() { + ui.PrintError("Docker not found - please install Docker") return ErrDockerNotFound } - - gitInfo := git.GetInfo() - imageTag := generateImageTag(do.opts, gitInfo) - dockerImage := fmt.Sprintf("%s:%s", do.opts.Registry, imageTag) - do.SetState("dockerImage", dockerImage) + imageTag := generateImageTag(opts, gitInfo) + dockerImage = fmt.Sprintf("%s:%s", opts.Registry, imageTag) } else { - do.SetState("dockerImage", do.opts.DockerImage) + dockerImage = opts.DockerImage } - return nil -} - -func (do *DeploymentOrchestrator) buildImage(ctx context.Context) error { - dockerImage := orchestrator.MustStateAs[string](do.Orchestrator, "dockerImage") - - do.UpdateStepMessage("build", fmt.Sprintf("Building %s", dockerImage)) - - if err := buildImage(ctx, do.opts, dockerImage); err != nil { - return fmt.Errorf("docker build failed: %w", err) + if opts.DockerImage == "" { + ui.Print(fmt.Sprintf("Building image: %s", dockerImage)) + if err := buildImage(ctx, opts, dockerImage); err != nil { + ui.PrintError("Docker build failed") + return err + } + ui.PrintSuccess("Image built successfully") + } else { + ui.Print("Using pre-built Docker image") } - return nil -} - -func (do *DeploymentOrchestrator) pushImage(ctx context.Context) error { - dockerImage := orchestrator.MustStateAs[string](do.Orchestrator, "dockerImage") - - do.UpdateStepMessage("push", "Publishing to registry") - - if err := pushImage(ctx, dockerImage, do.opts.Registry); err != nil { - // For push failures, we continue deployment but log the error - fmt.Printf("Push failed but continuing with deployment\n") - return nil // Don't fail the step + if !opts.SkipPush && opts.DockerImage == "" { + ui.Print("Pushing to registry") + if err := pushImage(ctx, dockerImage, opts.Registry); err != nil { + ui.PrintError("Push failed but continuing deployment") + ui.PrintErrorDetails(err.Error()) + } else { + ui.PrintSuccess("Image pushed successfully") + } + } else if opts.SkipPush { + ui.Print("Skipping registry push") } - return nil -} - -func (do *DeploymentOrchestrator) deployToUnkey(ctx context.Context) error { - dockerImage := orchestrator.MustStateAs[string](do.Orchestrator, "dockerImage") - - do.UpdateStepMessage("deploy", "Starting deployment") + ui.Print("Creating deployment") - // Create version - versionId, err := do.controlPlane.CreateVersion(ctx, dockerImage) + controlPlane := NewControlPlaneClient(opts) + versionId, err := controlPlane.CreateVersion(ctx, dockerImage) if err != nil { - return fmt.Errorf("failed to create version: %w", err) + ui.PrintError("Failed to create version") + return err } - do.SetState("versionId", versionId) - do.UpdateStepMessage("deploy", fmt.Sprintf("Version created: %s", versionId)) + ui.PrintSuccess(fmt.Sprintf("Version created: %s", versionId)) + + ui.StartSpinner("Deploying to Unkey...") - // Poll with event handlers onStatusChange := func(event VersionStatusEvent) error { - return do.handleStatusChange(event) + if event.CurrentStatus == ctrlv1.VersionStatus_VERSION_STATUS_FAILED { + return handleVersionFailure(controlPlane, event.Version, ui) + } + return nil } - onStepUpdate := func(event VersionStepEvent) error { - return do.handleStepUpdate(event) + return handleStepUpdate(event, ui) } - if err := do.controlPlane.PollVersionStatus(ctx, do.logger, versionId, onStatusChange, onStepUpdate); err != nil { - return fmt.Errorf("deployment polling failed: %w", err) + err = controlPlane.PollVersionStatus(ctx, logger, versionId, onStatusChange, onStepUpdate) + if err != nil { + ui.StopSpinner("Deployment failed", false) + return err } - return nil -} + ui.StopSpinner("Deployment completed successfully", true) -func (do *DeploymentOrchestrator) handleStatusChange(event VersionStatusEvent) error { - tracker := do.GetTracker() - - switch event.CurrentStatus { - case ctrlv1.VersionStatus_VERSION_STATUS_PENDING: - tracker.UpdateStep("deploy", "Version queued and ready to start") - case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: - tracker.UpdateStep("deploy", "Building deployment image") - case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: - tracker.CompleteStep("deploy", "Deployment initiated") - tracker.StartStep("activate", "Deploying to unkey") - case ctrlv1.VersionStatus_VERSION_STATUS_ACTIVE: - if event.PreviousStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { - tracker.CompleteStep("activate", "Version is now active") - } else { - tracker.CompleteStep("deploy", "Deployment completed") - tracker.CompleteStep("activate", "Version is now active") - } - case ctrlv1.VersionStatus_VERSION_STATUS_FAILED: - errorMsg := do.controlPlane.getFailureMessage(event.Version) - if event.PreviousStatus == ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING { - tracker.FailStep("activate", errorMsg) - } else { - tracker.FailStep("deploy", errorMsg) - } - return fmt.Errorf("deployment failed: %s", errorMsg) - } + fmt.Printf("\n") + printCompletionInfo(opts, gitInfo, versionId) + fmt.Printf("\n") return nil } -func (do *DeploymentOrchestrator) handleStepUpdate(event VersionStepEvent) error { - tracker := do.GetTracker() - message := event.Step.GetMessage() +func handleVersionFailure(controlPlane *ControlPlaneClient, version *ctrlv1.Version, ui *UI) error { + errorMsg := controlPlane.getFailureMessage(version) + ui.PrintError("Deployment failed") + ui.PrintErrorDetails(errorMsg) + return fmt.Errorf("deployment failed: %s", errorMsg) +} - // Add status context if helpful - if event.Step.GetStatus() != "" { - message = fmt.Sprintf("[%s] %s", event.Step.GetStatus(), message) +func handleStepUpdate(event VersionStepEvent, ui *UI) error { + ui.mu.Lock() + if ui.spinning { + ui.spinning = false + fmt.Print("\r\033[K") } + ui.mu.Unlock() - switch event.Status { - case ctrlv1.VersionStatus_VERSION_STATUS_BUILDING: - tracker.UpdateStep("deploy", message) - case ctrlv1.VersionStatus_VERSION_STATUS_DEPLOYING: - tracker.UpdateStep("activate", message) - default: - tracker.UpdateStep("deploy", message) - } + step := event.Step - if DEBUG_DELAY > 0 { - time.Sleep(DEBUG_DELAY * time.Millisecond) + if step.GetErrorMessage() != "" { + fmt.Printf(" ✗ %s\n", step.GetMessage()) + fmt.Printf(" -> %s\n", step.GetErrorMessage()) + return fmt.Errorf("deployment failed: %s", step.GetErrorMessage()) } - return nil -} -func (do *DeploymentOrchestrator) generateSummary(ctx context.Context) error { - versionId, ok := orchestrator.StateAs[string](do.Orchestrator, "versionId") - if !ok { - return fmt.Errorf("no version ID available for summary") - } - if versionId == "" { - return fmt.Errorf("empty version ID") + if step.GetMessage() != "" { + fmt.Printf(" ✓ %s\n", step.GetMessage()) + + if DEBUG_DELAY > 0 { + time.Sleep(DEBUG_DELAY * time.Millisecond) + } } + return nil } -// Helper methods for building display information -func (do *DeploymentOrchestrator) buildSourceInfo(gitInfo git.Info) string { - var parts []string - - parts = append(parts, fmt.Sprintf("Branch: %s", do.opts.Branch)) +func printSourceInfo(opts *DeployOptions, gitInfo git.Info) { + fmt.Printf("Source Information:\n") + fmt.Printf(" Branch: %s\n", opts.Branch) if gitInfo.IsRepo && gitInfo.CommitSHA != "" { shortSHA := gitInfo.CommitSHA if len(shortSHA) > 7 { shortSHA = shortSHA[:7] } - commitInfo := fmt.Sprintf("Commit: %s", shortSHA) + commitInfo := shortSHA if gitInfo.IsDirty { commitInfo += " (dirty)" } - parts = append(parts, commitInfo) - } else if !gitInfo.IsRepo { - parts = append(parts, "Not a git repository") + fmt.Printf(" Commit: %s\n", commitInfo) } - parts = append(parts, fmt.Sprintf("Context: %s", do.opts.Context)) + fmt.Printf(" Context: %s\n", opts.Context) - if do.opts.DockerImage != "" { - parts = append(parts, fmt.Sprintf("Image: %s", do.opts.DockerImage)) + if opts.DockerImage != "" { + fmt.Printf(" Image: %s\n", opts.DockerImage) } - return strings.Join(parts, " | ") + fmt.Printf("\n") } -func (do *DeploymentOrchestrator) buildCompletionInfo(gitInfo git.Info) string { - versionId := orchestrator.MustStateAs[string](do.Orchestrator, "versionId") - - if versionId == "" || do.opts.WorkspaceID == "" || do.opts.Branch == "" { - return "" +func printCompletionInfo(opts *DeployOptions, gitInfo git.Info, versionId string) { + if versionId == "" || opts.WorkspaceID == "" || opts.Branch == "" { + fmt.Printf("✓ Deployment completed\n") + return } - var parts []string - - parts = append(parts, fmt.Sprintf("Version: %s", versionId)) - parts = append(parts, "Status: Ready") - parts = append(parts, "Env: Production") + fmt.Printf("Deployment Summary:\n") + fmt.Printf(" Version: %s\n", versionId) + fmt.Printf(" Status: Ready\n") + fmt.Printf(" Environment: Production\n") identifier := versionId if gitInfo.ShortSHA != "" { identifier = gitInfo.ShortSHA } - domain := fmt.Sprintf("https://%s-%s-%s.unkey.app", do.opts.Branch, identifier, do.opts.WorkspaceID) - parts = append(parts, fmt.Sprintf("URL: %s", domain)) - - return strings.Join(parts, " | ") + domain := fmt.Sprintf("https://%s-%s-%s.unkey.app", opts.Branch, identifier, opts.WorkspaceID) + fmt.Printf(" URL: %s\n", domain) } diff --git a/go/cmd/cli/commands/deploy/ui.go b/go/cmd/cli/commands/deploy/ui.go new file mode 100644 index 00000000000..7d5c8c99d4c --- /dev/null +++ b/go/cmd/cli/commands/deploy/ui.go @@ -0,0 +1,95 @@ +package deploy + +import ( + "fmt" + "sync" + "time" +) + +var spinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +type UI struct { + mu sync.Mutex + spinning bool + done chan struct{} +} + +func NewUI() *UI { + return &UI{ + done: make(chan struct{}), + } +} + +func (ui *UI) Print(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf("• %s\n", message) +} + +func (ui *UI) PrintSuccess(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf("✓ %s\n", message) +} + +func (ui *UI) PrintError(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf("✗ %s\n", message) +} + +func (ui *UI) PrintErrorDetails(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf(" -> %s\n", message) +} + +func (ui *UI) StartSpinner(message string) { + ui.mu.Lock() + if ui.spinning { + ui.mu.Unlock() + return + } + ui.spinning = true + ui.mu.Unlock() + + go func() { + frame := 0 + for { + select { + case <-ui.done: + return + default: + ui.mu.Lock() + if !ui.spinning { + ui.mu.Unlock() + return + } + + fmt.Printf("\r%s %s", spinnerChars[frame%len(spinnerChars)], message) + ui.mu.Unlock() + + frame++ + time.Sleep(100 * time.Millisecond) + } + } + }() +} + +func (ui *UI) StopSpinner(finalMessage string, success bool) { + ui.mu.Lock() + defer ui.mu.Unlock() + + if !ui.spinning { + return + } + + ui.spinning = false + fmt.Print("\r\033[K") + + if success { + fmt.Printf("✓ %s\n", finalMessage) + } else { + fmt.Printf("✗ %s\n", finalMessage) + } +} diff --git a/go/cmd/cli/orchestrator/orchestrator.go b/go/cmd/cli/orchestrator/orchestrator.go deleted file mode 100644 index 598d3850820..00000000000 --- a/go/cmd/cli/orchestrator/orchestrator.go +++ /dev/null @@ -1,130 +0,0 @@ -// Package orchestrator provides a reusable pattern for executing multi-step operations -// with progress tracking and error handling. -package orchestrator - -import ( - "context" - "fmt" - - "github.com/unkeyed/unkey/go/cmd/cli/progress" -) - -// Orchestrator manages the execution of multiple steps with progress tracking -type Orchestrator struct { - title string - steps []*Step - tracker *progress.Tracker - ctx context.Context - state map[string]any // Shared state between steps -} - -// New creates a new orchestrator with the given title -func New(ctx context.Context, title string) *Orchestrator { - return &Orchestrator{ - title: title, - steps: make([]*Step, 0), - ctx: ctx, - state: make(map[string]any), - } -} - -// AddStep adds a step to the orchestrator -func (o *Orchestrator) AddStep(step *Step) *Orchestrator { - o.steps = append(o.steps, step) - return o -} - -// AddSteps adds multiple steps to the orchestrator -func (o *Orchestrator) AddSteps(steps ...*Step) *Orchestrator { - for _, step := range steps { - o.AddStep(step) - } - return o -} - -// Execute runs all the steps in sequence with progress tracking -func (o *Orchestrator) Execute() error { - // Initialize tracker - o.tracker = progress.NewTracker(o.title) - - // Add all steps to tracker - for _, step := range o.steps { - o.tracker.AddStep(step.ID, step.Name) - } - - o.tracker.Start() - defer o.tracker.Stop() - - // Execute steps - for _, step := range o.steps { - if err := o.executeStep(step); err != nil { - return err - } - } - - return nil -} - -// executeStep executes a single step with proper error handling -func (o *Orchestrator) executeStep(step *Step) error { - // Safety check for nil Execute function - if step.Execute == nil { - return fmt.Errorf("step '%s' has no execution function", step.Name) - } - - // Check if step should be skipped - if step.SkipIf != nil && step.SkipIf() { - reason := fmt.Sprintf("Skipped: %s", step.Name) - if step.SkipReason != nil { - reason = step.SkipReason() - } - o.tracker.SkipStep(step.ID, reason) - return nil - } - - // Start the step - o.tracker.StartStep(step.ID, fmt.Sprintf("Executing %s", step.Name)) - - // Execute the step - err := step.Execute(o.ctx) - if err != nil { - // Handle error - errorMsg := err.Error() - if step.OnError != nil { - errorMsg = step.OnError(err) - } - - o.tracker.FailStep(step.ID, errorMsg) - - // If step is required, stop execution - if step.Required { - return fmt.Errorf("required step '%s' failed: %w", step.Name, err) - } - - // For non-required steps, continue execution - return nil - } - - // Handle success - successMsg := fmt.Sprintf("%s completed", step.Name) - if step.OnSuccess != nil { - successMsg = step.OnSuccess() - } - - o.tracker.CompleteStep(step.ID, successMsg) - return nil -} - -// UpdateStepMessage updates the message for a currently running step -func (o *Orchestrator) UpdateStepMessage(stepID, message string) { - if o.tracker != nil { - o.tracker.UpdateStep(stepID, message) - } -} - -// GetTracker returns the underlying progress tracker for advanced usage -// This should be used sparingly and only when the orchestrator pattern -// doesn't cover your specific use case -func (o *Orchestrator) GetTracker() *progress.Tracker { - return o.tracker -} diff --git a/go/cmd/cli/orchestrator/state.go b/go/cmd/cli/orchestrator/state.go deleted file mode 100644 index 51c50baaf10..00000000000 --- a/go/cmd/cli/orchestrator/state.go +++ /dev/null @@ -1,167 +0,0 @@ -package orchestrator - -import ( - "fmt" - "reflect" -) - -// SetState sets a value in the shared state -func (o *Orchestrator) SetState(key string, value any) { - o.state[key] = value -} - -// State gets a value from the shared state -func (o *Orchestrator) State(key string) (any, bool) { - value, exists := o.state[key] - return value, exists -} - -// MustState gets a value and panics if not found -func (o *Orchestrator) MustState(key string) any { - value, exists := o.state[key] - if !exists { - panic(fmt.Sprintf("required state key '%s' not found", key)) - } - return value -} - -// StateAs gets a value with automatic type assertion -func StateAs[T any](o *Orchestrator, key string) (T, bool) { - var zero T - value, exists := o.state[key] - if !exists { - return zero, false - } - - // Direct type assertion - if typed, ok := value.(T); ok { - return typed, true - } - - // Try reflection-based conversion for compatible types - return convertValue[T](value) -} - -// MustStateAs gets a value with type assertion and panics if not found or wrong type -func MustStateAs[T any](o *Orchestrator, key string) T { - value, ok := StateAs[T](o, key) - if !ok { - var zero T - panic(fmt.Sprintf("required state key '%s' not found or cannot convert to %T", key, zero)) - } - return value -} - -// convertValue attempts to convert a value to the target type using reflection -func convertValue[T any](value any) (T, bool) { - var zero T - targetType := reflect.TypeOf(zero) - sourceValue := reflect.ValueOf(value) - - // If source is nil, return zero value - if !sourceValue.IsValid() { - return zero, false - } - - sourceType := sourceValue.Type() - - // Same type - should have been caught by direct assertion, but just in case - if sourceType == targetType { - return value.(T), true - } - - // Check if source is convertible to target - if sourceType.ConvertibleTo(targetType) { - converted := sourceValue.Convert(targetType) - return converted.Interface().(T), true - } - - // Handle pointer/non-pointer conversions - if targetType.Kind() == reflect.Ptr && sourceType == targetType.Elem() { - // Converting value to pointer - ptr := reflect.New(sourceType) - ptr.Elem().Set(sourceValue) - return ptr.Interface().(T), true - } - - if sourceType.Kind() == reflect.Ptr && sourceType.Elem() == targetType { - // Converting pointer to value - if sourceValue.IsNil() { - return zero, false - } - return sourceValue.Elem().Interface().(T), true - } - - // String conversions - if targetType.Kind() == reflect.String { - return reflect.ValueOf(fmt.Sprintf("%v", value)).Interface().(T), true - } - - return zero, false -} - -// HasState checks if a key exists in state -func (o *Orchestrator) HasState(key string) bool { - _, exists := o.state[key] - return exists -} - -// RemoveState removes a specific key from state -func (o *Orchestrator) RemoveState(key string) { - delete(o.state, key) -} - -// ClearState clears all state -func (o *Orchestrator) ClearState() { - o.state = make(map[string]any) -} - -// StateKeys returns all keys in the state -func (o *Orchestrator) StateKeys() []string { - keys := make([]string, 0, len(o.state)) - for key := range o.state { - keys = append(keys, key) - } - return keys -} - -// StateCount returns the number of items in state -func (o *Orchestrator) StateCount() int { - return len(o.state) -} - -// StateSnapshot returns a copy of the current state -func (o *Orchestrator) StateSnapshot() map[string]any { - snapshot := make(map[string]any, len(o.state)) - for k, v := range o.state { - snapshot[k] = v - } - return snapshot -} - -// SetStateIf sets a value only if the key doesn't exist -func (o *Orchestrator) SetStateIf(key string, value any) bool { - if !o.HasState(key) { - o.SetState(key, value) - return true - } - return false -} - -// UpdateState updates a value using a function if the key exists -func (o *Orchestrator) UpdateState(key string, fn func(any) any) bool { - if value, exists := o.state[key]; exists { - o.state[key] = fn(value) - return true - } - return false -} - -// UpdateStateAs updates a value with type safety -func UpdateStateAs[T any](o *Orchestrator, key string, fn func(T) T) bool { - if value, ok := StateAs[T](o, key); ok { - o.SetState(key, fn(value)) - return true - } - return false -} diff --git a/go/cmd/cli/orchestrator/step.go b/go/cmd/cli/orchestrator/step.go deleted file mode 100644 index 6deb4dd2db9..00000000000 --- a/go/cmd/cli/orchestrator/step.go +++ /dev/null @@ -1,90 +0,0 @@ -package orchestrator - -import "context" - -// StepFunc represents a function that executes a single step -type StepFunc func(ctx context.Context) error - -// Step represents a single operation in a multi-step process -type Step struct { - ID string - Name string - Execute StepFunc - OnSuccess func() string // Optional: custom success message - OnError func(error) string // Optional: custom error message - SkipIf func() bool // Optional: condition to skip this step - SkipReason func() string // Optional: reason for skipping - Required bool // If true, failure stops the entire process -} - -// StepBuilder provides a fluent interface for building steps -type StepBuilder struct { - step *Step -} - -// NewStep creates a new step builder -func NewStep(id, name string) *StepBuilder { - return &StepBuilder{ - step: &Step{ - ID: id, - Name: name, - Required: true, // Default to required - }, - } -} - -// Execute sets the execution function -func (sb *StepBuilder) Execute(fn StepFunc) *StepBuilder { - sb.step.Execute = fn - return sb -} - -// OnSuccess sets the success message function -func (sb *StepBuilder) OnSuccess(fn func() string) *StepBuilder { - sb.step.OnSuccess = fn - return sb -} - -// OnError sets the error message function -func (sb *StepBuilder) OnError(fn func(error) string) *StepBuilder { - sb.step.OnError = fn - return sb -} - -// SkipIf sets the skip condition -func (sb *StepBuilder) SkipIf(fn func() bool) *StepBuilder { - sb.step.SkipIf = fn - return sb -} - -// SkipReason sets the skip reason function -func (sb *StepBuilder) SkipReason(fn func() string) *StepBuilder { - sb.step.SkipReason = fn - return sb -} - -// Required sets whether the step is required (default: true) -func (sb *StepBuilder) Required(required bool) *StepBuilder { - sb.step.Required = required - return sb -} - -// Optional marks the step as optional (failure won't stop execution) -func (sb *StepBuilder) Optional() *StepBuilder { - sb.step.Required = false - return sb -} - -// Run returns the constructed step -func (sb *StepBuilder) Run() *Step { - return sb.step -} - -// ConditionalStep creates a step that can be skipped based on a condition -func ConditionalStep(id, name string, fn StepFunc, skipIf func() bool, skipReason func() string) *Step { - return NewStep(id, name). - Execute(fn). - SkipIf(skipIf). - SkipReason(skipReason). - Run() -} diff --git a/go/cmd/cli/progress/progress.go b/go/cmd/cli/progress/progress.go deleted file mode 100644 index 765e3308e98..00000000000 --- a/go/cmd/cli/progress/progress.go +++ /dev/null @@ -1,544 +0,0 @@ -// Package progress provides reusable animated progress tracking for CLI operations -package progress - -import ( - "fmt" - "strings" - "sync" - "time" -) - -// Colors for different states -const ( - ColorReset = "\033[0m" - ColorGreen = "\033[32m" - ColorYellow = "\033[33m" - ColorRed = "\033[31m" - ColorBlue = "\033[34m" - ColorCyan = "\033[36m" - ColorGray = "\033[90m" -) - -// Animation characters -var ( - SpinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - DotsChars = []string{"", ".", "..", "..."} -) - -// Status represents the state of a tracked item -type Status string - -const ( - StatusPending Status = "pending" - StatusRunning Status = "running" - StatusCompleted Status = "completed" - StatusFailed Status = "failed" - StatusSkipped Status = "skipped" -) - -// Step represents a single step in a process -type Step struct { - ID string - Name string - Status Status - Message string - Error string - StartTime time.Time - EndTime time.Time - Active bool - Progress float64 // 0.0 to 1.0 for progress bars - metadata map[string]any - mu sync.RWMutex -} - -// SetMetadata sets custom metadata for the step -func (s *Step) SetMetadata(key string, value any) { - s.mu.Lock() - defer s.mu.Unlock() - if s.metadata == nil { - s.metadata = make(map[string]any) - } - s.metadata[key] = value -} - -// GetMetadata gets custom metadata for the step -func (s *Step) GetMetadata(key string) (any, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - if s.metadata == nil { - return nil, false - } - val, exists := s.metadata[key] - return val, exists -} - -// Duration returns the duration of the step -func (s *Step) Duration() time.Duration { - s.mu.RLock() - defer s.mu.RUnlock() - if s.EndTime.IsZero() { - if s.StartTime.IsZero() { - return 0 - } - return time.Since(s.StartTime) - } - return s.EndTime.Sub(s.StartTime) -} - -// Tracker manages animated progress tracking -type Tracker struct { - title string - steps map[string]*Step - stepOrder []string - animation animationState - done chan struct{} - running bool - mu sync.RWMutex - options TrackerOptions - renderState renderState - firstRender bool -} - -type animationState struct { - frame int - lastUpdate time.Time -} - -type renderState struct { - linesRendered int - lastContent []string -} - -// TrackerOptions configures the tracker behavior -type TrackerOptions struct { - ShowElapsed bool // Show elapsed time for running steps - ShowDuration bool // Show duration for completed steps - ShowProgress bool // Show progress bars when available - AnimationSpeed time.Duration // Animation update interval - ClearOnDone bool // Clear screen when done - Compact bool // Use compact display - NoColor bool // Disable colors -} - -// DefaultOptions returns sensible default options -func DefaultOptions() TrackerOptions { - return TrackerOptions{ - ShowElapsed: true, - ShowDuration: true, - ShowProgress: true, - AnimationSpeed: 100 * time.Millisecond, - ClearOnDone: false, - Compact: false, - NoColor: false, - } -} - -// NewTracker creates a new progress tracker -func NewTracker(title string, opts ...TrackerOptions) *Tracker { - options := DefaultOptions() - if len(opts) > 0 { - options = opts[0] - } - - return &Tracker{ - title: title, - steps: make(map[string]*Step), - stepOrder: make([]string, 0), - animation: animationState{lastUpdate: time.Now()}, - done: make(chan struct{}), - options: options, - firstRender: true, - } -} - -// AddStep adds a new step to track -func (t *Tracker) AddStep(id, name string) *Step { - t.mu.Lock() - defer t.mu.Unlock() - - if _, exists := t.steps[id]; !exists { - t.stepOrder = append(t.stepOrder, id) - } - - step := &Step{ - ID: id, - Name: name, - Status: StatusPending, - StartTime: time.Now(), - Active: false, - } - - t.steps[id] = step - return step -} - -// GetStep returns a step by ID -func (t *Tracker) GetStep(id string) (*Step, bool) { - t.mu.RLock() - defer t.mu.RUnlock() - step, exists := t.steps[id] - return step, exists -} - -// StartStep marks a step as running -func (t *Tracker) StartStep(id string, message ...string) { - t.mu.Lock() - defer t.mu.Unlock() - - if step, exists := t.steps[id]; exists { - step.Status = StatusRunning - step.StartTime = time.Now() - step.Active = true - if len(message) > 0 { - step.Message = message[0] - } - } -} - -// UpdateStep updates a step's message and optionally progress -func (t *Tracker) UpdateStep(id, message string, progress ...float64) { - t.mu.Lock() - defer t.mu.Unlock() - - if step, exists := t.steps[id]; exists { - step.Message = message - if len(progress) > 0 { - step.Progress = progress[0] - } - } -} - -// CompleteStep marks a step as completed -func (t *Tracker) CompleteStep(id string, message ...string) { - t.mu.Lock() - defer t.mu.Unlock() - - if step, exists := t.steps[id]; exists { - step.Status = StatusCompleted - step.EndTime = time.Now() - step.Active = false - if len(message) > 0 { - step.Message = message[0] - } - } -} - -// FailStep marks a step as failed -func (t *Tracker) FailStep(id, errorMsg string) { - t.mu.Lock() - defer t.mu.Unlock() - - if step, exists := t.steps[id]; exists { - step.Status = StatusFailed - step.Error = errorMsg - step.EndTime = time.Now() - step.Active = false - } -} - -// SkipStep marks a step as skipped -func (t *Tracker) SkipStep(id string, reason ...string) { - t.mu.Lock() - defer t.mu.Unlock() - - if step, exists := t.steps[id]; exists { - step.Status = StatusSkipped - step.EndTime = time.Now() - step.Active = false - if len(reason) > 0 { - step.Message = reason[0] - } - } -} - -// Start begins the animation loop -func (t *Tracker) Start() { - t.mu.Lock() - if t.running { - t.mu.Unlock() - return - } - t.running = true - t.mu.Unlock() - - // Do initial render immediately to avoid race conditions - t.render(false) - - go t.animationLoop() -} - -// Stop stops the animation and shows final state -func (t *Tracker) Stop() { - t.mu.Lock() - if !t.running { - t.mu.Unlock() - return - } - t.running = false - t.mu.Unlock() - - close(t.done) - - // Wait a brief moment for the animation loop to finish - time.Sleep(50 * time.Millisecond) - - // Ensure final state is rendered - t.render(true) -} - -// animationLoop runs the animation updates -func (t *Tracker) animationLoop() { - ticker := time.NewTicker(t.options.AnimationSpeed) - defer ticker.Stop() - - for { - select { - case <-t.done: - // Render final state before exiting - if t.options.ClearOnDone { - t.renderFinalState() - } else { - t.render(true) // Final render without animation - } - return - case <-ticker.C: - t.updateAnimation() - t.render(false) - } - } -} - -// updateAnimation updates the animation frame -func (t *Tracker) updateAnimation() { - t.mu.Lock() - defer t.mu.Unlock() - - now := time.Now() - if now.Sub(t.animation.lastUpdate) >= t.options.AnimationSpeed { - t.animation.frame++ - t.animation.lastUpdate = now - } -} - -// render displays the current state with minimal layout shift -func (t *Tracker) render(final bool) { - // Build the complete content first - content := t.buildContent(final) - - if t.firstRender { - // First render - just print everything - for _, line := range content { - fmt.Println(line) - } - t.firstRender = false - t.renderState.linesRendered = len(content) - t.renderState.lastContent = make([]string, len(content)) - copy(t.renderState.lastContent, content) - return - } - - // Update only changed lines - t.updateChangedLines(content) - - // Store current content for next comparison - t.renderState.lastContent = make([]string, len(content)) - copy(t.renderState.lastContent, content) -} - -// buildContent builds the complete content as slice of lines -func (t *Tracker) buildContent(final bool) []string { - var content []string - - // Title - titleColor := t.color(ColorBlue) - content = append(content, fmt.Sprintf("%s%s%s", titleColor, t.title, t.colorReset())) - - if !t.options.Compact { - content = append(content, strings.Repeat("─", 50)) - } - - // Build step content - t.mu.RLock() - for _, stepID := range t.stepOrder { - step := t.steps[stepID] - stepLines := t.buildStepContent(step, final) - content = append(content, stepLines...) - } - t.mu.RUnlock() - - content = append(content, "") // Empty line at the end - - return content -} - -// buildStepContent builds content for a single step -func (t *Tracker) buildStepContent(step *Step, final bool) []string { - var lines []string - - step.mu.RLock() - defer step.mu.RUnlock() - - icon, color := t.getStepIcon(step, final) - - // Step name with icon - stepLine := fmt.Sprintf("%s %s%s%s", icon, color, step.Name, t.colorReset()) - - // Show elapsed time for running steps - if t.options.ShowElapsed && step.Status == StatusRunning && step.Active && !final { - elapsed := time.Since(step.StartTime).Truncate(time.Second) - stepLine += fmt.Sprintf(" %s(%s)%s", t.color(ColorGray), elapsed, t.colorReset()) - } - - // Show duration for completed steps - if t.options.ShowDuration && step.Status == StatusCompleted && !step.EndTime.IsZero() { - duration := step.EndTime.Sub(step.StartTime).Truncate(time.Millisecond) - stepLine += fmt.Sprintf(" %s(%s)%s", t.color(ColorGreen), duration, t.colorReset()) - } - - lines = append(lines, stepLine) - - // Show message - if step.Message != "" { - indent := " " - message := step.Message - - // Add animated dots for running steps - if step.Status == StatusRunning && step.Active && !final { - dots := DotsChars[t.animation.frame%len(DotsChars)] - message = message + dots - } - - lines = append(lines, fmt.Sprintf("%s%s", indent, message)) - } - - // Show progress bar if available - if t.options.ShowProgress && step.Progress > 0 && step.Status == StatusRunning { - progressLine := t.buildProgressBar(step.Progress) - lines = append(lines, progressLine) - } - - // Show error if present - if step.Error != "" { - errorLine := fmt.Sprintf(" %s -> Error: %s%s", t.color(ColorRed), step.Error, t.colorReset()) - lines = append(lines, errorLine) - } - - return lines -} - -// updateChangedLines updates only the lines that have changed -func (t *Tracker) updateChangedLines(newContent []string) { - maxLines := max(len(t.renderState.lastContent), len(newContent)) - - for i := range maxLines { - var newLine, oldLine string - - if i < len(newContent) { - newLine = newContent[i] - } - if i < len(t.renderState.lastContent) { - oldLine = t.renderState.lastContent[i] - } - - if newLine != oldLine { - // Move cursor to the line and clear it - fmt.Printf("\033[%d;1H\033[K%s", i+1, newLine) - } - } - - // If we have fewer lines now, clear the remaining ones - if len(newContent) < len(t.renderState.lastContent) { - for i := len(newContent); i < len(t.renderState.lastContent); i++ { - fmt.Printf("\033[%d;1H\033[K", i+1) - } - } - - t.renderState.linesRendered = len(newContent) -} - -// buildProgressBar builds a progress bar string -func (t *Tracker) buildProgressBar(progress float64) string { - if progress < 0 { - progress = 0 - } - if progress > 1 { - progress = 1 - } - - width := 30 - filled := int(progress * float64(width)) - bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) - percentage := int(progress * 100) - - return fmt.Sprintf(" %s[%s%s%s] %d%%", - t.color(ColorCyan), - t.color(ColorGreen), - bar, - t.colorReset(), - percentage) -} - -// getStepIcon returns the appropriate icon and color for a step -func (t *Tracker) getStepIcon(step *Step, final bool) (string, string) { - switch step.Status { - case StatusPending: - return t.colorize("○", ColorYellow), t.color(ColorYellow) - case StatusRunning: - if step.Active && !final { - char := SpinnerChars[t.animation.frame%len(SpinnerChars)] - return t.colorize(char, ColorCyan), t.color(ColorCyan) - } - return t.colorize("●", ColorCyan), t.color(ColorCyan) - case StatusCompleted: - return t.colorize("✓", ColorGreen), t.color(ColorGreen) - case StatusFailed: - return t.colorize("✗", ColorRed), t.color(ColorRed) - case StatusSkipped: - return t.colorize("⊘", ColorGray), t.color(ColorGray) - default: - return t.colorize("○", ColorYellow), t.color(ColorYellow) - } -} - -// renderFinalState shows the final state -func (t *Tracker) renderFinalState() { - fmt.Print("\033[H\033[J") - fmt.Printf("%s%s - Complete%s\n", t.color(ColorGreen), t.title, t.colorReset()) - fmt.Println(strings.Repeat("─", 50)) - - t.mu.RLock() - for _, stepID := range t.stepOrder { - step := t.steps[stepID] - stepLines := t.buildStepContent(step, true) - for _, line := range stepLines { - fmt.Println(line) - } - } - t.mu.RUnlock() - - fmt.Println() -} - -// color returns color code if colors are enabled -func (t *Tracker) color(color string) string { - if t.options.NoColor { - return "" - } - return color -} - -// colorReset returns reset code if colors are enabled -func (t *Tracker) colorReset() string { - if t.options.NoColor { - return "" - } - return ColorReset -} - -// colorize wraps text in color if colors are enabled -func (t *Tracker) colorize(text, color string) string { - if t.options.NoColor { - return text - } - return color + text + ColorReset -} From e8ab3dc0dd4e716a37c3773c528106283c5fa9b9 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 15 Jul 2025 16:51:36 +0300 Subject: [PATCH 13/23] feat: add colors for make it distinguishable --- go/cmd/cli/commands/deploy/build_docker.go | 1 - go/cmd/cli/commands/deploy/deploy.go | 6 ++-- go/cmd/cli/commands/deploy/ui.go | 34 +++++++++++++++++----- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/go/cmd/cli/commands/deploy/build_docker.go b/go/cmd/cli/commands/deploy/build_docker.go index a3b24cacaa6..0d64d3da481 100644 --- a/go/cmd/cli/commands/deploy/build_docker.go +++ b/go/cmd/cli/commands/deploy/build_docker.go @@ -70,7 +70,6 @@ func classifyPushError(output, registry string) string { } } -// ## HELPERS func getRegistryHost(registry string) string { parts := strings.Split(registry, "/") if len(parts) > 0 { diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index a4670895d31..14890fcbf2c 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -157,13 +157,13 @@ func handleStepUpdate(event VersionStepEvent, ui *UI) error { step := event.Step if step.GetErrorMessage() != "" { - fmt.Printf(" ✗ %s\n", step.GetMessage()) - fmt.Printf(" -> %s\n", step.GetErrorMessage()) + ui.PrintStepError(step.GetMessage()) + ui.PrintErrorDetails(step.GetErrorMessage()) return fmt.Errorf("deployment failed: %s", step.GetErrorMessage()) } if step.GetMessage() != "" { - fmt.Printf(" ✓ %s\n", step.GetMessage()) + ui.PrintStepSuccess(step.GetMessage()) if DEBUG_DELAY > 0 { time.Sleep(DEBUG_DELAY * time.Millisecond) diff --git a/go/cmd/cli/commands/deploy/ui.go b/go/cmd/cli/commands/deploy/ui.go index 7d5c8c99d4c..98d7570dc47 100644 --- a/go/cmd/cli/commands/deploy/ui.go +++ b/go/cmd/cli/commands/deploy/ui.go @@ -6,6 +6,14 @@ import ( "time" ) +// Color constants +const ( + ColorReset = "\033[0m" + ColorRed = "\033[31m" + ColorGreen = "\033[32m" + ColorYellow = "\033[33m" +) + var spinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} type UI struct { @@ -23,25 +31,37 @@ func NewUI() *UI { func (ui *UI) Print(message string) { ui.mu.Lock() defer ui.mu.Unlock() - fmt.Printf("• %s\n", message) + fmt.Printf("%s•%s %s\n", ColorYellow, ColorReset, message) } func (ui *UI) PrintSuccess(message string) { ui.mu.Lock() defer ui.mu.Unlock() - fmt.Printf("✓ %s\n", message) + fmt.Printf("%s✓%s %s\n", ColorGreen, ColorReset, message) } func (ui *UI) PrintError(message string) { ui.mu.Lock() defer ui.mu.Unlock() - fmt.Printf("✗ %s\n", message) + fmt.Printf("%s✗%s %s\n", ColorRed, ColorReset, message) } func (ui *UI) PrintErrorDetails(message string) { ui.mu.Lock() defer ui.mu.Unlock() - fmt.Printf(" -> %s\n", message) + fmt.Printf(" %s->%s %s\n", ColorRed, ColorReset, message) +} + +func (ui *UI) PrintStepSuccess(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf(" %s✓%s %s\n", ColorGreen, ColorReset, message) +} + +func (ui *UI) PrintStepError(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + fmt.Printf(" %s✗%s %s\n", ColorRed, ColorReset, message) } func (ui *UI) StartSpinner(message string) { @@ -65,10 +85,8 @@ func (ui *UI) StartSpinner(message string) { ui.mu.Unlock() return } - fmt.Printf("\r%s %s", spinnerChars[frame%len(spinnerChars)], message) ui.mu.Unlock() - frame++ time.Sleep(100 * time.Millisecond) } @@ -88,8 +106,8 @@ func (ui *UI) StopSpinner(finalMessage string, success bool) { fmt.Print("\r\033[K") if success { - fmt.Printf("✓ %s\n", finalMessage) + fmt.Printf("%s✓%s %s\n", ColorGreen, ColorReset, finalMessage) } else { - fmt.Printf("✗ %s\n", finalMessage) + fmt.Printf("%s✗%s %s\n", ColorRed, ColorReset, finalMessage) } } From 915276747d27aa2b4ea9da1610404a0e077083de Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 15 Jul 2025 18:04:48 +0300 Subject: [PATCH 14/23] fix: steps --- go/cmd/cli/commands/deploy/build_docker.go | 2 +- go/cmd/cli/commands/deploy/deploy.go | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/go/cmd/cli/commands/deploy/build_docker.go b/go/cmd/cli/commands/deploy/build_docker.go index 0d64d3da481..91b1b374246 100644 --- a/go/cmd/cli/commands/deploy/build_docker.go +++ b/go/cmd/cli/commands/deploy/build_docker.go @@ -45,7 +45,7 @@ func pushImage(ctx context.Context, dockerImage, registry string) error { output, err := cmd.CombinedOutput() if err != nil { detailedMsg := classifyPushError(string(output), registry) - return fmt.Errorf("%s", detailedMsg) + return fmt.Errorf("%s: %w", detailedMsg, err) } fmt.Printf("%s\n", string(output)) return nil diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index 14890fcbf2c..0ae47a4d255 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -16,8 +16,6 @@ const DEBUG_DELAY = 250 var ( ErrDockerNotFound = errors.New("docker command not found - please install Docker") ErrDockerBuildFailed = errors.New("docker build failed") - ErrDockerPushFailed = errors.New("docker push failed") - ErrInvalidImageTag = errors.New("invalid image tag generated") ) type DeployOptions struct { @@ -70,7 +68,8 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { if opts.DockerImage == "" { if !isDockerAvailable() { ui.PrintError("Docker not found - please install Docker") - return ErrDockerNotFound + ui.PrintErrorDetails(ErrDockerNotFound.Error()) + return nil } imageTag := generateImageTag(opts, gitInfo) dockerImage = fmt.Sprintf("%s:%s", opts.Registry, imageTag) @@ -82,7 +81,8 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { ui.Print(fmt.Sprintf("Building image: %s", dockerImage)) if err := buildImage(ctx, opts, dockerImage); err != nil { ui.PrintError("Docker build failed") - return err + ui.PrintErrorDetails(err.Error()) + return nil } ui.PrintSuccess("Image built successfully") } else { @@ -107,7 +107,8 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { versionId, err := controlPlane.CreateVersion(ctx, dockerImage) if err != nil { ui.PrintError("Failed to create version") - return err + ui.PrintErrorDetails(err.Error()) + return nil } ui.PrintSuccess(fmt.Sprintf("Version created: %s", versionId)) From 52fe00ddcdfb8bf37d27431b97882ccad2f79ee2 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 15 Jul 2025 18:24:34 +0300 Subject: [PATCH 15/23] fix: code rabbit issues --- go/cmd/cli/commands/deploy/build_docker.go | 6 +++--- go/cmd/cli/commands/deploy/deploy.go | 6 ++---- go/cmd/cli/commands/deploy/ui.go | 25 ++++++++-------------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/go/cmd/cli/commands/deploy/build_docker.go b/go/cmd/cli/commands/deploy/build_docker.go index 91b1b374246..ba664c57072 100644 --- a/go/cmd/cli/commands/deploy/build_docker.go +++ b/go/cmd/cli/commands/deploy/build_docker.go @@ -57,13 +57,13 @@ func classifyPushError(output, registry string) string { switch { case strings.Contains(output, "denied"): - return fmt.Sprintf("registry access denied. Try: docker login %s", registryHost) + return fmt.Sprintf("registry access denied. try: docker login %s", registryHost) case strings.Contains(output, "not found") || strings.Contains(output, "404"): - return "registry not found. Create repository or use --registry=your-registry/your-app" + return "registry not found. create repository or use --registry=your-registry/your-app" case strings.Contains(output, "unauthorized"): - return fmt.Sprintf("authentication required. Run: docker login %s", registryHost) + return fmt.Sprintf("authentication required. run: docker login %s", registryHost) default: return output diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index 0ae47a4d255..23b02463f3c 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -65,6 +65,7 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { ui.Print("Preparing deployment") var dockerImage string + if opts.DockerImage == "" { if !isDockerAvailable() { ui.PrintError("Docker not found - please install Docker") @@ -73,11 +74,7 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { } imageTag := generateImageTag(opts, gitInfo) dockerImage = fmt.Sprintf("%s:%s", opts.Registry, imageTag) - } else { - dockerImage = opts.DockerImage - } - if opts.DockerImage == "" { ui.Print(fmt.Sprintf("Building image: %s", dockerImage)) if err := buildImage(ctx, opts, dockerImage); err != nil { ui.PrintError("Docker build failed") @@ -86,6 +83,7 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { } ui.PrintSuccess("Image built successfully") } else { + dockerImage = opts.DockerImage ui.Print("Using pre-built Docker image") } diff --git a/go/cmd/cli/commands/deploy/ui.go b/go/cmd/cli/commands/deploy/ui.go index 98d7570dc47..f34af6d4f18 100644 --- a/go/cmd/cli/commands/deploy/ui.go +++ b/go/cmd/cli/commands/deploy/ui.go @@ -19,13 +19,10 @@ var spinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", " type UI struct { mu sync.Mutex spinning bool - done chan struct{} } func NewUI() *UI { - return &UI{ - done: make(chan struct{}), - } + return &UI{} } func (ui *UI) Print(message string) { @@ -76,20 +73,16 @@ func (ui *UI) StartSpinner(message string) { go func() { frame := 0 for { - select { - case <-ui.done: - return - default: - ui.mu.Lock() - if !ui.spinning { - ui.mu.Unlock() - return - } - fmt.Printf("\r%s %s", spinnerChars[frame%len(spinnerChars)], message) + ui.mu.Lock() + if !ui.spinning { ui.mu.Unlock() - frame++ - time.Sleep(100 * time.Millisecond) + return } + fmt.Printf("\r%s %s", spinnerChars[frame%len(spinnerChars)], message) + ui.mu.Unlock() + + frame++ + time.Sleep(100 * time.Millisecond) } }() } From f80dd823b22f8f23afce83900ec87f950f91b8c4 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 16 Jul 2025 11:18:46 +0300 Subject: [PATCH 16/23] feat: add proper flag parsing logic --- go/cmd/cli/app/cli.go | 118 ------------- go/cmd/cli/cli/command.go | 90 ++++++++++ go/cmd/cli/cli/flag.go | 205 +++++++++++++++++++++++ go/cmd/cli/cli/help.go | 152 +++++++++++++++++ go/cmd/cli/cli/parser.go | 177 +++++++++++++++++++ go/cmd/cli/commands/deploy/deploy.go | 77 ++++++++- go/cmd/cli/commands/deploy/flags.go | 52 ------ go/cmd/cli/commands/deploy/help.go | 57 ------- go/cmd/cli/commands/versions.go | 142 ---------------- go/cmd/cli/commands/versions/versions.go | 164 ++++++++++++++++++ go/cmd/cli/main.go | 23 ++- 11 files changed, 871 insertions(+), 386 deletions(-) delete mode 100644 go/cmd/cli/app/cli.go create mode 100644 go/cmd/cli/cli/command.go create mode 100644 go/cmd/cli/cli/flag.go create mode 100644 go/cmd/cli/cli/help.go create mode 100644 go/cmd/cli/cli/parser.go delete mode 100644 go/cmd/cli/commands/deploy/flags.go delete mode 100644 go/cmd/cli/commands/deploy/help.go delete mode 100644 go/cmd/cli/commands/versions.go create mode 100644 go/cmd/cli/commands/versions/versions.go diff --git a/go/cmd/cli/app/cli.go b/go/cmd/cli/app/cli.go deleted file mode 100644 index df83e0918e1..00000000000 --- a/go/cmd/cli/app/cli.go +++ /dev/null @@ -1,118 +0,0 @@ -package app - -import ( - "context" - "fmt" - "os" - - "github.com/unkeyed/unkey/go/cmd/cli/commands" - "github.com/unkeyed/unkey/go/cmd/cli/commands/deploy" -) - -// CLI represents our command line interface -type CLI struct { - args []string - name string - usage string - version string - env map[string]string -} - -// New creates a new CLI instance -func New(args []string, name, usage, version string) *CLI { - env := map[string]string{ - "UNKEY_WORKSPACE_ID": os.Getenv("UNKEY_WORKSPACE_ID"), - "UNKEY_PROJECT_ID": os.Getenv("UNKEY_PROJECT_ID"), - } - - return &CLI{ - args: args, - name: name, - usage: usage, - version: version, - env: env, - } -} - -// Run executes the CLI -func (c *CLI) Run(ctx context.Context) error { - if len(c.args) < 2 { - c.PrintUsage() - return nil - } - - command := c.args[1] - switch command { - case "init": - return commands.Init(c.args[2:], c.env) - case "deploy": - return deploy.Deploy(ctx, c.args[2:], c.env) - case "version": - return commands.Version(ctx, c.args[2:], c.env) - case "help", "-h", "--help": - return c.runHelp() - case "-v", "--version": - fmt.Println(c.version) - return nil - default: - c.PrintUsage() - return fmt.Errorf("unknown command: %s", command) - } -} - -// runHelp handles the help command -func (c *CLI) runHelp() error { - if len(c.args) < 3 { - c.PrintUsage() - return nil - } - - helpTopic := c.args[2] - switch helpTopic { - case "init": - commands.PrintInitHelp() - case "deploy": - deploy.PrintDeployHelp() - case "version": - commands.PrintVersionHelp() - default: - fmt.Printf("No help available for '%s'\n", helpTopic) - c.PrintUsage() - } - return nil -} - -// PrintUsage prints general usage information -func (c *CLI) PrintUsage() { - fmt.Printf("%s - %s\n", c.name, c.usage) - fmt.Println("") - fmt.Println("USAGE:") - fmt.Printf(" %s [flags]\n", c.name) - fmt.Println("") - fmt.Println("VERSION:") - fmt.Printf(" %s\n", c.version) - fmt.Println("") - fmt.Println("COMMANDS:") - fmt.Println(" init Initialize configuration file") - fmt.Println(" deploy Deploy a new version") - fmt.Println(" version Manage API versions") - fmt.Println(" help Show help information") - fmt.Println("") - fmt.Println("FLAGS:") - fmt.Println(" -h, --help Show help") - fmt.Println(" -v, --version Show version") - fmt.Println("") - fmt.Println("ENVIRONMENT VARIABLES:") - fmt.Println(" UNKEY_WORKSPACE_ID Workspace ID (can be overridden by --workspace-id)") - fmt.Println(" UNKEY_PROJECT_ID Project ID (can be overridden by --project-id)") - fmt.Println("") - fmt.Println("EXAMPLES:") - fmt.Printf(" %s help\n", c.name) - fmt.Printf(" %s help deploy\n", c.name) - fmt.Printf(" %s init\n", c.name) - fmt.Printf(" %s deploy --workspace-id=ws_123 --project-id=proj_456\n", c.name) - fmt.Printf(" UNKEY_WORKSPACE_ID=ws_123 %s deploy\n", c.name) - fmt.Printf(" UNKEY_WORKSPACE_ID=ws_123 UNKEY_PROJECT_ID=proj_456 %s deploy\n", c.name) - fmt.Println("") - fmt.Printf("For detailed help on a command, use '%s help '\n", c.name) -} diff --git a/go/cmd/cli/cli/command.go b/go/cmd/cli/cli/command.go new file mode 100644 index 00000000000..62bb5a9aad3 --- /dev/null +++ b/go/cmd/cli/cli/command.go @@ -0,0 +1,90 @@ +package cli + +import ( + "context" + "fmt" + "os" +) + +// Action represents a command handler function that receives context and the parsed command +type Action func(context.Context, *Command) error + +// Command represents a CLI command with its configuration and runtime state +type Command struct { + // Configuration + Name string // Command name (e.g., "deploy", "version") + Usage string // Short description shown in help + Description string // Longer description for detailed help + Version string // Version string (only used for root command) + Commands []*Command // Subcommands + Flags []Flag // Available flags for this command + Action Action // Function to execute when command is run + Aliases []string // Alternative names for this command + + // Runtime state (populated during parsing) + args []string // Non-flag arguments passed to command + flagMap map[string]Flag // Map for O(1) flag lookup + parent *Command // Parent command (for building usage paths) +} + +// Args returns the non-flag arguments passed to the command +// Example: "mycli deploy myapp" -> Args() returns ["myapp"] +func (c *Command) Args() []string { + return c.args +} + +// String returns the value of a string flag by name +// Returns empty string if flag doesn't exist or isn't a StringFlag +func (c *Command) String(name string) string { + if flag, ok := c.flagMap[name]; ok { + if sf, ok := flag.(*StringFlag); ok { + return sf.Value() + } + } + return "" +} + +// Bool returns the value of a boolean flag by name +// Returns false if flag doesn't exist or isn't a BoolFlag +func (c *Command) Bool(name string) bool { + if flag, ok := c.flagMap[name]; ok { + if bf, ok := flag.(*BoolFlag); ok { + return bf.Value() + } + } + return false +} + +// Int returns the value of an integer flag by name +// Returns 0 if flag doesn't exist or isn't an IntFlag +func (c *Command) Int(name string) int { + if flag, ok := c.flagMap[name]; ok { + if inf, ok := flag.(*IntFlag); ok { + return inf.Value() + } + } + return 0 +} + +// Run executes the command with the given arguments (typically os.Args) +// This is the main entry point for CLI execution +func (c *Command) Run(ctx context.Context, args []string) error { + if len(args) == 0 { + return fmt.Errorf("no arguments provided") + } + // Initialize flag lookup map for O(1) access + c.flagMap = make(map[string]Flag) + for _, flag := range c.Flags { + c.flagMap[flag.Name()] = flag + } + // Parse arguments starting from index 1 (skip program name) + return c.parse(ctx, args[1:]) +} + +// Exit provides a clean way to exit with an error message and code +// This is a convenience function that prints the message and calls os.Exit +func Exit(message string, code int) error { + fmt.Println(message) + os.Exit(code) + return nil // unreachable but satisfies error interface +} diff --git a/go/cmd/cli/cli/flag.go b/go/cmd/cli/cli/flag.go new file mode 100644 index 00000000000..dc9fc394c16 --- /dev/null +++ b/go/cmd/cli/cli/flag.go @@ -0,0 +1,205 @@ +package cli + +import ( + "fmt" + "os" + "strconv" +) + +// Flag represents a command line flag interface +// All flag types must implement these methods +type Flag interface { + Name() string // The flag name (without dashes) + Usage() string // Help text describing the flag + Required() bool // Whether this flag is mandatory + Parse(value string) error // Parse string value into the flag's type + IsSet() bool // Whether the flag was explicitly set by user +} + +// StringFlag represents a string command line flag +type StringFlag struct { + name string // Flag name + usage string // Help description + value string // Current value + envVar string // Environment variable to check for default + required bool // Whether flag is mandatory + set bool // Whether user explicitly provided this flag +} + +// Name returns the flag name +func (f *StringFlag) Name() string { return f.name } + +// Usage returns the flag's help text +func (f *StringFlag) Usage() string { return f.usage } + +// Required returns whether this flag is mandatory +func (f *StringFlag) Required() bool { return f.required } + +// IsSet returns whether the user explicitly provided this flag +func (f *StringFlag) IsSet() bool { return f.set } + +// Parse sets the flag value from a string +func (f *StringFlag) Parse(value string) error { + f.value = value + f.set = true + return nil +} + +// Value returns the current string value +func (f *StringFlag) Value() string { return f.value } + +// EnvVar returns the environment variable name for this flag +func (f *StringFlag) EnvVar() string { return f.envVar } + +// BoolFlag represents a boolean command line flag +type BoolFlag struct { + name string // Flag name + usage string // Help description + value bool // Current value + envVar string // Environment variable to check for default + required bool // Whether flag is mandatory + set bool // Whether user explicitly provided this flag +} + +// Name returns the flag name +func (f *BoolFlag) Name() string { return f.name } + +// Usage returns the flag's help text +func (f *BoolFlag) Usage() string { return f.usage } + +// Required returns whether this flag is mandatory +func (f *BoolFlag) Required() bool { return f.required } + +// IsSet returns whether the user explicitly provided this flag +func (f *BoolFlag) IsSet() bool { return f.set } + +// Parse sets the flag value from a string +// Empty string means the flag was provided without a value (--flag), which sets it to true +// Otherwise parses as boolean: "true", "false", "1", "0", etc. +func (f *BoolFlag) Parse(value string) error { + if value == "" { + f.value = true + f.set = true + return nil + } + + parsed, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid boolean value: %s", value) + } + f.value = parsed + f.set = true + return nil +} + +// Value returns the current boolean value +func (f *BoolFlag) Value() bool { return f.value } + +// EnvVar returns the environment variable name for this flag +func (f *BoolFlag) EnvVar() string { return f.envVar } + +// IntFlag represents an integer command line flag +type IntFlag struct { + name string // Flag name + usage string // Help description + value int // Current value + envVar string // Environment variable to check for default + required bool // Whether flag is mandatory + set bool // Whether user explicitly provided this flag +} + +// Name returns the flag name +func (f *IntFlag) Name() string { return f.name } + +// Usage returns the flag's help text +func (f *IntFlag) Usage() string { return f.usage } + +// Required returns whether this flag is mandatory +func (f *IntFlag) Required() bool { return f.required } + +// IsSet returns whether the user explicitly provided this flag +func (f *IntFlag) IsSet() bool { return f.set } + +// Parse sets the flag value from a string +func (f *IntFlag) Parse(value string) error { + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid integer value: %s", value) + } + f.value = parsed + f.set = true + return nil +} + +// Value returns the current integer value +func (f *IntFlag) Value() int { return f.value } + +// EnvVar returns the environment variable name for this flag +func (f *IntFlag) EnvVar() string { return f.envVar } + +// String creates a new string flag with environment variable support +// If envVar is provided and set, it will be used as the default value +func String(name, usage, defaultValue, envVar string, required bool) *StringFlag { + flag := &StringFlag{ + name: name, + usage: usage, + value: defaultValue, + envVar: envVar, + required: required, + } + + // Check environment variable for default value + if envVar != "" { + if envValue := os.Getenv(envVar); envValue != "" { + flag.value = envValue + flag.set = true // Mark as set since env var was found + } + } + + return flag +} + +// Bool creates a new boolean flag with environment variable support +func Bool(name, usage, envVar string, required bool) *BoolFlag { + flag := &BoolFlag{ + name: name, + usage: usage, + envVar: envVar, + required: required, + } + + // Check environment variable for default value + if envVar != "" { + if envValue := os.Getenv(envVar); envValue != "" { + if parsed, err := strconv.ParseBool(envValue); err == nil { + flag.value = parsed + flag.set = true // Mark as set since env var was found + } + } + } + + return flag +} + +// Int creates a new integer flag with environment variable support +func Int(name, usage string, defaultValue int, envVar string, required bool) *IntFlag { + flag := &IntFlag{ + name: name, + usage: usage, + value: defaultValue, + envVar: envVar, + required: required, + } + + // Check environment variable for default value + if envVar != "" { + if envValue := os.Getenv(envVar); envValue != "" { + if parsed, err := strconv.Atoi(envValue); err == nil { + flag.value = parsed + flag.set = true // Mark as set since env var was found + } + } + } + + return flag +} diff --git a/go/cmd/cli/cli/help.go b/go/cmd/cli/cli/help.go new file mode 100644 index 00000000000..a74351ab423 --- /dev/null +++ b/go/cmd/cli/cli/help.go @@ -0,0 +1,152 @@ +package cli + +import ( + "fmt" + "strings" +) + +// showHelp displays comprehensive help information for the command +// This includes name, description, usage, subcommands, and flags +func (c *Command) showHelp() { + // Command name and usage description + fmt.Printf("NAME:\n %s", c.Name) + if c.Usage != "" { + fmt.Printf(" - %s", c.Usage) + } + fmt.Printf("\n\n") + + // Extended description if available + if c.Description != "" { + fmt.Printf("DESCRIPTION:\n %s\n\n", c.Description) + } + + // Build and show usage line + c.showUsageLine() + + // Show version for root command + if c.Version != "" { + fmt.Printf("VERSION:\n %s\n\n", c.Version) + } + + // Show available subcommands + if len(c.Commands) > 0 { + c.showCommands() + } + + // Show available flags/options + if len(c.Flags) > 0 { + c.showFlags() + } +} + +// showUsageLine displays the command usage syntax +func (c *Command) showUsageLine() { + fmt.Printf("USAGE:\n ") + + // Build full command path (parent commands + this command) + path := c.buildCommandPath() + fmt.Printf("%s", strings.Join(path, " ")) + + // Add syntax indicators + if len(c.Flags) > 0 { + fmt.Printf(" [options]") + } + if len(c.Commands) > 0 { + fmt.Printf(" [command]") + } + + fmt.Printf("\n\n") +} + +// buildCommandPath constructs the full command path from root to current command +func (c *Command) buildCommandPath() []string { + var path []string + + // Walk up the parent chain to build full path + cmd := c + for cmd != nil { + path = append([]string{cmd.Name}, path...) + cmd = cmd.parent + } + + return path +} + +// showCommands displays all available subcommands in a formatted table +func (c *Command) showCommands() { + fmt.Printf("COMMANDS:\n") + + // Find the longest command name for alignment + maxLen := 0 + for _, cmd := range c.Commands { + if len(cmd.Name) > maxLen { + maxLen = len(cmd.Name) + } + } + + // Display each command with aliases + for _, cmd := range c.Commands { + name := cmd.Name + if len(cmd.Aliases) > 0 { + name += fmt.Sprintf(", %s", strings.Join(cmd.Aliases, ", ")) + } + fmt.Printf(" %-*s %s\n", maxLen+10, name, cmd.Usage) + } + + // Add built-in help command + fmt.Printf(" %-*s %s\n", maxLen+10, "help, h", "Shows help for commands") + fmt.Printf("\n") +} + +// showFlags displays all available flags in a formatted table +func (c *Command) showFlags() { + fmt.Printf("OPTIONS:\n") + + for _, flag := range c.Flags { + c.showFlag(flag) + } + + // Add built-in help flag + fmt.Printf(" %-25s %s\n", "--help, -h", "show help") + fmt.Printf("\n") +} + +// showFlag displays a single flag with proper formatting +func (c *Command) showFlag(flag Flag) { + // Build flag name(s) - support both short and long forms + name := fmt.Sprintf("--%s", flag.Name()) + if len(flag.Name()) == 1 { + name = fmt.Sprintf("-%s, --%s", flag.Name(), flag.Name()) + } + + // Build usage description + usage := flag.Usage() + + // Add required indicator + if flag.Required() { + usage += " (required)" + } + + // Add environment variable info if available + envVar := c.getEnvVar(flag) + if envVar != "" { + usage += fmt.Sprintf(" [$%s]", envVar) + } + + // Display with consistent formatting + fmt.Printf(" %-25s %s\n", name, usage) +} + +// getEnvVar extracts environment variable name from flag if it supports it +func (c *Command) getEnvVar(flag Flag) string { + switch f := flag.(type) { + case *StringFlag: + return f.EnvVar() + case *BoolFlag: + return f.EnvVar() + case *IntFlag: + return f.EnvVar() + default: + return "" + } +} diff --git a/go/cmd/cli/cli/parser.go b/go/cmd/cli/cli/parser.go new file mode 100644 index 00000000000..588db247c13 --- /dev/null +++ b/go/cmd/cli/cli/parser.go @@ -0,0 +1,177 @@ +package cli + +import ( + "context" + "fmt" + "slices" + "strings" +) + +// parse processes command line arguments and executes the appropriate action +// This handles flag parsing, subcommand routing, and help display +func (c *Command) parse(ctx context.Context, args []string) error { + // Initialize flagMap if not already done + if c.flagMap == nil { + c.flagMap = make(map[string]Flag) + for _, flag := range c.Flags { + c.flagMap[flag.Name()] = flag + } + } + + var commandArgs []string + + for i := 0; i < len(args); i++ { + arg := args[i] + + // Handle help flags first - these short-circuit normal processing + if arg == "-h" || arg == "--help" || arg == "help" { + c.showHelp() + return nil + } + + // Handle "help " pattern - show help for specific subcommand + if arg == "help" && i+1 < len(args) { + cmdName := args[i+1] + for _, subcmd := range c.Commands { + if subcmd.Name == cmdName { + subcmd.parent = c + subcmd.showHelp() + return nil + } + // Check aliases + if slices.Contains(subcmd.Aliases, cmdName) { + subcmd.parent = c + subcmd.showHelp() + return nil + } + } + return fmt.Errorf("unknown command: %s", cmdName) + } + + // Check for subcommands (non-flag arguments) + if !strings.HasPrefix(arg, "-") { + // Look for matching subcommand + for _, subcmd := range c.Commands { + if subcmd.Name == arg { + subcmd.parent = c + return subcmd.parse(ctx, args[i+1:]) + } + // Check aliases + if slices.Contains(subcmd.Aliases, arg) { + subcmd.parent = c + return subcmd.parse(ctx, args[i+1:]) + } + } + // Not a subcommand, treat as regular argument + commandArgs = append(commandArgs, arg) + continue + } + + // Parse flags (arguments starting with -) + if err := c.parseFlag(args, &i); err != nil { + return err + } + } + + // Store parsed arguments + c.args = commandArgs + + // If no arguments were provided and this command has required flags, show help + if len(args) == 0 && c.hasRequiredFlags() { + c.showHelp() + return nil + } + + // Validate all required flags are present + if err := c.validateRequiredFlags(); err != nil { + c.showHelp() + return err + } + + // Execute action if present + if c.Action != nil { + return c.Action(ctx, c) + } + + // No action defined - show help if we have subcommands + if len(c.Commands) > 0 { + c.showHelp() + } + + return nil +} + +// Helper method to check if command has required flags +func (c *Command) hasRequiredFlags() bool { + for _, flag := range c.Flags { + if flag.Required() { + return true + } + } + return false +} + +// parseFlag handles parsing of a single flag and its value +// It modifies the index i to skip consumed arguments +func (c *Command) parseFlag(args []string, i *int) error { + arg := args[*i] + + // Remove leading dashes properly + var flagName string + if strings.HasPrefix(arg, "--") { + flagName = arg[2:] // Remove exactly "--" + } else if strings.HasPrefix(arg, "-") { + flagName = arg[1:] // Remove exactly "-" + } else { + return fmt.Errorf("invalid flag format: %s", arg) + } + + // Handle --flag=value format + var flagValue string + var hasValue bool + if eqIndex := strings.Index(flagName, "="); eqIndex != -1 { + flagValue = flagName[eqIndex+1:] + flagName = flagName[:eqIndex] + hasValue = true + } + + // Look up the flag + flag, exists := c.flagMap[flagName] + if !exists { + return fmt.Errorf("unknown flag: %s", flagName) + } + + // Handle boolean flags specially - they don't require values + if bf, ok := flag.(*BoolFlag); ok { + if hasValue { + // --bool-flag=true/false format + return bf.Parse(flagValue) + } else { + // --bool-flag format (implies true) + return bf.Parse("") + } + } + + // For non-boolean flags, we need a value + if !hasValue { + // Value should be in next argument + if *i+1 >= len(args) { + return fmt.Errorf("flag %s requires a value", flagName) + } + *i++ // Move to next argument + flagValue = args[*i] + } + + // Parse the flag value + return flag.Parse(flagValue) +} + +// validateRequiredFlags checks that all required flags have been set +func (c *Command) validateRequiredFlags() error { + for _, flag := range c.Flags { + if flag.Required() && !flag.IsSet() { + return fmt.Errorf("required flag missing: %s", flag.Name()) + } + } + return nil +} diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index 23b02463f3c..51c12426b4f 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/unkeyed/unkey/go/cmd/cli/cli" ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" "github.com/unkeyed/unkey/go/pkg/git" "github.com/unkeyed/unkey/go/pkg/otel/logging" @@ -18,6 +19,7 @@ var ( ErrDockerBuildFailed = errors.New("docker build failed") ) +// DeployOptions contains all configuration for deployment type DeployOptions struct { WorkspaceID string ProjectID string @@ -32,15 +34,74 @@ type DeployOptions struct { AuthToken string } -func Deploy(ctx context.Context, args []string, env map[string]string) error { - if len(args) < 1 { - PrintDeployHelp() - return fmt.Errorf("deploy command requires arguments") - } +var DeployFlags = []cli.Flag{ + // Required flags + cli.String("workspace-id", "Workspace ID", "", "UNKEY_WORKSPACE_ID", true), + cli.String("project-id", "Project ID", "", "UNKEY_PROJECT_ID", true), + + // Optional flags with defaults + cli.String("context", "Docker context path", ".", "", false), + cli.String("branch", "Git branch", "main", "", false), + cli.String("docker-image", "Pre-built docker image", "", "", false), + cli.String("dockerfile", "Path to Dockerfile", "Dockerfile", "", false), + cli.String("commit", "Git commit SHA", "", "", false), + cli.String("registry", "Docker registry", "ghcr.io/unkeyed/deploy", "UNKEY_DOCKER_REGISTRY", false), + cli.Bool("skip-push", "Skip pushing to registry (for local testing)", "", false), + + // Control plane flags (internal) + cli.String("control-plane-url", "Control plane URL", "http://localhost:7091", "", false), + cli.String("auth-token", "Control plane auth token", "ctrl-secret-token", "", false), +} - opts, err := parseDeployFlags(args, env) - if err != nil { - return err +// Command defines the deploy CLI command +var Command = &cli.Command{ + Name: "deploy", + Usage: "Deploy a new version", + Description: `Build and deploy a new version of your application. +Builds a Docker image from the specified context and +deploys it to the Unkey platform. + +EXAMPLES: + # Basic deployment + unkey deploy \ + --workspace-id=ws_4QgQsKsKfdm3nGeC \ + --project-id=proj_9aiaks2dzl6mcywnxjf \ + --context=./demo_api + + # Deploy with your own registry + unkey deploy \ + --workspace-id=ws_4QgQsKsKfdm3nGeC \ + --project-id=proj_9aiaks2dzl6mcywnxjf \ + --registry=docker.io/mycompany/myapp + + # Local development (skip push) + unkey deploy \ + --workspace-id=ws_4QgQsKsKfdm3nGeC \ + --project-id=proj_9aiaks2dzl6mcywnxjf \ + --skip-push + + # Deploy pre-built image + unkey deploy \ + --workspace-id=ws_4QgQsKsKfdm3nGeC \ + --project-id=proj_9aiaks2dzl6mcywnxjf \ + --docker-image=ghcr.io/user/app:v1.0.0`, + Flags: DeployFlags, + Action: DeployAction, +} + +func DeployAction(ctx context.Context, cmd *cli.Command) error { + opts := &DeployOptions{ + WorkspaceID: cmd.String("workspace-id"), + ProjectID: cmd.String("project-id"), + Context: cmd.String("context"), + Branch: cmd.String("branch"), + DockerImage: cmd.String("docker-image"), + Dockerfile: cmd.String("dockerfile"), + Commit: cmd.String("commit"), + Registry: cmd.String("registry"), + SkipPush: cmd.Bool("skip-push"), + ControlPlaneURL: cmd.String("control-plane-url"), + AuthToken: cmd.String("auth-token"), } return executeDeploy(ctx, opts) diff --git a/go/cmd/cli/commands/deploy/flags.go b/go/cmd/cli/commands/deploy/flags.go deleted file mode 100644 index 57e29d641f4..00000000000 --- a/go/cmd/cli/commands/deploy/flags.go +++ /dev/null @@ -1,52 +0,0 @@ -package deploy - -import ( - "flag" - "fmt" -) - -var commandName = "deploy" - -// parseDeployFlags parses flags for deploy/version create commands -func parseDeployFlags(args []string, env map[string]string) (*DeployOptions, error) { - fs := flag.NewFlagSet(commandName, flag.ExitOnError) - opts := &DeployOptions{} - - defaultWorkspaceID := env["UNKEY_WORKSPACE_ID"] - defaultProjectID := env["UNKEY_PROJECT_ID"] - defaultRegistry := env["UNKEY_DOCKER_REGISTRY"] - if defaultRegistry == "" { - defaultRegistry = "ghcr.io/unkeyed/deploy" - } - - // Required flags - fs.StringVar(&opts.WorkspaceID, "workspace-id", defaultWorkspaceID, "Workspace ID (required)") - fs.StringVar(&opts.ProjectID, "project-id", defaultProjectID, "Project ID (required)") - - // Optional flags with defaults - fs.StringVar(&opts.Context, "context", ".", "Docker context path") - fs.StringVar(&opts.Branch, "branch", "main", "Git branch") - fs.StringVar(&opts.DockerImage, "docker-image", "", "Pre-built docker image") - fs.StringVar(&opts.Dockerfile, "dockerfile", "Dockerfile", "Path to Dockerfile") - fs.StringVar(&opts.Commit, "commit", "", "Git commit SHA") - fs.StringVar(&opts.Registry, "registry", defaultRegistry, "Docker registry") - fs.BoolVar(&opts.SkipPush, "skip-push", false, "Skip pushing to registry (for local testing)") - - // Control plane flags (internal) - fs.StringVar(&opts.ControlPlaneURL, "control-plane-url", "http://localhost:7091", "Control plane URL") - fs.StringVar(&opts.AuthToken, "auth-token", "ctrl-secret-token", "Control plane auth token") - - if err := fs.Parse(args); err != nil { - return nil, fmt.Errorf("failed to parse %s flags: %w", commandName, err) - } - - // Validate required fields - if opts.WorkspaceID == "" { - return nil, fmt.Errorf("--workspace-id is required (or set UNKEY_WORKSPACE_ID)") - } - if opts.ProjectID == "" { - return nil, fmt.Errorf("--project-id is required (or set UNKEY_PROJECT_ID)") - } - - return opts, nil -} diff --git a/go/cmd/cli/commands/deploy/help.go b/go/cmd/cli/commands/deploy/help.go deleted file mode 100644 index b5671920401..00000000000 --- a/go/cmd/cli/commands/deploy/help.go +++ /dev/null @@ -1,57 +0,0 @@ -package deploy - -import "fmt" - -func PrintDeployHelp() { - fmt.Println("unkey deploy - Deploy a new version") - fmt.Println("") - fmt.Println("USAGE:") - fmt.Println(" unkey deploy [FLAGS]") - fmt.Println("") - fmt.Println("DESCRIPTION:") - fmt.Println(" Build and deploy a new version of your application.") - fmt.Println(" Builds a Docker image from the specified context and") - fmt.Println(" deploys it to the Unkey platform.") - fmt.Println("") - fmt.Println("REQUIRED FLAGS:") - fmt.Println(" --workspace-id Workspace ID") - fmt.Println(" --project-id Project ID") - fmt.Println("") - fmt.Println("OPTIONAL FLAGS:") - fmt.Println(" --context Docker context path (default: .)") - fmt.Println(" --branch Git branch (default: main)") - fmt.Println(" --docker-image Pre-built docker image") - fmt.Println(" --dockerfile Path to Dockerfile (default: Dockerfile)") - fmt.Println(" --commit Git commit SHA") - fmt.Println(" --registry Docker registry (default: ghcr.io/unkeyed/deploy)") - fmt.Println(" --skip-push Skip pushing to registry") - fmt.Println("") - fmt.Println("ENVIRONMENT VARIABLES:") - fmt.Println(" UNKEY_WORKSPACE_ID Default workspace ID") - fmt.Println(" UNKEY_PROJECT_ID Default project ID") - fmt.Println("") - fmt.Println("EXAMPLES:") - fmt.Println(" # Basic deployment") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --context=./demo_api") - fmt.Println("") - fmt.Println(" # Deploy with your own registry") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --registry=docker.io/mycompany/myapp") - fmt.Println("") - fmt.Println(" # Local development (skip push)") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --skip-push") - fmt.Println("") - fmt.Println(" # Deploy pre-built image") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --docker-image=ghcr.io/user/app:v1.0.0") -} diff --git a/go/cmd/cli/commands/versions.go b/go/cmd/cli/commands/versions.go deleted file mode 100644 index 40a3981f10e..00000000000 --- a/go/cmd/cli/commands/versions.go +++ /dev/null @@ -1,142 +0,0 @@ -package commands - -import ( - "context" - "flag" - "fmt" - - "github.com/unkeyed/unkey/go/cmd/cli/commands/deploy" -) - -// VersionListOptions holds options for version list command -type VersionListOptions struct { - Branch string - Status string - Limit int -} - -// Version handles the version command and its subcommands -func Version(ctx context.Context, args []string, env map[string]string) error { - if len(args) < 1 { - PrintVersionCommandHelp() - return fmt.Errorf("version command requires a subcommand") - } - - subcommand := args[0] - switch subcommand { - case "create": - return VersionCreate(ctx, args[1:], env) - case "list": - return VersionList(args[1:]) - case "get": - return VersionGet(args[1:]) - default: - PrintVersionCommandHelp() - return fmt.Errorf("unknown version subcommand: %s", subcommand) - } -} - -// VersionCreate handles version create (same as deploy) -func VersionCreate(ctx context.Context, args []string, env map[string]string) error { - return deploy.Deploy(ctx, args, env) -} - -// VersionList handles version list command -func VersionList(args []string) error { - opts, err := parseVersionListFlags(args) - if err != nil { - return err - } - - fmt.Printf("Listing versions (branch=%s, status=%s, limit=%d)\n", - opts.Branch, opts.Status, opts.Limit) - - // TODO: Add actual version listing logic - fmt.Println("ID STATUS BRANCH CREATED") - fmt.Println("v_abc123def456 ACTIVE main 2024-01-01 12:00:00") - fmt.Println("v_def456ghi789 ACTIVE feature 2024-01-01 11:00:00") - - return nil -} - -// parseVersionListFlags parses flags for version list command -func parseVersionListFlags(args []string) (*VersionListOptions, error) { - fs := flag.NewFlagSet("version list", flag.ExitOnError) - - opts := &VersionListOptions{} - - fs.StringVar(&opts.Branch, "branch", "", "Filter by branch") - fs.StringVar(&opts.Status, "status", "", "Filter by status") - fs.IntVar(&opts.Limit, "limit", 10, "Number of versions to show") - - if err := fs.Parse(args); err != nil { - return nil, fmt.Errorf("failed to parse version list flags: %w", err) - } - - return opts, nil -} - -// VersionGet handles version get command -func VersionGet(args []string) error { - if len(args) == 0 { - return fmt.Errorf("version get requires a version ID") - } - - versionID := args[0] - fmt.Printf("Getting version: %s\n", versionID) - - // TODO: Add actual version get logic - fmt.Printf("Version: %s\n", versionID) - fmt.Println("Status: ACTIVE") - fmt.Println("Branch: main") - fmt.Println("Created: 2024-01-01 12:00:00") - - return nil -} - -// PrintVersionCommandHelp shows help for version subcommands -func PrintVersionCommandHelp() { - fmt.Println("'version' requires a subcommand.") - fmt.Println("") - fmt.Println("Valid subcommands for 'version':") - fmt.Println(" create Create a new version") - fmt.Println(" list List versions") - fmt.Println(" get Get version details") - fmt.Println("") - fmt.Println("For detailed help: unkey help version") -} - -// PrintVersionHelp prints detailed help for version command -func PrintVersionHelp() { - fmt.Println("unkey version - Manage API versions") - fmt.Println("") - fmt.Println("USAGE:") - fmt.Println(" unkey version [FLAGS]") - fmt.Println("") - fmt.Println("SUBCOMMANDS:") - fmt.Println(" create Create a new version (same as deploy)") - fmt.Println(" list List versions") - fmt.Println(" get Get version details") - fmt.Println("") - fmt.Println("VERSION CREATE:") - fmt.Println(" Same as 'unkey deploy'. See 'unkey help deploy' for details.") - fmt.Println("") - fmt.Println("VERSION LIST FLAGS:") - fmt.Println(" --branch Filter by branch") - fmt.Println(" --status Filter by status (pending, building, active, failed)") - fmt.Println(" --limit Number of versions to show (default: 10)") - fmt.Println("") - fmt.Println("VERSION GET:") - fmt.Println(" unkey version get ") - fmt.Println("") - fmt.Println("EXAMPLES:") - fmt.Println(" # Create new version") - fmt.Println(" unkey version create --workspace-id=ws_123 --project-id=proj_456") - fmt.Println("") - fmt.Println(" # List versions") - fmt.Println(" unkey version list") - fmt.Println(" unkey version list --branch=main --limit=20") - fmt.Println("") - fmt.Println(" # Get specific version") - fmt.Println(" unkey version get v_abc123def456") -} diff --git a/go/cmd/cli/commands/versions/versions.go b/go/cmd/cli/commands/versions/versions.go new file mode 100644 index 00000000000..643d1c35249 --- /dev/null +++ b/go/cmd/cli/commands/versions/versions.go @@ -0,0 +1,164 @@ +package versions + +import ( + "context" + "fmt" + + "github.com/unkeyed/unkey/go/cmd/cli/cli" + "github.com/unkeyed/unkey/go/cmd/cli/commands/deploy" +) + +// VersionListOptions holds options for version list command +type VersionListOptions struct { + Branch string + Status string + Limit int +} + +// Command defines the version CLI command with subcommands +var Command = &cli.Command{ + Name: "version", + Usage: "Manage API versions", + Description: `Create, list, and manage versions of your API. + +Versions are immutable snapshots of your code, configuration, and infrastructure settings. + +EXAMPLES: + # Create new version + unkey version create --workspace-id=ws_123 --project-id=proj_456 + + # List versions + unkey version list + unkey version list --branch=main --limit=20 + + # Get specific version + unkey version get v_abc123def456`, + Commands: []*cli.Command{ + createCmd, + listCmd, + getCmd, + }, +} + +// createCmd handles version create (alias for deploy) +var createCmd = &cli.Command{ + Name: "create", + Aliases: []string{"deploy"}, + Usage: "Create a new version (same as deploy)", + Description: "Same as 'unkey deploy'. See 'unkey help deploy' for details.", + Flags: deploy.DeployFlags, + Action: deploy.DeployAction, +} + +// listCmd handles version listing +var listCmd = &cli.Command{ + Name: "list", + Usage: "List versions", + Description: `List all versions with optional filtering. + +EXAMPLES: + # List all versions + unkey version list + + # Filter by branch + unkey version list --branch=main + + # Filter by status and limit results + unkey version list --status=active --limit=5`, + Flags: []cli.Flag{ + cli.String("branch", "Filter by branch", "", "", false), + cli.String("status", "Filter by status (pending, building, active, failed)", "", "", false), + cli.Int("limit", "Number of versions to show", 10, "", false), + }, + Action: listAction, +} + +// getCmd handles getting version details +var getCmd = &cli.Command{ + Name: "get", + Usage: "Get version details", + Description: `Get detailed information about a specific version. + +USAGE: + unkey version get + +EXAMPLES: + unkey version get v_abc123def456`, + Action: getAction, +} + +// listAction handles the version list command execution +func listAction(ctx context.Context, cmd *cli.Command) error { + opts := &VersionListOptions{ + Branch: cmd.String("branch"), + Status: cmd.String("status"), + Limit: cmd.Int("limit"), + } + + // Display filter info if provided + filters := []string{} + if opts.Branch != "" { + filters = append(filters, fmt.Sprintf("branch=%s", opts.Branch)) + } + if opts.Status != "" { + filters = append(filters, fmt.Sprintf("status=%s", opts.Status)) + } + filters = append(filters, fmt.Sprintf("limit=%d", opts.Limit)) + + if len(filters) > 1 { + fmt.Printf("Listing versions (%s)\n", fmt.Sprintf("%v", filters)) + } else { + fmt.Printf("Listing versions (limit=%d)\n", opts.Limit) + } + fmt.Println() + + // TODO: Add actual version listing logic here + // This would typically: + // 1. Call control plane API with filters + // 2. Parse response + // 3. Format and display results + + // Mock data for demonstration + fmt.Println("ID STATUS BRANCH CREATED") + fmt.Println("v_abc123def456 ACTIVE main 2024-01-01 12:00:00") + if opts.Branch == "" || opts.Branch == "feature" { + fmt.Println("v_def456ghi789 ACTIVE feature 2024-01-01 11:00:00") + } + if opts.Status == "" || opts.Status == "failed" { + fmt.Println("v_ghi789jkl012 FAILED main 2024-01-01 10:00:00") + } + + return nil +} + +// getAction handles the version get command execution +func getAction(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args() + if len(args) == 0 { + return cli.Exit("version get requires a version ID", 1) + } + + versionID := args[0] + fmt.Printf("Getting version: %s\n", versionID) + fmt.Println() + + // TODO: Add actual version get logic here + // This would typically: + // 1. Call control plane API with version ID + // 2. Parse response + // 3. Display detailed information + + // Mock data for demonstration + fmt.Printf("Version: %s\n", versionID) + fmt.Printf("Status: ACTIVE\n") + fmt.Printf("Branch: main\n") + fmt.Printf("Created: 2024-01-01 12:00:00\n") + fmt.Printf("Docker Image: ghcr.io/unkeyed/deploy:main-abc123\n") + fmt.Printf("Commit: abc123def456789\n") + fmt.Println() + fmt.Printf("Hostnames:\n") + fmt.Printf(" - https://main-abc123-workspace.unkey.app\n") + fmt.Printf(" - https://api.acme.com\n") + + return nil +} diff --git a/go/cmd/cli/main.go b/go/cmd/cli/main.go index abb39b2a593..ca102acebeb 100644 --- a/go/cmd/cli/main.go +++ b/go/cmd/cli/main.go @@ -5,19 +5,24 @@ import ( "fmt" "os" - "github.com/unkeyed/unkey/go/cmd/cli/app" + "github.com/unkeyed/unkey/go/cmd/cli/cli" + "github.com/unkeyed/unkey/go/cmd/cli/commands/deploy" + "github.com/unkeyed/unkey/go/cmd/cli/commands/versions" "github.com/unkeyed/unkey/go/pkg/version" ) func main() { - c := app.New( - os.Args, - "unkey", - "Deploy and manage your API versions", - version.Version, - ) - ctx := context.Background() - if err := c.Run(ctx); err != nil { + app := &cli.Command{ + Name: "unkey", + Usage: "Deploy and manage your API versions", + Version: version.Version, + Commands: []*cli.Command{ + deploy.Command, + versions.Command, + }, + } + + if err := app.Run(context.Background(), os.Args); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } From 496a99468abf0acc5598ff8f21ef521ed6ca7caf Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 16 Jul 2025 11:53:42 +0300 Subject: [PATCH 17/23] refactor: show help if required args are missing --- go/cmd/cli/cli/help.go | 33 ++++++++++++++++----------------- go/cmd/cli/cli/parser.go | 24 +++++++----------------- 2 files changed, 23 insertions(+), 34 deletions(-) diff --git a/go/cmd/cli/cli/help.go b/go/cmd/cli/cli/help.go index a74351ab423..d391e3baf85 100644 --- a/go/cmd/cli/cli/help.go +++ b/go/cmd/cli/cli/help.go @@ -33,10 +33,24 @@ func (c *Command) showHelp() { c.showCommands() } - // Show available flags/options + // Show command-specific flags if any exist if len(c.Flags) > 0 { - c.showFlags() + fmt.Printf("OPTIONS:\n") + for _, flag := range c.Flags { + c.showFlag(flag) + } + fmt.Printf("\n") + } + + // Always show global options + fmt.Printf("GLOBAL OPTIONS:\n") + fmt.Printf(" %-25s %s\n", "--help, -h", "show help") + + // Add version flag only for root command (commands with Version set) + if c.Version != "" { + fmt.Printf(" %-25s %s\n", "--version, -v", "print the version") } + fmt.Printf("\n") } // showUsageLine displays the command usage syntax @@ -54,7 +68,6 @@ func (c *Command) showUsageLine() { if len(c.Commands) > 0 { fmt.Printf(" [command]") } - fmt.Printf("\n\n") } @@ -68,7 +81,6 @@ func (c *Command) buildCommandPath() []string { path = append([]string{cmd.Name}, path...) cmd = cmd.parent } - return path } @@ -98,19 +110,6 @@ func (c *Command) showCommands() { fmt.Printf("\n") } -// showFlags displays all available flags in a formatted table -func (c *Command) showFlags() { - fmt.Printf("OPTIONS:\n") - - for _, flag := range c.Flags { - c.showFlag(flag) - } - - // Add built-in help flag - fmt.Printf(" %-25s %s\n", "--help, -h", "show help") - fmt.Printf("\n") -} - // showFlag displays a single flag with proper formatting func (c *Command) showFlag(flag Flag) { // Build flag name(s) - support both short and long forms diff --git a/go/cmd/cli/cli/parser.go b/go/cmd/cli/cli/parser.go index 588db247c13..845fb45a2ad 100644 --- a/go/cmd/cli/cli/parser.go +++ b/go/cmd/cli/cli/parser.go @@ -19,7 +19,6 @@ func (c *Command) parse(ctx context.Context, args []string) error { } var commandArgs []string - for i := 0; i < len(args); i++ { arg := args[i] @@ -29,6 +28,12 @@ func (c *Command) parse(ctx context.Context, args []string) error { return nil } + // Handle version flags - print version and exit + if (arg == "-v" || arg == "--version") && c.Version != "" { + fmt.Println(c.Version) + return nil + } + // Handle "help " pattern - show help for specific subcommand if arg == "help" && i+1 < len(args) { cmdName := args[i+1] @@ -76,14 +81,9 @@ func (c *Command) parse(ctx context.Context, args []string) error { // Store parsed arguments c.args = commandArgs - // If no arguments were provided and this command has required flags, show help - if len(args) == 0 && c.hasRequiredFlags() { - c.showHelp() - return nil - } - // Validate all required flags are present if err := c.validateRequiredFlags(); err != nil { + fmt.Printf("Error: %v\n\n", err) c.showHelp() return err } @@ -101,16 +101,6 @@ func (c *Command) parse(ctx context.Context, args []string) error { return nil } -// Helper method to check if command has required flags -func (c *Command) hasRequiredFlags() bool { - for _, flag := range c.Flags { - if flag.Required() { - return true - } - } - return false -} - // parseFlag handles parsing of a single flag and its value // It modifies the index i to skip consumed arguments func (c *Command) parseFlag(args []string, i *int) error { From b8f964f0bdd4b96f5875bf43a6f7a3a93514f4ff Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 16 Jul 2025 12:10:36 +0300 Subject: [PATCH 18/23] feat: add missing commands --- go/cmd/cli/cli/command.go | 22 ++ go/cmd/cli/cli/flag.go | 145 +++++++++++ go/cmd/cli/cli/help.go | 4 + .../cli/commands/healthcheck/healthcheck.go | 47 ++++ go/cmd/cli/commands/init.go | 38 --- go/cmd/cli/commands/init/init.go | 69 ++++++ go/cmd/cli/commands/quotacheck/quotacheck.go | 230 +++++++++++++++++ go/cmd/cli/commands/run/run.go | 232 ++++++++++++++++++ go/cmd/cli/main.go | 8 + 9 files changed, 757 insertions(+), 38 deletions(-) create mode 100644 go/cmd/cli/commands/healthcheck/healthcheck.go delete mode 100644 go/cmd/cli/commands/init.go create mode 100644 go/cmd/cli/commands/init/init.go create mode 100644 go/cmd/cli/commands/quotacheck/quotacheck.go create mode 100644 go/cmd/cli/commands/run/run.go diff --git a/go/cmd/cli/cli/command.go b/go/cmd/cli/cli/command.go index 62bb5a9aad3..698faecb43c 100644 --- a/go/cmd/cli/cli/command.go +++ b/go/cmd/cli/cli/command.go @@ -66,6 +66,28 @@ func (c *Command) Int(name string) int { return 0 } +// Float returns the value of a float flag by name +// Returns 0.0 if flag doesn't exist or isn't a FloatFlag +func (c *Command) Float(name string) float64 { + if flag, ok := c.flagMap[name]; ok { + if ff, ok := flag.(*FloatFlag); ok { + return ff.Value() + } + } + return 0.0 +} + +// StringSlice returns the value of a string slice flag by name +// Returns empty slice if flag doesn't exist or isn't a StringSliceFlag +func (c *Command) StringSlice(name string) []string { + if flag, ok := c.flagMap[name]; ok { + if ssf, ok := flag.(*StringSliceFlag); ok { + return ssf.Value() + } + } + return []string{} +} + // Run executes the command with the given arguments (typically os.Args) // This is the main entry point for CLI execution func (c *Command) Run(ctx context.Context, args []string) error { diff --git a/go/cmd/cli/cli/flag.go b/go/cmd/cli/cli/flag.go index dc9fc394c16..83de44612b6 100644 --- a/go/cmd/cli/cli/flag.go +++ b/go/cmd/cli/cli/flag.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "strings" ) // Flag represents a command line flag interface @@ -203,3 +204,147 @@ func Int(name, usage string, defaultValue int, envVar string, required bool) *In return flag } + +// FloatFlag represents a float64 command line flag +type FloatFlag struct { + name string // Flag name + usage string // Help description + value float64 // Current value + envVar string // Environment variable to check for default + required bool // Whether flag is mandatory + set bool // Whether user explicitly provided this flag +} + +// Name returns the flag name +func (f *FloatFlag) Name() string { return f.name } + +// Usage returns the flag's help text +func (f *FloatFlag) Usage() string { return f.usage } + +// Required returns whether this flag is mandatory +func (f *FloatFlag) Required() bool { return f.required } + +// IsSet returns whether the user explicitly provided this flag +func (f *FloatFlag) IsSet() bool { return f.set } + +// Parse sets the flag value from a string +func (f *FloatFlag) Parse(value string) error { + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid float value: %s", value) + } + f.value = parsed + f.set = true + return nil +} + +// Value returns the current float64 value +func (f *FloatFlag) Value() float64 { return f.value } + +// EnvVar returns the environment variable name for this flag +func (f *FloatFlag) EnvVar() string { return f.envVar } + +// StringSliceFlag represents a string slice command line flag +type StringSliceFlag struct { + name string // Flag name + usage string // Help description + value []string // Current value + envVar string // Environment variable to check for default + required bool // Whether flag is mandatory + set bool // Whether user explicitly provided this flag +} + +// Name returns the flag name +func (f *StringSliceFlag) Name() string { return f.name } + +// Usage returns the flag's help text +func (f *StringSliceFlag) Usage() string { return f.usage } + +// Required returns whether this flag is mandatory +func (f *StringSliceFlag) Required() bool { return f.required } + +// IsSet returns whether the user explicitly provided this flag +func (f *StringSliceFlag) IsSet() bool { return f.set } + +// Parse sets the flag value from a string (comma-separated values) +func (f *StringSliceFlag) Parse(value string) error { + if value == "" { + f.value = []string{} + } else { + // Split by comma and trim whitespace + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + f.value = result + } + f.set = true + return nil +} + +// Value returns the current string slice value +func (f *StringSliceFlag) Value() []string { return f.value } + +// EnvVar returns the environment variable name for this flag +func (f *StringSliceFlag) EnvVar() string { return f.envVar } + +// Float creates a new float flag with environment variable support +func Float(name, usage string, defaultValue float64, envVar string, required bool) *FloatFlag { + flag := &FloatFlag{ + name: name, + usage: usage, + value: defaultValue, + envVar: envVar, + required: required, + } + + // Check environment variable for default value + if envVar != "" { + if envValue := os.Getenv(envVar); envValue != "" { + if parsed, err := strconv.ParseFloat(envValue, 64); err == nil { + flag.value = parsed + flag.set = true // Mark as set since env var was found + } + } + } + + return flag +} + +// StringSlice creates a new string slice flag with environment variable support +func StringSlice(name, usage string, defaultValue []string, envVar string, required bool) *StringSliceFlag { + flag := &StringSliceFlag{ + name: name, + usage: usage, + value: defaultValue, + envVar: envVar, + required: required, + } + + // Check environment variable for default value + if envVar != "" { + if envValue := os.Getenv(envVar); envValue != "" { + // Parse comma-separated values from environment + if envValue == "" { + flag.value = []string{} + } else { + parts := strings.Split(envValue, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + flag.value = result + } + flag.set = true // Mark as set since env var was found + } + } + + return flag +} diff --git a/go/cmd/cli/cli/help.go b/go/cmd/cli/cli/help.go index d391e3baf85..34483e69d05 100644 --- a/go/cmd/cli/cli/help.go +++ b/go/cmd/cli/cli/help.go @@ -145,6 +145,10 @@ func (c *Command) getEnvVar(flag Flag) string { return f.EnvVar() case *IntFlag: return f.EnvVar() + case *FloatFlag: + return f.EnvVar() + case *StringSliceFlag: + return f.EnvVar() default: return "" } diff --git a/go/cmd/cli/commands/healthcheck/healthcheck.go b/go/cmd/cli/commands/healthcheck/healthcheck.go new file mode 100644 index 00000000000..b381cf1a8a8 --- /dev/null +++ b/go/cmd/cli/commands/healthcheck/healthcheck.go @@ -0,0 +1,47 @@ +package healthcheck + +import ( + "context" + "fmt" + "net/http" + + "github.com/unkeyed/unkey/go/cmd/cli/cli" +) + +var Command = &cli.Command{ + Name: "healthcheck", + Usage: "Perform HTTP healthcheck against a URL", + Description: `Perform an HTTP healthcheck against a given URL. +This command exits with 0 if the status code is 200, otherwise it exits with 1. + +EXAMPLES: + # Check if API is healthy + unkey healthcheck https://api.example.com/health + + # Check local service + unkey healthcheck http://localhost:8080/health`, + Action: run, +} + +func run(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args() + if len(args) == 0 { + return fmt.Errorf("you must provide a URL like so: 'unkey healthcheck '") + } + + url := args[0] + + // nolint:gosec + res, err := http.Get(url) + if err != nil { + return fmt.Errorf("failed to perform healthcheck: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("healthcheck failed with status code %d", res.StatusCode) + } + + fmt.Printf("✓ Healthcheck passed for %s (status: %d)\n", url, res.StatusCode) + return nil +} diff --git a/go/cmd/cli/commands/init.go b/go/cmd/cli/commands/init.go deleted file mode 100644 index 7bbe10cd95c..00000000000 --- a/go/cmd/cli/commands/init.go +++ /dev/null @@ -1,38 +0,0 @@ -package commands - -import ( - "fmt" -) - -// Init handles the init command -func Init(args []string, env map[string]string) error { - fmt.Println("Init command - config file support coming soon!") - fmt.Println("For now, use flags directly:") - fmt.Println("") - fmt.Println("Example:") - fmt.Println(" unkey deploy \\") - fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") - fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") - fmt.Println(" --context=./demo_api") - - return nil -} - -// PrintInitHelp prints detailed help for init command -func PrintInitHelp() { - fmt.Println("unkey init - Initialize configuration") - fmt.Println("") - fmt.Println("USAGE:") - fmt.Println(" unkey init [FLAGS]") - fmt.Println("") - fmt.Println("DESCRIPTION:") - fmt.Println(" Initialize a configuration file to store default values for") - fmt.Println(" workspace ID, project ID, and docker context path.") - fmt.Println("") - fmt.Println("FLAGS:") - fmt.Println(" --config Configuration file path (default: ./unkey.json)") - fmt.Println("") - fmt.Println("EXAMPLES:") - fmt.Println(" unkey init") - fmt.Println(" unkey init --config=./my-project.json") -} diff --git a/go/cmd/cli/commands/init/init.go b/go/cmd/cli/commands/init/init.go new file mode 100644 index 00000000000..3b5b91f0ed8 --- /dev/null +++ b/go/cmd/cli/commands/init/init.go @@ -0,0 +1,69 @@ +package init + +import ( + "context" + "fmt" + + "github.com/unkeyed/unkey/go/cmd/cli/cli" +) + +var Command = &cli.Command{ + Name: "init", + Usage: "Initialize configuration file for Unkey CLI", + Description: `Initialize a configuration file to store default values for workspace ID, project ID, and context path. +This will create a configuration file that can be used to avoid specifying common flags repeatedly. + +EXAMPLES: + # Create default config file (./unkey.json) + unkey init + + # Create config file at custom location + unkey init --config=./my-project.json + + # Initialize with specific values + unkey init --workspace-id=ws_123 --project-id=proj_456`, + Flags: []cli.Flag{ + cli.String("config", "Configuration file path", "./unkey.json", "", false), + cli.String("workspace-id", "Default workspace ID to save in config", "", "", false), + cli.String("project-id", "Default project ID to save in config", "", "", false), + cli.String("context", "Default Docker context path to save in config", "", "", false), + }, + Action: run, +} + +func run(ctx context.Context, cmd *cli.Command) error { + configPath := cmd.String("config") + workspaceID := cmd.String("workspace-id") + projectID := cmd.String("project-id") + contextPath := cmd.String("context") + + fmt.Println("🚀 Unkey CLI Configuration Setup") + fmt.Println("") + + // For now, just show what would be saved + fmt.Println("Configuration file support coming soon!") + fmt.Println("") + fmt.Printf("Config file location: %s\n", configPath) + + if workspaceID != "" { + fmt.Printf("Workspace ID: %s\n", workspaceID) + } + if projectID != "" { + fmt.Printf("Project ID: %s\n", projectID) + } + if contextPath != "" { + fmt.Printf("Context path: %s\n", contextPath) + } + + fmt.Println("") + fmt.Println("For now, use flags directly:") + fmt.Println("") + fmt.Println("Example:") + fmt.Println(" unkey deploy \\") + fmt.Println(" --workspace-id=ws_4QgQsKsKfdm3nGeC \\") + fmt.Println(" --project-id=proj_9aiaks2dzl6mcywnxjf \\") + fmt.Println(" --context=./demo_api") + fmt.Println("") + + return nil +} diff --git a/go/cmd/cli/commands/quotacheck/quotacheck.go b/go/cmd/cli/commands/quotacheck/quotacheck.go new file mode 100644 index 00000000000..7e2c2b26e15 --- /dev/null +++ b/go/cmd/cli/commands/quotacheck/quotacheck.go @@ -0,0 +1,230 @@ +package quotacheck + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/unkeyed/unkey/go/cmd/cli/cli" + "github.com/unkeyed/unkey/go/pkg/clickhouse" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/otel/logging" + "golang.org/x/text/language" + "golang.org/x/text/message" + "golang.org/x/text/number" +) + +var Command = &cli.Command{ + Name: "quotacheck", + Usage: "Check for workspaces that have exceeded their quotas", + Description: `Check all workspaces for quota violations and optionally send Slack notifications. +This command scans all enabled workspaces and compares their monthly usage against their quota limits. + +EXAMPLES: + # Check quotas with just database connections + unkey quotacheck --database-dsn="postgres://..." --clickhouse-url="http://..." + + # Check quotas and send Slack notifications for violations + unkey quotacheck --database-dsn="postgres://..." --clickhouse-url="http://..." --slack-webhook-url="https://hooks.slack.com/..."`, + Flags: []cli.Flag{ + cli.String("clickhouse-url", "URL for the ClickHouse database", "", "CLICKHOUSE_URL", true), + cli.String("database-dsn", "DSN for the primary database", "", "DATABASE_DSN", true), + cli.String("slack-webhook-url", "Slack webhook URL to send notifications", "", "SLACK_WEBHOOK_URL", false), + }, + Action: run, +} + +func run(ctx context.Context, cmd *cli.Command) error { + year, month, _ := time.Now().Date() + logger := logging.New() + slackWebhookURL := cmd.String("slack-webhook-url") + + database, err := db.New(db.Config{ + PrimaryDSN: cmd.String("database-dsn"), + ReadOnlyDSN: "", + Logger: logger, + }) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + ch, err := clickhouse.New(clickhouse.Config{ + URL: cmd.String("clickhouse-url"), + Logger: logger, + }) + if err != nil { + return fmt.Errorf("failed to connect to ClickHouse: %w", err) + } + + logger.Info("Starting quota check for all workspaces") + counter := 0 + violationsFound := 0 + cursor := "" + + for { + list, err := db.Query.ListWorkspaces(ctx, database.RO(), cursor) + if err != nil { + return fmt.Errorf("failed to list workspaces: %w", err) + } + + if len(list) == 0 { + break + } + + cursor = list[len(list)-1].Workspace.ID + + for _, e := range list { + counter++ + if counter%100 == 0 { + logger.Info("progress", "count", counter) + } + + if !e.Workspace.Enabled { + continue + } + + usedVerifications, err := ch.GetBillableVerifications(ctx, e.Workspace.ID, year, int(month)) + if err != nil { + return fmt.Errorf("failed to get billable verifications for workspace %s: %w", e.Workspace.ID, err) + } + + usedRatelimits, err := ch.GetBillableRatelimits(ctx, e.Workspace.ID, year, int(month)) + if err != nil { + return fmt.Errorf("failed to get billable ratelimits for workspace %s: %w", e.Workspace.ID, err) + } + + usage := usedVerifications + usedRatelimits + + if usage > e.Quotas.RequestsPerMonth { + violationsFound++ + logger.Warn("workspace has exceeded request quota", + "workspace_id", e.Workspace.ID, + "workspace_name", e.Workspace.Name, + "usage", usage, + "limit", e.Quotas.RequestsPerMonth, + ) + + if slackWebhookURL != "" { + err = sendSlackNotification(slackWebhookURL, e, usage) + if err != nil { + return fmt.Errorf("failed to send slack notification for workspace %s: %w", e.Workspace.ID, err) + } + logger.Info("sent Slack notification", "workspace_id", e.Workspace.ID) + } + } + } + } + + logger.Info("quota check completed", + "total_workspaces_checked", counter, + "violations_found", violationsFound, + ) + + if violationsFound > 0 { + fmt.Printf("Found %d workspace(s) exceeding quotas out of %d checked\n", violationsFound, counter) + } else { + fmt.Printf("All %d workspaces are within their quota limits\n", counter) + } + + return nil +} + +func sendSlackNotification(webhookURL string, e db.ListWorkspacesRow, used int64) error { + payload := map[string]any{ + "text": fmt.Sprintf("Quota Exceeded: %s", e.Workspace.Name), + "blocks": []map[string]any{ + { + "type": "header", + "text": map[string]any{ + "type": "plain_text", + "text": fmt.Sprintf("🚨 Quota Exceeded: %s", e.Workspace.Name), + "emoji": true, + }, + }, + { + "type": "section", + "fields": []map[string]any{ + { + "type": "mrkdwn", + "text": fmt.Sprintf("*Workspace ID:*\n`%s`", e.Workspace.ID), + }, + { + "type": "mrkdwn", + "text": fmt.Sprintf("*Workspace Name:*\n%s", e.Workspace.Name), + }, + { + "type": "mrkdwn", + "text": fmt.Sprintf("*Organisation ID:*\n`%s`", e.Workspace.OrgID), + }, + { + "type": "mrkdwn", + "text": fmt.Sprintf("*Stripe ID:*\n`%s`", e.Workspace.StripeCustomerID.String), + }, + }, + }, + { + "type": "section", + "fields": []map[string]any{ + { + "type": "mrkdwn", + "text": fmt.Sprintf("*Workspace Tier:*\n%s", e.Workspace.Tier.String), + }, + { + "type": "mrkdwn", + "text": fmt.Sprintf("*Quota Type:*\nRequests Per Month"), + }, + }, + }, + { + "type": "section", + "fields": []map[string]any{ + { + "type": "mrkdwn", + "text": fmt.Sprintf("*Limit:*\n%s", message.NewPrinter(language.English).Sprint(number.Decimal(e.Quotas.RequestsPerMonth))), + }, + { + "type": "mrkdwn", + "text": fmt.Sprintf("*Used:*\n%s", message.NewPrinter(language.English).Sprint(number.Decimal(used))), + }, + }, + }, + { + "type": "section", + "text": map[string]any{ + "type": "mrkdwn", + "text": fmt.Sprintf("*Overage:* %s requests (%.1f%% over limit)", + message.NewPrinter(language.English).Sprint(number.Decimal(used-e.Quotas.RequestsPerMonth)), + float64(used-e.Quotas.RequestsPerMonth)/float64(e.Quotas.RequestsPerMonth)*100, + ), + }, + }, + }, + } + + slackBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal slack payload: %w", err) + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, webhookURL, bytes.NewBuffer(slackBody)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send HTTP request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("slack notification failed with status code: %d", resp.StatusCode) + } + + return nil +} diff --git a/go/cmd/cli/commands/run/run.go b/go/cmd/cli/commands/run/run.go new file mode 100644 index 00000000000..e30354ea903 --- /dev/null +++ b/go/cmd/cli/commands/run/run.go @@ -0,0 +1,232 @@ +package run + +import ( + "context" + "fmt" + + "github.com/unkeyed/unkey/go/apps/api" + "github.com/unkeyed/unkey/go/apps/ctrl" + "github.com/unkeyed/unkey/go/cmd/cli/cli" + "github.com/unkeyed/unkey/go/pkg/clock" + "github.com/unkeyed/unkey/go/pkg/tls" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +var Command = &cli.Command{ + Name: "run", + Usage: "Run Unkey services", + Description: `Run various Unkey services including: + - api: The main API server for validating and managing API keys + - ctrl: The control plane service for managing infrastructure`, + Commands: []*cli.Command{ + apiCommand, + ctrlCommand, + }, +} + +var apiCommand = &cli.Command{ + Name: "api", + Usage: "Run the Unkey API server for validating and managing API keys", + Flags: []cli.Flag{ + // Server Configuration + cli.Int("http-port", "HTTP port for the API server to listen on", 7070, "UNKEY_HTTP_PORT", false), + cli.Bool("color", "Enable colored log output", "UNKEY_LOGS_COLOR", false), + cli.Bool("test-mode", "Enable test mode. WARNING: Potentially unsafe, may trust client inputs blindly", "UNKEY_TEST_MODE", false), + + // Instance Identification + cli.String("platform", "Cloud platform identifier for this node. Used for logging and metrics", "", "UNKEY_PLATFORM", false), + cli.String("image", "Container image identifier. Used for logging and metrics", "", "UNKEY_IMAGE", false), + cli.String("region", "Geographic region identifier. Used for logging and routing", "unknown", "UNKEY_REGION", false), + cli.String("instance-id", "Unique identifier for this instance. Auto-generated if not provided", uid.New(uid.InstancePrefix, 4), "UNKEY_INSTANCE_ID", false), + + // Database Configuration + cli.String("database-primary", "MySQL connection string for primary database. Required for all deployments. Example: user:pass@host:3306/unkey?parseTime=true", "", "UNKEY_DATABASE_PRIMARY", true), + cli.String("database-replica", "MySQL connection string for read-replica. Reduces load on primary database. Format same as database-primary", "", "UNKEY_DATABASE_REPLICA", false), + + // Caching and Storage + cli.String("redis-url", "Redis connection string for rate-limiting and distributed counters. Example: redis://localhost:6379", "", "UNKEY_REDIS_URL", false), + cli.String("clickhouse-url", "ClickHouse connection string for analytics. Recommended for production. Example: clickhouse://user:pass@host:9000/unkey", "", "UNKEY_CLICKHOUSE_URL", false), + + // Observability + cli.Bool("otel", "Enable OpenTelemetry tracing and metrics", "UNKEY_OTEL", false), + cli.Float("otel-trace-sampling-rate", "Sampling rate for OpenTelemetry traces (0.0-1.0). Only used when --otel is provided", 0.25, "UNKEY_OTEL_TRACE_SAMPLING_RATE", false), + cli.Int("prometheus-port", "Enable Prometheus /metrics endpoint on specified port. Set to 0 to disable", 0, "UNKEY_PROMETHEUS_PORT", false), + + // TLS Configuration + cli.String("tls-cert-file", "Path to TLS certificate file for HTTPS. Both cert and key must be provided to enable HTTPS", "", "UNKEY_TLS_CERT_FILE", false), + cli.String("tls-key-file", "Path to TLS key file for HTTPS. Both cert and key must be provided to enable HTTPS", "", "UNKEY_TLS_KEY_FILE", false), + + // Vault Configuration + cli.StringSlice("vault-master-keys", "Vault master keys for encryption (comma-separated)", []string{}, "UNKEY_VAULT_MASTER_KEYS", false), + cli.String("vault-s3-url", "S3 Compatible Endpoint URL", "", "UNKEY_VAULT_S3_URL", false), + cli.String("vault-s3-bucket", "S3 bucket name", "", "UNKEY_VAULT_S3_BUCKET", false), + cli.String("vault-s3-access-key-id", "S3 access key ID", "", "UNKEY_VAULT_S3_ACCESS_KEY_ID", false), + cli.String("vault-s3-secret-access-key", "S3 secret access key", "", "UNKEY_VAULT_S3_SECRET_ACCESS_KEY", false), + }, + Action: apiAction, +} + +var ctrlCommand = &cli.Command{ + Name: "ctrl", + Usage: "Run the Unkey control plane service for managing infrastructure and services", + Flags: []cli.Flag{ + // Server Configuration + cli.Int("http-port", "HTTP port for the control plane server to listen on", 8080, "UNKEY_HTTP_PORT", false), + cli.Bool("color", "Enable colored log output", "UNKEY_LOGS_COLOR", false), + + // Instance Identification + cli.String("platform", "Cloud platform identifier for this node. Used for logging and metrics", "", "UNKEY_PLATFORM", false), + cli.String("image", "Container image identifier. Used for logging and metrics", "", "UNKEY_IMAGE", false), + cli.String("region", "Geographic region identifier. Used for logging and routing", "unknown", "UNKEY_REGION", false), + cli.String("instance-id", "Unique identifier for this instance. Auto-generated if not provided", uid.New(uid.InstancePrefix, 4), "UNKEY_INSTANCE_ID", false), + + // Database Configuration + cli.String("database-primary", "MySQL connection string for primary database. Required for all deployments. Example: user:pass@host:3306/unkey?parseTime=true", "", "UNKEY_DATABASE_PRIMARY", true), + cli.String("database-hydra", "MySQL connection string for hydra database. Required for all deployments. Example: user:pass@host:3306/hydra?parseTime=true", "", "UNKEY_DATABASE_HYDRA", true), + + // Observability + cli.Bool("otel", "Enable OpenTelemetry tracing and metrics", "UNKEY_OTEL", false), + cli.Float("otel-trace-sampling-rate", "Sampling rate for OpenTelemetry traces (0.0-1.0). Only used when --otel is provided", 0.25, "UNKEY_OTEL_TRACE_SAMPLING_RATE", false), + + // TLS Configuration + cli.String("tls-cert-file", "Path to TLS certificate file for HTTPS. Both cert and key must be provided to enable HTTPS", "", "UNKEY_TLS_CERT_FILE", false), + cli.String("tls-key-file", "Path to TLS key file for HTTPS. Both cert and key must be provided to enable HTTPS", "", "UNKEY_TLS_KEY_FILE", false), + + // Control Plane Specific + cli.String("auth-token", "Authentication token for control plane API access. Required for secure deployments", "", "UNKEY_AUTH_TOKEN", false), + cli.String("metald-address", "Full URL of the metald service for VM operations. Required for deployments. Example: https://metald.example.com:8080", "", "UNKEY_METALD_ADDRESS", true), + cli.String("spiffe-socket-path", "Path to SPIFFE agent socket for mTLS authentication", "/var/lib/spire/agent/agent.sock", "UNKEY_SPIFFE_SOCKET_PATH", false), + }, + Action: ctrlAction, +} + +func apiAction(ctx context.Context, cmd *cli.Command) error { + // Check if TLS flags are properly set (both or none) + tlsCertFile := cmd.String("tls-cert-file") + tlsKeyFile := cmd.String("tls-key-file") + if (tlsCertFile == "" && tlsKeyFile != "") || (tlsCertFile != "" && tlsKeyFile == "") { + return fmt.Errorf("both --tls-cert-file and --tls-key-file must be provided to enable HTTPS") + } + + // Initialize TLS config if TLS flags are provided + var tlsConfig *tls.Config + if tlsCertFile != "" && tlsKeyFile != "" { + var err error + tlsConfig, err = tls.NewFromFiles(tlsCertFile, tlsKeyFile) + if err != nil { + return fmt.Errorf("failed to load TLS configuration: %w", err) + } + } + + // Parse vault master keys from StringSlice flag + vaultMasterKeys := cmd.StringSlice("vault-master-keys") + + // Get sampling rate directly as float + samplingRate := cmd.Float("otel-trace-sampling-rate") + + var vaultS3Config *api.S3Config + if cmd.String("vault-s3-url") != "" { + vaultS3Config = &api.S3Config{ + URL: cmd.String("vault-s3-url"), + Bucket: cmd.String("vault-s3-bucket"), + AccessKeyID: cmd.String("vault-s3-access-key-id"), + SecretAccessKey: cmd.String("vault-s3-secret-access-key"), + } + } + + config := api.Config{ + // Basic configuration + Platform: cmd.String("platform"), + Image: cmd.String("image"), + HttpPort: cmd.Int("http-port"), + Region: cmd.String("region"), + + // Database configuration + DatabasePrimary: cmd.String("database-primary"), + DatabaseReadonlyReplica: cmd.String("database-replica"), + + // ClickHouse + ClickhouseURL: cmd.String("clickhouse-url"), + + // OpenTelemetry configuration + OtelEnabled: cmd.Bool("otel"), + OtelTraceSamplingRate: samplingRate, + + // TLS Configuration + TLSConfig: tlsConfig, + + InstanceID: cmd.String("instance-id"), + RedisUrl: cmd.String("redis-url"), + PrometheusPort: cmd.Int("prometheus-port"), + Clock: clock.New(), + TestMode: cmd.Bool("test-mode"), + + // Vault configuration + VaultMasterKeys: vaultMasterKeys, + VaultS3: vaultS3Config, + } + + err := config.Validate() + if err != nil { + return err + } + + return api.Run(ctx, config) +} + +func ctrlAction(ctx context.Context, cmd *cli.Command) error { + // Check if TLS flags are properly set (both or none) + tlsCertFile := cmd.String("tls-cert-file") + tlsKeyFile := cmd.String("tls-key-file") + if (tlsCertFile == "" && tlsKeyFile != "") || (tlsCertFile != "" && tlsKeyFile == "") { + return fmt.Errorf("both --tls-cert-file and --tls-key-file must be provided to enable HTTPS") + } + + // Initialize TLS config if TLS flags are provided + var tlsConfig *tls.Config + if tlsCertFile != "" && tlsKeyFile != "" { + var err error + tlsConfig, err = tls.NewFromFiles(tlsCertFile, tlsKeyFile) + if err != nil { + return fmt.Errorf("failed to load TLS configuration: %w", err) + } + } + + // Get sampling rate directly as float + samplingRate := cmd.Float("otel-trace-sampling-rate") + + config := ctrl.Config{ + // Basic configuration + Platform: cmd.String("platform"), + Image: cmd.String("image"), + HttpPort: cmd.Int("http-port"), + Region: cmd.String("region"), + InstanceID: cmd.String("instance-id"), + + // Database configuration + DatabasePrimary: cmd.String("database-primary"), + DatabaseHydra: cmd.String("database-hydra"), + + // Observability + OtelEnabled: cmd.Bool("otel"), + OtelTraceSamplingRate: samplingRate, + + // TLS Configuration + TLSConfig: tlsConfig, + + // Control Plane Specific + AuthToken: cmd.String("auth-token"), + MetaldAddress: cmd.String("metald-address"), + SPIFFESocketPath: cmd.String("spiffe-socket-path"), + + // Common + Clock: clock.New(), + } + + err := config.Validate() + if err != nil { + return err + } + + return ctrl.Run(ctx, config) +} diff --git a/go/cmd/cli/main.go b/go/cmd/cli/main.go index ca102acebeb..f309e84128c 100644 --- a/go/cmd/cli/main.go +++ b/go/cmd/cli/main.go @@ -7,6 +7,10 @@ import ( "github.com/unkeyed/unkey/go/cmd/cli/cli" "github.com/unkeyed/unkey/go/cmd/cli/commands/deploy" + "github.com/unkeyed/unkey/go/cmd/cli/commands/healthcheck" + initcmd "github.com/unkeyed/unkey/go/cmd/cli/commands/init" + "github.com/unkeyed/unkey/go/cmd/cli/commands/quotacheck" + "github.com/unkeyed/unkey/go/cmd/cli/commands/run" "github.com/unkeyed/unkey/go/cmd/cli/commands/versions" "github.com/unkeyed/unkey/go/pkg/version" ) @@ -19,6 +23,10 @@ func main() { Commands: []*cli.Command{ deploy.Command, versions.Command, + run.Command, + healthcheck.Command, + quotacheck.Command, + initcmd.Command, }, } From 1f5b4769cd85d4d22bf8769e63817cb4d9a1e397 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 16 Jul 2025 12:19:33 +0300 Subject: [PATCH 19/23] fix: code rabbit comments --- go/cmd/cli/cli/command.go | 5 ----- go/cmd/cli/commands/deploy/deploy.go | 10 +++++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/go/cmd/cli/cli/command.go b/go/cmd/cli/cli/command.go index 698faecb43c..cd9fdd4811d 100644 --- a/go/cmd/cli/cli/command.go +++ b/go/cmd/cli/cli/command.go @@ -94,11 +94,6 @@ func (c *Command) Run(ctx context.Context, args []string) error { if len(args) == 0 { return fmt.Errorf("no arguments provided") } - // Initialize flag lookup map for O(1) access - c.flagMap = make(map[string]Flag) - for _, flag := range c.Flags { - c.flagMap[flag.Name()] = flag - } // Parse arguments starting from index 1 (skip program name) return c.parse(ctx, args[1:]) } diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index 51c12426b4f..84e086a67dc 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -153,6 +153,9 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { if err := pushImage(ctx, dockerImage, opts.Registry); err != nil { ui.PrintError("Push failed but continuing deployment") ui.PrintErrorDetails(err.Error()) + // INFO: For now we are ignoring registry push because everyone one is working locally, + // omit this when comments and put the return nil back when going to prod + // return nil } else { ui.PrintSuccess("Image pushed successfully") } @@ -238,11 +241,8 @@ func printSourceInfo(opts *DeployOptions, gitInfo git.Info) { fmt.Printf(" Branch: %s\n", opts.Branch) if gitInfo.IsRepo && gitInfo.CommitSHA != "" { - shortSHA := gitInfo.CommitSHA - if len(shortSHA) > 7 { - shortSHA = shortSHA[:7] - } - commitInfo := shortSHA + + commitInfo := gitInfo.ShortSHA if gitInfo.IsDirty { commitInfo += " (dirty)" } From 74f407a364e60e8c2b9d8ca437c25b7a376afeb9 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 16 Jul 2025 12:25:34 +0300 Subject: [PATCH 20/23] refactor: fix redundancy --- go/cmd/cli/cli/flag.go | 292 +++++++++++++++-------------------------- 1 file changed, 105 insertions(+), 187 deletions(-) diff --git a/go/cmd/cli/cli/flag.go b/go/cmd/cli/cli/flag.go index 83de44612b6..da1c6d0ef4a 100644 --- a/go/cmd/cli/cli/flag.go +++ b/go/cmd/cli/cli/flag.go @@ -17,27 +17,35 @@ type Flag interface { IsSet() bool // Whether the flag was explicitly set by user } -// StringFlag represents a string command line flag -type StringFlag struct { +// baseFlag contains common fields and methods shared by all flag types +type baseFlag struct { name string // Flag name usage string // Help description - value string // Current value envVar string // Environment variable to check for default required bool // Whether flag is mandatory set bool // Whether user explicitly provided this flag } // Name returns the flag name -func (f *StringFlag) Name() string { return f.name } +func (b *baseFlag) Name() string { return b.name } // Usage returns the flag's help text -func (f *StringFlag) Usage() string { return f.usage } +func (b *baseFlag) Usage() string { return b.usage } // Required returns whether this flag is mandatory -func (f *StringFlag) Required() bool { return f.required } +func (b *baseFlag) Required() bool { return b.required } // IsSet returns whether the user explicitly provided this flag -func (f *StringFlag) IsSet() bool { return f.set } +func (b *baseFlag) IsSet() bool { return b.set } + +// EnvVar returns the environment variable name for this flag +func (b *baseFlag) EnvVar() string { return b.envVar } + +// StringFlag represents a string command line flag +type StringFlag struct { + baseFlag + value string // Current value +} // Parse sets the flag value from a string func (f *StringFlag) Parse(value string) error { @@ -49,31 +57,12 @@ func (f *StringFlag) Parse(value string) error { // Value returns the current string value func (f *StringFlag) Value() string { return f.value } -// EnvVar returns the environment variable name for this flag -func (f *StringFlag) EnvVar() string { return f.envVar } - // BoolFlag represents a boolean command line flag type BoolFlag struct { - name string // Flag name - usage string // Help description - value bool // Current value - envVar string // Environment variable to check for default - required bool // Whether flag is mandatory - set bool // Whether user explicitly provided this flag + baseFlag + value bool // Current value } -// Name returns the flag name -func (f *BoolFlag) Name() string { return f.name } - -// Usage returns the flag's help text -func (f *BoolFlag) Usage() string { return f.usage } - -// Required returns whether this flag is mandatory -func (f *BoolFlag) Required() bool { return f.required } - -// IsSet returns whether the user explicitly provided this flag -func (f *BoolFlag) IsSet() bool { return f.set } - // Parse sets the flag value from a string // Empty string means the flag was provided without a value (--flag), which sets it to true // Otherwise parses as boolean: "true", "false", "1", "0", etc. @@ -83,7 +72,6 @@ func (f *BoolFlag) Parse(value string) error { f.set = true return nil } - parsed, err := strconv.ParseBool(value) if err != nil { return fmt.Errorf("invalid boolean value: %s", value) @@ -96,31 +84,12 @@ func (f *BoolFlag) Parse(value string) error { // Value returns the current boolean value func (f *BoolFlag) Value() bool { return f.value } -// EnvVar returns the environment variable name for this flag -func (f *BoolFlag) EnvVar() string { return f.envVar } - // IntFlag represents an integer command line flag type IntFlag struct { - name string // Flag name - usage string // Help description - value int // Current value - envVar string // Environment variable to check for default - required bool // Whether flag is mandatory - set bool // Whether user explicitly provided this flag + baseFlag + value int // Current value } -// Name returns the flag name -func (f *IntFlag) Name() string { return f.name } - -// Usage returns the flag's help text -func (f *IntFlag) Usage() string { return f.usage } - -// Required returns whether this flag is mandatory -func (f *IntFlag) Required() bool { return f.required } - -// IsSet returns whether the user explicitly provided this flag -func (f *IntFlag) IsSet() bool { return f.set } - // Parse sets the flag value from a string func (f *IntFlag) Parse(value string) error { parsed, err := strconv.Atoi(value) @@ -135,20 +104,70 @@ func (f *IntFlag) Parse(value string) error { // Value returns the current integer value func (f *IntFlag) Value() int { return f.value } -// EnvVar returns the environment variable name for this flag -func (f *IntFlag) EnvVar() string { return f.envVar } +// FloatFlag represents a float64 command line flag +type FloatFlag struct { + baseFlag + value float64 // Current value +} + +// Parse sets the flag value from a string +func (f *FloatFlag) Parse(value string) error { + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid float value: %s", value) + } + f.value = parsed + f.set = true + return nil +} + +// Value returns the current float64 value +func (f *FloatFlag) Value() float64 { return f.value } + +// StringSliceFlag represents a string slice command line flag +type StringSliceFlag struct { + baseFlag + value []string // Current value +} + +// parseCommaSeparated splits a comma-separated string into a slice of trimmed non-empty strings +func (f *StringSliceFlag) parseCommaSeparated(value string) []string { + if value == "" { + return []string{} + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +// Parse sets the flag value from a string (comma-separated values) +func (f *StringSliceFlag) Parse(value string) error { + f.value = f.parseCommaSeparated(value) + f.set = true + return nil +} + +// Value returns the current string slice value +func (f *StringSliceFlag) Value() []string { return f.value } // String creates a new string flag with environment variable support // If envVar is provided and set, it will be used as the default value func String(name, usage, defaultValue, envVar string, required bool) *StringFlag { flag := &StringFlag{ - name: name, - usage: usage, - value: defaultValue, - envVar: envVar, - required: required, + baseFlag: baseFlag{ + name: name, + usage: usage, + envVar: envVar, + required: required, + }, + value: defaultValue, } - // Check environment variable for default value if envVar != "" { if envValue := os.Getenv(envVar); envValue != "" { @@ -156,19 +175,19 @@ func String(name, usage, defaultValue, envVar string, required bool) *StringFlag flag.set = true // Mark as set since env var was found } } - return flag } // Bool creates a new boolean flag with environment variable support func Bool(name, usage, envVar string, required bool) *BoolFlag { flag := &BoolFlag{ - name: name, - usage: usage, - envVar: envVar, - required: required, + baseFlag: baseFlag{ + name: name, + usage: usage, + envVar: envVar, + required: required, + }, } - // Check environment variable for default value if envVar != "" { if envValue := os.Getenv(envVar); envValue != "" { @@ -178,20 +197,20 @@ func Bool(name, usage, envVar string, required bool) *BoolFlag { } } } - return flag } // Int creates a new integer flag with environment variable support func Int(name, usage string, defaultValue int, envVar string, required bool) *IntFlag { flag := &IntFlag{ - name: name, - usage: usage, - value: defaultValue, - envVar: envVar, - required: required, + baseFlag: baseFlag{ + name: name, + usage: usage, + envVar: envVar, + required: required, + }, + value: defaultValue, } - // Check environment variable for default value if envVar != "" { if envValue := os.Getenv(envVar); envValue != "" { @@ -201,107 +220,20 @@ func Int(name, usage string, defaultValue int, envVar string, required bool) *In } } } - return flag } -// FloatFlag represents a float64 command line flag -type FloatFlag struct { - name string // Flag name - usage string // Help description - value float64 // Current value - envVar string // Environment variable to check for default - required bool // Whether flag is mandatory - set bool // Whether user explicitly provided this flag -} - -// Name returns the flag name -func (f *FloatFlag) Name() string { return f.name } - -// Usage returns the flag's help text -func (f *FloatFlag) Usage() string { return f.usage } - -// Required returns whether this flag is mandatory -func (f *FloatFlag) Required() bool { return f.required } - -// IsSet returns whether the user explicitly provided this flag -func (f *FloatFlag) IsSet() bool { return f.set } - -// Parse sets the flag value from a string -func (f *FloatFlag) Parse(value string) error { - parsed, err := strconv.ParseFloat(value, 64) - if err != nil { - return fmt.Errorf("invalid float value: %s", value) - } - f.value = parsed - f.set = true - return nil -} - -// Value returns the current float64 value -func (f *FloatFlag) Value() float64 { return f.value } - -// EnvVar returns the environment variable name for this flag -func (f *FloatFlag) EnvVar() string { return f.envVar } - -// StringSliceFlag represents a string slice command line flag -type StringSliceFlag struct { - name string // Flag name - usage string // Help description - value []string // Current value - envVar string // Environment variable to check for default - required bool // Whether flag is mandatory - set bool // Whether user explicitly provided this flag -} - -// Name returns the flag name -func (f *StringSliceFlag) Name() string { return f.name } - -// Usage returns the flag's help text -func (f *StringSliceFlag) Usage() string { return f.usage } - -// Required returns whether this flag is mandatory -func (f *StringSliceFlag) Required() bool { return f.required } - -// IsSet returns whether the user explicitly provided this flag -func (f *StringSliceFlag) IsSet() bool { return f.set } - -// Parse sets the flag value from a string (comma-separated values) -func (f *StringSliceFlag) Parse(value string) error { - if value == "" { - f.value = []string{} - } else { - // Split by comma and trim whitespace - parts := strings.Split(value, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed != "" { - result = append(result, trimmed) - } - } - f.value = result - } - f.set = true - return nil -} - -// Value returns the current string slice value -func (f *StringSliceFlag) Value() []string { return f.value } - -// EnvVar returns the environment variable name for this flag -func (f *StringSliceFlag) EnvVar() string { return f.envVar } - // Float creates a new float flag with environment variable support func Float(name, usage string, defaultValue float64, envVar string, required bool) *FloatFlag { flag := &FloatFlag{ - name: name, - usage: usage, - value: defaultValue, - envVar: envVar, - required: required, + baseFlag: baseFlag{ + name: name, + usage: usage, + envVar: envVar, + required: required, + }, + value: defaultValue, } - // Check environment variable for default value if envVar != "" { if envValue := os.Getenv(envVar); envValue != "" { @@ -311,40 +243,26 @@ func Float(name, usage string, defaultValue float64, envVar string, required boo } } } - return flag } // StringSlice creates a new string slice flag with environment variable support func StringSlice(name, usage string, defaultValue []string, envVar string, required bool) *StringSliceFlag { flag := &StringSliceFlag{ - name: name, - usage: usage, - value: defaultValue, - envVar: envVar, - required: required, + baseFlag: baseFlag{ + name: name, + usage: usage, + envVar: envVar, + required: required, + }, + value: defaultValue, } - // Check environment variable for default value if envVar != "" { if envValue := os.Getenv(envVar); envValue != "" { - // Parse comma-separated values from environment - if envValue == "" { - flag.value = []string{} - } else { - parts := strings.Split(envValue, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed != "" { - result = append(result, trimmed) - } - } - flag.value = result - } + flag.value = flag.parseCommaSeparated(envValue) flag.set = true // Mark as set since env var was found } } - return flag } From 20068a17191e5b59d2281b17b7fc447eceeef22f Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 16 Jul 2025 15:15:17 +0300 Subject: [PATCH 21/23] refactor: improve sub spinner --- go/cmd/cli/commands/deploy/deploy.go | 67 ++++++++++++++------- go/cmd/cli/commands/deploy/ui.go | 89 ++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 28 deletions(-) diff --git a/go/cmd/cli/commands/deploy/deploy.go b/go/cmd/cli/commands/deploy/deploy.go index 84e086a67dc..a286f157fec 100644 --- a/go/cmd/cli/commands/deploy/deploy.go +++ b/go/cmd/cli/commands/deploy/deploy.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "time" "github.com/unkeyed/unkey/go/cmd/cli/cli" ctrlv1 "github.com/unkeyed/unkey/go/gen/proto/ctrl/v1" @@ -12,7 +11,18 @@ import ( "github.com/unkeyed/unkey/go/pkg/otel/logging" ) -const DEBUG_DELAY = 250 +// Step predictor - maps current step message patterns to next expected steps +// Based on the actual workflow messages from version.go +// TODO: In the future directly get those from hydra +var stepSequence = map[string]string{ + "Version queued and ready to start": "Downloading Docker image:", + "Downloading Docker image:": "Building rootfs from Docker image:", + "Building rootfs from Docker image:": "Uploading rootfs image to storage", + "Uploading rootfs image to storage": "Creating VM for version:", + "Creating VM for version:": "VM booted successfully:", + "VM booted successfully:": "Assigned hostname:", + "Assigned hostname:": "Version deployment completed successfully", +} var ( ErrDockerNotFound = errors.New("docker command not found - please install Docker") @@ -107,6 +117,7 @@ func DeployAction(ctx context.Context, cmd *cli.Command) error { return executeDeploy(ctx, opts) } +// Updated executeDeploy function - remove global spinner for deployment steps func executeDeploy(ctx context.Context, opts *DeployOptions) error { ui := NewUI() logger := logging.New() @@ -175,25 +186,27 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { ui.PrintSuccess(fmt.Sprintf("Version created: %s", versionId)) - ui.StartSpinner("Deploying to Unkey...") - onStatusChange := func(event VersionStatusEvent) error { if event.CurrentStatus == ctrlv1.VersionStatus_VERSION_STATUS_FAILED { return handleVersionFailure(controlPlane, event.Version, ui) } return nil } + onStepUpdate := func(event VersionStepEvent) error { return handleStepUpdate(event, ui) } err = controlPlane.PollVersionStatus(ctx, logger, versionId, onStatusChange, onStepUpdate) if err != nil { - ui.StopSpinner("Deployment failed", false) + // Complete any running step spinner on error + ui.CompleteCurrentStep("Deployment failed", false) return err } - ui.StopSpinner("Deployment completed successfully", true) + // Complete final step if still spinning + ui.CompleteCurrentStep("Version deployment completed successfully", true) + ui.PrintSuccess("Deployment completed successfully") fmt.Printf("\n") printCompletionInfo(opts, gitInfo, versionId) @@ -202,40 +215,50 @@ func executeDeploy(ctx context.Context, opts *DeployOptions) error { return nil } -func handleVersionFailure(controlPlane *ControlPlaneClient, version *ctrlv1.Version, ui *UI) error { - errorMsg := controlPlane.getFailureMessage(version) - ui.PrintError("Deployment failed") - ui.PrintErrorDetails(errorMsg) - return fmt.Errorf("deployment failed: %s", errorMsg) +func getNextStepMessage(currentMessage string) string { + // Check if current message starts with any known step pattern + for key, next := range stepSequence { + if len(currentMessage) >= len(key) && currentMessage[:len(key)] == key { + return next + } + } + return "" } func handleStepUpdate(event VersionStepEvent, ui *UI) error { - ui.mu.Lock() - if ui.spinning { - ui.spinning = false - fmt.Print("\r\033[K") - } - ui.mu.Unlock() - step := event.Step if step.GetErrorMessage() != "" { - ui.PrintStepError(step.GetMessage()) + ui.CompleteCurrentStep(step.GetMessage(), false) ui.PrintErrorDetails(step.GetErrorMessage()) return fmt.Errorf("deployment failed: %s", step.GetErrorMessage()) } if step.GetMessage() != "" { - ui.PrintStepSuccess(step.GetMessage()) + message := step.GetMessage() + nextStep := getNextStepMessage(message) - if DEBUG_DELAY > 0 { - time.Sleep(DEBUG_DELAY * time.Millisecond) + if !ui.stepSpinning { + // First step - start spinner, then complete and start next + ui.StartStepSpinner(message) + ui.CompleteStepAndStartNext(message, nextStep) + } else { + // Complete current step and start next + ui.CompleteStepAndStartNext(message, nextStep) } } return nil } +func handleVersionFailure(controlPlane *ControlPlaneClient, version *ctrlv1.Version, ui *UI) error { + errorMsg := controlPlane.getFailureMessage(version) + ui.CompleteCurrentStep("Deployment failed", false) + ui.PrintError("Deployment failed") + ui.PrintErrorDetails(errorMsg) + return fmt.Errorf("deployment failed: %s", errorMsg) +} + func printSourceInfo(opts *DeployOptions, gitInfo git.Info) { fmt.Printf("Source Information:\n") fmt.Printf(" Branch: %s\n", opts.Branch) diff --git a/go/cmd/cli/commands/deploy/ui.go b/go/cmd/cli/commands/deploy/ui.go index f34af6d4f18..37ccc2a3e4b 100644 --- a/go/cmd/cli/commands/deploy/ui.go +++ b/go/cmd/cli/commands/deploy/ui.go @@ -17,8 +17,10 @@ const ( var spinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} type UI struct { - mu sync.Mutex - spinning bool + mu sync.Mutex + spinning bool + currentStep string + stepSpinning bool } func NewUI() *UI { @@ -80,7 +82,6 @@ func (ui *UI) StartSpinner(message string) { } fmt.Printf("\r%s %s", spinnerChars[frame%len(spinnerChars)], message) ui.mu.Unlock() - frame++ time.Sleep(100 * time.Millisecond) } @@ -90,17 +91,93 @@ func (ui *UI) StartSpinner(message string) { func (ui *UI) StopSpinner(finalMessage string, success bool) { ui.mu.Lock() defer ui.mu.Unlock() - if !ui.spinning { return } - ui.spinning = false fmt.Print("\r\033[K") - if success { fmt.Printf("%s✓%s %s\n", ColorGreen, ColorReset, finalMessage) } else { fmt.Printf("%s✗%s %s\n", ColorRed, ColorReset, finalMessage) } } + +// Step spinner methods - indented with 2 spaces to show as sub-steps +func (ui *UI) StartStepSpinner(message string) { + ui.mu.Lock() + defer ui.mu.Unlock() + + if ui.stepSpinning { + fmt.Print("\r\033[K") + } + + ui.currentStep = message + ui.stepSpinning = true + + go func() { + frame := 0 + for { + ui.mu.Lock() + if !ui.stepSpinning { + ui.mu.Unlock() + return + } + fmt.Printf("\r %s %s", spinnerChars[frame%len(spinnerChars)], ui.currentStep) + ui.mu.Unlock() + frame++ + time.Sleep(100 * time.Millisecond) + } + }() +} + +func (ui *UI) CompleteStepAndStartNext(completedMessage, nextMessage string) { + ui.mu.Lock() + defer ui.mu.Unlock() + + // Stop current spinner and show completion + if ui.stepSpinning { + ui.stepSpinning = false + fmt.Print("\r\033[K") + fmt.Printf(" %s✓%s %s\n", ColorGreen, ColorReset, completedMessage) + } + + // Start next step if provided + if nextMessage != "" { + ui.currentStep = nextMessage + ui.stepSpinning = true + + go func() { + frame := 0 + for { + ui.mu.Lock() + if !ui.stepSpinning { + ui.mu.Unlock() + return + } + fmt.Printf("\r %s %s", spinnerChars[frame%len(spinnerChars)], ui.currentStep) + ui.mu.Unlock() + frame++ + time.Sleep(100 * time.Millisecond) + } + }() + } +} + +func (ui *UI) CompleteCurrentStep(message string, success bool) { + ui.mu.Lock() + defer ui.mu.Unlock() + + if !ui.stepSpinning { + return + } + + ui.stepSpinning = false + fmt.Print("\r\033[K") + + if success { + fmt.Printf(" %s✓%s %s\n", ColorGreen, ColorReset, message) + } else { + fmt.Printf(" %s✗%s %s\n", ColorRed, ColorReset, message) + } +} From 02d2597505e75209c4ff4e2fbe1d629e6fa94f96 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 16 Jul 2025 15:48:41 +0300 Subject: [PATCH 22/23] refactor: move duplicated spinner loop --- go/cmd/cli/commands/deploy/ui.go | 70 +++++++++++--------------------- 1 file changed, 23 insertions(+), 47 deletions(-) diff --git a/go/cmd/cli/commands/deploy/ui.go b/go/cmd/cli/commands/deploy/ui.go index 37ccc2a3e4b..12601dd9c93 100644 --- a/go/cmd/cli/commands/deploy/ui.go +++ b/go/cmd/cli/commands/deploy/ui.go @@ -63,24 +63,17 @@ func (ui *UI) PrintStepError(message string) { fmt.Printf(" %s✗%s %s\n", ColorRed, ColorReset, message) } -func (ui *UI) StartSpinner(message string) { - ui.mu.Lock() - if ui.spinning { - ui.mu.Unlock() - return - } - ui.spinning = true - ui.mu.Unlock() - +func (ui *UI) spinnerLoop(prefix string, messageGetter func() string, isActive func() bool) { go func() { frame := 0 for { ui.mu.Lock() - if !ui.spinning { + if !isActive() { ui.mu.Unlock() return } - fmt.Printf("\r%s %s", spinnerChars[frame%len(spinnerChars)], message) + message := messageGetter() + fmt.Printf("\r%s%s %s", prefix, spinnerChars[frame%len(spinnerChars)], message) ui.mu.Unlock() frame++ time.Sleep(100 * time.Millisecond) @@ -88,6 +81,19 @@ func (ui *UI) StartSpinner(message string) { }() } +func (ui *UI) StartSpinner(message string) { + ui.mu.Lock() + if ui.spinning { + ui.mu.Unlock() + return + } + ui.spinning = true + spinnerMessage := message + ui.mu.Unlock() + + ui.spinnerLoop("", func() string { return spinnerMessage }, func() bool { return ui.spinning }) +} + func (ui *UI) StopSpinner(finalMessage string, success bool) { ui.mu.Lock() defer ui.mu.Unlock() @@ -106,35 +112,18 @@ func (ui *UI) StopSpinner(finalMessage string, success bool) { // Step spinner methods - indented with 2 spaces to show as sub-steps func (ui *UI) StartStepSpinner(message string) { ui.mu.Lock() - defer ui.mu.Unlock() - if ui.stepSpinning { fmt.Print("\r\033[K") } - ui.currentStep = message ui.stepSpinning = true + ui.mu.Unlock() - go func() { - frame := 0 - for { - ui.mu.Lock() - if !ui.stepSpinning { - ui.mu.Unlock() - return - } - fmt.Printf("\r %s %s", spinnerChars[frame%len(spinnerChars)], ui.currentStep) - ui.mu.Unlock() - frame++ - time.Sleep(100 * time.Millisecond) - } - }() + ui.spinnerLoop(" ", func() string { return ui.currentStep }, func() bool { return ui.stepSpinning }) } func (ui *UI) CompleteStepAndStartNext(completedMessage, nextMessage string) { ui.mu.Lock() - defer ui.mu.Unlock() - // Stop current spinner and show completion if ui.stepSpinning { ui.stepSpinning = false @@ -146,35 +135,22 @@ func (ui *UI) CompleteStepAndStartNext(completedMessage, nextMessage string) { if nextMessage != "" { ui.currentStep = nextMessage ui.stepSpinning = true + ui.mu.Unlock() - go func() { - frame := 0 - for { - ui.mu.Lock() - if !ui.stepSpinning { - ui.mu.Unlock() - return - } - fmt.Printf("\r %s %s", spinnerChars[frame%len(spinnerChars)], ui.currentStep) - ui.mu.Unlock() - frame++ - time.Sleep(100 * time.Millisecond) - } - }() + ui.spinnerLoop(" ", func() string { return ui.currentStep }, func() bool { return ui.stepSpinning }) + } else { + ui.mu.Unlock() } } func (ui *UI) CompleteCurrentStep(message string, success bool) { ui.mu.Lock() defer ui.mu.Unlock() - if !ui.stepSpinning { return } - ui.stepSpinning = false fmt.Print("\r\033[K") - if success { fmt.Printf(" %s✓%s %s\n", ColorGreen, ColorReset, message) } else { From b1fd2917a38b4c55f2681913239e631fb2967331 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 17 Jul 2025 12:20:18 +0300 Subject: [PATCH 23/23] refactor: remove some commands for later --- .../cli/commands/healthcheck/healthcheck.go | 47 ---- go/cmd/cli/commands/quotacheck/quotacheck.go | 230 ----------------- go/cmd/cli/commands/run/run.go | 232 ------------------ go/cmd/cli/main.go | 6 - 4 files changed, 515 deletions(-) delete mode 100644 go/cmd/cli/commands/healthcheck/healthcheck.go delete mode 100644 go/cmd/cli/commands/quotacheck/quotacheck.go delete mode 100644 go/cmd/cli/commands/run/run.go diff --git a/go/cmd/cli/commands/healthcheck/healthcheck.go b/go/cmd/cli/commands/healthcheck/healthcheck.go deleted file mode 100644 index b381cf1a8a8..00000000000 --- a/go/cmd/cli/commands/healthcheck/healthcheck.go +++ /dev/null @@ -1,47 +0,0 @@ -package healthcheck - -import ( - "context" - "fmt" - "net/http" - - "github.com/unkeyed/unkey/go/cmd/cli/cli" -) - -var Command = &cli.Command{ - Name: "healthcheck", - Usage: "Perform HTTP healthcheck against a URL", - Description: `Perform an HTTP healthcheck against a given URL. -This command exits with 0 if the status code is 200, otherwise it exits with 1. - -EXAMPLES: - # Check if API is healthy - unkey healthcheck https://api.example.com/health - - # Check local service - unkey healthcheck http://localhost:8080/health`, - Action: run, -} - -func run(ctx context.Context, cmd *cli.Command) error { - args := cmd.Args() - if len(args) == 0 { - return fmt.Errorf("you must provide a URL like so: 'unkey healthcheck '") - } - - url := args[0] - - // nolint:gosec - res, err := http.Get(url) - if err != nil { - return fmt.Errorf("failed to perform healthcheck: %w", err) - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return fmt.Errorf("healthcheck failed with status code %d", res.StatusCode) - } - - fmt.Printf("✓ Healthcheck passed for %s (status: %d)\n", url, res.StatusCode) - return nil -} diff --git a/go/cmd/cli/commands/quotacheck/quotacheck.go b/go/cmd/cli/commands/quotacheck/quotacheck.go deleted file mode 100644 index 7e2c2b26e15..00000000000 --- a/go/cmd/cli/commands/quotacheck/quotacheck.go +++ /dev/null @@ -1,230 +0,0 @@ -package quotacheck - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/unkeyed/unkey/go/cmd/cli/cli" - "github.com/unkeyed/unkey/go/pkg/clickhouse" - "github.com/unkeyed/unkey/go/pkg/db" - "github.com/unkeyed/unkey/go/pkg/otel/logging" - "golang.org/x/text/language" - "golang.org/x/text/message" - "golang.org/x/text/number" -) - -var Command = &cli.Command{ - Name: "quotacheck", - Usage: "Check for workspaces that have exceeded their quotas", - Description: `Check all workspaces for quota violations and optionally send Slack notifications. -This command scans all enabled workspaces and compares their monthly usage against their quota limits. - -EXAMPLES: - # Check quotas with just database connections - unkey quotacheck --database-dsn="postgres://..." --clickhouse-url="http://..." - - # Check quotas and send Slack notifications for violations - unkey quotacheck --database-dsn="postgres://..." --clickhouse-url="http://..." --slack-webhook-url="https://hooks.slack.com/..."`, - Flags: []cli.Flag{ - cli.String("clickhouse-url", "URL for the ClickHouse database", "", "CLICKHOUSE_URL", true), - cli.String("database-dsn", "DSN for the primary database", "", "DATABASE_DSN", true), - cli.String("slack-webhook-url", "Slack webhook URL to send notifications", "", "SLACK_WEBHOOK_URL", false), - }, - Action: run, -} - -func run(ctx context.Context, cmd *cli.Command) error { - year, month, _ := time.Now().Date() - logger := logging.New() - slackWebhookURL := cmd.String("slack-webhook-url") - - database, err := db.New(db.Config{ - PrimaryDSN: cmd.String("database-dsn"), - ReadOnlyDSN: "", - Logger: logger, - }) - if err != nil { - return fmt.Errorf("failed to connect to database: %w", err) - } - - ch, err := clickhouse.New(clickhouse.Config{ - URL: cmd.String("clickhouse-url"), - Logger: logger, - }) - if err != nil { - return fmt.Errorf("failed to connect to ClickHouse: %w", err) - } - - logger.Info("Starting quota check for all workspaces") - counter := 0 - violationsFound := 0 - cursor := "" - - for { - list, err := db.Query.ListWorkspaces(ctx, database.RO(), cursor) - if err != nil { - return fmt.Errorf("failed to list workspaces: %w", err) - } - - if len(list) == 0 { - break - } - - cursor = list[len(list)-1].Workspace.ID - - for _, e := range list { - counter++ - if counter%100 == 0 { - logger.Info("progress", "count", counter) - } - - if !e.Workspace.Enabled { - continue - } - - usedVerifications, err := ch.GetBillableVerifications(ctx, e.Workspace.ID, year, int(month)) - if err != nil { - return fmt.Errorf("failed to get billable verifications for workspace %s: %w", e.Workspace.ID, err) - } - - usedRatelimits, err := ch.GetBillableRatelimits(ctx, e.Workspace.ID, year, int(month)) - if err != nil { - return fmt.Errorf("failed to get billable ratelimits for workspace %s: %w", e.Workspace.ID, err) - } - - usage := usedVerifications + usedRatelimits - - if usage > e.Quotas.RequestsPerMonth { - violationsFound++ - logger.Warn("workspace has exceeded request quota", - "workspace_id", e.Workspace.ID, - "workspace_name", e.Workspace.Name, - "usage", usage, - "limit", e.Quotas.RequestsPerMonth, - ) - - if slackWebhookURL != "" { - err = sendSlackNotification(slackWebhookURL, e, usage) - if err != nil { - return fmt.Errorf("failed to send slack notification for workspace %s: %w", e.Workspace.ID, err) - } - logger.Info("sent Slack notification", "workspace_id", e.Workspace.ID) - } - } - } - } - - logger.Info("quota check completed", - "total_workspaces_checked", counter, - "violations_found", violationsFound, - ) - - if violationsFound > 0 { - fmt.Printf("Found %d workspace(s) exceeding quotas out of %d checked\n", violationsFound, counter) - } else { - fmt.Printf("All %d workspaces are within their quota limits\n", counter) - } - - return nil -} - -func sendSlackNotification(webhookURL string, e db.ListWorkspacesRow, used int64) error { - payload := map[string]any{ - "text": fmt.Sprintf("Quota Exceeded: %s", e.Workspace.Name), - "blocks": []map[string]any{ - { - "type": "header", - "text": map[string]any{ - "type": "plain_text", - "text": fmt.Sprintf("🚨 Quota Exceeded: %s", e.Workspace.Name), - "emoji": true, - }, - }, - { - "type": "section", - "fields": []map[string]any{ - { - "type": "mrkdwn", - "text": fmt.Sprintf("*Workspace ID:*\n`%s`", e.Workspace.ID), - }, - { - "type": "mrkdwn", - "text": fmt.Sprintf("*Workspace Name:*\n%s", e.Workspace.Name), - }, - { - "type": "mrkdwn", - "text": fmt.Sprintf("*Organisation ID:*\n`%s`", e.Workspace.OrgID), - }, - { - "type": "mrkdwn", - "text": fmt.Sprintf("*Stripe ID:*\n`%s`", e.Workspace.StripeCustomerID.String), - }, - }, - }, - { - "type": "section", - "fields": []map[string]any{ - { - "type": "mrkdwn", - "text": fmt.Sprintf("*Workspace Tier:*\n%s", e.Workspace.Tier.String), - }, - { - "type": "mrkdwn", - "text": fmt.Sprintf("*Quota Type:*\nRequests Per Month"), - }, - }, - }, - { - "type": "section", - "fields": []map[string]any{ - { - "type": "mrkdwn", - "text": fmt.Sprintf("*Limit:*\n%s", message.NewPrinter(language.English).Sprint(number.Decimal(e.Quotas.RequestsPerMonth))), - }, - { - "type": "mrkdwn", - "text": fmt.Sprintf("*Used:*\n%s", message.NewPrinter(language.English).Sprint(number.Decimal(used))), - }, - }, - }, - { - "type": "section", - "text": map[string]any{ - "type": "mrkdwn", - "text": fmt.Sprintf("*Overage:* %s requests (%.1f%% over limit)", - message.NewPrinter(language.English).Sprint(number.Decimal(used-e.Quotas.RequestsPerMonth)), - float64(used-e.Quotas.RequestsPerMonth)/float64(e.Quotas.RequestsPerMonth)*100, - ), - }, - }, - }, - } - - slackBody, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal slack payload: %w", err) - } - - req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, webhookURL, bytes.NewBuffer(slackBody)) - if err != nil { - return fmt.Errorf("failed to create HTTP request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to send HTTP request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("slack notification failed with status code: %d", resp.StatusCode) - } - - return nil -} diff --git a/go/cmd/cli/commands/run/run.go b/go/cmd/cli/commands/run/run.go deleted file mode 100644 index e30354ea903..00000000000 --- a/go/cmd/cli/commands/run/run.go +++ /dev/null @@ -1,232 +0,0 @@ -package run - -import ( - "context" - "fmt" - - "github.com/unkeyed/unkey/go/apps/api" - "github.com/unkeyed/unkey/go/apps/ctrl" - "github.com/unkeyed/unkey/go/cmd/cli/cli" - "github.com/unkeyed/unkey/go/pkg/clock" - "github.com/unkeyed/unkey/go/pkg/tls" - "github.com/unkeyed/unkey/go/pkg/uid" -) - -var Command = &cli.Command{ - Name: "run", - Usage: "Run Unkey services", - Description: `Run various Unkey services including: - - api: The main API server for validating and managing API keys - - ctrl: The control plane service for managing infrastructure`, - Commands: []*cli.Command{ - apiCommand, - ctrlCommand, - }, -} - -var apiCommand = &cli.Command{ - Name: "api", - Usage: "Run the Unkey API server for validating and managing API keys", - Flags: []cli.Flag{ - // Server Configuration - cli.Int("http-port", "HTTP port for the API server to listen on", 7070, "UNKEY_HTTP_PORT", false), - cli.Bool("color", "Enable colored log output", "UNKEY_LOGS_COLOR", false), - cli.Bool("test-mode", "Enable test mode. WARNING: Potentially unsafe, may trust client inputs blindly", "UNKEY_TEST_MODE", false), - - // Instance Identification - cli.String("platform", "Cloud platform identifier for this node. Used for logging and metrics", "", "UNKEY_PLATFORM", false), - cli.String("image", "Container image identifier. Used for logging and metrics", "", "UNKEY_IMAGE", false), - cli.String("region", "Geographic region identifier. Used for logging and routing", "unknown", "UNKEY_REGION", false), - cli.String("instance-id", "Unique identifier for this instance. Auto-generated if not provided", uid.New(uid.InstancePrefix, 4), "UNKEY_INSTANCE_ID", false), - - // Database Configuration - cli.String("database-primary", "MySQL connection string for primary database. Required for all deployments. Example: user:pass@host:3306/unkey?parseTime=true", "", "UNKEY_DATABASE_PRIMARY", true), - cli.String("database-replica", "MySQL connection string for read-replica. Reduces load on primary database. Format same as database-primary", "", "UNKEY_DATABASE_REPLICA", false), - - // Caching and Storage - cli.String("redis-url", "Redis connection string for rate-limiting and distributed counters. Example: redis://localhost:6379", "", "UNKEY_REDIS_URL", false), - cli.String("clickhouse-url", "ClickHouse connection string for analytics. Recommended for production. Example: clickhouse://user:pass@host:9000/unkey", "", "UNKEY_CLICKHOUSE_URL", false), - - // Observability - cli.Bool("otel", "Enable OpenTelemetry tracing and metrics", "UNKEY_OTEL", false), - cli.Float("otel-trace-sampling-rate", "Sampling rate for OpenTelemetry traces (0.0-1.0). Only used when --otel is provided", 0.25, "UNKEY_OTEL_TRACE_SAMPLING_RATE", false), - cli.Int("prometheus-port", "Enable Prometheus /metrics endpoint on specified port. Set to 0 to disable", 0, "UNKEY_PROMETHEUS_PORT", false), - - // TLS Configuration - cli.String("tls-cert-file", "Path to TLS certificate file for HTTPS. Both cert and key must be provided to enable HTTPS", "", "UNKEY_TLS_CERT_FILE", false), - cli.String("tls-key-file", "Path to TLS key file for HTTPS. Both cert and key must be provided to enable HTTPS", "", "UNKEY_TLS_KEY_FILE", false), - - // Vault Configuration - cli.StringSlice("vault-master-keys", "Vault master keys for encryption (comma-separated)", []string{}, "UNKEY_VAULT_MASTER_KEYS", false), - cli.String("vault-s3-url", "S3 Compatible Endpoint URL", "", "UNKEY_VAULT_S3_URL", false), - cli.String("vault-s3-bucket", "S3 bucket name", "", "UNKEY_VAULT_S3_BUCKET", false), - cli.String("vault-s3-access-key-id", "S3 access key ID", "", "UNKEY_VAULT_S3_ACCESS_KEY_ID", false), - cli.String("vault-s3-secret-access-key", "S3 secret access key", "", "UNKEY_VAULT_S3_SECRET_ACCESS_KEY", false), - }, - Action: apiAction, -} - -var ctrlCommand = &cli.Command{ - Name: "ctrl", - Usage: "Run the Unkey control plane service for managing infrastructure and services", - Flags: []cli.Flag{ - // Server Configuration - cli.Int("http-port", "HTTP port for the control plane server to listen on", 8080, "UNKEY_HTTP_PORT", false), - cli.Bool("color", "Enable colored log output", "UNKEY_LOGS_COLOR", false), - - // Instance Identification - cli.String("platform", "Cloud platform identifier for this node. Used for logging and metrics", "", "UNKEY_PLATFORM", false), - cli.String("image", "Container image identifier. Used for logging and metrics", "", "UNKEY_IMAGE", false), - cli.String("region", "Geographic region identifier. Used for logging and routing", "unknown", "UNKEY_REGION", false), - cli.String("instance-id", "Unique identifier for this instance. Auto-generated if not provided", uid.New(uid.InstancePrefix, 4), "UNKEY_INSTANCE_ID", false), - - // Database Configuration - cli.String("database-primary", "MySQL connection string for primary database. Required for all deployments. Example: user:pass@host:3306/unkey?parseTime=true", "", "UNKEY_DATABASE_PRIMARY", true), - cli.String("database-hydra", "MySQL connection string for hydra database. Required for all deployments. Example: user:pass@host:3306/hydra?parseTime=true", "", "UNKEY_DATABASE_HYDRA", true), - - // Observability - cli.Bool("otel", "Enable OpenTelemetry tracing and metrics", "UNKEY_OTEL", false), - cli.Float("otel-trace-sampling-rate", "Sampling rate for OpenTelemetry traces (0.0-1.0). Only used when --otel is provided", 0.25, "UNKEY_OTEL_TRACE_SAMPLING_RATE", false), - - // TLS Configuration - cli.String("tls-cert-file", "Path to TLS certificate file for HTTPS. Both cert and key must be provided to enable HTTPS", "", "UNKEY_TLS_CERT_FILE", false), - cli.String("tls-key-file", "Path to TLS key file for HTTPS. Both cert and key must be provided to enable HTTPS", "", "UNKEY_TLS_KEY_FILE", false), - - // Control Plane Specific - cli.String("auth-token", "Authentication token for control plane API access. Required for secure deployments", "", "UNKEY_AUTH_TOKEN", false), - cli.String("metald-address", "Full URL of the metald service for VM operations. Required for deployments. Example: https://metald.example.com:8080", "", "UNKEY_METALD_ADDRESS", true), - cli.String("spiffe-socket-path", "Path to SPIFFE agent socket for mTLS authentication", "/var/lib/spire/agent/agent.sock", "UNKEY_SPIFFE_SOCKET_PATH", false), - }, - Action: ctrlAction, -} - -func apiAction(ctx context.Context, cmd *cli.Command) error { - // Check if TLS flags are properly set (both or none) - tlsCertFile := cmd.String("tls-cert-file") - tlsKeyFile := cmd.String("tls-key-file") - if (tlsCertFile == "" && tlsKeyFile != "") || (tlsCertFile != "" && tlsKeyFile == "") { - return fmt.Errorf("both --tls-cert-file and --tls-key-file must be provided to enable HTTPS") - } - - // Initialize TLS config if TLS flags are provided - var tlsConfig *tls.Config - if tlsCertFile != "" && tlsKeyFile != "" { - var err error - tlsConfig, err = tls.NewFromFiles(tlsCertFile, tlsKeyFile) - if err != nil { - return fmt.Errorf("failed to load TLS configuration: %w", err) - } - } - - // Parse vault master keys from StringSlice flag - vaultMasterKeys := cmd.StringSlice("vault-master-keys") - - // Get sampling rate directly as float - samplingRate := cmd.Float("otel-trace-sampling-rate") - - var vaultS3Config *api.S3Config - if cmd.String("vault-s3-url") != "" { - vaultS3Config = &api.S3Config{ - URL: cmd.String("vault-s3-url"), - Bucket: cmd.String("vault-s3-bucket"), - AccessKeyID: cmd.String("vault-s3-access-key-id"), - SecretAccessKey: cmd.String("vault-s3-secret-access-key"), - } - } - - config := api.Config{ - // Basic configuration - Platform: cmd.String("platform"), - Image: cmd.String("image"), - HttpPort: cmd.Int("http-port"), - Region: cmd.String("region"), - - // Database configuration - DatabasePrimary: cmd.String("database-primary"), - DatabaseReadonlyReplica: cmd.String("database-replica"), - - // ClickHouse - ClickhouseURL: cmd.String("clickhouse-url"), - - // OpenTelemetry configuration - OtelEnabled: cmd.Bool("otel"), - OtelTraceSamplingRate: samplingRate, - - // TLS Configuration - TLSConfig: tlsConfig, - - InstanceID: cmd.String("instance-id"), - RedisUrl: cmd.String("redis-url"), - PrometheusPort: cmd.Int("prometheus-port"), - Clock: clock.New(), - TestMode: cmd.Bool("test-mode"), - - // Vault configuration - VaultMasterKeys: vaultMasterKeys, - VaultS3: vaultS3Config, - } - - err := config.Validate() - if err != nil { - return err - } - - return api.Run(ctx, config) -} - -func ctrlAction(ctx context.Context, cmd *cli.Command) error { - // Check if TLS flags are properly set (both or none) - tlsCertFile := cmd.String("tls-cert-file") - tlsKeyFile := cmd.String("tls-key-file") - if (tlsCertFile == "" && tlsKeyFile != "") || (tlsCertFile != "" && tlsKeyFile == "") { - return fmt.Errorf("both --tls-cert-file and --tls-key-file must be provided to enable HTTPS") - } - - // Initialize TLS config if TLS flags are provided - var tlsConfig *tls.Config - if tlsCertFile != "" && tlsKeyFile != "" { - var err error - tlsConfig, err = tls.NewFromFiles(tlsCertFile, tlsKeyFile) - if err != nil { - return fmt.Errorf("failed to load TLS configuration: %w", err) - } - } - - // Get sampling rate directly as float - samplingRate := cmd.Float("otel-trace-sampling-rate") - - config := ctrl.Config{ - // Basic configuration - Platform: cmd.String("platform"), - Image: cmd.String("image"), - HttpPort: cmd.Int("http-port"), - Region: cmd.String("region"), - InstanceID: cmd.String("instance-id"), - - // Database configuration - DatabasePrimary: cmd.String("database-primary"), - DatabaseHydra: cmd.String("database-hydra"), - - // Observability - OtelEnabled: cmd.Bool("otel"), - OtelTraceSamplingRate: samplingRate, - - // TLS Configuration - TLSConfig: tlsConfig, - - // Control Plane Specific - AuthToken: cmd.String("auth-token"), - MetaldAddress: cmd.String("metald-address"), - SPIFFESocketPath: cmd.String("spiffe-socket-path"), - - // Common - Clock: clock.New(), - } - - err := config.Validate() - if err != nil { - return err - } - - return ctrl.Run(ctx, config) -} diff --git a/go/cmd/cli/main.go b/go/cmd/cli/main.go index f309e84128c..d96409f649b 100644 --- a/go/cmd/cli/main.go +++ b/go/cmd/cli/main.go @@ -7,10 +7,7 @@ import ( "github.com/unkeyed/unkey/go/cmd/cli/cli" "github.com/unkeyed/unkey/go/cmd/cli/commands/deploy" - "github.com/unkeyed/unkey/go/cmd/cli/commands/healthcheck" initcmd "github.com/unkeyed/unkey/go/cmd/cli/commands/init" - "github.com/unkeyed/unkey/go/cmd/cli/commands/quotacheck" - "github.com/unkeyed/unkey/go/cmd/cli/commands/run" "github.com/unkeyed/unkey/go/cmd/cli/commands/versions" "github.com/unkeyed/unkey/go/pkg/version" ) @@ -23,9 +20,6 @@ func main() { Commands: []*cli.Command{ deploy.Command, versions.Command, - run.Command, - healthcheck.Command, - quotacheck.Command, initcmd.Command, }, }