From 0b53841a4c68c033b2c259931a06a89433d30b0b Mon Sep 17 00:00:00 2001 From: ashish Date: Thu, 4 Jun 2026 19:04:48 +0530 Subject: [PATCH 1/8] feat: scaffold stackdome CLI with foundation packages Rename module to github.com/stackdome/cli, bump to Go 1.25.0. New internal packages: - errors: unified CLIError type with exit codes and HTTP mapping - config: ~/.stackdome/config.json with atomic writes - output: formatter (table/json/yaml) with TTY-aware colors - cmdutil: CommandContext, WithContext/RequireAuth/RequireStack middleware New entry point at cmd/stackdome/ with root command (global flags: --log-level, --no-color, --output) and version command. Makefile updated for stackdome binary with ldflags. --- .gitignore | 2 +- Makefile | 15 +++- cmd/stackdome/main.go | 7 ++ cmd/stackdome/root.go | 86 +++++++++++++++++++ cmd/stackdome/version.go | 29 +++++++ go.mod | 4 +- internal/cmdutil/context.go | 27 ++++++ internal/cmdutil/ctxhelper.go | 7 ++ internal/cmdutil/middleware.go | 47 ++++++++++ internal/config/config.go | 143 +++++++++++++++++++++++++++++++ internal/errors/errors.go | 152 +++++++++++++++++++++++++++++++++ internal/output/color.go | 60 +++++++++++++ internal/output/formatter.go | 113 ++++++++++++++++++++++++ 13 files changed, 686 insertions(+), 6 deletions(-) create mode 100644 cmd/stackdome/main.go create mode 100644 cmd/stackdome/root.go create mode 100644 cmd/stackdome/version.go create mode 100644 internal/cmdutil/context.go create mode 100644 internal/cmdutil/ctxhelper.go create mode 100644 internal/cmdutil/middleware.go create mode 100644 internal/config/config.go create mode 100644 internal/errors/errors.go create mode 100644 internal/output/color.go create mode 100644 internal/output/formatter.go diff --git a/.gitignore b/.gitignore index b497ff1..371546c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ bin/ -.vscode/ \ No newline at end of file +.vscode/stackdome diff --git a/Makefile b/Makefile index 2ee82f0..05f581d 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,12 @@ -binary: - go build -o bin/voyager cmd/main.go -.PHONY: binary +VERSION ?= dev +GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_DATE := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') +LDFLAGS := -X main.Version=$(VERSION) -X main.GitCommit=$(GIT_COMMIT) -X main.BuildDate=$(BUILD_DATE) + +.PHONY: build clean + +build: + go build -ldflags "$(LDFLAGS)" -o bin/stackdome ./cmd/stackdome + +clean: + rm -rf bin/ diff --git a/cmd/stackdome/main.go b/cmd/stackdome/main.go new file mode 100644 index 0000000..89ef398 --- /dev/null +++ b/cmd/stackdome/main.go @@ -0,0 +1,7 @@ +package main + +import "os" + +func main() { + os.Exit(run()) +} diff --git a/cmd/stackdome/root.go b/cmd/stackdome/root.go new file mode 100644 index 0000000..8a986e1 --- /dev/null +++ b/cmd/stackdome/root.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" + "github.com/stackdome/cli/internal/config" + clierrors "github.com/stackdome/cli/internal/errors" + "github.com/stackdome/cli/internal/output" +) + +var ( + flagLogLevel string + flagNoColor bool + flagOutput string +) + +func newRootCmd() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "stackdome", + Short: "CLI for the Stackdome platform", + Long: "Deploy, manage, and monitor your applications on Stackdome.", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + output.SetNoColor(flagNoColor) + + format, err := output.ParseFormat(flagOutput) + if err != nil { + return err + } + + level := parseLogLevel(flagLogLevel) + + cfg, err := config.Load() + if err != nil { + return err + } + + ctx := cmdutil.NewCommandContext(cfg, format, level) + cmdutil.SetContext(cmd, ctx) + return nil + }, + } + + rootCmd.PersistentFlags().StringVar(&flagLogLevel, "log-level", "warn", "Log level (debug, info, warn, error)") + rootCmd.PersistentFlags().BoolVar(&flagNoColor, "no-color", false, "Disable colored output") + rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "table", "Output format (table, json, yaml)") + + rootCmd.AddCommand(newVersionCmd()) + + return rootCmd +} + +func run() int { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + rootCmd := newRootCmd() + if err := rootCmd.ExecuteContext(ctx); err != nil { + msg := clierrors.UserMessage(err) + fmt.Fprintf(os.Stderr, "Error: %s\n", msg) + return clierrors.ExitCodeFrom(err) + } + return 0 +} + +func parseLogLevel(s string) slog.Level { + switch s { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelWarn + } +} diff --git a/cmd/stackdome/version.go b/cmd/stackdome/version.go new file mode 100644 index 0000000..bc187ca --- /dev/null +++ b/cmd/stackdome/version.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "runtime" + + "github.com/spf13/cobra" +) + +var ( + Version = "dev" + GitCommit = "unknown" + BuildDate = "unknown" +) + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the CLI version", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Printf("stackdome %s\n", Version) + fmt.Printf(" commit: %s\n", GitCommit) + fmt.Printf(" built: %s\n", BuildDate) + fmt.Printf(" go: %s\n", runtime.Version()) + fmt.Printf(" os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) + return nil + }, + } +} diff --git a/go.mod b/go.mod index ee5e45a..2ba9032 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ -module github.com/ashishmax31/voyager-cli +module github.com/stackdome/cli -go 1.22.5 +go 1.25.0 require ( github.com/ashishmax31/stackdome-api-server v0.0.0-00010101000000-000000000000 diff --git a/internal/cmdutil/context.go b/internal/cmdutil/context.go new file mode 100644 index 0000000..2feb830 --- /dev/null +++ b/internal/cmdutil/context.go @@ -0,0 +1,27 @@ +package cmdutil + +import ( + "log/slog" + "os" + + "github.com/stackdome/cli/internal/config" + "github.com/stackdome/cli/internal/output" +) + +type CommandContext struct { + Config *config.Config + Formatter *output.Formatter + Logger *slog.Logger +} + +func NewCommandContext(cfg *config.Config, format output.Format, logLevel slog.Level) *CommandContext { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: logLevel, + })) + + return &CommandContext{ + Config: cfg, + Formatter: output.NewFormatter(format), + Logger: logger, + } +} diff --git a/internal/cmdutil/ctxhelper.go b/internal/cmdutil/ctxhelper.go new file mode 100644 index 0000000..aae74c4 --- /dev/null +++ b/internal/cmdutil/ctxhelper.go @@ -0,0 +1,7 @@ +package cmdutil + +import "context" + +func withValue(parent context.Context, key, val any) context.Context { + return context.WithValue(parent, key, val) +} diff --git a/internal/cmdutil/middleware.go b/internal/cmdutil/middleware.go new file mode 100644 index 0000000..f1ae64c --- /dev/null +++ b/internal/cmdutil/middleware.go @@ -0,0 +1,47 @@ +package cmdutil + +import ( + "github.com/spf13/cobra" +) + +type contextKey struct{} + +func SetContext(cmd *cobra.Command, ctx *CommandContext) { + cmd.SetContext(withValue(cmd.Context(), contextKey{}, ctx)) +} + +func GetContext(cmd *cobra.Command) *CommandContext { + return cmd.Context().Value(contextKey{}).(*CommandContext) +} + +type RunEWithContext func(ctx *CommandContext, cmd *cobra.Command, args []string) error + +func WithContext(fn RunEWithContext) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + ctx := GetContext(cmd) + return fn(ctx, cmd, args) + } +} + +func RequireAuth(fn RunEWithContext) RunEWithContext { + return func(ctx *CommandContext, cmd *cobra.Command, args []string) error { + if err := ctx.Config.RequireAuth(); err != nil { + return err + } + return fn(ctx, cmd, args) + } +} + +func RequireStack(fn func(ctx *CommandContext, cmd *cobra.Command, args []string, stackName string) error) RunEWithContext { + return func(ctx *CommandContext, cmd *cobra.Command, args []string) error { + stackName, _ := cmd.Flags().GetString("stack") + if stackName == "" { + var err error + stackName, err = ctx.Config.RequireStack() + if err != nil { + return err + } + } + return fn(ctx, cmd, args, stackName) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2df0c0b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,143 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + clierrors "github.com/stackdome/cli/internal/errors" +) + +const ( + configDirName = ".stackdome" + configFileName = "config.json" + configFileMode = 0600 + configDirMode = 0700 +) + +type Config struct { + ServerURL string `json:"server_url"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` + TeamName string `json:"team_name,omitempty"` + Username string `json:"username,omitempty"` + CurrentStack string `json:"current_stack,omitempty"` + Insecure bool `json:"insecure,omitempty"` + + path string `json:"-"` +} + +func DefaultPath() (string, error) { + if p := os.Getenv("STACKDOME_CONFIG"); p != "" { + return p, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", clierrors.Wrap(err, "Cannot determine home directory") + } + return filepath.Join(home, configDirName, configFileName), nil +} + +func Load() (*Config, error) { + path, err := DefaultPath() + if err != nil { + return nil, err + } + return LoadFrom(path) +} + +func LoadFrom(path string) (*Config, error) { + cfg := &Config{path: path} + + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return cfg, nil + } + if err != nil { + return nil, clierrors.Wrapf(err, "Failed to read config from %s", path) + } + + if err := json.Unmarshal(data, cfg); err != nil { + return nil, clierrors.Wrapf(err, "Failed to parse config from %s", path) + } + cfg.path = path + return cfg, nil +} + +func (c *Config) Save() error { + if c.path == "" { + p, err := DefaultPath() + if err != nil { + return err + } + c.path = p + } + + dir := filepath.Dir(c.path) + if err := os.MkdirAll(dir, configDirMode); err != nil { + return clierrors.Wrapf(err, "Failed to create config directory %s", dir) + } + + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return clierrors.Wrap(err, "Failed to serialize config") + } + + tmp := c.path + ".tmp" + if err := os.WriteFile(tmp, data, configFileMode); err != nil { + return clierrors.Wrapf(err, "Failed to write config to %s", tmp) + } + if err := os.Rename(tmp, c.path); err != nil { + os.Remove(tmp) + return clierrors.Wrapf(err, "Failed to save config to %s", c.path) + } + return nil +} + +func (c *Config) Clear() error { + *c = Config{path: c.path} + return c.Save() +} + +func (c *Config) IsLoggedIn() bool { + return c.AccessToken != "" && c.ServerURL != "" +} + +func (c *Config) RequireAuth() error { + if !c.IsLoggedIn() { + return clierrors.AuthError("Not logged in. Run `stackdome login` first.") + } + return nil +} + +func (c *Config) RequireStack() (string, error) { + if err := c.RequireAuth(); err != nil { + return "", err + } + if c.CurrentStack == "" { + return "", clierrors.New("No stack selected. Run `stackdome deploy` or use `--stack `.") + } + return c.CurrentStack, nil +} + +func (c *Config) SetCurrentStack(name string) error { + c.CurrentStack = name + return c.Save() +} + +func (c *Config) Path() string { + return c.path +} + +func (c *Config) Summary() string { + if !c.IsLoggedIn() { + return "Not logged in" + } + s := fmt.Sprintf("Server: %s\nUser: %s\nOrg: %s", c.ServerURL, c.Username, c.OrganizationID) + if c.CurrentStack != "" { + s += fmt.Sprintf("\nStack: %s", c.CurrentStack) + } + return s +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..d360d5f --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,152 @@ +package errors + +import ( + "errors" + "fmt" +) + +const ( + ExitGeneral = 1 + ExitAuth = 2 + ExitNotFound = 3 + ExitValidation = 4 + ExitConflict = 5 + ExitUserCanceled = 130 +) + +type CLIError struct { + Message string + Detail string + Code string + ExitCode int + Cause error +} + +func (e *CLIError) Error() string { + if e.Detail != "" { + return fmt.Sprintf("%s: %s", e.Message, e.Detail) + } + return e.Message +} + +func (e *CLIError) Unwrap() error { + return e.Cause +} + +func New(message string) *CLIError { + return &CLIError{ + Message: message, + ExitCode: ExitGeneral, + } +} + +func Newf(format string, args ...any) *CLIError { + return &CLIError{ + Message: fmt.Sprintf(format, args...), + ExitCode: ExitGeneral, + } +} + +func Wrap(err error, message string) *CLIError { + return &CLIError{ + Message: message, + ExitCode: ExitGeneral, + Cause: err, + } +} + +func Wrapf(err error, format string, args ...any) *CLIError { + return &CLIError{ + Message: fmt.Sprintf(format, args...), + ExitCode: ExitGeneral, + Cause: err, + } +} + +func (e *CLIError) WithCode(code string) *CLIError { + e.Code = code + return e +} + +func (e *CLIError) WithDetail(detail string) *CLIError { + e.Detail = detail + return e +} + +func (e *CLIError) WithExitCode(code int) *CLIError { + e.ExitCode = code + return e +} + +func AuthError(message string) *CLIError { + return &CLIError{ + Message: message, + Code: "AUTH_ERROR", + ExitCode: ExitAuth, + } +} + +func NotFoundError(resource, name string) *CLIError { + return &CLIError{ + Message: fmt.Sprintf("%s %q not found", resource, name), + Code: "NOT_FOUND", + ExitCode: ExitNotFound, + } +} + +func ValidationError(message string) *CLIError { + return &CLIError{ + Message: message, + Code: "VALIDATION_ERROR", + ExitCode: ExitValidation, + } +} + +func FromHTTP(statusCode int, body string) *CLIError { + e := &CLIError{ + Detail: body, + } + switch { + case statusCode == 401: + e.Message = "Authentication failed. Run `stackdome login` to re-authenticate." + e.Code = "AUTH_EXPIRED" + e.ExitCode = ExitAuth + case statusCode == 403: + e.Message = "Permission denied" + e.Code = "FORBIDDEN" + e.ExitCode = ExitAuth + case statusCode == 404: + e.Message = "Resource not found" + e.Code = "NOT_FOUND" + e.ExitCode = ExitNotFound + case statusCode == 409: + e.Message = "Conflict" + e.Code = "CONFLICT" + e.ExitCode = ExitConflict + case statusCode >= 500: + e.Message = "Server error — try again later" + e.Code = "SERVER_ERROR" + e.ExitCode = ExitGeneral + default: + e.Message = fmt.Sprintf("Request failed (HTTP %d)", statusCode) + e.Code = "REQUEST_FAILED" + e.ExitCode = ExitGeneral + } + return e +} + +func ExitCodeFrom(err error) int { + var cliErr *CLIError + if errors.As(err, &cliErr) { + return cliErr.ExitCode + } + return ExitGeneral +} + +func UserMessage(err error) string { + var cliErr *CLIError + if errors.As(err, &cliErr) { + return cliErr.Message + } + return err.Error() +} diff --git a/internal/output/color.go b/internal/output/color.go new file mode 100644 index 0000000..fceec47 --- /dev/null +++ b/internal/output/color.go @@ -0,0 +1,60 @@ +package output + +import ( + "os" + + "golang.org/x/term" +) + +const ( + reset = "\033[0m" + red = "\033[31m" + green = "\033[32m" + yellow = "\033[33m" + blue = "\033[34m" + magenta = "\033[35m" + cyan = "\033[36m" + bold = "\033[1m" + dim = "\033[2m" +) + +var noColor bool + +func SetNoColor(v bool) { + noColor = v +} + +func isTTY() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + +func colorize(color, text string) string { + if noColor || !isTTY() { + return text + } + return color + text + reset +} + +func Red(text string) string { return colorize(red, text) } +func Green(text string) string { return colorize(green, text) } +func Yellow(text string) string { return colorize(yellow, text) } +func Blue(text string) string { return colorize(blue, text) } +func Magenta(text string) string { return colorize(magenta, text) } +func Cyan(text string) string { return colorize(cyan, text) } +func Bold(text string) string { return colorize(bold, text) } +func Dim(text string) string { return colorize(dim, text) } + +func StateColor(state string) string { + switch state { + case "Ready": + return Green(state) + case "Pending": + return Yellow(state) + case "Failed", "Error": + return Red(state) + case "Deleting": + return Magenta(state) + default: + return state + } +} diff --git a/internal/output/formatter.go b/internal/output/formatter.go new file mode 100644 index 0000000..c1e27cd --- /dev/null +++ b/internal/output/formatter.go @@ -0,0 +1,113 @@ +package output + +import ( + "encoding/json" + "fmt" + "io" + "os" + "text/tabwriter" + + "gopkg.in/yaml.v3" +) + +type Format string + +const ( + FormatTable Format = "table" + FormatJSON Format = "json" + FormatYAML Format = "yaml" +) + +func ParseFormat(s string) (Format, error) { + switch s { + case "table", "": + return FormatTable, nil + case "json": + return FormatJSON, nil + case "yaml": + return FormatYAML, nil + default: + return "", fmt.Errorf("unknown output format %q (valid: table, json, yaml)", s) + } +} + +type Formatter struct { + Format Format + Writer io.Writer +} + +func NewFormatter(format Format) *Formatter { + return &Formatter{ + Format: format, + Writer: os.Stdout, + } +} + +func (f *Formatter) PrintJSON(v any) error { + enc := json.NewEncoder(f.Writer) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func (f *Formatter) PrintYAML(v any) error { + enc := yaml.NewEncoder(f.Writer) + enc.SetIndent(2) + defer enc.Close() + return enc.Encode(v) +} + +func (f *Formatter) PrintStructured(v any) error { + switch f.Format { + case FormatJSON: + return f.PrintJSON(v) + case FormatYAML: + return f.PrintYAML(v) + default: + return nil + } +} + +func (f *Formatter) IsTable() bool { + return f.Format == FormatTable +} + +func (f *Formatter) NewTable() *Table { + tw := tabwriter.NewWriter(f.Writer, 0, 0, 2, ' ', 0) + return &Table{tw: tw} +} + +type Table struct { + tw *tabwriter.Writer +} + +func (t *Table) AddHeader(cols ...string) { + for i, col := range cols { + if i > 0 { + fmt.Fprint(t.tw, "\t") + } + fmt.Fprint(t.tw, Bold(col)) + } + fmt.Fprintln(t.tw) +} + +func (t *Table) AddRow(cols ...string) { + for i, col := range cols { + if i > 0 { + fmt.Fprint(t.tw, "\t") + } + fmt.Fprint(t.tw, col) + } + fmt.Fprintln(t.tw) +} + +func (t *Table) Flush() error { + return t.tw.Flush() +} + +func (f *Formatter) Println(args ...any) { + fmt.Fprintln(f.Writer, args...) +} + +func (f *Formatter) Printf(format string, args ...any) { + fmt.Fprintf(f.Writer, format, args...) +} From 59c39a00ee281ca721401c4b274830782bc365a3 Mon Sep 17 00:00:00 2001 From: ashish Date: Thu, 4 Jun 2026 19:58:14 +0530 Subject: [PATCH 2/8] feat: add API client, auth commands, and config management API client wrapping the generated OpenAPI client with bearer token auth via default headers, transparent token refresh, and org/team scoping. Commands: - stackdome login (interactive email/password + --token for CI) - stackdome logout - stackdome signup (with --org for organisation creation) - stackdome config view (table + json/yaml output) - stackdome config set-context (switch server) - stackdome config set-stack (set current stack context) --- cmd/stackdome/config.go | 80 +++++++++++++++++++ cmd/stackdome/login.go | 151 +++++++++++++++++++++++++++++++++++ cmd/stackdome/logout.go | 23 ++++++ cmd/stackdome/root.go | 4 + cmd/stackdome/signup.go | 86 ++++++++++++++++++++ go.mod | 112 +++++++++++++------------- go.sum | 63 +++++++++++++++ internal/client/auth.go | 94 ++++++++++++++++++++++ internal/client/client.go | 155 ++++++++++++++++++++++++++++++++++++ internal/cmdutil/context.go | 17 ++++ 10 files changed, 729 insertions(+), 56 deletions(-) create mode 100644 cmd/stackdome/config.go create mode 100644 cmd/stackdome/login.go create mode 100644 cmd/stackdome/logout.go create mode 100644 cmd/stackdome/signup.go create mode 100644 internal/client/auth.go create mode 100644 internal/client/client.go diff --git a/cmd/stackdome/config.go b/cmd/stackdome/config.go new file mode 100644 index 0000000..7bf9324 --- /dev/null +++ b/cmd/stackdome/config.go @@ -0,0 +1,80 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" + clierrors "github.com/stackdome/cli/internal/errors" +) + +func newConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage CLI configuration", + } + + cmd.AddCommand(newConfigViewCmd()) + cmd.AddCommand(newConfigSetContextCmd()) + cmd.AddCommand(newConfigSetStackCmd()) + return cmd +} + +func newConfigViewCmd() *cobra.Command { + return &cobra.Command{ + Use: "view", + Short: "Show current configuration", + RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(ctx.Config) + } + fmt.Fprintln(os.Stdout, ctx.Config.Summary()) + return nil + }), + } +} + +func newConfigSetStackCmd() *cobra.Command { + return &cobra.Command{ + Use: "set-stack ", + Short: "Set the current stack context", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + if err := ctx.Config.SetCurrentStack(args[0]); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Current stack set to %s\n", args[0]) + return nil + }), + } +} + +func newConfigSetContextCmd() *cobra.Command { + return &cobra.Command{ + Use: "set-context ", + Short: "Switch to a different Stackdome server", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + newURL := args[0] + if newURL == "" { + return clierrors.ValidationError("URL cannot be empty") + } + + ctx.Config.ServerURL = newURL + ctx.Config.AccessToken = "" + ctx.Config.RefreshToken = "" + ctx.Config.OrganizationID = "" + ctx.Config.TeamName = "" + ctx.Config.Username = "" + ctx.Config.CurrentStack = "" + + if err := ctx.Config.Save(); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Context switched to %s. Run `stackdome login` to authenticate.\n", newURL) + return nil + }), + } +} diff --git a/cmd/stackdome/login.go b/cmd/stackdome/login.go new file mode 100644 index 0000000..5a7dc21 --- /dev/null +++ b/cmd/stackdome/login.go @@ -0,0 +1,151 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + + serverapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/client" + "github.com/stackdome/cli/internal/config" + clierrors "github.com/stackdome/cli/internal/errors" + "golang.org/x/term" +) + +func newLoginCmd() *cobra.Command { + var ( + flagURL string + flagEmail string + flagPassword string + flagToken string + flagInsecure bool + ) + + cmd := &cobra.Command{ + Use: "login", + Short: "Authenticate with a Stackdome server", + RunE: func(cmd *cobra.Command, args []string) error { + if flagURL == "" { + return clierrors.ValidationError("--url is required") + } + + cfg, err := config.Load() + if err != nil { + return err + } + + if flagToken != "" { + return loginWithToken(cmd, cfg, flagURL, flagToken, flagInsecure) + } + return loginWithCredentials(cmd, cfg, flagURL, flagEmail, flagPassword, flagInsecure) + }, + } + + cmd.Flags().StringVar(&flagURL, "url", "", "Stackdome server URL (required)") + cmd.Flags().StringVar(&flagEmail, "email", "", "Email address") + cmd.Flags().StringVar(&flagPassword, "password", "", "Password") + cmd.Flags().StringVar(&flagToken, "token", "", "API token (skips email/password)") + cmd.Flags().BoolVar(&flagInsecure, "insecure", false, "Allow insecure HTTPS") + + return cmd +} + +func loginWithToken(cmd *cobra.Command, cfg *config.Config, serverURL, token string, insecure bool) error { + c := client.New(serverURL, + client.WithTokens(token, ""), + client.WithInsecure(insecure), + ) + + user, err := c.GetCurrentUser(cmd.Context()) + if err != nil { + return err + } + + teamName, err := c.ResolveDefaultTeam(cmd.Context(), user.GetOrganisationId()) + if err != nil { + return err + } + + cfg.ServerURL = serverURL + cfg.AccessToken = token + cfg.RefreshToken = "" + cfg.OrganizationID = user.GetOrganisationId() + cfg.TeamName = teamName + cfg.Username = userDisplayName(user) + cfg.Insecure = insecure + + if err := cfg.Save(); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Logged in as %s\n", cfg.Username) + return nil +} + +func loginWithCredentials(cmd *cobra.Command, cfg *config.Config, serverURL, email, password string, insecure bool) error { + if email == "" { + email = promptInput("Email: ") + } + if password == "" { + password = promptPassword("Password: ") + } + + c := client.New(serverURL, client.WithInsecure(insecure)) + + result, err := c.Login(cmd.Context(), email, password) + if err != nil { + return err + } + + teamName, err := c.ResolveDefaultTeam(cmd.Context(), result.User.GetOrganisationId()) + if err != nil { + return err + } + + cfg.ServerURL = serverURL + cfg.AccessToken = result.AccessToken + cfg.RefreshToken = result.RefreshToken + cfg.OrganizationID = result.User.GetOrganisationId() + cfg.TeamName = teamName + cfg.Username = userDisplayName(result.User) + cfg.Insecure = insecure + + if err := cfg.Save(); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Logged in as %s\n", cfg.Username) + return nil +} + +func userDisplayName(u *serverapi.User) string { + if u == nil { + return "" + } + if name := u.GetUsername(); name != "" { + return name + } + if name := u.GetName(); name != "" { + return name + } + return u.GetEmail() +} + +func promptInput(prompt string) string { + fmt.Fprint(os.Stderr, prompt) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + return strings.TrimSpace(scanner.Text()) +} + +func promptPassword(prompt string) string { + fmt.Fprint(os.Stderr, prompt) + b, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return "" + } + return string(b) +} diff --git a/cmd/stackdome/logout.go b/cmd/stackdome/logout.go new file mode 100644 index 0000000..23e9ce6 --- /dev/null +++ b/cmd/stackdome/logout.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" +) + +func newLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Clear stored credentials", + RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + if err := ctx.Config.Clear(); err != nil { + return err + } + fmt.Fprintln(os.Stderr, "Logged out.") + return nil + }), + } +} diff --git a/cmd/stackdome/root.go b/cmd/stackdome/root.go index 8a986e1..a340af9 100644 --- a/cmd/stackdome/root.go +++ b/cmd/stackdome/root.go @@ -53,6 +53,10 @@ func newRootCmd() *cobra.Command { rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "table", "Output format (table, json, yaml)") rootCmd.AddCommand(newVersionCmd()) + rootCmd.AddCommand(newLoginCmd()) + rootCmd.AddCommand(newLogoutCmd()) + rootCmd.AddCommand(newSignupCmd()) + rootCmd.AddCommand(newConfigCmd()) return rootCmd } diff --git a/cmd/stackdome/signup.go b/cmd/stackdome/signup.go new file mode 100644 index 0000000..825c925 --- /dev/null +++ b/cmd/stackdome/signup.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/client" + "github.com/stackdome/cli/internal/config" + clierrors "github.com/stackdome/cli/internal/errors" +) + +func newSignupCmd() *cobra.Command { + var ( + flagURL string + flagName string + flagEmail string + flagPassword string + flagOrg string + flagInsecure bool + ) + + cmd := &cobra.Command{ + Use: "signup", + Short: "Create a new Stackdome account", + RunE: func(cmd *cobra.Command, args []string) error { + if flagURL == "" { + return clierrors.ValidationError("--url is required") + } + + if flagName == "" { + flagName = promptInput("Name: ") + } + if flagEmail == "" { + flagEmail = promptInput("Email: ") + } + if flagPassword == "" { + flagPassword = promptPassword("Password: ") + } + if flagOrg == "" { + flagOrg = promptInput("Organisation name: ") + } + + c := client.New(flagURL, client.WithInsecure(flagInsecure)) + + result, err := c.Signup(cmd.Context(), flagName, flagEmail, flagPassword, flagOrg) + if err != nil { + return err + } + + teamName, err := c.ResolveDefaultTeam(cmd.Context(), result.User.GetOrganisationId()) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + cfg.ServerURL = flagURL + cfg.AccessToken = result.AccessToken + cfg.RefreshToken = result.RefreshToken + cfg.OrganizationID = result.User.GetOrganisationId() + cfg.TeamName = teamName + cfg.Username = userDisplayName(result.User) + cfg.Insecure = flagInsecure + + if err := cfg.Save(); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Account created. Logged in as %s\n", cfg.Username) + return nil + }, + } + + cmd.Flags().StringVar(&flagURL, "url", "", "Stackdome server URL (required)") + cmd.Flags().StringVar(&flagName, "name", "", "Your name") + cmd.Flags().StringVar(&flagEmail, "email", "", "Email address") + cmd.Flags().StringVar(&flagPassword, "password", "", "Password") + cmd.Flags().StringVar(&flagOrg, "org", "", "Organisation name") + cmd.Flags().BoolVar(&flagInsecure, "insecure", false, "Allow insecure HTTPS") + + return cmd +} diff --git a/go.mod b/go.mod index 2ba9032..55451f3 100644 --- a/go.mod +++ b/go.mod @@ -5,63 +5,65 @@ go 1.25.0 require ( github.com/ashishmax31/stackdome-api-server v0.0.0-00010101000000-000000000000 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc - github.com/fsnotify/fsnotify v1.7.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/go-playground/validator v9.31.0+incompatible github.com/gofrs/flock v0.8.1 github.com/hashicorp/go-envparse v0.1.0 github.com/hashicorp/go-getter v1.7.4 github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cobra v1.8.0 - github.com/spf13/pflag v1.0.5 - golang.org/x/crypto v0.22.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + golang.org/x/crypto v0.49.0 + golang.org/x/term v0.41.0 gopkg.in/yaml.v2 v2.4.0 - k8s.io/api v0.30.1 - k8s.io/apimachinery v0.30.1 - k8s.io/client-go v0.30.1 - k8s.io/kubectl v0.30.0 - k8s.io/utils v0.0.0-20240102154912-e7106e64919e - sigs.k8s.io/controller-runtime v0.18.4 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.35.2 + k8s.io/apimachinery v0.35.2 + k8s.io/client-go v0.35.2 + k8s.io/kubectl v0.32.3 + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 + sigs.k8s.io/controller-runtime v0.23.1 soradev.io/cluster-agent v0.0.0-00010101000000-000000000000 ) require ( cloud.google.com/go v0.111.0 // indirect - cloud.google.com/go/compute/metadata v0.3.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.1.5 // indirect cloud.google.com/go/storage v1.30.1 // indirect - github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/aws/aws-sdk-go v1.44.122 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/chai2010/gettext-go v1.0.2 // indirect - github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch v4.12.0+incompatible // indirect - github.com/evanphx/json-patch/v5 v5.9.0 // indirect - github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/camelcase v1.0.0 // indirect - github.com/go-errors/errors v1.4.2 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-errors/errors v1.5.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.4 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/swag v0.25.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/btree v1.0.1 // indirect - github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect - github.com/gorilla/websocket v1.5.0 // indirect - github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/hashicorp/go-version v1.6.0 // indirect @@ -70,17 +72,17 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.15.14 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/moby/spdystream v0.2.0 // indirect - github.com/moby/term v0.0.0-20221205130635-1aeaba878587 // indirect + github.com/moby/spdystream v0.5.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect @@ -90,35 +92,33 @@ require ( github.com/ulikunitz/xz v0.5.10 // indirect github.com/xlab/treeprint v1.2.0 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/otel v1.19.0 // indirect - go.opentelemetry.io/otel/metric v1.19.0 // indirect - go.opentelemetry.io/otel/trace v1.19.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect - golang.org/x/net v0.23.0 // indirect - golang.org/x/oauth2 v0.20.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.19.0 // indirect - golang.org/x/term v0.19.0 // indirect - golang.org/x/text v0.21.0 // indirect - golang.org/x/time v0.5.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.149.0 // indirect google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe // indirect - google.golang.org/grpc v1.61.0 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/go-playground/assert.v1 v1.2.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/cli-runtime v0.30.0 // indirect - k8s.io/component-base v0.30.1 // indirect - k8s.io/klog/v2 v2.120.1 // indirect - k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3 // indirect - sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + k8s.io/cli-runtime v0.34.2 // indirect + k8s.io/component-base v0.35.2 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.21.0 // indirect + sigs.k8s.io/kustomize/kyaml v0.21.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) replace soradev.io/cluster-agent => ../cluster-agent diff --git a/go.sum b/go.sum index c342c2f..2c49a1a 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,7 @@ cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQH cloud.google.com/go/compute v1.10.0/go.mod h1:ER5CLbMxl90o2jtNbGSbtfOpQKR0t15FOtRsugnLrlU= cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/containeranalysis v0.5.1/go.mod h1:1D92jd8gRR/c0fGMlymRgxWD3Qw9C1ff6/T7mLgVL8I= cloud.google.com/go/containeranalysis v0.6.0/go.mod h1:HEJoiEIu+lEXM+k7+qLCci0h33lX3ZqoYFdmPcoO7s4= cloud.google.com/go/datacatalog v1.3.0/go.mod h1:g9svFY6tuR+j+hrTw3J2dNcmI0dzmSiyOzm8kpLq0a0= @@ -185,6 +186,7 @@ cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoIS dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -217,6 +219,7 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -225,6 +228,7 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -239,32 +243,40 @@ github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwCFad8crR9dcMQWvV9Hvulu6hwUh4tWPJnM= github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -283,6 +295,7 @@ github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4er github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= @@ -316,8 +329,10 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -335,6 +350,7 @@ github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -361,6 +377,7 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= @@ -390,8 +407,10 @@ github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= @@ -427,6 +446,7 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= github.com/klauspost/compress v1.15.14 h1:i7WCKDToww0wA+9qrUZ1xOjp218vfFo3nTU6UHp+gOc= github.com/klauspost/compress v1.15.14/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -440,6 +460,7 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= @@ -451,13 +472,16 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/term v0.0.0-20221205130635-1aeaba878587 h1:HfkjXDfhgVaN5rmueG8cL8KKeFNecRCXFhaJ2qZ5SKA= github.com/moby/term v0.0.0-20221205130635-1aeaba878587/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -488,8 +512,13 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= @@ -525,12 +554,15 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE= go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.starlark.net v0.0.0-20230525235612-a134d8f9ddca h1:VdD38733bfYv5tUZwEIskMM93VanwNIi5bIKnDrJdEY= go.starlark.net v0.0.0-20230525235612-a134d8f9ddca/go.mod h1:jxU+3+j+71eXOW14274+SmmuW82qJzl6iZSeqEtTGds= @@ -538,6 +570,7 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -546,6 +579,7 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -633,6 +667,7 @@ golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfS golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -660,6 +695,8 @@ golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A= golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -676,6 +713,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -743,12 +781,16 @@ golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -761,11 +803,13 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -993,8 +1037,10 @@ google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac h1:ZL/Teoy/ZGnzyrq google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:+Rvu7ElI+aLzyDQhpHMFMMltsD6m7nqpuWDd2CwJw3k= google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 h1:rcS6EyEaoCO52hQDupoSfrxI3R6C2Tq741is7X8OvnM= google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe h1:bQnxqljG/wqi4NTXu2+DJ3n7APcEA882QZ1JvhQAq9o= google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1032,6 +1078,7 @@ google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCD google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0= google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1050,6 +1097,7 @@ google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -1077,36 +1125,51 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= k8s.io/api v0.30.1 h1:kCm/6mADMdbAxmIh0LBjS54nQBE+U4KmbCfIkF5CpJY= k8s.io/api v0.30.1/go.mod h1:ddbN2C0+0DIiPntan/bye3SW3PdwLa11/0yqwvuRrJM= +k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= k8s.io/apiextensions-apiserver v0.30.1 h1:4fAJZ9985BmpJG6PkoxVRpXv9vmPUOVzl614xarePws= k8s.io/apiextensions-apiserver v0.30.1/go.mod h1:R4GuSrlhgq43oRY9sF2IToFh7PVlF1JjfWdoG3pixk4= k8s.io/apimachinery v0.30.1 h1:ZQStsEfo4n65yAdlGTfP/uSHMQSoYzU/oeEbkmF7P2U= k8s.io/apimachinery v0.30.1/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/cli-runtime v0.30.0 h1:0vn6/XhOvn1RJ2KJOC6IRR2CGqrpT6QQF4+8pYpWQ48= k8s.io/cli-runtime v0.30.0/go.mod h1:vATpDMATVTMA79sZ0YUCzlMelf6rUjoBzlp+RnoM+cg= +k8s.io/cli-runtime v0.34.2/go.mod h1:X13tsrYexYUCIq8MarCBy8lrm0k0weFPTpcaNo7lms4= k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q= k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc= +k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= k8s.io/component-base v0.30.1 h1:bvAtlPh1UrdaZL20D9+sWxsJljMi0QZ3Lmw+kmZAaxQ= k8s.io/component-base v0.30.1/go.mod h1:e/X9kDiOebwlI41AvBHuWdqFriSRrX50CdwA9TFaHLI= +k8s.io/component-base v0.35.2/go.mod h1:B1iBJjooe6xIJYUucAxb26RwhAjzx0gHnqO9htWIX+0= k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/kubectl v0.30.0 h1:xbPvzagbJ6RNYVMVuiHArC1grrV5vSmmIcSZuCdzRyk= k8s.io/kubectl v0.30.0/go.mod h1:zgolRw2MQXLPwmic2l/+iHs239L49fhSeICuMhQQXTI= +k8s.io/kubectl v0.32.3/go.mod h1:6Euv2aso5GKzo/UVMacV6C7miuyevpfI91SvBvV9Zdg= k8s.io/utils v0.0.0-20240102154912-e7106e64919e h1:eQ/4ljkx21sObifjzXwlPKpdGLrCfRziVtos3ofG/sQ= k8s.io/utils v0.0.0-20240102154912-e7106e64919e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/controller-runtime v0.18.4 h1:87+guW1zhvuPLh1PHybKdYFLU0YJp4FhJRmiHvm5BZw= sigs.k8s.io/controller-runtime v0.18.4/go.mod h1:TVoGrfdpbA9VRFaRnKgk9P5/atA0pMwq+f+msb9M8Sg= +sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3 h1:XX3Ajgzov2RKUdc5jW3t5jwY7Bo7dcRm+tFxT+NfgY0= sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3/go.mod h1:9n16EZKMhXBNSiUC5kSdFQJkdH3zbxS/JoO619G1VAY= +sigs.k8s.io/kustomize/api v0.21.0/go.mod h1:XGVQuR5n2pXKWbzXHweZU683pALGw/AMVO4zU4iS8SE= sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 h1:W6cLQc5pnqM7vh3b7HvGNfXrJ/xL6BDMS0v1V/HHg5U= sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3/go.mod h1:JWP1Fj0VWGHyw3YUPjXSQnRnrwezrZSrApfX5S0nIag= +sigs.k8s.io/kustomize/kyaml v0.21.0/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/client/auth.go b/internal/client/auth.go new file mode 100644 index 0000000..f64f2c6 --- /dev/null +++ b/internal/client/auth.go @@ -0,0 +1,94 @@ +package client + +import ( + "context" + + serverapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + clierrors "github.com/stackdome/cli/internal/errors" +) + +type LoginResult struct { + AccessToken string + RefreshToken string + User *serverapi.User +} + +type SignupResult struct { + AccessToken string + RefreshToken string + User *serverapi.User +} + +func (c *Client) Login(ctx context.Context, email, password string) (*LoginResult, error) { + loginReq := serverapi.NewLoginRequest(email, password) + + resp, httpResp, err := c.apiClient.DefaultApi.ApiV1AuthLoginPost(ctx). + LoginRequest(*loginReq).Execute() + if err != nil { + if httpResp != nil && httpResp.StatusCode == 401 { + return nil, clierrors.AuthError("Invalid email or password.") + } + return nil, WrapError(httpResp, err, "Login failed") + } + + c.SetTokens(resp.GetToken(), resp.GetRefreshToken()) + + return &LoginResult{ + AccessToken: resp.GetToken(), + RefreshToken: resp.GetRefreshToken(), + User: resp.User, + }, nil +} + +func (c *Client) Signup(ctx context.Context, name, email, password, orgName string) (*SignupResult, error) { + signupReq := serverapi.NewUserSignupRequest(name, email, password) + org := serverapi.NewOrganisation() + org.SetName(orgName) + signupReq.SetOrganisation(*org) + + resp, httpResp, err := c.apiClient.DefaultApi.ApiV1UserSignupPost(ctx). + UserSignupRequest(*signupReq).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Signup failed") + } + + c.SetTokens(resp.GetJwtToken(), resp.GetRefreshToken()) + + return &SignupResult{ + AccessToken: resp.GetJwtToken(), + RefreshToken: resp.GetRefreshToken(), + User: resp.User, + }, nil +} + +func (c *Client) GetCurrentUser(ctx context.Context) (*serverapi.User, error) { + resp, httpResp, err := c.apiClient.DefaultApi.ApiV1UsersCurrentGet(ctx).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to get current user") + } + return resp, nil +} + +func (c *Client) GetOrgTeams(ctx context.Context, orgID string) ([]serverapi.Team, error) { + resp, httpResp, err := c.apiClient.DefaultApi.ApiV1OrganizationsOrgIdTeamsGet(ctx, orgID).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to get teams") + } + return resp.Items, nil +} + +func (c *Client) ResolveDefaultTeam(ctx context.Context, orgID string) (string, error) { + teams, err := c.GetOrgTeams(ctx, orgID) + if err != nil { + return "", err + } + for _, t := range teams { + if t.DefaultTeam != nil && *t.DefaultTeam { + return t.Name, nil + } + } + if len(teams) > 0 { + return teams[0].Name, nil + } + return "", clierrors.New("No teams found for your account.") +} diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..436c0b9 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,155 @@ +package client + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "net/url" + "time" + + serverapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + clierrors "github.com/stackdome/cli/internal/errors" +) + +const defaultTimeout = 30 * time.Second + +type Client struct { + apiClient *serverapi.APIClient + cfg *serverapi.Configuration + accessToken string + refreshToken string + orgID string + teamName string + baseURL string + onTokenRefresh func(accessToken, refreshToken string) error +} + +type Option func(*Client) + +func WithInsecure(insecure bool) Option { + return func(c *Client) { + if insecure { + c.cfg.HTTPClient = &http.Client{ + Timeout: defaultTimeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + } + } +} + +func WithTokens(accessToken, refreshToken string) Option { + return func(c *Client) { + c.accessToken = accessToken + c.refreshToken = refreshToken + } +} + +func WithOrgAndTeam(orgID, teamName string) Option { + return func(c *Client) { + c.orgID = orgID + c.teamName = teamName + } +} + +func WithTokenRefreshCallback(fn func(accessToken, refreshToken string) error) Option { + return func(c *Client) { + c.onTokenRefresh = fn + } +} + +func New(baseURL string, opts ...Option) *Client { + cfg := serverapi.NewConfiguration() + cfg.Servers = serverapi.ServerConfigurations{ + {URL: baseURL}, + } + cfg.UserAgent = "stackdome-cli" + cfg.HTTPClient = &http.Client{ + Timeout: defaultTimeout, + } + + c := &Client{ + cfg: cfg, + baseURL: baseURL, + } + + for _, opt := range opts { + opt(c) + } + + c.apiClient = serverapi.NewAPIClient(cfg) + c.applyAuth() + return c +} + +func (c *Client) API() *serverapi.DefaultApiService { + return c.apiClient.DefaultApi +} + +func (c *Client) applyAuth() { + if c.accessToken != "" { + c.cfg.DefaultHeader["Authorization"] = "Bearer " + c.accessToken + } +} + +func (c *Client) SetTokens(accessToken, refreshToken string) { + c.accessToken = accessToken + c.refreshToken = refreshToken + c.applyAuth() +} + +func (c *Client) SetOrgAndTeam(orgID, teamName string) { + c.orgID = orgID + c.teamName = teamName +} + +func (c *Client) OrgID() string { return c.orgID } +func (c *Client) TeamName() string { return c.teamName } + +func (c *Client) TeamPath() string { + return fmt.Sprintf("/organizations/%s/teams/%s", c.orgID, c.teamName) +} + +func (c *Client) TryRefreshToken(ctx context.Context) error { + req := c.apiClient.DefaultApi.ApiV1AuthRefreshPost(ctx) + req = req.RefreshTokenRequest(serverapi.RefreshTokenRequest{ + RefreshToken: c.refreshToken, + }) + + resp, _, err := req.Execute() + if err != nil { + return clierrors.AuthError("Session expired. Run `stackdome login` to re-authenticate.") + } + + c.accessToken = resp.GetToken() + c.refreshToken = resp.GetRefreshToken() + c.applyAuth() + + if c.onTokenRefresh != nil { + return c.onTokenRefresh(c.accessToken, c.refreshToken) + } + return nil +} + +func WrapError(httpResp *http.Response, err error, message string) error { + if httpResp != nil { + return clierrors.FromHTTP(httpResp.StatusCode, err.Error()).WithDetail(message) + } + if isTimeoutError(err) { + return clierrors.Wrapf(err, "%s: request timed out", message) + } + return clierrors.Wrapf(err, message) +} + +func isTimeoutError(err error) bool { + if urlErr, ok := err.(*url.Error); ok && urlErr.Timeout() { + return true + } + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + return true + } + return err == context.DeadlineExceeded +} diff --git a/internal/cmdutil/context.go b/internal/cmdutil/context.go index 2feb830..8086a67 100644 --- a/internal/cmdutil/context.go +++ b/internal/cmdutil/context.go @@ -4,12 +4,14 @@ import ( "log/slog" "os" + "github.com/stackdome/cli/internal/client" "github.com/stackdome/cli/internal/config" "github.com/stackdome/cli/internal/output" ) type CommandContext struct { Config *config.Config + Client *client.Client Formatter *output.Formatter Logger *slog.Logger } @@ -19,8 +21,23 @@ func NewCommandContext(cfg *config.Config, format output.Format, logLevel slog.L Level: logLevel, })) + var c *client.Client + if cfg.IsLoggedIn() { + c = client.New(cfg.ServerURL, + client.WithTokens(cfg.AccessToken, cfg.RefreshToken), + client.WithOrgAndTeam(cfg.OrganizationID, cfg.TeamName), + client.WithInsecure(cfg.Insecure), + client.WithTokenRefreshCallback(func(accessToken, refreshToken string) error { + cfg.AccessToken = accessToken + cfg.RefreshToken = refreshToken + return cfg.Save() + }), + ) + } + return &CommandContext{ Config: cfg, + Client: c, Formatter: output.NewFormatter(format), Logger: logger, } From 902fb056fe00144be35f70d6377da3180e1c1915 Mon Sep 17 00:00:00 2001 From: ashish Date: Thu, 4 Jun 2026 21:42:14 +0530 Subject: [PATCH 3/8] feat: add stackfile parser and YAML-to-Stack API conversion Parse developer-friendly YAML stackfiles into OpenAPI Stack objects. Handles resources, builds, env refs, secrets, addon connections, volumes, and JSON passthrough + tests. --- go.sum | 1 + internal/stackfile/convert.go | 382 +++++++++ internal/stackfile/convert_test.go | 803 ++++++++++++++++++ internal/stackfile/fixture_test.go | 230 +++++ internal/stackfile/json.go | 30 + internal/stackfile/parse.go | 93 ++ internal/stackfile/testdata/basic_image.yaml | 10 + .../stackfile/testdata/build_from_source.yaml | 13 + internal/stackfile/testdata/infisical.yaml | 49 ++ internal/stackfile/testdata/kitchen_sink.yaml | 61 ++ internal/stackfile/testdata/with_addon.yaml | 16 + .../testdata/with_addon_superuser.yaml | 12 + internal/stackfile/testdata/with_secrets.yaml | 17 + internal/stackfile/types.go | 90 ++ 14 files changed, 1807 insertions(+) create mode 100644 internal/stackfile/convert.go create mode 100644 internal/stackfile/convert_test.go create mode 100644 internal/stackfile/fixture_test.go create mode 100644 internal/stackfile/json.go create mode 100644 internal/stackfile/parse.go create mode 100644 internal/stackfile/testdata/basic_image.yaml create mode 100644 internal/stackfile/testdata/build_from_source.yaml create mode 100644 internal/stackfile/testdata/infisical.yaml create mode 100644 internal/stackfile/testdata/kitchen_sink.yaml create mode 100644 internal/stackfile/testdata/with_addon.yaml create mode 100644 internal/stackfile/testdata/with_addon_superuser.yaml create mode 100644 internal/stackfile/testdata/with_secrets.yaml create mode 100644 internal/stackfile/types.go diff --git a/go.sum b/go.sum index 2c49a1a..5f9607b 100644 --- a/go.sum +++ b/go.sum @@ -1151,6 +1151,7 @@ k8s.io/kubectl v0.30.0/go.mod h1:zgolRw2MQXLPwmic2l/+iHs239L49fhSeICuMhQQXTI= k8s.io/kubectl v0.32.3/go.mod h1:6Euv2aso5GKzo/UVMacV6C7miuyevpfI91SvBvV9Zdg= k8s.io/utils v0.0.0-20240102154912-e7106e64919e h1:eQ/4ljkx21sObifjzXwlPKpdGLrCfRziVtos3ofG/sQ= k8s.io/utils v0.0.0-20240102154912-e7106e64919e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= diff --git a/internal/stackfile/convert.go b/internal/stackfile/convert.go new file mode 100644 index 0000000..b7e6acb --- /dev/null +++ b/internal/stackfile/convert.go @@ -0,0 +1,382 @@ +package stackfile + +import ( + "regexp" + "strings" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + "k8s.io/utils/ptr" +) + +var envRefPattern = regexp.MustCompile(`\{\{\s*(\w+)\.(\S+)\s*\}\}`) + +func (sf *Stackfile) ToStack() openapi.Stack { + spec := openapi.StackSpec{ + StackResources: sf.buildResources(), + Volumes: sf.buildVolumes(), + Connections: sf.buildConnections(), + } + return openapi.Stack{ + Name: sf.Name, + Spec: spec, + } +} + +func (sf *Stackfile) buildResources() []openapi.StackResource { + resources := make([]openapi.StackResource, 0, len(sf.Resources)) + for name, res := range sf.Resources { + sr := openapi.StackResource{ + Name: name, + DependsOn: res.DependsOn, + } + + if res.Image != "" { + sr.ImageSpec = &openapi.ImageSpec{Image: res.Image} + } + + if res.Build != nil { + sr.BuildSpec = buildSpec(res.Build) + } + + if res.Stateful { + sr.Stateful = ptr.To(true) + } + + sr.Ports = buildPorts(res.Ports) + sr.ExecutionConfig = buildExecutionConfig(res.Env) + sr.VolumeMounts = buildVolumeMounts(res.Volumes) + + resources = append(resources, sr) + } + return resources +} + +func buildSpec(b *BuildConfig) *openapi.StackResourceBuildSpec { + spec := &openapi.StackResourceBuildSpec{ + ContextPathWithinSource: ".", + DockerfilePath: "Dockerfile", + ImageRepository: openapi.ImageRepository{ + UseInternalRegistry: ptr.To(true), + }, + } + + if b.Context != "" { + spec.ContextPathWithinSource = b.Context + } + if b.Dockerfile != "" { + spec.DockerfilePath = b.Dockerfile + } + + spec.SourceContext = openapi.BuildSourceContext{ + GitRepo: &openapi.BuildSourceContextGitRepo{ + RepoUrl: b.Repo, + }, + } + + revision := openapi.BuildSourceRevision{} + if b.Branch != "" { + revision.GitRepoRevision = &openapi.GitRepoRevision{ + Branch: &openapi.GitRepoRevisionBranch{ + Name: ptr.To(b.Branch), + }, + } + } else if b.Tag != "" { + revision.GitRepoRevision = &openapi.GitRepoRevision{ + Tag: ptr.To(b.Tag), + } + } else if b.Commit != "" { + revision.GitRepoRevision = &openapi.GitRepoRevision{ + Commit: ptr.To(b.Commit), + } + } else { + revision.GitRepoRevision = &openapi.GitRepoRevision{ + Branch: &openapi.GitRepoRevisionBranch{ + Name: ptr.To("main"), + }, + } + } + spec.SourceRevision = revision + + return spec +} + +func buildPorts(ports []PortDef) []openapi.Port { + if len(ports) == 0 { + return nil + } + out := make([]openapi.Port, len(ports)) + for i, p := range ports { + port := openapi.Port{ + Name: p.Name, + Number: p.Port, + ExposedToPublic: p.Public, + } + if p.Protocol != "" { + port.Protocol = ptr.To(p.Protocol) + } + if p.Subdomain != "" { + port.SubdomainPrefix = ptr.To(p.Subdomain) + } + out[i] = port + } + return out +} + +func buildExecutionConfig(env map[string]string) *openapi.ExecutionConfig { + if len(env) == 0 { + return nil + } + + var envVars []openapi.EnvVar + for name, value := range env { + ev := openapi.EnvVar{Name: name} + + if strings.HasPrefix(value, "{{ self.") && strings.HasSuffix(value, " }}") { + output := strings.TrimPrefix(value, "{{ self.") + output = strings.TrimSuffix(output, " }}") + output = strings.TrimSpace(output) + ev.SelfOutput = ptr.To(output) + } else if !envRefPattern.MatchString(value) { + ev.Value = ptr.To(value) + } + // {{ resource.output }} refs are handled via connections, skip here + + if ev.Value != nil || ev.SelfOutput != nil { + envVars = append(envVars, ev) + } + } + + if len(envVars) == 0 { + return nil + } + return &openapi.ExecutionConfig{ + EnvironmentVariables: envVars, + } +} + +func buildVolumeMounts(mounts []VolumeMountDef) []openapi.VolumeMount { + if len(mounts) == 0 { + return nil + } + out := make([]openapi.VolumeMount, len(mounts)) + for i, m := range mounts { + out[i] = openapi.VolumeMount{ + SourceVolumeName: m.Name, + TargetPath: m.Path, + } + } + return out +} + +func (sf *Stackfile) buildVolumes() []openapi.Volume { + if len(sf.Volumes) == 0 { + return nil + } + volumes := make([]openapi.Volume, 0, len(sf.Volumes)) + for name, v := range sf.Volumes { + accessMode := openapi.VolumeAccessMode("ReadWriteOnce") + if v.AccessMode != "" { + accessMode = openapi.VolumeAccessMode(v.AccessMode) + } + vol := openapi.Volume{ + Name: name, + Spec: openapi.VolumeSpec{ + Size: v.Size, + AccessMode: accessMode, + NeedsSyncBeforeUse: false, + }, + } + volumes = append(volumes, vol) + } + return volumes +} + +func (sf *Stackfile) buildConnections() []openapi.StackConnection { + var connections []openapi.StackConnection + + for resourceName, res := range sf.Resources { + connections = append(connections, buildEnvRefConnections(resourceName, res.Env)...) + connections = append(connections, buildSecretConnections(resourceName, res.Secrets)...) + connections = append(connections, buildAddonConnections(resourceName, res.Addons)...) + connections = append(connections, buildVolumeMountConnections(resourceName, res.Volumes)...) + } + + return connections +} + +func buildEnvRefConnections(targetResource string, env map[string]string) []openapi.StackConnection { + grouped := make(map[string][]openapi.ConnectionMapping) + + for envName, value := range env { + matches := envRefPattern.FindStringSubmatch(value) + if matches == nil || matches[1] == "self" { + continue + } + + source := matches[1] + output := matches[2] + + mapping := openapi.ConnectionMapping{ + Target: openapi.ConnectionTarget{ + Type: "env", + Name: ptr.To(envName), + }, + Value: openapi.ValueRef{ + Output: ptr.To(output), + }, + } + grouped[source] = append(grouped[source], mapping) + } + + var connections []openapi.StackConnection + for source, mappings := range grouped { + conn := openapi.StackConnection{ + Kind: "env", + From: openapi.TopologyNodeRef{ + Type: "stack_resource", + Name: ptr.To(source), + }, + To: openapi.TopologyNodeRef{ + Type: "stack_resource", + Name: ptr.To(targetResource), + }, + Mappings: mappings, + } + connections = append(connections, conn) + } + return connections +} + +func buildSecretConnections(targetResource string, secrets map[string]SecretMapping) []openapi.StackConnection { + var connections []openapi.StackConnection + + for secretID, mapping := range secrets { + var mappings []openapi.ConnectionMapping + for envName, secretKey := range mapping { + mappings = append(mappings, openapi.ConnectionMapping{ + Target: openapi.ConnectionTarget{ + Type: "env", + Name: ptr.To(envName), + }, + Value: openapi.ValueRef{ + Output: ptr.To(secretKey), + }, + }) + } + + conn := openapi.StackConnection{ + Kind: "env", + From: openapi.TopologyNodeRef{ + Type: "secret", + Id: ptr.To(secretID), + }, + To: openapi.TopologyNodeRef{ + Type: "stack_resource", + Name: ptr.To(targetResource), + }, + Mappings: mappings, + } + connections = append(connections, conn) + } + return connections +} + +func buildAddonConnections(targetResource string, addons map[string]AddonConnectionConfig) []openapi.StackConnection { + var connections []openapi.StackConnection + + for addonID, addon := range addons { + var mappings []openapi.ConnectionMapping + for envName, tmpl := range addon.Env { + vr := openapi.ValueRef{} + if strings.Contains(tmpl, "{{") { + vr.Template = ptr.To(tmpl) + values := extractTemplateVars(tmpl) + if len(values) > 0 { + vr.Values = &values + } + } else { + vr.Output = ptr.To(tmpl) + } + + mappings = append(mappings, openapi.ConnectionMapping{ + Target: openapi.ConnectionTarget{ + Type: "env", + Name: ptr.To(envName), + }, + Value: vr, + }) + } + + fromType := "addon/" + addon.Type + conn := openapi.StackConnection{ + Kind: "env", + From: openapi.TopologyNodeRef{ + Type: fromType, + Id: ptr.To(addonID), + }, + To: openapi.TopologyNodeRef{ + Type: "stack_resource", + Name: ptr.To(targetResource), + }, + Mappings: mappings, + } + + if addon.Postgres != nil { + pg := addon.Postgres + if pg.Database != "" || pg.Superuser { + pgConfig := &openapi.PostgresEnvConfig{} + if pg.Database != "" { + pgConfig.Database = ptr.To(pg.Database) + } + if pg.Superuser { + pgConfig.Superuser = ptr.To(true) + } + conn.Config = &openapi.StackConnectionConfig{ + PostgresEnvConfig: pgConfig, + } + } + } + + connections = append(connections, conn) + } + return connections +} + +var templateVarPattern = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`) + +func extractTemplateVars(tmpl string) map[string]openapi.OutputValueRef { + matches := templateVarPattern.FindAllStringSubmatch(tmpl, -1) + if len(matches) == 0 { + return nil + } + values := make(map[string]openapi.OutputValueRef) + for _, m := range matches { + varName := m[1] + values[varName] = openapi.OutputValueRef{Output: varName} + } + return values +} + +func buildVolumeMountConnections(targetResource string, mounts []VolumeMountDef) []openapi.StackConnection { + var connections []openapi.StackConnection + for _, m := range mounts { + conn := openapi.StackConnection{ + Kind: "volume_mount", + From: openapi.TopologyNodeRef{ + Type: "volume", + Name: ptr.To(m.Name), + }, + To: openapi.TopologyNodeRef{ + Type: "stack_resource", + Name: ptr.To(targetResource), + }, + Config: &openapi.StackConnectionConfig{ + VolumeMountConfig: &openapi.VolumeMountConfig{ + MountPath: m.Path, + }, + }, + } + connections = append(connections, conn) + } + return connections +} diff --git a/internal/stackfile/convert_test.go b/internal/stackfile/convert_test.go new file mode 100644 index 0000000..11e0ea2 --- /dev/null +++ b/internal/stackfile/convert_test.go @@ -0,0 +1,803 @@ +package stackfile + +import ( + "testing" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" +) + +func TestToStack_BasicImageResource(t *testing.T) { + sf := &Stackfile{ + Name: "my-stack", + Resources: map[string]Resource{ + "web": { + Image: "nginx:latest", + Ports: []PortDef{ + {Name: "http", Port: 80, Public: true, Subdomain: "web"}, + }, + }, + }, + } + + stack := sf.ToStack() + + if stack.Name != "my-stack" { + t.Errorf("expected name 'my-stack', got %q", stack.Name) + } + if len(stack.Spec.StackResources) != 1 { + t.Fatalf("expected 1 resource, got %d", len(stack.Spec.StackResources)) + } + + res := stack.Spec.StackResources[0] + if res.Name != "web" { + t.Errorf("expected resource name 'web', got %q", res.Name) + } + if res.ImageSpec == nil || res.ImageSpec.Image != "nginx:latest" { + t.Errorf("expected image 'nginx:latest', got %v", res.ImageSpec) + } + if res.BuildSpec != nil { + t.Error("expected no build spec for image resource") + } + if len(res.Ports) != 1 { + t.Fatalf("expected 1 port, got %d", len(res.Ports)) + } + if res.Ports[0].Name != "http" || res.Ports[0].Number != 80 || !res.Ports[0].ExposedToPublic { + t.Errorf("unexpected port config: %+v", res.Ports[0]) + } + if res.Ports[0].SubdomainPrefix == nil || *res.Ports[0].SubdomainPrefix != "web" { + t.Errorf("expected subdomain 'web', got %v", res.Ports[0].SubdomainPrefix) + } +} + +func TestToStack_BuildFromSource(t *testing.T) { + sf := &Stackfile{ + Name: "build-stack", + Resources: map[string]Resource{ + "api": { + Build: &BuildConfig{ + Repo: "https://github.com/myorg/myapp.git", + Branch: "develop", + Dockerfile: "docker/Dockerfile.prod", + Context: "./backend", + }, + }, + }, + } + + stack := sf.ToStack() + res := stack.Spec.StackResources[0] + + if res.ImageSpec != nil { + t.Error("expected no image spec for build resource") + } + if res.BuildSpec == nil { + t.Fatal("expected build spec") + } + if res.BuildSpec.SourceContext.GitRepo == nil { + t.Fatal("expected git repo source context") + } + if res.BuildSpec.SourceContext.GitRepo.RepoUrl != "https://github.com/myorg/myapp.git" { + t.Errorf("unexpected repo url: %s", res.BuildSpec.SourceContext.GitRepo.RepoUrl) + } + if res.BuildSpec.ContextPathWithinSource != "./backend" { + t.Errorf("expected context './backend', got %q", res.BuildSpec.ContextPathWithinSource) + } + if res.BuildSpec.DockerfilePath != "docker/Dockerfile.prod" { + t.Errorf("expected dockerfile 'docker/Dockerfile.prod', got %q", res.BuildSpec.DockerfilePath) + } + if res.BuildSpec.SourceRevision.GitRepoRevision == nil { + t.Fatal("expected git repo revision") + } + if res.BuildSpec.SourceRevision.GitRepoRevision.Branch == nil || *res.BuildSpec.SourceRevision.GitRepoRevision.Branch.Name != "develop" { + t.Errorf("expected branch 'develop', got %v", res.BuildSpec.SourceRevision.GitRepoRevision.Branch) + } +} + +func TestToStack_BuildDefaults(t *testing.T) { + sf := &Stackfile{ + Name: "build-defaults", + Resources: map[string]Resource{ + "app": { + Build: &BuildConfig{ + Repo: "https://github.com/myorg/myapp.git", + }, + }, + }, + } + + stack := sf.ToStack() + res := stack.Spec.StackResources[0] + + if res.BuildSpec.ContextPathWithinSource != "." { + t.Errorf("expected default context '.', got %q", res.BuildSpec.ContextPathWithinSource) + } + if res.BuildSpec.DockerfilePath != "Dockerfile" { + t.Errorf("expected default dockerfile 'Dockerfile', got %q", res.BuildSpec.DockerfilePath) + } + if res.BuildSpec.SourceRevision.GitRepoRevision.Branch == nil || *res.BuildSpec.SourceRevision.GitRepoRevision.Branch.Name != "main" { + t.Error("expected default branch 'main'") + } + if res.BuildSpec.ImageRepository.UseInternalRegistry == nil || !*res.BuildSpec.ImageRepository.UseInternalRegistry { + t.Error("expected internal registry to be true by default") + } +} + +func TestToStack_BuildWithTag(t *testing.T) { + sf := &Stackfile{ + Name: "tag-build", + Resources: map[string]Resource{ + "app": {Build: &BuildConfig{Repo: "https://github.com/x/y.git", Tag: "v1.0.0"}}, + }, + } + + stack := sf.ToStack() + rev := stack.Spec.StackResources[0].BuildSpec.SourceRevision.GitRepoRevision + if rev.Tag == nil || *rev.Tag != "v1.0.0" { + t.Errorf("expected tag 'v1.0.0', got %v", rev.Tag) + } + if rev.Branch != nil { + t.Error("expected no branch when tag is set") + } +} + +func TestToStack_BuildWithCommit(t *testing.T) { + sf := &Stackfile{ + Name: "commit-build", + Resources: map[string]Resource{ + "app": {Build: &BuildConfig{Repo: "https://github.com/x/y.git", Commit: "abc123"}}, + }, + } + + stack := sf.ToStack() + rev := stack.Spec.StackResources[0].BuildSpec.SourceRevision.GitRepoRevision + if rev.Commit == nil || *rev.Commit != "abc123" { + t.Errorf("expected commit 'abc123', got %v", rev.Commit) + } +} + +func TestToStack_EnvLiterals(t *testing.T) { + sf := &Stackfile{ + Name: "env-test", + Resources: map[string]Resource{ + "app": { + Image: "myapp:latest", + Env: map[string]string{ + "FOO": "bar", + "NUM": "42", + }, + }, + }, + } + + stack := sf.ToStack() + res := stack.Spec.StackResources[0] + + if res.ExecutionConfig == nil { + t.Fatal("expected execution config") + } + + envMap := envVarsToMap(res.ExecutionConfig.EnvironmentVariables) + if v, ok := envMap["FOO"]; !ok || *v.Value != "bar" { + t.Errorf("expected FOO=bar, got %v", v) + } + if v, ok := envMap["NUM"]; !ok || *v.Value != "42" { + t.Errorf("expected NUM=42, got %v", v) + } +} + +func TestToStack_SelfOutputEnv(t *testing.T) { + sf := &Stackfile{ + Name: "self-output", + Resources: map[string]Resource{ + "app": { + Image: "myapp:latest", + Env: map[string]string{ + "SITE_URL": "{{ self.public.http.url }}", + }, + }, + }, + } + + stack := sf.ToStack() + res := stack.Spec.StackResources[0] + + envMap := envVarsToMap(res.ExecutionConfig.EnvironmentVariables) + v := envMap["SITE_URL"] + if v.SelfOutput == nil || *v.SelfOutput != "public.http.url" { + t.Errorf("expected self output 'public.http.url', got %v", v.SelfOutput) + } + if v.Value != nil { + t.Error("self output should not have a literal value") + } +} + +func TestToStack_ResourceRefEnvGeneratesConnection(t *testing.T) { + sf := &Stackfile{ + Name: "ref-test", + Resources: map[string]Resource{ + "app": { + Image: "myapp:latest", + Env: map[string]string{ + "DB_HOST": "{{ db.host }}", + "DB_PORT": "{{ db.port }}", + "LITERAL_VALUE": "hello", + }, + }, + "db": { + Image: "postgres:14", + }, + }, + } + + stack := sf.ToStack() + + // Resource ref env vars should NOT appear in execution config + for _, res := range stack.Spec.StackResources { + if res.Name == "app" && res.ExecutionConfig != nil { + for _, ev := range res.ExecutionConfig.EnvironmentVariables { + if ev.Name == "DB_HOST" || ev.Name == "DB_PORT" { + t.Errorf("resource ref %q should not be in execution config", ev.Name) + } + } + } + } + + // Should generate a connection from db → app + found := false + for _, conn := range stack.Spec.Connections { + if conn.Kind == "env" && conn.From.Type == "stack_resource" && *conn.From.Name == "db" && *conn.To.Name == "app" { + found = true + if len(conn.Mappings) != 2 { + t.Errorf("expected 2 mappings, got %d", len(conn.Mappings)) + } + mappingMap := make(map[string]string) + for _, m := range conn.Mappings { + mappingMap[*m.Target.Name] = *m.Value.Output + } + if mappingMap["DB_HOST"] != "host" { + t.Errorf("expected DB_HOST→host mapping, got %v", mappingMap) + } + if mappingMap["DB_PORT"] != "port" { + t.Errorf("expected DB_PORT→port mapping, got %v", mappingMap) + } + } + } + if !found { + t.Error("expected connection from db to app") + } +} + +func TestToStack_MultipleResourceRefs(t *testing.T) { + sf := &Stackfile{ + Name: "multi-ref", + Resources: map[string]Resource{ + "app": { + Image: "myapp:latest", + Env: map[string]string{ + "DB_HOST": "{{ db.host }}", + "REDIS_HOST": "{{ redis.host }}", + }, + }, + "db": {Image: "postgres:14"}, + "redis": {Image: "redis:latest"}, + }, + } + + stack := sf.ToStack() + + sources := make(map[string]bool) + for _, conn := range stack.Spec.Connections { + if conn.From.Type == "stack_resource" && conn.From.Name != nil { + sources[*conn.From.Name] = true + } + } + if !sources["db"] { + t.Error("expected connection from db") + } + if !sources["redis"] { + t.Error("expected connection from redis") + } +} + +func TestToStack_Secrets(t *testing.T) { + sf := &Stackfile{ + Name: "secret-test", + Resources: map[string]Resource{ + "app": { + Image: "myapp:latest", + Secrets: map[string]SecretMapping{ + "my-secret-id": { + "API_KEY": "api_key", + "API_SECRET": "api_secret", + }, + }, + }, + }, + } + + stack := sf.ToStack() + + found := false + for _, conn := range stack.Spec.Connections { + if conn.Kind == "env" && conn.From.Type == "secret" && *conn.From.Id == "my-secret-id" { + found = true + if *conn.To.Name != "app" { + t.Errorf("expected target 'app', got %q", *conn.To.Name) + } + if len(conn.Mappings) != 2 { + t.Errorf("expected 2 mappings, got %d", len(conn.Mappings)) + } + mappingMap := make(map[string]string) + for _, m := range conn.Mappings { + mappingMap[*m.Target.Name] = *m.Value.Output + } + if mappingMap["API_KEY"] != "api_key" { + t.Errorf("expected API_KEY→api_key, got %v", mappingMap) + } + } + } + if !found { + t.Error("expected secret connection") + } +} + + +func TestToStack_Volumes(t *testing.T) { + sf := &Stackfile{ + Name: "vol-test", + Resources: map[string]Resource{ + "db": { + Image: "postgres:14", + Volumes: []VolumeMountDef{ + {Name: "pg-data", Path: "/var/lib/postgresql/data"}, + }, + Stateful: true, + }, + }, + Volumes: map[string]VolumeDef{ + "pg-data": {Size: "10Gi"}, + }, + } + + stack := sf.ToStack() + + // Check volume definition + if len(stack.Spec.Volumes) != 1 { + t.Fatalf("expected 1 volume, got %d", len(stack.Spec.Volumes)) + } + vol := stack.Spec.Volumes[0] + if vol.Name != "pg-data" { + t.Errorf("expected volume name 'pg-data', got %q", vol.Name) + } + if vol.Spec.Size != "10Gi" { + t.Errorf("expected size '10Gi', got %q", vol.Spec.Size) + } + if vol.Spec.AccessMode != "ReadWriteOnce" { + t.Errorf("expected default access mode 'ReadWriteOnce', got %q", vol.Spec.AccessMode) + } + + // Check volume mount on resource + res := stack.Spec.StackResources[0] + if len(res.VolumeMounts) != 1 { + t.Fatalf("expected 1 volume mount, got %d", len(res.VolumeMounts)) + } + if res.VolumeMounts[0].SourceVolumeName != "pg-data" { + t.Errorf("expected source volume 'pg-data', got %q", res.VolumeMounts[0].SourceVolumeName) + } + if res.VolumeMounts[0].TargetPath != "/var/lib/postgresql/data" { + t.Errorf("expected target path '/var/lib/postgresql/data', got %q", res.VolumeMounts[0].TargetPath) + } + + // Check volume mount connection + foundConn := false + for _, conn := range stack.Spec.Connections { + if conn.Kind == "volume_mount" && *conn.From.Name == "pg-data" && *conn.To.Name == "db" { + foundConn = true + if conn.Config == nil || conn.Config.VolumeMountConfig == nil { + t.Fatal("expected volume mount config") + } + if conn.Config.VolumeMountConfig.MountPath != "/var/lib/postgresql/data" { + t.Errorf("expected mount path '/var/lib/postgresql/data', got %q", conn.Config.VolumeMountConfig.MountPath) + } + } + } + if !foundConn { + t.Error("expected volume_mount connection") + } + + // Check stateful flag + if res.Stateful == nil || !*res.Stateful { + t.Error("expected stateful=true") + } +} + +func TestToStack_CustomAccessMode(t *testing.T) { + sf := &Stackfile{ + Name: "access-mode", + Resources: map[string]Resource{ + "app": {Image: "nginx:latest"}, + }, + Volumes: map[string]VolumeDef{ + "shared": {Size: "5Gi", AccessMode: "ReadWriteMany"}, + }, + } + + stack := sf.ToStack() + if stack.Spec.Volumes[0].Spec.AccessMode != "ReadWriteMany" { + t.Errorf("expected ReadWriteMany, got %q", stack.Spec.Volumes[0].Spec.AccessMode) + } +} + +func TestToStack_DependsOn(t *testing.T) { + sf := &Stackfile{ + Name: "deps-test", + Resources: map[string]Resource{ + "app": { + Image: "myapp:latest", + DependsOn: []string{"db", "redis"}, + }, + "db": {Image: "postgres:14"}, + "redis": {Image: "redis:latest"}, + }, + } + + stack := sf.ToStack() + for _, res := range stack.Spec.StackResources { + if res.Name == "app" { + if len(res.DependsOn) != 2 { + t.Errorf("expected 2 dependencies, got %d", len(res.DependsOn)) + } + } + } +} + +func TestToStack_PortProtocol(t *testing.T) { + sf := &Stackfile{ + Name: "proto-test", + Resources: map[string]Resource{ + "db": { + Image: "postgres:14", + Ports: []PortDef{ + {Name: "postgres", Port: 5432, Protocol: "TCP"}, + }, + }, + }, + } + + stack := sf.ToStack() + port := stack.Spec.StackResources[0].Ports[0] + if port.Protocol == nil || *port.Protocol != "TCP" { + t.Errorf("expected protocol TCP, got %v", port.Protocol) + } + if port.ExposedToPublic { + t.Error("expected not public") + } +} + +func TestToStack_NoEnv(t *testing.T) { + sf := &Stackfile{ + Name: "no-env", + Resources: map[string]Resource{ + "app": {Image: "nginx:latest"}, + }, + } + + stack := sf.ToStack() + if stack.Spec.StackResources[0].ExecutionConfig != nil { + t.Error("expected nil execution config when no env") + } +} + +func TestToStack_FullInfisicalExample(t *testing.T) { + sf := &Stackfile{ + Name: "infisical", + Resources: map[string]Resource{ + "infisical": { + Image: "infisical/infisical:latest", + Ports: []PortDef{ + {Name: "http", Port: 80, Public: true, Subdomain: "infisical"}, + }, + Env: map[string]string{ + "SITE_URL": "{{ self.public.http.url }}", + "DB_HOST": "{{ db.host }}", + "DB_PORT": "{{ db.port }}", + "REDIS_HOST": "{{ redis.host }}", + "ENCRYPTION_KEY": "my-secret-key", + }, + DependsOn: []string{"db", "redis"}, + }, + "db": { + Image: "postgres:14-alpine", + Ports: []PortDef{{Name: "postgres", Port: 5432, Protocol: "TCP"}}, + Env: map[string]string{"POSTGRES_DB": "infisical"}, + Volumes: []VolumeMountDef{{Name: "pg-data", Path: "/var/lib/postgresql/data"}}, + Stateful: true, + }, + "redis": { + Image: "redis:latest", + Ports: []PortDef{{Name: "redis", Port: 6379, Protocol: "TCP"}}, + Volumes: []VolumeMountDef{{Name: "redis-data", Path: "/data"}}, + Stateful: true, + }, + }, + Volumes: map[string]VolumeDef{ + "pg-data": {Size: "5Gi"}, + "redis-data": {Size: "1Gi"}, + }, + } + + stack := sf.ToStack() + + if stack.Name != "infisical" { + t.Errorf("expected name 'infisical', got %q", stack.Name) + } + if len(stack.Spec.StackResources) != 3 { + t.Errorf("expected 3 resources, got %d", len(stack.Spec.StackResources)) + } + if len(stack.Spec.Volumes) != 2 { + t.Errorf("expected 2 volumes, got %d", len(stack.Spec.Volumes)) + } + + // Should have connections: db→infisical (env), redis→infisical (env), 2x volume_mount + envConns := 0 + volConns := 0 + for _, conn := range stack.Spec.Connections { + switch conn.Kind { + case "env": + envConns++ + case "volume_mount": + volConns++ + } + } + if envConns != 2 { + t.Errorf("expected 2 env connections (db + redis), got %d", envConns) + } + if volConns != 2 { + t.Errorf("expected 2 volume_mount connections, got %d", volConns) + } + + // Infisical resource should have SITE_URL as self output and ENCRYPTION_KEY as literal + for _, res := range stack.Spec.StackResources { + if res.Name == "infisical" && res.ExecutionConfig != nil { + envMap := envVarsToMap(res.ExecutionConfig.EnvironmentVariables) + if v, ok := envMap["SITE_URL"]; !ok || v.SelfOutput == nil { + t.Error("expected SITE_URL as self output") + } + if v, ok := envMap["ENCRYPTION_KEY"]; !ok || *v.Value != "my-secret-key" { + t.Error("expected ENCRYPTION_KEY as literal") + } + if _, ok := envMap["DB_HOST"]; ok { + t.Error("DB_HOST should not be in execution config (handled by connection)") + } + } + } +} + +func TestToStack_MultipleSecrets(t *testing.T) { + sf := &Stackfile{ + Name: "multi-secret", + Resources: map[string]Resource{ + "app": { + Image: "myapp:latest", + Secrets: map[string]SecretMapping{ + "db-creds": { + "DB_USER": "username", + "DB_PASS": "password", + }, + "api-keys": { + "STRIPE_KEY": "stripe_key", + "SENDGRID_KEY": "sendgrid_key", + }, + }, + }, + }, + } + + stack := sf.ToStack() + + secretConns := 0 + for _, conn := range stack.Spec.Connections { + if conn.From.Type == "secret" { + secretConns++ + } + } + if secretConns != 2 { + t.Errorf("expected 2 secret connections, got %d", secretConns) + } + + // Verify each secret has correct mappings + for _, conn := range stack.Spec.Connections { + if conn.From.Type != "secret" { + continue + } + switch *conn.From.Id { + case "db-creds": + if len(conn.Mappings) != 2 { + t.Errorf("db-creds: expected 2 mappings, got %d", len(conn.Mappings)) + } + case "api-keys": + if len(conn.Mappings) != 2 { + t.Errorf("api-keys: expected 2 mappings, got %d", len(conn.Mappings)) + } + default: + t.Errorf("unexpected secret id: %s", *conn.From.Id) + } + if conn.To.Type != "stack_resource" || *conn.To.Name != "app" { + t.Errorf("expected target app, got %s/%v", conn.To.Type, conn.To.Name) + } + } +} + +func TestToStack_SecretOnMultipleResources(t *testing.T) { + sf := &Stackfile{ + Name: "shared-secret", + Resources: map[string]Resource{ + "api": { + Image: "api:latest", + Secrets: map[string]SecretMapping{ + "shared-creds": {"DB_URL": "url"}, + }, + }, + "worker": { + Image: "worker:latest", + Secrets: map[string]SecretMapping{ + "shared-creds": {"DB_URL": "url"}, + }, + }, + }, + } + + stack := sf.ToStack() + + targets := make(map[string]bool) + for _, conn := range stack.Spec.Connections { + if conn.From.Type == "secret" && *conn.From.Id == "shared-creds" { + targets[*conn.To.Name] = true + } + } + if !targets["api"] || !targets["worker"] { + t.Errorf("expected connections to both api and worker, got %v", targets) + } +} + + + + + + +func TestToStack_MultipleVolumeMountsOnOneResource(t *testing.T) { + sf := &Stackfile{ + Name: "multi-mount", + Resources: map[string]Resource{ + "db": { + Image: "postgres:16", + Volumes: []VolumeMountDef{ + {Name: "pg-data", Path: "/var/lib/postgresql/data"}, + {Name: "pg-wal", Path: "/var/lib/postgresql/wal"}, + }, + Stateful: true, + }, + }, + Volumes: map[string]VolumeDef{ + "pg-data": {Size: "20Gi"}, + "pg-wal": {Size: "5Gi"}, + }, + } + + stack := sf.ToStack() + + res := stack.Spec.StackResources[0] + if len(res.VolumeMounts) != 2 { + t.Fatalf("expected 2 volume mounts, got %d", len(res.VolumeMounts)) + } + + mountPaths := make(map[string]string) + for _, vm := range res.VolumeMounts { + mountPaths[vm.SourceVolumeName] = vm.TargetPath + } + if mountPaths["pg-data"] != "/var/lib/postgresql/data" { + t.Errorf("expected pg-data → /var/lib/postgresql/data, got %q", mountPaths["pg-data"]) + } + if mountPaths["pg-wal"] != "/var/lib/postgresql/wal" { + t.Errorf("expected pg-wal → /var/lib/postgresql/wal, got %q", mountPaths["pg-wal"]) + } + + volConns := 0 + for _, conn := range stack.Spec.Connections { + if conn.Kind == "volume_mount" { + volConns++ + if conn.Config == nil || conn.Config.VolumeMountConfig == nil { + t.Error("expected volume mount config") + } + } + } + if volConns != 2 { + t.Errorf("expected 2 volume_mount connections, got %d", volConns) + } +} + +func TestToStack_SharedVolumeBetweenResources(t *testing.T) { + sf := &Stackfile{ + Name: "shared-vol", + Resources: map[string]Resource{ + "writer": { + Image: "writer:latest", + Volumes: []VolumeMountDef{{Name: "shared-data", Path: "/data/output"}}, + }, + "reader": { + Image: "reader:latest", + Volumes: []VolumeMountDef{{Name: "shared-data", Path: "/data/input"}}, + }, + }, + Volumes: map[string]VolumeDef{ + "shared-data": {Size: "10Gi", AccessMode: "ReadWriteMany"}, + }, + } + + stack := sf.ToStack() + + if len(stack.Spec.Volumes) != 1 { + t.Fatalf("expected 1 volume definition, got %d", len(stack.Spec.Volumes)) + } + + // Should create 2 volume_mount connections (one per resource) + volConns := make(map[string]string) + for _, conn := range stack.Spec.Connections { + if conn.Kind == "volume_mount" && *conn.From.Name == "shared-data" { + volConns[*conn.To.Name] = conn.Config.VolumeMountConfig.MountPath + } + } + if volConns["writer"] != "/data/output" { + t.Errorf("expected writer mount at /data/output, got %q", volConns["writer"]) + } + if volConns["reader"] != "/data/input" { + t.Errorf("expected reader mount at /data/input, got %q", volConns["reader"]) + } +} + +func TestToStack_VolumeMountConnectionHasCorrectNodeTypes(t *testing.T) { + sf := &Stackfile{ + Name: "vol-types", + Resources: map[string]Resource{ + "app": { + Image: "app:latest", + Volumes: []VolumeMountDef{{Name: "cache", Path: "/cache"}}, + }, + }, + Volumes: map[string]VolumeDef{ + "cache": {Size: "1Gi"}, + }, + } + + stack := sf.ToStack() + + for _, conn := range stack.Spec.Connections { + if conn.Kind != "volume_mount" { + continue + } + if conn.From.Type != "volume" { + t.Errorf("expected from.type 'volume', got %q", conn.From.Type) + } + if conn.From.Name == nil || *conn.From.Name != "cache" { + t.Errorf("expected from.name 'cache', got %v", conn.From.Name) + } + if conn.To.Type != "stack_resource" { + t.Errorf("expected to.type 'stack_resource', got %q", conn.To.Type) + } + if conn.To.Name == nil || *conn.To.Name != "app" { + t.Errorf("expected to.name 'app', got %v", conn.To.Name) + } + } +} + + + + + +func envVarsToMap(vars []openapi.EnvVar) map[string]openapi.EnvVar { + m := make(map[string]openapi.EnvVar) + for _, v := range vars { + m[v.Name] = v + } + return m +} diff --git a/internal/stackfile/fixture_test.go b/internal/stackfile/fixture_test.go new file mode 100644 index 0000000..96e143a --- /dev/null +++ b/internal/stackfile/fixture_test.go @@ -0,0 +1,230 @@ +package stackfile + +import ( + "path/filepath" + "runtime" + "testing" +) + +func testdataPath(name string) string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "testdata", name) +} + +func TestFixture_BasicImage(t *testing.T) { + sf, err := Load(testdataPath("basic_image.yaml")) + if err != nil { + t.Fatalf("failed to load: %v", err) + } + + stack := sf.ToStack() + + if stack.Name != "basic-stack" { + t.Errorf("expected name 'basic-stack', got %q", stack.Name) + } + if len(stack.Spec.StackResources) != 1 { + t.Fatalf("expected 1 resource, got %d", len(stack.Spec.StackResources)) + } + res := stack.Spec.StackResources[0] + if res.ImageSpec == nil || res.ImageSpec.Image != "nginx:latest" { + t.Error("expected image nginx:latest") + } + if len(res.Ports) != 1 || !res.Ports[0].ExposedToPublic { + t.Error("expected public port") + } +} + +func TestFixture_BuildFromSource(t *testing.T) { + sf, err := Load(testdataPath("build_from_source.yaml")) + if err != nil { + t.Fatalf("failed to load: %v", err) + } + + stack := sf.ToStack() + res := stack.Spec.StackResources[0] + + if res.BuildSpec == nil { + t.Fatal("expected build spec") + } + if res.BuildSpec.SourceContext.GitRepo.RepoUrl != "https://github.com/myorg/myapp.git" { + t.Error("wrong repo url") + } + if res.BuildSpec.ContextPathWithinSource != "./backend" { + t.Errorf("expected context './backend', got %q", res.BuildSpec.ContextPathWithinSource) + } + if *res.BuildSpec.SourceRevision.GitRepoRevision.Branch.Name != "develop" { + t.Error("expected branch develop") + } +} + +func TestFixture_Infisical(t *testing.T) { + sf, err := Load(testdataPath("infisical.yaml")) + if err != nil { + t.Fatalf("failed to load: %v", err) + } + + stack := sf.ToStack() + + if stack.Name != "infisical" { + t.Errorf("expected name 'infisical', got %q", stack.Name) + } + if len(stack.Spec.StackResources) != 3 { + t.Errorf("expected 3 resources, got %d", len(stack.Spec.StackResources)) + } + if len(stack.Spec.Volumes) != 2 { + t.Errorf("expected 2 volumes, got %d", len(stack.Spec.Volumes)) + } + + envConns := 0 + volConns := 0 + for _, conn := range stack.Spec.Connections { + switch conn.Kind { + case "env": + envConns++ + case "volume_mount": + volConns++ + } + } + if envConns != 2 { + t.Errorf("expected 2 env connections, got %d", envConns) + } + if volConns != 2 { + t.Errorf("expected 2 volume mount connections, got %d", volConns) + } + + for _, res := range stack.Spec.StackResources { + if res.Name != "infisical" || res.ExecutionConfig == nil { + continue + } + envMap := envVarsToMap(res.ExecutionConfig.EnvironmentVariables) + if v, ok := envMap["SITE_URL"]; !ok || v.SelfOutput == nil { + t.Error("SITE_URL should be self output") + } + if v, ok := envMap["ENCRYPTION_KEY"]; !ok || *v.Value != "my-secret-key" { + t.Error("ENCRYPTION_KEY should be literal") + } + if _, ok := envMap["DB_HOST"]; ok { + t.Error("DB_HOST should be a connection, not in execution config") + } + } +} + +func TestFixture_WithSecrets(t *testing.T) { + sf, err := Load(testdataPath("with_secrets.yaml")) + if err != nil { + t.Fatalf("failed to load: %v", err) + } + + stack := sf.ToStack() + + secretConns := 0 + for _, conn := range stack.Spec.Connections { + if conn.From.Type == "secret" { + secretConns++ + if *conn.To.Name != "api" { + t.Errorf("expected target 'api', got %q", *conn.To.Name) + } + } + } + if secretConns != 2 { + t.Errorf("expected 2 secret connections, got %d", secretConns) + } +} + +func TestFixture_WithAddon(t *testing.T) { + sf, err := Load(testdataPath("with_addon.yaml")) + if err != nil { + t.Fatalf("failed to load: %v", err) + } + + stack := sf.ToStack() + + found := false + for _, conn := range stack.Spec.Connections { + if conn.From.Type == "addon/postgres" && *conn.From.Id == "pg-addon-id" { + found = true + if conn.Config == nil || conn.Config.PostgresEnvConfig == nil { + t.Fatal("expected postgres config") + } + if *conn.Config.PostgresEnvConfig.Database != "appdb" { + t.Errorf("expected database 'appdb', got %q", *conn.Config.PostgresEnvConfig.Database) + } + if len(conn.Mappings) != 2 { + t.Errorf("expected 2 mappings, got %d", len(conn.Mappings)) + } + + for _, m := range conn.Mappings { + switch *m.Target.Name { + case "DATABASE_URL": + if m.Value.Template == nil { + t.Error("DATABASE_URL should use template") + } + case "DB_HOST": + if m.Value.Output == nil || *m.Value.Output != "host" { + t.Error("DB_HOST should be direct output 'host'") + } + } + } + } + } + if !found { + t.Error("expected addon/postgres connection") + } +} + +func TestFixture_WithAddonSuperuser(t *testing.T) { + sf, err := Load(testdataPath("with_addon_superuser.yaml")) + if err != nil { + t.Fatalf("failed to load: %v", err) + } + + stack := sf.ToStack() + + for _, conn := range stack.Spec.Connections { + if conn.From.Type != "addon/postgres" { + continue + } + pgCfg := conn.Config.PostgresEnvConfig + if pgCfg.Superuser == nil || !*pgCfg.Superuser { + t.Error("expected superuser=true") + } + if pgCfg.Database == nil || *pgCfg.Database != "appdb" { + t.Errorf("expected database 'appdb', got %v", pgCfg.Database) + } + } +} + +func TestFixture_KitchenSink(t *testing.T) { + sf, err := Load(testdataPath("kitchen_sink.yaml")) + if err != nil { + t.Fatalf("failed to load: %v", err) + } + + stack := sf.ToStack() + + if len(stack.Spec.StackResources) != 3 { + t.Errorf("expected 3 resources, got %d", len(stack.Spec.StackResources)) + } + if len(stack.Spec.Volumes) != 1 { + t.Errorf("expected 1 volume, got %d", len(stack.Spec.Volumes)) + } + + counts := map[string]int{} + for _, conn := range stack.Spec.Connections { + key := conn.Kind + ":" + conn.From.Type + counts[key]++ + } + + if counts["env:stack_resource"] != 2 { + t.Errorf("expected 2 resource env connections (redis→api, redis→worker), got %d", counts["env:stack_resource"]) + } + if counts["env:secret"] != 3 { + t.Errorf("expected 3 secret connections, got %d", counts["env:secret"]) + } + if counts["env:addon/postgres"] != 2 { + t.Errorf("expected 2 addon connections, got %d", counts["env:addon/postgres"]) + } + if counts["volume_mount:volume"] != 1 { + t.Errorf("expected 1 volume mount connection, got %d", counts["volume_mount:volume"]) + } +} diff --git a/internal/stackfile/json.go b/internal/stackfile/json.go new file mode 100644 index 0000000..aaaf76e --- /dev/null +++ b/internal/stackfile/json.go @@ -0,0 +1,30 @@ +package stackfile + +import ( + "encoding/json" + "os" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + clierrors "github.com/stackdome/cli/internal/errors" +) + +func LoadJSON(path string) (*openapi.Stack, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, clierrors.Newf("Stack file not found: %s", path) + } + return nil, clierrors.Wrapf(err, "Failed to read stack file: %s", path) + } + + var stack openapi.Stack + if err := json.Unmarshal(data, &stack); err != nil { + return nil, clierrors.Wrapf(err, "Failed to parse stack JSON: %s", path) + } + + if stack.Name == "" { + return nil, clierrors.ValidationError("Stack JSON missing required field: name") + } + + return &stack, nil +} diff --git a/internal/stackfile/parse.go b/internal/stackfile/parse.go new file mode 100644 index 0000000..29ffc5c --- /dev/null +++ b/internal/stackfile/parse.go @@ -0,0 +1,93 @@ +package stackfile + +import ( + "os" + "path/filepath" + "strings" + + clierrors "github.com/stackdome/cli/internal/errors" + "gopkg.in/yaml.v3" +) + +func Load(path string) (*Stackfile, error) { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".yaml", ".yml": + return loadYAML(path) + default: + return nil, clierrors.ValidationError("Unsupported file format: " + ext + " (expected .yaml or .yml)") + } +} + +func loadYAML(path string) (*Stackfile, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, clierrors.Newf("Stackfile not found: %s", path) + } + return nil, clierrors.Wrapf(err, "Failed to read stackfile: %s", path) + } + + var sf Stackfile + if err := yaml.Unmarshal(data, &sf); err != nil { + return nil, clierrors.Wrapf(err, "Failed to parse stackfile: %s", path) + } + + if err := validate(&sf); err != nil { + return nil, err + } + + return &sf, nil +} + +func validate(sf *Stackfile) error { + if sf.Name == "" { + return clierrors.ValidationError("Stackfile missing required field: name") + } + if len(sf.Resources) == 0 { + return clierrors.ValidationError("Stackfile must define at least one resource") + } + for name, res := range sf.Resources { + if res.Image == "" && res.Build == nil { + return clierrors.ValidationError("Resource '" + name + "' must have either 'image' or 'build'") + } + if res.Image != "" && res.Build != nil { + return clierrors.ValidationError("Resource '" + name + "' cannot have both 'image' and 'build'") + } + if res.Build != nil { + if res.Build.Repo == "" { + return clierrors.ValidationError("Resource '" + name + "' build config missing 'repo'") + } + set := 0 + if res.Build.Branch != "" { + set++ + } + if res.Build.Tag != "" { + set++ + } + if res.Build.Commit != "" { + set++ + } + if set > 1 { + return clierrors.ValidationError("Resource '" + name + "' build config: only one of 'branch', 'tag', or 'commit' can be set") + } + if res.Build.Dockerfile != "" && !strings.HasSuffix(res.Build.Dockerfile, "Dockerfile") && !strings.Contains(res.Build.Dockerfile, "Dockerfile.") && !strings.Contains(res.Build.Dockerfile, "dockerfile") { + return clierrors.ValidationError("Resource '" + name + "' build config: 'dockerfile' should be a path to a Dockerfile") + } + } + for _, p := range res.Ports { + if p.Name == "" { + return clierrors.ValidationError("Resource '" + name + "' has a port without a name") + } + if p.Port <= 0 { + return clierrors.ValidationError("Resource '" + name + "' port '" + p.Name + "' has invalid port number") + } + } + for _, vm := range res.Volumes { + if _, ok := sf.Volumes[vm.Name]; !ok { + return clierrors.ValidationError("Resource '" + name + "' references undefined volume '" + vm.Name + "'") + } + } + } + return nil +} diff --git a/internal/stackfile/testdata/basic_image.yaml b/internal/stackfile/testdata/basic_image.yaml new file mode 100644 index 0000000..620ac82 --- /dev/null +++ b/internal/stackfile/testdata/basic_image.yaml @@ -0,0 +1,10 @@ +name: basic-stack + +resources: + web: + image: nginx:latest + ports: + - name: http + port: 80 + public: true + subdomain: web diff --git a/internal/stackfile/testdata/build_from_source.yaml b/internal/stackfile/testdata/build_from_source.yaml new file mode 100644 index 0000000..5560361 --- /dev/null +++ b/internal/stackfile/testdata/build_from_source.yaml @@ -0,0 +1,13 @@ +name: build-stack + +resources: + api: + build: + repo: https://github.com/myorg/myapp.git + branch: develop + dockerfile: docker/Dockerfile.prod + context: ./backend + ports: + - name: http + port: 3000 + public: true diff --git a/internal/stackfile/testdata/infisical.yaml b/internal/stackfile/testdata/infisical.yaml new file mode 100644 index 0000000..49b9d71 --- /dev/null +++ b/internal/stackfile/testdata/infisical.yaml @@ -0,0 +1,49 @@ +name: infisical + +resources: + infisical: + image: infisical/infisical:latest + ports: + - name: http + port: 80 + public: true + subdomain: infisical + env: + SITE_URL: "{{ self.public.http.url }}" + DB_HOST: "{{ db.host }}" + DB_PORT: "{{ db.port }}" + REDIS_HOST: "{{ redis.host }}" + ENCRYPTION_KEY: "my-secret-key" + depends_on: + - db + - redis + + db: + image: postgres:14-alpine + ports: + - name: postgres + port: 5432 + protocol: TCP + env: + POSTGRES_DB: infisical + volumes: + - name: pg-data + path: /var/lib/postgresql/data + stateful: true + + redis: + image: redis:latest + ports: + - name: redis + port: 6379 + protocol: TCP + volumes: + - name: redis-data + path: /data + stateful: true + +volumes: + pg-data: + size: 5Gi + redis-data: + size: 1Gi diff --git a/internal/stackfile/testdata/kitchen_sink.yaml b/internal/stackfile/testdata/kitchen_sink.yaml new file mode 100644 index 0000000..a61fb3c --- /dev/null +++ b/internal/stackfile/testdata/kitchen_sink.yaml @@ -0,0 +1,61 @@ +name: kitchen-sink + +resources: + api: + image: api:latest + ports: + - name: http + port: 3000 + public: true + subdomain: api + env: + SITE_URL: "{{ self.public.http.url }}" + CACHE_HOST: "{{ redis.host }}" + CACHE_PORT: "{{ redis.port }}" + LOG_LEVEL: info + secrets: + jwt-secret: + JWT_SECRET: jwt_signing_key + smtp-creds: + SMTP_USER: username + SMTP_PASS: password + addons: + main-db: + type: postgres + database: api_production + env: + DATABASE_URL: "postgres://{{ username }}:{{ password }}@{{ host }}:{{ port }}/{{ database }}" + depends_on: + - redis + + redis: + image: redis:7-alpine + ports: + - name: redis + port: 6379 + protocol: TCP + volumes: + - name: redis-data + path: /data + stateful: true + + worker: + image: api:latest + env: + ROLE: worker + CACHE_HOST: "{{ redis.host }}" + secrets: + jwt-secret: + JWT_SECRET: jwt_signing_key + addons: + main-db: + type: postgres + database: api_production + env: + DATABASE_URL: "postgres://{{ username }}:{{ password }}@{{ host }}:{{ port }}/{{ database }}" + depends_on: + - redis + +volumes: + redis-data: + size: 2Gi diff --git a/internal/stackfile/testdata/with_addon.yaml b/internal/stackfile/testdata/with_addon.yaml new file mode 100644 index 0000000..1e6912c --- /dev/null +++ b/internal/stackfile/testdata/with_addon.yaml @@ -0,0 +1,16 @@ +name: addon-app + +resources: + api: + image: api:latest + ports: + - name: http + port: 3000 + public: true + addons: + pg-addon-id: + type: postgres + database: appdb + env: + DATABASE_URL: "postgres://{{ username }}:{{ password }}@{{ host }}:{{ port }}/{{ database }}" + DB_HOST: host diff --git a/internal/stackfile/testdata/with_addon_superuser.yaml b/internal/stackfile/testdata/with_addon_superuser.yaml new file mode 100644 index 0000000..1ef4202 --- /dev/null +++ b/internal/stackfile/testdata/with_addon_superuser.yaml @@ -0,0 +1,12 @@ +name: superuser-app + +resources: + migration-runner: + image: myapp:latest + addons: + pg-main: + type: postgres + database: appdb + superuser: true + env: + DATABASE_URL: "postgres://{{ username }}:{{ password }}@{{ host }}:{{ port }}/{{ database }}" diff --git a/internal/stackfile/testdata/with_secrets.yaml b/internal/stackfile/testdata/with_secrets.yaml new file mode 100644 index 0000000..e7db917 --- /dev/null +++ b/internal/stackfile/testdata/with_secrets.yaml @@ -0,0 +1,17 @@ +name: secret-app + +resources: + api: + image: api:latest + ports: + - name: http + port: 3000 + public: true + env: + LOG_LEVEL: info + secrets: + jwt-secret-id: + JWT_SECRET: jwt_signing_key + smtp-creds-id: + SMTP_USER: username + SMTP_PASS: password diff --git a/internal/stackfile/types.go b/internal/stackfile/types.go new file mode 100644 index 0000000..5ecb67c --- /dev/null +++ b/internal/stackfile/types.go @@ -0,0 +1,90 @@ +package stackfile + +import "gopkg.in/yaml.v3" + +type Stackfile struct { + Name string `yaml:"name"` + Resources map[string]Resource `yaml:"resources"` + Volumes map[string]VolumeDef `yaml:"volumes,omitempty"` +} + +type Resource struct { + Image string `yaml:"image,omitempty"` + Build *BuildConfig `yaml:"build,omitempty"` + Ports []PortDef `yaml:"ports,omitempty"` + Env map[string]string `yaml:"env,omitempty"` + Secrets map[string]SecretMapping `yaml:"secrets,omitempty"` + Addons map[string]AddonConnectionConfig `yaml:"addons,omitempty"` + Volumes []VolumeMountDef `yaml:"volumes,omitempty"` + DependsOn []string `yaml:"depends_on,omitempty"` + Stateful bool `yaml:"stateful,omitempty"` +} + +type BuildConfig struct { + Repo string `yaml:"repo"` + Branch string `yaml:"branch,omitempty"` + Tag string `yaml:"tag,omitempty"` + Commit string `yaml:"commit,omitempty"` + Dockerfile string `yaml:"dockerfile,omitempty"` + Context string `yaml:"context,omitempty"` +} + +type PortDef struct { + Name string `yaml:"name"` + Port int32 `yaml:"port"` + Protocol string `yaml:"protocol,omitempty"` + Public bool `yaml:"public,omitempty"` + Subdomain string `yaml:"subdomain,omitempty"` +} + +type VolumeDef struct { + Size string `yaml:"size"` + AccessMode string `yaml:"access_mode,omitempty"` +} + +type VolumeMountDef struct { + Name string `yaml:"name"` + Path string `yaml:"path"` +} + +// SecretMapping maps secret keys to env var names. +// Key = env var name, Value = secret key +type SecretMapping map[string]string + +type AddonConnectionConfig struct { + Type string `yaml:"type"` + Env map[string]string `yaml:"env"` + Postgres *PostgresAddonConfig `yaml:"-"` + + rawNode yaml.Node `yaml:"-"` +} + +type PostgresAddonConfig struct { + Database string `yaml:"database,omitempty"` + Superuser bool `yaml:"superuser,omitempty"` +} + +func (a *AddonConnectionConfig) UnmarshalYAML(node *yaml.Node) error { + a.rawNode = *node + + var base struct { + Type string `yaml:"type"` + Env map[string]string `yaml:"env"` + } + if err := node.Decode(&base); err != nil { + return err + } + a.Type = base.Type + a.Env = base.Env + + switch a.Type { + case "postgres": + var pg PostgresAddonConfig + if err := node.Decode(&pg); err != nil { + return err + } + a.Postgres = &pg + } + + return nil +} From 2c2b4b105959b8c6e98b07ce3395f8537c97858e Mon Sep 17 00:00:00 2001 From: ashish Date: Tue, 9 Jun 2026 23:52:40 +0530 Subject: [PATCH 4/8] feat: add stack lifecycle commands and improved stackfile handling Commands: deploy, status, destroy, validate, stack list/info/delete. Deploy supports stackfile YAML and raw JSON, waits for Ready/Failed. Status renders resource table with colors using lipgloss. Stack info and delete resolve by name. Status --stack accepts name. Stackfile improvements: - Env refs support exact ({{ db.host }}) and template ("redis://{{ redis.host }}:6379") patterns - Secrets and addons reference by name, resolved at deploy time - Validation: self/resource output checking against port definitions, single-source-per-env-value enforcement, addon output validation - OpenAPI schema for stackfile format Stack client: create, get, update, delete, list, find-by-name. --- cmd/stackdome/deploy.go | 139 +++++ cmd/stackdome/destroy.go | 65 ++ cmd/stackdome/root.go | 5 + cmd/stackdome/stack.go | 133 ++++ cmd/stackdome/status.go | 87 +++ cmd/stackdome/validate.go | 30 + config/stackfile_schema.yaml | 288 +++++++++ go.mod | 13 + go.sum | 31 + internal/client/client.go | 21 +- internal/client/stacks.go | 76 +++ internal/output/color.go | 6 + internal/output/formatter.go | 49 +- internal/output/status.go | 267 ++++++++ .../stackfile/connection_builders_test.go | 571 ++++++++++++++++++ internal/stackfile/convert.go | 271 ++++++--- internal/stackfile/convert_test.go | 136 +++-- internal/stackfile/fixture_test.go | 8 +- internal/stackfile/parse.go | 182 ++++++ internal/stackfile/resolve.go | 54 ++ internal/stackfile/testdata/infisical.yaml | 20 +- internal/stackfile/testdata/kitchen_sink.yaml | 4 +- internal/stackfile/testdata/simple_nginx.yaml | 8 + internal/stackfile/testdata/with_addon.yaml | 2 +- internal/stackfile/types.go | 26 +- 25 files changed, 2336 insertions(+), 156 deletions(-) create mode 100644 cmd/stackdome/deploy.go create mode 100644 cmd/stackdome/destroy.go create mode 100644 cmd/stackdome/stack.go create mode 100644 cmd/stackdome/status.go create mode 100644 cmd/stackdome/validate.go create mode 100644 config/stackfile_schema.yaml create mode 100644 internal/client/stacks.go create mode 100644 internal/output/status.go create mode 100644 internal/stackfile/connection_builders_test.go create mode 100644 internal/stackfile/resolve.go create mode 100644 internal/stackfile/testdata/simple_nginx.yaml diff --git a/cmd/stackdome/deploy.go b/cmd/stackdome/deploy.go new file mode 100644 index 0000000..e5d4d07 --- /dev/null +++ b/cmd/stackdome/deploy.go @@ -0,0 +1,139 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" + clierrors "github.com/stackdome/cli/internal/errors" + "github.com/stackdome/cli/internal/output" + "github.com/stackdome/cli/internal/stackfile" +) + +func newDeployCmd() *cobra.Command { + var ( + flagFile string + flagName string + ) + + cmd := &cobra.Command{ + Use: "deploy", + Short: "Deploy a stack from a stackfile or JSON", + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + stack, err := loadStack(flagFile, flagName) + if err != nil { + return err + } + + existing, err := ctx.Client.FindStackByName(cmd.Context(), stack.Name) + if err != nil { + return err + } + + var result *openapi.Stack + if existing != nil { + fmt.Fprintf(os.Stderr, "Updating stack %q...\n", stack.Name) + result, err = ctx.Client.UpdateStack(cmd.Context(), *existing.Id, *stack) + } else { + fmt.Fprintf(os.Stderr, "Creating stack %q...\n", stack.Name) + result, err = ctx.Client.CreateStack(cmd.Context(), *stack) + } + if err != nil { + return err + } + + if err := ctx.Config.SetCurrentStack(*result.Id); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Waiting for stack to be ready...\n") + final, err := waitForStack(ctx, cmd, *result.Id) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(final) + } + + output.RenderStackStatus(os.Stdout, final, false) + return nil + })), + } + + cmd.Flags().StringVarP(&flagFile, "file", "f", "stackfile.yaml", "Path to stackfile or stack JSON") + cmd.Flags().StringVar(&flagName, "name", "", "Override stack name") + + return cmd +} + +func loadStack(path, nameOverride string) (*openapi.Stack, error) { + ext := strings.ToLower(filepath.Ext(path)) + + switch ext { + case ".json": + stack, err := stackfile.LoadJSON(path) + if err != nil { + return nil, err + } + if nameOverride != "" { + stack.Name = nameOverride + } + return stack, nil + + case ".yaml", ".yml": + sf, err := stackfile.Load(path) + if err != nil { + return nil, err + } + if nameOverride != "" { + sf.Name = nameOverride + } + stack := sf.ToStack() + return &stack, nil + + default: + return nil, clierrors.ValidationError("Unsupported file format: " + ext) + } +} + +func waitForStack(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID string) (*openapi.Stack, error) { + timeout := time.After(5 * time.Minute) + tick := time.NewTicker(3 * time.Second) + defer tick.Stop() + + for { + select { + case <-cmd.Context().Done(): + return nil, clierrors.New("Interrupted") + case <-timeout: + stack, _ := ctx.Client.GetStack(cmd.Context(), stackID) + if stack != nil { + return stack, nil + } + return nil, clierrors.New("Timed out waiting for stack to be ready") + case <-tick.C: + stack, err := ctx.Client.GetStack(cmd.Context(), stackID) + if err != nil { + continue + } + if stack.Status == nil || stack.Status.State == nil { + continue + } + state := *stack.Status.State + switch state { + case "Ready": + fmt.Fprintf(os.Stderr, "Stack is ready.\n") + return stack, nil + case "Failed", "Error": + fmt.Fprintf(os.Stderr, "Stack %s.\n", strings.ToLower(state)) + return stack, nil + } + } + } +} diff --git a/cmd/stackdome/destroy.go b/cmd/stackdome/destroy.go new file mode 100644 index 0000000..e45467f --- /dev/null +++ b/cmd/stackdome/destroy.go @@ -0,0 +1,65 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" +) + +func newDestroyCmd() *cobra.Command { + var ( + flagYes bool + flagStack string + ) + + cmd := &cobra.Command{ + Use: "destroy", + Short: "Delete the current stack", + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + stackID := flagStack + if stackID == "" { + var err error + stackID, err = ctx.Config.RequireStack() + if err != nil { + return err + } + } + + stack, err := ctx.Client.GetStack(cmd.Context(), stackID) + if err != nil { + return err + } + + if !flagYes { + fmt.Fprintf(os.Stderr, "This will permanently delete stack %q. Type the stack name to confirm: ", stack.Name) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + if strings.TrimSpace(scanner.Text()) != stack.Name { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + if err := ctx.Client.DeleteStack(cmd.Context(), stackID); err != nil { + return err + } + + if ctx.Config.CurrentStack == stackID { + ctx.Config.CurrentStack = "" + _ = ctx.Config.Save() + } + + fmt.Fprintf(os.Stderr, "Stack %q deletion initiated.\n", stack.Name) + return nil + })), + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation") + cmd.Flags().StringVar(&flagStack, "stack", "", "Stack ID (overrides current context)") + + return cmd +} diff --git a/cmd/stackdome/root.go b/cmd/stackdome/root.go index a340af9..1a39405 100644 --- a/cmd/stackdome/root.go +++ b/cmd/stackdome/root.go @@ -57,6 +57,11 @@ func newRootCmd() *cobra.Command { rootCmd.AddCommand(newLogoutCmd()) rootCmd.AddCommand(newSignupCmd()) rootCmd.AddCommand(newConfigCmd()) + rootCmd.AddCommand(newDeployCmd()) + rootCmd.AddCommand(newStatusCmd()) + rootCmd.AddCommand(newDestroyCmd()) + rootCmd.AddCommand(newValidateCmd()) + rootCmd.AddCommand(newStackCmd()) return rootCmd } diff --git a/cmd/stackdome/stack.go b/cmd/stackdome/stack.go new file mode 100644 index 0000000..b266f83 --- /dev/null +++ b/cmd/stackdome/stack.go @@ -0,0 +1,133 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" + clierrors "github.com/stackdome/cli/internal/errors" + "github.com/stackdome/cli/internal/output" +) + +func newStackCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "stack", + Short: "Manage stacks", + } + + cmd.AddCommand(newStackListCmd()) + cmd.AddCommand(newStackInfoCmd()) + cmd.AddCommand(newStackDeleteCmd()) + return cmd +} + +func newStackListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all stacks", + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + stacks, err := ctx.Client.ListStacks(cmd.Context()) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(stacks) + } + + if len(stacks) == 0 { + fmt.Fprintln(os.Stderr, "No stacks found.") + return nil + } + + tbl := ctx.Formatter.NewTable("", "NAME", "ID", "STATE") + + for _, s := range stacks { + marker := " " + if s.Id != nil && *s.Id == ctx.Config.CurrentStack { + marker = "*" + } + state := "Unknown" + if s.Status != nil && s.Status.State != nil { + state = *s.Status.State + } + id := "" + if s.Id != nil { + id = *s.Id + } + tbl.AddRow(marker, s.Name, id, output.StateColor(state)) + } + tbl.Render() + return nil + })), + } +} + +func newStackInfoCmd() *cobra.Command { + return &cobra.Command{ + Use: "info ", + Short: "Show detailed stack info", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + stack, err := ctx.Client.FindStackByName(cmd.Context(), args[0]) + if err != nil { + return err + } + if stack == nil { + return clierrors.NotFoundError("Stack", args[0]) + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(stack) + } + + output.RenderStackStatus(os.Stdout, stack, true) + return nil + })), + } +} + +func newStackDeleteCmd() *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a stack by name", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + stack, err := ctx.Client.FindStackByName(cmd.Context(), args[0]) + if err != nil { + return err + } + if stack == nil { + return clierrors.NotFoundError("Stack", args[0]) + } + + if !flagYes { + fmt.Fprintf(os.Stderr, "Delete stack %q? [y/N]: ", stack.Name) + var confirm string + fmt.Scanln(&confirm) + if confirm != "y" && confirm != "Y" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + if err := ctx.Client.DeleteStack(cmd.Context(), *stack.Id); err != nil { + return err + } + + if ctx.Config.CurrentStack == *stack.Id { + ctx.Config.CurrentStack = "" + _ = ctx.Config.Save() + } + + fmt.Fprintf(os.Stderr, "Stack %q deletion initiated.\n", stack.Name) + return nil + })), + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation") + return cmd +} diff --git a/cmd/stackdome/status.go b/cmd/stackdome/status.go new file mode 100644 index 0000000..82f8228 --- /dev/null +++ b/cmd/stackdome/status.go @@ -0,0 +1,87 @@ +package main + +import ( + "os" + "time" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" + clierrors "github.com/stackdome/cli/internal/errors" + "github.com/stackdome/cli/internal/output" +) + +func newStatusCmd() *cobra.Command { + var ( + flagWatch bool + flagConditions bool + flagStack string + ) + + cmd := &cobra.Command{ + Use: "status [resource]", + Short: "Show stack and resource status", + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + stackID := "" + if flagStack != "" { + s, err := ctx.Client.FindStackByName(cmd.Context(), flagStack) + if err != nil { + return err + } + if s == nil { + return clierrors.NotFoundError("Stack", flagStack) + } + stackID = *s.Id + } else { + var err error + stackID, err = ctx.Config.RequireStack() + if err != nil { + return err + } + } + + if flagWatch { + return watchStatus(ctx, cmd, stackID, flagConditions) + } + + stack, err := ctx.Client.GetStack(cmd.Context(), stackID) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(stack) + } + + output.RenderStackStatus(os.Stdout, stack, flagConditions) + return nil + })), + } + + cmd.Flags().BoolVarP(&flagWatch, "watch", "w", false, "Live refresh") + cmd.Flags().BoolVar(&flagConditions, "conditions", false, "Show full condition history") + cmd.Flags().StringVar(&flagStack, "stack", "", "Stack name (overrides current context)") + + return cmd +} + +func watchStatus(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID string, showConditions bool) error { + tick := time.NewTicker(3 * time.Second) + defer tick.Stop() + + for { + stack, err := ctx.Client.GetStack(cmd.Context(), stackID) + if err != nil { + return err + } + + // Clear screen + os.Stdout.WriteString("\033[2J\033[H") + output.RenderStackStatus(os.Stdout, stack, showConditions) + + select { + case <-cmd.Context().Done(): + return nil + case <-tick.C: + } + } +} diff --git a/cmd/stackdome/validate.go b/cmd/stackdome/validate.go new file mode 100644 index 0000000..b766a96 --- /dev/null +++ b/cmd/stackdome/validate.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/stackfile" +) + +func newValidateCmd() *cobra.Command { + var flagFile string + + cmd := &cobra.Command{ + Use: "validate", + Short: "Validate a stackfile", + RunE: func(cmd *cobra.Command, args []string) error { + _, err := stackfile.Load(flagFile) + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Stackfile %q is valid.\n", flagFile) + return nil + }, + } + + cmd.Flags().StringVarP(&flagFile, "file", "f", "stackfile.yaml", "Path to stackfile") + + return cmd +} diff --git a/config/stackfile_schema.yaml b/config/stackfile_schema.yaml new file mode 100644 index 0000000..a190daf --- /dev/null +++ b/config/stackfile_schema.yaml @@ -0,0 +1,288 @@ +openapi: 3.0.0 +info: + title: Stackdome Stackfile Schema + version: 1.0.0 + description: > + Schema for the Stackdome stackfile format. A stackfile is a developer-friendly + YAML manifest that defines a stack (multi-service deployment) for the Stackdome + platform. The CLI parses stackfiles and converts them into the Stack API format. + +components: + schemas: + Stackfile: + type: object + required: + - name + - resources + additionalProperties: false + properties: + name: + type: string + minLength: 1 + description: Stack name. Must be unique within the team/project. + example: infisical + resources: + type: object + description: > + Map of resource name to resource definition. Each resource is a + containerized service within the stack. At least one resource is required. + additionalProperties: + $ref: '#/components/schemas/Resource' + minProperties: 1 + volumes: + type: object + description: > + Map of volume name to volume definition. Volumes provide persistent + storage that can be mounted into resources. + additionalProperties: + $ref: '#/components/schemas/VolumeDef' + + Resource: + type: object + description: > + A containerized service within the stack. Must have either `image` (pre-built + container image) or `build` (build from source), but not both. + + Mutual exclusion: exactly one of `image` or `build` must be provided. + This is enforced via oneOf with two variants. + additionalProperties: false + oneOf: + - required: [image] + not: + required: [build] + - required: [build] + not: + required: [image] + properties: + image: + type: string + minLength: 1 + description: Container image reference. Mutually exclusive with `build`. + example: postgres:14-alpine + build: + $ref: '#/components/schemas/BuildConfig' + ports: + type: array + items: + $ref: '#/components/schemas/PortDef' + env: + type: object + description: > + Environment variables for the resource. Values can be: + + - Literal strings: `KEY: "value"` + - Self output references: `KEY: "{{ self. }}"` — reads from this resource's own outputs + - Resource references: `KEY: "{{ . }}"` — reads from another resource's outputs, auto-generates a connection + - Embedded templates: `KEY: "postgres://user:pass@{{ db.host }}:5432/mydb"` — generates a template connection + + Available stack resource outputs: + - `host` — internal service hostname + - `port.` — port number (e.g., `port.postgres`) + - `url.` — internal URL + - `public..host` — public hostname (only if port is public) + - `public..url` — public URL (only if port is public) + additionalProperties: + type: string + secrets: + type: object + description: > + Map of secret name to env var mappings. Each key is a Stackdome secret + name, and the value maps environment variable names to secret keys. + The CLI resolves secret names to IDs at deploy time. + additionalProperties: + $ref: '#/components/schemas/SecretMapping' + addons: + type: object + description: > + Map of addon name to connection config. Each key is a Stackdome addon + name, and the value configures how the addon's outputs are injected. + The CLI resolves addon names to IDs at deploy time. + additionalProperties: + $ref: '#/components/schemas/AddonConnectionConfig' + volumes: + type: array + description: Volumes to mount into this resource. Each referenced volume must be defined in the top-level `volumes` section. + items: + $ref: '#/components/schemas/VolumeMountDef' + depends_on: + type: array + description: List of resource names this resource depends on for startup ordering. + items: + type: string + minLength: 1 + stateful: + type: boolean + description: Mark this resource as stateful (e.g., databases). Default false. + default: false + + BuildConfig: + type: object + description: > + Build a container image from source. Mutually exclusive with `image`. + + Revision: at most one of `branch`, `tag`, or `commit` may be specified. + If none is set, defaults to branch "main". + required: + - repo + additionalProperties: false + properties: + repo: + type: string + minLength: 1 + description: Git repository URL. + example: https://github.com/myorg/myapp.git + branch: + type: string + minLength: 1 + description: Git branch to build from. Mutually exclusive with `tag` and `commit`. Default "main". + example: main + tag: + type: string + minLength: 1 + description: Git tag to build from. Mutually exclusive with `branch` and `commit`. + example: v1.0.0 + commit: + type: string + minLength: 1 + description: Git commit SHA to build from. Mutually exclusive with `branch` and `tag`. + dockerfile: + type: string + minLength: 1 + description: Path to Dockerfile relative to context. Default "Dockerfile". + default: Dockerfile + context: + type: string + minLength: 1 + description: Build context path within the repository. Default ".". + default: "." + # At most one of branch, tag, commit. OpenAPI 3.0 cannot express + # "at most one of" directly; validated in code. + + PortDef: + type: object + required: + - name + - port + additionalProperties: false + properties: + name: + type: string + minLength: 1 + description: > + Port name. Used in output accessors (e.g., a port named "http" produces + outputs `port.http`, `url.http`, and if public, `public.http.url`). + example: http + port: + type: integer + minimum: 1 + maximum: 65535 + description: Port number. + example: 8080 + protocol: + type: string + description: Protocol. Default "HTTP". + enum: + - HTTP + - TCP + default: HTTP + public: + type: boolean + description: Expose this port publicly via ingress. Default false. + default: false + subdomain: + type: string + minLength: 1 + description: Subdomain prefix for the public URL. Only applicable when `public` is true. + example: api + + VolumeDef: + type: object + required: + - size + additionalProperties: false + properties: + size: + type: string + minLength: 1 + pattern: '^\d+(Ki|Mi|Gi|Ti|Pi|Ei)?$' + description: > + Volume size in Kubernetes resource quantity format (e.g., "1Gi", "500Mi", "10Gi"). + example: 5Gi + access_mode: + type: string + description: Volume access mode. Default "ReadWriteOnce". + enum: + - ReadWriteOnce + - ReadWriteMany + - ReadOnlyMany + default: ReadWriteOnce + + VolumeMountDef: + type: object + required: + - name + - path + additionalProperties: false + properties: + name: + type: string + minLength: 1 + description: Name of a volume defined in the top-level `volumes` section. + path: + type: string + minLength: 1 + pattern: '^/' + description: Absolute path where the volume is mounted in the container. + example: /var/lib/postgresql/data + + SecretMapping: + type: object + description: > + Maps environment variable names to secret keys. Each key is the env var + name to set, and each value is the key within the Stackdome secret. + additionalProperties: + type: string + minLength: 1 + minProperties: 1 + example: + API_KEY: api_key + API_SECRET: api_secret + + AddonConnectionConfig: + type: object + required: + - type + - env + additionalProperties: false + description: > + Connection config for an addon. The `type` field determines which + addon-specific fields are available. + properties: + type: + type: string + description: Addon type. + enum: + - postgres + env: + type: object + description: > + Maps environment variable names to addon output accessors or templates. + + For postgres addons, available outputs are: + `host`, `port`, `database`, `username`, `password`, `sslmode`, + `ca_certificate`, `url`. + + Values can be: + - Direct output: `DB_HOST: host` + - Template: `DATABASE_URL: "postgres://{{ username }}:{{ password }}@{{ host }}:{{ port }}/{{ database }}"` + additionalProperties: + type: string + minProperties: 1 + database: + type: string + minLength: 1 + description: Target database name within the postgres addon. + superuser: + type: boolean + description: Use superuser credentials. Default false. + default: false diff --git a/go.mod b/go.mod index 55451f3..3079c27 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,14 @@ go 1.25.0 require ( github.com/ashishmax31/stackdome-api-server v0.0.0-00010101000000-000000000000 + github.com/charmbracelet/lipgloss v1.1.0 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/fsnotify/fsnotify v1.9.0 github.com/go-playground/validator v9.31.0+incompatible github.com/gofrs/flock v0.8.1 github.com/hashicorp/go-envparse v0.1.0 github.com/hashicorp/go-getter v1.7.4 + github.com/samber/lo v1.53.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -34,8 +36,13 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/aws/aws-sdk-go v1.44.122 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch v4.12.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect @@ -75,7 +82,10 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect @@ -84,13 +94,16 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/ulikunitz/xz v0.5.10 // indirect github.com/xlab/treeprint v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect diff --git a/go.sum b/go.sum index 5f9607b..dde1463 100644 --- a/go.sum +++ b/go.sum @@ -197,6 +197,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go v1.44.122 h1:p6mw01WBaNpbdP2xrisz5tIkcNwzj/HysobNoaAHjgo= github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -204,6 +206,16 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -458,12 +470,18 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= @@ -484,6 +502,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= @@ -499,12 +519,19 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= +github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= +github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -537,6 +564,8 @@ github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -779,6 +808,7 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= @@ -803,6 +833,7 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internal/client/client.go b/internal/client/client.go index 436c0b9..cdf4761 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -3,6 +3,7 @@ package client import ( "context" "crypto/tls" + "encoding/json" "fmt" "net" "net/http" @@ -136,7 +137,11 @@ func (c *Client) TryRefreshToken(ctx context.Context) error { func WrapError(httpResp *http.Response, err error, message string) error { if httpResp != nil { - return clierrors.FromHTTP(httpResp.StatusCode, err.Error()).WithDetail(message) + reason := extractAPIReason(err) + if reason != "" { + return clierrors.FromHTTP(httpResp.StatusCode, reason) + } + return clierrors.FromHTTP(httpResp.StatusCode, err.Error()) } if isTimeoutError(err) { return clierrors.Wrapf(err, "%s: request timed out", message) @@ -144,6 +149,20 @@ func WrapError(httpResp *http.Response, err error, message string) error { return clierrors.Wrapf(err, message) } +func extractAPIReason(err error) string { + if err == nil { + return "" + } + body := err.Error() + var apiErr struct { + Reason string `json:"reason"` + } + if json.Unmarshal([]byte(body), &apiErr) == nil && apiErr.Reason != "" { + return apiErr.Reason + } + return "" +} + func isTimeoutError(err error) bool { if urlErr, ok := err.(*url.Error); ok && urlErr.Timeout() { return true diff --git a/internal/client/stacks.go b/internal/client/stacks.go new file mode 100644 index 0000000..c4b0dcf --- /dev/null +++ b/internal/client/stacks.go @@ -0,0 +1,76 @@ +package client + +import ( + "context" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" +) + +func (c *Client) CreateStack(ctx context.Context, stack openapi.Stack) (*openapi.Stack, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameStacksPost(ctx, c.orgID, c.teamName). + Stack(stack).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to create stack") + } + return resp, nil +} + +func (c *Client) GetStack(ctx context.Context, stackID string) (*openapi.Stack, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameStacksIdGet(ctx, c.orgID, c.teamName, stackID).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to get stack") + } + return resp, nil +} + +func (c *Client) UpdateStack(ctx context.Context, stackID string, stack openapi.Stack) (*openapi.Stack, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameStacksIdPut(ctx, c.orgID, c.teamName, stackID). + Stack(stack).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to update stack") + } + return resp, nil +} + +func (c *Client) DeleteStack(ctx context.Context, stackID string) error { + _, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameStacksIdDelete(ctx, c.orgID, c.teamName, stackID).Execute() + if err != nil { + return WrapError(httpResp, err, "Failed to delete stack") + } + return nil +} + +func (c *Client) ListStacks(ctx context.Context) ([]openapi.Stack, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameStacksGet(ctx, c.orgID, c.teamName).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to list stacks") + } + return resp.Items, nil +} + +func (c *Client) GetStackResources(ctx context.Context, stackID string) ([]openapi.StackResource, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameStacksIdResourcesGet(ctx, c.orgID, c.teamName, stackID).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to get stack resources") + } + return resp.Items, nil +} + +func (c *Client) FindStackByName(ctx context.Context, name string) (*openapi.Stack, error) { + stacks, err := c.ListStacks(ctx) + if err != nil { + return nil, err + } + for i := range stacks { + if stacks[i].Name == name { + return &stacks[i], nil + } + } + return nil, nil +} diff --git a/internal/output/color.go b/internal/output/color.go index fceec47..8f51867 100644 --- a/internal/output/color.go +++ b/internal/output/color.go @@ -44,6 +44,12 @@ func Cyan(text string) string { return colorize(cyan, text) } func Bold(text string) string { return colorize(bold, text) } func Dim(text string) string { return colorize(dim, text) } +// TabEscape wraps ANSI-colored text with \xff markers so that +// tabwriter.StripEscape excludes the invisible bytes from width calculation. +func TabEscape(s string) string { + return "\xff" + s + "\xff" +} + func StateColor(state string) string { switch state { case "Ready": diff --git a/internal/output/formatter.go b/internal/output/formatter.go index c1e27cd..203f9eb 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -5,8 +5,9 @@ import ( "fmt" "io" "os" - "text/tabwriter" + "github.com/charmbracelet/lipgloss" + lgTable "github.com/charmbracelet/lipgloss/table" "gopkg.in/yaml.v3" ) @@ -71,37 +72,37 @@ func (f *Formatter) IsTable() bool { return f.Format == FormatTable } -func (f *Formatter) NewTable() *Table { - tw := tabwriter.NewWriter(f.Writer, 0, 0, 2, ' ', 0) - return &Table{tw: tw} +func (f *Formatter) NewTable(headers ...string) *Table { + return &Table{ + writer: f.Writer, + headers: headers, + } } type Table struct { - tw *tabwriter.Writer -} - -func (t *Table) AddHeader(cols ...string) { - for i, col := range cols { - if i > 0 { - fmt.Fprint(t.tw, "\t") - } - fmt.Fprint(t.tw, Bold(col)) - } - fmt.Fprintln(t.tw) + writer io.Writer + headers []string + rows [][]string } func (t *Table) AddRow(cols ...string) { - for i, col := range cols { - if i > 0 { - fmt.Fprint(t.tw, "\t") - } - fmt.Fprint(t.tw, col) - } - fmt.Fprintln(t.tw) + t.rows = append(t.rows, cols) } -func (t *Table) Flush() error { - return t.tw.Flush() +func (t *Table) Render() { + tbl := lgTable.New(). + Border(lipgloss.HiddenBorder()). + BorderTop(false).BorderBottom(false).BorderLeft(false).BorderRight(false).BorderHeader(false). + Headers(t.headers...). + Rows(t.rows...). + StyleFunc(func(row, col int) lipgloss.Style { + if row == lgTable.HeaderRow { + return lipgloss.NewStyle().Bold(true).PaddingRight(2) + } + return lipgloss.NewStyle().PaddingRight(2) + }) + + fmt.Fprintln(t.writer, tbl) } func (f *Formatter) Println(args ...any) { diff --git a/internal/output/status.go b/internal/output/status.go new file mode 100644 index 0000000..171e1d7 --- /dev/null +++ b/internal/output/status.go @@ -0,0 +1,267 @@ +package output + +import ( + "fmt" + "io" + "strings" + "time" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +func RenderStackStatus(w io.Writer, stack *openapi.Stack, showConditions bool) { + state := "Unknown" + if stack.Status != nil && stack.Status.State != nil { + state = *stack.Status.State + } + + fmt.Fprintf(w, "Stack: %-20s State: %s\n\n", Bold(stack.Name), StateColor(state)) + + if stack.Status != nil && stack.Status.Message != nil && *stack.Status.Message != "" { + fmt.Fprintf(w, " %s\n\n", *stack.Status.Message) + } + + renderResourceTable(w, stack.Spec.StackResources) + + renderFailures(w, stack.Spec.StackResources, showConditions) +} + +var ( + headerStyle = lipgloss.NewStyle().Bold(true) + cellStyle = lipgloss.NewStyle().PaddingRight(2) +) + +func renderResourceTable(w io.Writer, resources []openapi.StackResource) { + var rows [][]string + for _, res := range resources { + rows = append(rows, []string{ + res.Name, + StateColor(resourceState(&res)), + formatPorts(res.Ports), + formatURL(&res), + }) + } + + t := table.New(). + Border(lipgloss.HiddenBorder()). + BorderTop(false).BorderBottom(false).BorderLeft(false).BorderRight(false).BorderHeader(false). + Headers("RESOURCE", "STATE", "PORTS", "URL"). + Rows(rows...). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return headerStyle + } + return cellStyle + }) + + fmt.Fprintln(w, t) +} + +func renderFailures(w io.Writer, resources []openapi.StackResource, showConditions bool) { + hasFailures := false + for _, res := range resources { + if res.Status == nil || res.Status.LastFailure == nil { + if showConditions && res.Status != nil && len(res.Status.Conditions) > 0 { + if !hasFailures { + hasFailures = true + } + } + continue + } + if !hasFailures { + fmt.Fprintf(w, "%s\n\n", Bold("FAILURES:")) + hasFailures = true + } + renderResourceFailure(w, &res) + } + + if showConditions { + for _, res := range resources { + if res.Status != nil && len(res.Status.Conditions) > 0 { + renderConditions(w, &res) + } + } + } +} + +func renderResourceFailure(w io.Writer, res *openapi.StackResource) { + failure := res.Status.LastFailure + failureType := "" + if failure.Type != nil { + failureType = *failure.Type + } + + fmt.Fprintf(w, " %s — %s\n", Bold(res.Name), Red(failureType)) + + if failure.Container != nil { + renderContainerFailure(w, "Container", failure.Container) + } + if failure.InitContainer != nil { + renderContainerFailure(w, "Init Container", failure.InitContainer) + } + if failure.Build != nil { + renderBuildFailure(w, failure.Build) + } + + if len(res.Status.Conditions) > 0 { + renderLastConditions(w, res.Status.Conditions, 3) + } + + fmt.Fprintln(w) +} + +func renderContainerFailure(w io.Writer, label string, detail *openapi.ContainerFailureDetail) { + parts := []string{} + if detail.FailureType != nil { + parts = append(parts, string(*detail.FailureType)) + } + if detail.ExitCode != nil { + parts = append(parts, fmt.Sprintf("exit code %d", *detail.ExitCode)) + } + if detail.RestartCount != nil && *detail.RestartCount > 0 { + parts = append(parts, fmt.Sprintf("restarted %d times", *detail.RestartCount)) + } + fmt.Fprintf(w, " %s: %s\n", label, strings.Join(parts, ", ")) + + if detail.Message != nil && *detail.Message != "" { + fmt.Fprintf(w, " Message: %q\n", *detail.Message) + } +} + +func renderBuildFailure(w io.Writer, detail *openapi.BuildFailureDetail) { + parts := []string{} + if detail.FailureType != nil { + parts = append(parts, string(*detail.FailureType)) + } + if detail.ExitCode != nil { + parts = append(parts, fmt.Sprintf("exit code %d", *detail.ExitCode)) + } + fmt.Fprintf(w, " Build: %s\n", strings.Join(parts, ", ")) + + if detail.Message != nil && *detail.Message != "" { + fmt.Fprintf(w, " Message: %q\n", *detail.Message) + } +} + +func renderLastConditions(w io.Writer, conditions []openapi.Condition, max int) { + fmt.Fprintln(w) + fmt.Fprintf(w, " %s\n", Dim("Last conditions:")) + + count := len(conditions) + start := 0 + if count > max { + start = count - max + } + + var rows [][]string + for _, c := range conditions[start:] { + rows = append(rows, conditionRow(c)) + } + + t := table.New(). + Border(lipgloss.HiddenBorder()). + BorderTop(false).BorderBottom(false).BorderLeft(false).BorderRight(false).BorderHeader(false). + Rows(rows...). + StyleFunc(func(row, col int) lipgloss.Style { + return cellStyle.Padding(0, 1, 0, 4) + }) + + fmt.Fprintln(w, t) +} + +func conditionRow(c openapi.Condition) []string { + status, condType, reason, message, age := "", "", "", "", "" + if c.Status != nil { + status = *c.Status + } + if c.Type != nil { + condType = *c.Type + } + if c.Reason != nil { + reason = *c.Reason + } + if c.Message != nil { + message = *c.Message + } + if c.LastTransitionTime != nil { + age = Dim(timeAgo(*c.LastTransitionTime)) + } + return []string{status, condType, reason, message, age} +} + +func renderConditions(w io.Writer, res *openapi.StackResource) { + fmt.Fprintf(w, "\n%s conditions:\n", Bold(res.Name)) + + var rows [][]string + for _, c := range res.Status.Conditions { + rows = append(rows, conditionRow(c)) + } + + t := table.New(). + Border(lipgloss.HiddenBorder()). + BorderTop(false).BorderBottom(false).BorderLeft(false).BorderRight(false).BorderHeader(false). + Rows(rows...). + StyleFunc(func(row, col int) lipgloss.Style { + return cellStyle + }) + + fmt.Fprintln(w, t) +} + +func resourceState(res *openapi.StackResource) string { + if res.Status == nil || res.Status.State == nil { + return "Unknown" + } + return *res.Status.State +} + +func formatPorts(ports []openapi.Port) string { + if len(ports) == 0 { + return "-" + } + parts := make([]string, 0, len(ports)) + for _, p := range ports { + proto := "HTTP" + if p.Protocol != nil { + proto = *p.Protocol + } + s := fmt.Sprintf("%d/%s", p.Number, proto) + if p.ExposedToPublic { + s += " (public)" + } + parts = append(parts, s) + } + return strings.Join(parts, ", ") +} + +func formatURL(res *openapi.StackResource) string { + if res.Status == nil || len(res.Status.PublicIngress) == 0 { + return "-" + } + urls := make([]string, 0) + for _, ing := range res.Status.PublicIngress { + if ing.Url != nil && *ing.Url != "" { + urls = append(urls, *ing.Url) + } + } + if len(urls) == 0 { + return "-" + } + return strings.Join(urls, ", ") +} + +func timeAgo(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds ago", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} diff --git a/internal/stackfile/connection_builders_test.go b/internal/stackfile/connection_builders_test.go new file mode 100644 index 0000000..bd66004 --- /dev/null +++ b/internal/stackfile/connection_builders_test.go @@ -0,0 +1,571 @@ +package stackfile + +import ( + "testing" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" +) + +// ─── buildEnvRefConnections ───────────────────────────────────────────────── + +func TestBuildEnvRefConnections_ExactRef(t *testing.T) { + env := map[string]string{ + "DB_HOST": "{{ db.host }}", + } + conns := buildEnvRefConnections("app", env) + + if len(conns) != 1 { + t.Fatalf("expected 1 connection, got %d", len(conns)) + } + c := conns[0] + assertConnection(t, c, "env", "stack_resource", "db", "stack_resource", "app") + + if len(c.Mappings) != 1 { + t.Fatalf("expected 1 mapping, got %d", len(c.Mappings)) + } + assertDirectMapping(t, c.Mappings[0], "DB_HOST", "host") +} + +func TestBuildEnvRefConnections_TemplateRef(t *testing.T) { + env := map[string]string{ + "REDIS_URL": "redis://{{ redis.host }}:6379", + } + conns := buildEnvRefConnections("app", env) + + if len(conns) != 1 { + t.Fatalf("expected 1 connection, got %d", len(conns)) + } + c := conns[0] + assertConnection(t, c, "env", "stack_resource", "redis", "stack_resource", "app") + + m := c.Mappings[0] + if m.Value.Template == nil { + t.Fatal("expected template value") + } + if *m.Value.Template != "redis://{{ host }}:6379" { + t.Errorf("expected template 'redis://{{ host }}:6379', got %q", *m.Value.Template) + } + assertTemplateVar(t, m, "host", "host") +} + +func TestBuildEnvRefConnections_TemplateWithDottedOutput(t *testing.T) { + env := map[string]string{ + "CALLBACK": "https://{{ api.public.http.url }}/callback", + } + conns := buildEnvRefConnections("worker", env) + + m := conns[0].Mappings[0] + if *m.Value.Template != "https://{{ public_http_url }}/callback" { + t.Errorf("expected dotted output converted to underscore var, got %q", *m.Value.Template) + } + assertTemplateVar(t, m, "public_http_url", "public.http.url") +} + +func TestBuildEnvRefConnections_MultipleRefsFromSameSource(t *testing.T) { + env := map[string]string{ + "DB_HOST": "{{ db.host }}", + "DB_PORT": "{{ db.port.postgres }}", + } + conns := buildEnvRefConnections("app", env) + + if len(conns) != 1 { + t.Fatalf("expected 1 connection (grouped), got %d", len(conns)) + } + if len(conns[0].Mappings) != 2 { + t.Fatalf("expected 2 mappings, got %d", len(conns[0].Mappings)) + } +} + +func TestBuildEnvRefConnections_DifferentSourcesCreateSeparateConnections(t *testing.T) { + env := map[string]string{ + "DB_HOST": "{{ db.host }}", + "CACHE_HOST": "{{ redis.host }}", + } + conns := buildEnvRefConnections("app", env) + + if len(conns) != 2 { + t.Fatalf("expected 2 connections, got %d", len(conns)) + } + + sources := map[string]bool{} + for _, c := range conns { + sources[*c.From.Name] = true + } + if !sources["db"] || !sources["redis"] { + t.Errorf("expected connections from db and redis, got %v", sources) + } +} + +func TestBuildEnvRefConnections_SkipsSelfRefs(t *testing.T) { + env := map[string]string{ + "SITE_URL": "{{ self.public.http.url }}", + } + conns := buildEnvRefConnections("app", env) + + if len(conns) != 0 { + t.Errorf("expected 0 connections for self refs, got %d", len(conns)) + } +} + +func TestBuildEnvRefConnections_SkipsLiterals(t *testing.T) { + env := map[string]string{ + "LOG_LEVEL": "info", + "PORT": "3000", + } + conns := buildEnvRefConnections("app", env) + + if len(conns) != 0 { + t.Errorf("expected 0 connections for literals, got %d", len(conns)) + } +} + +func TestBuildEnvRefConnections_MixedLiteralsAndRefs(t *testing.T) { + env := map[string]string{ + "DB_HOST": "{{ db.host }}", + "LOG_LEVEL": "info", + "SITE_URL": "{{ self.public.http.url }}", + } + conns := buildEnvRefConnections("app", env) + + if len(conns) != 1 { + t.Fatalf("expected 1 connection (only db ref), got %d", len(conns)) + } + if *conns[0].From.Name != "db" { + t.Errorf("expected connection from db, got %s", *conns[0].From.Name) + } +} + +func TestBuildEnvRefConnections_TemplateMultipleRefsFromSameSource(t *testing.T) { + env := map[string]string{ + "DB_URL": "postgres://{{ db.host }}:{{ db.port.postgres }}", + } + conns := buildEnvRefConnections("app", env) + + if len(conns) != 1 { + t.Fatalf("expected 1 connection, got %d", len(conns)) + } + + m := conns[0].Mappings[0] + if m.Value.Template == nil { + t.Fatal("expected template") + } + if *m.Value.Template != "postgres://{{ host }}:{{ port_postgres }}" { + t.Errorf("unexpected template: %q", *m.Value.Template) + } + assertTemplateVar(t, m, "host", "host") + assertTemplateVar(t, m, "port_postgres", "port.postgres") +} + +func TestBuildEnvRefConnections_EmptyEnv(t *testing.T) { + conns := buildEnvRefConnections("app", nil) + if len(conns) != 0 { + t.Errorf("expected 0, got %d", len(conns)) + } + conns = buildEnvRefConnections("app", map[string]string{}) + if len(conns) != 0 { + t.Errorf("expected 0, got %d", len(conns)) + } +} + +// ─── buildSecretConnections ───────────────────────────────────────────────── + +func TestBuildSecretConnections_SingleSecret(t *testing.T) { + secrets := map[string]SecretMapping{ + "api-keys": { + "API_KEY": "api_key", + "API_SECRET": "api_secret", + }, + } + conns := buildSecretConnections("app", secrets) + + if len(conns) != 1 { + t.Fatalf("expected 1 connection, got %d", len(conns)) + } + c := conns[0] + assertConnection(t, c, "env", "secret", "api-keys", "stack_resource", "app") + + if len(c.Mappings) != 2 { + t.Fatalf("expected 2 mappings, got %d", len(c.Mappings)) + } + mappings := mappingMap(c.Mappings) + if mappings["API_KEY"] != "api_key" { + t.Errorf("expected API_KEY→api_key, got %s", mappings["API_KEY"]) + } + if mappings["API_SECRET"] != "api_secret" { + t.Errorf("expected API_SECRET→api_secret, got %s", mappings["API_SECRET"]) + } +} + +func TestBuildSecretConnections_MultipleSecrets(t *testing.T) { + secrets := map[string]SecretMapping{ + "db-creds": {"DB_PASS": "password"}, + "smtp-creds": {"SMTP_PASS": "password"}, + } + conns := buildSecretConnections("app", secrets) + + if len(conns) != 2 { + t.Fatalf("expected 2 connections, got %d", len(conns)) + } + + names := map[string]bool{} + for _, c := range conns { + names[*c.From.Name] = true + if c.From.Type != "secret" { + t.Errorf("expected from.type 'secret', got %q", c.From.Type) + } + if *c.To.Name != "app" { + t.Errorf("expected to.name 'app', got %q", *c.To.Name) + } + } + if !names["db-creds"] || !names["smtp-creds"] { + t.Errorf("expected both secrets, got %v", names) + } +} + +func TestBuildSecretConnections_UsesNameNotId(t *testing.T) { + secrets := map[string]SecretMapping{ + "my-secret": {"KEY": "value"}, + } + conns := buildSecretConnections("app", secrets) + + c := conns[0] + if c.From.Name == nil || *c.From.Name != "my-secret" { + t.Errorf("expected from.name 'my-secret', got %v", c.From.Name) + } + if c.From.Id != nil { + t.Error("expected from.id to be nil (resolved at deploy time)") + } +} + +func TestBuildSecretConnections_Empty(t *testing.T) { + conns := buildSecretConnections("app", nil) + if len(conns) != 0 { + t.Errorf("expected 0, got %d", len(conns)) + } +} + +// ─── buildAddonConnections ────────────────────────────────────────────────── + +func TestBuildAddonConnections_DirectOutput(t *testing.T) { + addons := map[string]AddonConnectionConfig{ + "main-db": { + Type: "postgres", + Env: map[string]string{"DB_HOST": "{{ host }}"}, + Postgres: &PostgresAddonConfig{Database: "mydb"}, + }, + } + conns := buildAddonConnections("app", addons) + + if len(conns) != 1 { + t.Fatalf("expected 1 connection, got %d", len(conns)) + } + c := conns[0] + assertConnection(t, c, "env", "addon/postgres", "main-db", "stack_resource", "app") + assertDirectMapping(t, c.Mappings[0], "DB_HOST", "host") +} + +func TestBuildAddonConnections_Template(t *testing.T) { + addons := map[string]AddonConnectionConfig{ + "main-db": { + Type: "postgres", + Env: map[string]string{ + "DATABASE_URL": "postgres://{{ username }}:{{ password }}@{{ host }}:{{ port }}/{{ database }}", + }, + Postgres: &PostgresAddonConfig{Database: "mydb"}, + }, + } + conns := buildAddonConnections("app", addons) + + m := conns[0].Mappings[0] + if m.Value.Template == nil { + t.Fatal("expected template") + } + if m.Value.Output != nil { + t.Error("template mapping should not have direct output") + } + + vals := *m.Value.Values + for _, key := range []string{"username", "password", "host", "port", "database"} { + if _, ok := vals[key]; !ok { + t.Errorf("missing template var %q", key) + } + } +} + +func TestBuildAddonConnections_MixedDirectAndTemplate(t *testing.T) { + addons := map[string]AddonConnectionConfig{ + "pg": { + Type: "postgres", + Env: map[string]string{ + "DB_HOST": "{{ host }}", + "DATABASE_URL": "postgres://{{ username }}:{{ password }}@{{ host }}:{{ port }}/{{ database }}", + }, + Postgres: &PostgresAddonConfig{}, + }, + } + conns := buildAddonConnections("app", addons) + + if len(conns[0].Mappings) != 2 { + t.Fatalf("expected 2 mappings, got %d", len(conns[0].Mappings)) + } + + for _, m := range conns[0].Mappings { + switch *m.Target.Name { + case "DB_HOST": + if m.Value.Output == nil || *m.Value.Output != "host" { + t.Error("DB_HOST should be direct output 'host'") + } + case "DATABASE_URL": + if m.Value.Template == nil { + t.Error("DATABASE_URL should be template") + } + } + } +} + +func TestBuildAddonConnections_WithDatabaseConfig(t *testing.T) { + addons := map[string]AddonConnectionConfig{ + "pg": { + Type: "postgres", + Env: map[string]string{"DB_HOST": "{{ host }}"}, + Postgres: &PostgresAddonConfig{Database: "appdb"}, + }, + } + conns := buildAddonConnections("app", addons) + + c := conns[0] + if c.Config == nil || c.Config.PostgresEnvConfig == nil { + t.Fatal("expected postgres config") + } + if *c.Config.PostgresEnvConfig.Database != "appdb" { + t.Errorf("expected database 'appdb', got %q", *c.Config.PostgresEnvConfig.Database) + } +} + +func TestBuildAddonConnections_WithSuperuser(t *testing.T) { + addons := map[string]AddonConnectionConfig{ + "pg": { + Type: "postgres", + Env: map[string]string{"DB_HOST": "{{ host }}"}, + Postgres: &PostgresAddonConfig{Database: "appdb", Superuser: true}, + }, + } + conns := buildAddonConnections("app", addons) + + pgCfg := conns[0].Config.PostgresEnvConfig + if pgCfg.Superuser == nil || !*pgCfg.Superuser { + t.Error("expected superuser=true") + } +} + +func TestBuildAddonConnections_NoConfig(t *testing.T) { + addons := map[string]AddonConnectionConfig{ + "pg": { + Type: "postgres", + Env: map[string]string{"DB_HOST": "{{ host }}"}, + Postgres: &PostgresAddonConfig{}, + }, + } + conns := buildAddonConnections("app", addons) + + if conns[0].Config != nil { + t.Error("expected nil config when no database or superuser set") + } +} + +func TestBuildAddonConnections_UsesNameNotId(t *testing.T) { + addons := map[string]AddonConnectionConfig{ + "my-postgres": { + Type: "postgres", + Env: map[string]string{"DB_HOST": "{{ host }}"}, + Postgres: &PostgresAddonConfig{}, + }, + } + conns := buildAddonConnections("app", addons) + + c := conns[0] + if c.From.Name == nil || *c.From.Name != "my-postgres" { + t.Errorf("expected from.name 'my-postgres', got %v", c.From.Name) + } + if c.From.Id != nil { + t.Error("expected from.id to be nil") + } +} + +func TestBuildAddonConnections_MultipleAddons(t *testing.T) { + addons := map[string]AddonConnectionConfig{ + "primary": { + Type: "postgres", + Env: map[string]string{"PRIMARY_HOST": "{{ host }}"}, + Postgres: &PostgresAddonConfig{Database: "primary"}, + }, + "analytics": { + Type: "postgres", + Env: map[string]string{"ANALYTICS_HOST": "{{ host }}"}, + Postgres: &PostgresAddonConfig{Database: "analytics"}, + }, + } + conns := buildAddonConnections("app", addons) + + if len(conns) != 2 { + t.Fatalf("expected 2 connections, got %d", len(conns)) + } + + dbs := map[string]string{} + for _, c := range conns { + dbs[*c.From.Name] = *c.Config.PostgresEnvConfig.Database + } + if dbs["primary"] != "primary" || dbs["analytics"] != "analytics" { + t.Errorf("unexpected addon configs: %v", dbs) + } +} + +func TestBuildAddonConnections_Empty(t *testing.T) { + conns := buildAddonConnections("app", nil) + if len(conns) != 0 { + t.Errorf("expected 0, got %d", len(conns)) + } +} + +// ─── buildVolumeMountConnections ──────────────────────────────────────────── + +func TestBuildVolumeMountConnections_Single(t *testing.T) { + mounts := []VolumeMountDef{ + {Name: "pg-data", Path: "/var/lib/postgresql/data"}, + } + conns := buildVolumeMountConnections("db", mounts) + + if len(conns) != 1 { + t.Fatalf("expected 1 connection, got %d", len(conns)) + } + c := conns[0] + assertConnection(t, c, "volume_mount", "volume", "pg-data", "stack_resource", "db") + + if c.Config == nil || c.Config.VolumeMountConfig == nil { + t.Fatal("expected volume mount config") + } + if c.Config.VolumeMountConfig.MountPath != "/var/lib/postgresql/data" { + t.Errorf("expected mount path '/var/lib/postgresql/data', got %q", c.Config.VolumeMountConfig.MountPath) + } +} + +func TestBuildVolumeMountConnections_Multiple(t *testing.T) { + mounts := []VolumeMountDef{ + {Name: "data", Path: "/data"}, + {Name: "logs", Path: "/var/log"}, + {Name: "config", Path: "/etc/app"}, + } + conns := buildVolumeMountConnections("app", mounts) + + if len(conns) != 3 { + t.Fatalf("expected 3 connections, got %d", len(conns)) + } + + paths := map[string]string{} + for _, c := range conns { + paths[*c.From.Name] = c.Config.VolumeMountConfig.MountPath + } + if paths["data"] != "/data" { + t.Errorf("expected data→/data, got %q", paths["data"]) + } + if paths["logs"] != "/var/log" { + t.Errorf("expected logs→/var/log, got %q", paths["logs"]) + } + if paths["config"] != "/etc/app" { + t.Errorf("expected config→/etc/app, got %q", paths["config"]) + } +} + +func TestBuildVolumeMountConnections_NodeTypes(t *testing.T) { + mounts := []VolumeMountDef{{Name: "vol", Path: "/mnt"}} + conns := buildVolumeMountConnections("app", mounts) + + c := conns[0] + if c.From.Type != "volume" { + t.Errorf("expected from.type 'volume', got %q", c.From.Type) + } + if c.To.Type != "stack_resource" { + t.Errorf("expected to.type 'stack_resource', got %q", c.To.Type) + } + if c.Kind != "volume_mount" { + t.Errorf("expected kind 'volume_mount', got %q", c.Kind) + } + if c.Mappings != nil { + t.Error("volume_mount connections should have no mappings") + } +} + +func TestBuildVolumeMountConnections_Empty(t *testing.T) { + conns := buildVolumeMountConnections("app", nil) + if len(conns) != 0 { + t.Errorf("expected 0, got %d", len(conns)) + } +} + +// ─── helpers ──────────────────────────────────────────────────────────────── + +func assertConnection(t *testing.T, c interface{ }, kind, fromType, fromName, toType, toName string) { + t.Helper() + // Type assert based on what we receive + conn, ok := c.(openapi.StackConnection) + if !ok { + t.Fatal("expected StackConnection") + } + if conn.Kind != kind { + t.Errorf("expected kind %q, got %q", kind, conn.Kind) + } + if conn.From.Type != fromType { + t.Errorf("expected from.type %q, got %q", fromType, conn.From.Type) + } + if conn.From.Name == nil || *conn.From.Name != fromName { + t.Errorf("expected from.name %q, got %v", fromName, conn.From.Name) + } + if conn.To.Type != toType { + t.Errorf("expected to.type %q, got %q", toType, conn.To.Type) + } + if conn.To.Name == nil || *conn.To.Name != toName { + t.Errorf("expected to.name %q, got %v", toName, conn.To.Name) + } +} + +func assertDirectMapping(t *testing.T, m openapi.ConnectionMapping, envName, output string) { + t.Helper() + if m.Target.Type != "env" { + t.Errorf("expected target.type 'env', got %q", m.Target.Type) + } + if *m.Target.Name != envName { + t.Errorf("expected target.name %q, got %q", envName, *m.Target.Name) + } + if m.Value.Output == nil || *m.Value.Output != output { + t.Errorf("expected direct output %q, got %v", output, m.Value.Output) + } + if m.Value.Template != nil { + t.Error("direct mapping should not have template") + } +} + +func assertTemplateVar(t *testing.T, m openapi.ConnectionMapping, varName, output string) { + t.Helper() + if m.Value.Values == nil { + t.Fatalf("expected values map for template var %q", varName) + } + vals := *m.Value.Values + ref, ok := vals[varName] + if !ok { + t.Errorf("missing template var %q", varName) + return + } + if ref.Output != output { + t.Errorf("expected var %q → output %q, got %q", varName, output, ref.Output) + } +} + +func mappingMap(mappings []openapi.ConnectionMapping) map[string]string { + m := make(map[string]string) + for _, mapping := range mappings { + if mapping.Value.Output != nil { + m[*mapping.Target.Name] = *mapping.Value.Output + } + } + return m +} diff --git a/internal/stackfile/convert.go b/internal/stackfile/convert.go index b7e6acb..c17586b 100644 --- a/internal/stackfile/convert.go +++ b/internal/stackfile/convert.go @@ -5,10 +5,19 @@ import ( "strings" openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + "github.com/samber/lo" "k8s.io/utils/ptr" ) -var envRefPattern = regexp.MustCompile(`\{\{\s*(\w+)\.(\S+)\s*\}\}`) +var ( + // Matches a full-value ref: the entire string is {{ source.output }} + exactRefPattern = regexp.MustCompile(`^\{\{\s*([\w-]+(?:\.[\w-]+)+)\s*\}\}$`) + // Matches embedded refs within a larger string + embeddedRefPattern = regexp.MustCompile(`\{\{\s*([\w-]+(?:\.[\w-]+)+)\s*\}\}`) + // addonVarPattern matches {{ varname }} in addon env templates. + // Addon vars are plain output names (host, port, username) — no source prefix. + addonVarPattern = regexp.MustCompile(`\{\{\s*([\w-]+)\s*\}\}`) +) func (sf *Stackfile) ToStack() openapi.Stack { spec := openapi.StackSpec{ @@ -130,20 +139,17 @@ func buildExecutionConfig(env map[string]string) *openapi.ExecutionConfig { var envVars []openapi.EnvVar for name, value := range env { ev := openapi.EnvVar{Name: name} - - if strings.HasPrefix(value, "{{ self.") && strings.HasSuffix(value, " }}") { - output := strings.TrimPrefix(value, "{{ self.") - output = strings.TrimSuffix(output, " }}") - output = strings.TrimSpace(output) + switch { + case isSelfRef(value): + output := extractSelfOutput(value) ev.SelfOutput = ptr.To(output) - } else if !envRefPattern.MatchString(value) { + case hasResourceRef(value): + // skip for now, will be handled in connections + continue + default: ev.Value = ptr.To(value) } - // {{ resource.output }} refs are handled via connections, skip here - - if ev.Value != nil || ev.SelfOutput != nil { - envVars = append(envVars, ev) - } + envVars = append(envVars, ev) } if len(envVars) == 0 { @@ -154,6 +160,87 @@ func buildExecutionConfig(env map[string]string) *openapi.ExecutionConfig { } } +type envRef struct { + Source string + Output string + RawMatch string // the exact substring matched, e.g. "{{ redis.host }}" +} + +// FindAllStringSubmatch returns a [][]string — a slice of matches, where each match is a slice of strings. + +// For the regex \{\{\s*([\w-]+(?:\.[\w-]+)+)\s*\}\}: +// - [i][0] = the full match (including {{ }}) +// - [i][1] = the first capture group (the content inside) + +// Example with one ref: + +// Input: "redis://{{ redis.host }}:6379" + +// Returns: +// [ +// ["{{ redis.host }}", "redis.host"], +// ] + +// Example with two refs: + +// Input: "{{ db.host }}:{{ db.port.postgres }}" + +// Returns: +// [ +// ["{{ db.host }}", "db.host"], +// ["{{ db.port.postgres }}", "db.port.postgres"], +// ] + +func findRefs(value string) []envRef { + matches := embeddedRefPattern.FindAllStringSubmatch(value, -1) + var refs []envRef + for _, m := range matches { + parts := strings.SplitN(m[1], ".", 2) + if len(parts) == 2 { + refs = append(refs, envRef{Source: parts[0], Output: parts[1], RawMatch: m[0]}) + } + } + return refs +} + +func isExactRef(value string) bool { + return exactRefPattern.MatchString(value) +} + +func isSelfRef(value string) bool { + refs := findRefs(value) + for _, r := range refs { + if r.Source == "self" { + return true + } + } + return false +} + +func extractSelfOutput(value string) string { + refs := findRefs(value) + for _, r := range refs { + if r.Source == "self" { + return r.Output + } + } + return "" +} + +func hasResourceRef(value string) bool { + refs := findRefs(value) + for _, r := range refs { + if r.Source != "self" { + return true + } + } + return false +} + +func outputToVarName(output string) string { + return strings.ReplaceAll(output, ".", "_") +} + func buildVolumeMounts(mounts []VolumeMountDef) []openapi.VolumeMount { if len(mounts) == 0 { return nil @@ -208,23 +295,53 @@ func buildEnvRefConnections(targetResource string, env map[string]string) []open grouped := make(map[string][]openapi.ConnectionMapping) for envName, value := range env { - matches := envRefPattern.FindStringSubmatch(value) - if matches == nil || matches[1] == "self" { + refsInCurrentEnv := findRefs(value) + if len(refsInCurrentEnv) == 0 { continue } - source := matches[1] - output := matches[2] + _, currentEnvIsSelfRef := lo.Find(refsInCurrentEnv, func(r envRef) bool { return r.Source == "self" }) + if currentEnvIsSelfRef { + // skip self refs, they will be handled in execution config + continue + } + + source := refsInCurrentEnv[0].Source + + var vr openapi.ValueRef + if isExactRef(value) && len(refsInCurrentEnv) == 1 { + // Output will be the the string inside {{ }}, e.g. "redis.host" + vr.Output = ptr.To(refsInCurrentEnv[0].Output) + } else { + // Templated value, e.g. "redis://{{ redis.host }}:6379" + tmpl := value + // Extract each of the interpolated refs and add them to a map of variable name -> output ref, + // which will be used to replace the {{ }} with {{ varName }} in the template. + // E.g. for "redis://{{ redis.host }}:{{ redis.port }}" we would create a + // map: {"redis_host": {Output: "redis.host"}, "redis_port": {Output: "redis.port"}} + // and the template would become "redis://{{ redis_host }}:{{ redis_port }}" + values := make(map[string]openapi.OutputValueRef) + for _, r := range refsInCurrentEnv { + // convert interpolated var name to a valid env var name by replacing dots with underscores + varName := outputToVarName(r.Output) + // replace the original {{ redis.host }} with {{ redis_host }} in the template + tmpl = strings.Replace(tmpl, r.RawMatch, "{{ "+varName+" }}", 1) + // add to values map: "redis_host" -> {Output: "redis.host"} + values[varName] = openapi.OutputValueRef{Output: r.Output} + } + vr.Template = ptr.To(tmpl) + vr.Values = &values + } mapping := openapi.ConnectionMapping{ Target: openapi.ConnectionTarget{ Type: "env", Name: ptr.To(envName), }, - Value: openapi.ValueRef{ - Output: ptr.To(output), - }, + Value: vr, } + // For each source (e.g. "redis"), we can have multiple env vars referencing it, + // so we group by source and create one connection per source with multiple mappings grouped[source] = append(grouped[source], mapping) } @@ -250,7 +367,7 @@ func buildEnvRefConnections(targetResource string, env map[string]string) []open func buildSecretConnections(targetResource string, secrets map[string]SecretMapping) []openapi.StackConnection { var connections []openapi.StackConnection - for secretID, mapping := range secrets { + for secretName, mapping := range secrets { var mappings []openapi.ConnectionMapping for envName, secretKey := range mapping { mappings = append(mappings, openapi.ConnectionMapping{ @@ -268,7 +385,7 @@ func buildSecretConnections(targetResource string, secrets map[string]SecretMapp Kind: "env", From: openapi.TopologyNodeRef{ Type: "secret", - Id: ptr.To(secretID), + Name: ptr.To(secretName), }, To: openapi.TopologyNodeRef{ Type: "stack_resource", @@ -284,57 +401,21 @@ func buildSecretConnections(targetResource string, secrets map[string]SecretMapp func buildAddonConnections(targetResource string, addons map[string]AddonConnectionConfig) []openapi.StackConnection { var connections []openapi.StackConnection - for addonID, addon := range addons { - var mappings []openapi.ConnectionMapping - for envName, tmpl := range addon.Env { - vr := openapi.ValueRef{} - if strings.Contains(tmpl, "{{") { - vr.Template = ptr.To(tmpl) - values := extractTemplateVars(tmpl) - if len(values) > 0 { - vr.Values = &values - } - } else { - vr.Output = ptr.To(tmpl) - } + for addonName, addon := range addons { + mappings := buildAddonMappings(addon.Env) - mappings = append(mappings, openapi.ConnectionMapping{ - Target: openapi.ConnectionTarget{ - Type: "env", - Name: ptr.To(envName), - }, - Value: vr, - }) - } - - fromType := "addon/" + addon.Type conn := openapi.StackConnection{ Kind: "env", From: openapi.TopologyNodeRef{ - Type: fromType, - Id: ptr.To(addonID), + Type: "addon/" + addon.Type, + Name: ptr.To(addonName), }, To: openapi.TopologyNodeRef{ Type: "stack_resource", Name: ptr.To(targetResource), }, Mappings: mappings, - } - - if addon.Postgres != nil { - pg := addon.Postgres - if pg.Database != "" || pg.Superuser { - pgConfig := &openapi.PostgresEnvConfig{} - if pg.Database != "" { - pgConfig.Database = ptr.To(pg.Database) - } - if pg.Superuser { - pgConfig.Superuser = ptr.To(true) - } - conn.Config = &openapi.StackConnectionConfig{ - PostgresEnvConfig: pgConfig, - } - } + Config: buildAddonConfig(addon), } connections = append(connections, conn) @@ -342,19 +423,67 @@ func buildAddonConnections(targetResource string, addons map[string]AddonConnect return connections } -var templateVarPattern = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`) +type addonRef struct { + Output string + RawMatch string +} -func extractTemplateVars(tmpl string) map[string]openapi.OutputValueRef { - matches := templateVarPattern.FindAllStringSubmatch(tmpl, -1) - if len(matches) == 0 { +func findAddonRefs(value string) []addonRef { + matches := addonVarPattern.FindAllStringSubmatch(value, -1) + var refs []addonRef + for _, m := range matches { + refs = append(refs, addonRef{Output: m[1], RawMatch: m[0]}) + } + return refs +} + +func buildAddonMappings(env map[string]string) []openapi.ConnectionMapping { + var mappings []openapi.ConnectionMapping + for envName, envValue := range env { + refs := findAddonRefs(envValue) + + var vr openapi.ValueRef + switch { + case len(refs) == 1 && refs[0].RawMatch == envValue: + vr.Output = ptr.To(refs[0].Output) + default: + values := make(map[string]openapi.OutputValueRef) + for _, r := range refs { + values[r.Output] = openapi.OutputValueRef{Output: r.Output} + } + vr.Template = ptr.To(envValue) + vr.Values = &values + } + + mappings = append(mappings, openapi.ConnectionMapping{ + Target: openapi.ConnectionTarget{ + Type: "env", + Name: ptr.To(envName), + }, + Value: vr, + }) + } + return mappings +} + +func buildAddonConfig(addon AddonConnectionConfig) *openapi.StackConnectionConfig { + if addon.Postgres == nil { return nil } - values := make(map[string]openapi.OutputValueRef) - for _, m := range matches { - varName := m[1] - values[varName] = openapi.OutputValueRef{Output: varName} + pg := addon.Postgres + if pg.Database == "" && !pg.Superuser { + return nil + } + pgConfig := &openapi.PostgresEnvConfig{} + if pg.Database != "" { + pgConfig.Database = ptr.To(pg.Database) + } + if pg.Superuser { + pgConfig.Superuser = ptr.To(true) + } + return &openapi.StackConnectionConfig{ + PostgresEnvConfig: pgConfig, } - return values } func buildVolumeMountConnections(targetResource string, mounts []VolumeMountDef) []openapi.StackConnection { diff --git a/internal/stackfile/convert_test.go b/internal/stackfile/convert_test.go index 11e0ea2..2ade01c 100644 --- a/internal/stackfile/convert_test.go +++ b/internal/stackfile/convert_test.go @@ -191,6 +191,9 @@ func TestToStack_SelfOutputEnv(t *testing.T) { Resources: map[string]Resource{ "app": { Image: "myapp:latest", + Ports: []PortDef{ + {Name: "http", Port: 8080, Public: true, Subdomain: "app"}, + }, Env: map[string]string{ "SITE_URL": "{{ self.public.http.url }}", }, @@ -211,16 +214,15 @@ func TestToStack_SelfOutputEnv(t *testing.T) { } } -func TestToStack_ResourceRefEnvGeneratesConnection(t *testing.T) { +func TestToStack_ResourceRefSimpleOutput(t *testing.T) { sf := &Stackfile{ Name: "ref-test", Resources: map[string]Resource{ "app": { Image: "myapp:latest", Env: map[string]string{ - "DB_HOST": "{{ db.host }}", - "DB_PORT": "{{ db.port }}", - "LITERAL_VALUE": "hello", + "DB_HOST": "{{ db.host }}", + "LITERAL_VALUE": "hello", }, }, "db": { @@ -231,34 +233,79 @@ func TestToStack_ResourceRefEnvGeneratesConnection(t *testing.T) { stack := sf.ToStack() - // Resource ref env vars should NOT appear in execution config for _, res := range stack.Spec.StackResources { if res.Name == "app" && res.ExecutionConfig != nil { for _, ev := range res.ExecutionConfig.EnvironmentVariables { - if ev.Name == "DB_HOST" || ev.Name == "DB_PORT" { - t.Errorf("resource ref %q should not be in execution config", ev.Name) + if ev.Name == "DB_HOST" { + t.Error("DB_HOST should not be in execution config") } } } } - // Should generate a connection from db → app found := false for _, conn := range stack.Spec.Connections { if conn.Kind == "env" && conn.From.Type == "stack_resource" && *conn.From.Name == "db" && *conn.To.Name == "app" { found = true - if len(conn.Mappings) != 2 { - t.Errorf("expected 2 mappings, got %d", len(conn.Mappings)) + if len(conn.Mappings) != 1 { + t.Errorf("expected 1 mapping, got %d", len(conn.Mappings)) } - mappingMap := make(map[string]string) - for _, m := range conn.Mappings { - mappingMap[*m.Target.Name] = *m.Value.Output + m := conn.Mappings[0] + if *m.Target.Name != "DB_HOST" || *m.Value.Output != "host" { + t.Errorf("expected DB_HOST→host, got %s→%s", *m.Target.Name, *m.Value.Output) + } + } + } + if !found { + t.Error("expected connection from db to app") + } +} + +func TestToStack_ResourceRefTemplate(t *testing.T) { + sf := &Stackfile{ + Name: "template-ref", + Resources: map[string]Resource{ + "app": { + Image: "myapp:latest", + Env: map[string]string{ + "DB_URL": "postgres://user:pass@{{ db.host }}:5432/mydb", + }, + }, + "db": { + Image: "postgres:14", + }, + }, + } + + stack := sf.ToStack() + + for _, res := range stack.Spec.StackResources { + if res.Name == "app" && res.ExecutionConfig != nil { + for _, ev := range res.ExecutionConfig.EnvironmentVariables { + if ev.Name == "DB_URL" { + t.Error("DB_URL should not be in execution config (handled by connection)") + } } - if mappingMap["DB_HOST"] != "host" { - t.Errorf("expected DB_HOST→host mapping, got %v", mappingMap) + } + } + + found := false + for _, conn := range stack.Spec.Connections { + if conn.From.Type == "stack_resource" && *conn.From.Name == "db" { + found = true + m := conn.Mappings[0] + if m.Value.Template == nil { + t.Fatal("expected template value") + } + if *m.Value.Template != "postgres://user:pass@{{ host }}:5432/mydb" { + t.Errorf("expected template with {{ host }}, got %q", *m.Value.Template) } - if mappingMap["DB_PORT"] != "port" { - t.Errorf("expected DB_PORT→port mapping, got %v", mappingMap) + if m.Value.Values == nil { + t.Fatal("expected values map") + } + vals := *m.Value.Values + if vals["host"].Output != "host" { + t.Errorf("expected host output, got %q", vals["host"].Output) } } } @@ -274,8 +321,8 @@ func TestToStack_MultipleResourceRefs(t *testing.T) { "app": { Image: "myapp:latest", Env: map[string]string{ - "DB_HOST": "{{ db.host }}", - "REDIS_HOST": "{{ redis.host }}", + "DB_HOST": "{{ db.host }}", + "REDIS_URL": "redis://{{ redis.host }}:6379", }, }, "db": {Image: "postgres:14"}, @@ -319,7 +366,7 @@ func TestToStack_Secrets(t *testing.T) { found := false for _, conn := range stack.Spec.Connections { - if conn.Kind == "env" && conn.From.Type == "secret" && *conn.From.Id == "my-secret-id" { + if conn.Kind == "env" && conn.From.Type == "secret" && *conn.From.Name == "my-secret-id" { found = true if *conn.To.Name != "app" { t.Errorf("expected target 'app', got %q", *conn.To.Name) @@ -495,29 +542,31 @@ func TestToStack_FullInfisicalExample(t *testing.T) { "infisical": { Image: "infisical/infisical:latest", Ports: []PortDef{ - {Name: "http", Port: 80, Public: true, Subdomain: "infisical"}, + {Name: "http", Port: 8080, Protocol: "HTTP", Public: true, Subdomain: "infisical"}, }, Env: map[string]string{ - "SITE_URL": "{{ self.public.http.url }}", - "DB_HOST": "{{ db.host }}", - "DB_PORT": "{{ db.port }}", - "REDIS_HOST": "{{ redis.host }}", - "ENCRYPTION_KEY": "my-secret-key", + "SITE_URL": "{{ self.public.http.url }}", + "DB_CONNECTION_URI": "postgres://infisical:infisical@{{ db.host }}:5432/infisical", + "REDIS_URL": "redis://{{ redis.host }}:6379", + "ENCRYPTION_KEY": "6c1fe4e407b8911c104518103505b218", + "POSTGRES_USER": "infisical", + "POSTGRES_PASSWORD": "infisical", + "POSTGRES_DB": "infisical", }, DependsOn: []string{"db", "redis"}, }, "db": { Image: "postgres:14-alpine", Ports: []PortDef{{Name: "postgres", Port: 5432, Protocol: "TCP"}}, - Env: map[string]string{"POSTGRES_DB": "infisical"}, + Env: map[string]string{"POSTGRES_USER": "infisical", "POSTGRES_PASSWORD": "infisical", "POSTGRES_DB": "infisical"}, Volumes: []VolumeMountDef{{Name: "pg-data", Path: "/var/lib/postgresql/data"}}, Stateful: true, }, "redis": { - Image: "redis:latest", - Ports: []PortDef{{Name: "redis", Port: 6379, Protocol: "TCP"}}, - Volumes: []VolumeMountDef{{Name: "redis-data", Path: "/data"}}, - Stateful: true, + Image: "redis:latest", + Ports: []PortDef{{Name: "redis", Port: 6379, Protocol: "TCP"}}, + Env: map[string]string{"ALLOW_EMPTY_PASSWORD": "yes"}, + Volumes: []VolumeMountDef{{Name: "redis-data", Path: "/data"}}, }, }, Volumes: map[string]VolumeDef{ @@ -538,13 +587,20 @@ func TestToStack_FullInfisicalExample(t *testing.T) { t.Errorf("expected 2 volumes, got %d", len(stack.Spec.Volumes)) } - // Should have connections: db→infisical (env), redis→infisical (env), 2x volume_mount envConns := 0 volConns := 0 for _, conn := range stack.Spec.Connections { switch conn.Kind { case "env": envConns++ + // Verify template-based connections + if conn.From.Type == "stack_resource" { + for _, m := range conn.Mappings { + if m.Value.Template == nil { + t.Errorf("expected template for env connection from %s", *conn.From.Name) + } + } + } case "volume_mount": volConns++ } @@ -556,18 +612,20 @@ func TestToStack_FullInfisicalExample(t *testing.T) { t.Errorf("expected 2 volume_mount connections, got %d", volConns) } - // Infisical resource should have SITE_URL as self output and ENCRYPTION_KEY as literal for _, res := range stack.Spec.StackResources { if res.Name == "infisical" && res.ExecutionConfig != nil { envMap := envVarsToMap(res.ExecutionConfig.EnvironmentVariables) if v, ok := envMap["SITE_URL"]; !ok || v.SelfOutput == nil { t.Error("expected SITE_URL as self output") } - if v, ok := envMap["ENCRYPTION_KEY"]; !ok || *v.Value != "my-secret-key" { + if v, ok := envMap["ENCRYPTION_KEY"]; !ok || *v.Value != "6c1fe4e407b8911c104518103505b218" { t.Error("expected ENCRYPTION_KEY as literal") } - if _, ok := envMap["DB_HOST"]; ok { - t.Error("DB_HOST should not be in execution config (handled by connection)") + if _, ok := envMap["DB_CONNECTION_URI"]; ok { + t.Error("DB_CONNECTION_URI should not be in execution config (handled by connection)") + } + if _, ok := envMap["REDIS_URL"]; ok { + t.Error("REDIS_URL should not be in execution config (handled by connection)") } } } @@ -610,7 +668,7 @@ func TestToStack_MultipleSecrets(t *testing.T) { if conn.From.Type != "secret" { continue } - switch *conn.From.Id { + switch *conn.From.Name { case "db-creds": if len(conn.Mappings) != 2 { t.Errorf("db-creds: expected 2 mappings, got %d", len(conn.Mappings)) @@ -620,7 +678,7 @@ func TestToStack_MultipleSecrets(t *testing.T) { t.Errorf("api-keys: expected 2 mappings, got %d", len(conn.Mappings)) } default: - t.Errorf("unexpected secret id: %s", *conn.From.Id) + t.Errorf("unexpected secret name: %s", *conn.From.Name) } if conn.To.Type != "stack_resource" || *conn.To.Name != "app" { t.Errorf("expected target app, got %s/%v", conn.To.Type, conn.To.Name) @@ -651,7 +709,7 @@ func TestToStack_SecretOnMultipleResources(t *testing.T) { targets := make(map[string]bool) for _, conn := range stack.Spec.Connections { - if conn.From.Type == "secret" && *conn.From.Id == "shared-creds" { + if conn.From.Type == "secret" && *conn.From.Name == "shared-creds" { targets[*conn.To.Name] = true } } diff --git a/internal/stackfile/fixture_test.go b/internal/stackfile/fixture_test.go index 96e143a..87fbd5b 100644 --- a/internal/stackfile/fixture_test.go +++ b/internal/stackfile/fixture_test.go @@ -100,11 +100,11 @@ func TestFixture_Infisical(t *testing.T) { if v, ok := envMap["SITE_URL"]; !ok || v.SelfOutput == nil { t.Error("SITE_URL should be self output") } - if v, ok := envMap["ENCRYPTION_KEY"]; !ok || *v.Value != "my-secret-key" { + if v, ok := envMap["ENCRYPTION_KEY"]; !ok || *v.Value != "6c1fe4e407b8911c104518103505b218" { t.Error("ENCRYPTION_KEY should be literal") } - if _, ok := envMap["DB_HOST"]; ok { - t.Error("DB_HOST should be a connection, not in execution config") + if _, ok := envMap["DB_CONNECTION_URI"]; ok { + t.Error("DB_CONNECTION_URI should be a connection, not in execution config") } } } @@ -141,7 +141,7 @@ func TestFixture_WithAddon(t *testing.T) { found := false for _, conn := range stack.Spec.Connections { - if conn.From.Type == "addon/postgres" && *conn.From.Id == "pg-addon-id" { + if conn.From.Type == "addon/postgres" && *conn.From.Name == "pg-addon-id" { found = true if conn.Config == nil || conn.Config.PostgresEnvConfig == nil { t.Fatal("expected postgres config") diff --git a/internal/stackfile/parse.go b/internal/stackfile/parse.go index 29ffc5c..d05a54c 100644 --- a/internal/stackfile/parse.go +++ b/internal/stackfile/parse.go @@ -88,6 +88,188 @@ func validate(sf *Stackfile) error { return clierrors.ValidationError("Resource '" + name + "' references undefined volume '" + vm.Name + "'") } } + + if err := validateEnvRefs(name, res.Env, res.Ports, sf.Resources); err != nil { + return err + } + + for addonName, addon := range res.Addons { + if err := validateAddonEnv(name, addonName, addon); err != nil { + return err + } + } + } + return nil +} + +func validateEnvRefs(resourceName string, env map[string]string, ports []PortDef, allResources map[string]Resource) error { + for envKey, envVal := range env { + refs := findRefs(envVal) + if len(refs) == 0 { + continue + } + + // Check that self refs are not mixed with resource refs + hasSelf := false + hasResource := false + for _, ref := range refs { + if ref.Source == "self" { + hasSelf = true + } else { + hasResource = true + } + } + if hasSelf && hasResource { + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': cannot mix self and resource references in the same value") + } + + // Self refs must be exact (entire value is the ref) + if hasSelf { + if !exactRefPattern.MatchString(envVal) { + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': self-references must be the only content of the env var (e.g., '{{ self.port.http }}')") + } + if err := validateSelfOutput(resourceName, envKey, refs[0].Output, ports); err != nil { + return err + } + continue + } + + // All resource refs in a single env value must reference the same source + source := refs[0].Source + for _, ref := range refs[1:] { + if ref.Source != source { + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': references multiple resources ('" + source + "' and '" + ref.Source + "'). Each env var can only reference one source resource.") + } + } + + // Validate each ref's output against the source resource + for _, ref := range refs { + targetRes, ok := allResources[ref.Source] + if !ok { + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': references resource '" + ref.Source + "' which is not defined in the stackfile") + } + if err := validateResourceOutput(resourceName, envKey, ref.Source, ref.Output, targetRes.Ports); err != nil { + return err + } + } + } + return nil +} + +var postgresAddonOutputs = map[string]bool{ + "host": true, + "port": true, + "database": true, + "username": true, + "password": true, + "sslmode": true, + "ca_certificate": true, + "url": true, +} + +func addonOutputsForType(addonType string) map[string]bool { + switch addonType { + case "postgres": + return postgresAddonOutputs + default: + return nil + } +} + +func validateAddonEnv(resourceName, addonName string, addon AddonConnectionConfig) error { + validOutputs := addonOutputsForType(addon.Type) + if validOutputs == nil { + return clierrors.ValidationError("Resource '" + resourceName + "' addon '" + addonName + "': unsupported addon type '" + addon.Type + "'") } + + for envKey, envVal := range addon.Env { + refs := findAddonRefs(envVal) + if len(refs) == 0 { + return clierrors.ValidationError("Resource '" + resourceName + "' addon '" + addonName + "' env var '" + envKey + "': value must use {{ output }} references (e.g., '{{ host }}'), got bare string '" + envVal + "'") + } + for _, ref := range refs { + if !validOutputs[ref.Output] { + valid := make([]string, 0, len(validOutputs)) + for k := range validOutputs { + valid = append(valid, k) + } + return clierrors.ValidationError("Resource '" + resourceName + "' addon '" + addonName + "' env var '" + envKey + "': unknown " + addon.Type + " output '" + ref.Output + "'. Valid outputs: " + strings.Join(valid, ", ")) + } + } + } + return nil +} + +func validateResourceOutput(resourceName, envKey, sourceResource, output string, sourcePorts []PortDef) error { + return validateOutputAgainstPorts(resourceName, envKey, sourceResource, output, sourcePorts) +} + +func validateSelfOutput(resourceName, envKey, output string, ports []PortDef) error { + return validateOutputAgainstPorts(resourceName, envKey, "self", output, ports) +} + +func validateOutputAgainstPorts(resourceName, envKey, source, output string, ports []PortDef) error { + if output == "host" { + return nil + } + + portNames := make(map[string]PortDef) + for _, p := range ports { + portNames[p.Name] = p + } + + parts := strings.Split(output, ".") + label := source + if source == "self" { + label = "resource '" + resourceName + "'" + } else { + label = "resource '" + source + "'" + } + + switch parts[0] { + case "port": + if len(parts) != 2 { + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': invalid output '" + output + "'. Expected 'port.'") + } + if _, ok := portNames[parts[1]]; !ok { + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': output '" + output + "' references port '" + parts[1] + "' which is not defined on " + label) + } + + case "url": + if len(parts) != 2 { + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': invalid output '" + output + "'. Expected 'url.'") + } + if _, ok := portNames[parts[1]]; !ok { + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': output '" + output + "' references port '" + parts[1] + "' which is not defined on " + label) + } + + case "public": + if len(parts) != 3 { + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': invalid output '" + output + "'. Expected 'public..host' or 'public..url'") + } + portName := parts[1] + suffix := parts[2] + if suffix != "host" && suffix != "url" { + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': invalid output '" + output + "'. Expected 'public..host' or 'public..url'") + } + p, ok := portNames[portName] + if !ok { + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': output '" + output + "' references port '" + portName + "' which is not defined on " + label) + } + if !p.Public { + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': output '" + output + "' requires port '" + portName + "' to have 'public: true'") + } + + default: + valid := []string{"host"} + for _, p := range ports { + valid = append(valid, "port."+p.Name, "url."+p.Name) + if p.Public { + valid = append(valid, "public."+p.Name+".host", "public."+p.Name+".url") + } + } + return clierrors.ValidationError("Resource '" + resourceName + "' env var '" + envKey + "': unknown output '" + output + "' on " + label + ". Valid outputs: " + strings.Join(valid, ", ")) + } + return nil } diff --git a/internal/stackfile/resolve.go b/internal/stackfile/resolve.go new file mode 100644 index 0000000..36b8137 --- /dev/null +++ b/internal/stackfile/resolve.go @@ -0,0 +1,54 @@ +package stackfile + +import ( + "context" + "fmt" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + clierrors "github.com/stackdome/cli/internal/errors" +) + +type Resolver interface { + ResolveSecretByName(ctx context.Context, name string) (string, error) + ResolveAddonByName(ctx context.Context, addonType, name string) (string, error) +} + +func ResolveStack(ctx context.Context, stack *openapi.Stack, resolver Resolver) error { + for i := range stack.Spec.Connections { + conn := &stack.Spec.Connections[i] + + switch conn.From.Type { + case "secret": + if conn.From.Name == nil || *conn.From.Name == "" { + continue + } + id, err := resolver.ResolveSecretByName(ctx, *conn.From.Name) + if err != nil { + return clierrors.Newf("Secret %q not found. Run `stackdome secret list` to see available secrets.", *conn.From.Name) + } + conn.From.Id = &id + conn.From.Name = nil + + case "addon/postgres": + if conn.From.Name == nil || *conn.From.Name == "" { + continue + } + id, err := resolver.ResolveAddonByName(ctx, "postgres", *conn.From.Name) + if err != nil { + return clierrors.Newf("Postgres addon %q not found. Check available addons in the dashboard.", *conn.From.Name) + } + conn.From.Id = &id + conn.From.Name = nil + + default: + if isAddonType(conn.From.Type) && conn.From.Name != nil { + return fmt.Errorf("unsupported addon type in connection: %s", conn.From.Type) + } + } + } + return nil +} + +func isAddonType(t string) bool { + return len(t) > 6 && t[:6] == "addon/" +} diff --git a/internal/stackfile/testdata/infisical.yaml b/internal/stackfile/testdata/infisical.yaml index 49b9d71..f1f7adb 100644 --- a/internal/stackfile/testdata/infisical.yaml +++ b/internal/stackfile/testdata/infisical.yaml @@ -5,15 +5,20 @@ resources: image: infisical/infisical:latest ports: - name: http - port: 80 + port: 8080 + protocol: HTTP public: true subdomain: infisical env: + NODE_ENV: production + ENCRYPTION_KEY: "6c1fe4e407b8911c104518103505b218" + AUTH_SECRET: "5lrMXKKWCVocS/uerPsl7V+TX/aaUaI7iDkgl3tSmLE=" SITE_URL: "{{ self.public.http.url }}" - DB_HOST: "{{ db.host }}" - DB_PORT: "{{ db.port }}" - REDIS_HOST: "{{ redis.host }}" - ENCRYPTION_KEY: "my-secret-key" + POSTGRES_USER: infisical + POSTGRES_PASSWORD: infisical + POSTGRES_DB: infisical + DB_CONNECTION_URI: "postgres://infisical:infisical@{{ db.host }}:5432/infisical" + REDIS_URL: "redis://{{ redis.host }}:6379" depends_on: - db - redis @@ -25,6 +30,8 @@ resources: port: 5432 protocol: TCP env: + POSTGRES_USER: infisical + POSTGRES_PASSWORD: infisical POSTGRES_DB: infisical volumes: - name: pg-data @@ -37,10 +44,11 @@ resources: - name: redis port: 6379 protocol: TCP + env: + ALLOW_EMPTY_PASSWORD: "yes" volumes: - name: redis-data path: /data - stateful: true volumes: pg-data: diff --git a/internal/stackfile/testdata/kitchen_sink.yaml b/internal/stackfile/testdata/kitchen_sink.yaml index a61fb3c..7e8d9da 100644 --- a/internal/stackfile/testdata/kitchen_sink.yaml +++ b/internal/stackfile/testdata/kitchen_sink.yaml @@ -11,7 +11,7 @@ resources: env: SITE_URL: "{{ self.public.http.url }}" CACHE_HOST: "{{ redis.host }}" - CACHE_PORT: "{{ redis.port }}" + CACHE_URL: "redis://{{ redis.host }}:6379" LOG_LEVEL: info secrets: jwt-secret: @@ -43,7 +43,7 @@ resources: image: api:latest env: ROLE: worker - CACHE_HOST: "{{ redis.host }}" + CACHE_URL: "redis://{{ redis.host }}:6379" secrets: jwt-secret: JWT_SECRET: jwt_signing_key diff --git a/internal/stackfile/testdata/simple_nginx.yaml b/internal/stackfile/testdata/simple_nginx.yaml new file mode 100644 index 0000000..9a1f35d --- /dev/null +++ b/internal/stackfile/testdata/simple_nginx.yaml @@ -0,0 +1,8 @@ +name: simple-nginx + +resources: + web: + image: nginx:latest + ports: + - name: http + port: 80 diff --git a/internal/stackfile/testdata/with_addon.yaml b/internal/stackfile/testdata/with_addon.yaml index 1e6912c..8dfdeec 100644 --- a/internal/stackfile/testdata/with_addon.yaml +++ b/internal/stackfile/testdata/with_addon.yaml @@ -13,4 +13,4 @@ resources: database: appdb env: DATABASE_URL: "postgres://{{ username }}:{{ password }}@{{ host }}:{{ port }}/{{ database }}" - DB_HOST: host + DB_HOST: "{{ host }}" diff --git a/internal/stackfile/types.go b/internal/stackfile/types.go index 5ecb67c..21f5f93 100644 --- a/internal/stackfile/types.go +++ b/internal/stackfile/types.go @@ -2,6 +2,10 @@ package stackfile import "gopkg.in/yaml.v3" +const ( + PostgresAddonType = "postgres" +) + type Stackfile struct { Name string `yaml:"name"` Resources map[string]Resource `yaml:"resources"` @@ -9,11 +13,13 @@ type Stackfile struct { } type Resource struct { - Image string `yaml:"image,omitempty"` - Build *BuildConfig `yaml:"build,omitempty"` - Ports []PortDef `yaml:"ports,omitempty"` - Env map[string]string `yaml:"env,omitempty"` - Secrets map[string]SecretMapping `yaml:"secrets,omitempty"` + Image string `yaml:"image,omitempty"` + Build *BuildConfig `yaml:"build,omitempty"` + Ports []PortDef `yaml:"ports,omitempty"` + Env map[string]string `yaml:"env,omitempty"` + // Secret Name -> Mapping of secret keys to env var names + Secrets map[string]SecretMapping `yaml:"secrets,omitempty"` + // Addon Name -> Connection Config Addons map[string]AddonConnectionConfig `yaml:"addons,omitempty"` Volumes []VolumeMountDef `yaml:"volumes,omitempty"` DependsOn []string `yaml:"depends_on,omitempty"` @@ -52,8 +58,12 @@ type VolumeMountDef struct { type SecretMapping map[string]string type AddonConnectionConfig struct { - Type string `yaml:"type"` - Env map[string]string `yaml:"env"` + Type string `yaml:"type"` + // Env vars to inject into the resource, with values templated from the addon outputs. E.g. for a postgres addon we might have: + // env: + // POSTGRES_HOST: {{ postgres.host }} + // POSTGRES_URI: postgres://{{ postgres.host }}:{{ postgres.port }} + Env map[string]string `yaml:"env"` Postgres *PostgresAddonConfig `yaml:"-"` rawNode yaml.Node `yaml:"-"` @@ -78,7 +88,7 @@ func (a *AddonConnectionConfig) UnmarshalYAML(node *yaml.Node) error { a.Env = base.Env switch a.Type { - case "postgres": + case PostgresAddonType: var pg PostgresAddonConfig if err := node.Decode(&pg); err != nil { return err From 7724730f9c681d5fcfe4acd34495ce0969e432c3 Mon Sep 17 00:00:00 2001 From: ashish Date: Thu, 11 Jun 2026 17:26:02 +0530 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20add=20runtime=20commands=20?= =?UTF-8?q?=E2=80=94=20logs,=20build,=20restart,=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logs: SSE streaming from stack or resource with --follow, --tail, --since - build: list/info subcommands for viewing build history and details - restart: triggers resource restart via POST actions/restart endpoint - open: opens resource public URL in browser, prepends http:// if needed - SSE parser for line-by-line event stream parsing - Client methods for log streaming (raw HTTP), builds (OpenAPI), restart - Export TimeAgo from output package for reuse --- cmd/stackdome/build.go | 211 ++++++++++++++++++++++++++++++++++++ cmd/stackdome/logs.go | 79 ++++++++++++++ cmd/stackdome/open.go | 110 +++++++++++++++++++ cmd/stackdome/restart.go | 39 +++++++ cmd/stackdome/root.go | 4 + internal/client/builds.go | 37 +++++++ internal/client/client.go | 2 +- internal/client/logs.go | 60 ++++++++++ internal/client/sse.go | 46 ++++++++ internal/client/sse_test.go | 102 +++++++++++++++++ internal/client/stacks.go | 10 ++ internal/output/status.go | 4 +- 12 files changed, 701 insertions(+), 3 deletions(-) create mode 100644 cmd/stackdome/build.go create mode 100644 cmd/stackdome/logs.go create mode 100644 cmd/stackdome/open.go create mode 100644 cmd/stackdome/restart.go create mode 100644 internal/client/builds.go create mode 100644 internal/client/logs.go create mode 100644 internal/client/sse.go create mode 100644 internal/client/sse_test.go diff --git a/cmd/stackdome/build.go b/cmd/stackdome/build.go new file mode 100644 index 0000000..b7d33ff --- /dev/null +++ b/cmd/stackdome/build.go @@ -0,0 +1,211 @@ +package main + +import ( + "fmt" + "os" + "time" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" + "github.com/stackdome/cli/internal/output" +) + +func newBuildCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "build", + Short: "View build history", + } + + cmd.AddCommand(newBuildListCmd()) + cmd.AddCommand(newBuildInfoCmd()) + return cmd +} + +func newBuildListCmd() *cobra.Command { + var ( + flagResource string + flagStack string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List builds for the current stack", + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + stackID, err := resolveStackID(ctx, cmd, flagStack) + if err != nil { + return err + } + + var builds []openapi.ImageBuild + if flagResource != "" { + builds, err = ctx.Client.ListResourceBuilds(cmd.Context(), stackID, flagResource) + } else { + builds, err = ctx.Client.ListBuilds(cmd.Context(), stackID) + } + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(builds) + } + + if len(builds) == 0 { + fmt.Fprintln(os.Stderr, "No builds found.") + return nil + } + + tbl := ctx.Formatter.NewTable("ID", "RESOURCE", "STATE", "SOURCE", "STARTED", "DURATION") + for _, b := range builds { + tbl.AddRow( + shortID(b.GetId()), + b.StackResourceName, + buildStateColor(b), + buildSource(b), + buildStarted(b), + buildDuration(b), + ) + } + tbl.Render() + return nil + })), + } + + cmd.Flags().StringVarP(&flagResource, "resource", "r", "", "Filter to specific resource") + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + + return cmd +} + +func newBuildInfoCmd() *cobra.Command { + var flagStack string + + cmd := &cobra.Command{ + Use: "info ", + Short: "Show build details", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + stackID, err := resolveStackID(ctx, cmd, flagStack) + if err != nil { + return err + } + + build, err := ctx.Client.GetBuild(cmd.Context(), stackID, args[0]) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(build) + } + + renderBuildInfo(build) + return nil + })), + } + + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + return cmd +} + +func renderBuildInfo(b *openapi.ImageBuild) { + fmt.Printf("Build: %s\n", b.GetId()) + fmt.Printf("Resource: %s\n", b.StackResourceName) + fmt.Printf("State: %s\n", buildStateColor(*b)) + fmt.Printf("Source: %s\n", buildSource(*b)) + + if b.Status != nil && b.Status.ImageUrl != nil && *b.Status.ImageUrl != "" { + fmt.Printf("Image: %s\n", *b.Status.ImageUrl) + } + + if b.CreatedAt != nil { + fmt.Printf("Started: %s\n", b.CreatedAt.Format(time.DateTime)) + } + + fmt.Printf("Duration: %s\n", buildDuration(*b)) + + if b.Status != nil && b.Status.LastBuildFailureDetail != nil { + f := b.Status.LastBuildFailureDetail + fmt.Println() + fmt.Println("Failure:") + if f.FailureType != nil { + fmt.Printf(" Type: %s\n", *f.FailureType) + } + if f.Reason != nil { + fmt.Printf(" Reason: %s\n", *f.Reason) + } + if f.Message != nil { + fmt.Printf(" Message: %s\n", *f.Message) + } + if f.ExitCode != nil { + fmt.Printf(" Exit: %d\n", *f.ExitCode) + } + } +} + +func shortID(id string) string { + if len(id) > 8 { + return id[:8] + } + return id +} + +func buildStateColor(b openapi.ImageBuild) string { + if b.Status == nil || b.Status.State == nil { + return "Unknown" + } + state := *b.Status.State + switch state { + case "Success": + return output.Green(state) + case "Failed": + return output.Red(state) + case "Pending", "Building": + return output.Yellow(state) + default: + return state + } +} + +func buildSource(b openapi.ImageBuild) string { + rev := b.SourceRevision + if rev.GitRepoRevision != nil { + git := rev.GitRepoRevision + if git.Branch != nil && git.Branch.Name != nil { + name := *git.Branch.Name + if git.Branch.HeadSha != nil && len(*git.Branch.HeadSha) >= 7 { + return name + "@" + (*git.Branch.HeadSha)[:7] + } + return name + } + if git.Tag != nil { + return "tag:" + *git.Tag + } + if git.Commit != nil && len(*git.Commit) >= 7 { + return (*git.Commit)[:7] + } + } + if rev.VolumeSourceRevision != nil { + return "volume" + } + return "-" +} + +func buildStarted(b openapi.ImageBuild) string { + if b.CreatedAt == nil { + return "-" + } + return output.TimeAgo(*b.CreatedAt) +} + +func buildDuration(b openapi.ImageBuild) string { + if b.CreatedAt == nil || b.UpdatedAt == nil { + return "-" + } + d := b.UpdatedAt.Sub(*b.CreatedAt) + if d < time.Second { + return "<1s" + } + return d.Round(time.Second).String() +} diff --git a/cmd/stackdome/logs.go b/cmd/stackdome/logs.go new file mode 100644 index 0000000..d8eeeb7 --- /dev/null +++ b/cmd/stackdome/logs.go @@ -0,0 +1,79 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/client" + "github.com/stackdome/cli/internal/cmdutil" + clierrors "github.com/stackdome/cli/internal/errors" +) + +func newLogsCmd() *cobra.Command { + var ( + flagFollow bool + flagTail int32 + flagSince string + flagStack string + ) + + cmd := &cobra.Command{ + Use: "logs [resource]", + Short: "Stream logs from a stack or resource", + Args: cobra.MaximumNArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + stackID, err := resolveStackID(ctx, cmd, flagStack) + if err != nil { + return err + } + + resourceName := "" + if len(args) > 0 { + resourceName = args[0] + } + + opts := client.LogOptions{ + Follow: flagFollow, + Tail: flagTail, + Since: flagSince, + } + + stream, err := ctx.Client.StreamLogs(cmd.Context(), stackID, resourceName, opts) + if err != nil { + return err + } + defer stream.Close() + + return client.ParseSSEStream(stream, func(e client.SSEEvent) error { + if e.Event == "error" { + fmt.Fprintf(os.Stderr, "Error: %s\n", e.Data) + return clierrors.New(e.Data) + } + fmt.Println(e.Data) + return nil + }) + })), + } + + cmd.Flags().BoolVarP(&flagFollow, "follow", "f", false, "Follow log output") + cmd.Flags().Int32Var(&flagTail, "tail", 100, "Number of lines to show") + cmd.Flags().StringVar(&flagSince, "since", "", "Show logs since duration (e.g. 5m, 1h)") + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + + return cmd +} + +func resolveStackID(ctx *cmdutil.CommandContext, cmd *cobra.Command, flagStack string) (string, error) { + if flagStack != "" { + s, err := ctx.Client.FindStackByName(cmd.Context(), flagStack) + if err != nil { + return "", err + } + if s == nil { + return "", clierrors.NotFoundError("Stack", flagStack) + } + return *s.Id, nil + } + return ctx.Config.RequireStack() +} diff --git a/cmd/stackdome/open.go b/cmd/stackdome/open.go new file mode 100644 index 0000000..dbed659 --- /dev/null +++ b/cmd/stackdome/open.go @@ -0,0 +1,110 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" + clierrors "github.com/stackdome/cli/internal/errors" +) + +func newOpenCmd() *cobra.Command { + var flagStack string + + cmd := &cobra.Command{ + Use: "open [resource]", + Short: "Open a resource's public URL in the browser", + Args: cobra.MaximumNArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + stackID, err := resolveStackID(ctx, cmd, flagStack) + if err != nil { + return err + } + + resources, err := ctx.Client.GetStackResources(cmd.Context(), stackID) + if err != nil { + return err + } + + resourceFilter := "" + if len(args) > 0 { + resourceFilter = args[0] + } + + type publicURL struct { + Resource string + URL string + } + + var urls []publicURL + for _, res := range resources { + if resourceFilter != "" && res.Name != resourceFilter { + continue + } + if res.Status == nil { + continue + } + for _, ing := range res.Status.PublicIngress { + if ing.Url != nil && *ing.Url != "" { + urls = append(urls, publicURL{Resource: res.Name, URL: *ing.Url}) + } + } + } + + if resourceFilter != "" && len(urls) == 0 { + found := false + for _, res := range resources { + if res.Name == resourceFilter { + found = true + break + } + } + if !found { + return clierrors.NotFoundError("Resource", resourceFilter) + } + return clierrors.Newf("No public URLs found for resource %q", resourceFilter) + } + + if len(urls) == 0 { + return clierrors.New("No public URLs found in this stack") + } + + if len(urls) > 1 { + tbl := ctx.Formatter.NewTable("RESOURCE", "URL") + for _, u := range urls { + tbl.AddRow(u.Resource, u.URL) + } + tbl.Render() + fmt.Fprintln(os.Stderr) + } + + target := urls[0].URL + fmt.Fprintf(os.Stderr, "Opening %s...\n", target) + return openBrowser(target) + })), + } + + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + + return cmd +} + +func openBrowser(url string) error { + if !strings.Contains(url, "://") { + url = "http://" + url + } + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + default: + return clierrors.Newf("unsupported platform %q — open %s manually", runtime.GOOS, url) + } + return cmd.Start() +} diff --git a/cmd/stackdome/restart.go b/cmd/stackdome/restart.go new file mode 100644 index 0000000..a449de8 --- /dev/null +++ b/cmd/stackdome/restart.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" +) + +func newRestartCmd() *cobra.Command { + var flagStack string + + cmd := &cobra.Command{ + Use: "restart ", + Short: "Restart a stack resource", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + resourceName := args[0] + + stackID, err := resolveStackID(ctx, cmd, flagStack) + if err != nil { + return err + } + + _, err = ctx.Client.RestartResource(cmd.Context(), stackID, resourceName) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Restart initiated for resource %q\n", resourceName) + return nil + })), + } + + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + + return cmd +} diff --git a/cmd/stackdome/root.go b/cmd/stackdome/root.go index 1a39405..6a2d15a 100644 --- a/cmd/stackdome/root.go +++ b/cmd/stackdome/root.go @@ -62,6 +62,10 @@ func newRootCmd() *cobra.Command { rootCmd.AddCommand(newDestroyCmd()) rootCmd.AddCommand(newValidateCmd()) rootCmd.AddCommand(newStackCmd()) + rootCmd.AddCommand(newLogsCmd()) + rootCmd.AddCommand(newBuildCmd()) + rootCmd.AddCommand(newRestartCmd()) + rootCmd.AddCommand(newOpenCmd()) return rootCmd } diff --git a/internal/client/builds.go b/internal/client/builds.go new file mode 100644 index 0000000..2b61ca5 --- /dev/null +++ b/internal/client/builds.go @@ -0,0 +1,37 @@ +package client + +import ( + "context" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" +) + +func (c *Client) ListBuilds(ctx context.Context, stackID string) ([]openapi.ImageBuild, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameStacksIdBuildsGet(ctx, c.orgID, c.teamName, stackID). + Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to list builds") + } + return resp.GetItems(), nil +} + +func (c *Client) ListResourceBuilds(ctx context.Context, stackID, resourceName string) ([]openapi.ImageBuild, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameStacksIdResourcesResourceNameBuildsGet(ctx, c.orgID, c.teamName, stackID, resourceName). + Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to list builds for resource") + } + return resp.GetItems(), nil +} + +func (c *Client) GetBuild(ctx context.Context, stackID, buildID string) (*openapi.ImageBuild, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameStacksIdBuildsBuildIdGet(ctx, c.orgID, c.teamName, stackID, buildID). + Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to get build") + } + return resp, nil +} diff --git a/internal/client/client.go b/internal/client/client.go index cdf4761..296f51c 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -146,7 +146,7 @@ func WrapError(httpResp *http.Response, err error, message string) error { if isTimeoutError(err) { return clierrors.Wrapf(err, "%s: request timed out", message) } - return clierrors.Wrapf(err, message) + return clierrors.Wrapf(err, "%s", message) } func extractAPIReason(err error) string { diff --git a/internal/client/logs.go b/internal/client/logs.go new file mode 100644 index 0000000..fe2b6ef --- /dev/null +++ b/internal/client/logs.go @@ -0,0 +1,60 @@ +package client + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" + + clierrors "github.com/stackdome/cli/internal/errors" +) + +type LogOptions struct { + Follow bool + Tail int32 + Since string +} + +func (c *Client) StreamLogs(ctx context.Context, stackID, resourceName string, opts LogOptions) (io.ReadCloser, error) { + path := fmt.Sprintf("/api/v1/organizations/%s/teams/%s/stacks/%s", c.orgID, c.teamName, stackID) + if resourceName != "" { + path += fmt.Sprintf("/resources/%s", resourceName) + } + path += "/logs" + + query := "?tail=" + strconv.Itoa(int(opts.Tail)) + if opts.Follow { + query += "&follow=true" + } + if opts.Since != "" { + query += "&since=" + opts.Since + } + + url := c.baseURL + path + query + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, clierrors.Wrap(err, "Failed to create log request") + } + + req.Header.Set("Authorization", "Bearer "+c.accessToken) + req.Header.Set("Accept", "text/event-stream") + + httpClient := c.cfg.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, clierrors.Wrap(err, "Failed to connect to log stream") + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, clierrors.FromHTTP(resp.StatusCode, "Log streaming failed") + } + + return resp.Body, nil +} diff --git a/internal/client/sse.go b/internal/client/sse.go new file mode 100644 index 0000000..b53d538 --- /dev/null +++ b/internal/client/sse.go @@ -0,0 +1,46 @@ +package client + +import ( + "bufio" + "io" + "strings" +) + +type SSEEvent struct { + Event string + Data string +} + +func ParseSSEStream(r io.Reader, fn func(SSEEvent) error) error { + scanner := bufio.NewScanner(r) + var event SSEEvent + hasData := false + + for scanner.Scan() { + line := scanner.Text() + + if line == "" { + if hasData { + if err := fn(event); err != nil { + return err + } + } + event = SSEEvent{} + hasData = false + continue + } + + if strings.HasPrefix(line, "data: ") { + event.Data = line[6:] + hasData = true + } else if line == "data:" { + event.Data = "" + hasData = true + } else if strings.HasPrefix(line, "event: ") { + event.Event = line[7:] + hasData = true + } + } + + return scanner.Err() +} diff --git a/internal/client/sse_test.go b/internal/client/sse_test.go new file mode 100644 index 0000000..2253e33 --- /dev/null +++ b/internal/client/sse_test.go @@ -0,0 +1,102 @@ +package client + +import ( + "strings" + "testing" +) + +func TestParseSSEStream_DataEvents(t *testing.T) { + input := "data: [web]: hello world\n\ndata: [web]: second line\n\n" + var events []SSEEvent + err := ParseSSEStream(strings.NewReader(input), func(e SSEEvent) error { + events = append(events, e) + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + if events[0].Data != "[web]: hello world" { + t.Errorf("expected '[web]: hello world', got %q", events[0].Data) + } + if events[0].Event != "" { + t.Errorf("expected empty event type, got %q", events[0].Event) + } +} + +func TestParseSSEStream_ErrorEvent(t *testing.T) { + input := "event: error\ndata: connection lost\n\n" + var events []SSEEvent + err := ParseSSEStream(strings.NewReader(input), func(e SSEEvent) error { + events = append(events, e) + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Event != "error" { + t.Errorf("expected event type 'error', got %q", events[0].Event) + } + if events[0].Data != "connection lost" { + t.Errorf("expected 'connection lost', got %q", events[0].Data) + } +} + +func TestParseSSEStream_EmptyData(t *testing.T) { + input := "data: \n\n" + var events []SSEEvent + err := ParseSSEStream(strings.NewReader(input), func(e SSEEvent) error { + events = append(events, e) + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Data != "" { + t.Errorf("expected empty data, got %q", events[0].Data) + } +} + +func TestParseSSEStream_EmptyInput(t *testing.T) { + var events []SSEEvent + err := ParseSSEStream(strings.NewReader(""), func(e SSEEvent) error { + events = append(events, e) + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 0 { + t.Fatalf("expected 0 events, got %d", len(events)) + } +} + +func TestParseSSEStream_CallbackError(t *testing.T) { + input := "data: line1\n\ndata: line2\n\n" + count := 0 + err := ParseSSEStream(strings.NewReader(input), func(e SSEEvent) error { + count++ + if count == 1 { + return &testErr{"stop"} + } + return nil + }) + if err == nil { + t.Fatal("expected error from callback") + } + if count != 1 { + t.Errorf("expected callback called once, got %d", count) + } +} + +type testErr struct{ msg string } + +func (e *testErr) Error() string { return e.msg } diff --git a/internal/client/stacks.go b/internal/client/stacks.go index c4b0dcf..2967736 100644 --- a/internal/client/stacks.go +++ b/internal/client/stacks.go @@ -62,6 +62,16 @@ func (c *Client) GetStackResources(ctx context.Context, stackID string) ([]opena return resp.Items, nil } +func (c *Client) RestartResource(ctx context.Context, stackID, resourceName string) (*openapi.StackResource, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameStacksIdResourcesResourceNameActionsRestartPost(ctx, c.orgID, c.teamName, stackID, resourceName). + Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to restart resource") + } + return resp, nil +} + func (c *Client) FindStackByName(ctx context.Context, name string) (*openapi.Stack, error) { stacks, err := c.ListStacks(ctx) if err != nil { diff --git a/internal/output/status.go b/internal/output/status.go index 171e1d7..5948e2e 100644 --- a/internal/output/status.go +++ b/internal/output/status.go @@ -186,7 +186,7 @@ func conditionRow(c openapi.Condition) []string { message = *c.Message } if c.LastTransitionTime != nil { - age = Dim(timeAgo(*c.LastTransitionTime)) + age = Dim(TimeAgo(*c.LastTransitionTime)) } return []string{status, condType, reason, message, age} } @@ -252,7 +252,7 @@ func formatURL(res *openapi.StackResource) string { return strings.Join(urls, ", ") } -func timeAgo(t time.Time) string { +func TimeAgo(t time.Time) string { d := time.Since(t) switch { case d < time.Minute: From 91dd05c29f7bb60ff7b4f9d2ac157f846514a8f4 Mon Sep 17 00:00:00 2001 From: ashish Date: Thu, 11 Jun 2026 17:54:30 +0530 Subject: [PATCH 6/8] feat: add secret and volume commands with improved error messages - secret: list, info, create, set, delete with support for all 6 types (Generic, DockerRegistry, GitCredentials, UsernamePassword, Token, SSHKey) - secret create: --data KEY=VALUE (repeatable) and --from-file for .env files - volume: list and delete (create blocked by stale WorkspaceName validation) - Extract API reason from 400 responses so validation errors show clearly (e.g. "docker registry secret requires field: registry") --- cmd/stackdome/root.go | 2 + cmd/stackdome/secret.go | 307 +++++++++++++++++++++++++++++++++++++ cmd/stackdome/volume.go | 102 ++++++++++++ internal/client/client.go | 18 ++- internal/client/secrets.go | 70 +++++++++ internal/client/volumes.go | 66 ++++++++ internal/errors/errors.go | 7 + 7 files changed, 569 insertions(+), 3 deletions(-) create mode 100644 cmd/stackdome/secret.go create mode 100644 cmd/stackdome/volume.go create mode 100644 internal/client/secrets.go create mode 100644 internal/client/volumes.go diff --git a/cmd/stackdome/root.go b/cmd/stackdome/root.go index 6a2d15a..27b9b7f 100644 --- a/cmd/stackdome/root.go +++ b/cmd/stackdome/root.go @@ -66,6 +66,8 @@ func newRootCmd() *cobra.Command { rootCmd.AddCommand(newBuildCmd()) rootCmd.AddCommand(newRestartCmd()) rootCmd.AddCommand(newOpenCmd()) + rootCmd.AddCommand(newSecretCmd()) + rootCmd.AddCommand(newVolumeCmd()) return rootCmd } diff --git a/cmd/stackdome/secret.go b/cmd/stackdome/secret.go new file mode 100644 index 0000000..034d0bf --- /dev/null +++ b/cmd/stackdome/secret.go @@ -0,0 +1,307 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + "time" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" + clierrors "github.com/stackdome/cli/internal/errors" + "github.com/stackdome/cli/internal/output" +) + +func newSecretCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "secret", + Short: "Manage secrets", + } + + cmd.AddCommand(newSecretListCmd()) + cmd.AddCommand(newSecretInfoCmd()) + cmd.AddCommand(newSecretCreateCmd()) + cmd.AddCommand(newSecretSetCmd()) + cmd.AddCommand(newSecretDeleteCmd()) + return cmd +} + +func newSecretListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all secrets", + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + secrets, err := ctx.Client.ListSecrets(cmd.Context()) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(secrets) + } + + if len(secrets) == 0 { + fmt.Fprintln(os.Stderr, "No secrets found.") + return nil + } + + tbl := ctx.Formatter.NewTable("NAME", "TYPE", "KEYS", "CREATED") + for _, s := range secrets { + tbl.AddRow( + s.Name, + string(s.Type), + secretKeys(s), + secretCreated(s), + ) + } + tbl.Render() + return nil + })), + } +} + +func newSecretInfoCmd() *cobra.Command { + return &cobra.Command{ + Use: "info ", + Short: "Show secret details", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + secret, err := ctx.Client.FindSecretByName(cmd.Context(), args[0]) + if err != nil { + return err + } + if secret == nil { + return clierrors.NotFoundError("Secret", args[0]) + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(secret) + } + + fmt.Printf("Name: %s\n", secret.Name) + fmt.Printf("Type: %s\n", secret.Type) + if secret.Description != nil && *secret.Description != "" { + fmt.Printf("Description: %s\n", *secret.Description) + } + + if len(secret.Data) > 0 { + fmt.Println("\nKeys:") + for _, d := range secret.Data { + fmt.Printf(" %s\n", d.Key) + } + } + + if secret.CreatedAt != nil { + fmt.Printf("\nCreated: %s\n", secret.CreatedAt.Format(time.DateTime)) + } + if secret.UpdatedAt != nil { + fmt.Printf("Updated: %s\n", secret.UpdatedAt.Format(time.DateTime)) + } + return nil + })), + } +} + +func newSecretCreateCmd() *cobra.Command { + var ( + flagData []string + flagFromFile string + flagType string + flagDescription string + ) + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new secret", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + data, err := collectSecretData(flagData, flagFromFile) + if err != nil { + return err + } + if len(data) == 0 { + return clierrors.ValidationError("At least one --data or --from-file is required") + } + + secret := openapi.Secret{ + Name: args[0], + Type: openapi.SecretType(flagType), + Data: data, + } + if flagDescription != "" { + secret.Description = &flagDescription + } + + result, err := ctx.Client.CreateSecret(cmd.Context(), secret) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Secret %q created.\n", result.Name) + return nil + })), + } + + cmd.Flags().StringArrayVar(&flagData, "data", nil, "Secret data as KEY=VALUE (repeatable)") + cmd.Flags().StringVar(&flagFromFile, "from-file", "", "Read KEY=VALUE pairs from file") + cmd.Flags().StringVar(&flagType, "type", "Generic", "Secret type (Generic, DockerRegistry, GitCredentials, UsernamePassword, Token, SSHKey)") + cmd.Flags().StringVar(&flagDescription, "description", "", "Secret description") + + return cmd +} + +func newSecretSetCmd() *cobra.Command { + var ( + flagData []string + flagFromFile string + ) + + cmd := &cobra.Command{ + Use: "set ", + Short: "Update a secret's data", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + existing, err := ctx.Client.FindSecretByName(cmd.Context(), args[0]) + if err != nil { + return err + } + if existing == nil { + return clierrors.NotFoundError("Secret", args[0]) + } + + data, err := collectSecretData(flagData, flagFromFile) + if err != nil { + return err + } + if len(data) == 0 { + return clierrors.ValidationError("At least one --data or --from-file is required") + } + + updated := openapi.Secret{ + Name: existing.Name, + Type: existing.Type, + Data: data, + } + if existing.Description != nil { + updated.Description = existing.Description + } + + _, err = ctx.Client.UpdateSecret(cmd.Context(), *existing.Id, updated) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Secret %q updated.\n", args[0]) + return nil + })), + } + + cmd.Flags().StringArrayVar(&flagData, "data", nil, "Secret data as KEY=VALUE (repeatable)") + cmd.Flags().StringVar(&flagFromFile, "from-file", "", "Read KEY=VALUE pairs from file") + + return cmd +} + +func newSecretDeleteCmd() *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a secret", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + secret, err := ctx.Client.FindSecretByName(cmd.Context(), args[0]) + if err != nil { + return err + } + if secret == nil { + return clierrors.NotFoundError("Secret", args[0]) + } + + if !flagYes { + fmt.Fprintf(os.Stderr, "Delete secret %q? [y/N]: ", args[0]) + var confirm string + fmt.Scanln(&confirm) + if confirm != "y" && confirm != "Y" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + if err := ctx.Client.DeleteSecret(cmd.Context(), *secret.Id); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Secret %q deleted.\n", args[0]) + return nil + })), + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation") + return cmd +} + +func collectSecretData(dataFlags []string, fromFile string) ([]openapi.SecretData, error) { + var data []openapi.SecretData + + for _, kv := range dataFlags { + key, value, ok := strings.Cut(kv, "=") + if !ok { + return nil, clierrors.ValidationError(fmt.Sprintf("invalid --data format %q, expected KEY=VALUE", kv)) + } + data = append(data, openapi.SecretData{Key: key, Value: value}) + } + + if fromFile != "" { + fileData, err := parseEnvFile(fromFile) + if err != nil { + return nil, err + } + data = append(data, fileData...) + } + + return data, nil +} + +func parseEnvFile(path string) ([]openapi.SecretData, error) { + f, err := os.Open(path) + if err != nil { + return nil, clierrors.Wrapf(err, "Failed to read file %q", path) + } + defer f.Close() + + var data []openapi.SecretData + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + return nil, clierrors.ValidationError(fmt.Sprintf("invalid line in %q: %q (expected KEY=VALUE)", path, line)) + } + data = append(data, openapi.SecretData{Key: key, Value: value}) + } + return data, scanner.Err() +} + +func secretKeys(s openapi.Secret) string { + if len(s.Data) == 0 { + return "-" + } + keys := make([]string, len(s.Data)) + for i, d := range s.Data { + keys[i] = d.Key + } + return strings.Join(keys, ", ") +} + +func secretCreated(s openapi.Secret) string { + if s.CreatedAt == nil { + return "-" + } + return output.TimeAgo(*s.CreatedAt) +} diff --git a/cmd/stackdome/volume.go b/cmd/stackdome/volume.go new file mode 100644 index 0000000..b1da211 --- /dev/null +++ b/cmd/stackdome/volume.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "os" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" + clierrors "github.com/stackdome/cli/internal/errors" +) + +func newVolumeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "volume", + Short: "Manage volumes", + } + + cmd.AddCommand(newVolumeListCmd()) + cmd.AddCommand(newVolumeDeleteCmd()) + return cmd +} + +func newVolumeListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List volumes", + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + volumes, err := ctx.Client.ListVolumes(cmd.Context()) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(volumes) + } + + if len(volumes) == 0 { + fmt.Fprintln(os.Stderr, "No volumes found.") + return nil + } + + tbl := ctx.Formatter.NewTable("NAME", "SIZE", "ACCESS MODE", "PHASE") + for _, v := range volumes { + tbl.AddRow( + v.Name, + v.Spec.Size, + string(v.Spec.AccessMode), + volumePhase(v), + ) + } + tbl.Render() + return nil + })), + } +} + +func newVolumeDeleteCmd() *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a volume", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + volume, err := ctx.Client.FindVolumeByName(cmd.Context(), args[0]) + if err != nil { + return err + } + if volume == nil { + return clierrors.NotFoundError("Volume", args[0]) + } + + if !flagYes { + fmt.Fprintf(os.Stderr, "Delete volume %q? [y/N]: ", args[0]) + var confirm string + fmt.Scanln(&confirm) + if confirm != "y" && confirm != "Y" { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + + if err := ctx.Client.DeleteVolume(cmd.Context(), *volume.Id); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Volume %q deleted.\n", args[0]) + return nil + })), + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation") + return cmd +} + +func volumePhase(v openapi.Volume) string { + if v.Status == nil || v.Status.Phase == nil { + return "Unknown" + } + return *v.Status.Phase +} diff --git a/internal/client/client.go b/internal/client/client.go index 296f51c..e0c9de4 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -149,16 +149,28 @@ func WrapError(httpResp *http.Response, err error, message string) error { return clierrors.Wrapf(err, "%s", message) } +type bodyer interface { + Body() []byte +} + func extractAPIReason(err error) string { if err == nil { return "" } - body := err.Error() + + var sources [][]byte + if b, ok := err.(bodyer); ok { + sources = append(sources, b.Body()) + } + sources = append(sources, []byte(err.Error())) + var apiErr struct { Reason string `json:"reason"` } - if json.Unmarshal([]byte(body), &apiErr) == nil && apiErr.Reason != "" { - return apiErr.Reason + for _, src := range sources { + if json.Unmarshal(src, &apiErr) == nil && apiErr.Reason != "" { + return apiErr.Reason + } } return "" } diff --git a/internal/client/secrets.go b/internal/client/secrets.go new file mode 100644 index 0000000..913cb02 --- /dev/null +++ b/internal/client/secrets.go @@ -0,0 +1,70 @@ +package client + +import ( + "context" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" +) + +func (c *Client) ListSecrets(ctx context.Context) ([]openapi.Secret, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameSecretsGet(ctx, c.orgID, c.teamName). + Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to list secrets") + } + return resp.GetItems(), nil +} + +func (c *Client) GetSecret(ctx context.Context, secretID string) (*openapi.Secret, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameSecretsIdGet(ctx, c.orgID, c.teamName, secretID). + Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to get secret") + } + return resp, nil +} + +func (c *Client) CreateSecret(ctx context.Context, secret openapi.Secret) (*openapi.Secret, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameSecretsPost(ctx, c.orgID, c.teamName). + Secret(secret).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to create secret") + } + return resp, nil +} + +func (c *Client) UpdateSecret(ctx context.Context, secretID string, secret openapi.Secret) (*openapi.Secret, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameSecretsIdPut(ctx, c.orgID, c.teamName, secretID). + Secret(secret).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to update secret") + } + return resp, nil +} + +func (c *Client) DeleteSecret(ctx context.Context, secretID string) error { + httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameSecretsIdDelete(ctx, c.orgID, c.teamName, secretID). + Execute() + if err != nil { + return WrapError(httpResp, err, "Failed to delete secret") + } + return nil +} + +func (c *Client) FindSecretByName(ctx context.Context, name string) (*openapi.Secret, error) { + secrets, err := c.ListSecrets(ctx) + if err != nil { + return nil, err + } + for i := range secrets { + if secrets[i].Name == name { + return &secrets[i], nil + } + } + return nil, nil +} diff --git a/internal/client/volumes.go b/internal/client/volumes.go new file mode 100644 index 0000000..74a9a6f --- /dev/null +++ b/internal/client/volumes.go @@ -0,0 +1,66 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + clierrors "github.com/stackdome/cli/internal/errors" +) + +func (c *Client) ListVolumes(ctx context.Context) ([]openapi.Volume, error) { + url := fmt.Sprintf("%s/api/v1/organizations/%s/teams/%s/volumes/current", c.baseURL, c.orgID, c.teamName) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, clierrors.Wrap(err, "Failed to create volume list request") + } + req.Header.Set("Authorization", "Bearer "+c.accessToken) + req.Header.Set("Accept", "application/json") + + httpClient := c.cfg.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, clierrors.Wrap(err, "Failed to list volumes") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, clierrors.FromHTTP(resp.StatusCode, "Failed to list volumes") + } + + var list openapi.VolumeList + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + return nil, clierrors.Wrap(err, "Failed to decode volume list") + } + return list.GetItems(), nil +} + +func (c *Client) DeleteVolume(ctx context.Context, volumeID string) error { + httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdTeamsTeamNameVolumesIdDelete(ctx, c.orgID, c.teamName, volumeID). + Execute() + if err != nil { + return WrapError(httpResp, err, "Failed to delete volume") + } + return nil +} + +func (c *Client) FindVolumeByName(ctx context.Context, name string) (*openapi.Volume, error) { + volumes, err := c.ListVolumes(ctx) + if err != nil { + return nil, err + } + for i := range volumes { + if volumes[i].Name == name { + return &volumes[i], nil + } + } + return nil, nil +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go index d360d5f..4647583 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -123,6 +123,13 @@ func FromHTTP(statusCode int, body string) *CLIError { e.Message = "Conflict" e.Code = "CONFLICT" e.ExitCode = ExitConflict + case statusCode == 400: + e.Message = body + if e.Message == "" { + e.Message = "Invalid request" + } + e.Code = "VALIDATION_ERROR" + e.ExitCode = ExitValidation case statusCode >= 500: e.Message = "Server error — try again later" e.Code = "SERVER_ERROR" From 46ae4f43ba22651035ee6cdbb60b8ba418d4ce59 Mon Sep 17 00:00:00 2001 From: ashish Date: Thu, 11 Jun 2026 20:02:08 +0530 Subject: [PATCH 7/8] feat: add init with compose converter, shell completion, deploy spinner, and code cleanup - stackdome init: converts docker-compose.yaml to stackfile with validation - stackdome completion: bash, zsh, fish - deploy returns immediately by default, use --wait for blocking - env_file and command/args support in stackfile format - spinner for deploy --wait - code cleanup --- cmd/cmds/build/build.go | 61 --- cmd/cmds/delete/delete_workspace.go | 74 --- cmd/cmds/deploy/deploy.go | 47 -- cmd/cmds/exec/exec.go | 70 --- cmd/cmds/init/init.go | 60 --- cmd/cmds/list/list.go | 15 - cmd/cmds/list/list_builds.go | 97 ---- cmd/cmds/list/list_workspace_storages.go | 58 --- cmd/cmds/list/list_workspaces.go | 60 --- cmd/cmds/login/login.go | 64 --- cmd/cmds/logs/logs.go | 80 ---- cmd/cmds/restart/restart.go | 58 --- cmd/cmds/status/status.go | 60 --- cmd/cmds/sync/sync.go | 55 --- cmd/cmds/sync/sync_status.go | 45 -- cmd/cmds/syncsession/start_session.go | 75 --- cmd/cmds/syncsession/stop_session.go | 41 -- cmd/cmds/syncsession/sync_session.go | 21 - cmd/cmds/validate/validate.go | 43 -- cmd/main.go | 72 --- cmd/stackdome/completion.go | 42 ++ cmd/stackdome/deploy.go | 33 +- cmd/stackdome/helpers.go | 21 + cmd/stackdome/init.go | 183 +++++++ cmd/stackdome/logs.go | 13 - cmd/stackdome/root.go | 2 + cmd/stackdome/secret.go | 19 +- go.mod | 1 + go.sum | 2 + internal/errors/errors.go | 2 +- internal/output/spinner.go | 53 +++ internal/stackfile/compose.go | 432 +++++++++++++++++ internal/stackfile/convert.go | 25 +- internal/stackfile/parse.go | 39 +- internal/stackfile/types.go | 13 +- pkg/api/v1alpha1/resource_builds.go | 20 - pkg/api/v1alpha1/user_stack.go | 188 -------- pkg/api/v1alpha1/user_types.go | 109 ----- pkg/api/v1alpha1/workspace.go | 144 ------ pkg/api/v1alpha1/workspace_storage.go | 89 ---- pkg/client/k8s_provider_client.go | 78 --- pkg/client/stackdome_client.go | 298 ------------ pkg/config/config.go | 251 ---------- pkg/config/runtime.go | 180 ------- pkg/mapper/resource_builds.go | 38 -- pkg/mapper/workspace.go | 445 ------------------ pkg/mapper/workspace_storage.go | 149 ------ pkg/mapper/workspace_user.go | 229 --------- pkg/process/process.go | 16 - pkg/provider/k8s/kuberenetes_provider.go | 321 ------------- pkg/provider/types.go | 26 - pkg/services/errors.go | 47 -- .../workspace_initialization_service.go | 153 ------ pkg/services/workspace_service.go | 252 ---------- pkg/services/workspace_storage_service.go | 114 ----- pkg/session/session.go | 258 ---------- pkg/sync/mutagen_syncer.go | 293 ------------ pkg/sync/types.go | 17 - pkg/tools/dir_hash.go | 34 -- pkg/tools/downloader.go | 32 -- pkg/tools/file.go | 36 -- pkg/tools/file_flag.go | 47 -- pkg/tools/filewatcher.go | 107 ----- pkg/tools/ssh.go | 159 ------- pkg/validation/userstack_validation.go | 57 --- pkg/workspace/build_handler.go | 53 --- pkg/workspace/delete_handler.go | 110 ----- pkg/workspace/deploy_handler.go | 160 ------- pkg/workspace/execute_handler.go | 55 --- pkg/workspace/list_handler.go | 63 --- pkg/workspace/logs_handler.go | 57 --- pkg/workspace/restart_handler.go | 83 ---- pkg/workspace/status_handler.go | 278 ----------- pkg/workspace/sync_handler.go | 113 ----- pkg/workspace/workspace_handler.go | 57 --- 75 files changed, 826 insertions(+), 6426 deletions(-) delete mode 100644 cmd/cmds/build/build.go delete mode 100644 cmd/cmds/delete/delete_workspace.go delete mode 100644 cmd/cmds/deploy/deploy.go delete mode 100644 cmd/cmds/exec/exec.go delete mode 100644 cmd/cmds/init/init.go delete mode 100644 cmd/cmds/list/list.go delete mode 100644 cmd/cmds/list/list_builds.go delete mode 100644 cmd/cmds/list/list_workspace_storages.go delete mode 100644 cmd/cmds/list/list_workspaces.go delete mode 100644 cmd/cmds/login/login.go delete mode 100644 cmd/cmds/logs/logs.go delete mode 100644 cmd/cmds/restart/restart.go delete mode 100644 cmd/cmds/status/status.go delete mode 100644 cmd/cmds/sync/sync.go delete mode 100644 cmd/cmds/sync/sync_status.go delete mode 100644 cmd/cmds/syncsession/start_session.go delete mode 100644 cmd/cmds/syncsession/stop_session.go delete mode 100644 cmd/cmds/syncsession/sync_session.go delete mode 100644 cmd/cmds/validate/validate.go delete mode 100644 cmd/main.go create mode 100644 cmd/stackdome/completion.go create mode 100644 cmd/stackdome/helpers.go create mode 100644 cmd/stackdome/init.go create mode 100644 internal/output/spinner.go create mode 100644 internal/stackfile/compose.go delete mode 100644 pkg/api/v1alpha1/resource_builds.go delete mode 100644 pkg/api/v1alpha1/user_stack.go delete mode 100644 pkg/api/v1alpha1/user_types.go delete mode 100644 pkg/api/v1alpha1/workspace.go delete mode 100644 pkg/api/v1alpha1/workspace_storage.go delete mode 100644 pkg/client/k8s_provider_client.go delete mode 100644 pkg/client/stackdome_client.go delete mode 100644 pkg/config/config.go delete mode 100644 pkg/config/runtime.go delete mode 100644 pkg/mapper/resource_builds.go delete mode 100644 pkg/mapper/workspace.go delete mode 100644 pkg/mapper/workspace_storage.go delete mode 100644 pkg/mapper/workspace_user.go delete mode 100644 pkg/process/process.go delete mode 100644 pkg/provider/k8s/kuberenetes_provider.go delete mode 100644 pkg/provider/types.go delete mode 100644 pkg/services/errors.go delete mode 100644 pkg/services/workspace_initialization_service.go delete mode 100644 pkg/services/workspace_service.go delete mode 100644 pkg/services/workspace_storage_service.go delete mode 100644 pkg/session/session.go delete mode 100644 pkg/sync/mutagen_syncer.go delete mode 100644 pkg/sync/types.go delete mode 100644 pkg/tools/dir_hash.go delete mode 100644 pkg/tools/downloader.go delete mode 100644 pkg/tools/file.go delete mode 100644 pkg/tools/file_flag.go delete mode 100644 pkg/tools/filewatcher.go delete mode 100644 pkg/tools/ssh.go delete mode 100644 pkg/validation/userstack_validation.go delete mode 100644 pkg/workspace/build_handler.go delete mode 100644 pkg/workspace/delete_handler.go delete mode 100644 pkg/workspace/deploy_handler.go delete mode 100644 pkg/workspace/execute_handler.go delete mode 100644 pkg/workspace/list_handler.go delete mode 100644 pkg/workspace/logs_handler.go delete mode 100644 pkg/workspace/restart_handler.go delete mode 100644 pkg/workspace/status_handler.go delete mode 100644 pkg/workspace/sync_handler.go delete mode 100644 pkg/workspace/workspace_handler.go diff --git a/cmd/cmds/build/build.go b/cmd/cmds/build/build.go deleted file mode 100644 index 8f2685b..0000000 --- a/cmd/cmds/build/build.go +++ /dev/null @@ -1,61 +0,0 @@ -package build - -import ( - "context" - "fmt" - "os" - - "github.com/ashishmax31/voyager-cli/cmd/common" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" -) - -var buildArgs struct { - stackFilePath string - all bool -} - -func NewBuildCommand() *cobra.Command { - var buildCmd = &cobra.Command{ - Use: "build", - Short: "Trigger a new build for a resource/all resources.", - Long: `Trigger a new build for a resource/all resources. Pass --all or -a flag to trigger a new build of all resources.`, - Run: func(cmd *cobra.Command, args []string) { - if err := build(context.Background(), args); err != nil { - fmt.Printf("build error: %s \n", err.Error()) - os.Exit(1) - } - }, - Args: cobra.RangeArgs(0, 1), - } - buildCmd.Flags().BoolVarP(&buildArgs.all, "all", "a", false, "-a or --all") - buildCmd.Flags().StringVar(&buildArgs.stackFilePath, common.VoyagerFilePathFlag, "./voyagerfile.yaml", fmt.Sprintf("--%s=voyagerfile.yaml", common.VoyagerFilePathFlag)) - return buildCmd -} - -func build(ctx context.Context, args []string) error { - if len(args) == 0 && !buildArgs.all { - return fmt.Errorf("atleast one argument is required or pass --all flag") - } - - if len(args) == 0 { - args = append(args, "") - } - - runtime, err := config.NewRuntime("build", config.Args{ - StackFilePath: &buildArgs.stackFilePath, - AllResources: &buildArgs.all, - ResourceName: &args[0], - }) - - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - return handler.Build(ctx, runtime) -} diff --git a/cmd/cmds/delete/delete_workspace.go b/cmd/cmds/delete/delete_workspace.go deleted file mode 100644 index 29badee..0000000 --- a/cmd/cmds/delete/delete_workspace.go +++ /dev/null @@ -1,74 +0,0 @@ -package delete - -import ( - "context" - "fmt" - - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" -) - -type deleteFlags struct { - allWorkspaces bool - removeWorkspaceStorage bool - currentWorkspace bool -} - -var flags deleteFlags - -func NewWorkspaceDeleteCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "delete-workspace [workspace-name | -a | --all | -c | --current] [--remove-storage] ", - Run: func(cmd *cobra.Command, args []string) { - if err := run(cmd, args); err != nil { - fmt.Printf("delete-workspace error: %s \n", err.Error()) - } - }, - Short: "Delete a workspace or all workspaces", - Long: `Delete a workspace or all workspaces.`, - Args: func(cmd *cobra.Command, args []string) error { - all, _ := cmd.Flags().GetBool("all") - current, _ := cmd.Flags().GetBool("current") - - if len(args) == 0 && !all && !current { - return fmt.Errorf("you must specify a workspace name, use --all/-a, or use --current/-c") - } - - if (all && current) || (len(args) > 0 && (all || current)) { - return fmt.Errorf("you cannot specify a workspace name and use --all/-a or --current/-c together") - } - - return nil - }, - } - cmd.Flags().BoolVarP(&flags.allWorkspaces, "all", "a", false, "Delete all workspaces") - cmd.Flags().BoolVar(&flags.removeWorkspaceStorage, "remove-storage", false, "Remove workspace storage") - cmd.Flags().BoolVarP(&flags.currentWorkspace, "current", "c", false, "Delete the current workspace") - return cmd -} - -func run(_ *cobra.Command, args []string) error { - // Append a default value to args if none is provided. This wont be used in the delete-workspace command. - if len(args) == 0 { - args = append(args, "") - } - - runtime, err := config.NewRuntime("delete-workspace", config.Args{ - WorkspaceName: &args[0], - AllWorkspaces: &flags.allWorkspaces, - RemoveStorage: &flags.removeWorkspaceStorage, - CurrentWorkspace: &flags.currentWorkspace, - }) - - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - - return handler.Delete(context.Background(), runtime) -} diff --git a/cmd/cmds/deploy/deploy.go b/cmd/cmds/deploy/deploy.go deleted file mode 100644 index 3ee6948..0000000 --- a/cmd/cmds/deploy/deploy.go +++ /dev/null @@ -1,47 +0,0 @@ -package deploy - -import ( - "context" - "fmt" - - "github.com/ashishmax31/voyager-cli/cmd/common" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" -) - -var deployArgs struct { - voyagerFilePath string -} - -func NewDeployCommand() *cobra.Command { - var deployCmd = &cobra.Command{ - Use: "deploy", - Short: "Deploy the resources specified in the voyagerfile", - Long: `Deploy the resources specified in the voyagerfile`, - Run: func(cmd *cobra.Command, args []string) { - if err := deploy(context.Background()); err != nil { - fmt.Printf("deploy error: %s \n", err.Error()) - } - }, - Args: cobra.NoArgs, - } - deployCmd.Flags().StringVar(&deployArgs.voyagerFilePath, common.VoyagerFilePathFlag, "./voyagerfile.yaml", fmt.Sprintf("--%s=voyagerfile.yaml", common.VoyagerFilePathFlag)) - return deployCmd -} - -func deploy(ctx context.Context) error { - runtime, err := config.NewRuntime("deploy", config.Args{ - StackFilePath: &deployArgs.voyagerFilePath, - }) - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - - return handler.Deploy(ctx, runtime) -} diff --git a/cmd/cmds/exec/exec.go b/cmd/cmds/exec/exec.go deleted file mode 100644 index b35367f..0000000 --- a/cmd/cmds/exec/exec.go +++ /dev/null @@ -1,70 +0,0 @@ -package exec - -import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" -) - -var execArgs struct { - interactive bool -} - -func NewExecCommand() *cobra.Command { - var execCmd = &cobra.Command{ - Use: "exec", - Short: "Execute a command inside a workspace resource.", - Long: `Execute a command inside a workspace resource. Pass -i or --i flag for starting an interactive session.`, - Run: func(cmd *cobra.Command, args []string) { - if err := exec(context.Background(), args); err != nil { - fmt.Printf("exec error: %s \n", err.Error()) - os.Exit(1) - } - }, - } - execCmd.Flags().BoolVarP(&execArgs.interactive, "i", "i", false, "-i or --i") - return execCmd -} - -func exec(ctx context.Context, args []string) error { - if len(args) < 2 { - return fmt.Errorf("missing required arguments.. usage: voyager exec ") - } - - runtime, err := config.NewRuntime("exec", config.Args{ - ResourceName: &args[0], - Interactive: &execArgs.interactive, - ExecuteCmd: args[1:], - }) - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - - ctx, cancelFn := context.WithCancel(ctx) - defer cancelFn() - signalTermination := make(chan os.Signal, 1) - signal.Notify(signalTermination, syscall.SIGINT, syscall.SIGTERM) - go func() { - <-signalTermination - cancelFn() - }() - return ignoreCtxCancelledErr(handler.Execute(ctx, runtime)) -} - -func ignoreCtxCancelledErr(err error) error { - if err == context.Canceled { - return nil - } - return err -} diff --git a/cmd/cmds/init/init.go b/cmd/cmds/init/init.go deleted file mode 100644 index d057a00..0000000 --- a/cmd/cmds/init/init.go +++ /dev/null @@ -1,60 +0,0 @@ -package init - -import ( - "context" - "fmt" - "os" - - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - - "github.com/spf13/cobra" -) - -var initArgs struct { - workspaceName string -} - -// initCmd represents the init command -func NewInitCommand() *cobra.Command { - var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize workspace environment", - Long: `Initialize workspace environment`, - Run: func(cmd *cobra.Command, args []string) { - if err := run(); err != nil { - fmt.Printf("init error: %s \n", err.Error()) - os.Exit(1) - } - }, - Args: cobra.NoArgs, - } - // Required flag workspace-name - initCmd.Flags().StringVar(&initArgs.workspaceName, "workspace-name", "", "workspace name") - return initCmd -} - -func run() error { - if initArgs.workspaceName == "" { - return fmt.Errorf("workspace name is required") - } - - runtime, err := config.NewRuntime("init", config.Args{ - WorkspaceName: &initArgs.workspaceName, - }) - - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - - if err := handler.Initialize(context.Background(), initArgs.workspaceName); err != nil { - return fmt.Errorf("failed to initialize workspace: %w", err) - } - - return nil -} diff --git a/cmd/cmds/list/list.go b/cmd/cmds/list/list.go deleted file mode 100644 index 8c64736..0000000 --- a/cmd/cmds/list/list.go +++ /dev/null @@ -1,15 +0,0 @@ -package list - -import "github.com/spf13/cobra" - -func NewListCommand() *cobra.Command { - var listCmd = &cobra.Command{ - Use: "list workspaces | workspace-storages", - Short: "List various resources owned by the user", - Long: `List various resources owned by the user`, - Args: cobra.NoArgs, - } - - listCmd.AddCommand(newListWorkspacesCommand(), newListWorkspaceStorageCommand(), newListBuildsCommand()) - return listCmd -} diff --git a/cmd/cmds/list/list_builds.go b/cmd/cmds/list/list_builds.go deleted file mode 100644 index b64bce8..0000000 --- a/cmd/cmds/list/list_builds.go +++ /dev/null @@ -1,97 +0,0 @@ -package list - -import ( - "context" - "fmt" - "os" - "text/tabwriter" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" - "k8s.io/utils/ptr" -) - -type listBuildArgs struct { - resourceName string -} - -var listBuildArg listBuildArgs - -func newListBuildsCommand() *cobra.Command { - var listWorkspaceStorageCmd = &cobra.Command{ - Use: "builds [--resource-name resourceName | -r resourceName]", - Short: "List created builds in the workspace", - Long: `List created builds in the workspace`, - Args: cobra.MaximumNArgs(1), - RunE: listWorkspaceBuilds, - } - listWorkspaceStorageCmd.Flags().StringVarP(&listBuildArg.resourceName, "resource-name", "r", "", "resource name") - return listWorkspaceStorageCmd -} - -func listWorkspaceBuilds(cmd *cobra.Command, args []string) error { - configArgs := config.Args{} - - if len(listBuildArg.resourceName) == 0 { - configArgs.AllResources = ptr.To(true) - } else { - configArgs.ResourceName = &listBuildArg.resourceName - } - - runtime, err := config.NewRuntime("list builds", configArgs) - if err != nil { - fmt.Printf("list builds error: %s \n", err.Error()) - os.Exit(1) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - fmt.Printf("list builds error: %s \n", err.Error()) - os.Exit(1) - } - - builds, err := handler.ListWorkspaceBuilds(context.Background(), runtime) - if err != nil { - fmt.Printf("list builds error: %s \n", err.Error()) - os.Exit(1) - } - - printWorkspaceBuilds(builds) - return nil -} - -func printWorkspaceBuilds(builds []v1alpha1.ResourceBuild) { - // Create a tab writer for formatted output - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.TabIndent) - defer w.Flush() - - // Print the header - fmt.Fprintln(w, "ID\tWorkspaceName\tResourceName\tState\tSourceHash") - - // Iterate over the ResourceBuilds and print each - for _, build := range builds { - // Extract the state and image URL safely in case Status is nil - state := "Unknown" - if build.Status != nil { - state = build.Status.State - } - - // Print the fields in a tab-separated format - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", - build.ID, - build.WorkspaceName, - build.WorkspaceResourceName, - state, - renderSourceHash(build), - ) - } -} - -func renderSourceHash(build v1alpha1.ResourceBuild) string { - if build.Current { - return fmt.Sprintf("%s (current)", build.SourceHash) - } - return build.SourceHash -} diff --git a/cmd/cmds/list/list_workspace_storages.go b/cmd/cmds/list/list_workspace_storages.go deleted file mode 100644 index 2ea5dff..0000000 --- a/cmd/cmds/list/list_workspace_storages.go +++ /dev/null @@ -1,58 +0,0 @@ -package list - -import ( - "context" - "fmt" - "os" - "text/tabwriter" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" -) - -func newListWorkspaceStorageCommand() *cobra.Command { - var listWorkspaceStorageCmd = &cobra.Command{ - Use: "workspace-storages", - Short: "List workspace storages", - Long: `List workspace storages`, - Args: cobra.NoArgs, - RunE: listWorkspaceStorages, - } - return listWorkspaceStorageCmd -} - -func listWorkspaceStorages(cmd *cobra.Command, args []string) error { - runtime, err := config.NewRuntime("list workspace storages", config.Args{}) - if err != nil { - return fmt.Errorf("list workspace storages error: %w", err) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("list workspace storages error: %w", err) - } - - storages, err := handler.ListWorkspaceStorages(context.Background(), runtime) - if err != nil { - return fmt.Errorf("list workspace storages error: %w", err) - } - - printWorkspaceStorages(storages) - return nil -} - -func printWorkspaceStorages(storages []*v1alpha1.WorkspaceStorage) { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.TabIndent) - defer w.Flush() - - fmt.Fprintln(w, "ID\tName\tState\t") - for _, ws := range storages { - state := "Unknown" - if ws.Status != nil { - state = ws.Status.State - } - fmt.Fprintf(w, "%s\t%s\t%s\t\n", ws.ID, ws.Name, state) - } -} diff --git a/cmd/cmds/list/list_workspaces.go b/cmd/cmds/list/list_workspaces.go deleted file mode 100644 index 1e4d824..0000000 --- a/cmd/cmds/list/list_workspaces.go +++ /dev/null @@ -1,60 +0,0 @@ -package list - -import ( - "context" - "fmt" - "os" - "text/tabwriter" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" -) - -func newListWorkspacesCommand() *cobra.Command { - var listWorkspacesCmd = &cobra.Command{ - Use: "workspaces", - Short: "List all workspaces", - Long: `List all workspaces`, - Args: cobra.NoArgs, - RunE: listWorkspaces, - } - - return listWorkspacesCmd -} - -func listWorkspaces(cmd *cobra.Command, args []string) error { - runtime, err := config.NewRuntime("list workspaces", config.Args{}) - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - - workspaces, err := handler.ListWorkspaces(context.Background(), runtime) - if err != nil { - return fmt.Errorf("list workspaces error: %w", err) - } - - printWorkspaces(workspaces) - return nil -} - -func printWorkspaces(workspaces []*v1alpha1.Workspace) { - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.TabIndent) - defer w.Flush() - - fmt.Fprintln(w, "ID\tName\tState\t") - - for _, ws := range workspaces { - state := "Unknown" - if ws.Status != nil { - state = ws.Status.State - } - fmt.Fprintf(w, "%s\t%s\t%s\t\n", ws.ID, ws.Name, state) - } -} diff --git a/cmd/cmds/login/login.go b/cmd/cmds/login/login.go deleted file mode 100644 index a9b2a66..0000000 --- a/cmd/cmds/login/login.go +++ /dev/null @@ -1,64 +0,0 @@ -package login - -import ( - "context" - "fmt" - "os" - - "github.com/ashishmax31/voyager-cli/pkg/client" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/spf13/cobra" -) - -var args struct { - token string - voyagerServerUrl string - insecure bool -} - -func NewLoginCommand() *cobra.Command { - var loginCmd = &cobra.Command{ - Use: "login", - Short: "Login to a voyager server", - Long: `Login to a voyager server, pass the voyager server url and voyager token as args`, - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - if err := login(); err != nil { - fmt.Printf("failed to login: %s \n", err.Error()) - os.Exit(1) - } - }, - } - loginCmd.Flags().StringVar(&args.token, "token", "", "Access token obtained from voyager website") - loginCmd.Flags().StringVar(&args.voyagerServerUrl, "url", "", "Voyager server url") - loginCmd.Flags().BoolVar(&args.insecure, "insecure", false, "Voyager server insecure") - return loginCmd -} - -func login() error { - if args.token == "" { - return fmt.Errorf("missing token, pass token as --token= flag") - } - if args.voyagerServerUrl == "" { - return fmt.Errorf("missing Voyager server url, pass url as --url= flag") - } - - cfg := config.New() - ctx := context.Background() - cfg.AccessToken = args.token - cfg.VoyagerServerUrl = args.voyagerServerUrl - cfg.Insecure = args.insecure - stackdomeClient := client.NewStackdomeClient(cfg) - resp, err := stackdomeClient.GetUser(ctx) - if err != nil { - return fmt.Errorf("failed to authenticate with voyager server: %w", err) - } - cfg.Username = resp.Name - cfg.Organisation = resp.Organisation - cfg.OrganisationID = resp.OrganisationID - if err := config.Save(cfg); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } - fmt.Printf("sucessfully logged in as user: %s \n", cfg.Username) - return nil -} diff --git a/cmd/cmds/logs/logs.go b/cmd/cmds/logs/logs.go deleted file mode 100644 index e760bfd..0000000 --- a/cmd/cmds/logs/logs.go +++ /dev/null @@ -1,80 +0,0 @@ -package logs - -import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" -) - -var logsArgs struct { - all bool - tailLines int64 - follow bool -} - -func NewLogsCommand() *cobra.Command { - var logsCmd = &cobra.Command{ - Use: "logs", - Short: "Get the logs of a resource", - Long: `Get the logs of a resource. Pass --all or -a flag to print the logs of all resources.`, - Run: func(cmd *cobra.Command, args []string) { - if err := logs(context.Background(), args); err != nil { - fmt.Printf("logs error: %s \n", err.Error()) - os.Exit(1) - } - }, - } - logsCmd.Flags().BoolVarP(&logsArgs.all, "all", "a", false, "-a or --all") - logsCmd.Flags().BoolVarP(&logsArgs.follow, "follow", "f", false, "-f or --follow") - logsCmd.Flags().Int64VarP(&logsArgs.tailLines, "tail", "t", 100, "-t=10 or --tail=10") - return logsCmd -} - -func logs(ctx context.Context, args []string) error { - if len(args) == 0 && !logsArgs.all { - return fmt.Errorf("atleast one argument is required or pass --all flag") - } - - if len(args) == 0 { - args = append(args, "") - } - - runtime, err := config.NewRuntime("logs", config.Args{ - ResourceName: &args[0], - AllResources: &logsArgs.all, - TailLines: &logsArgs.tailLines, - Follow: &logsArgs.follow, - }) - - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - - ctx, cancelFn := context.WithCancel(ctx) - defer cancelFn() - signalTermination := make(chan os.Signal, 1) - signal.Notify(signalTermination, syscall.SIGINT, syscall.SIGTERM) - go func() { - <-signalTermination - cancelFn() - }() - return ignoreCtxCancelledErr(handler.GetLogs(ctx, runtime)) -} - -func ignoreCtxCancelledErr(err error) error { - if err == context.Canceled { - return nil - } - return err -} diff --git a/cmd/cmds/restart/restart.go b/cmd/cmds/restart/restart.go deleted file mode 100644 index 360d9e2..0000000 --- a/cmd/cmds/restart/restart.go +++ /dev/null @@ -1,58 +0,0 @@ -package restart - -import ( - "context" - "fmt" - "os" - - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" -) - -var restartArgs struct { - all bool -} - -func NewRestartCommand() *cobra.Command { - var restartCmd = &cobra.Command{ - Use: "restart", - Short: "Restart a resource.", - Long: `Restart a resource. Pass --all or -a flag to restart all resources`, - Run: func(cmd *cobra.Command, args []string) { - if err := restart(context.Background(), args); err != nil { - fmt.Printf("restart error: %s \n", err.Error()) - os.Exit(1) - } - }, - Args: cobra.RangeArgs(0, 1), - } - restartCmd.Flags().BoolVarP(&restartArgs.all, "all", "a", false, "-a or --all") - return restartCmd -} - -func restart(ctx context.Context, args []string) error { - if len(args) == 0 && !restartArgs.all { - return fmt.Errorf("atleast one argument is required or pass --all flag") - } - - // If no resource name is provided, then pass an empty string - if len(args) == 0 { - args = append(args, "") - } - - runtime, err := config.NewRuntime("restart", config.Args{ - AllResources: &restartArgs.all, - ResourceName: &args[0], - }) - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - - return handler.Restart(ctx, runtime) -} diff --git a/cmd/cmds/status/status.go b/cmd/cmds/status/status.go deleted file mode 100644 index eb95472..0000000 --- a/cmd/cmds/status/status.go +++ /dev/null @@ -1,60 +0,0 @@ -package status - -import ( - "context" - "fmt" - "os" - - "github.com/ashishmax31/voyager-cli/cmd/common" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - - "github.com/spf13/cobra" -) - -var statusArgs struct { - all bool -} - -func NewStatusCommand() *cobra.Command { - var statusCmd = &cobra.Command{ - Use: "status [--all] | [resourceName]", - Short: "Get the status of a resource/all resources", - Long: `Get the status of a resource/all resources. Pass --all or -a flag to print the status of all resources.`, - Run: func(cmd *cobra.Command, args []string) { - if (len(args) == 0 || len(args[0]) == 0) && !statusArgs.all { - // either --all flag or resourceName is required - fmt.Println("either --all flag or resourceName is required") - os.Exit(1) - } - if err := status(context.Background(), args); err != nil { - fmt.Printf("status error: %s \n", err.Error()) - os.Exit(1) - } - }, - Args: cobra.RangeArgs(0, 1), - } - statusCmd.Flags().BoolVarP(&statusArgs.all, common.AllResourcesFlag, "a", false, fmt.Sprintf("--%s", common.AllResourcesFlag)) - return statusCmd -} - -func status(ctx context.Context, args []string) error { - if len(args) == 0 { - args = append(args, "") - } - - runtime, err := config.NewRuntime("status", config.Args{ - AllResources: &statusArgs.all, - ResourceName: &args[0], - }) - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - - return handler.Status(ctx, runtime) -} diff --git a/cmd/cmds/sync/sync.go b/cmd/cmds/sync/sync.go deleted file mode 100644 index 070282c..0000000 --- a/cmd/cmds/sync/sync.go +++ /dev/null @@ -1,55 +0,0 @@ -package sync - -import ( - "context" - "fmt" - "os" - - "github.com/ashishmax31/voyager-cli/cmd/common" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" -) - -var syncArgs struct { - voyagerFilePath string -} - -func NewSyncCommand() *cobra.Command { - var syncCmd = &cobra.Command{ - Use: "sync", - Short: "sync local directories mentioned in the voyagerfile against remote volumes", - Long: `sync local directories mentioned in the voyagerfile against remote volumes`, - Run: func(cmd *cobra.Command, args []string) { - if err := sync(); err != nil { - fmt.Printf("failed to sync local directories against remote volumes: %s\n", err.Error()) - os.Exit(1) - } - }, - Args: cobra.NoArgs, - } - statusCmd := NewSyncStatusCommand() - syncCmd.AddCommand(statusCmd) - syncCmd.Flags().StringVar(&syncArgs.voyagerFilePath, common.VoyagerFilePathFlag, "", fmt.Sprintf("--%s=voyagerfile.yaml", common.VoyagerFilePathFlag)) - return syncCmd -} - -func sync() error { - runtime, err := config.NewRuntime("sync", config.Args{ - StackFilePath: &syncArgs.voyagerFilePath, - }) - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - - if err := handler.Sync(context.Background()); err != nil { - return fmt.Errorf("failed to sync volumes: %w", err) - } - fmt.Printf("Successfully synced volumes..") - return nil -} diff --git a/cmd/cmds/sync/sync_status.go b/cmd/cmds/sync/sync_status.go deleted file mode 100644 index 0679adb..0000000 --- a/cmd/cmds/sync/sync_status.go +++ /dev/null @@ -1,45 +0,0 @@ -package sync - -import ( - "context" - "fmt" - "os" - - "github.com/ashishmax31/voyager-cli/cmd/common" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" -) - -func NewSyncStatusCommand() *cobra.Command { - var syncStatusCmd = &cobra.Command{ - Use: "status", - Short: "Current sync status", - Long: `Current sync status`, - Run: func(cmd *cobra.Command, args []string) { - if err := syncStatus(); err != nil { - fmt.Printf("failed to check sync status: %s\n", err.Error()) - os.Exit(1) - } - }, - Args: cobra.NoArgs, - } - syncStatusCmd.Flags().StringVar(&syncArgs.voyagerFilePath, common.VoyagerFilePathFlag, "", fmt.Sprintf("--%s=voyagerfile.yaml", common.VoyagerFilePathFlag)) - return syncStatusCmd -} - -func syncStatus() error { - runtime, err := config.NewRuntime("sync", config.Args{ - StackFilePath: &syncArgs.voyagerFilePath, - }) - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - syncHandler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - - return syncHandler.SyncStatus(context.Background()) -} diff --git a/cmd/cmds/syncsession/start_session.go b/cmd/cmds/syncsession/start_session.go deleted file mode 100644 index a2143f4..0000000 --- a/cmd/cmds/syncsession/start_session.go +++ /dev/null @@ -1,75 +0,0 @@ -package syncsession - -import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - - "github.com/ashishmax31/voyager-cli/cmd/common" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" -) - -var syncSessionStartArgs struct { - voyagerFilePath string -} - -func newSyncSessionStartCommand() *cobra.Command { - var syncCmd = &cobra.Command{ - Use: "start", - Short: "start a sync sync session", - Long: `start a sync sync session which is responsible for syncing local directories mentioned in the voyagerfile against remote volumes`, - Run: func(cmd *cobra.Command, args []string) { - if err := startSyncSession(); err != nil { - fmt.Printf("sync session failed: %s\n", err.Error()) - os.Exit(1) - } - }, - Args: cobra.NoArgs, - } - syncCmd.Flags().StringVar(&syncSessionStartArgs.voyagerFilePath, common.VoyagerFilePathFlag, "", fmt.Sprintf("--%s=voyagerfile.yaml", common.VoyagerFilePathFlag)) - return syncCmd -} - -func startSyncSession() error { - runtime, err := config.NewRuntime("start-sync", config.Args{ - StackFilePath: &syncSessionStartArgs.voyagerFilePath, - }) - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - syncHandler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - - userstack, err := runtime.UserStack() - if err != nil { - return fmt.Errorf("failed to get user stack: %w", err) - } - - ctx, cancelFn := context.WithCancel(context.Background()) - defer cancelFn() - signalTermination := make(chan os.Signal, 1) - signal.Notify(signalTermination, syscall.SIGINT, syscall.SIGTERM) - exitedChan := make(chan struct{}) - go func() { - defer close(exitedChan) - if err := syncHandler.StartSyncSession(ctx, userstack); err != nil { - fmt.Printf("sync session stopped with err: %s \n", err.Error()) - } - }() - select { - case <-exitedChan: - return nil - case <-signalTermination: - cancelFn() - <-exitedChan - } - fmt.Printf("Successfully synced volumes..") - return nil -} diff --git a/cmd/cmds/syncsession/stop_session.go b/cmd/cmds/syncsession/stop_session.go deleted file mode 100644 index b02445b..0000000 --- a/cmd/cmds/syncsession/stop_session.go +++ /dev/null @@ -1,41 +0,0 @@ -package syncsession - -import ( - "context" - "fmt" - "os" - - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/workspace" - "github.com/spf13/cobra" -) - -func newSyncSessionStopCommand() *cobra.Command { - var syncCmd = &cobra.Command{ - Use: "stop", - Short: "stop a sync sync session", - Long: `Stop a sync sync session`, - Run: func(cmd *cobra.Command, args []string) { - if err := stopSyncSession(); err != nil { - fmt.Printf("failed to stop sync session: %s\n", err.Error()) - os.Exit(1) - } - }, - Args: cobra.NoArgs, - } - return syncCmd -} - -func stopSyncSession() error { - runtime, err := config.NewRuntime("sync", config.Args{}) - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - - handler, err := workspace.NewWorkspaceHandler(runtime) - if err != nil { - return fmt.Errorf("failed to create workspace handler: %w", err) - } - - return handler.StopSyncSession(context.Background()) -} diff --git a/cmd/cmds/syncsession/sync_session.go b/cmd/cmds/syncsession/sync_session.go deleted file mode 100644 index 2236314..0000000 --- a/cmd/cmds/syncsession/sync_session.go +++ /dev/null @@ -1,21 +0,0 @@ -package syncsession - -import ( - "github.com/spf13/cobra" -) - -var syncSessionArgs struct { - voyagerFilePath string -} - -func NewSyncSessionCommand() *cobra.Command { - var syncCmd = &cobra.Command{ - Use: "sync-session start|stop", - Short: "Manage a sync sync session", - Long: `Manage a sync sync session`, - Args: cobra.NoArgs, - } - - syncCmd.AddCommand(newSyncSessionStartCommand(), newSyncSessionStopCommand()) - return syncCmd -} diff --git a/cmd/cmds/validate/validate.go b/cmd/cmds/validate/validate.go deleted file mode 100644 index f0b58f6..0000000 --- a/cmd/cmds/validate/validate.go +++ /dev/null @@ -1,43 +0,0 @@ -package validate - -import ( - "fmt" - "os" - - "github.com/ashishmax31/voyager-cli/pkg/validation" - "github.com/spf13/cobra" -) - -// validateCmd represents the validate command -var validateCmd = &cobra.Command{ - Use: "validate", - Short: "Validate voyager file", - Long: `Validate voyager file.`, - Args: cobra.ExactArgs(1), - RunE: validateRun, -} - -func NewValidateCommand() *cobra.Command { - var validateCmd = &cobra.Command{ - Use: "validate", - Short: "Validate voyagerfile", - Long: `Validate voyagerfile.`, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - if err := validateRun(cmd, args); err != nil { - fmt.Println(err.Error()) - os.Exit(1) - } - }, - } - return validateCmd -} - -func validateRun(cmd *cobra.Command, args []string) error { - voyagerfilePath := args[0] - err := validation.Validate(voyagerfilePath) - if err != nil { - return fmt.Errorf("failed to validate voyagerfile at '%s': %w", voyagerfilePath, err) - } - return nil -} diff --git a/cmd/main.go b/cmd/main.go deleted file mode 100644 index 171c2e9..0000000 --- a/cmd/main.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - "os" - - "github.com/ashishmax31/voyager-cli/cmd/cmds/build" - "github.com/ashishmax31/voyager-cli/cmd/cmds/delete" - "github.com/ashishmax31/voyager-cli/cmd/cmds/deploy" - "github.com/ashishmax31/voyager-cli/cmd/cmds/exec" - initcmd "github.com/ashishmax31/voyager-cli/cmd/cmds/init" - "github.com/ashishmax31/voyager-cli/cmd/cmds/list" - "github.com/ashishmax31/voyager-cli/cmd/cmds/login" - "github.com/ashishmax31/voyager-cli/cmd/cmds/logs" - "github.com/ashishmax31/voyager-cli/cmd/cmds/restart" - "github.com/ashishmax31/voyager-cli/cmd/cmds/status" - synccmd "github.com/ashishmax31/voyager-cli/cmd/cmds/sync" - "github.com/ashishmax31/voyager-cli/cmd/cmds/syncsession" - "github.com/ashishmax31/voyager-cli/cmd/cmds/validate" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" -) - -var logLevel string - -func main() { - rootCmd := &cobra.Command{ - Use: "voyager-cli [--log-level=debug|info|warn|error|fatal|panic]", - Short: "CLI to manage, lifecycle and interact with your applications deployed on voyager stack", - Long: `CLI to manage, lifecycle and interact with your applications deployed on voyager stack`, - } - buildCmd := build.NewBuildCommand() - deployCmd := deploy.NewDeployCommand() - initCmd := initcmd.NewInitCommand() - loginCmd := login.NewLoginCommand() - syncCmd := synccmd.NewSyncCommand() - syncSessionCmd := syncsession.NewSyncSessionCommand() - validateCmd := validate.NewValidateCommand() - restartCmd := restart.NewRestartCommand() - statusCmd := status.NewStatusCommand() - execCmd := exec.NewExecCommand() - logsCmd := logs.NewLogsCommand() - deleteWorkspaceCmd := delete.NewWorkspaceDeleteCommand() - listCmd := list.NewListCommand() - rootCmd.AddCommand( - buildCmd, - deployCmd, - initCmd, - loginCmd, - syncCmd, - validateCmd, - syncSessionCmd, - restartCmd, - statusCmd, - execCmd, - logsCmd, - deleteWorkspaceCmd, - listCmd, - ) - rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Set the log level (debug, info, warn)") - level, err := logrus.ParseLevel(logLevel) - if err != nil { - logrus.Fatalf("Invalid log level: %v", err) - } - logrus.SetLevel(level) - logrus.SetFormatter(&logrus.TextFormatter{ - FullTimestamp: true, - }) - if err := rootCmd.Execute(); err != nil { - println(err) - os.Exit(1) - } -} diff --git a/cmd/stackdome/completion.go b/cmd/stackdome/completion.go new file mode 100644 index 0000000..ee24c06 --- /dev/null +++ b/cmd/stackdome/completion.go @@ -0,0 +1,42 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" +) + +func newCompletionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "completion [bash|zsh|fish]", + Short: "Generate shell completion script", + Long: `Generate a shell completion script for stackdome. + +Add the output to your shell profile: + + # bash + stackdome completion bash >> ~/.bashrc + + # zsh + stackdome completion zsh >> ~/.zshrc + + # fish + stackdome completion fish > ~/.config/fish/completions/stackdome.fish`, + Args: cobra.ExactArgs(1), + ValidArgs: []string{"bash", "zsh", "fish"}, + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletionV2(os.Stdout, true) + case "zsh": + return cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + return cmd.Root().GenFishCompletion(os.Stdout, true) + default: + return cmd.Help() + } + }, + } + + return cmd +} diff --git a/cmd/stackdome/deploy.go b/cmd/stackdome/deploy.go index e5d4d07..9787e9f 100644 --- a/cmd/stackdome/deploy.go +++ b/cmd/stackdome/deploy.go @@ -19,6 +19,7 @@ func newDeployCmd() *cobra.Command { var ( flagFile string flagName string + flagWait bool ) cmd := &cobra.Command{ @@ -51,23 +52,34 @@ func newDeployCmd() *cobra.Command { return err } - fmt.Fprintf(os.Stderr, "Waiting for stack to be ready...\n") - final, err := waitForStack(ctx, cmd, *result.Id) - if err != nil { - return err - } - - if !ctx.Formatter.IsTable() { - return ctx.Formatter.PrintStructured(final) + if flagWait { + spin := output.NewSpinner("Waiting for stack to be ready...") + spin.Start() + final, err := waitForStack(ctx, cmd, *result.Id) + spin.Stop() + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(final) + } + + output.RenderStackStatus(os.Stdout, final, false) + return nil } - output.RenderStackStatus(os.Stdout, final, false) + fmt.Fprintf(os.Stderr, "\nStack %q submitted. Track progress with:\n", result.Name) + fmt.Fprintf(os.Stderr, " stackdome status # current state\n") + fmt.Fprintf(os.Stderr, " stackdome status --watch # live updates\n") + fmt.Fprintf(os.Stderr, " stackdome logs # stream logs\n") return nil })), } cmd.Flags().StringVarP(&flagFile, "file", "f", "stackfile.yaml", "Path to stackfile or stack JSON") cmd.Flags().StringVar(&flagName, "name", "", "Override stack name") + cmd.Flags().BoolVarP(&flagWait, "wait", "w", false, "Wait for stack to be ready") return cmd } @@ -91,6 +103,9 @@ func loadStack(path, nameOverride string) (*openapi.Stack, error) { if err != nil { return nil, err } + if err := stackfile.ResolveEnvFiles(sf, filepath.Dir(path)); err != nil { + return nil, err + } if nameOverride != "" { sf.Name = nameOverride } diff --git a/cmd/stackdome/helpers.go b/cmd/stackdome/helpers.go new file mode 100644 index 0000000..4ec27e0 --- /dev/null +++ b/cmd/stackdome/helpers.go @@ -0,0 +1,21 @@ +package main + +import ( + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" + clierrors "github.com/stackdome/cli/internal/errors" +) + +func resolveStackID(ctx *cmdutil.CommandContext, cmd *cobra.Command, flagStack string) (string, error) { + if flagStack != "" { + s, err := ctx.Client.FindStackByName(cmd.Context(), flagStack) + if err != nil { + return "", err + } + if s == nil { + return "", clierrors.NotFoundError("Stack", flagStack) + } + return *s.Id, nil + } + return ctx.Config.RequireStack() +} diff --git a/cmd/stackdome/init.go b/cmd/stackdome/init.go new file mode 100644 index 0000000..d304499 --- /dev/null +++ b/cmd/stackdome/init.go @@ -0,0 +1,183 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + clierrors "github.com/stackdome/cli/internal/errors" + "github.com/stackdome/cli/internal/output" + "github.com/stackdome/cli/internal/stackfile" + "gopkg.in/yaml.v3" +) + +const stackfileTemplate = `name: {{NAME}} + +resources: + web: + image: nginx:latest + ports: + - name: http + port: 8080 + public: true + subdomain: web + env: + APP_ENV: "production" + DB_HOST: "{{ db.host }}" + DB_URL: "postgres://{{ db.host }}:{{ db.port.postgres }}/mydb" + REDIS_URL: "redis://{{ redis.host }}:6379" + # secrets: + # my-secret: + # API_KEY: api_key + depends_on: [db, redis] + + db: + image: postgres:16 + ports: + - name: postgres + port: 5432 + env: + POSTGRES_DB: mydb + POSTGRES_USER: app + POSTGRES_PASSWORD: changeme + volumes: + - name: db-data + path: /var/lib/postgresql/data + stateful: true + + redis: + image: redis:7-alpine + ports: + - name: redis + port: 6379 + +volumes: + db-data: + size: 5Gi +` + +func newInitCmd() *cobra.Command { + var ( + flagName string + flagForce bool + flagFile string + ) + + cmd := &cobra.Command{ + Use: "init", + Short: "Scaffold a new stackfile", + Long: `Scaffold a new stackfile.yaml for your project. + +If a docker-compose.yaml (or compose.yaml) is found in the current directory, +it will be converted to a stackfile automatically. Use -f to specify a +compose file explicitly. + +If no compose file is found, a starter template is generated.`, + RunE: func(cmd *cobra.Command, args []string) error { + name := flagName + if name == "" { + dir, err := os.Getwd() + if err != nil { + return clierrors.Wrap(err, "Failed to get current directory") + } + name = filepath.Base(dir) + } + + outPath := "stackfile.yaml" + if !flagForce { + if _, err := os.Stat(outPath); err == nil { + return clierrors.ValidationError(fmt.Sprintf("%s already exists (use --force to overwrite)", outPath)) + } + } + + composePath := flagFile + if composePath == "" { + dir, _ := os.Getwd() + composePath = stackfile.FindComposeFile(dir) + } + + var content []byte + if composePath != "" { + sf, err := stackfile.FromCompose(composePath, name) + if err != nil { + return clierrors.Wrap(err, "Failed to convert compose file") + } + out, err := yaml.Marshal(sf) + if err != nil { + return clierrors.Wrap(err, "Failed to generate stackfile") + } + content = out + + if err := os.WriteFile(outPath, content, 0644); err != nil { + return clierrors.Wrap(err, "Failed to write stackfile") + } + + fmt.Fprintf(os.Stderr, "%s Converted %s → %s\n\n", + output.Green("✓"), composePath, outPath) + + resources := make([]string, 0, len(sf.Resources)) + for name := range sf.Resources { + resources = append(resources, name) + } + fmt.Fprintf(os.Stderr, " %s %s\n", output.Bold("Resources:"), strings.Join(resources, ", ")) + if len(sf.Volumes) > 0 { + volumes := make([]string, 0, len(sf.Volumes)) + for name := range sf.Volumes { + volumes = append(volumes, name) + } + fmt.Fprintf(os.Stderr, " %s %s\n", output.Bold("Volumes:"), strings.Join(volumes, ", ")) + } + fmt.Fprintln(os.Stderr) + + warnings := checkConvertedStackfile(sf) + if len(warnings) > 0 { + for _, w := range warnings { + fmt.Fprintf(os.Stderr, " %s %s\n", output.Yellow("!"), w) + } + fmt.Fprintln(os.Stderr) + } + + if err := stackfile.Validate(sf); err != nil { + fmt.Fprintf(os.Stderr, " %s Validation failed: %s\n\n", output.Red("✗"), err) + } else { + fmt.Fprintf(os.Stderr, " %s Validation passed\n\n", output.Green("✓")) + } + + fmt.Fprintf(os.Stderr, " %s\n", output.Dim("Next steps:")) + fmt.Fprintf(os.Stderr, " %s\n", output.Dim(" stackdome deploy -f "+outPath)) + } else { + content = []byte(strings.Replace(stackfileTemplate, "{{NAME}}", name, 1)) + if err := os.WriteFile(outPath, content, 0644); err != nil { + return clierrors.Wrap(err, "Failed to write stackfile") + } + fmt.Fprintf(os.Stderr, "Created %s\n", outPath) + } + + return nil + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "App name (defaults to current directory name)") + cmd.Flags().BoolVar(&flagForce, "force", false, "Overwrite existing stackfile.yaml") + cmd.Flags().StringVarP(&flagFile, "file", "f", "", "Path to docker-compose file to convert") + + return cmd +} + +func checkConvertedStackfile(sf *stackfile.Stackfile) []string { + var warnings []string + for name, res := range sf.Resources { + if res.Build != nil && res.Build.Repo == "" { + warnings = append(warnings, fmt.Sprintf("resource %q has a local build (no git repo) — set build.repo to a git URL", name)) + } + if res.Image == "" && res.Build == nil { + warnings = append(warnings, fmt.Sprintf("resource %q has no image or build config", name)) + } + if res.EnvFile != "" { + warnings = append(warnings, fmt.Sprintf("resource %q uses env_file %q — ensure it exists relative to the stackfile", name, res.EnvFile)) + } + } + return warnings +} diff --git a/cmd/stackdome/logs.go b/cmd/stackdome/logs.go index d8eeeb7..244b9da 100644 --- a/cmd/stackdome/logs.go +++ b/cmd/stackdome/logs.go @@ -64,16 +64,3 @@ func newLogsCmd() *cobra.Command { return cmd } -func resolveStackID(ctx *cmdutil.CommandContext, cmd *cobra.Command, flagStack string) (string, error) { - if flagStack != "" { - s, err := ctx.Client.FindStackByName(cmd.Context(), flagStack) - if err != nil { - return "", err - } - if s == nil { - return "", clierrors.NotFoundError("Stack", flagStack) - } - return *s.Id, nil - } - return ctx.Config.RequireStack() -} diff --git a/cmd/stackdome/root.go b/cmd/stackdome/root.go index 27b9b7f..336402a 100644 --- a/cmd/stackdome/root.go +++ b/cmd/stackdome/root.go @@ -68,6 +68,8 @@ func newRootCmd() *cobra.Command { rootCmd.AddCommand(newOpenCmd()) rootCmd.AddCommand(newSecretCmd()) rootCmd.AddCommand(newVolumeCmd()) + rootCmd.AddCommand(newInitCmd()) + rootCmd.AddCommand(newCompletionCmd()) return rootCmd } diff --git a/cmd/stackdome/secret.go b/cmd/stackdome/secret.go index 034d0bf..9e2ec05 100644 --- a/cmd/stackdome/secret.go +++ b/cmd/stackdome/secret.go @@ -1,13 +1,13 @@ package main import ( - "bufio" "fmt" "os" "strings" "time" openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + "github.com/joho/godotenv" "github.com/spf13/cobra" "github.com/stackdome/cli/internal/cmdutil" clierrors "github.com/stackdome/cli/internal/errors" @@ -266,26 +266,15 @@ func collectSecretData(dataFlags []string, fromFile string) ([]openapi.SecretDat } func parseEnvFile(path string) ([]openapi.SecretData, error) { - f, err := os.Open(path) + env, err := godotenv.Read(path) if err != nil { return nil, clierrors.Wrapf(err, "Failed to read file %q", path) } - defer f.Close() - var data []openapi.SecretData - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - key, value, ok := strings.Cut(line, "=") - if !ok { - return nil, clierrors.ValidationError(fmt.Sprintf("invalid line in %q: %q (expected KEY=VALUE)", path, line)) - } + for key, value := range env { data = append(data, openapi.SecretData{Key: key, Value: value}) } - return data, scanner.Err() + return data, nil } func secretKeys(s openapi.Secret) string { diff --git a/go.mod b/go.mod index 3079c27..ac2b0a4 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/gofrs/flock v0.8.1 github.com/hashicorp/go-envparse v0.1.0 github.com/hashicorp/go-getter v1.7.4 + github.com/joho/godotenv v1.5.1 github.com/samber/lo v1.53.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index dde1463..d8e3e2b 100644 --- a/go.sum +++ b/go.sum @@ -447,6 +447,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 4647583..6609b5d 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -112,7 +112,7 @@ func FromHTTP(statusCode int, body string) *CLIError { e.Code = "AUTH_EXPIRED" e.ExitCode = ExitAuth case statusCode == 403: - e.Message = "Permission denied" + e.Message = "Permission denied. Your session may have expired — run `stackdome login` to re-authenticate." e.Code = "FORBIDDEN" e.ExitCode = ExitAuth case statusCode == 404: diff --git a/internal/output/spinner.go b/internal/output/spinner.go new file mode 100644 index 0000000..760619b --- /dev/null +++ b/internal/output/spinner.go @@ -0,0 +1,53 @@ +package output + +import ( + "fmt" + "os" + "sync" + "time" +) + +var frames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +type Spinner struct { + message string + stop chan struct{} + done sync.WaitGroup + stopOnce sync.Once +} + +func NewSpinner(message string) *Spinner { + return &Spinner{ + message: message, + stop: make(chan struct{}), + } +} + +func (s *Spinner) Start() { + if !isTTY() { + fmt.Fprintf(os.Stderr, "%s\n", s.message) + return + } + s.done.Add(1) + go func() { + defer s.done.Done() + i := 0 + tick := time.NewTicker(80 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-s.stop: + fmt.Fprintf(os.Stderr, "\r\033[K") + return + case <-tick.C: + fmt.Fprintf(os.Stderr, "\r%s %s", frames[i%len(frames)], s.message) + i++ + } + } + }() +} + +func (s *Spinner) Stop() { + s.stopOnce.Do(func() { close(s.stop) }) + s.done.Wait() +} diff --git a/internal/stackfile/compose.go b/internal/stackfile/compose.go new file mode 100644 index 0000000..47eeff3 --- /dev/null +++ b/internal/stackfile/compose.go @@ -0,0 +1,432 @@ +package stackfile + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +type composeFile struct { + Services map[string]composeService `yaml:"services"` + Volumes map[string]any `yaml:"volumes"` +} + +type composeService struct { + Image string `yaml:"image"` + Build any `yaml:"build"` + Command any `yaml:"command"` + Entrypoint any `yaml:"entrypoint"` + Ports []string `yaml:"ports"` + Environment any `yaml:"environment"` + EnvFile any `yaml:"env_file"` + Volumes []string `yaml:"volumes"` + DependsOn any `yaml:"depends_on"` +} + +func FindComposeFile(dir string) string { + candidates := []string{ + "docker-compose.yaml", + "docker-compose.yml", + "compose.yaml", + "compose.yml", + } + for _, name := range candidates { + path := filepath.Join(dir, name) + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" +} + +func FromCompose(path, appName string) (*Stackfile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + + var compose composeFile + if err := yaml.Unmarshal(data, &compose); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", path, err) + } + + if len(compose.Services) == 0 { + return nil, fmt.Errorf("no services found in %s", path) + } + + sf := &Stackfile{ + Name: appName, + Resources: make(map[string]Resource), + Volumes: make(map[string]VolumeDef), + } + + for name, svc := range compose.Services { + res := convertService(svc) + sf.Resources[name] = res + } + + for volName := range compose.Volumes { + sf.Volumes[volName] = VolumeDef{Size: "1Gi"} + } + + collectNamedVolumes(sf) + + return sf, nil +} + +func convertService(svc composeService) Resource { + var res Resource + + res.Image = svc.Image + res.Build = parseBuild(svc.Build) + res.Command, res.Args = parseCommandArgs(svc.Entrypoint, svc.Command) + res.Ports = parsePorts(svc.Ports) + res.Env = parseEnvironment(svc.Environment) + res.EnvFile = parseEnvFileRef(svc.EnvFile) + res.Volumes = parseVolumeMounts(svc.Volumes) + res.DependsOn = parseDependsOn(svc.DependsOn) + + if hasStatefulVolume(svc.Volumes) { + res.Stateful = true + } + + return res +} + +func parseCommandArgs(entrypoint, command any) (cmd []string, args []string) { + ep := parseStringOrList(entrypoint) + c := parseStringOrList(command) + + if len(ep) > 0 { + // Both set: entrypoint → command, command → args + cmd = ep + args = c + } else { + // Only command set: maps to command (overrides the container's default) + cmd = c + } + return +} + +func parseStringOrList(raw any) []string { + if raw == nil { + return nil + } + switch v := raw.(type) { + case string: + fields := strings.Fields(v) + if len(fields) == 0 { + return nil + } + return fields + case []any: + var out []string + for _, item := range v { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + } + return nil +} + +func parseEnvFileRef(raw any) string { + if raw == nil { + return "" + } + switch v := raw.(type) { + case string: + return v + case []any: + if len(v) > 0 { + if s, ok := v[0].(string); ok { + return s + } + } + } + return "" +} + +func parseBuild(raw any) *BuildConfig { + if raw == nil { + return nil + } + + switch v := raw.(type) { + case string: + return &BuildConfig{Context: v} + case map[string]any: + bc := &BuildConfig{} + if ctx, ok := v["context"].(string); ok { + bc.Context = ctx + } + if df, ok := v["dockerfile"].(string); ok { + bc.Dockerfile = df + } + return bc + } + return nil +} + +func parsePorts(ports []string) []PortDef { + if len(ports) == 0 { + return nil + } + + var defs []PortDef + for _, p := range ports { + def := parsePort(p) + if def != nil { + defs = append(defs, *def) + } + } + return defs +} + +func parsePort(s string) *PortDef { + s = strings.TrimSpace(s) + + protocol := "" + if idx := strings.Index(s, "/"); idx != -1 { + protocol = strings.ToUpper(s[idx+1:]) + s = s[:idx] + } + + parts := strings.Split(s, ":") + var containerPort string + hostMapped := false + + switch len(parts) { + case 1: + containerPort = parts[0] + case 2: + containerPort = parts[1] + hostMapped = true + case 3: + containerPort = parts[2] + hostMapped = true + default: + return nil + } + + portRange := strings.Split(containerPort, "-") + port, err := strconv.ParseInt(portRange[0], 10, 32) + if err != nil { + return nil + } + + def := &PortDef{ + Port: int32(port), + } + + if protocol != "" && protocol != "TCP" { + def.Protocol = protocol + } + + def.Name = portName(int32(port), protocol) + + if hostMapped && !isBackingServicePort(int32(port)) { + def.Public = true + } + + return def +} + +func isBackingServicePort(port int32) bool { + switch port { + case 5432: // PostgreSQL + return true + case 3306: // MySQL / MariaDB + return true + case 6379: // Redis + return true + case 27017: // MongoDB + return true + case 9092: // Kafka + return true + case 4222: // NATS + return true + case 2181: // ZooKeeper + return true + case 9200: // Elasticsearch / OpenSearch + return true + case 5672: // RabbitMQ (AMQP) + return true + case 11211: // Memcached + return true + case 8123: // ClickHouse (HTTP) + return true + case 9000: // ClickHouse (native) + return true + case 6650: // Apache Pulsar + return true + case 7687: // Neo4j (Bolt) + return true + case 8529: // ArangoDB + return true + case 9042: // Cassandra (CQL) + return true + case 7000: // Cassandra (inter-node) + return true + case 6380: // KeyDB / Valkey + return true + case 26257: // CockroachDB + return true + case 28015: // RethinkDB + return true + case 8086: // InfluxDB + return true + case 1433: // SQL Server + return true + case 1521: // Oracle DB + return true + case 6363: // Milvus (vector DB) + return true + case 19530: // Milvus (gRPC) + return true + case 6333: // Qdrant (vector DB) + return true + case 8484: // Weaviate (vector DB) + return true + } + return false +} + +func portName(port int32, _ string) string { + switch port { + case 80, 8080, 8000, 3000: + return "http" + case 443: + return "https" + case 5432: + return "postgres" + case 3306: + return "mysql" + case 6379: + return "redis" + case 27017: + return "mongo" + case 9092: + return "kafka" + case 4222: + return "nats" + default: + return fmt.Sprintf("port-%d", port) + } +} + +func parseEnvironment(raw any) map[string]string { + if raw == nil { + return nil + } + + env := make(map[string]string) + + switch v := raw.(type) { + case map[string]any: + for key, val := range v { + env[key] = fmt.Sprintf("%v", val) + } + case []any: + for _, item := range v { + s, ok := item.(string) + if !ok { + continue + } + key, val, _ := strings.Cut(s, "=") + env[key] = val + } + } + + if len(env) == 0 { + return nil + } + return env +} + +func parseVolumeMounts(volumes []string) []VolumeMountDef { + if len(volumes) == 0 { + return nil + } + + var mounts []VolumeMountDef + for _, v := range volumes { + parts := strings.SplitN(v, ":", 3) + if len(parts) < 2 { + continue + } + source := parts[0] + target := parts[1] + + if strings.HasPrefix(source, "/") || strings.HasPrefix(source, ".") { + continue + } + + mounts = append(mounts, VolumeMountDef{ + Name: source, + Path: target, + }) + } + + if len(mounts) == 0 { + return nil + } + return mounts +} + +func parseDependsOn(raw any) []string { + if raw == nil { + return nil + } + + switch v := raw.(type) { + case []any: + var deps []string + for _, item := range v { + if s, ok := item.(string); ok { + deps = append(deps, s) + } + } + return deps + case map[string]any: + var deps []string + for name := range v { + deps = append(deps, name) + } + sort.Strings(deps) + return deps + } + return nil +} + +func hasStatefulVolume(volumes []string) bool { + for _, v := range volumes { + parts := strings.SplitN(v, ":", 3) + if len(parts) >= 2 { + source := parts[0] + if !strings.HasPrefix(source, "/") && !strings.HasPrefix(source, ".") { + return true + } + } + } + return false +} + +func collectNamedVolumes(sf *Stackfile) { + for _, res := range sf.Resources { + for _, m := range res.Volumes { + if _, exists := sf.Volumes[m.Name]; !exists { + sf.Volumes[m.Name] = VolumeDef{Size: "1Gi"} + } + } + } + if len(sf.Volumes) == 0 { + sf.Volumes = nil + } +} diff --git a/internal/stackfile/convert.go b/internal/stackfile/convert.go index c17586b..ef16549 100644 --- a/internal/stackfile/convert.go +++ b/internal/stackfile/convert.go @@ -52,7 +52,7 @@ func (sf *Stackfile) buildResources() []openapi.StackResource { } sr.Ports = buildPorts(res.Ports) - sr.ExecutionConfig = buildExecutionConfig(res.Env) + sr.ExecutionConfig = buildExecutionConfig(res.Env, res.Command, res.Args) sr.VolumeMounts = buildVolumeMounts(res.Volumes) resources = append(resources, sr) @@ -131,11 +131,20 @@ func buildPorts(ports []PortDef) []openapi.Port { return out } -func buildExecutionConfig(env map[string]string) *openapi.ExecutionConfig { - if len(env) == 0 { +func buildExecutionConfig(env map[string]string, command, args []string) *openapi.ExecutionConfig { + if len(env) == 0 && len(command) == 0 && len(args) == 0 { return nil } + cfg := &openapi.ExecutionConfig{} + + if len(command) > 0 { + cfg.Command = command + } + if len(args) > 0 { + cfg.Args = args + } + var envVars []openapi.EnvVar for name, value := range env { ev := openapi.EnvVar{Name: name} @@ -144,7 +153,6 @@ func buildExecutionConfig(env map[string]string) *openapi.ExecutionConfig { output := extractSelfOutput(value) ev.SelfOutput = ptr.To(output) case hasResourceRef(value): - // skip for now, will be handled in connections continue default: ev.Value = ptr.To(value) @@ -152,12 +160,11 @@ func buildExecutionConfig(env map[string]string) *openapi.ExecutionConfig { envVars = append(envVars, ev) } - if len(envVars) == 0 { - return nil - } - return &openapi.ExecutionConfig{ - EnvironmentVariables: envVars, + if len(envVars) > 0 { + cfg.EnvironmentVariables = envVars } + + return cfg } type envRef struct { diff --git a/internal/stackfile/parse.go b/internal/stackfile/parse.go index d05a54c..74e2c27 100644 --- a/internal/stackfile/parse.go +++ b/internal/stackfile/parse.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" + "github.com/joho/godotenv" clierrors "github.com/stackdome/cli/internal/errors" "gopkg.in/yaml.v3" ) @@ -19,6 +20,40 @@ func Load(path string) (*Stackfile, error) { } } +func ResolveEnvFiles(sf *Stackfile, baseDir string) error { + for name, res := range sf.Resources { + if res.EnvFile == "" { + continue + } + envPath := res.EnvFile + if !filepath.IsAbs(envPath) { + envPath = filepath.Join(baseDir, envPath) + } + fileEnv, err := loadEnvFile(envPath) + if err != nil { + return clierrors.Wrapf(err, "Failed to read env_file for resource %q", name) + } + if res.Env == nil { + res.Env = make(map[string]string) + } + for k, v := range fileEnv { + if v == "" { + continue + } + if _, exists := res.Env[k]; !exists { + res.Env[k] = v + } + } + sf.Resources[name] = res + } + + return nil +} + +func loadEnvFile(path string) (map[string]string, error) { + return godotenv.Read(path) +} + func loadYAML(path string) (*Stackfile, error) { data, err := os.ReadFile(path) if err != nil { @@ -33,14 +68,14 @@ func loadYAML(path string) (*Stackfile, error) { return nil, clierrors.Wrapf(err, "Failed to parse stackfile: %s", path) } - if err := validate(&sf); err != nil { + if err := Validate(&sf); err != nil { return nil, err } return &sf, nil } -func validate(sf *Stackfile) error { +func Validate(sf *Stackfile) error { if sf.Name == "" { return clierrors.ValidationError("Stackfile missing required field: name") } diff --git a/internal/stackfile/types.go b/internal/stackfile/types.go index 21f5f93..e4d924a 100644 --- a/internal/stackfile/types.go +++ b/internal/stackfile/types.go @@ -13,10 +13,13 @@ type Stackfile struct { } type Resource struct { - Image string `yaml:"image,omitempty"` - Build *BuildConfig `yaml:"build,omitempty"` - Ports []PortDef `yaml:"ports,omitempty"` - Env map[string]string `yaml:"env,omitempty"` + Image string `yaml:"image,omitempty"` + Build *BuildConfig `yaml:"build,omitempty"` + Command []string `yaml:"command,omitempty"` + Args []string `yaml:"args,omitempty"` + Ports []PortDef `yaml:"ports,omitempty"` + EnvFile string `yaml:"env_file,omitempty"` + Env map[string]string `yaml:"env,omitempty"` // Secret Name -> Mapping of secret keys to env var names Secrets map[string]SecretMapping `yaml:"secrets,omitempty"` // Addon Name -> Connection Config @@ -27,7 +30,7 @@ type Resource struct { } type BuildConfig struct { - Repo string `yaml:"repo"` + Repo string `yaml:"repo,omitempty"` Branch string `yaml:"branch,omitempty"` Tag string `yaml:"tag,omitempty"` Commit string `yaml:"commit,omitempty"` diff --git a/pkg/api/v1alpha1/resource_builds.go b/pkg/api/v1alpha1/resource_builds.go deleted file mode 100644 index 65dad1a..0000000 --- a/pkg/api/v1alpha1/resource_builds.go +++ /dev/null @@ -1,20 +0,0 @@ -package v1alpha1 - -type ResourceBuild struct { - ID string - WorkspaceID string - WorkspaceName string - WorkspaceResourceID string - WorkspaceResourceName string - SourceHash string - ImageRegistry string - Current bool - Status *ResourceBuildStatus -} - -type ResourceBuildStatus struct { - State string - Conditions []Condition - ImageURL string - SourceHash string -} diff --git a/pkg/api/v1alpha1/user_stack.go b/pkg/api/v1alpha1/user_stack.go deleted file mode 100644 index f7a86ea..0000000 --- a/pkg/api/v1alpha1/user_stack.go +++ /dev/null @@ -1,188 +0,0 @@ -package v1alpha1 - -import ( - "fmt" - "os" - - "github.com/hashicorp/go-envparse" - "gopkg.in/yaml.v2" -) - -type UserStack struct { - Name string - Resources map[string]*WorkspaceResourceSpec `yaml:",inline"` - Volumes map[string]*VolumeSpec `yaml:"volumes"` -} - -func (w *UserStack) WorkspaceStorageName() string { - return fmt.Sprintf("%s-%s", w.Name, "storage") -} - -func (w *UserStack) HasSyncingVolumes() bool { - for _, volume := range w.Volumes { - if volume.Source != nil && volume.Source.LocalDir != nil { - return true - } - } - return false -} - -func (w *UserStack) HasVolumes() bool { - return len(w.Volumes) > 0 -} - -type VolumeSpec struct { - Size string `yaml:"size"` - Source *VolumeSource `yaml:"source"` -} - -type LocalDir struct { - Path string `yaml:"path"` - Sync bool `yaml:"sync"` -} - -type VolumeSource struct { - LocalDir *LocalDir `yaml:"localDir"` - BuildArtifacts []*BuildArtifactSource `yaml:"buildArtifacts,omitempty"` - // URL? - // S3? -} -type BuildArtifactSource struct { - ResourceName string `yaml:"resourceName"` - SourcePath string `yaml:"sourcePath"` - DestinationPath string `yaml:"destinationPath"` -} - -type WorkspaceResourceSpec struct { - ImageRegistry *string `yaml:"imageRegistry"` - Command []string `yaml:"command"` - Args []string `yaml:"args"` - Init *InitCommand `yaml:"init"` - VolumeMounts map[string]string `yaml:"volumeMounts"` - EnvironmentVariables map[string]string `yaml:"environmentVariables"` - EnvFiles []string `yaml:"envFiles"` - DependsOn []string `yaml:"dependsOn"` - Ports []Port `yaml:"ports"` - Build *ApplicationBuildSpec `yaml:"build" validate:"required_without=Image"` - Image *string `yaml:"image" validate:"required_without=Build"` -} - -type InitCommand struct { - Command []string `yaml:"command" validate:"required"` - Args []string `yaml:"args"` -} - -type Port struct { - Number int32 `yaml:"number" validate:"required"` - ExposeToPublic bool `yaml:"exposeToPublic"` - IsHttp bool `yaml:"isHttp"` -} - -type ResourceMounts struct { - Source string `yaml:"source" validate:"required"` - Destination string `yaml:"destination" validate:"required"` -} - -type ApplicationBuildSpec struct { - // Volume name where the applications source code is present - SourceVolume string `yaml:"sourceVolume" validate:"required"` - // Build context within the source volume. - BuildContext string `yaml:"buildContext" validate:"required"` - // Path within the volume where the dockerfile can be found. - DockerFilePath *string `yaml:"dockerFilePath" validate:"required"` - // Internal - DirHash string -} - -type PrebuiltApplicationSpec struct { - Image string `yaml:"image" validate:"required"` -} - -type ResourceStatus struct { - ResourceName string - Available bool - Reason string - Message string - Addresses []Address - BuildStatus *BuildStatus -} - -type BuildStatus struct { - BuildName string - SourceHash string - Completed bool - Reason string - Message string -} - -type VolumeStatus struct { - VolumeName string - LocalPath *string - LastSyncedAt *string - Available bool -} - -type Address struct { - Port int - Url string -} - -type WorkspaceAvailablityStatus struct { - Available bool - Reason string - Message string -} - -func Unmarshal(voyagerFilePath string) (*UserStack, error) { - yamlFile, err := os.Open(voyagerFilePath) - if err != nil { - return nil, fmt.Errorf("error opening YAML file: %v\n", err) - } - defer yamlFile.Close() - - // Parse the YAML file - var workspace UserStack - decoder := yaml.NewDecoder(yamlFile) - decoder.SetStrict(true) - err = decoder.Decode(&workspace) - if err != nil { - return nil, fmt.Errorf("error parsing YAML file: %v\n", err) - } - return &workspace, nil -} - -func (w *UserStack) SetDirHashForAllResources(hash string) { - for _, resource := range w.Resources { - if resource.Build != nil { - resource.Build.DirHash = hash - } - } -} - -func (r *WorkspaceResourceSpec) SetDirHash(hash string) { - if r.Build != nil { - r.Build.DirHash = hash - } -} - -func (w *UserStack) ReadEnvFiles() error { - for _, spec := range w.Resources { - for _, envFile := range spec.EnvFiles { - file, err := os.Open(envFile) - if err != nil { - return err - } - envVarsFromFile, err := envparse.Parse(file) - if err != nil { - return err - } - if spec.EnvironmentVariables == nil { - spec.EnvironmentVariables = map[string]string{} - } - for key, value := range envVarsFromFile { - spec.EnvironmentVariables[key] = value - } - } - } - return nil -} diff --git a/pkg/api/v1alpha1/user_types.go b/pkg/api/v1alpha1/user_types.go deleted file mode 100644 index f79ff6c..0000000 --- a/pkg/api/v1alpha1/user_types.go +++ /dev/null @@ -1,109 +0,0 @@ -package v1alpha1 - -import "time" - -const ( - WorkspaceUserAvailableCondition = "Available" - - ConditionTrue = "True" - ConditionFalse = "False" - ConditionUnknown = "Unknown" -) - -type WorkspaceUser struct { - ID string - UserID string - OrgID string - SshPublicKey string - Workspaces []string - Version int32 - Status *WorkspaceUserStatus - State string - Message string -} - -func (w *WorkspaceUser) IsAvailable() bool { - return w.Status != nil && w.Status.IsAvailable() && w.Version == w.Status.ObservedVersion -} - -type WorkspaceUserStatus struct { - ObservedVersion int32 - ProvisionedWorkspaces []ProvisionedWorkspace - ServiceAccountname string - ServiceAccountToken string - ClusterCaCert string - ClusterUrl string - Conditions []Condition -} - -func (w WorkspaceUserStatus) GetServiceAccountName() string { - return w.ServiceAccountname -} - -func (w WorkspaceUserStatus) GetServiceAccountToken() string { - return w.ServiceAccountToken -} - -func (w WorkspaceUserStatus) GetClusterCaCert() string { - return w.ClusterCaCert -} - -func (w WorkspaceUserStatus) GetClusterUrl() string { - return w.ClusterUrl -} - -func (w WorkspaceUserStatus) GetProvisionedWorkspaces() []ProvisionedWorkspace { - return w.ProvisionedWorkspaces -} - -type ProvisionedWorkspace struct { - WorkspaceName string `json:"workspaceName"` - Namespace string `json:"namespace"` -} - -type Condition struct { - Type string - Status string - LastTransitionTime time.Time - Reason string - Message string -} - -func (w *WorkspaceUserStatus) IsAvailable() bool { - availableCond := GetCondition(w.Conditions, WorkspaceUserAvailableCondition) - return availableCond != nil && availableCond.Status == ConditionTrue -} - -func GetCondition(conditions []Condition, conditionType string) *Condition { - for _, cond := range conditions { - if cond.Type == conditionType { - return &cond - } - } - return nil -} - -func (w *WorkspaceUserStatus) ContainsWorkspace(workspaceName string) bool { - for _, ns := range w.ProvisionedWorkspaces { - if ns.WorkspaceName == workspaceName { - return true - } - } - return false -} - -type User struct { - // User's ID - Id string - // User's name - Name string - // User's username - Username string - // User's email address - Email string - // User's organisation - Organisation string - // User's role - Role string - OrganisationID string -} diff --git a/pkg/api/v1alpha1/workspace.go b/pkg/api/v1alpha1/workspace.go deleted file mode 100644 index b0b805f..0000000 --- a/pkg/api/v1alpha1/workspace.go +++ /dev/null @@ -1,144 +0,0 @@ -package v1alpha1 - -import "time" - -const ( - WorkspaceAvailableCondition = "Available" - WorkspaceResourceAvailableCondition = "Available" -) - -type Workspace struct { - ID string - Name string - Namespace string - Labels []KeyValue - Annotations []KeyValue - Version int32 - Resources []WorkspaceResource - Status *WorkspaceStatus - CreatedAt time.Time - UpdatedAt time.Time -} - -func (w *Workspace) HasLocalSyncingVolumes() bool { - for _, resource := range w.Resources { - for _, volumeMount := range resource.VolumeMounts { - if volumeMount.SourceVolumeType == LOCAL { - return true - } - } - } - return false -} - -func (w *Workspace) ResourceHasLocalSyncingVolume(resourceName string) bool { - resource := w.GetResourceByName(resourceName) - if resource == nil { - return false - } - for _, volumeMount := range resource.VolumeMounts { - if volumeMount.SourceVolumeType == LOCAL { - return true - } - } - return false -} - -func (w *Workspace) GetResourceByName(name string) *WorkspaceResource { - for _, resource := range w.Resources { - if resource.Name == name { - return &resource - } - } - return nil -} - -func (w *Workspace) IsAvailable() bool { - return w.Status != nil && w.Status.IsAvailable() && w.Version == w.Status.ObservedVersion -} - -func (w *WorkspaceStatus) IsAvailable() bool { - availableCond := GetCondition(w.Conditions, WorkspaceAvailableCondition) - return availableCond != nil && availableCond.Status == "True" -} - -type WorkspaceStatus struct { - ObservedVersion int32 - State string - Conditions []Condition -} - -type WorkspaceResource struct { - ID string - Name string - Labels []KeyValue - Annotations []KeyValue - Version int32 - ImageRegistry *string - BuildConfig *BuildConfig - PreBuiltImage *PreBuiltImage - Init *InitConfig - ExecutionConfig *ExecutionConfig - LifecycleConfig *LifecycleConfig - VolumeMounts []VolumeMount - Ports []Port - Stateful bool - DependsOn []string - Status *WorkspaceResourceStatus -} - -func (wr *WorkspaceResource) IsAvailable() bool { - return wr.Status != nil && wr.Status.IsAvailable() && wr.Version == wr.Status.ObservedVersion -} - -func (wr *WorkspaceResourceStatus) IsAvailable() bool { - availableCond := GetCondition(wr.Conditions, WorkspaceResourceAvailableCondition) - return availableCond != nil && availableCond.Status == "True" -} - -type BuildConfig struct { - SourceVolumeID string - ContextPath string - DockerFilePath string - ContextDirHash string -} - -type PreBuiltImage struct { - Image string -} - -type InitConfig struct { - Command []string - Args []string -} - -type ExecutionConfig struct { - Command []string - Args []string - EnvironmentVariables []KeyValue -} - -type LifecycleConfig struct { - RestartRequestTime *time.Time -} - -type VolumeMount struct { - SourceVolumeID string - SourceVolumeType VolumeSourceType - SourceSubPath *string - TargetPath string -} - -type WorkspaceResourceStatus struct { - ObservedVersion int32 - InternalServiceName *string - State string - LastRestartRequestProcessedTime *time.Time - Conditions []Condition - PublicIngresses []Ingress -} - -type Ingress struct { - URL string - TargetPort int32 -} diff --git a/pkg/api/v1alpha1/workspace_storage.go b/pkg/api/v1alpha1/workspace_storage.go deleted file mode 100644 index 4b4f310..0000000 --- a/pkg/api/v1alpha1/workspace_storage.go +++ /dev/null @@ -1,89 +0,0 @@ -package v1alpha1 - -import ( - "time" - - serverapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" -) - -const ( - WorkspaceStorageAvailableCondition = "Available" -) - -// List of VolumeSourceTypes - -type VolumeSourceType string - -const ( - LOCAL VolumeSourceType = VolumeSourceType(serverapi.LOCAL_SYNCED_VOLUME) - BUILD_ARTIFACT VolumeSourceType = VolumeSourceType(serverapi.BUILD_ARTIFACT_SYNCED_VOLUME) - EMPTY VolumeSourceType = VolumeSourceType(serverapi.EMPTY_VOLUME) -) - -type WorkspaceStorage struct { - ID string - OrganisationID string - Name string - Namespace string - Labels []KeyValue - Annotations []KeyValue - Version int32 - WorkspaceName string - Volumes []Volume - Status *WorkspaceStorageStatus - CreatedAt time.Time - UpdatedAt time.Time -} - -func (w *WorkspaceStorage) HasLocalSyncingVolumes() bool { - for _, volume := range w.Volumes { - if volume.VolumeSource != nil && volume.VolumeSource.LocalDir != nil { - return true - } - } - return false -} - -type WorkspaceStorageStatus struct { - ObservedVersion int32 - Conditions []Condition - State string - StorageServiceName string -} - -type Volume struct { - Name string - Labels []KeyValue - Annotations []KeyValue - Size string - StorageClass string - SyncBeforeUse bool - VolumeSource *VolumeSource - Status *WorkspaceVolumeStatus -} - -type WorkspaceVolumeStatus struct { - Conditions []Condition - Phase string - BuildArtifactSyncs []BuildArtifactSyncInfo -} - -type BuildArtifactSyncInfo struct { - ResourceName string - BuildId string - Status string -} - -type KeyValue struct { - Key string - Value string -} - -func (w *WorkspaceStorage) IsAvailable() bool { - return w.Status != nil && w.Status.IsAvailable() && w.Version == w.Status.ObservedVersion -} - -func (w WorkspaceStorageStatus) IsAvailable() bool { - cond := GetCondition(w.Conditions, WorkspaceStorageAvailableCondition) - return cond != nil && cond.Status == "True" -} diff --git a/pkg/client/k8s_provider_client.go b/pkg/client/k8s_provider_client.go deleted file mode 100644 index d4375af..0000000 --- a/pkg/client/k8s_provider_client.go +++ /dev/null @@ -1,78 +0,0 @@ -package client - -import ( - "encoding/base64" - "fmt" - - "k8s.io/apimachinery/pkg/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - certutil "k8s.io/client-go/util/cert" - "sigs.k8s.io/controller-runtime/pkg/client" - workspacev1alpha1 "soradev.io/cluster-agent/api/v1alpha1" -) - -type providerConfig interface { - ProviderCACert() string - ProviderServerURL() string - ProviderToken() string - Valid() bool - SSHUser() string -} - -type ProviderClient struct { - config providerConfig - scheme *runtime.Scheme - RestConfig *rest.Config - client.Client -} - -func NewProviderClient(cfg providerConfig) (*ProviderClient, error) { - if !cfg.Valid() { - return nil, fmt.Errorf("config not valid") - } - - // Validate CA cert. - caCertBytes, err := base64.StdEncoding.DecodeString(cfg.ProviderCACert()) - if err != nil { - return nil, fmt.Errorf("failed to base64 decode CA cert string: %w", err) - } - _, err = certutil.NewPoolFromBytes(caCertBytes) - if err != nil { - return nil, err - } - - restConfig := &rest.Config{ - Host: cfg.ProviderServerURL(), - BearerToken: cfg.ProviderToken(), - TLSClientConfig: rest.TLSClientConfig{ - CAData: caCertBytes, - }, - } - scheme := runtime.NewScheme() - if err := clientgoscheme.AddToScheme(scheme); err != nil { - return nil, err - } - - if err := workspacev1alpha1.AddToScheme(scheme); err != nil { - return nil, err - } - // Uncached k8s client. - clientset, err := client.New(restConfig, client.Options{ - Scheme: scheme, - }) - if err != nil { - return nil, err - } - - return &ProviderClient{ - config: cfg, - scheme: scheme, - Client: clientset, - RestConfig: restConfig, - }, nil -} - -func (c *ProviderClient) SSHUser() string { - return c.config.SSHUser() -} diff --git a/pkg/client/stackdome_client.go b/pkg/client/stackdome_client.go deleted file mode 100644 index 42de3be..0000000 --- a/pkg/client/stackdome_client.go +++ /dev/null @@ -1,298 +0,0 @@ -package client - -import ( - "context" - "fmt" - "net" - "net/http" - "net/url" - "time" - - serverapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" - internalapi "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/mapper" - "github.com/davecgh/go-spew/spew" -) - -type StackdomeAPIError struct { - HttpCode int - err error - Message string -} - -func (e *StackdomeAPIError) Error() string { - return fmt.Sprintf("%s: received '%d' code from stackdome API server: %s", e.Message, e.HttpCode, e.err.Error()) -} - -type StackdomeAPIClient interface { - GetUser(ctx context.Context) (*internalapi.User, error) - CreateWorkspaceUser(ctx context.Context, workspaceUser *internalapi.WorkspaceUser) (*internalapi.WorkspaceUser, *StackdomeAPIError) - UpdateWorkspaceUser(ctx context.Context, ID string, workspaceUser *internalapi.WorkspaceUser) (*internalapi.WorkspaceUser, *StackdomeAPIError) - GetWorskpaceUser(ctx context.Context, workspaceUserID string) (*internalapi.WorkspaceUser, *StackdomeAPIError) - GetCurrentUserWorskpaceUser(ctx context.Context) (*internalapi.WorkspaceUser, *StackdomeAPIError) - GetCurrentUserWorkspaceStorages(ctx context.Context) ([]*internalapi.WorkspaceStorage, *StackdomeAPIError) - GetWorkspaceStorage(ctx context.Context, id string) (*internalapi.WorkspaceStorage, *StackdomeAPIError) - UpdateWorkspaceStorage(ctx context.Context, ID string, workspace *internalapi.UserStack) (*internalapi.WorkspaceStorage, *StackdomeAPIError) - CreateWorkspaceStorage(ctx context.Context, workspace *internalapi.UserStack) (*internalapi.WorkspaceStorage, *StackdomeAPIError) - DeleteWorkspaceStorage(ctx context.Context, ID string) *StackdomeAPIError - - CreateWorkspace(ctx context.Context, workspace *internalapi.Workspace) (*internalapi.Workspace, *StackdomeAPIError) - GetWorkspace(ctx context.Context, id string) (*internalapi.Workspace, *StackdomeAPIError) - GetWorkspaceResources(ctx context.Context, id string) ([]internalapi.WorkspaceResource, *StackdomeAPIError) - UpdateWorkspace(ctx context.Context, ID string, workspace *internalapi.Workspace) (*internalapi.Workspace, *StackdomeAPIError) - DeleteWorkspace(ctx context.Context, ID string) *StackdomeAPIError - GetCurrentWorkspaces(ctx context.Context) ([]*internalapi.Workspace, *StackdomeAPIError) - GetWorkspaceBuilds(ctx context.Context, workspaceID string) ([]internalapi.ResourceBuild, *StackdomeAPIError) - GetWorkspaceResourceBuilds(ctx context.Context, workspaceID string, resourceName string) ([]internalapi.ResourceBuild, *StackdomeAPIError) - MarkVolumeAsSynced(ctx context.Context, workspaceStorageID string, volumeID string) *StackdomeAPIError -} - -type stackdomeClient struct { - AccessToken string - URL string - Insecure bool - OrganisationID string - client *serverapi.APIClient -} - -type clientConfig interface { - Valid() bool - GetServerURL() string - GetAccessToken() string - GetInsecure() bool - GetOrganisationID() string -} - -func NewStackdomeClient(in clientConfig) StackdomeAPIClient { - cfg := serverapi.Configuration{ - UserAgent: "stackdome-cli", - Debug: false, - Servers: serverapi.ServerConfigurations{ - serverapi.ServerConfiguration{ - URL: in.GetServerURL(), - }, - }, - HTTPClient: &http.Client{ - Timeout: time.Second * 10, - }, - } - return &stackdomeClient{ - AccessToken: in.GetAccessToken(), - URL: in.GetServerURL(), - Insecure: in.GetInsecure(), - OrganisationID: in.GetOrganisationID(), - client: serverapi.NewAPIClient(&cfg), - } -} - -func (c *stackdomeClient) GetUser(ctx context.Context) (*internalapi.User, error) { - spew.Dump(*c) - resp, httpResp, err := c.client.DefaultApi.ApiV1UsersMeGet(c.withAuthenticatedCtx(ctx)).Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to get User information") - } - return mapper.ToClientAPIUser(resp), nil -} - -// ---------------------------------------------------------------------------- -// Workspaces - -func (c *stackdomeClient) CreateWorkspace(ctx context.Context, workspace *internalapi.Workspace) (*internalapi.Workspace, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi.ApiV1OrganizationsOrgIdWorkspacesPost(c.withAuthenticatedCtx(ctx), c.OrganisationID). - Workspace(mapper.ToServerAPIWorkpace(workspace)).Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to create workspace") - } - return mapper.ToClientAPIWorkspace(resp), nil -} - -func (c *stackdomeClient) GetWorkspace(ctx context.Context, id string) (*internalapi.Workspace, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi.ApiV1OrganizationsOrgIdWorkspacesIdGet(c.withAuthenticatedCtx(ctx), c.OrganisationID, id).Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to get workspace") - } - return mapper.ToClientAPIWorkspace(resp), nil -} - -func (c *stackdomeClient) GetWorkspaceResources(ctx context.Context, id string) ([]internalapi.WorkspaceResource, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi.ApiV1OrganizationsOrgIdWorkspacesWorkspaceIdResourcesGet(c.withAuthenticatedCtx(ctx), c.OrganisationID, id).Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to get workspace") - } - return mapper.ToClientWorkspaceResources(resp.Items), nil -} - -func (c *stackdomeClient) GetWorkspaceBuilds(ctx context.Context, workspaceID string) ([]internalapi.ResourceBuild, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi.ApiV1OrganizationsOrgIdWorkspacesWorkspaceIdBuildsGet( - c.withAuthenticatedCtx(ctx), c.OrganisationID, workspaceID).Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to get workspace builds") - } - return mapper.ToClientResourceBuilds(resp.Items), nil -} - -func (c *stackdomeClient) GetWorkspaceResourceBuilds(ctx context.Context, workspaceID string, resourceName string) ([]internalapi.ResourceBuild, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi.ApiV1OrganizationsOrgIdWorkspacesWorkspaceIdResourcesIdBuildsGet( - c.withAuthenticatedCtx(ctx), c.OrganisationID, workspaceID, resourceName).Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to get workspace resource builds") - } - return mapper.ToClientResourceBuilds(resp.Items), nil -} - -func (c *stackdomeClient) UpdateWorkspace(ctx context.Context, ID string, workspace *internalapi.Workspace) (*internalapi.Workspace, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi.ApiV1OrganizationsOrgIdWorkspacesIdPut(c.withAuthenticatedCtx(ctx), c.OrganisationID, ID). - Workspace(mapper.ToServerAPIWorkpace(workspace)).Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to update workspace") - } - return mapper.ToClientAPIWorkspace(resp), nil -} - -func (c *stackdomeClient) DeleteWorkspace(ctx context.Context, ID string) *StackdomeAPIError { - httpResp, err := c.client.DefaultApi.ApiV1OrganizationsOrgIdWorkspacesIdDelete(c.withAuthenticatedCtx(ctx), c.OrganisationID, ID).Execute() - if err != nil { - return handleError(httpResp, err, "failed to delete workspace") - } - return nil -} - -func (c *stackdomeClient) GetCurrentWorkspaces(ctx context.Context) ([]*internalapi.Workspace, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi.ApiV1OrganizationsOrgIdWorkspacesCurrentGet(c.withAuthenticatedCtx(ctx), c.OrganisationID).Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to get workspaces") - } - - return mapper.ToClientAPIWorkspaces(resp.Items), nil -} - -// -// Workspaces end -// ---------------------------------------------------------------------------- - -func (c *stackdomeClient) GetCurrentUserWorkspaceStorages(ctx context.Context) ([]*internalapi.WorkspaceStorage, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi. - ApiV1OrganizationsOrgIdWorkspaceStoragesCurrentGet(c.withAuthenticatedCtx(ctx), c.OrganisationID).Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to get workspace storages") - } - return mapper.ToClientWorkspaceStorages(resp.Items), nil -} - -func (c *stackdomeClient) GetWorkspaceStorage(ctx context.Context, id string) (*internalapi.WorkspaceStorage, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi. - ApiV1OrganizationsOrgIdWorkspaceStoragesIdGet(c.withAuthenticatedCtx(ctx), c.OrganisationID, id).Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to get workspace storage") - } - return mapper.ToClientWorkspaceStorage(resp), nil -} - -func (c *stackdomeClient) CreateWorkspaceStorage(ctx context.Context, workspace *internalapi.UserStack) (*internalapi.WorkspaceStorage, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi. - ApiV1OrganizationsIdWorkspaceStoragesPost(c.withAuthenticatedCtx(ctx), c.OrganisationID). - WorkspaceStorage(mapper.ToServerWorkspaceStorage(workspace)). - Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to create workspace storage") - } - return mapper.ToClientWorkspaceStorage(resp), nil -} - -func (c *stackdomeClient) UpdateWorkspaceStorage(ctx context.Context, ID string, workspace *internalapi.UserStack) (*internalapi.WorkspaceStorage, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi. - ApiV1OrganizationsOrgIdWorkspaceStoragesIdPut(c.withAuthenticatedCtx(ctx), c.OrganisationID, ID). - WorkspaceStorage(mapper.ToServerWorkspaceStorage(workspace)). - Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to update workspace storage") - } - return mapper.ToClientWorkspaceStorage(resp), nil -} - -func (c *stackdomeClient) DeleteWorkspaceStorage(ctx context.Context, ID string) *StackdomeAPIError { - httpResp, err := c.client.DefaultApi. - ApiV1OrganizationsOrgIdWorkspaceStoragesIdDelete(c.withAuthenticatedCtx(ctx), c.OrganisationID, ID). - Execute() - if err != nil { - return handleError(httpResp, err, "failed to delete workspace storage") - } - return nil -} - -func (c *stackdomeClient) MarkVolumeAsSynced(ctx context.Context, workspaceStorageID string, volumeID string) *StackdomeAPIError { - httpResp, err := c.client.DefaultApi. - ApiV1OrganizationsOrgIdWorkspaceStoragesIdVolumesVolumeIdMarkAsSyncedPost(c.withAuthenticatedCtx(ctx), c.OrganisationID, workspaceStorageID, volumeID). - Execute() - if err != nil { - return handleError(httpResp, err, "failed to mark volume as synced") - } - return nil -} - -func (c *stackdomeClient) CreateWorkspaceUser(ctx context.Context, workspaceUser *internalapi.WorkspaceUser) (*internalapi.WorkspaceUser, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi. - ApiV1WorkspaceUsersPost(c.withAuthenticatedCtx(ctx)). - WorkspaceUser(mapper.ToServerAPIWorkspaceUser(workspaceUser)). - Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to create workspace provision request") - } - return mapper.ToClientAPIWorkspaceUser(resp), nil -} - -func (c *stackdomeClient) UpdateWorkspaceUser(ctx context.Context, ID string, workspaceUser *internalapi.WorkspaceUser) (*internalapi.WorkspaceUser, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi. - ApiV1WorkspaceUsersIdPut(c.withAuthenticatedCtx(ctx), ID). - WorkspaceUser(mapper.ToServerAPIWorkspaceUser(workspaceUser)). - Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to update workspace provision request") - } - return mapper.ToClientAPIWorkspaceUser(resp), nil -} - -func (c *stackdomeClient) GetWorskpaceUser(ctx context.Context, workspaceUserID string) (*internalapi.WorkspaceUser, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi. - ApiV1WorkspaceUsersIdGet(c.withAuthenticatedCtx(ctx), workspaceUserID).Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to get workspace provision request") - } - return mapper.ToClientAPIWorkspaceUser(resp), nil -} - -func (c *stackdomeClient) GetCurrentUserWorskpaceUser(ctx context.Context) (*internalapi.WorkspaceUser, *StackdomeAPIError) { - resp, httpResp, err := c.client.DefaultApi.ApiV1WorkspaceUsersCurrentGet(c.withAuthenticatedCtx(ctx)).Execute() - if err != nil { - return nil, handleError(httpResp, err, "failed to get workspace provision request") - } - return mapper.ToClientAPIWorkspaceUser(resp), nil -} - -func (c *stackdomeClient) withAuthenticatedCtx(ctx context.Context) context.Context { - return context.WithValue(ctx, serverapi.ContextAccessToken, c.AccessToken) -} - -func handleError(httpResp *http.Response, err error, message string) *StackdomeAPIError { - if httpResp != nil { - return &StackdomeAPIError{HttpCode: httpResp.StatusCode, err: err, Message: message} - } - if isTimeoutError(err) { - return &StackdomeAPIError{HttpCode: http.StatusRequestTimeout, err: err, Message: message} - } - // Unknown error - return &StackdomeAPIError{HttpCode: 0, err: err, Message: message} -} - -func isTimeoutError(err error) bool { - if urlErr, ok := err.(*url.Error); ok && urlErr.Timeout() { - return true - } - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - return true - } - if err == context.DeadlineExceeded { - return true - } - return false -} diff --git a/pkg/config/config.go b/pkg/config/config.go deleted file mode 100644 index b0ed952..0000000 --- a/pkg/config/config.go +++ /dev/null @@ -1,251 +0,0 @@ -package config - -import ( - "encoding/json" - "fmt" - "os" - "os/user" - "path/filepath" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" -) - -type providerConfigGetter interface { - GetServiceAccountName() string - GetServiceAccountToken() string - GetClusterCaCert() string - GetClusterUrl() string - GetProvisionedWorkspaces() []v1alpha1.ProvisionedWorkspace -} - -type Config struct { - AccessToken string `json:"accessToken,omitempty" doc:"Bearer access token."` - VoyagerServerUrl string `json:"voyagerServerUrl"` - Insecure bool `json:"insecure"` - ProviderConfig *ComputeProviderConfig `json:"providerConfig"` - Username string `json:"username,omitempty" doc:"User name."` - Organisation string `json:"organisation"` - OrganisationID string `json:"organisationID"` - UserPublicKeyPath string `json:"userPublicKeyPath"` - UserPrivateKeyPath string `json:"userPrivateKeyPath"` - Workspaces []v1alpha1.ProvisionedWorkspace `json:"workspaces"` - CurrentWorkspace *string `json:"currentWorkspace,omitempty"` -} - -func (c *Config) SSHUser() string { - if c.ProviderConfig.SSHUserName == "" { - return "stackdomeuser" - } - return c.ProviderConfig.SSHUserName -} - -func (c *Config) ProviderConfigPresent() bool { - return c.ProviderConfig != nil && - c.ProviderConfig.ServiceAccountName != "" && - c.ProviderConfig.Token != "" && - c.ProviderConfig.CaCert != "" && - c.ProviderConfig.ServerUrl != "" -} - -func (c *Config) GetServerURL() string { - return c.VoyagerServerUrl -} - -func (c *Config) GetAccessToken() string { - return c.AccessToken -} - -func (c *Config) GetInsecure() bool { - return c.Insecure -} - -func (c *Config) GetOrganisationID() string { - return c.OrganisationID -} - -func (c *Config) ProviderCACert() string { - return c.ProviderConfig.CaCert -} - -func (c *Config) ProviderServerURL() string { - return c.ProviderConfig.ServerUrl -} - -func (c *Config) ServiceAccountName() string { - return c.ProviderConfig.ServiceAccountName -} - -func (c *Config) ProviderToken() string { - return c.ProviderConfig.Token -} - -func (c *Config) PersistCurrentWorkspace(workspaceName string) error { - c.CurrentWorkspace = &workspaceName - if err := Save(c); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } - return nil -} - -func (c *Config) PersistProviderConfig(input providerConfigGetter) error { - if c.ProviderConfig == nil { - c.ProviderConfig = &ComputeProviderConfig{} - } - c.ProviderConfig.CaCert = input.GetClusterCaCert() - c.ProviderConfig.ServerUrl = input.GetClusterUrl() - c.ProviderConfig.ServiceAccountName = input.GetServiceAccountName() - c.ProviderConfig.Token = input.GetServiceAccountToken() - c.Workspaces = input.GetProvisionedWorkspaces() - if err := Save(c); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } - return nil -} - -func (c *Config) CurrentNamespace() string { - if c.CurrentWorkspace == nil { - panic("current workspace is nil") - } - for _, ws := range c.Workspaces { - if ws.WorkspaceName == *c.CurrentWorkspace { - return ws.Namespace - } - } - panic(fmt.Sprintf("current workspace %s not found in config", *c.CurrentWorkspace)) -} - -type Workspace struct { - Name string `json:"name"` - Namespace string `json:"namespace"` -} - -type SyncDaemonInfo struct { - PortForwardDaemonPID int `json:"portForwardDaemonPID"` - MutagenDaemonPID int `json:"mutagenDaemonPID"` -} - -type ComputeProviderConfig struct { - ServiceAccountName string `json:"serviceAccountName"` - Token string `json:"token"` - CaCert string `json:"caCert"` - ServerUrl string `json:"serverUrl"` - SSHUserName string `json:"sshUserName"` -} - -func notNull(attr any) bool { - return attr != nil -} - -func notEmpty(attr string) bool { - return len(attr) != 0 -} - -func notEmptyList[T any](attr []T) bool { - return len(attr) != 0 -} - -func (c *Config) Valid() bool { - validations := []bool{ - notEmpty(c.AccessToken), - notEmpty(c.VoyagerServerUrl), - notNull(c.ProviderConfig), - notEmpty(c.OrganisationID), - notEmptyList(c.Workspaces), - notNull(c.CurrentWorkspace), - len(c.ProviderConfig.CaCert) != 0, - notEmpty(c.ProviderConfig.Token), - notEmpty(c.ProviderConfig.ServerUrl), - } - for _, valid := range validations { - if !valid { - return false - } - } - return true -} - -func (c *Config) SetUserPrivateKeyPublicKeyPath(privateKeyPath string, publicKeyPath string) { - c.UserPrivateKeyPath = privateKeyPath - c.UserPublicKeyPath = publicKeyPath -} - -func (c *Config) ConfigDir() (string, error) { - return ConfigDir() -} - -func ConfigLocation() (string, error) { - if voyagerConfig := os.Getenv("VOYAGER_CONFIG"); voyagerConfig != "" { - return voyagerConfig, nil - } - configDir, err := ConfigDir() - if err != nil { - return "", err - } - - path := filepath.Join(configDir, "config.json") - return path, nil -} - -func ConfigDir() (string, error) { - currentUser, err := user.Current() - if err != nil { - return "", err - } - // Get the user's home directory - configDir := currentUser.HomeDir - path := filepath.Join(configDir, "/.voyager") - return path, nil -} - -func Load() (*Config, error) { - filePath, err := ConfigLocation() - if err != nil { - return nil, err - } - _, err = os.Stat(filePath) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("cant find voyager config file at: %s", filePath) - } - return nil, fmt.Errorf("can't check if config file '%s' exists: %w", filePath, err) - } - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("can't read config file '%s': %v", filePath, err) - } - cfg := &Config{} - if len(data) == 0 { - return nil, fmt.Errorf("empty config file") - } - err = json.Unmarshal(data, cfg) - if err != nil { - return nil, fmt.Errorf("can't parse config file '%s': %v", filePath, err) - } - return cfg, nil -} - -func New() *Config { - return &Config{} -} - -// Save the given configuration to the configuration file. -func Save(cfg *Config) error { - file, err := ConfigLocation() - if err != nil { - return err - } - dir := filepath.Dir(file) - err = os.MkdirAll(dir, os.FileMode(0755)) - if err != nil { - return fmt.Errorf("can't create directory %s: %v", dir, err) - } - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return fmt.Errorf("can't marshal config: %v", err) - } - err = os.WriteFile(file, data, 0600) - if err != nil { - return fmt.Errorf("can't write file '%s': %v", file, err) - } - return nil -} diff --git a/pkg/config/runtime.go b/pkg/config/runtime.go deleted file mode 100644 index 6ab030c..0000000 --- a/pkg/config/runtime.go +++ /dev/null @@ -1,180 +0,0 @@ -package config - -import ( - "errors" - "fmt" - "os" - "path/filepath" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/session" - "github.com/ashishmax31/voyager-cli/pkg/tools" -) - -type Args struct { - ResourceName *string - WorkspaceName *string - AllResources *bool - Interactive *bool - TailLines *int64 - Follow *bool - StackFilePath *string - ExecuteCmd []string - AllWorkspaces *bool - RemoveStorage *bool - CurrentWorkspace *bool -} - -func (a *Args) IsAllResources() bool { - return a.AllResources != nil && *a.AllResources -} - -func (a *Args) IsCurrentWorkspace() bool { - return a.CurrentWorkspace != nil && *a.CurrentWorkspace -} - -func (a *Args) IsInteractive() bool { - return a.Interactive != nil && *a.Interactive -} - -func (a *Args) IsFollow() bool { - return a.Follow != nil && *a.Follow -} - -func (a *Args) IsTailLines() bool { - return a.TailLines != nil -} - -func (a *Args) GetResourceName() string { - if a.ResourceName == nil { - return "" - } - return *a.ResourceName -} - -func (a *Args) GetWorkspaceName() string { - if a.WorkspaceName == nil { - return "" - } - return *a.WorkspaceName -} - -func (a *Args) GetStackFilePath() string { - if a.StackFilePath == nil { - return "" - } - return *a.StackFilePath -} - -func (a *Args) GetTailLines() int64 { - if a.TailLines == nil { - return 0 - } - return *a.TailLines -} - -func (a *Args) IsAllWorkspaces() bool { - return a.AllWorkspaces != nil && *a.AllWorkspaces -} - -func (a *Args) IsRemoveStorage() bool { - return a.RemoveStorage != nil && *a.RemoveStorage -} - -type Runtime struct { - Command string - cfg *Config - ConfigDir string - DepsDir string - LogDir string - Session session.Session - Args Args -} - -func (a *Runtime) UserStack() (*v1alpha1.UserStack, error) { - if a.Args.StackFilePath == nil { - return nil, errors.New("stack file path is not set") - } - var res v1alpha1.UserStack - if err := tools.UnmarshalYamlFile(*a.Args.StackFilePath, &res); err != nil { - return nil, fmt.Errorf("failed to unmarshal stack file: %w", err) - } - if a.Config().CurrentWorkspace != nil { - res.Name = *a.Config().CurrentWorkspace - } - if err := res.ReadEnvFiles(); err != nil { - return nil, fmt.Errorf("failed to read env files: %w", err) - } - - return &res, nil -} - -func NewRuntime(command string, args Args) (*Runtime, error) { - cfg, err := Load() - if err != nil { - return nil, fmt.Errorf("failed to load config: %w", err) - } - - dir, err := ConfigDir() - if err != nil { - return nil, fmt.Errorf("failed to get config dir: %w", err) - } - - if err := os.MkdirAll(dir, 0755); err != nil { - return nil, fmt.Errorf("failed to create config dir: %w", err) - } - - depsDir := filepath.Join(dir, "bin") - - if err := os.MkdirAll(depsDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create deps dir: %w", err) - } - - logsDir := filepath.Join(dir, "logs") - if err := os.MkdirAll(logsDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create logs dir: %w", err) - } - - var s session.Session - if cfg.ProviderConfigPresent() { - s, err = session.NewSession(cfg, true) - if err != nil { - return nil, fmt.Errorf("failed to create session: %w", err) - } - } else { - s, err = session.NewSession(cfg, false) - if err != nil { - return nil, fmt.Errorf("failed to create session: %w", err) - } - } - - return &Runtime{ - cfg: cfg, - Command: command, - Args: args, - Session: s, - ConfigDir: dir, - DepsDir: depsDir, - LogDir: logsDir, - }, nil -} - -func (r *Runtime) Config() *Config { - return r.cfg -} - -func (r *Runtime) SaveConfig() error { - return Save(r.cfg) -} - -func (r *Runtime) CreateLogFile(name string) (*os.File, error) { - return os.Create(filepath.Join(r.LogDir, name)) -} - -func (r *Runtime) CurrentWorkspaceStorageName() (string, error) { - currentWorkspaceName := r.Config().CurrentWorkspace - if currentWorkspaceName == nil { - return "", fmt.Errorf("current workspace not set") - } - return fmt.Sprintf("%s-%s", *currentWorkspaceName, "storage"), nil -} diff --git a/pkg/mapper/resource_builds.go b/pkg/mapper/resource_builds.go deleted file mode 100644 index 88fb0b8..0000000 --- a/pkg/mapper/resource_builds.go +++ /dev/null @@ -1,38 +0,0 @@ -package mapper - -import ( - serverapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" - clientapi "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" -) - -func ToClientResourceBuilds(in []serverapi.WorkspaceResourceBuild) []clientapi.ResourceBuild { - res := make([]clientapi.ResourceBuild, 0) - for _, rb := range in { - res = append(res, ToClientResourceBuild(&rb)) - } - return res -} - -func ToClientResourceBuild(in *serverapi.WorkspaceResourceBuild) clientapi.ResourceBuild { - return clientapi.ResourceBuild{ - ID: in.GetId(), - WorkspaceID: in.GetWorkspaceId(), - WorkspaceResourceID: in.GetWorkspaceResourceId(), - WorkspaceResourceName: in.GetWorkspaceResourceName(), - SourceHash: in.GetSourceHash(), - ImageRegistry: in.GetImageRegistry(), - Status: ToClientResourceBuildStatus(in.Status), - } -} - -func ToClientResourceBuildStatus(in *serverapi.ResourceBuildStatus) *clientapi.ResourceBuildStatus { - if in == nil { - return nil - } - return &clientapi.ResourceBuildStatus{ - State: in.GetState(), - Conditions: ToClientAPIConditions(in.GetConditions()), - ImageURL: in.GetImageUrl(), - SourceHash: in.GetBuildSourceHash(), - } -} diff --git a/pkg/mapper/workspace.go b/pkg/mapper/workspace.go deleted file mode 100644 index fa7e097..0000000 --- a/pkg/mapper/workspace.go +++ /dev/null @@ -1,445 +0,0 @@ -package mapper - -import ( - "strings" - - serverapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" - clientapi "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "k8s.io/utils/ptr" -) - -func ToClientAPIWorkspaces(in []serverapi.Workspace) []*clientapi.Workspace { - res := make([]*clientapi.Workspace, 0) - for _, workspace := range in { - res = append(res, ToClientAPIWorkspace(&workspace)) - } - return res -} - -func ToClientAPIWorkspace(in *serverapi.Workspace) *clientapi.Workspace { - return &clientapi.Workspace{ - ID: in.GetId(), - Name: in.Name, - Namespace: in.GetNamespace(), - Labels: ServerLabelsToClientKeyValues(in.Labels), - Annotations: ServerAnnotationsToClientKeyValues(in.Annotations), - Resources: ToClientWorkspaceResources(in.Spec.Resources), - Status: toClientWorkspaceStatus(in.Status), - } -} - -func toClientWorkspaceStatus(in *serverapi.WorkspaceStatus) *clientapi.WorkspaceStatus { - if in == nil { - return nil - } - return &clientapi.WorkspaceStatus{ - Conditions: ToClientAPIConditions(in.Conditions), - State: in.GetState(), - } -} - -func ToClientWorkspaceResources(in []serverapi.WorkspaceResource) []clientapi.WorkspaceResource { - res := make([]clientapi.WorkspaceResource, 0) - for _, resource := range in { - currentResource := clientapi.WorkspaceResource{ - ID: *resource.Id, - Name: resource.Name, - ImageRegistry: resource.ImageRegistry, - DependsOn: resource.DependsOn, - Labels: ServerLabelsToClientKeyValues(resource.Labels), - Annotations: ServerAnnotationsToClientKeyValues(resource.Annotations), - BuildConfig: toClientBuildSpec(resource.Build), - PreBuiltImage: toClientPrebuiltImage(resource.Prebuilt), - Init: toClientInitConfig(resource.Init), - ExecutionConfig: toClientExecutionConfig(resource.ExecutionConfig), - LifecycleConfig: toClientLifecycleConfig(resource.LifecycleConfig), - Ports: toClientPorts(resource.Ports), - Stateful: nilValue(resource.Stateful), - VolumeMounts: toClientVolumeMounts(resource.VolumeMounts), - Status: toClientWorkspaceResourceStatus(resource.Status), - } - res = append(res, currentResource) - } - return res -} - -func ToClientWorkspaceResource(in *serverapi.WorkspaceResource) *clientapi.WorkspaceResource { - if in == nil { - return nil - } - return &clientapi.WorkspaceResource{ - ID: *in.Id, - Name: in.Name, - ImageRegistry: in.ImageRegistry, - DependsOn: in.DependsOn, - Labels: ServerLabelsToClientKeyValues(in.Labels), - Annotations: ServerAnnotationsToClientKeyValues(in.Annotations), - BuildConfig: toClientBuildSpec(in.Build), - PreBuiltImage: toClientPrebuiltImage(in.Prebuilt), - Init: toClientInitConfig(in.Init), - ExecutionConfig: toClientExecutionConfig(in.ExecutionConfig), - LifecycleConfig: toClientLifecycleConfig(in.LifecycleConfig), - Ports: toClientPorts(in.Ports), - Stateful: nilValue(in.Stateful), - VolumeMounts: toClientVolumeMounts(in.VolumeMounts), - Status: toClientWorkspaceResourceStatus(in.Status), - } -} - -func toClientInitConfig(in *serverapi.InitConfig) *clientapi.InitConfig { - if in == nil { - return nil - } - return &clientapi.InitConfig{ - Command: in.Command, - Args: in.Args, - } -} - -func toClientPrebuiltImage(in *serverapi.PrebuiltConfig) *clientapi.PreBuiltImage { - if in == nil { - return nil - } - return &clientapi.PreBuiltImage{ - Image: in.Image, - } -} - -func toClientExecutionConfig(in *serverapi.ExecutionConfig) *clientapi.ExecutionConfig { - if in == nil { - return nil - } - return &clientapi.ExecutionConfig{ - Command: in.Command, - Args: in.Args, - EnvironmentVariables: toClientEnvironmentVariables(in.EnvironmentVariables), - } -} - -func toClientEnvironmentVariables(in []serverapi.EnvVar) []clientapi.KeyValue { - res := make([]clientapi.KeyValue, 0) - for _, envVar := range in { - res = append(res, clientapi.KeyValue{ - Key: envVar.Name, - Value: envVar.Value, - }) - } - return res -} - -func toClientBuildSpec(in *serverapi.BuildConfig) *clientapi.BuildConfig { - if in == nil { - return nil - } - return &clientapi.BuildConfig{ - SourceVolumeID: in.SourceVolumeId, - ContextPath: in.ContextPath, - DockerFilePath: in.GetDockerfilePath(), - ContextDirHash: in.SourceHash, - } -} - -func toClientVolumeMounts(in []serverapi.VolumeMount) []clientapi.VolumeMount { - res := make([]clientapi.VolumeMount, 0) - for _, mount := range in { - res = append(res, clientapi.VolumeMount{ - SourceVolumeID: mount.SourceVolumeId, - SourceSubPath: mount.SourceSubPath, - TargetPath: mount.TargetPath, - }) - } - return res -} - -func toClientLifecycleConfig(in *serverapi.LifecycleConfig) *clientapi.LifecycleConfig { - if in == nil { - return nil - } - return &clientapi.LifecycleConfig{ - RestartRequestTime: in.RestartRequestTime, - } -} - -func toClientPorts(in []serverapi.Port) []clientapi.Port { - res := make([]clientapi.Port, 0) - for _, port := range in { - res = append(res, clientapi.Port{ - Number: port.Number, - ExposeToPublic: port.GetExposedToPublic(), - }) - } - return res -} - -func toClientWorkspaceResourceStatus(in *serverapi.ResourceStatus) *clientapi.WorkspaceResourceStatus { - if in == nil { - return nil - } - return &clientapi.WorkspaceResourceStatus{ - ObservedVersion: in.GetObservedVersion(), - State: in.GetState(), - InternalServiceName: in.InternalServiceName, - LastRestartRequestProcessedTime: in.LastRestartRequestProcessedAt, - PublicIngresses: toClientPublicIngress(in.PublicIngress), - Conditions: ToClientAPIConditions(in.Conditions), - } -} - -func toClientPublicIngress(in []serverapi.Ingress) []clientapi.Ingress { - res := make([]clientapi.Ingress, 0) - for _, ingress := range in { - res = append(res, clientapi.Ingress{ - URL: ingress.GetUrl(), - TargetPort: ingress.GetTargetPort(), - }) - } - return res -} - -func ToServerAPIWorkpace(in *clientapi.Workspace) serverapi.Workspace { - return serverapi.Workspace{ - Name: in.Name, - Labels: clientLabelsToServerKeyValues(in.Labels), - Annotations: clientAnnotationsToServerKeyValues(in.Annotations), - Spec: toServerWorkspaceSpec(in.Resources), - } -} - -func toServerWorkspaceSpec(in []clientapi.WorkspaceResource) serverapi.WorkspaceSpec { - res := make([]serverapi.WorkspaceResource, 0) - for _, resource := range in { - currentResource := serverapi.WorkspaceResource{ - Name: resource.Name, - ImageRegistry: resource.ImageRegistry, - DependsOn: resource.DependsOn, - Labels: clientLabelsToServerKeyValues(resource.Labels), - Annotations: clientAnnotationsToServerKeyValues(resource.Annotations), - Build: toServerBuildSpec(resource.BuildConfig), - Prebuilt: toServerPrebuiltImage(resource.PreBuiltImage), - Init: toServerInitConfig(resource.Init), - VolumeMounts: toServerVolumeMounts(resource.VolumeMounts), - ExecutionConfig: toServerExecutionConfig(resource.ExecutionConfig), - LifecycleConfig: toServerLifecycleConfig(resource.LifecycleConfig), - Ports: toServerPorts(resource.Ports), - Stateful: &resource.Stateful, - } - res = append(res, currentResource) - } - return serverapi.WorkspaceSpec{ - Resources: res, - } -} - -// TODO: Allow users to set subdomain for a resource ingress. -func toServerPorts(in []clientapi.Port) []serverapi.Port { - res := make([]serverapi.Port, 0) - for _, port := range in { - res = append(res, serverapi.Port{ - Number: port.Number, - ExposedToPublic: &port.ExposeToPublic, - }) - } - return res - -} - -func toServerLifecycleConfig(in *clientapi.LifecycleConfig) *serverapi.LifecycleConfig { - if in == nil { - return nil - } - return &serverapi.LifecycleConfig{ - RestartRequestTime: in.RestartRequestTime, - } -} - -func toServerExecutionConfig(in *clientapi.ExecutionConfig) *serverapi.ExecutionConfig { - if in == nil { - return nil - } - return &serverapi.ExecutionConfig{ - Command: in.Command, - Args: in.Args, - EnvironmentVariables: toServerEnvironmentVariables(in.EnvironmentVariables), - } -} - -func toServerEnvironmentVariables(in []clientapi.KeyValue) []serverapi.EnvVar { - res := make([]serverapi.EnvVar, 0) - for _, kv := range in { - res = append(res, serverapi.EnvVar{ - Name: kv.Key, - Value: kv.Value, - }) - } - return res -} - -func toServerVolumeMounts(in []clientapi.VolumeMount) []serverapi.VolumeMount { - res := make([]serverapi.VolumeMount, 0) - for _, mount := range in { - res = append(res, serverapi.VolumeMount{ - SourceVolumeId: mount.SourceVolumeID, - SourceSubPath: mount.SourceSubPath, - TargetPath: mount.TargetPath, - }) - } - return res -} - -func toServerInitConfig(in *clientapi.InitConfig) *serverapi.InitConfig { - if in == nil { - return nil - } - return &serverapi.InitConfig{ - Command: in.Command, - Args: in.Args, - } -} - -func toServerPrebuiltImage(in *clientapi.PreBuiltImage) *serverapi.PrebuiltConfig { - if in == nil { - return nil - } - return &serverapi.PrebuiltConfig{ - Image: in.Image, - } -} - -func toServerBuildSpec(in *clientapi.BuildConfig) *serverapi.BuildConfig { - if in == nil { - return nil - } - return &serverapi.BuildConfig{ - SourceVolumeId: in.SourceVolumeID, - ContextPath: in.ContextPath, - DockerfilePath: in.DockerFilePath, - SourceHash: in.ContextDirHash, - } -} - -func clientLabelsToServerKeyValues(in []clientapi.KeyValue) []serverapi.Label { - res := make([]serverapi.Label, 0) - for _, kv := range in { - res = append(res, serverapi.Label{ - Key: kv.Key, - Value: kv.Value, - }) - } - return res -} - -func clientAnnotationsToServerKeyValues(in []clientapi.KeyValue) []serverapi.Annotation { - res := make([]serverapi.Annotation, 0) - for _, kv := range in { - res = append(res, serverapi.Annotation{ - Key: kv.Key, - Value: kv.Value, - }) - } - return res -} - -func WorkspaceFromUserStack(in *clientapi.UserStack) clientapi.Workspace { - return clientapi.Workspace{ - Name: in.Name, - Resources: workspaceResources(in.Resources), - } -} - -func workspaceResources(in map[string]*clientapi.WorkspaceResourceSpec) []clientapi.WorkspaceResource { - res := make([]clientapi.WorkspaceResource, 0) - for name, spec := range in { - currentResource := clientapi.WorkspaceResource{ - Name: name, - ImageRegistry: spec.ImageRegistry, - BuildConfig: buildConfig(spec.Build), - PreBuiltImage: prebuiltImage(spec.Image), - Init: initConfig(spec.Init), - ExecutionConfig: &clientapi.ExecutionConfig{ - Command: spec.Command, - Args: spec.Args, - EnvironmentVariables: environmentVariables(spec.EnvironmentVariables), - }, - LifecycleConfig: &clientapi.LifecycleConfig{}, - VolumeMounts: volumeMounts(spec.VolumeMounts), - Ports: spec.Ports, - Stateful: false, - DependsOn: spec.DependsOn, - Status: &clientapi.WorkspaceResourceStatus{}, - } - res = append(res, currentResource) - } - return res -} - -func environmentVariables(in map[string]string) []clientapi.KeyValue { - res := make([]clientapi.KeyValue, 0) - for k, v := range in { - res = append(res, clientapi.KeyValue{ - Key: k, - Value: v, - }) - } - return res -} - -func volumeMounts(in map[string]string) []clientapi.VolumeMount { - res := make([]clientapi.VolumeMount, 0) - for src, dst := range in { - // Ex : src = "sourceVolumeID/subPath", dst = "targetPath" - // Ex: deps/node_modules:/app/node_modules - // deps is the sourceVolumeID and node_modules is the subPath - //TODO: Use OS specific path separator? - curr := strings.Split(src, "/") - sourceVolumeID := curr[0] - var subPath *string - if strings.Join(curr[1:], "/") != "" { - subPath = ptr.To(strings.Join(curr[1:], "/")) - } - res = append(res, clientapi.VolumeMount{ - SourceVolumeID: sourceVolumeID, - SourceSubPath: subPath, - TargetPath: dst, - }) - } - return res -} - -func initConfig(in *clientapi.InitCommand) *clientapi.InitConfig { - if in == nil { - return nil - } - return &clientapi.InitConfig{ - Command: in.Command, - Args: in.Args, - } -} - -func prebuiltImage(image *string) *clientapi.PreBuiltImage { - if image == nil { - return nil - } - return &clientapi.PreBuiltImage{ - Image: *image, - } -} - -func buildConfig(in *clientapi.ApplicationBuildSpec) *clientapi.BuildConfig { - if in == nil { - return nil - } - return &clientapi.BuildConfig{ - SourceVolumeID: in.SourceVolume, - ContextPath: in.BuildContext, - DockerFilePath: nilValue(in.DockerFilePath), - ContextDirHash: in.DirHash, - } -} - -func nilValue[T any](in *T) T { - if in == nil { - var zero T - return zero - } - return *in -} diff --git a/pkg/mapper/workspace_storage.go b/pkg/mapper/workspace_storage.go deleted file mode 100644 index d8fefd3..0000000 --- a/pkg/mapper/workspace_storage.go +++ /dev/null @@ -1,149 +0,0 @@ -package mapper - -import ( - serverapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" - clientapi "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" -) - -type KeyValuer interface { - GetKey() string - GetValue() string -} - -func WorkspaceStorageFromUserStack(in *clientapi.UserStack) serverapi.WorkspaceStorage { - return serverapi.WorkspaceStorage{ - Name: in.WorkspaceStorageName(), - Spec: serverapi.WorkspaceStorageSpec{ - WorkspaceName: in.Name, - Volumes: ToServerWorkspaceVolumes(in.Volumes), - }, - } -} - -func ToClientWorkspaceStorages(in []serverapi.WorkspaceStorage) []*clientapi.WorkspaceStorage { - res := make([]*clientapi.WorkspaceStorage, 0) - for _, ws := range in { - res = append(res, ToClientWorkspaceStorage(&ws)) - } - return res -} - -func ToClientWorkspaceStorage(in *serverapi.WorkspaceStorage) *clientapi.WorkspaceStorage { - return &clientapi.WorkspaceStorage{ - ID: in.GetId(), - OrganisationID: in.GetOrganisationId(), - Name: in.Name, - Namespace: in.GetNamespace(), - Labels: ServerLabelsToClientKeyValues(in.GetLabels()), - Annotations: ServerAnnotationsToClientKeyValues(in.GetAnnotations()), - Version: in.GetVersion(), - WorkspaceName: in.Spec.WorkspaceName, - CreatedAt: in.GetCreatedAt(), - UpdatedAt: in.GetUpdatedAt(), - Volumes: ToClientVolumes(in.Spec.Volumes), - Status: ToClientWorkspaceStorageStatus(in.GetStatus()), - } -} - -func ToClientWorkspaceStorageStatus(in serverapi.WorkspaceStorageStatus) *clientapi.WorkspaceStorageStatus { - return &clientapi.WorkspaceStorageStatus{ - ObservedVersion: in.GetObservedVersion(), - Conditions: ToClientAPIConditions(in.GetConditions()), - State: string(in.GetState()), - StorageServiceName: in.GetStorageServerServiceName(), - } -} - -func ToClientVolumes(in []serverapi.Volume) []clientapi.Volume { - res := make([]clientapi.Volume, 0) - for _, vol := range in { - res = append(res, ToClientVolume(&vol)) - } - return res -} - -func ToClientVolume(in *serverapi.Volume) clientapi.Volume { - return clientapi.Volume{ - Name: in.Name, - Labels: ServerLabelsToClientKeyValues(in.GetLabels()), - Annotations: ServerAnnotationsToClientKeyValues(in.GetAnnotations()), - Size: in.Spec.GetSize(), - StorageClass: in.Spec.GetStorageClass(), - SyncBeforeUse: in.Spec.GetSyncBeforeUse(), - VolumeSource: ToClientVolumeSource(in.Spec.GetSource()), - Status: ToClientVolumeStatus(in.GetStatus()), - } -} - -func ToClientVolumeStatus(in serverapi.VolumeStatus) *clientapi.WorkspaceVolumeStatus { - return &clientapi.WorkspaceVolumeStatus{ - Conditions: ToClientAPIConditions(in.GetConditions()), - Phase: in.GetPhase(), - BuildArtifactSyncs: ToClientBuildArtifactSyncInfos(in.GetBuildArtifactSyncs()), - } -} - -func ToClientBuildArtifactSyncInfos(in []serverapi.BuildArtifactSyncInfo) []clientapi.BuildArtifactSyncInfo { - res := make([]clientapi.BuildArtifactSyncInfo, 0) - for _, syncInfo := range in { - res = append(res, clientapi.BuildArtifactSyncInfo{ - ResourceName: syncInfo.GetResourceName(), - BuildId: syncInfo.GetBuildId(), - Status: syncInfo.GetStatus(), - }) - } - return res -} - -func ToClientVolumeSource(in serverapi.VolumeSource) *clientapi.VolumeSource { - switch in.SourceType { - case serverapi.LOCAL: - return &clientapi.VolumeSource{ - LocalDir: &clientapi.LocalDir{ - Path: in.LocalSource.GetPath(), - Sync: in.LocalSource.GetSync(), - }, - } - case serverapi.BUILD_ARTIFACT: - return &clientapi.VolumeSource{ - BuildArtifacts: ToClientBuildArtifactSources(in.BuildSource), - } - default: - return &clientapi.VolumeSource{} - } -} - -func ToClientBuildArtifactSources(in []serverapi.BuildArtifact) []*clientapi.BuildArtifactSource { - res := make([]*clientapi.BuildArtifactSource, 0) - for _, artifact := range in { - res = append(res, &clientapi.BuildArtifactSource{ - ResourceName: artifact.GetResourceRef(), - SourcePath: artifact.GetSourcePath(), - DestinationPath: artifact.GetDestinationPath(), - }) - } - return res -} - -func ToClientKeyValue[T KeyValuer](in T) clientapi.KeyValue { - return clientapi.KeyValue{ - Key: in.GetKey(), - Value: in.GetValue(), - } -} - -func ServerLabelsToClientKeyValues(in []serverapi.Label) []clientapi.KeyValue { - res := make([]clientapi.KeyValue, 0) - for _, kv := range in { - res = append(res, ToClientKeyValue(&kv)) - } - return res -} - -func ServerAnnotationsToClientKeyValues(in []serverapi.Annotation) []clientapi.KeyValue { - res := make([]clientapi.KeyValue, 0) - for _, kv := range in { - res = append(res, ToClientKeyValue(&kv)) - } - return res -} diff --git a/pkg/mapper/workspace_user.go b/pkg/mapper/workspace_user.go deleted file mode 100644 index 1233458..0000000 --- a/pkg/mapper/workspace_user.go +++ /dev/null @@ -1,229 +0,0 @@ -package mapper - -import ( - "fmt" - - serverapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" - clientapi "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "k8s.io/utils/ptr" -) - -func ToServerWorkspaceStorage(in *clientapi.UserStack) serverapi.WorkspaceStorage { - return serverapi.WorkspaceStorage{ - Name: in.WorkspaceStorageName(), - Spec: serverapi.WorkspaceStorageSpec{ - WorkspaceName: in.Name, - Volumes: ToServerWorkspaceVolumes(in.Volumes), - }, - } -} - -func ToServerWorkspaceVolumes(in map[string]*clientapi.VolumeSpec) []serverapi.Volume { - res := make([]serverapi.Volume, 0) - for name, spec := range in { - currentVolume := serverapi.Volume{ - Name: name, - Spec: serverapi.WorkspaceVolumeSpec{ - Size: spec.Size, - }, - } - switch { - case spec.Source != nil && spec.Source.LocalDir != nil: - currentVolume.Spec.SyncBeforeUse = ptr.To(true) - currentVolume.Spec.Source = &serverapi.VolumeSource{ - SourceType: serverapi.LOCAL, - LocalSource: &serverapi.LocalSource{ - Path: spec.Source.LocalDir.Path, - Sync: true, - }, - } - case spec.Source != nil && spec.Source.BuildArtifacts != nil: - currentVolume.Spec.SyncBeforeUse = ptr.To(true) - currentVolume.Spec.Source = &serverapi.VolumeSource{ - SourceType: serverapi.BUILD_ARTIFACT, - BuildSource: ToServerBuildArtifactVolumeSources(spec.Source.BuildArtifacts), - } - } - res = append(res, currentVolume) - } - return res -} - -func ToServerBuildArtifactVolumeSources(in []*clientapi.BuildArtifactSource) []serverapi.BuildArtifact { - res := make([]serverapi.BuildArtifact, 0) - for _, artifact := range in { - res = append(res, serverapi.BuildArtifact{ - ResourceRef: artifact.ResourceName, - SourcePath: artifact.SourcePath, - DestinationPath: artifact.DestinationPath, - }) - } - return res -} - -func ToClientAPIUser(in *serverapi.User) *clientapi.User { - return &clientapi.User{ - Id: in.GetId(), - Name: in.GetName(), - Username: in.GetUsername(), - Email: in.GetEmail(), - Organisation: in.GetOrganisation(), - Role: in.GetRole(), - OrganisationID: in.GetOrganisationId(), - } -} - -func ToServerAPIWorkspaceUser(in *clientapi.WorkspaceUser) serverapi.WorkspaceUser { - return serverapi.WorkspaceUser{ - SshPublicKey: in.SshPublicKey, - Workspaces: in.Workspaces, - } -} - -func ToClientAPIWorkspaceUser(in *serverapi.WorkspaceUser) *clientapi.WorkspaceUser { - return &clientapi.WorkspaceUser{ - ID: in.GetId(), - UserID: in.GetUserId(), - OrgID: in.GetOrgId(), - SshPublicKey: in.SshPublicKey, - Workspaces: in.Workspaces, - Version: in.GetVersion(), - State: string(in.GetState()), - Message: in.GetMessage(), - Status: ToClientAPIWorkspaceUserStatus(in.Status), - } -} - -func ToClientAPIWorkspaceUserStatus(in *serverapi.WorkspaceUserStatus) *clientapi.WorkspaceUserStatus { - if in == nil { - return &clientapi.WorkspaceUserStatus{} - } - return &clientapi.WorkspaceUserStatus{ - ObservedVersion: in.GetObservedVersion(), - ProvisionedWorkspaces: ToClientAPIProvisionedWorkspaces(in.GetProvisionedNamespaces()), - ServiceAccountname: in.GetServiceAccountName(), - ServiceAccountToken: in.GetServiceaccountToken(), - ClusterCaCert: in.GetClusterCaCert(), - ClusterUrl: in.GetClusterUrl(), - Conditions: ToClientAPIConditions(in.GetConditions()), - } -} - -func ToClientAPIProvisionedWorkspaces(in []serverapi.WorkspaceUserStatusProvisionedNamespacesInner) []clientapi.ProvisionedWorkspace { - res := make([]clientapi.ProvisionedWorkspace, 0) - for _, ns := range in { - res = append(res, clientapi.ProvisionedWorkspace{ - WorkspaceName: ns.GetWorkspaceName(), - Namespace: ns.GetNamespace(), - }) - } - return res -} - -func ToClientAPIConditions(in []serverapi.Condition) []clientapi.Condition { - res := make([]clientapi.Condition, 0) - for _, cond := range in { - res = append(res, clientapi.Condition{ - Type: cond.GetType(), - Status: cond.GetStatus(), - LastTransitionTime: cond.GetLastTransitionTime(), - Reason: cond.GetReason(), - Message: cond.GetMessage(), - }) - } - return res -} - -func WorkspaceCRName(userName string) string { - return fmt.Sprintf("%s-workspace", userName) -} - -func WorkspaceStorageName(username string) string { - return fmt.Sprintf("%s-storage", username) -} - -// func MapVoyagerFileToWorkspaceCR(in voyagerfile.Workspace, username, namespace, organisation, domain string) *workspacev1alpha1.Workspace { -// resourceSpecList := make([]workspacev1alpha1.ResourceSpec, 0) -// for resourceName, userSpec := range in.Resources { -// currResourceSpec := workspacev1alpha1.ResourceSpec{ -// Name: resourceName, -// Spec: workspacev1alpha1.WorkspaceResourceSpec{ -// ImageRegistry: userSpec.ImageRegistry, -// Command: userSpec.Command, -// Args: userSpec.Args, -// DependsOn: userSpec.DependsOn, -// }, -// } -// if userSpec.Init != nil { -// currResourceSpec.Spec.Init = &workspacev1alpha1.WorkspaceResourceInit{ -// Command: userSpec.Init.Command, -// Args: userSpec.Init.Args, -// } -// } -// if userSpec.Image != nil { -// currResourceSpec.Spec.PrebuiltApplicationSpec = &workspacev1alpha1.PrebuiltApplicationSpec{ -// Image: *userSpec.Image, -// } -// } else { -// currResourceSpec.Spec.ApplicationBuildSpec = &workspacev1alpha1.ApplicationBuildSpec{ -// Context: userSpec.Build.BuildContext, -// VolumeName: userSpec.Build.SourceVolume, -// } -// if userSpec.Build.DockerFilePath != nil { -// currResourceSpec.Spec.ApplicationBuildSpec.DockerFile = *userSpec.Build.DockerFilePath -// } -// } -// currResourceSpec.Spec.EnvironmentVariables = mapEnvs(userSpec.EnvironmentVariables) -// currResourceSpec.Spec.Ports = mapPorts(userSpec.Ports) -// currResourceSpec.Spec.VolumeMounts = mapMounts(userSpec.VolumeMounts) -// resourceSpecList = append(resourceSpecList, currResourceSpec) -// } - -// ws := workspacev1alpha1.Workspace{ -// ObjectMeta: v1.ObjectMeta{ -// Name: WorkspaceCRName(username), -// Namespace: namespace, -// }, -// Spec: workspacev1alpha1.WorkspaceSpec{ -// Resources: resourceSpecList, -// UserName: username, -// Organisation: organisation, -// Domain: domain, -// }, -// } -// return &ws -// } - -// func mapEnvs(in map[string]string) []workspacev1alpha1.EnvironmentVariables { -// res := make([]workspacev1alpha1.EnvironmentVariables, 0) -// for name, value := range in { -// res = append(res, workspacev1alpha1.EnvironmentVariables{ -// Name: name, -// Value: value, -// }) -// } -// return res -// } - -// func mapMounts(in map[string]string) []workspacev1alpha1.VolumeMount { -// res := make([]workspacev1alpha1.VolumeMount, 0) -// for src, dst := range in { -// res = append(res, workspacev1alpha1.VolumeMount{ -// Source: src, -// Destination: dst, -// }) -// } -// return res -// } - -// func mapPorts(in []voyagerfile.Port) []workspacev1alpha1.Port { -// res := make([]workspacev1alpha1.Port, 0) -// for _, portDefn := range in { -// res = append(res, workspacev1alpha1.Port{ -// Number: portDefn.Number, -// IsHttp: portDefn.IsHttp, -// ExposeToPublic: portDefn.ExposeToPublic, -// }) -// } -// return res -// } diff --git a/pkg/process/process.go b/pkg/process/process.go deleted file mode 100644 index 1b8027a..0000000 --- a/pkg/process/process.go +++ /dev/null @@ -1,16 +0,0 @@ -package process - -import ( - "os" - - "github.com/spf13/pflag" -) - -func GetCurrentProcessFlag(flag string) *string { - flagRef := pflag.String(flag, "", "target") - pflag.CommandLine.Parse(os.Args[1:]) - if flagRef != nil && len(*flagRef) != 0 { - return flagRef - } - return nil -} diff --git a/pkg/provider/k8s/kuberenetes_provider.go b/pkg/provider/k8s/kuberenetes_provider.go deleted file mode 100644 index 1af6684..0000000 --- a/pkg/provider/k8s/kuberenetes_provider.go +++ /dev/null @@ -1,321 +0,0 @@ -package k8s - -import ( - "bufio" - "context" - "fmt" - "io" - "net/http" - "os" - "sort" - "sync" - "time" - - "github.com/ashishmax31/voyager-cli/pkg/client" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/provider" - "github.com/sirupsen/logrus" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/tools/portforward" - "k8s.io/client-go/tools/remotecommand" - "k8s.io/client-go/transport/spdy" - "k8s.io/kubectl/pkg/polymorphichelpers" - "k8s.io/kubectl/pkg/scheme" - "k8s.io/kubectl/pkg/util/podutils" - "k8s.io/utils/ptr" -) - -const ( - MaxRetryOnConnectionLoss = 20 -) - -const ( - SERVICE_TARGET = "service" - POD_TARGET = "pod" - STORAGE_POD_SSH_USER = "stackdomeuser" -) - -type portForwardTarget struct { - podName *string - svcName *string - targetType string -} - -func NewServiceTarget(svcName string) provider.Target { - return &portForwardTarget{ - svcName: &svcName, - targetType: SERVICE_TARGET, - } -} - -func NewPodTarget(podName string) provider.Target { - return &portForwardTarget{ - podName: &podName, - targetType: POD_TARGET, - } -} - -func (p *portForwardTarget) TargetName() string { - if p.podName != nil { - return *p.podName - } else { - return *p.svcName - } -} - -func (p *portForwardTarget) TargetType() string { - return p.targetType -} - -type k8sProvider struct { - cfg *config.Config - providerClient *client.ProviderClient -} - -func NewK8sProvider(cfg *config.Config, client *client.ProviderClient) provider.Provider { - return &k8sProvider{ - cfg: cfg, - providerClient: client, - } -} - -func (k *k8sProvider) SetupSSHTunnel(ctx context.Context, localPort int, target provider.Target) (chan struct{}, error) { - logrus.Debug("in setupSSHTunnel") - return k.SetupPortForward(ctx, localPort, 2222, target) -} - -func (k *k8sProvider) SSHUser() string { - return k.cfg.SSHUser() -} - -func (k *k8sProvider) Execute(ctx context.Context, target provider.Target, cmd []string, interactive bool) error { - clientset, err := kubernetes.NewForConfig(k.providerClient.RestConfig) - if err != nil { - return err - } - attachablePod, err := k.attachablePodFromTarget(ctx, clientset, target) - if err != nil { - return err - } - - req := clientset.CoreV1().RESTClient().Post(). - Resource("pods"). - Name(attachablePod.Name). - Namespace(attachablePod.Namespace). - SubResource("exec") - - execOptions := &corev1.PodExecOptions{ - Command: cmd, - Stdout: true, - Stderr: true, - } - - streamOptions := remotecommand.StreamOptions{ - Stdout: os.Stdout, - Stderr: os.Stderr, - } - if interactive { - execOptions.TTY = true - execOptions.Stdin = true - streamOptions.Tty = true - streamOptions.Stdin = os.Stdin - } - - req.VersionedParams(execOptions, scheme.ParameterCodec) - - executor, err := remotecommand.NewSPDYExecutor(k.providerClient.RestConfig, "POST", req.URL()) - if err != nil { - return err - } - return executor.StreamWithContext(ctx, streamOptions) -} - -func (k *k8sProvider) StreamLogs(ctx context.Context, targets []provider.Target, logOptions provider.LogOptions) error { - if len(targets) == 0 { - return nil - } - - clientset, err := kubernetes.NewForConfig(k.providerClient.RestConfig) - if err != nil { - return err - } - - concernedPods := make(map[string]*corev1.Pod) - for _, target := range targets { - attachablePod, err := k.attachablePodFromTarget(ctx, clientset, target) - if err != nil { - return err - } - concernedPods[target.TargetName()] = attachablePod - } - var wg sync.WaitGroup - - wg.Add(len(concernedPods)) - - outputChan := make(chan string) - for resourceName, pod := range concernedPods { - go func(pod *corev1.Pod, resourceName string) { - defer wg.Done() - podLogOptions := &corev1.PodLogOptions{} - if logOptions.Follow { - podLogOptions.Follow = true - } - if logOptions.TailLines != 0 { - podLogOptions.TailLines = ptr.To(logOptions.TailLines) - } - req := clientset.CoreV1().Pods(pod.Namespace).GetLogs(pod.Name, podLogOptions) - podLogs, err := req.Stream(ctx) - if err != nil { - if err == context.Canceled { - return - } - outputChan <- fmt.Sprintf("error fetching logs for resource %s: %v\n", resourceName, err) - return - } - defer podLogs.Close() - - reader := bufio.NewReader(podLogs) - for { - line, err := reader.ReadString('\n') - if err == io.EOF { - break - } - if err == context.Canceled { - return - } - if err != nil { - outputChan <- fmt.Sprintf("error reading logs for resource %s: %v\n", resourceName, err) - break - } - outputChan <- fmt.Sprintf("[%s] %s", resourceName, line) - } - - outputChan <- fmt.Sprintln() - }(pod, resourceName) - } - - go func() { - wg.Wait() - close(outputChan) - }() - - for output := range outputChan { - fmt.Print(output) - } - return nil -} - -func (k *k8sProvider) SetupPortForward(ctx context.Context, localPort int, targetPort int, target provider.Target) (chan struct{}, error) { - stopChan := make(chan struct{}) - portForwardExitChan := make(chan struct{}) - portForwardReadyChan := make(chan struct{}) - go k.runPortforwarder(ctx, localPort, targetPort, target, stopChan, portForwardExitChan, portForwardReadyChan) - go func() { - <-ctx.Done() - close(stopChan) - }() - // Wait for portforward session to complete. - logrus.Debugf("waiting for portforward session to become ready") - // TODO: Use Context with timeout so that we dont wait indefinitely. - <-portForwardReadyChan - return portForwardExitChan, nil -} - -func (k *k8sProvider) runPortforwarder(ctx context.Context, localport int, targetPort int, target provider.Target, stopChan, exitChan, initialReadyChan chan struct{}) { - defer close(exitChan) - defer logrus.Info("portforward session stopped") - var currentReadyChan chan struct{} - currentReadyChan = initialReadyChan - for attempt := 1; attempt <= MaxRetryOnConnectionLoss; attempt++ { - pf, err := k.newPortForwarder(ctx, localport, targetPort, target, stopChan, currentReadyChan) - if err != nil { - logrus.Errorf("failed to create portforwarder: %s", err.Error()) - return - } - if err := pf.ForwardPorts(); err != nil { - if err == portforward.ErrLostConnectionToPod { - logrus.Warnf("lost connection to pod... retrying to establish connection, attempt: %d", attempt) - // Set the current ready chan to a new instance. - currentReadyChan = make(chan struct{}) - continue - } else { - logrus.Errorf("portforward session errored: %s", err.Error()) - return - } - } - } -} - -func (k *k8sProvider) newPortForwarder( - ctx context.Context, - localPort int, - targetPort int, - target provider.Target, - stopChan, readyChan chan struct{}) (*portforward.PortForwarder, error) { - clientSet, err := kubernetes.NewForConfig(k.providerClient.RestConfig) - if err != nil { - return nil, err - } - attachablePod, err := k.attachablePodFromTarget(ctx, clientSet, target) - if err != nil { - return nil, err - } - - req := clientSet.CoreV1().RESTClient().Post().Resource("pods").Namespace(attachablePod.Namespace).Name(attachablePod.Name).SubResource("portforward") - transport, upgrader, err := spdy.RoundTripperFor(k.providerClient.RestConfig) - if err != nil { - return nil, err - } - dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", req.URL()) - ports := []string{fmt.Sprintf("%d:%d", localPort, targetPort)} - return portforward.New(dialer, ports, stopChan, readyChan, os.Stdout, os.Stderr) -} - -func (k *k8sProvider) attachablePodFromTarget(ctx context.Context, client *kubernetes.Clientset, target provider.Target) (*corev1.Pod, error) { - if target.TargetType() == SERVICE_TARGET { - storageSvc := &corev1.Service{} - if err := k.providerClient.Get( - ctx, types.NamespacedName{ - Name: target.TargetName(), - Namespace: k.cfg.CurrentNamespace(), - }, - storageSvc, - ); err != nil { - return nil, err - } - attachablePod, err := attachablePodForObject(client, storageSvc, time.Second*10) - if err != nil { - return nil, err - } - return attachablePod, nil - } - referencedPod := &corev1.Pod{} - if err := k.providerClient.Get( - ctx, types.NamespacedName{ - Name: target.TargetName(), - Namespace: k.cfg.CurrentNamespace(), - }, - referencedPod, - ); err != nil { - return nil, err - } - return referencedPod, nil -} - -func attachablePodForObject(client *kubernetes.Clientset, object runtime.Object, timeout time.Duration) (*corev1.Pod, error) { - switch t := object.(type) { - case *corev1.Pod: - return t, nil - } - namespace, selector, err := polymorphichelpers.SelectorsForObject(object) - if err != nil { - return nil, fmt.Errorf("cannot attach to %T: %v", object, err) - } - sortBy := func(pods []*corev1.Pod) sort.Interface { return sort.Reverse(podutils.ActivePods(pods)) } - pod, _, err := polymorphichelpers.GetFirstPod(client.CoreV1(), namespace, selector.String(), timeout, sortBy) - return pod, err -} diff --git a/pkg/provider/types.go b/pkg/provider/types.go deleted file mode 100644 index 0026c8f..0000000 --- a/pkg/provider/types.go +++ /dev/null @@ -1,26 +0,0 @@ -package provider - -import ( - "context" -) - -type Provider interface { - StorageSSHhandler - Execute(ctx context.Context, target Target, cmd []string, interactive bool) error - StreamLogs(ctx context.Context, targets []Target, options LogOptions) error -} - -type Target interface { - TargetName() string - TargetType() string -} - -type LogOptions struct { - Follow bool - TailLines int64 -} - -type StorageSSHhandler interface { - SetupSSHTunnel(ctx context.Context, localPort int, target Target) (chan struct{}, error) - SSHUser() string -} diff --git a/pkg/services/errors.go b/pkg/services/errors.go deleted file mode 100644 index 6f1f285..0000000 --- a/pkg/services/errors.go +++ /dev/null @@ -1,47 +0,0 @@ -package services - -import "fmt" - -type ServiceError struct { - Message string - OriginalError error - Code int -} - -func (e *ServiceError) Error() string { - if code := e.Code; code != 0 { - return fmt.Sprintf("%s: %s (http error code: %d)", e.Message, e.OriginalError.Error(), code) - } - return fmt.Sprintf("%s: %s", e.Message, e.OriginalError.Error()) -} - -func NewServiceError(err error) *ServiceError { - return &ServiceError{ - Message: "service error", - OriginalError: err, - } -} - -func NewServiceErrorWithCode(err error, code int) *ServiceError { - return &ServiceError{ - Message: "service error", - OriginalError: err, - Code: code, - } -} - -// With message and code -func NewServiceErrorWithMessageAndCode(err error, message string, code int) *ServiceError { - return &ServiceError{ - Message: message, - OriginalError: err, - Code: code, - } -} - -func NewServiceErrorWithMessage(err error, message string) *ServiceError { - return &ServiceError{ - Message: message, - OriginalError: err, - } -} diff --git a/pkg/services/workspace_initialization_service.go b/pkg/services/workspace_initialization_service.go deleted file mode 100644 index e5738cc..0000000 --- a/pkg/services/workspace_initialization_service.go +++ /dev/null @@ -1,153 +0,0 @@ -package services - -import ( - "context" - "fmt" - "net/http" - "time" - - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/session" - "github.com/ashishmax31/voyager-cli/pkg/tools" - "k8s.io/apimachinery/pkg/util/wait" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" -) - -type WorkspaceInitializationService interface { - InitializeWorkspace(ctx context.Context, workspaceName string) *ServiceError -} - -type workspaceInitializationService struct { - runtime *config.Runtime -} - -func NewWorkspaceInitializationService(runtime *config.Runtime) WorkspaceInitializationService { - return &workspaceInitializationService{ - runtime: runtime, - } -} - -func (s *workspaceInitializationService) InitializeWorkspace(ctx context.Context, workspaceName string) *ServiceError { - publicKeyPath, privateKeyPath, err := tools.EnsureSSHKeyPair(s.runtime.ConfigDir) - if err != nil { - return NewServiceError(err) - } - - s.runtime.Config().SetUserPrivateKeyPublicKeyPath(privateKeyPath, publicKeyPath) - if err := s.runtime.SaveConfig(); err != nil { - return NewServiceError(err) - } - - userPublicKey, err := tools.ReadFile(publicKeyPath) - if err != nil { - return NewServiceError(err) - } - - // We need to merge the current workspace name with the ones already present in the config file. - desiredWorkspaceUser := &v1alpha1.WorkspaceUser{ - SshPublicKey: userPublicKey, - Workspaces: []string{workspaceName}, - } - - err = s.ensureWorkspaceUser(ctx, desiredWorkspaceUser, workspaceName) - if err != nil { - return NewServiceErrorWithMessage(err, "failed to initialize workspace") - } - - workspaceUser, pErr := s.waitForWorkspaceUserToBeAvailable(ctx) - if pErr != nil { - return NewServiceErrorWithMessage(pErr, "failed to initialize workspace") - } - - if err := s.runtime.Config().PersistProviderConfig(workspaceUser.Status); err != nil { - return NewServiceErrorWithMessage(err, "failed to initialize workspace") - } - - if err := s.runtime.Config().PersistCurrentWorkspace(workspaceName); err != nil { - return NewServiceErrorWithMessage(err, "failed to initialize workspace") - } - - return nil -} - -func (s *workspaceInitializationService) ensureWorkspaceUser(ctx context.Context, workspaceUser *v1alpha1.WorkspaceUser, currentWorkspaceName string) error { - currentWorkspaceUser, err := s.runtime.Session.GetCurrentWorskpaceUser(ctx) - if err != nil { - if err.HttpCode == http.StatusNotFound { - return s.handleWorkspaceUserCreation(ctx, workspaceUser, currentWorkspaceName) - } - return fmt.Errorf("failed to get current user's workspace user. Error: %w", err) - } - - if currentWorkspaceUser.Status.ContainsWorkspace(currentWorkspaceName) && - currentWorkspaceUser.IsAvailable() && currentWorkspaceUser.SshPublicKey == workspaceUser.SshPublicKey { - return nil - } - - return s.handleWorkspaceUserUpdate(ctx, workspaceUser, currentWorkspaceUser) -} - -func (s *workspaceInitializationService) handleWorkspaceUserCreation(ctx context.Context, desiredWorkspaceUser *v1alpha1.WorkspaceUser, currentWorkspace string) error { - var workspacesInConfig []string - for _, workspace := range s.runtime.Config().Workspaces { - if workspace.WorkspaceName != currentWorkspace { - workspacesInConfig = append(workspacesInConfig, workspace.WorkspaceName) - } - } - desiredWorkspaceUser.Workspaces = append(desiredWorkspaceUser.Workspaces, workspacesInConfig...) - _, err := s.runtime.Session.CreateWorkspaceUser(ctx, desiredWorkspaceUser) - if err != nil { - return fmt.Errorf("failed to create workspace user. Error: %w", err) - } - return nil -} - -func (s *workspaceInitializationService) handleWorkspaceUserUpdate(ctx context.Context, desiredWorkspaceUser *v1alpha1.WorkspaceUser, currentWorkspaceUser *v1alpha1.WorkspaceUser) error { - allWorkspaces := make(map[string]struct{}) - - for _, workspace := range s.runtime.Config().Workspaces { - allWorkspaces[workspace.WorkspaceName] = struct{}{} - } - - for _, workspace := range currentWorkspaceUser.Workspaces { - allWorkspaces[workspace] = struct{}{} - } - - for _, workspace := range desiredWorkspaceUser.Workspaces { - allWorkspaces[workspace] = struct{}{} - } - - desiredWorkspaces := make([]string, 0) - for workspace := range allWorkspaces { - desiredWorkspaces = append(desiredWorkspaces, workspace) - } - desiredWorkspaceUser.Workspaces = desiredWorkspaces - - _, err := s.runtime.Session.UpdateWorkspaceUser(ctx, currentWorkspaceUser.ID, desiredWorkspaceUser) - if err != nil { - return fmt.Errorf("failed to update workspace user. Error: %w", err) - } - return nil -} - -func (s *workspaceInitializationService) waitForWorkspaceUserToBeAvailable(ctx context.Context) (*v1alpha1.WorkspaceUser, error) { - var currentUser *v1alpha1.WorkspaceUser - var perr *session.SessionError - - pollErr := wait.PollUntilContextTimeout(ctx, time.Second*5, time.Minute*2, true, func(ctx context.Context) (done bool, err error) { - currentUser, perr = s.runtime.Session.GetCurrentWorskpaceUser(ctx) - if perr != nil { - return false, fmt.Errorf("failed to get current user's workspaceuser. Error: %w", perr) - } - if currentUser.Status.IsAvailable() { - return true, nil - } - return false, nil - }) - - if pollErr != nil { - return nil, fmt.Errorf("error when waiting for workspaceuser to become ready: %w", pollErr) - } - return currentUser, nil -} diff --git a/pkg/services/workspace_service.go b/pkg/services/workspace_service.go deleted file mode 100644 index 50be316..0000000 --- a/pkg/services/workspace_service.go +++ /dev/null @@ -1,252 +0,0 @@ -package services - -import ( - "context" - "fmt" - "net/http" - "time" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/mapper" - "github.com/ashishmax31/voyager-cli/pkg/session" - "github.com/ashishmax31/voyager-cli/pkg/tools" - "k8s.io/apimachinery/pkg/api/equality" - "k8s.io/utils/ptr" -) - -type WorkspaceService interface { - CreateWorkspace(ctx context.Context, stack *v1alpha1.UserStack) (*v1alpha1.Workspace, *ServiceError) - GetCurrentWorkspaces(ctx context.Context) ([]*v1alpha1.Workspace, *ServiceError) - GetWorkspace(ctx context.Context, id string) (*v1alpha1.Workspace, *ServiceError) - GetWorkspaceByName(ctx context.Context, name string) (*v1alpha1.Workspace, *ServiceError) - UpdateWorkspace(ctx context.Context, ID string, workspace *v1alpha1.UserStack) (*v1alpha1.Workspace, *ServiceError) - DeleteWorkspace(ctx context.Context, ID string) *ServiceError - ListWorkspaceResources(ctx context.Context, workspaceID string) ([]v1alpha1.WorkspaceResource, *ServiceError) - TriggerBuildForAllResources(ctx context.Context, workspace *v1alpha1.Workspace) *ServiceError - TriggerBuildForResource(ctx context.Context, workspace *v1alpha1.Workspace, resourceName string) *ServiceError - ListWorkspaceBuilds(ctx context.Context, workspace *v1alpha1.Workspace) ([]v1alpha1.ResourceBuild, *ServiceError) - ListWorkspaceResourceBuilds(ctx context.Context, workspace *v1alpha1.Workspace, resource *v1alpha1.WorkspaceResource) ([]v1alpha1.ResourceBuild, *ServiceError) - RestartAllResources(ctx context.Context, workspace *v1alpha1.Workspace) *ServiceError - RestartResource(ctx context.Context, workspace *v1alpha1.Workspace, resourceName string) *ServiceError -} - -type workspaceService struct { - session session.Session -} - -type WorkspaceServiceSpec struct { - Session session.Session -} - -func NewWorkspaceService(spec WorkspaceServiceSpec) WorkspaceService { - return &workspaceService{ - session: spec.Session, - } -} - -func (w *workspaceService) CreateWorkspace(ctx context.Context, stack *v1alpha1.UserStack) (*v1alpha1.Workspace, *ServiceError) { - desiredWorkspace := mapper.WorkspaceFromUserStack(stack) - // TODO: Compute context directory hash using some logic in the filesystem - // like last modified time of the files in the directory. - populateBuildContextHash(&desiredWorkspace) - - createdWorkpace, serr := w.session.CreateWorkspace(ctx, &desiredWorkspace) - if serr != nil { - return nil, NewServiceError(serr) - } - return createdWorkpace, nil -} - -func (w *workspaceService) DeleteWorkspace(ctx context.Context, ID string) *ServiceError { - err := w.session.DeleteWorkspace(ctx, ID) - if err != nil { - return NewServiceError(err) - } - return nil -} - -func (w *workspaceService) GetCurrentWorkspaces(ctx context.Context) ([]*v1alpha1.Workspace, *ServiceError) { - workspaces, serr := w.session.GetCurrentWorkspaces(ctx) - if serr != nil { - return nil, NewServiceError(serr) - } - return workspaces, nil -} - -func (w *workspaceService) ListWorkspaceResources(ctx context.Context, workspaceID string) ([]v1alpha1.WorkspaceResource, *ServiceError) { - resources, serr := w.session.GetWorkspaceResources(ctx, workspaceID) - if serr != nil { - return nil, NewServiceError(serr) - } - return resources, nil -} - -func (w *workspaceService) ListWorkspaceBuilds(ctx context.Context, workspace *v1alpha1.Workspace) ([]v1alpha1.ResourceBuild, *ServiceError) { - builds, serr := w.session.GetWorkspaceBuilds(ctx, workspace) - if serr != nil { - return nil, NewServiceError(serr) - } - for i := range builds { - builds[i].WorkspaceName = workspace.Name - resource := workspace.GetResourceByName(builds[i].WorkspaceResourceName) - if resource != nil && resource.BuildConfig != nil && resource.BuildConfig.ContextDirHash == builds[i].SourceHash { - builds[i].Current = true - } - } - return builds, nil -} - -func (w *workspaceService) ListWorkspaceResourceBuilds(ctx context.Context, workspace *v1alpha1.Workspace, resource *v1alpha1.WorkspaceResource) ([]v1alpha1.ResourceBuild, *ServiceError) { - builds, serr := w.session.GetWorkspaceResourceBuilds(ctx, workspace, resource.Name) - if serr != nil { - return nil, NewServiceError(serr) - } - for i := range builds { - builds[i].WorkspaceName = workspace.Name - if resource.BuildConfig != nil && resource.BuildConfig.ContextDirHash == builds[i].SourceHash { - builds[i].Current = true - } - } - return builds, nil -} - -func (w *workspaceService) GetWorkspace(ctx context.Context, id string) (*v1alpha1.Workspace, *ServiceError) { - workspace, serr := w.session.GetWorkspace(ctx, id) - if serr != nil { - return nil, NewServiceErrorWithCode(serr, serr.HttpCode) - } - resources, rErr := w.session.GetWorkspaceResources(ctx, workspace.ID) - if rErr != nil { - return nil, NewServiceError(rErr) - } - workspace.Resources = resources - return workspace, nil -} - -func (w *workspaceService) GetWorkspaceByName(ctx context.Context, name string) (*v1alpha1.Workspace, *ServiceError) { - workspaces, serr := w.session.GetCurrentWorkspaces(ctx) - if serr != nil { - return nil, NewServiceError(serr) - } - for _, ws := range workspaces { - if ws.Name == name { - resources, rErr := w.session.GetWorkspaceResources(ctx, ws.ID) - if rErr != nil { - return nil, NewServiceError(rErr) - } - ws.Resources = resources - return ws, nil - } - } - return nil, NewServiceErrorWithCode(fmt.Errorf("workspace '%s' not found", name), http.StatusNotFound) -} - -func (w *workspaceService) UpdateWorkspace(ctx context.Context, ID string, stack *v1alpha1.UserStack) (*v1alpha1.Workspace, *ServiceError) { - desiredWorkspace := mapper.WorkspaceFromUserStack(stack) - existingWorkspace, serr := w.GetWorkspace(ctx, ID) - if serr != nil { - return nil, NewServiceErrorWithCode(serr, serr.Code) - } - if !equality.Semantic.DeepDerivative(desiredWorkspace, existingWorkspace) { - copyBuildSourceHash(existingWorkspace, &desiredWorkspace) - updatedWorkspace, updateErr := w.session.UpdateWorkspace(ctx, existingWorkspace.ID, &desiredWorkspace) - if updateErr != nil { - return nil, NewServiceError(updateErr) - } - return updatedWorkspace, nil - } - return existingWorkspace, nil -} - -func (w *workspaceService) TriggerBuildForAllResources(ctx context.Context, existingWorkspace *v1alpha1.Workspace) *ServiceError { - for _, resource := range existingWorkspace.Resources { - if resource.BuildConfig != nil { - resource.BuildConfig.ContextDirHash = tools.GenRandomHash() - } - } - _, err := w.session.UpdateWorkspace(ctx, existingWorkspace.ID, existingWorkspace) - if err != nil { - return NewServiceError(err) - } - return nil -} - -func (w *workspaceService) TriggerBuildForResource(ctx context.Context, existingWorkspace *v1alpha1.Workspace, resourceName string) *ServiceError { - for i := range existingWorkspace.Resources { - currResource := &existingWorkspace.Resources[i] - if currResource.Name == resourceName && currResource.BuildConfig == nil { - return NewServiceError(fmt.Errorf("resource '%s' does not have a build config", resourceName)) - } - if currResource.Name == resourceName && currResource.BuildConfig != nil { - currResource.BuildConfig.ContextDirHash = tools.GenRandomHash() - _, err := w.session.UpdateWorkspace(ctx, existingWorkspace.ID, existingWorkspace) - if err != nil { - return NewServiceError(err) - } - return nil - } - } - return NewServiceError(fmt.Errorf("resource '%s' not found in workspace", resourceName)) -} - -func (w *workspaceService) RestartAllResources(ctx context.Context, existingWorkspace *v1alpha1.Workspace) *ServiceError { - for i := range existingWorkspace.Resources { - currResource := &existingWorkspace.Resources[i] - currResource.LifecycleConfig = &v1alpha1.LifecycleConfig{ - RestartRequestTime: ptr.To(time.Now().UTC().Round(time.Second)), - } - } - _, err := w.session.UpdateWorkspace(ctx, existingWorkspace.ID, existingWorkspace) - if err != nil { - return NewServiceError(err) - } - return nil -} - -func (w *workspaceService) RestartResource(ctx context.Context, existingWorkspace *v1alpha1.Workspace, resourceName string) *ServiceError { - for i := range existingWorkspace.Resources { - currResource := &existingWorkspace.Resources[i] - if currResource.Name == resourceName { - currResource.LifecycleConfig = &v1alpha1.LifecycleConfig{ - RestartRequestTime: ptr.To(time.Now().UTC().Round(time.Second)), - } - _, err := w.session.UpdateWorkspace(ctx, existingWorkspace.ID, existingWorkspace) - if err != nil { - return NewServiceError(err) - } - return nil - } - } - return NewServiceError(fmt.Errorf("resource '%s' not found in workspace", resourceName)) -} - -func copyBuildSourceHash(existingWS, desiredWS *v1alpha1.Workspace) { - currentBuildHashMap := make(map[string]string) - - for i := range existingWS.Resources { - currResource := &existingWS.Resources[i] - if currResource.BuildConfig != nil { - currentBuildHashMap[currResource.Name] = currResource.BuildConfig.ContextDirHash - } - } - for i := range desiredWS.Resources { - currResource := &desiredWS.Resources[i] - if currResource.BuildConfig != nil { - if _, found := currentBuildHashMap[currResource.Name]; found { - currResource.BuildConfig.ContextDirHash = currentBuildHashMap[currResource.Name] - } else { - // This is a new resource, so we need to set a new build hash. - currResource.BuildConfig.ContextDirHash = tools.GenRandomHash() - } - } - } -} - -func populateBuildContextHash(workspace *v1alpha1.Workspace) { - for i := range workspace.Resources { - currResource := &workspace.Resources[i] - if currResource.BuildConfig != nil { - currResource.BuildConfig.ContextDirHash = tools.GenRandomHash() - } - } -} diff --git a/pkg/services/workspace_storage_service.go b/pkg/services/workspace_storage_service.go deleted file mode 100644 index 089087d..0000000 --- a/pkg/services/workspace_storage_service.go +++ /dev/null @@ -1,114 +0,0 @@ -package services - -import ( - "context" - "fmt" - "net/http" - "time" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/session" - "k8s.io/apimachinery/pkg/util/wait" -) - -type WorkspaceStorageService interface { - GetCurrentWorkspaceStorage(ctx context.Context, currentWorkspaceStorageName string) (*v1alpha1.WorkspaceStorage, *ServiceError) - GetCurrentWorkspaceStorages(ctx context.Context) ([]*v1alpha1.WorkspaceStorage, *ServiceError) - CreateWorkspaceStorage(ctx context.Context, stack *v1alpha1.UserStack) (*v1alpha1.WorkspaceStorage, *ServiceError) - UpdateWorkspaceStorage(ctx context.Context, id string, workspace *v1alpha1.UserStack) (*v1alpha1.WorkspaceStorage, *ServiceError) - DeleteWorkspaceStorage(ctx context.Context, id string) *ServiceError - WaitForCurrentWorkspaceStorageToBeAvailable(ctx context.Context, currentWorkspaceStorageName string) (*v1alpha1.WorkspaceStorage, *ServiceError) - MarkCurrentWorkspaceAsSynced(ctx context.Context, currentWorkspaceStorageName string, volumeID string) *ServiceError -} - -type workspaceStorageService struct { - session session.Session -} - -type WorkspaceStorageServiceSpec struct { - Session session.Session -} - -func NewWorkspaceStorageService(spec WorkspaceStorageServiceSpec) WorkspaceStorageService { - return &workspaceStorageService{ - session: spec.Session, - } -} - -func (w *workspaceStorageService) GetCurrentWorkspaceStorage(ctx context.Context, currentWorkspaceStorageName string) (*v1alpha1.WorkspaceStorage, *ServiceError) { - workspaceStorages, err := w.session.GetCurrentUserWorkspaceStorages(ctx) - if err != nil { - return nil, NewServiceError(err) - } - - for _, ws := range workspaceStorages { - if ws.Name == currentWorkspaceStorageName { - return ws, nil - } - } - return nil, NewServiceErrorWithCode(fmt.Errorf("workspace storage not found"), http.StatusNotFound) -} - -func (w *workspaceStorageService) GetCurrentWorkspaceStorages(ctx context.Context) ([]*v1alpha1.WorkspaceStorage, *ServiceError) { - workspaceStorages, err := w.session.GetCurrentUserWorkspaceStorages(ctx) - if err != nil { - return nil, NewServiceError(err) - } - return workspaceStorages, nil -} - -func (w *workspaceStorageService) CreateWorkspaceStorage(ctx context.Context, stack *v1alpha1.UserStack) (*v1alpha1.WorkspaceStorage, *ServiceError) { - storage, err := w.session.CreateWorkspaceStorage(ctx, stack) - if err != nil { - return nil, NewServiceError(err) - } - return storage, nil -} - -// TODO: Only update if the workspace storage has changed. -func (w *workspaceStorageService) UpdateWorkspaceStorage(ctx context.Context, id string, stack *v1alpha1.UserStack) (*v1alpha1.WorkspaceStorage, *ServiceError) { - storage, err := w.session.UpdateWorkspaceStorage(ctx, id, stack) - if err != nil { - return nil, NewServiceError(err) - } - return storage, nil -} - -func (w *workspaceStorageService) DeleteWorkspaceStorage(ctx context.Context, id string) *ServiceError { - if err := w.session.DeleteWorkspaceStorage(ctx, id); err != nil { - return NewServiceError(err) - } - return nil -} - -func (w *workspaceStorageService) MarkCurrentWorkspaceAsSynced(ctx context.Context, currentWorkspaceStorageName string, volumeID string) *ServiceError { - workspaceStorage, serr := w.GetCurrentWorkspaceStorage(ctx, currentWorkspaceStorageName) - if serr != nil { - return serr - } - - if err := w.session.MarkAsSynced(ctx, workspaceStorage.ID, volumeID); err != nil { - return NewServiceError(err) - } - return nil -} - -func (w *workspaceStorageService) WaitForCurrentWorkspaceStorageToBeAvailable(ctx context.Context, currentWorkspaceStorageName string) (*v1alpha1.WorkspaceStorage, *ServiceError) { - var currentWorkspaceStorage *v1alpha1.WorkspaceStorage - var serr *ServiceError - pollErr := wait.PollUntilContextTimeout(ctx, time.Second*5, time.Minute*5, true, func(ctx context.Context) (done bool, err error) { - currentWorkspaceStorage, serr = w.GetCurrentWorkspaceStorage(ctx, currentWorkspaceStorageName) - if serr != nil { - return false, fmt.Errorf("failed to get workspace storage: %w", serr) - } - if currentWorkspaceStorage.IsAvailable() { - return true, nil - } - fmt.Println("Waiting for workspace storage to be ready...") - return false, nil - }) - if pollErr != nil { - return nil, NewServiceError(pollErr) - } - return currentWorkspaceStorage, nil -} diff --git a/pkg/session/session.go b/pkg/session/session.go deleted file mode 100644 index ba92c93..0000000 --- a/pkg/session/session.go +++ /dev/null @@ -1,258 +0,0 @@ -package session - -import ( - "context" - "fmt" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/client" - k8sclient "sigs.k8s.io/controller-runtime/pkg/client" -) - -type config interface { - GetServerURL() string - GetAccessToken() string - GetInsecure() bool - GetOrganisationID() string - ProviderCACert() string - ProviderServerURL() string - ProviderToken() string - Valid() bool - SSHUser() string -} - -type SessionError struct { - HttpCode int - err error - Message string -} - -func (e *SessionError) Error() string { - return fmt.Sprintf("session error: %s", e.err.Error()) -} - -func ToSessionErr(in *client.StackdomeAPIError) *SessionError { - if in == nil { - return nil - } - return &SessionError{ - HttpCode: in.HttpCode, - err: in, - Message: in.Message, - } -} - -func NewSessionError(err error) *SessionError { - return &SessionError{ - err: err, - } -} - -type session struct { - stackdomeClient client.StackdomeAPIClient - providerClient *client.ProviderClient -} - -type Session interface { - // GetWorkspaceVolumesFromProvider(ctx context.Context, wstorage *workspacev1alpha1.WorkspaceStorage) ([]workspacev1alpha1.WorkspaceVolume, error) - GetCurrentUserWorkspaceStorages(ctx context.Context) ([]*v1alpha1.WorkspaceStorage, *SessionError) - GetWorkspaceStorage(ctx context.Context, id string) (*v1alpha1.WorkspaceStorage, *SessionError) - UpdateWorkspaceStorage(ctx context.Context, ID string, userStack *v1alpha1.UserStack) (*v1alpha1.WorkspaceStorage, *SessionError) - DeleteWorkspaceStorage(ctx context.Context, ID string) *SessionError - CreateWorkspaceStorage(ctx context.Context, userStack *v1alpha1.UserStack) (*v1alpha1.WorkspaceStorage, *SessionError) - MarkAsSynced(ctx context.Context, storageID string, volumeID string) *SessionError - GetCurrentWorskpaceUser(ctx context.Context) (*v1alpha1.WorkspaceUser, *SessionError) - CreateWorkspaceUser(ctx context.Context, desiredWorkspaceUser *v1alpha1.WorkspaceUser) (*v1alpha1.WorkspaceUser, *SessionError) - UpdateWorkspaceUser(ctx context.Context, ID string, workspaceUser *v1alpha1.WorkspaceUser) (*v1alpha1.WorkspaceUser, *SessionError) - - CreateWorkspace(ctx context.Context, workspace *v1alpha1.Workspace) (*v1alpha1.Workspace, *SessionError) - GetWorkspace(ctx context.Context, id string) (*v1alpha1.Workspace, *SessionError) - GetWorkspaceResources(ctx context.Context, id string) ([]v1alpha1.WorkspaceResource, *SessionError) - UpdateWorkspace(ctx context.Context, ID string, workspace *v1alpha1.Workspace) (*v1alpha1.Workspace, *SessionError) - DeleteWorkspace(ctx context.Context, ID string) *SessionError - GetCurrentWorkspaces(ctx context.Context) ([]*v1alpha1.Workspace, *SessionError) - GetWorkspaceBuilds(ctx context.Context, workspace *v1alpha1.Workspace) ([]v1alpha1.ResourceBuild, *SessionError) - GetWorkspaceResourceBuilds(ctx context.Context, workspace *v1alpha1.Workspace, resourceName string) ([]v1alpha1.ResourceBuild, *SessionError) - ProviderClient() *client.ProviderClient -} - -func NewSession(config config, withProvider bool) (Session, error) { - if config.GetOrganisationID() == "" { - return nil, fmt.Errorf("organisation id missing, run 'stackdome login' ") - } - if withProvider { - if !config.Valid() { - return nil, fmt.Errorf("stackdome configfile is invalid! Create a workspace environment first by running 'voyager create-workspace ...'") - } - providerClient, err := client.NewProviderClient(config) - if err != nil { - return nil, fmt.Errorf("failed to create provider client: %w", err) - } - return &session{ - stackdomeClient: client.NewStackdomeClient(config), - providerClient: providerClient, - }, nil - } - return &session{ - stackdomeClient: client.NewStackdomeClient(config), - }, nil -} - -func (s *session) ProviderClient() *client.ProviderClient { - return s.providerClient -} - -func (s *session) CreateWorkspace(ctx context.Context, workspace *v1alpha1.Workspace) (*v1alpha1.Workspace, *SessionError) { - createdWorkspace, err := s.stackdomeClient.CreateWorkspace(ctx, workspace) - if err != nil { - return nil, ToSessionErr(err) - } - return createdWorkspace, nil -} - -func (s *session) GetWorkspace(ctx context.Context, id string) (*v1alpha1.Workspace, *SessionError) { - workspace, err := s.stackdomeClient.GetWorkspace(ctx, id) - if err != nil { - return nil, ToSessionErr(err) - } - return workspace, nil -} - -func (s *session) GetWorkspaceResources(ctx context.Context, id string) ([]v1alpha1.WorkspaceResource, *SessionError) { - workspaceResources, serr := s.stackdomeClient.GetWorkspaceResources(ctx, id) - if serr != nil { - return nil, ToSessionErr(serr) - } - return workspaceResources, nil -} - -func (s *session) GetWorkspaceBuilds(ctx context.Context, workspace *v1alpha1.Workspace) ([]v1alpha1.ResourceBuild, *SessionError) { - builds, serr := s.stackdomeClient.GetWorkspaceBuilds(ctx, workspace.ID) - if serr != nil { - return nil, ToSessionErr(serr) - } - for i := range builds { - builds[i].WorkspaceName = workspace.Name - } - return builds, nil -} - -func (s *session) GetWorkspaceResourceBuilds(ctx context.Context, workspace *v1alpha1.Workspace, resourceName string) ([]v1alpha1.ResourceBuild, *SessionError) { - builds, serr := s.stackdomeClient.GetWorkspaceResourceBuilds(ctx, workspace.ID, resourceName) - if serr != nil { - return nil, ToSessionErr(serr) - } - return builds, nil -} - -func (s *session) UpdateWorkspace(ctx context.Context, ID string, workspace *v1alpha1.Workspace) (*v1alpha1.Workspace, *SessionError) { - updatedWorkspace, err := s.stackdomeClient.UpdateWorkspace(ctx, ID, workspace) - if err != nil { - return nil, ToSessionErr(err) - } - return updatedWorkspace, nil -} - -func (s *session) DeleteWorkspace(ctx context.Context, ID string) *SessionError { - err := s.stackdomeClient.DeleteWorkspace(ctx, ID) - if err != nil { - return ToSessionErr(err) - } - return nil -} - -func (s *session) GetCurrentWorkspaces(ctx context.Context) ([]*v1alpha1.Workspace, *SessionError) { - workspaces, err := s.stackdomeClient.GetCurrentWorkspaces(ctx) - if err != nil { - return nil, ToSessionErr(err) - } - return workspaces, nil -} - -func (s *session) GetCurrentWorskpaceUser(ctx context.Context) (*v1alpha1.WorkspaceUser, *SessionError) { - user, err := s.stackdomeClient.GetCurrentUserWorskpaceUser(ctx) - if err != nil { - return nil, ToSessionErr(err) - } - return user, nil -} - -func (s *session) GetWorkspaceStorage(ctx context.Context, id string) (*v1alpha1.WorkspaceStorage, *SessionError) { - storage, err := s.stackdomeClient.GetWorkspaceStorage(ctx, id) - if err != nil { - return nil, ToSessionErr(err) - } - return storage, nil -} - -func (s *session) UpdateWorkspaceStorage(ctx context.Context, ID string, userStack *v1alpha1.UserStack) (*v1alpha1.WorkspaceStorage, *SessionError) { - storage, err := s.stackdomeClient.UpdateWorkspaceStorage(ctx, ID, userStack) - if err != nil { - return nil, ToSessionErr(err) - } - return storage, nil -} - -func (s *session) DeleteWorkspaceStorage(ctx context.Context, ID string) *SessionError { - err := s.stackdomeClient.DeleteWorkspaceStorage(ctx, ID) - if err != nil { - return ToSessionErr(err) - } - return nil -} - -func (s *session) MarkAsSynced(ctx context.Context, storageID string, volumeID string) *SessionError { - err := s.stackdomeClient.MarkVolumeAsSynced(ctx, storageID, volumeID) - if err != nil { - return ToSessionErr(err) - } - return nil -} - -func (s *session) CreateWorkspaceUser(ctx context.Context, desiredWorkspaceUser *v1alpha1.WorkspaceUser) (*v1alpha1.WorkspaceUser, *SessionError) { - user, err := s.stackdomeClient.CreateWorkspaceUser(ctx, desiredWorkspaceUser) - if err != nil { - return nil, ToSessionErr(err) - } - return user, nil -} - -func (s *session) UpdateWorkspaceUser(ctx context.Context, ID string, workspaceUser *v1alpha1.WorkspaceUser) (*v1alpha1.WorkspaceUser, *SessionError) { - user, err := s.stackdomeClient.UpdateWorkspaceUser(ctx, ID, workspaceUser) - if err != nil { - return nil, ToSessionErr(err) - } - return user, nil -} - -func (s *session) GetCurrentUserWorkspaceStorages(ctx context.Context) ([]*v1alpha1.WorkspaceStorage, *SessionError) { - storages, err := s.stackdomeClient.GetCurrentUserWorkspaceStorages(ctx) - if err != nil { - return nil, ToSessionErr(err) - } - return storages, nil -} - -func (s *session) CreateWorkspaceStorage(ctx context.Context, userStack *v1alpha1.UserStack) (*v1alpha1.WorkspaceStorage, *SessionError) { - storage, err := s.stackdomeClient.CreateWorkspaceStorage(ctx, userStack) - if err != nil { - return nil, ToSessionErr(err) - } - return storage, nil -} - -func (s *session) CreateResourceInProvider(ctx context.Context, obj k8sclient.Object, opts ...k8sclient.CreateOption) error { - return s.providerClient.Create(ctx, obj, opts...) -} - -func (s *session) GetResourceFromProvider(ctx context.Context, key k8sclient.ObjectKey, obj k8sclient.Object, opts ...k8sclient.GetOption) error { - return s.providerClient.Get(ctx, key, obj, opts...) -} - -func (s *session) UpdateResourceInProvider(ctx context.Context, obj k8sclient.Object, opts ...k8sclient.UpdateOption) error { - return s.providerClient.Update(ctx, obj, opts...) -} - -func (s *session) DeleteResourceInProvider(ctx context.Context, obj k8sclient.Object, opts ...k8sclient.DeleteOption) error { - return s.providerClient.Delete(ctx, obj, opts...) -} diff --git a/pkg/sync/mutagen_syncer.go b/pkg/sync/mutagen_syncer.go deleted file mode 100644 index 8c9a0be..0000000 --- a/pkg/sync/mutagen_syncer.go +++ /dev/null @@ -1,293 +0,0 @@ -package sync - -import ( - "context" - "fmt" - "os" - "os/exec" - "os/user" - "path/filepath" - - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/provider" - "github.com/ashishmax31/voyager-cli/pkg/tools" - "github.com/fsnotify/fsnotify" - "github.com/gofrs/flock" - "github.com/sirupsen/logrus" -) - -const ( - LOCAL_PORT_FOR_SSH_TUNNEL = 17892 - MUTAGEN_VERSION = "v0.18.0" -) - -type SourceDestintionPair struct { - Source string - Destination string -} - -type SourceDestintionList []SourceDestintionPair - -type mutagenSync struct { - cfg *config.Config - lockPath string - syncRunningFlag tools.FileFlag - lockDir string - runningFlagPath string - mutgenBinaryDir string - sshHandler provider.StorageSSHhandler -} - -func NewMutagenSyncer(cfg *config.Config, lockDir string, mutgenBinaryDir string, dstSSHhandler provider.StorageSSHhandler) Syncer { - w := &mutagenSync{ - cfg: cfg, - lockDir: lockDir, - mutgenBinaryDir: mutgenBinaryDir, - sshHandler: dstSSHhandler, - syncRunningFlag: tools.NewFileFlag(filepath.Join(lockDir, "voyager-sync-running.flag")), - } - - w.lockPath = filepath.Join(lockDir, "voyager-daemon.lock") - w.runningFlagPath = filepath.Join(lockDir, "voyager-sync-running.flag") - return w -} - -func (m *mutagenSync) Initialized(context.Context) (bool, error) { - lock := flock.New(m.lockPath) - locked, err := lock.TryLock() - if err != nil { - return false, fmt.Errorf("failed to acquire file lock: %w", err) - } - defer lock.Close() - return !locked, nil -} - -func (m *mutagenSync) SyncSessionRunning(context.Context) (bool, error) { - return m.syncRunningFlag.Raised() -} - -func (m *mutagenSync) SyncSessionRunningFlagPath() string { - return m.runningFlagPath -} - -func (m *mutagenSync) ForceSync(context.Context) error { - logrus.Debug("in mutagen sync") - syncProcess := exec.Command(m.mutagenBinaryPath(), "sync", "flush", "--all") - syncProcess.Stdout = os.Stdout - syncProcess.Stderr = os.Stderr - // Wait for the forked process to complete. - if err := syncProcess.Run(); err != nil { - return fmt.Errorf("error when flushing mutagen sync sessions: %w", err) - } - return nil -} - -func (m *mutagenSync) Status(context.Context) error { - syncProcess := exec.Command(m.mutagenBinaryPath(), "sync", "list") - syncProcess.Stdout = os.Stdout - syncProcess.Stderr = os.Stderr - // Wait for the forked process to complete. - if err := syncProcess.Run(); err != nil { - return fmt.Errorf("error when checking sync status: %w", err) - } - return nil -} - -func (m *mutagenSync) StopSyncSession(context.Context) error { - if err := os.Remove(m.lockPath); err != nil { - if pathError, ok := err.(*os.PathError); ok && os.IsNotExist(pathError.Err) { - return nil - } - return err - } - return nil -} - -func (m *mutagenSync) SetupSyncSession(ctx context.Context, spec SourceDestintionList, target provider.Target) error { - lock := flock.New(m.lockPath) - locked, err := lock.TryLock() - if err != nil { - return fmt.Errorf("failed to acquire file lock: %w", err) - } - if !locked { - logrus.Debug("not locked! Some other instance already running") - return nil - } - defer lock.Close() - logrus.Debug("in mutagen SetupSyncSession") - - if err := m.ensureMutagenBinary(); err != nil { - return err - } - - ctx, cancelFn := context.WithCancel(ctx) - defer cancelFn() - sshTunnelExitChan, err := m.sshHandler.SetupSSHTunnel(ctx, LOCAL_PORT_FOR_SSH_TUNNEL, target) - if err != nil { - return err - } - - if err := m.ensureSSHConfig(); err != nil { - return err - } - - if err := m.cleanupMutagenDaemons(); err != nil { - return fmt.Errorf("failed to cleanup mutagen sync sessions: %w", err) - } - - // Start sync sessions for SourceDestintionList. - for _, srcDestPair := range spec { - err := m.createMutgenSync(ctx, srcDestPair.Source, srcDestPair.Destination) - if err != nil { - return err - } - } - defer m.cleanupMutagenDaemons() - - watcher := tools.NewFileSystemWatcher(m.lockDir, - tools.WithOperationFilter(fsnotify.Remove), tools.WithFileWatches(m.lockPath)) - err = watcher.StartWatch() - if err != nil { - return err - } - defer watcher.Stop() - - // Raise the sync running flag for other processes to know that the sync is running. - if err := m.syncRunningFlag.Set(); err != nil { - return fmt.Errorf("failed to raise the sync running flag: %w", err) - } - // Cleanup when exiting. - defer m.syncRunningFlag.UnSet() - - // We wait for either the context to be cancelled or the lockfile to be deleted - // to stop all the daemons and exit. - logrus.Info("watching for lock file to be deleted") - select { - case <-watcher.NotifyChan(): - logrus.Info("lock file deleted, stopping all daemons") - return nil - case <-ctx.Done(): - logrus.Info("context canceled, stopping all daemons") - return nil - case <-sshTunnelExitChan: - logrus.Info("ssh tunnel exited, stopping all daemons") - return nil - } -} - -func (m *mutagenSync) cleanupMutagenDaemons() error { - if err := m.stopAllMutgenSyncSessions(); err != nil { - return err - } - if err := m.stopMutagenDaemon(); err != nil { - return err - } - return nil -} - -func (m *mutagenSync) createMutgenSync(ctx context.Context, srcPath string, dstPath string) error { - user := m.sshHandler.SSHUser() - alpha := srcPath - beta := fmt.Sprintf("%s@localhost:%d:%s", user, LOCAL_PORT_FOR_SSH_TUNNEL, dstPath) - daemonProcess := exec.Command(m.mutagenBinaryPath(), "sync", "create", alpha, beta, "-m", "two-way-resolved") - daemonProcess.Stdout = os.Stdout - daemonProcess.Stderr = os.Stderr - // Wait for the forked process to complete. - if err := daemonProcess.Run(); err != nil { - return fmt.Errorf("error when creating mutagen sync sessions: %w", err) - } - return nil -} - -func (m *mutagenSync) stopAllMutgenSyncSessions() error { - cleanupProcess := exec.Command(m.mutagenBinaryPath(), "sync", "terminate", "--all") - cleanupProcess.Stdout = os.Stdout - cleanupProcess.Stderr = os.Stderr - // Wait for the forked process to complete. - if err := cleanupProcess.Run(); err != nil { - return fmt.Errorf("error when removing mutagen sync sessions: %w", err) - } - return nil -} - -func (m *mutagenSync) stopMutagenDaemon() error { - stopDaemonProcess := exec.Command(m.mutagenBinaryPath(), "daemon", "stop") - stopDaemonProcess.Stdout = os.Stdout - stopDaemonProcess.Stderr = os.Stderr - // Wait for the forked process to complete. - if err := stopDaemonProcess.Run(); err != nil { - return fmt.Errorf("error when stopping mutagen daemon: %w", err) - } - return nil -} - -func (m *mutagenSync) mutagenBinaryPath() string { - return filepath.Join(m.mutgenBinaryDir, "mutagen") -} - -func (m *mutagenSync) ensureMutagenBinary() error { - if _, err := os.Stat(m.mutagenBinaryPath()); err != nil { - if os.IsNotExist(err) { - return tools.DownloadMutagenBinary(m.mutgenBinaryDir, MUTAGEN_VERSION) - } - return err - } - return nil -} - -func (m *mutagenSync) ensureSSHConfig() error { - sshConfigPath, err := ensureSSHConfigFileExists() - if err != nil { - return fmt.Errorf("failed to create ssh config file: %w", err) - } - - voyagerConfigDir, err := m.cfg.ConfigDir() - if err != nil { - return err - } - if err := tools.EnsureVoyagerSshConfig(sshConfigPath, voyagerConfigDir, &tools.SSHConfig{ - Port: LOCAL_PORT_FOR_SSH_TUNNEL, - User: m.sshHandler.SSHUser(), - IdentityFilePath: m.cfg.UserPrivateKeyPath, - }); err != nil { - return err - } - return nil -} - -func ensureSSHConfigFileExists() (string, error) { - // Get the current user - currentUser, err := user.Current() - if err != nil { - return "", err - } - - // Construct the SSH directory path - sshDirPath := filepath.Join(currentUser.HomeDir, ".ssh") - - // Create the SSH directory if it doesn't exist - if _, err := os.Stat(sshDirPath); os.IsNotExist(err) { - err = os.Mkdir(sshDirPath, 0700) - if err != nil { - return "", err - } - } - - // Construct the SSH config file path - sshConfigPath := filepath.Join(sshDirPath, "config") - - // Check if the SSH config file exists - _, err = os.Stat(sshConfigPath) - if os.IsNotExist(err) { - // Create the SSH config file if it doesn't exist - file, err := os.Create(sshConfigPath) - if err != nil { - return "", err - } - file.Close() - } else if err != nil { - return "", err - } - - return sshConfigPath, nil -} diff --git a/pkg/sync/types.go b/pkg/sync/types.go deleted file mode 100644 index 1034a03..0000000 --- a/pkg/sync/types.go +++ /dev/null @@ -1,17 +0,0 @@ -package sync - -import ( - "context" - - "github.com/ashishmax31/voyager-cli/pkg/provider" -) - -type Syncer interface { - SetupSyncSession(context.Context, SourceDestintionList, provider.Target) error - StopSyncSession(context.Context) error - ForceSync(context.Context) error - Initialized(context.Context) (bool, error) - SyncSessionRunning(context.Context) (bool, error) - SyncSessionRunningFlagPath() string - Status(context.Context) error -} diff --git a/pkg/tools/dir_hash.go b/pkg/tools/dir_hash.go deleted file mode 100644 index c9d6d03..0000000 --- a/pkg/tools/dir_hash.go +++ /dev/null @@ -1,34 +0,0 @@ -package tools - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" -) - -func ComputeDirHash(dir string, volumeName string) string { - return GenRandomHash() -} - -func GenRandomHash() string { - randomBytes := make([]byte, 16) - _, err := rand.Read(randomBytes) - if err != nil { - return "" - } - - // Create a new SHA-256 hash - hash := sha256.New() - - // Write the random bytes to the hash - _, err = hash.Write(randomBytes[0:6]) - if err != nil { - return "" - } - - // Get the hash sum - hashSum := hash.Sum(nil) - - // Convert the hash sum to a hexadecimal string representation - return hex.EncodeToString(hashSum) -} diff --git a/pkg/tools/downloader.go b/pkg/tools/downloader.go deleted file mode 100644 index d4689d9..0000000 --- a/pkg/tools/downloader.go +++ /dev/null @@ -1,32 +0,0 @@ -package tools - -import ( - "fmt" - "runtime" - - getter "github.com/hashicorp/go-getter" - "github.com/sirupsen/logrus" -) - -func DownloadFile(url string, targetPath string) error { - logrus.Debugf("downloading binary from url: %s", url) - err := getter.GetAny(targetPath, url) - if err != nil { - return err - } - return nil -} - -func DownloadMutagenBinary(targetDirectory string, version string) error { - arch := runtime.GOARCH - os := runtime.GOOS - - url := fmt.Sprintf( - "https://github.com/mutagen-io/mutagen/releases/download/%s/mutagen_%s_%s_%s.tar.gz", - version, - os, - arch, - version, - ) - return DownloadFile(url, targetDirectory) -} diff --git a/pkg/tools/file.go b/pkg/tools/file.go deleted file mode 100644 index aae22c0..0000000 --- a/pkg/tools/file.go +++ /dev/null @@ -1,36 +0,0 @@ -package tools - -import ( - "fmt" - "os" - - "gopkg.in/yaml.v2" -) - -// Read the content of a file and return it as a string. - -// ReadFile reads the content of a file and returns it as a string. -func ReadFile(filePath string) (string, error) { - res, err := os.ReadFile(filePath) - if err != nil { - return "", err - } - return string(res), nil -} - -func UnmarshalYamlFile[T any](filePath string, toType *T) error { - res, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("error reading file: %v", err) - } - err = yaml.Unmarshal(res, toType) - if err != nil { - return fmt.Errorf("error unmarshalling yaml: %v", err) - } - return nil -} - -func FileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} diff --git a/pkg/tools/file_flag.go b/pkg/tools/file_flag.go deleted file mode 100644 index 141ced9..0000000 --- a/pkg/tools/file_flag.go +++ /dev/null @@ -1,47 +0,0 @@ -package tools - -import ( - "errors" - "os" -) - -type FileFlag interface { - Set() error - UnSet() error - Raised() (bool, error) -} - -type fileFlag struct { - path string -} - -func NewFileFlag(filePath string) FileFlag { - return &fileFlag{ - path: filePath, - } -} - -func (f *fileFlag) Set() error { - _, err := os.Create(f.path) - return err -} - -func (f *fileFlag) UnSet() error { - err := os.Remove(f.path) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil -} - -func (f *fileFlag) Raised() (bool, error) { - _, err := os.Stat(f.path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - return false, err - } - - return true, nil -} diff --git a/pkg/tools/filewatcher.go b/pkg/tools/filewatcher.go deleted file mode 100644 index f464f79..0000000 --- a/pkg/tools/filewatcher.go +++ /dev/null @@ -1,107 +0,0 @@ -package tools - -import ( - "sync/atomic" - - "github.com/fsnotify/fsnotify" - "github.com/sirupsen/logrus" -) - -type FileSystemWatcher interface { - NotifyChan() chan struct{} - Stop() - StartWatch() error - Exited() bool -} - -type fileSystemWatcher struct { - cfg *WatcherConfig - watchDir string - notifyChan chan struct{} - stopChan chan struct{} - exited *atomic.Bool -} - -type WatcherConfig struct { - operation fsnotify.Op - fileName string -} - -type WatchOption func(*WatcherConfig) - -func WithFileWatches(file string) WatchOption { - return func(wc *WatcherConfig) { - wc.fileName = file - } -} - -func WithOperationFilter(op fsnotify.Op) WatchOption { - return func(wc *WatcherConfig) { - wc.operation = op - } -} - -func NewFileSystemWatcher(watchDirPath string, opts ...WatchOption) FileSystemWatcher { - w := &fileSystemWatcher{ - cfg: &WatcherConfig{}, - watchDir: watchDirPath, - notifyChan: make(chan struct{}), - stopChan: make(chan struct{}), - exited: new(atomic.Bool), - } - - for _, opt := range opts { - opt(w.cfg) - } - return w -} - -func (w *fileSystemWatcher) StartWatch() error { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return err - } - err = watcher.Add(w.watchDir) - if err != nil { - return err - } - - go func() { - defer func() { - w.exited.Store(true) - close(w.notifyChan) - watcher.Close() - }() - for { - select { - case event := <-watcher.Events: - logrus.Debugf("event: %+v: filtering for: %s \n", event, w.cfg.fileName) - if w.matchesFilter(event) { - return - } - case err := <-watcher.Errors: - logrus.Errorf("watch error: %s", err.Error()) - case <-w.stopChan: - return - } - } - }() - return nil -} - -func (w *fileSystemWatcher) Stop() { - close(w.stopChan) -} - -func (w *fileSystemWatcher) Exited() bool { - return w.exited.Load() -} - -func (w *fileSystemWatcher) NotifyChan() chan struct{} { - return w.notifyChan -} - -func (w *fileSystemWatcher) matchesFilter(event fsnotify.Event) bool { - return event.Op&w.cfg.operation == w.cfg.operation && - w.cfg.fileName == event.Name -} diff --git a/pkg/tools/ssh.go b/pkg/tools/ssh.go deleted file mode 100644 index 8b61638..0000000 --- a/pkg/tools/ssh.go +++ /dev/null @@ -1,159 +0,0 @@ -package tools - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/base64" - "encoding/pem" - "fmt" - "os" - "path/filepath" - "strings" - "text/template" - - "github.com/sirupsen/logrus" - - "golang.org/x/crypto/ssh" -) - -type SSHConfig struct { - Port int - User string - IdentityFilePath string -} - -const sshConfigTemplate = `# AUTO-GENERATED BY VOYAGER. DO NOT EDIT. -Host localhost - StrictHostKeyChecking no - HostName localhost - Port {{.Port}} - User {{.User}} - IdentityFile {{.IdentityFilePath}} -` - -func writeVoyagerSSHConfig(voyagerConfigPath string, config *SSHConfig) (string, error) { - sshDirPath := filepath.Join(voyagerConfigPath, "ssh") - if err := os.MkdirAll(sshDirPath, 0700); err != nil { - return "", fmt.Errorf("failed to create SSH directory: %v", err) - } - - voyagerSshConfigPath := filepath.Join(sshDirPath, "config") - file, err := os.OpenFile(voyagerSshConfigPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return "", fmt.Errorf("failed to open SSH config file: %v", err) - } - defer file.Close() - - tmpl, err := template.New("ssh_config").Parse(sshConfigTemplate) - if err != nil { - return "", fmt.Errorf("failed to parse SSH config template: %v", err) - } - err = tmpl.Execute(file, config) - if err != nil { - return "", fmt.Errorf("failed to write SSH config: %v", err) - } - println(voyagerSshConfigPath) - return voyagerSshConfigPath, nil -} - -func EnsureVoyagerSshConfig(sshConfigPath, voyagerConfigPath string, config *SSHConfig) error { - voyagerSshConfigPath, err := writeVoyagerSSHConfig(voyagerConfigPath, config) - if err != nil { - return err - } - desiredString := fmt.Sprintf("Include %s", voyagerSshConfigPath) - content, err := os.ReadFile(sshConfigPath) - if err != nil { - if os.IsNotExist(err) { - err = os.WriteFile(sshConfigPath, []byte(desiredString+"\n\n"), 0644) - if err != nil { - return fmt.Errorf("failed to create ssh config file: %v", err) - } - return nil - } - return fmt.Errorf("failed to read ssh config file: %v", err) - } - - lines := strings.Split(string(content), "\n") - - // Check if the first line matches the desired string - if len(lines) > 0 && lines[0] == desiredString { - return nil - } - - // If the desired string doesn't exist at the top, add it - lines = append([]string{desiredString}, lines...) - - // Join the lines back into a single string - newConfigContent := strings.Join(lines, "\n") - - // Write the updated content back to the file - err = os.WriteFile(sshConfigPath, []byte(newConfigContent), 0644) - if err != nil { - return fmt.Errorf("failed to update ssh config file: %v", err) - } - return nil -} - -func EnsureSSHKeyPair(directory string) (publicKeyPath string, privateKeyPath string, err error) { - // Check if the directory exists, create it if necessary - err = os.MkdirAll(directory, 0700) - if err != nil { - return "", "", fmt.Errorf("failed to create directory: %v", err) - } - - // Set the file paths for the private and public keys - privateKeyPath = filepath.Join(directory, "id_rsa") - publicKeyPath = filepath.Join(directory, "id_rsa.pub") - - // Check if the private key already exists - if _, err = os.Stat(privateKeyPath); err == nil { - logrus.Debugf("SSH key pair already exists at %s\n", directory) - return publicKeyPath, privateKeyPath, nil - } - - // Generate a new private key - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return "", "", fmt.Errorf("failed to generate private key: %v", err) - } - - // Encode the private key in PEM format - privateKeyPEM := &pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(privateKey), - } - - // Write the private key to a file with secure permissions - err = os.WriteFile(privateKeyPath, pem.EncodeToMemory(privateKeyPEM), 0600) - if err != nil { - return "", "", fmt.Errorf("failed to write private key file: %v", err) - } - - // Generate the public key - publicKey, err := ssh.NewPublicKey(&privateKey.PublicKey) - if err != nil { - return "", "", fmt.Errorf("failed to generate public key: %v", err) - } - - // Encode the public key in the authorized_keys format - publicKeyBytes := ssh.MarshalAuthorizedKey(publicKey) - - // Write the public key to a file with secure permissions - err = os.WriteFile(publicKeyPath, publicKeyBytes, 0644) - if err != nil { - return "", "", fmt.Errorf("failed to write public key file: %v", err) - } - - logrus.Debugf("SSH key pair generated successfully at %s\n", directory) - return publicKeyPath, privateKeyPath, nil -} - -func Base64EncodedFile(filePath string) (string, error) { - fileBytes, err := os.ReadFile(filePath) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(fileBytes), nil -} diff --git a/pkg/validation/userstack_validation.go b/pkg/validation/userstack_validation.go deleted file mode 100644 index b4af1b8..0000000 --- a/pkg/validation/userstack_validation.go +++ /dev/null @@ -1,57 +0,0 @@ -package validation - -import ( - "fmt" - "slices" - "strings" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/tools" - - "github.com/go-playground/validator" -) - -func Validate(voyagerFilePath string) error { - var workspace v1alpha1.UserStack - - if err := tools.UnmarshalYamlFile(voyagerFilePath, &workspace); err != nil { - return fmt.Errorf("error reading YAML file: %v", err) - } - - validate := validator.New() - definedVolumeNames := []string{} - for volumeName := range workspace.Volumes { - definedVolumeNames = append(definedVolumeNames, volumeName) - } - - for resource, resourceSpec := range workspace.Resources { - resourceSpec := *resourceSpec - if err := validate.Struct(resourceSpec); err != nil { - return fmt.Errorf("error validating YAML file, resource '%s': %v", resource, err) - } - // Validate build spec - if resourceSpec.Build != nil && !slices.Contains(definedVolumeNames, resourceSpec.Build.SourceVolume) { - return fmt.Errorf("'%s'resource build references a volume which is not defined", resource) - } - // Validate volume mounts - for source := range resourceSpec.VolumeMounts { - sourcePath := strings.Split(source, "/") - if len(sourcePath) < 1 { - return fmt.Errorf("empty volume mount for resource: '%s'", resource) - } - // first in this list is the volume name. - - currentVolumeName := sourcePath[0] - if !slices.Contains(definedVolumeNames, currentVolumeName) { - return fmt.Errorf("'%s' resource mount references a volume '%s' which is not defined", resource, currentVolumeName) - } - } - for _, envFile := range resourceSpec.EnvFiles { - if !tools.FileExists(envFile) { - return fmt.Errorf("cant read env file for resource '%s' at: '%s'", resource, envFile) - } - } - } - - return nil -} diff --git a/pkg/workspace/build_handler.go b/pkg/workspace/build_handler.go deleted file mode 100644 index db3a26d..0000000 --- a/pkg/workspace/build_handler.go +++ /dev/null @@ -1,53 +0,0 @@ -package workspace - -import ( - "context" - "fmt" - - "github.com/ashishmax31/voyager-cli/pkg/config" -) - -func (w *workspaceHandler) Build(ctx context.Context, runtime *config.Runtime) error { - currentWorkspaceName := runtime.Config().CurrentWorkspace - if currentWorkspaceName == nil { - return fmt.Errorf("current workspace not set") - } - - initialized, err := w.syncHandler.Initialized(ctx) - if err != nil { - return workspaceHandlerErr("failed to check sync session status: %w", err) - } - if initialized { - if err := w.Sync(ctx); err != nil { - return err - } - - if runtime.Args.IsAllResources() { - fmt.Println("triggering a new build for all resources...") - } else { - fmt.Printf("triggering a new build for '%s' resource...\n", runtime.Args.GetResourceName()) - } - - currentWorkspace, err := w.workspaceService.GetWorkspaceByName( - ctx, - *currentWorkspaceName, - ) - if err != nil { - return workspaceHandlerErr("failed to get current workspace with name '%s': %w", *currentWorkspaceName, err) - } - - if runtime.Args.IsAllResources() { - if err := w.workspaceService.TriggerBuildForAllResources(ctx, currentWorkspace); err != nil { - return workspaceHandlerErr("failed to trigger build for all resources: %w", err) - } - fmt.Println("build triggered successfully") - return nil - } - if err := w.workspaceService.TriggerBuildForResource(ctx, currentWorkspace, runtime.Args.GetResourceName()); err != nil { - return workspaceHandlerErr("failed to trigger build for resource '%s': %w", runtime.Args.GetResourceName(), err) - } - fmt.Printf("build triggered successfully for resource '%s'\n", runtime.Args.GetResourceName()) - return nil - } - return fmt.Errorf("sync session not running! Please run voyager sync init") -} diff --git a/pkg/workspace/delete_handler.go b/pkg/workspace/delete_handler.go deleted file mode 100644 index a52e034..0000000 --- a/pkg/workspace/delete_handler.go +++ /dev/null @@ -1,110 +0,0 @@ -package workspace - -import ( - "context" - "net/http" - - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/sirupsen/logrus" -) - -func (w *workspaceHandler) Delete(ctx context.Context, runtime *config.Runtime) error { - if runtime.Args.IsAllWorkspaces() { - return w.deleteAllWorkspaces(ctx, runtime) - } - - var workspaceName string - if runtime.Args.IsCurrentWorkspace() { - workspaceNamePtr := runtime.Config().CurrentWorkspace - if workspaceNamePtr == nil { - return workspaceHandlerErr("current workspace not set") - } - workspaceName = *workspaceNamePtr - } else { - workspaceName = runtime.Args.GetWorkspaceName() - } - return w.deleteWorkspace(ctx, runtime, workspaceName) -} - -func (w *workspaceHandler) deleteAllWorkspaces(ctx context.Context, runtime *config.Runtime) error { - workspaces, err := w.workspaceService.GetCurrentWorkspaces(ctx) - if err != nil { - return workspaceHandlerErr("failed to get workspaces: %w", err) - } - - for _, workspace := range workspaces { - if err := w.workspaceService.DeleteWorkspace(ctx, workspace.ID); err != nil { - if err.Code == http.StatusNotFound { - logrus.Infof("workspace '%s' not found", workspace.Name) - continue - } - return workspaceHandlerErr("failed to delete workspace '%s': %w", workspace.Name, err) - } - } - - if err := w.syncHandler.StopSyncSession(ctx); err != nil { - return workspaceHandlerErr("failed to stop sync session: %w", err) - } - - if runtime.Args.IsRemoveStorage() { - workspaceStorages, err := w.workspaceStorageService.GetCurrentWorkspaceStorages(ctx) - if err != nil { - return workspaceHandlerErr("failed to get workspace storages: %w", err) - } - for _, workspaceStorage := range workspaceStorages { - if err := w.workspaceStorageService.DeleteWorkspaceStorage(ctx, workspaceStorage.ID); err != nil { - if err.Code == http.StatusNotFound { - logrus.Infof("workspace storage '%s' not found", workspaceStorage.Name) - continue - } - return workspaceHandlerErr("failed to delete workspace storage '%s': %w", workspaceStorage.Name, err) - } - } - } - return nil -} - -func (w *workspaceHandler) deleteWorkspace(ctx context.Context, runtime *config.Runtime, workspaceName string) error { - var alreadyDeleted bool - workspace, err := w.workspaceService.GetWorkspaceByName(ctx, workspaceName) - if err != nil { - if err.Code == http.StatusNotFound { - logrus.Infof("workspace '%s' not found. It may have already been deleted", workspaceName) - alreadyDeleted = true - } else { - return workspaceHandlerErr("failed to get workspace '%s': %w", workspaceName, err) - } - } - - if !alreadyDeleted { - if err := w.workspaceService.DeleteWorkspace(ctx, workspace.ID); err != nil { - return workspaceHandlerErr("failed to delete workspace '%s': %w", workspaceName, err) - } - } - - if err := w.syncHandler.StopSyncSession(ctx); err != nil { - return workspaceHandlerErr("failed to stop sync session: %w", err) - } - - if runtime.Args.IsRemoveStorage() { - workspaceStorages, err := w.workspaceStorageService.GetCurrentWorkspaceStorages(ctx) - if err != nil { - return workspaceHandlerErr("failed to get workspace storage '%s': %w", workspaceName, err) - } - - for _, workspaceStorage := range workspaceStorages { - if workspaceStorage.WorkspaceName == workspace.Name { - if err := w.workspaceStorageService.DeleteWorkspaceStorage(ctx, workspaceStorage.ID); err != nil { - if err.Code == http.StatusNotFound { - logrus.Infof("workspace storage for workspace '%s' not found", workspaceName) - return nil - } - return workspaceHandlerErr("failed to delete workspace storage '%s': %w", workspaceName, err) - } - return nil - } - } - return nil - } - return nil -} diff --git a/pkg/workspace/deploy_handler.go b/pkg/workspace/deploy_handler.go deleted file mode 100644 index 8af8a9e..0000000 --- a/pkg/workspace/deploy_handler.go +++ /dev/null @@ -1,160 +0,0 @@ -package workspace - -import ( - "context" - "fmt" - "net/http" - "os" - "os/exec" - - "github.com/ashishmax31/voyager-cli/cmd/common" - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/tools" - "github.com/fsnotify/fsnotify" -) - -func (w *workspaceHandler) Deploy(ctx context.Context, runtime *config.Runtime) error { - stack, err := runtime.UserStack() - if err != nil { - return fmt.Errorf("failed to get user stack: %w", err) - } - - currentWorkspaceName := w.runtime.Config().CurrentWorkspace - if currentWorkspaceName == nil { - return fmt.Errorf("current workspace not set") - } - - if stack.HasVolumes() { - currentWorkspaceStorage, err := w.reconcileWorskpaceStorage(ctx, stack.WorkspaceStorageName(), stack) - if err != nil { - return workspaceHandlerErr("failed to reconcile workspace storage: %w", err) - } - if _, waitErr := w.workspaceStorageService.WaitForCurrentWorkspaceStorageToBeAvailable(ctx, currentWorkspaceStorage.Name); waitErr != nil { - return workspaceHandlerErr("workspace storage not available: %w", waitErr) - } - } - - if stack.HasSyncingVolumes() { - // ensure syncing process is running, if not start a new sync process. - running, err := w.syncHandler.SyncSessionRunning(ctx) - if err != nil { - return workspaceHandlerErr("failed to check sync session status: %w", err) - } - if !running { - fmt.Println("stack has local syncing type volumes..initializing sync session...") - if err := w.initializeSyncSession(ctx, runtime); err != nil { - return err - } - } - } - - if err := w.Sync(ctx); err != nil { - return err - } - - fmt.Println("Deploying stack...") - - if err := stack.ReadEnvFiles(); err != nil { - return workspaceHandlerErr("failed to read env files: %w", err) - } - - existingWorkspace, serr := w.workspaceService.GetWorkspaceByName(ctx, *currentWorkspaceName) - if serr != nil { - if serr.Code == http.StatusNotFound { - fmt.Println("No existing workspace found. Creating a new one...") - _, err := w.workspaceService.CreateWorkspace(ctx, stack) - if err != nil { - return err - } - fmt.Println("Workspace deployed successfully..") - return nil - } - return serr - } - _, updateErr := w.workspaceService.UpdateWorkspace(ctx, existingWorkspace.ID, stack) - if updateErr != nil { - return fmt.Errorf("failed to update workspace: %w", updateErr) - } - fmt.Println("Workspace deployed successfully..") - return nil -} - -func (w *workspaceHandler) reconcileWorskpaceStorage(ctx context.Context, currentWorkspaceStorageName string, stack *v1alpha1.UserStack) (*v1alpha1.WorkspaceStorage, error) { - existingWorkspaceStorage, serr := w.workspaceStorageService.GetCurrentWorkspaceStorage(ctx, currentWorkspaceStorageName) - if serr != nil { - if serr.Code == http.StatusNotFound { - fmt.Println("No existing workspace storage found. Creating a new one...") - createdWorkspaceStorage, err := w.workspaceStorageService.CreateWorkspaceStorage(ctx, stack) - if err != nil { - return nil, err - } - return createdWorkspaceStorage, nil - } - return nil, serr - } - existingWorkspaceStorage, err := w.workspaceStorageService.UpdateWorkspaceStorage(ctx, existingWorkspaceStorage.ID, stack) - if err != nil { - return nil, fmt.Errorf("failed to update workspace storage: %w", err) - } - - return existingWorkspaceStorage, nil -} - -func (w *workspaceHandler) initializeSyncSession(ctx context.Context, runtime *config.Runtime) error { - configDir := w.runtime.ConfigDir - - executablePath, err := os.Executable() - if err != nil { - return err - } - - // TODO: Move this to runtime. - cmdArgs := []string{"voyager", "sync-session", "start"} - voyagerFileFlagValue := runtime.Args.GetStackFilePath() - cmdArgs = append(cmdArgs, fmt.Sprintf("--%s=%s", common.VoyagerFilePathFlag, voyagerFileFlagValue)) - - logFile, err := w.runtime.CreateLogFile("sync-session-logs") - if err != nil { - return workspaceHandlerErr("error creating/opening sync-session-logs file: %w", err) - } - defer logFile.Close() - - syncSessionStartedWatcher := tools.NewFileSystemWatcher( - configDir, - tools.WithOperationFilter(fsnotify.Create), - tools.WithFileWatches(w.syncHandler.SyncSessionRunningFlagPath()), - ) - if err := syncSessionStartedWatcher.StartWatch(); err != nil { - return err - } - - syncProcess := &exec.Cmd{ - Path: executablePath, - Args: cmdArgs, - Stdout: logFile, - Stderr: logFile, - } - // Wait for the forked process to complete. - if err := syncProcess.Start(); err != nil { - return workspaceHandlerErr("error when starting voyager init subcommand: %w", err) - } - - if started, _ := w.syncHandler.SyncSessionRunning(ctx); started { - fmt.Println("Sync session already runnning.") - return nil - } - - syncSessionProcessExitChan := make(chan error, 1) - defer syncSessionStartedWatcher.Stop() - go func() { - syncSessionProcessExitChan <- syncProcess.Wait() - }() - select { - case err := <-syncSessionProcessExitChan: - return err - case <-syncSessionStartedWatcher.NotifyChan(): - fmt.Println("Sync session started.") - } - return nil -} diff --git a/pkg/workspace/execute_handler.go b/pkg/workspace/execute_handler.go deleted file mode 100644 index 87b19f0..0000000 --- a/pkg/workspace/execute_handler.go +++ /dev/null @@ -1,55 +0,0 @@ -package workspace - -import ( - "context" - "fmt" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/provider/k8s" -) - -func (w *workspaceHandler) Execute(ctx context.Context, runtime *config.Runtime) error { - currentWorkspaceName := runtime.Config().CurrentWorkspace - if currentWorkspaceName == nil { - workspaceHandlerErr("current workspace not set") - } - targetResourceName := runtime.Args.GetResourceName() - if targetResourceName == "" { - workspaceHandlerErr("resource name not specified") - } - - executeCmd := runtime.Args.ExecuteCmd - if len(executeCmd) == 0 { - workspaceHandlerErr("no command specified") - } - - currentWorkspace, serr := w.workspaceService.GetWorkspaceByName(ctx, *currentWorkspaceName) - if serr != nil { - return workspaceHandlerErr("failed to get current workspace '%s': %w", *currentWorkspaceName, serr) - } - - targetResource, err := findWorkspaceResource(currentWorkspace, targetResourceName) - if err != nil { - return workspaceHandlerErr("failed to find resource '%s' in workspace '%s': %w", targetResourceName, *currentWorkspaceName, err) - } - - if !targetResource.IsAvailable() { - return workspaceHandlerErr("resource '%s' is not yet available", targetResourceName) - } - - if err := w.provider.Execute(ctx, - k8s.NewServiceTarget(*targetResource.Status.InternalServiceName), runtime.Args.ExecuteCmd, runtime.Args.IsInteractive()); err != nil { - return workspaceHandlerErr("failed to execute command on resource '%s': %w", targetResourceName, err) - } - return nil -} - -func findWorkspaceResource(workspace *v1alpha1.Workspace, resourceName string) (*v1alpha1.WorkspaceResource, error) { - for _, resource := range workspace.Resources { - if resource.Name == resourceName { - return &resource, nil - } - } - return nil, fmt.Errorf("resource '%s' not found in workspace '%s'", resourceName, workspace.Name) -} diff --git a/pkg/workspace/list_handler.go b/pkg/workspace/list_handler.go deleted file mode 100644 index 7768f7e..0000000 --- a/pkg/workspace/list_handler.go +++ /dev/null @@ -1,63 +0,0 @@ -package workspace - -import ( - "context" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/config" -) - -func (h *workspaceHandler) ListWorkspaces(ctx context.Context, runtime *config.Runtime) ([]*v1alpha1.Workspace, error) { - workspaces, err := h.workspaceService.GetCurrentWorkspaces(ctx) - if err != nil { - return nil, workspaceHandlerErr("failed to list workspaces: %w", err) - } - return workspaces, nil -} - -func (h *workspaceHandler) ListWorkspaceStorages(ctx context.Context, runtime *config.Runtime) ([]*v1alpha1.WorkspaceStorage, error) { - workspaces, err := h.workspaceStorageService.GetCurrentWorkspaceStorages(ctx) - if err != nil { - return nil, workspaceHandlerErr("failed to list workspace storages: %w", err) - } - return workspaces, nil -} - -func (h *workspaceHandler) ListWorkspaceBuilds(ctx context.Context, runtime *config.Runtime) ([]v1alpha1.ResourceBuild, error) { - currentWorkspaceName := runtime.Config().CurrentWorkspace - if currentWorkspaceName == nil { - return nil, workspaceHandlerErr("current workspace not set") - } - currentWorkspace, err := h.workspaceService.GetWorkspaceByName(ctx, *currentWorkspaceName) - if err != nil { - return nil, workspaceHandlerErr("failed to get current workspace '%s': %w", *currentWorkspaceName, err) - } - - if runtime.Args.IsAllResources() { - builds, err := h.workspaceService.ListWorkspaceBuilds(ctx, currentWorkspace) - if err != nil { - return nil, workspaceHandlerErr("failed to list workspace builds: %w", err) - } - return builds, nil - } - - resourceName := runtime.Args.GetResourceName() - if resourceName == "" { - return nil, workspaceHandlerErr("resource name not specified") - } - resource := currentWorkspace.GetResourceByName(resourceName) - if resource == nil { - return nil, workspaceHandlerErr("resource '%s' not found in the workspace", resourceName) - } - - if resource.BuildConfig == nil { - return nil, workspaceHandlerErr("resource '%s' does not have a build configuration", resourceName) - } - - builds, err := h.workspaceService.ListWorkspaceResourceBuilds(ctx, currentWorkspace, resource) - if err != nil { - return nil, workspaceHandlerErr("failed to list resource builds: %w", err) - } - - return builds, nil -} diff --git a/pkg/workspace/logs_handler.go b/pkg/workspace/logs_handler.go deleted file mode 100644 index 59bef3e..0000000 --- a/pkg/workspace/logs_handler.go +++ /dev/null @@ -1,57 +0,0 @@ -package workspace - -import ( - "context" - "fmt" - - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/provider" - "github.com/ashishmax31/voyager-cli/pkg/provider/k8s" -) - -func (w *workspaceHandler) GetLogs(ctx context.Context, runtime *config.Runtime) error { - currentWorkspaceName := runtime.Config().CurrentWorkspace - if currentWorkspaceName == nil { - return workspaceHandlerErr("current workspace is not set") - } - - currentWorkspace, serr := w.workspaceService.GetWorkspaceByName( - ctx, - *currentWorkspaceName, - ) - if serr != nil { - return workspaceHandlerErr("failed to fetch current workspace '%s': %w", *currentWorkspaceName, serr) - } - - logTargets := make([]provider.Target, 0) - if runtime.Args.IsAllResources() { - for _, resource := range currentWorkspace.Resources { - if resource.Status.IsAvailable() { - logTargets = append(logTargets, k8s.NewServiceTarget(*resource.Status.InternalServiceName)) - } else { - fmt.Printf("skipping resource '%s' as its not available", resource.Name) - } - } - if len(logTargets) == 0 { - return workspaceHandlerErr("no resources available in workspace") - } - } else { - for _, resource := range currentWorkspace.Resources { - if resource.Name == runtime.Args.GetResourceName() { - if resource.Status.IsAvailable() { - logTargets = append(logTargets, k8s.NewServiceTarget(*resource.Status.InternalServiceName)) - } else { - return workspaceHandlerErr("resource '%s' is not available", runtime.Args.GetResourceName()) - } - } - } - if len(logTargets) == 0 { - return workspaceHandlerErr("resource '%s' not found in workspace", runtime.Args.GetResourceName()) - } - } - - return w.provider.StreamLogs(ctx, logTargets, provider.LogOptions{ - Follow: runtime.Args.IsFollow(), - TailLines: runtime.Args.GetTailLines(), - }) -} diff --git a/pkg/workspace/restart_handler.go b/pkg/workspace/restart_handler.go deleted file mode 100644 index 647c9eb..0000000 --- a/pkg/workspace/restart_handler.go +++ /dev/null @@ -1,83 +0,0 @@ -package workspace - -import ( - "context" - "fmt" - - "github.com/ashishmax31/voyager-cli/pkg/config" -) - -func (w *workspaceHandler) Restart(ctx context.Context, runtime *config.Runtime) error { - currentWorkspaceName := runtime.Config().CurrentWorkspace - if currentWorkspaceName == nil { - return workspaceHandlerErr("current workspace not set") - } - - if runtime.Args.IsAllResources() { - return w.restartAllResources(ctx, runtime) - } - - resourceName := runtime.Args.GetResourceName() - if resourceName == "" { - return workspaceHandlerErr("resource name not specified") - } - - return w.restartResource(ctx, resourceName) -} - -func (w *workspaceHandler) restartAllResources(ctx context.Context, runtime *config.Runtime) error { - currentWorkspace, err := w.workspaceService.GetWorkspaceByName(ctx, *runtime.Config().CurrentWorkspace) - if err != nil { - return workspaceHandlerErr("failed to get current workspace '%s': %w", *runtime.Config().CurrentWorkspace, err) - } - - if currentWorkspace.HasLocalSyncingVolumes() { - if err := w.handleStorageSync(ctx); err != nil { - return workspaceHandlerErr("failed to sync: %w", err) - } - } - - if err := w.workspaceService.RestartAllResources(ctx, currentWorkspace); err != nil { - return workspaceHandlerErr("failed to restart all resources: %w", err) - } - fmt.Printf("workspace '%s' marked for restart\n", currentWorkspace.Name) - return nil -} - -func (w *workspaceHandler) restartResource(ctx context.Context, resourceName string) error { - currentWorkspace, err := w.workspaceService.GetWorkspaceByName(ctx, *w.runtime.Config().CurrentWorkspace) - if err != nil { - return workspaceHandlerErr("failed to get current workspace '%s': %w", *w.runtime.Config().CurrentWorkspace, err) - } - - resource := currentWorkspace.GetResourceByName(resourceName) - if resource == nil { - return workspaceHandlerErr("resource '%s' not found", resourceName) - } - - if currentWorkspace.ResourceHasLocalSyncingVolume(resourceName) { - if err := w.handleStorageSync(ctx); err != nil { - return workspaceHandlerErr("failed to sync: %w", err) - } - } - - if err := w.workspaceService.RestartResource(ctx, currentWorkspace, resourceName); err != nil { - return workspaceHandlerErr("failed to restart resource '%s': %w", resourceName, err) - } - fmt.Printf("resource '%s' marked for restart\n", resourceName) - return nil -} - -func (w *workspaceHandler) handleStorageSync(ctx context.Context) error { - initialized, werr := w.syncHandler.Initialized(ctx) - if werr != nil { - return workspaceHandlerErr("failed to check sync session status: %w", werr) - } - if !initialized { - return workspaceHandlerErr("sync session not running! Please run voyager sync init") - } - if err := w.syncHandler.ForceSync(ctx); err != nil { - return workspaceHandlerErr("failed to force sync: %w", err) - } - return nil -} diff --git a/pkg/workspace/status_handler.go b/pkg/workspace/status_handler.go deleted file mode 100644 index c5b196c..0000000 --- a/pkg/workspace/status_handler.go +++ /dev/null @@ -1,278 +0,0 @@ -package workspace - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/config" -) - -const ( - green = "\033[32m" // Green color - red = "\033[31m" // Red color - reset = "\033[0m" // Reset to default color - - nameWidth = 20 - stateWidth = 10 // For "Ready", "Pending", "Error" - readyWidth = 8 // Include explicit padding for "✓" or "×" - ingressWidth = 40 - buildWidth = 20 - stateColWidth = 13 - restartStatusWidth = 22 -) - -func (w *workspaceHandler) Status(ctx context.Context, runtime *config.Runtime) error { - currentWorkspaceName := runtime.Config().CurrentWorkspace - if currentWorkspaceName == nil { - return workspaceHandlerErr("current workspace not set") - } - - _, serr := w.getCurretWorkspaceStorage(ctx, *currentWorkspaceName) - if serr != nil { - return workspaceHandlerErr("failed to get current workspace storage: %w", serr) - } - - currentWorkspace, wgerr := w.workspaceService.GetWorkspaceByName(ctx, *currentWorkspaceName) - if wgerr != nil { - return workspaceHandlerErr("failed to get workspace: %w", wgerr) - } - - currentBuilds, bErr := w.workspaceService.ListWorkspaceBuilds(ctx, currentWorkspace) - if bErr != nil { - return workspaceHandlerErr("failed to list workspace builds: %w", bErr) - } - - if runtime.Args.IsAllResources() { - PrintWorkspaceStatus(currentWorkspace, currentBuilds) - return nil - } - printResource(currentWorkspace, currentBuilds, runtime.Args.GetResourceName()) - return nil -} - -func printResource(workspace *v1alpha1.Workspace, builds []v1alpha1.ResourceBuild, resourceName string) { - for _, res := range workspace.Resources { - if res.Name == resourceName { - fmt.Printf("Resource: %s\n", res.Name) - fmt.Printf("Status: %s %s\n", - getStatusIndicator(res.IsAvailable()), - res.Status.State, - ) - fmt.Println() - - printBuildsForResource(builds, res.Name) - printIngressDetails(res.Status.PublicIngresses) - return - } - } - fmt.Printf("Resource '%s' not found\n", resourceName) -} - -func (w *workspaceHandler) getCurretWorkspaceStorage(ctx context.Context, workspaceName string) (*v1alpha1.WorkspaceStorage, error) { - storages, err := w.workspaceStorageService.GetCurrentWorkspaceStorages(ctx) - if err != nil { - return nil, workspaceHandlerErr("failed to get workspace storages: %w", err) - } - for _, storage := range storages { - if storage.WorkspaceName == workspaceName { - return storage, nil - } - } - return nil, nil -} - -func PrintWorkspaceStatus(w *v1alpha1.Workspace, builds []v1alpha1.ResourceBuild) { - fmt.Printf("Workspace: %s\n", w.Name) - fmt.Printf("Status: %s %s\n", - getStatusIndicator(w.IsAvailable()), - w.Status.State, - ) - fmt.Printf("CreatedAt: %s\n", w.CreatedAt.Local()) - fmt.Println() - - totalWidth := nameWidth + stateWidth + readyWidth + ingressWidth + buildWidth + stateColWidth + restartStatusWidth - - fmt.Println("RESOURCES:") - fmt.Printf("%-*s %-*s %-*s %-*s %-*s %-*s %s\n", - nameWidth, "NAME", - stateWidth, "STATE", - readyWidth, "READY", - ingressWidth, "INGRESS", - buildWidth, "CURRENT_BUILD", - stateColWidth, "BUILD_STATE", - "RESTART STATUS", - ) - fmt.Printf("%s\n", strings.Repeat("-", totalWidth)) - - for _, res := range w.Resources { - currentBuild := getCurrentBuild(builds, res) - var buildState string - var buildID string - if currentBuild == nil { - buildState = "-" - buildID = "-" - } else { - buildState = currentBuild.Status.State - buildID = currentBuild.ID - } - - ingressLines := formatIngressDetails(res.Status.PublicIngresses) - - if len(ingressLines) == 0 { - fmt.Printf("%-*s %-*s %-*s %-*s %-*s %-*s %s\n", - nameWidth, res.Name, - stateWidth, res.Status.State, - readyWidth, getStatusIndicator(res.IsAvailable()), - ingressWidth, "", - buildWidth, buildID, - stateColWidth, buildState, - formatRestartStatus(res.LifecycleConfig, res.Status), - ) - } else { - // Print first line with all columns - fmt.Printf("%-*s %-*s %-*s %-*s %-*s %-*s %s\n", - nameWidth, res.Name, - stateWidth, res.Status.State, - readyWidth, getStatusIndicator(res.IsAvailable()), - ingressWidth, ingressLines[0], - buildWidth, buildID, - stateColWidth, buildState, - formatRestartStatus(res.LifecycleConfig, res.Status), - ) - - // Print additional ingress lines aligned under the "INGRESS" column - for _, ingressLine := range ingressLines[1:] { - fmt.Printf("%-*s %-*s %-*s %-*s\n", - nameWidth, "", - stateWidth, "", - readyWidth, "", - ingressWidth, ingressLine, - ) - } - } - } -} - -func formatIngressDetails(ingresses []v1alpha1.Ingress) []string { - if len(ingresses) == 0 { - return nil - } - - var lines []string - for _, ingress := range ingresses { - lines = append(lines, fmt.Sprintf("%s → %d", ingress.URL, ingress.TargetPort)) - } - return lines -} - -func getStatusIndicator(ready bool) string { - if ready { - return "✓" - } - return "✗" -} - -func getCurrentBuild(builds []v1alpha1.ResourceBuild, res v1alpha1.WorkspaceResource) *v1alpha1.ResourceBuild { - if res.BuildConfig == nil { - return nil - } - for _, build := range builds { - if build.WorkspaceResourceName == res.Name && build.SourceHash == res.BuildConfig.ContextDirHash { - return &build - } - } - return nil -} - -func formatRestartStatus(lifecycle *v1alpha1.LifecycleConfig, status *v1alpha1.WorkspaceResourceStatus) string { - if lifecycle == nil || lifecycle.RestartRequestTime == nil { - return "-" - } - - requestTime := lifecycle.RestartRequestTime.UTC() - - if status == nil || status.LastRestartRequestProcessedTime == nil { - return fmt.Sprintf("Restart pending (%s)", formatDurationShort(time.Since(requestTime))) - } - - processedTime := status.LastRestartRequestProcessedTime.UTC() - - if processedTime.Before(requestTime) { - return fmt.Sprintf("Restart pending (%s)", formatDurationShort(time.Since(requestTime))) - } - - return fmt.Sprintf("Restarted %s ago", formatDurationShort(time.Since(processedTime))) -} - -func formatDurationShort(d time.Duration) string { - if d < time.Hour { - return fmt.Sprintf("%dm", int(d.Minutes())) - } - if d < 24*time.Hour { - return fmt.Sprintf("%dh", int(d.Hours())) - } - return fmt.Sprintf("%dd", int(d.Hours()/24)) -} - -// Helper functions - -// printBuildsForResource prints builds table for a specific resource -func printBuildsForResource(builds []v1alpha1.ResourceBuild, resourceName string) { - relevantBuilds := filterBuildsByResource(builds, resourceName) - if len(relevantBuilds) == 0 { - return - } - - fmt.Printf(" Builds:\n") - fmt.Printf(" %-40s %-15s %s\n", - "ID", "STATE", "SOURCE_HASH") - fmt.Printf(" %s\n", strings.Repeat("-", 130)) - - for _, build := range relevantBuilds { - state := "Unknown" - sourceHash := build.SourceHash - - if build.Status != nil { - if build.Status.State != "" { - state = build.Status.State - } - if build.Status.SourceHash != "" { - sourceHash = build.Status.SourceHash - } - } - - fmt.Printf(" %-40s %-15s %s\n", - build.ID, - state, - sourceHash, - ) - } - fmt.Println() -} - -func filterBuildsByResource(builds []v1alpha1.ResourceBuild, resourceName string) []v1alpha1.ResourceBuild { - var filtered []v1alpha1.ResourceBuild - for _, build := range builds { - if build.WorkspaceResourceName == resourceName { - filtered = append(filtered, build) - } - } - return filtered -} - -// printIngressDetails prints detailed ingress information -func printIngressDetails(ingresses []v1alpha1.Ingress) { - fmt.Printf(" Ingress Routes:\n") - fmt.Printf(" %-50s %s\n", "URL", "PORT") - fmt.Printf(" %s\n", strings.Repeat("-", 60)) - for _, ing := range ingresses { - fmt.Printf(" %-50s %d\n", - ing.URL, - ing.TargetPort, - ) - } - fmt.Println() -} diff --git a/pkg/workspace/sync_handler.go b/pkg/workspace/sync_handler.go deleted file mode 100644 index 088bb43..0000000 --- a/pkg/workspace/sync_handler.go +++ /dev/null @@ -1,113 +0,0 @@ -package workspace - -import ( - "context" - "fmt" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/provider/k8s" - "github.com/ashishmax31/voyager-cli/pkg/sync" - "github.com/sirupsen/logrus" -) - -func (w *workspaceHandler) StartSyncSession(ctx context.Context, userStack *v1alpha1.UserStack) error { - currentWorkspaceName := w.runtime.Config().CurrentWorkspace - if currentWorkspaceName == nil { - return fmt.Errorf("current workspace not set") - } - - existingWorkspaceStorage, serr := w.workspaceStorageService.GetCurrentWorkspaceStorage(ctx, userStack.WorkspaceStorageName()) - if serr != nil { - return workspaceHandlerErr("failed to get workspace storage for stack: %w", serr) - } - - if _, err := w.workspaceStorageService.WaitForCurrentWorkspaceStorageToBeAvailable(ctx, userStack.WorkspaceStorageName()); err != nil { - return workspaceHandlerErr("workspace storage not available: %w", err) - } - - initialized, err := w.syncHandler.Initialized(ctx) - if err != nil { - return workspaceHandlerErr("failed to check sync status: %w", err) - } - if initialized { - logrus.Info("already initialized") - return nil - } - - syncList := make(sync.SourceDestintionList, 0) - for _, volume := range existingWorkspaceStorage.Volumes { - if volume.VolumeSource != nil && volume.VolumeSource.LocalDir != nil { - current := sync.SourceDestintionPair{ - Source: volume.VolumeSource.LocalDir.Path, - // TODO: Server to expose this field in the status. - Destination: fmt.Sprintf("/%s/%s", existingWorkspaceStorage.Name, volume.Name), - } - syncList = append(syncList, current) - } - } - - logrus.Debugf("synclist : %+v \n", syncList) - // Blocking. - if err := w.syncHandler.SetupSyncSession( - ctx, - syncList, - k8s.NewServiceTarget(existingWorkspaceStorage.Status.StorageServiceName), - ); err != nil { - return workspaceHandlerErr("sync session failed: %w", err) - } - // Success - return nil -} - -func (w *workspaceHandler) SyncStatus(ctx context.Context) error { - return w.syncHandler.Status(ctx) -} - -func (w *workspaceHandler) StopSyncSession(ctx context.Context) error { - return w.syncHandler.StopSyncSession(ctx) -} - -func (w *workspaceHandler) Sync(ctx context.Context) error { - userStack, err := w.runtime.UserStack() - if err != nil { - return workspaceHandlerErr("failed to get user stack: %w", err) - } - - currentWorkspaceStorageName := userStack.WorkspaceStorageName() - - initialized, err := w.syncHandler.Initialized(ctx) - if err != nil { - return workspaceHandlerErr("failed to check sync session status: %w", err) - } - syncRunning, err := w.syncHandler.SyncSessionRunning(ctx) - if err != nil { - return workspaceHandlerErr("failed to check sync session status: %w", err) - } - - switch { - case !initialized: - return fmt.Errorf("sync session not initialized! Please run voyager sync init") - case !syncRunning: - return fmt.Errorf("sync session not running! Please run voyager sync init and wait for it to complete") - } - - if err := w.syncHandler.ForceSync(ctx); err != nil { - return workspaceHandlerErr("failed to force sync: %w", err) - } - return w.markAsSynced(ctx, currentWorkspaceStorageName) -} - -func (w *workspaceHandler) markAsSynced(ctx context.Context, currentWorkspaceStorageName string) error { - existingWorkspaceStorage, serr := w.workspaceStorageService.GetCurrentWorkspaceStorage(ctx, currentWorkspaceStorageName) - if serr != nil { - return workspaceHandlerErr("failed to get workspace storage for stack: %w", serr) - } - for _, volume := range existingWorkspaceStorage.Volumes { - if volume.VolumeSource != nil && volume.VolumeSource.LocalDir != nil { - if err := w.workspaceStorageService.MarkCurrentWorkspaceAsSynced(ctx, currentWorkspaceStorageName, volume.Name); err != nil { - return workspaceHandlerErr("failed to mark as synced: %w", err) - } - } - } - return nil -} diff --git a/pkg/workspace/workspace_handler.go b/pkg/workspace/workspace_handler.go deleted file mode 100644 index 563ab86..0000000 --- a/pkg/workspace/workspace_handler.go +++ /dev/null @@ -1,57 +0,0 @@ -package workspace - -import ( - "context" - "fmt" - - "github.com/ashishmax31/voyager-cli/pkg/client" - "github.com/ashishmax31/voyager-cli/pkg/config" - "github.com/ashishmax31/voyager-cli/pkg/provider" - "github.com/ashishmax31/voyager-cli/pkg/provider/k8s" - "github.com/ashishmax31/voyager-cli/pkg/services" - "github.com/ashishmax31/voyager-cli/pkg/sync" -) - -type workspaceHandler struct { - runtime *config.Runtime - syncHandler sync.Syncer - provider provider.Provider - workspaceStorageService services.WorkspaceStorageService - workspaceService services.WorkspaceService - workspaceInitializationService services.WorkspaceInitializationService -} - -func NewWorkspaceHandler(runtime *config.Runtime) (*workspaceHandler, error) { - w := &workspaceHandler{ - runtime: runtime, - workspaceStorageService: services.NewWorkspaceStorageService(services.WorkspaceStorageServiceSpec{ - Session: runtime.Session, - }), - workspaceService: services.NewWorkspaceService(services.WorkspaceServiceSpec{ - Session: runtime.Session, - }), - workspaceInitializationService: services.NewWorkspaceInitializationService(runtime), - } - - if runtime.Config().ProviderConfigPresent() { - providerClient, err := client.NewProviderClient(runtime.Config()) - if err != nil { - return nil, workspaceHandlerErr("failed to create provider client: %w", err) - } - provider := k8s.NewK8sProvider(runtime.Config(), providerClient) - w.provider = provider - w.syncHandler = sync.NewMutagenSyncer(runtime.Config(), runtime.ConfigDir, runtime.DepsDir, provider) - } - return w, nil -} - -func (w *workspaceHandler) Initialize(ctx context.Context, workspaceName string) error { - if err := w.workspaceInitializationService.InitializeWorkspace(ctx, workspaceName); err != nil { - return workspaceHandlerErr("failed to initialize workspace: %w", err) - } - return nil -} - -func workspaceHandlerErr(errString string, args ...any) error { - return fmt.Errorf(errString, args...) -} From 6cc9360a61fb7a2b8497f1687af226eb071ab2cd Mon Sep 17 00:00:00 2001 From: ashish Date: Thu, 11 Jun 2026 20:03:43 +0530 Subject: [PATCH 8/8] remove leftover cmd/common from old voyager code --- cmd/common/commands.go | 5 ---- cmd/common/common.go | 65 ------------------------------------------ 2 files changed, 70 deletions(-) delete mode 100644 cmd/common/commands.go delete mode 100644 cmd/common/common.go diff --git a/cmd/common/commands.go b/cmd/common/commands.go deleted file mode 100644 index 6aa59a0..0000000 --- a/cmd/common/commands.go +++ /dev/null @@ -1,5 +0,0 @@ -package common - -type Command struct { - Use string -} diff --git a/cmd/common/common.go b/cmd/common/common.go deleted file mode 100644 index 9141521..0000000 --- a/cmd/common/common.go +++ /dev/null @@ -1,65 +0,0 @@ -package common - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/ashishmax31/voyager-cli/pkg/api/v1alpha1" - "github.com/ashishmax31/voyager-cli/pkg/tools" - "github.com/ashishmax31/voyager-cli/pkg/validation" -) - -const ( - VoyagerFilePathFlag = "voyagerfile-path" - AllResourcesFlag = "all" - InteractiveSessionFlag = "interactive" -) - -func findVoyagerFile(dir string) (string, error) { - files, err := os.ReadDir(dir) - if err != nil { - return "", err - } - - for _, file := range files { - if !file.IsDir() { - currfileName := strings.ToLower(file.Name()) - if currfileName == "voyagerfile.yaml" || currfileName == "voyagerfile.yml" { - return filepath.Join(dir, file.Name()), nil - } - } - } - return "", fmt.Errorf("cant locate voyagerfile in directory: %s", dir) -} - -func CurrentWorkspaceDefinition(voyagerFilePath string) (*v1alpha1.Workspace, error) { - if len(voyagerFilePath) == 0 { - cwd, err := os.Getwd() - if err != nil { - return nil, err - } - voyagerFilePath, err = findVoyagerFile(cwd) - if err != nil { - return nil, err - } - } - if len(voyagerFilePath) == 0 { - return nil, fmt.Errorf("voyager file missing") - } - _, err := os.Stat(voyagerFilePath) - if err != nil { - return nil, fmt.Errorf("failed to stat voyagerfile at %s: %w", voyagerFilePath, err) - } - - if err := validation.Validate(voyagerFilePath); err != nil { - return nil, fmt.Errorf("invalid voyagerfile: %w", err) - } - - res := &v1alpha1.Workspace{} - if err := tools.UnmarshalYamlFile(voyagerFilePath, res); err != nil { - return nil, fmt.Errorf("failed to unmarshal voyagerfile: %w", err) - } - return res, nil -}