diff --git a/.gitignore b/.gitignore index 371546c..317a8f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ bin/ .vscode/stackdome +.env +/stackdome +/stackfile.yaml +/docs/superpowers/ +HANDOVER.md diff --git a/cmd/stackdome/addon.go b/cmd/stackdome/addon.go new file mode 100644 index 0000000..c5eb48b --- /dev/null +++ b/cmd/stackdome/addon.go @@ -0,0 +1,352 @@ +package main + +import ( + "fmt" + "os" + + openapi "github.com/Stackdome/stackdome/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 newAddonCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "addon", + Short: "Manage addons", + } + cmd.AddCommand(newPostgresCmd()) + return cmd +} + +func newPostgresCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "postgres", + Short: "Manage PostgreSQL addons", + } + + cmd.AddCommand(newPostgresCreateCmd()) + cmd.AddCommand(newPostgresListCmd()) + cmd.AddCommand(newPostgresInfoCmd()) + cmd.AddCommand(newPostgresDeleteCmd()) + cmd.AddCommand(newPostgresCredentialsCmd()) + cmd.AddCommand(newPostgresBackupCmd()) + cmd.AddCommand(newPostgresBackupsCmd()) + return cmd +} + +func newPostgresCreateCmd() *cobra.Command { + var ( + flagDatabase string + flagSuperuser bool + flagVersion int32 + flagInstances int32 + flagStorage string + ) + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a PostgreSQL addon", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + name := args[0] + database := flagDatabase + if database == "" { + database = name + } + + addon := openapi.PostgresAddon{ + Name: name, + Spec: openapi.PostgresAddonSpec{ + Version: openapi.PostgresVersion{Major: flagVersion}, + Instances: openapi.PostgresInstances{Count: flagInstances}, + Storage: openapi.PostgresStorage{Size: flagStorage}, + Databases: []openapi.PostgresDatabase{{Name: database}}, + }, + } + if flagSuperuser { + addon.Spec.Configuration = &openapi.PostgresConfiguration{EnableSuperuserAccess: &flagSuperuser} + } + + created, err := ctx.Client.CreatePostgresAddon(cmd.Context(), addon) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(created) + } + + fmt.Fprintf(os.Stderr, "Postgres addon %q created.\n", created.Name) + return nil + })), + } + + cmd.Flags().StringVar(&flagDatabase, "database", "", "Initial database name (defaults to the addon name)") + cmd.Flags().BoolVar(&flagSuperuser, "superuser", false, "Enable superuser access") + cmd.Flags().Int32Var(&flagVersion, "version", 16, "PostgreSQL major version (13-17)") + cmd.Flags().Int32Var(&flagInstances, "instances", 1, "Number of instances (1-5)") + cmd.Flags().StringVar(&flagStorage, "storage", "10Gi", "Storage size") + return cmd +} + +func newPostgresListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List PostgreSQL addons", + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + addons, err := ctx.Client.ListPostgresAddons(cmd.Context()) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(addons) + } + + if len(addons) == 0 { + fmt.Fprintln(os.Stderr, "No postgres addons found.") + return nil + } + + tbl := ctx.Formatter.NewTable("NAME", "VERSION", "INSTANCES", "STORAGE", "STATE", "CREATED") + for _, a := range addons { + tbl.AddRow( + a.Name, + fmt.Sprintf("%d", a.Spec.Version.Major), + fmt.Sprintf("%d", a.Spec.Instances.Count), + a.Spec.Storage.Size, + addonState(a), + addonCreated(a), + ) + } + tbl.Render() + return nil + })), + } +} + +func newPostgresInfoCmd() *cobra.Command { + return &cobra.Command{ + Use: "info ", + Short: "Show PostgreSQL addon details", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + addon, err := findPostgresAddon(ctx, cmd, args[0]) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(addon) + } + + fmt.Printf("Name: %s\n", addon.Name) + fmt.Printf("Version: %d\n", addon.Spec.Version.Major) + fmt.Printf("Instances: %d\n", addon.Spec.Instances.Count) + fmt.Printf("Storage: %s\n", addon.Spec.Storage.Size) + fmt.Printf("State: %s\n", addonState(*addon)) + + if addon.Status != nil && addon.Status.ConnectionInfo != nil { + info := addon.Status.ConnectionInfo + if info.Host != nil { + fmt.Printf("Host: %s\n", *info.Host) + } + if info.Port != nil { + fmt.Printf("Port: %d\n", *info.Port) + } + if len(info.Databases) > 0 { + fmt.Println("\nDatabases:") + for _, db := range info.Databases { + fmt.Printf(" %s\n", db.GetName()) + } + } + } + return nil + })), + } +} + +func newPostgresDeleteCmd() *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a PostgreSQL addon", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + addon, err := findPostgresAddon(ctx, cmd, args[0]) + if err != nil { + return err + } + + if _, err := cmdutil.Confirm(ctx.Formatter, fmt.Sprintf("Delete postgres addon %q and all its data?", args[0]), flagYes); err != nil { + return err + } + + if err := ctx.Client.DeletePostgresAddon(cmd.Context(), *addon.Id); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Postgres addon %q deleted.\n", args[0]) + return nil + })), + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation") + return cmd +} + +func newPostgresCredentialsCmd() *cobra.Command { + var flagSuperuser bool + + cmd := &cobra.Command{ + Use: "credentials ", + Short: "Get just-in-time credentials for a database", + Args: cobra.ExactArgs(2), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + addon, err := findPostgresAddon(ctx, cmd, args[0]) + if err != nil { + return err + } + + creds, err := ctx.Client.GetPostgresCredentials(cmd.Context(), *addon.Id, args[1], flagSuperuser) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(creds) + } + + for _, field := range []struct { + label string + value *string + }{ + {"Host", creds.Host}, + {"Database", creds.Database}, + {"Username", creds.Username}, + {"Password", creds.Password}, + {"SSL Mode", creds.SslMode}, + {"URL", creds.ConnectionString}, + } { + if field.value != nil && *field.value != "" { + fmt.Printf("%s: %s\n", field.label, *field.value) + } + } + if creds.Port != nil { + fmt.Printf("Port: %d\n", *creds.Port) + } + return nil + })), + } + + cmd.Flags().BoolVar(&flagSuperuser, "superuser", false, "Request superuser credentials") + return cmd +} + +func newPostgresBackupCmd() *cobra.Command { + var flagDescription string + + cmd := &cobra.Command{ + Use: "backup ", + Short: "Trigger an immediate backup", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + addon, err := findPostgresAddon(ctx, cmd, args[0]) + if err != nil { + return err + } + + resp, err := ctx.Client.BackupPostgresAddon(cmd.Context(), *addon.Id, flagDescription) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(resp) + } + + fmt.Fprintf(os.Stderr, "Backup triggered for %q (%s).\n", args[0], resp.GetBackupId()) + return nil + })), + } + + cmd.Flags().StringVar(&flagDescription, "description", "", "Description for this backup") + return cmd +} + +func newPostgresBackupsCmd() *cobra.Command { + return &cobra.Command{ + Use: "backups ", + Short: "List backups of a PostgreSQL addon", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + addon, err := findPostgresAddon(ctx, cmd, args[0]) + if err != nil { + return err + } + + backups, err := ctx.Client.ListPostgresBackups(cmd.Context(), *addon.Id) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(backups) + } + + if len(backups) == 0 { + fmt.Fprintln(os.Stderr, "No backups found.") + return nil + } + + tbl := ctx.Formatter.NewTable("ID", "NAME", "TYPE", "PHASE", "STARTED") + for _, b := range backups { + started := "-" + if b.StartedAt != nil { + started = output.TimeAgo(*b.StartedAt) + } + tbl.AddRow(shortID(b.GetId()), b.GetName(), b.GetType(), b.GetPhase(), started) + } + tbl.Render() + return nil + })), + } +} + +func findPostgresAddon(ctx *cmdutil.CommandContext, cmd *cobra.Command, name string) (*openapi.PostgresAddon, error) { + addon, err := ctx.Client.FindPostgresAddonByName(cmd.Context(), name) + if err != nil { + return nil, err + } + if addon == nil || addon.Id == nil { + return nil, clierrors.NotFoundError("Postgres addon", name) + } + return addon, nil +} + +func addonState(a openapi.PostgresAddon) string { + if a.Status == nil || a.Status.State == nil { + return "Unknown" + } + state := *a.Status.State + switch state { + case "Ready", "Running": + return output.Green(state) + case "Failed", "Error": + return output.Red(state) + case "Pending", "Provisioning": + return output.Yellow(state) + default: + return state + } +} + +func addonCreated(a openapi.PostgresAddon) string { + if a.CreatedAt == nil { + return "-" + } + return output.TimeAgo(*a.CreatedAt) +} diff --git a/cmd/stackdome/build.go b/cmd/stackdome/build.go index b7d33ff..807dc34 100644 --- a/cmd/stackdome/build.go +++ b/cmd/stackdome/build.go @@ -5,9 +5,11 @@ import ( "os" "time" - openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/client" "github.com/stackdome/cli/internal/cmdutil" + clierrors "github.com/stackdome/cli/internal/errors" "github.com/stackdome/cli/internal/output" ) @@ -19,6 +21,61 @@ func newBuildCmd() *cobra.Command { cmd.AddCommand(newBuildListCmd()) cmd.AddCommand(newBuildInfoCmd()) + cmd.AddCommand(newBuildLogsCmd()) + return cmd +} + +func newBuildLogsCmd() *cobra.Command { + var ( + flagFollow bool + flagTail int32 + flagSince string + flagStack string + ) + + cmd := &cobra.Command{ + Use: "logs ", + Short: "Stream logs for a build", + 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 + } + + buildID, err := resolveBuildID(ctx, cmd, stackID, args[0]) + if err != nil { + return err + } + + stream, err := ctx.Client.StreamBuildLogs(cmd.Context(), stackID, buildID, client.LogOptions{ + Follow: flagFollow, + Tail: flagTail, + Since: flagSince, + }) + 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) + } + if e.IsEnd() { + return nil + } + fmt.Println(e.Data) + return nil + }) + })), + } + + cmd.Flags().BoolVarP(&flagFollow, "follow", "f", false, "Follow log output") + cmd.Flags().Int32Var(&flagTail, "tail", 200, "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 } @@ -91,7 +148,12 @@ func newBuildInfoCmd() *cobra.Command { return err } - build, err := ctx.Client.GetBuild(cmd.Context(), stackID, args[0]) + buildID, err := resolveBuildID(ctx, cmd, stackID, args[0]) + if err != nil { + return err + } + + build, err := ctx.Client.GetBuild(cmd.Context(), stackID, buildID) if err != nil { return err } @@ -119,8 +181,8 @@ func renderBuildInfo(b *openapi.ImageBuild) { fmt.Printf("Image: %s\n", *b.Status.ImageUrl) } - if b.CreatedAt != nil { - fmt.Printf("Started: %s\n", b.CreatedAt.Format(time.DateTime)) + if start := buildStartTime(*b); start != nil { + fmt.Printf("Started: %s\n", start.Local().Format(time.RFC3339)) } fmt.Printf("Duration: %s\n", buildDuration(*b)) @@ -172,10 +234,10 @@ 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] + if git.Branch != nil { + name := *git.Branch + if git.Commit != nil && len(*git.Commit) >= 7 { + return name + "@" + (*git.Commit)[:7] } return name } @@ -193,19 +255,81 @@ func buildSource(b openapi.ImageBuild) string { } func buildStarted(b openapi.ImageBuild) string { - if b.CreatedAt == nil { + start := buildStartTime(b) + if start == nil { return "-" } - return output.TimeAgo(*b.CreatedAt) + return output.TimeAgo(*start) } func buildDuration(b openapi.ImageBuild) string { - if b.CreatedAt == nil || b.UpdatedAt == nil { + if b.CreatedAt != nil && b.UpdatedAt != nil { + return fmtDuration(b.UpdatedAt.Sub(*b.CreatedAt)) + } + start, end := buildStartTime(b), buildEndTime(b) + if start == nil { return "-" } - d := b.UpdatedAt.Sub(*b.CreatedAt) + // end.After(start) guards the single-condition case, where the start and + // end fallbacks resolve to the same condition. + if end != nil && end.After(*start) && buildIsTerminal(b) { + return fmtDuration(end.Sub(*start)) + } + if !buildIsTerminal(b) { + return fmtDuration(time.Since(*start)) + } + return "-" +} + +func fmtDuration(d time.Duration) string { if d < time.Second { return "<1s" } return d.Round(time.Second).String() } + +func buildIsTerminal(b openapi.ImageBuild) bool { + if b.Status == nil || b.Status.State == nil { + return false + } + return *b.Status.State == "Success" || *b.Status.State == "Failed" +} + +// buildStartTime prefers the model timestamp; the API omits it today +// (hub #10), so fall back to the BuildJobCreated condition or the earliest one. +func buildStartTime(b openapi.ImageBuild) *time.Time { + if b.CreatedAt != nil { + return b.CreatedAt + } + return conditionTime(b, "BuildJobCreated", false) +} + +// buildEndTime mirrors buildStartTime: model timestamp, else the Available +// condition (build completion) or the latest condition. +func buildEndTime(b openapi.ImageBuild) *time.Time { + if b.UpdatedAt != nil { + return b.UpdatedAt + } + return conditionTime(b, "Available", true) +} + +// conditionTime returns the transition time of the named condition, else the +// latest (or earliest) transition time across all conditions. +func conditionTime(b openapi.ImageBuild, want string, latest bool) *time.Time { + if b.Status == nil { + return nil + } + var fallback *time.Time + for _, c := range b.Status.Conditions { + if c.LastTransitionTime == nil { + continue + } + if c.Type != nil && *c.Type == want { + return c.LastTransitionTime + } + if fallback == nil || (latest && c.LastTransitionTime.After(*fallback)) || (!latest && c.LastTransitionTime.Before(*fallback)) { + fallback = c.LastTransitionTime + } + } + return fallback +} diff --git a/cmd/stackdome/build_test.go b/cmd/stackdome/build_test.go new file mode 100644 index 0000000..82f422f --- /dev/null +++ b/cmd/stackdome/build_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "testing" + "time" + + "github.com/Stackdome/stackdome/pkg/api/openapi" +) + +func cond(typ string, t time.Time) openapi.Condition { + return openapi.Condition{Type: &typ, LastTransitionTime: &t} +} + +func buildWith(state string, conds []openapi.Condition) openapi.ImageBuild { + b := openapi.ImageBuild{Status: &openapi.ImageBuildStatus{Conditions: conds}} + if state != "" { + b.Status.State = &state + } + return b +} + +func TestBuildDurationConditionFallback(t *testing.T) { + base := time.Date(2026, 8, 5, 20, 38, 0, 0, time.UTC) + done := base.Add(time.Minute + 41*time.Second) + + created := base.Add(-time.Hour) + updated := created.Add(30 * time.Second) + + tests := []struct { + name string + build openapi.ImageBuild + wantStart *time.Time + wantDur string + }{ + { + name: "model timestamps win", + build: openapi.ImageBuild{ + CreatedAt: &created, + UpdatedAt: &updated, + Status: &openapi.ImageBuildStatus{Conditions: []openapi.Condition{cond("BuildJobCreated", base)}}, + }, + wantStart: &created, + wantDur: "30s", + }, + { + name: "conditions present, terminal", + build: buildWith("Success", []openapi.Condition{ + cond("BuildJobCreated", base), + cond("Available", done), + }), + wantStart: &base, + wantDur: "1m41s", + }, + { + name: "failed build uses latest condition as end", + build: buildWith("Failed", []openapi.Condition{ + cond("BuildJobCreated", base), + cond("BuildJobFailed", done), + }), + wantStart: &base, + wantDur: "1m41s", + }, + { + name: "only created condition, terminal but no end", + build: buildWith("Success", []openapi.Condition{ + cond("BuildJobCreated", base), + }), + wantStart: &base, + wantDur: "-", + }, + { + name: "no conditions at all", + build: buildWith("Pending", nil), + wantStart: nil, + wantDur: "-", + }, + { + name: "no status at all", + build: openapi.ImageBuild{}, + wantStart: nil, + wantDur: "-", + }, + { + name: "no named condition falls back to earliest", + build: buildWith("Success", []openapi.Condition{ + cond("Reconciling", done), + cond("Initialized", base), + }), + wantStart: &base, + wantDur: "1m41s", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildStartTime(tt.build) + switch { + case tt.wantStart == nil && got != nil: + t.Fatalf("start = %v, want nil", got) + case tt.wantStart != nil && (got == nil || !got.Equal(*tt.wantStart)): + t.Fatalf("start = %v, want %v", got, *tt.wantStart) + } + if d := buildDuration(tt.build); d != tt.wantDur { + t.Errorf("duration = %q, want %q", d, tt.wantDur) + } + if tt.wantStart == nil && buildStarted(tt.build) != "-" { + t.Errorf("started = %q, want %q", buildStarted(tt.build), "-") + } + }) + } +} + +func TestBuildDurationInProgressShowsElapsed(t *testing.T) { + b := buildWith("Building", []openapi.Condition{ + // mid-bucket offset so rounding can't tip either way while the test runs + cond("BuildJobCreated", time.Now().Add(-90*time.Second-250*time.Millisecond)), + }) + if d := buildDuration(b); d != "1m30s" { + t.Errorf("duration = %q, want %q", d, "1m30s") + } +} diff --git a/cmd/stackdome/config.go b/cmd/stackdome/config.go index 7bf9324..88e0ddb 100644 --- a/cmd/stackdome/config.go +++ b/cmd/stackdome/config.go @@ -27,7 +27,12 @@ func newConfigViewCmd() *cobra.Command { 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) + // Redact on a copy: Save() marshals the same struct, so the + // tokens must stay intact on the real config. + view := *ctx.Config + view.AccessToken = redactSecret(view.AccessToken) + view.RefreshToken = redactSecret(view.RefreshToken) + return ctx.Formatter.PrintStructured(view) } fmt.Fprintln(os.Stdout, ctx.Config.Summary()) return nil @@ -35,18 +40,35 @@ func newConfigViewCmd() *cobra.Command { } } +// redactSecret replaces a credential with a fixed marker: even a prefix is a +// credential fragment, and structured output tends to end up in logs. +func redactSecret(s string) string { + if s == "" { + return "" + } + return "" +} + func newConfigSetStackCmd() *cobra.Command { return &cobra.Command{ - Use: "set-stack ", - Short: "Set the current stack context", + Use: "set-stack ", + Short: "Set the current stack context (name or ID)", 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 { + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + id, err := resolveStackRef(ctx, cmd, args[0]) + if err != nil { return err } - fmt.Fprintf(os.Stderr, "Current stack set to %s\n", args[0]) + if err := ctx.Config.SetCurrentStack(id); err != nil { + return err + } + note := "" + if ctx.Config.TokenFromEnv() { + note = " — this session only, not persisted with env-token auth" + } + fmt.Fprintf(os.Stderr, "Current stack set to %s (%s)%s\n", args[0], id, note) return nil - }), + })), } } @@ -65,7 +87,7 @@ func newConfigSetContextCmd() *cobra.Command { ctx.Config.AccessToken = "" ctx.Config.RefreshToken = "" ctx.Config.OrganizationID = "" - ctx.Config.TeamName = "" + ctx.Config.ProjectName = "" ctx.Config.Username = "" ctx.Config.CurrentStack = "" diff --git a/cmd/stackdome/deploy.go b/cmd/stackdome/deploy.go index 9787e9f..2c097bf 100644 --- a/cmd/stackdome/deploy.go +++ b/cmd/stackdome/deploy.go @@ -1,14 +1,15 @@ package main import ( + "context" "fmt" "os" "path/filepath" "strings" - "time" - openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/client" "github.com/stackdome/cli/internal/cmdutil" clierrors "github.com/stackdome/cli/internal/errors" "github.com/stackdome/cli/internal/output" @@ -25,25 +26,25 @@ func newDeployCmd() *cobra.Command { cmd := &cobra.Command{ Use: "deploy", Short: "Deploy a stack from a stackfile or JSON", + Long: `Deploy a stack from a stackfile or JSON. + +With -o json|yaml stdout carries {"stack": ..., "release": ...} — the release id +is the one to follow with ` + "`stackdome release events -f`" + `. With --wait +the release object is the final one.`, 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 { + // Names in `secrets:`/`addons:` become IDs before the document is + // sent — the server only speaks IDs. + if err := stackfile.ResolveStack(cmd.Context(), stack, &apiResolver{c: ctx.Client}); 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) - } + fmt.Fprintf(os.Stderr, "Applying stack %q...\n", stack.Name) + result, err := ctx.Client.ApplyStack(cmd.Context(), *stack) if err != nil { return err } @@ -52,38 +53,68 @@ func newDeployCmd() *cobra.Command { return err } - 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 - } + // Apply only stores the document; the release is what rolls it + // out. Its id is the one --wait follows — never a pre-existing one. + release, err := ctx.Client.CreateRelease(cmd.Context(), *result.Id) + if err != nil { + return err + } + if !flagWait { if !ctx.Formatter.IsTable() { - return ctx.Formatter.PrintStructured(final) + return ctx.Formatter.PrintStructured(deployResult{Stack: result, Release: release}) } - - output.RenderStackStatus(os.Stdout, final, false) + fmt.Fprintf(os.Stderr, "\nRelease #%d for stack %q submitted. Track progress with:\n", release.GetSequence(), result.Name) + fmt.Fprintf(os.Stderr, " stackdome release events %s -f\n", release.GetId()) + fmt.Fprintf(os.Stderr, " stackdome status --watch # live updates\n") + fmt.Fprintf(os.Stderr, " stackdome logs # stream logs\n") return nil } - 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 + final, waitErr := followRelease(ctx, cmd, *result.Id, release.GetId()) + + if err := printFinalStack(ctx, cmd, *result.Id, final); err != nil && waitErr == nil { + return err + } + return waitErr })), } 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") + cmd.Flags().BoolVarP(&flagWait, "wait", "w", false, "Wait for the release to finish") return cmd } +// apiResolver turns stackfile names into API IDs. +type apiResolver struct{ c *client.Client } + +func (r *apiResolver) ResolveSecretByName(ctx context.Context, name string) (string, error) { + secret, err := r.c.FindSecretByName(ctx, name) + if err != nil { + return "", err + } + if secret == nil || secret.Id == nil { + return "", clierrors.Newf("Secret %q not found. Run `stackdome secret list` to see available secrets.", name) + } + return *secret.Id, nil +} + +func (r *apiResolver) ResolveAddonByName(ctx context.Context, addonType, name string) (string, error) { + if addonType != "postgres" { + return "", clierrors.Newf("Unsupported addon type %q", addonType) + } + addon, err := r.c.FindPostgresAddonByName(ctx, name) + if err != nil { + return "", err + } + if addon == nil || addon.Id == nil { + return "", clierrors.Newf("Postgres addon %q not found. Check available addons in the dashboard.", name) + } + return *addon.Id, nil +} + func loadStack(path, nameOverride string) (*openapi.Stack, error) { ext := strings.ToLower(filepath.Ext(path)) @@ -103,13 +134,13 @@ 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 } - stack := sf.ToStack() + stack, err := sf.ToStack() + if err != nil { + return nil, clierrors.Wrap(err, "Failed to convert stackfile") + } return &stack, nil default: @@ -117,38 +148,98 @@ func loadStack(path, nameOverride string) (*openapi.Stack, error) { } } -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 - } +// deployResult is what `deploy -o json|yaml` prints: scripts need the release +// id to follow events, which a bare stack does not carry. +type deployResult struct { + Stack *openapi.Stack `json:"stack" yaml:"stack"` + Release any `json:"release" yaml:"release"` +} + +// followRelease streams a release's events to stderr and resolves its outcome, +// returning the final release (which may be non-nil alongside an error). A +// release that did not reach Released is an error, so `deploy --wait` exits +// non-zero on a failed deploy. +func followRelease(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID, releaseID string) (*openapi.StackReleaseDetail, error) { + events, err := ctx.Client.StreamReleaseEvents(cmd.Context(), stackID, releaseID, 0) + if err != nil { + return nil, err + } + for e := range events { + printReleaseEventLine(os.Stderr, e) + } + if cmd.Context().Err() != nil { + return nil, clierrors.ErrUserCanceled + } + + release, err := ctx.Client.GetRelease(cmd.Context(), stackID, releaseID) + if err != nil { + return nil, err + } + return release, releaseOutcomeError(release) +} + +func releaseOutcomeError(release *openapi.StackReleaseDetail) error { + state := "" + if release.State != nil { + state = string(*release.State) + } + + switch openapi.StackReleaseState(state) { + case openapi.RELEASE_STATE_RELEASED: + fmt.Fprintln(os.Stderr, output.Green("Release succeeded.")) + return nil + case openapi.RELEASE_STATE_FAILED, + openapi.RELEASE_STATE_CANCELLED, + openapi.RELEASE_STATE_SUPERSEDED: + return clierrors.Newf("Release %s: %s", strings.ToLower(state), releaseFailureDetail(release)) + default: + return clierrors.Newf("Release did not finish (state: %s)", state) + } +} + +func releaseFailureDetail(release *openapi.StackReleaseDetail) string { + if release.Message != nil && *release.Message != "" { + return *release.Message + } + if len(release.ValidationErrors) > 0 { + parts := make([]string, 0, len(release.ValidationErrors)) + for _, ve := range release.ValidationErrors { + parts = append(parts, validationErrorLine(ve)) } + return strings.Join(parts, "; ") + } + if release.Cause != nil && release.Cause.Detail != nil && *release.Cause.Detail != "" { + return *release.Cause.Detail + } + return "no detail reported" +} + +func validationErrorLine(ve openapi.ReleaseValidationError) string { + var b strings.Builder + if ve.ResourceName != nil { + b.WriteString(*ve.ResourceName + ": ") + } + if ve.Field != nil { + b.WriteString(*ve.Field + " ") + } + if ve.Message != nil { + b.WriteString(*ve.Message) + } + return strings.TrimSpace(b.String()) +} + +func printFinalStack(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID string, release *openapi.StackReleaseDetail) error { + final, err := ctx.Client.GetStack(cmd.Context(), stackID) + if err != nil { + return err + } + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(deployResult{Stack: final, Release: release}) + } + live, err := ctx.Client.GetStackLiveStatus(cmd.Context(), final) + if err != nil { + return err } + output.RenderStackStatus(os.Stdout, final, live, false) + return nil } diff --git a/cmd/stackdome/deploy_test.go b/cmd/stackdome/deploy_test.go new file mode 100644 index 0000000..b62122f --- /dev/null +++ b/cmd/stackdome/deploy_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stackdome/cli/internal/cmdutil" + "github.com/stackdome/cli/internal/config" + "github.com/stackdome/cli/internal/output" +) + +// Applying a stack only stores the document — the server creates no release for +// it. Deploy must follow the apply with an explicit createRelease, or nothing +// ever rolls out. +func TestDeployAppliesThenCreatesRelease(t *testing.T) { + var calls []string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodPut && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/apply": + _, _ = w.Write([]byte(`{"id":"stack-1","name":"basic-stack","spec":{}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"rel-7","stack_id":"stack-1","sequence":3,"state":"Pending"}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "config.json")) + + cfg := &config.Config{ + ServerURL: ts.URL, + AccessToken: "sdm_test", + OrganizationID: "org-1", + ProjectName: "proj-1", + } + ctx := cmdutil.NewCommandContext(cfg, output.FormatTable, slog.LevelError) + + cmd := newDeployCmd() + cmd.SetContext(context.Background()) + cmdutil.SetContext(cmd, ctx) + cmd.SetArgs([]string{"--file", filepath.Join("..", "..", "internal", "stackfile", "testdata", "basic_image.yaml")}) + cmd.SetOut(os.Stderr) + + if err := cmd.Execute(); err != nil { + t.Fatalf("deploy: %v", err) + } + + want := []string{ + "PUT /api/v1/organizations/org-1/projects/proj-1/stacks/apply", + "POST /api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases", + } + if len(calls) != len(want) { + t.Fatalf("expected %v, got %v", want, calls) + } + for i := range want { + if calls[i] != want[i] { + t.Errorf("call %d: got %q, want %q", i, calls[i], want[i]) + } + } + + if cfg.CurrentStack != "stack-1" { + t.Errorf("current stack = %q, want %q", cfg.CurrentStack, "stack-1") + } +} diff --git a/cmd/stackdome/destroy.go b/cmd/stackdome/destroy.go index e45467f..9da10cf 100644 --- a/cmd/stackdome/destroy.go +++ b/cmd/stackdome/destroy.go @@ -1,10 +1,8 @@ package main import ( - "bufio" "fmt" "os" - "strings" "github.com/spf13/cobra" "github.com/stackdome/cli/internal/cmdutil" @@ -20,13 +18,9 @@ func newDestroyCmd() *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 - } + stackID, err := resolveStackID(ctx, cmd, flagStack) + if err != nil { + return err } stack, err := ctx.Client.GetStack(cmd.Context(), stackID) @@ -34,14 +28,9 @@ func newDestroyCmd() *cobra.Command { 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 - } + prompt := fmt.Sprintf("This will permanently delete stack %q. Continue?", stack.Name) + if _, err := cmdutil.Confirm(ctx.Formatter, prompt, flagYes); err != nil { + return err } if err := ctx.Client.DeleteStack(cmd.Context(), stackID); err != nil { @@ -49,8 +38,7 @@ func newDestroyCmd() *cobra.Command { } if ctx.Config.CurrentStack == stackID { - ctx.Config.CurrentStack = "" - _ = ctx.Config.Save() + _ = ctx.Config.SetCurrentStack("") } fmt.Fprintf(os.Stderr, "Stack %q deletion initiated.\n", stack.Name) @@ -59,7 +47,7 @@ func newDestroyCmd() *cobra.Command { } cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation") - cmd.Flags().StringVar(&flagStack, "stack", "", "Stack ID (overrides current context)") + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") return cmd } diff --git a/cmd/stackdome/helpers.go b/cmd/stackdome/helpers.go index 4ec27e0..64a613c 100644 --- a/cmd/stackdome/helpers.go +++ b/cmd/stackdome/helpers.go @@ -1,6 +1,8 @@ package main import ( + "strings" + "github.com/spf13/cobra" "github.com/stackdome/cli/internal/cmdutil" clierrors "github.com/stackdome/cli/internal/errors" @@ -8,14 +10,110 @@ import ( 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 + return resolveStackRef(ctx, cmd, flagStack) + } + current, err := ctx.Config.RequireStack() + if err != nil { + return "", err + } + // Configs written before set-stack resolved its argument hold a stack name, + // which every API call rejects. Heal it in place on first use. + if looksLikeUUID(current) { + return current, nil + } + id, err := resolveStackRef(ctx, cmd, current) + if err != nil { + return "", err + } + _ = ctx.Config.SetCurrentStack(id) + return id, nil +} + +// looksLikeUUID is a shape check, not validation: stack IDs are UUIDs and stack +// names cannot contain the 8-4-4-4-12 layout, which is all we need to tell them +// apart without a lookup. +func looksLikeUUID(s string) bool { + if len(s) != 36 { + return false + } + for i, r := range s { + switch i { + case 8, 13, 18, 23: + if r != '-' { + return false + } + default: + if !strings.ContainsRune("0123456789abcdefABCDEF", r) { + return false + } } - if s == nil { - return "", clierrors.NotFoundError("Stack", flagStack) + } + return true +} + +// resolveStackRef turns whatever the user typed — a stack name, a full ID, or +// the truncated ID the list tables print — into a full stack ID. Everything +// downstream (config current_stack included) needs the ID, so a name stored +// verbatim would brick every later command. +func resolveStackRef(ctx *cmdutil.CommandContext, cmd *cobra.Command, ref string) (string, error) { + stacks, err := ctx.Client.ListStacks(cmd.Context()) + if err != nil { + return "", err + } + ids := make([]string, 0, len(stacks)) + for i := range stacks { + if stacks[i].Name == ref { + return stacks[i].GetId(), nil } - return *s.Id, nil + ids = append(ids, stacks[i].GetId()) + } + return resolveIDPrefix("Stack", ref, ids) +} + +// resolveIDPrefix maps a possibly-truncated ID back to a full one, docker-style: +// list tables print IDs through shortID(), so the IDs users copy are prefixes. +// An exact match always wins; otherwise a unique prefix match is used. +func resolveIDPrefix(kind, arg string, ids []string) (string, error) { + var matches []string + for _, id := range ids { + switch { + case id == arg: + return id, nil + case strings.HasPrefix(id, arg): + matches = append(matches, id) + } + } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return "", clierrors.NotFoundError(kind, arg) + default: + return "", clierrors.ValidationError( + "Ambiguous " + kind + " ID \"" + arg + "\": matches " + strings.Join(matches, ", ")) + } +} + +func resolveBuildID(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID, arg string) (string, error) { + builds, err := ctx.Client.ListBuilds(cmd.Context(), stackID) + if err != nil { + return "", err + } + ids := make([]string, 0, len(builds)) + for _, b := range builds { + ids = append(ids, b.GetId()) + } + return resolveIDPrefix("Build", arg, ids) +} + +func resolveReleaseID(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID, arg string) (string, error) { + releases, err := ctx.Client.ListReleases(cmd.Context(), stackID) + if err != nil { + return "", err + } + ids := make([]string, 0, len(releases)) + for _, r := range releases { + ids = append(ids, r.GetId()) } - return ctx.Config.RequireStack() + return resolveIDPrefix("Release", arg, ids) } diff --git a/cmd/stackdome/helpers_test.go b/cmd/stackdome/helpers_test.go new file mode 100644 index 0000000..db54d15 --- /dev/null +++ b/cmd/stackdome/helpers_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "testing" + + clierrors "github.com/stackdome/cli/internal/errors" +) + +func TestResolveIDPrefix(t *testing.T) { + ids := []string{"web-7e81aaaa", "web-7e81bbbb", "api-1234cccc", "api-1234"} + + tests := []struct { + name string + arg string + want string + wantExit int + }{ + {name: "exact match wins over prefix", arg: "api-1234", want: "api-1234"}, + {name: "unique prefix", arg: "web-7e81a", want: "web-7e81aaaa"}, + {name: "full id", arg: "web-7e81bbbb", want: "web-7e81bbbb"}, + {name: "ambiguous", arg: "web-7e81", wantExit: clierrors.ExitValidation}, + {name: "no match", arg: "zzz", wantExit: clierrors.ExitNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveIDPrefix("Build", tt.arg, ids) + if tt.wantExit != 0 { + cliErr, ok := err.(*clierrors.CLIError) + if !ok { + t.Fatalf("want CLIError, got %v", err) + } + if cliErr.ExitCode != tt.wantExit { + t.Fatalf("exit code: got %d want %d", cliErr.ExitCode, tt.wantExit) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("got %q want %q", got, tt.want) + } + }) + } +} + +func TestLooksLikeUUID(t *testing.T) { + cases := map[string]bool{ + "b02262ac-8e6e-45cd-b18e-acb5d3f97ce4": true, + "B02262AC-8E6E-45CD-B18E-ACB5D3F97CE4": true, + "hello-stack": false, + "": false, + "b02262ac8e6e45cdb18eacb5d3f97ce4": false, + "b02262ac-8e6e-45cd-b18e-acb5d3f97cez": false, + } + for in, want := range cases { + if got := looksLikeUUID(in); got != want { + t.Errorf("looksLikeUUID(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/cmd/stackdome/init.go b/cmd/stackdome/init.go index d304499..8b35899 100644 --- a/cmd/stackdome/init.go +++ b/cmd/stackdome/init.go @@ -25,8 +25,9 @@ resources: subdomain: web env: APP_ENV: "production" + PUBLIC_URL: "{{ self.public_url }}" DB_HOST: "{{ db.host }}" - DB_URL: "postgres://{{ db.host }}:{{ db.port.postgres }}/mydb" + DB_URL: "postgres://{{ db.host }}:{{ db.port }}/mydb" REDIS_URL: "redis://{{ redis.host }}:6379" # secrets: # my-secret: @@ -45,7 +46,6 @@ resources: volumes: - name: db-data path: /var/lib/postgresql/data - stateful: true redis: image: redis:7-alpine @@ -100,7 +100,7 @@ If no compose file is found, a starter template is generated.`, var content []byte if composePath != "" { - sf, err := stackfile.FromCompose(composePath, name) + sf, envFiles, err := stackfile.FromCompose(composePath, name) if err != nil { return clierrors.Wrap(err, "Failed to convert compose file") } @@ -131,7 +131,7 @@ If no compose file is found, a starter template is generated.`, } fmt.Fprintln(os.Stderr) - warnings := checkConvertedStackfile(sf) + warnings := checkConvertedStackfile(sf, envFiles) if len(warnings) > 0 { for _, w := range warnings { fmt.Fprintf(os.Stderr, " %s %s\n", output.Yellow("!"), w) @@ -166,7 +166,7 @@ If no compose file is found, a starter template is generated.`, return cmd } -func checkConvertedStackfile(sf *stackfile.Stackfile) []string { +func checkConvertedStackfile(sf *stackfile.Stackfile, envFiles map[string]string) []string { var warnings []string for name, res := range sf.Resources { if res.Build != nil && res.Build.Repo == "" { @@ -175,8 +175,8 @@ func checkConvertedStackfile(sf *stackfile.Stackfile) []string { 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)) + if ref, ok := envFiles[name]; ok { + warnings = append(warnings, fmt.Sprintf("resource %q used env_file %q in compose — add `env_file: %s` under it to load those vars at deploy", name, ref, ref)) } } return warnings diff --git a/cmd/stackdome/login.go b/cmd/stackdome/login.go index 5a7dc21..248ed68 100644 --- a/cmd/stackdome/login.go +++ b/cmd/stackdome/login.go @@ -6,7 +6,7 @@ import ( "os" "strings" - serverapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + serverapi "github.com/Stackdome/stackdome/pkg/api/openapi" "github.com/spf13/cobra" "github.com/stackdome/cli/internal/client" "github.com/stackdome/cli/internal/config" @@ -31,6 +31,11 @@ func newLoginCmd() *cobra.Command { return clierrors.ValidationError("--url is required") } + // Refuse to block on a prompt nobody can answer. + if flagToken == "" && (flagEmail == "" || flagPassword == "") && !term.IsTerminal(int(os.Stdin.Fd())) { + return clierrors.ValidationError("non-interactive login requires --token, or both --email and --password") + } + cfg, err := config.Load() if err != nil { return err @@ -63,20 +68,14 @@ func loginWithToken(cmd *cobra.Command, cfg *config.Config, serverURL, token str 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 { + if err := persistLogin(cmd, c, cfg); err != nil { return err } @@ -84,6 +83,28 @@ func loginWithToken(cmd *cobra.Command, cfg *config.Config, serverURL, token str return nil } +// persistLogin writes the credential before resolving the project: a project +// lookup that fails must not throw away a token the user just obtained. Without +// a project, commands that need one fail later with a clear message — but the +// login itself stands. +func persistLogin(cmd *cobra.Command, c *client.Client, cfg *config.Config) error { + // An explicit login always persists in full, even when the values happen to + // equal STACKDOME_URL / STACKDOME_TOKEN. + cfg.AdoptEnvValues() + if err := cfg.Save(); err != nil { + return err + } + + projectName, err := c.ResolveDefaultProject(cmd.Context(), cfg.OrganizationID) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: %s\n", clierrors.UserMessage(err)) + return nil + } + + cfg.ProjectName = projectName + return cfg.Save() +} + func loginWithCredentials(cmd *cobra.Command, cfg *config.Config, serverURL, email, password string, insecure bool) error { if email == "" { email = promptInput("Email: ") @@ -99,20 +120,14 @@ func loginWithCredentials(cmd *cobra.Command, cfg *config.Config, serverURL, ema 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 { + if err := persistLogin(cmd, c, cfg); err != nil { return err } diff --git a/cmd/stackdome/logs.go b/cmd/stackdome/logs.go index 244b9da..6bc2699 100644 --- a/cmd/stackdome/logs.go +++ b/cmd/stackdome/logs.go @@ -50,6 +50,9 @@ func newLogsCmd() *cobra.Command { fmt.Fprintf(os.Stderr, "Error: %s\n", e.Data) return clierrors.New(e.Data) } + if e.IsEnd() { + return nil + } fmt.Println(e.Data) return nil }) @@ -63,4 +66,3 @@ func newLogsCmd() *cobra.Command { return cmd } - diff --git a/cmd/stackdome/open.go b/cmd/stackdome/open.go index dbed659..d2e3701 100644 --- a/cmd/stackdome/open.go +++ b/cmd/stackdome/open.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/stackdome/cli/internal/cmdutil" clierrors "github.com/stackdome/cli/internal/errors" + "github.com/stackdome/cli/internal/output" ) func newOpenCmd() *cobra.Command { @@ -18,6 +19,10 @@ func newOpenCmd() *cobra.Command { cmd := &cobra.Command{ Use: "open [resource]", Short: "Open a resource's public URL in the browser", + Long: `Open a resource's public URL in the browser. + +With -o json|yaml no browser is launched: the public URLs are printed to stdout +as {"target": ..., "urls": [...]}.`, 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) @@ -25,19 +30,27 @@ func newOpenCmd() *cobra.Command { return err } - resources, err := ctx.Client.GetStackResources(cmd.Context(), stackID) + stack, err := ctx.Client.GetStack(cmd.Context(), stackID) + if err != nil { + return err + } + + // Public URLs live on the release status, not on the stack resources. + live, err := ctx.Client.GetStackLiveStatus(cmd.Context(), stack) if err != nil { return err } + resources := stack.Spec.StackResources + resourceFilter := "" if len(args) > 0 { resourceFilter = args[0] } type publicURL struct { - Resource string - URL string + Resource string `json:"resource" yaml:"resource"` + URL string `json:"url" yaml:"url"` } var urls []publicURL @@ -45,10 +58,11 @@ func newOpenCmd() *cobra.Command { if resourceFilter != "" && res.Name != resourceFilter { continue } - if res.Status == nil { + status := output.ResourceStatus(live, res.Name) + if status == nil { continue } - for _, ing := range res.Status.PublicIngress { + for _, ing := range status.PublicIngress { if ing.Url != nil && *ing.Url != "" { urls = append(urls, publicURL{Resource: res.Name, URL: *ing.Url}) } @@ -73,6 +87,17 @@ func newOpenCmd() *cobra.Command { return clierrors.New("No public URLs found in this stack") } + target := urls[0].URL + + // Structured mode is for scripts: report the URLs on stdout instead + // of launching a browser. + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(struct { + Target string `json:"target" yaml:"target"` + URLs []publicURL `json:"urls" yaml:"urls"` + }{Target: target, URLs: urls}) + } + if len(urls) > 1 { tbl := ctx.Formatter.NewTable("RESOURCE", "URL") for _, u := range urls { @@ -82,7 +107,6 @@ func newOpenCmd() *cobra.Command { fmt.Fprintln(os.Stderr) } - target := urls[0].URL fmt.Fprintf(os.Stderr, "Opening %s...\n", target) return openBrowser(target) })), diff --git a/cmd/stackdome/release.go b/cmd/stackdome/release.go new file mode 100644 index 0000000..a2c9781 --- /dev/null +++ b/cmd/stackdome/release.go @@ -0,0 +1,317 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "time" + + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/client" + "github.com/stackdome/cli/internal/cmdutil" + clierrors "github.com/stackdome/cli/internal/errors" + "github.com/stackdome/cli/internal/output" +) + +func newReleaseCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "release", + Short: "Inspect and cancel stack releases", + } + + cmd.AddCommand(newReleaseListCmd()) + cmd.AddCommand(newReleaseInfoCmd()) + cmd.AddCommand(newReleaseCancelCmd()) + cmd.AddCommand(newReleaseEventsCmd()) + return cmd +} + +func newReleaseListCmd() *cobra.Command { + var flagStack string + + cmd := &cobra.Command{ + Use: "list", + Short: "List releases 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 + } + + releases, err := ctx.Client.ListReleases(cmd.Context(), stackID) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(releases) + } + if len(releases) == 0 { + fmt.Fprintln(os.Stderr, "No releases found.") + return nil + } + + tbl := ctx.Formatter.NewTable("ID", "SEQ", "STATE", "CAUSE", "CREATED", "DURATION") + for _, r := range releases { + tbl.AddRow( + shortID(r.GetId()), + fmt.Sprintf("%d", r.GetSequence()), + releaseStateColor(r.State), + releaseCause(r.Cause), + releaseAge(r.CreatedAt), + releaseDuration(r.CreatedAt, r.CompletedAt), + ) + } + tbl.Render() + return nil + })), + } + + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + return cmd +} + +func newReleaseInfoCmd() *cobra.Command { + var flagStack string + + cmd := &cobra.Command{ + Use: "info ", + Short: "Show release 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 + } + + releaseID, err := resolveReleaseID(ctx, cmd, stackID, args[0]) + if err != nil { + return err + } + + release, err := ctx.Client.GetRelease(cmd.Context(), stackID, releaseID) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(release) + } + renderReleaseInfo(release) + return nil + })), + } + + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + return cmd +} + +func newReleaseCancelCmd() *cobra.Command { + var flagStack string + + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel a pending release", + 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 + } + + releaseID, err := resolveReleaseID(ctx, cmd, stackID, args[0]) + if err != nil { + return err + } + + if err := ctx.Client.CancelRelease(cmd.Context(), stackID, releaseID); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Release %s cancelled.\n", releaseID) + return nil + })), + } + + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + return cmd +} + +func newReleaseEventsCmd() *cobra.Command { + var ( + flagStack string + flagFollow bool + ) + + cmd := &cobra.Command{ + Use: "events ", + Short: "Show release events", + 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 + } + + releaseID, err := resolveReleaseID(ctx, cmd, stackID, args[0]) + if err != nil { + return err + } + + if flagFollow { + events, err := ctx.Client.StreamReleaseEvents(cmd.Context(), stackID, releaseID, 0) + if err != nil { + return err + } + var streamErr error + for e := range events { + if e.Event == "error" { + // Surfaced as the command error; printing it here too + // would duplicate it (and pollute -o json stdout). + streamErr = clierrors.New(e.Data) + continue + } + // Structured mode streams newline-delimited JSON envelopes; + // the human formatter would put prose and ANSI colour on + // stdout. + if ctx.Formatter.IsTable() { + printReleaseEventLine(os.Stdout, e) + } else { + fmt.Fprintf(os.Stdout, "{\"event\":%q,\"data\":%s}\n", e.Event, eventDataJSON(e.Data)) + } + } + return streamErr + } + + events, err := ctx.Client.ListReleaseEvents(cmd.Context(), stackID, releaseID, 0) + if err != nil { + return err + } + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(events) + } + for _, ev := range events { + fmt.Fprintln(os.Stdout, formatReleaseEvent(ev)) + } + return nil + })), + } + + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + cmd.Flags().BoolVarP(&flagFollow, "follow", "f", false, "Follow the event stream") + return cmd +} + +func renderReleaseInfo(r *openapi.StackReleaseDetail) { + fmt.Printf("Release: %s\n", r.GetId()) + fmt.Printf("Sequence: #%d\n", r.GetSequence()) + fmt.Printf("State: %s\n", releaseStateColor(r.State)) + fmt.Printf("Cause: %s\n", releaseCause(r.Cause)) + if r.Message != nil && *r.Message != "" { + fmt.Printf("Message: %s\n", *r.Message) + } + if r.CreatedAt != nil { + fmt.Printf("Created: %s\n", r.CreatedAt.Local().Format(time.RFC3339)) + } + fmt.Printf("Duration: %s\n", releaseDuration(r.CreatedAt, r.CompletedAt)) + + if len(r.ValidationErrors) > 0 { + fmt.Println() + fmt.Println("Validation errors:") + for _, ve := range r.ValidationErrors { + fmt.Printf(" - %s\n", validationErrorLine(ve)) + } + } +} + +// printReleaseEventLine renders one streamed SSE frame. The payload is a +// ReleaseEvent JSON document; anything unparseable is printed raw so nothing is +// silently swallowed. +// eventDataJSON embeds an SSE payload as-is when it is already JSON, and as a +// JSON string when it is not, so every streamed line stays valid JSON. +func eventDataJSON(data string) string { + if json.Valid([]byte(data)) { + return data + } + b, err := json.Marshal(data) + if err != nil { + return `""` + } + return string(b) +} + +func printReleaseEventLine(w io.Writer, e client.SSEEvent) { + if e.Event == "error" { + fmt.Fprintf(w, "%s %s\n", output.Red("error"), e.Data) + return + } + var ev openapi.ReleaseEvent + if json.Unmarshal([]byte(e.Data), &ev) != nil { + fmt.Fprintln(w, e.Data) + return + } + fmt.Fprintln(w, formatReleaseEvent(ev)) +} + +func formatReleaseEvent(ev openapi.ReleaseEvent) string { + var b strings.Builder + if ev.OccurredAt != nil { + b.WriteString(output.Dim(ev.OccurredAt.Local().Format(time.TimeOnly)) + " ") + } + if ev.ResourceName != nil && *ev.ResourceName != "" { + b.WriteString(output.Cyan(*ev.ResourceName) + " ") + } + msg := ev.GetMessage() + if msg == "" { + msg = ev.GetType() + } + if ev.Level != nil && strings.EqualFold(*ev.Level, "error") { + msg = output.Red(msg) + } + b.WriteString(msg) + return b.String() +} + +func releaseStateColor(state *openapi.StackReleaseState) string { + if state == nil { + return "Unknown" + } + s := string(*state) + switch *state { + case openapi.RELEASE_STATE_RELEASED: + return output.Green(s) + case openapi.RELEASE_STATE_FAILED, openapi.RELEASE_STATE_CANCELLED: + return output.Red(s) + case openapi.RELEASE_STATE_PENDING, openapi.RELEASE_STATE_IN_PROGRESS: + return output.Yellow(s) + default: + return s + } +} + +func releaseCause(cause *openapi.ReleaseCause) string { + if cause == nil || cause.Kind == nil { + return "-" + } + return string(*cause.Kind) +} + +func releaseAge(t *time.Time) string { + if t == nil { + return "-" + } + return output.TimeAgo(*t) +} + +func releaseDuration(start, end *time.Time) string { + if start == nil || end == nil { + return "-" + } + d := end.Sub(*start) + if d < time.Second { + return "<1s" + } + return d.Round(time.Second).String() +} diff --git a/cmd/stackdome/release_test.go b/cmd/stackdome/release_test.go new file mode 100644 index 0000000..59b3adb --- /dev/null +++ b/cmd/stackdome/release_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestEventDataJSONStaysValid(t *testing.T) { + for _, data := range []string{`{"state":"Released"}`, "plain text \"quoted\"", ""} { + line := `{"event":"message","data":` + eventDataJSON(data) + `}` + if !json.Valid([]byte(line)) { + t.Errorf("invalid NDJSON line for payload %q: %s", data, line) + } + } +} + +func TestRedactSecret(t *testing.T) { + if got := redactSecret("sdm_abcdefghijklmnop"); got != "" { + t.Errorf("long token: got %q", got) + } + if got := redactSecret("short"); got != "" { + t.Errorf("short token: got %q", got) + } + if got := redactSecret(""); got != "" { + t.Errorf("empty token: got %q", got) + } +} diff --git a/cmd/stackdome/root.go b/cmd/stackdome/root.go index 336402a..da17156 100644 --- a/cmd/stackdome/root.go +++ b/cmd/stackdome/root.go @@ -24,7 +24,24 @@ func newRootCmd() *cobra.Command { rootCmd := &cobra.Command{ Use: "stackdome", Short: "CLI for the Stackdome platform", - Long: "Deploy, manage, and monitor your applications on Stackdome.", + Long: `Deploy, manage, and monitor your applications on Stackdome. + +Every command runs non-interactively: pass --yes to skip confirmations, +STACKDOME_TOKEN (and STACKDOME_URL) to authenticate without a config file, and +-o json|yaml for machine-readable output. A token scoped too narrowly to look up +projects can name its scope directly with STACKDOME_ORG and STACKDOME_PROJECT. In -o json/yaml mode stdout carries +only the result object; all prose, prompts, and progress go to stderr. + +Run ` + "`stackdome whoami`" + ` first to verify credentials. + +Exit codes: + 0 success + 1 general error + 2 authentication / authorization failure + 3 not found + 4 invalid input or usage + 5 conflict (already exists, or state does not allow the operation) + 130 canceled (interrupted, or a confirmation was declined)`, SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { @@ -56,6 +73,7 @@ func newRootCmd() *cobra.Command { rootCmd.AddCommand(newLoginCmd()) rootCmd.AddCommand(newLogoutCmd()) rootCmd.AddCommand(newSignupCmd()) + rootCmd.AddCommand(newWhoamiCmd()) rootCmd.AddCommand(newConfigCmd()) rootCmd.AddCommand(newDeployCmd()) rootCmd.AddCommand(newStatusCmd()) @@ -64,16 +82,42 @@ func newRootCmd() *cobra.Command { rootCmd.AddCommand(newStackCmd()) rootCmd.AddCommand(newLogsCmd()) rootCmd.AddCommand(newBuildCmd()) + rootCmd.AddCommand(newReleaseCmd()) rootCmd.AddCommand(newRestartCmd()) rootCmd.AddCommand(newOpenCmd()) rootCmd.AddCommand(newSecretCmd()) rootCmd.AddCommand(newVolumeCmd()) + rootCmd.AddCommand(newAddonCmd()) + rootCmd.AddCommand(newTokenCmd()) rootCmd.AddCommand(newInitCmd()) rootCmd.AddCommand(newCompletionCmd()) + // Usage errors exit 4, as the help text above promises. Cobra reports bad + // flags and bad arg counts as plain errors, which would otherwise exit 1. + rootCmd.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return clierrors.ValidationError(err.Error()) + }) + wrapArgErrors(rootCmd) + return rootCmd } +func wrapArgErrors(cmd *cobra.Command) { + for _, sub := range cmd.Commands() { + wrapArgErrors(sub) + } + if cmd.Args == nil { + return + } + inner := cmd.Args + cmd.Args = func(c *cobra.Command, args []string) error { + if err := inner(c, args); err != nil { + return clierrors.ValidationError(err.Error()) + } + return nil + } +} + func run() int { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() diff --git a/cmd/stackdome/secret.go b/cmd/stackdome/secret.go index 9e2ec05..ba96992 100644 --- a/cmd/stackdome/secret.go +++ b/cmd/stackdome/secret.go @@ -6,7 +6,7 @@ import ( "strings" "time" - openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" "github.com/joho/godotenv" "github.com/spf13/cobra" "github.com/stackdome/cli/internal/cmdutil" @@ -139,6 +139,10 @@ func newSecretCreateCmd() *cobra.Command { return err } + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(result) + } + fmt.Fprintf(os.Stderr, "Secret %q created.\n", result.Name) return nil })), @@ -188,11 +192,15 @@ func newSecretSetCmd() *cobra.Command { updated.Description = existing.Description } - _, err = ctx.Client.UpdateSecret(cmd.Context(), *existing.Id, updated) + result, err := ctx.Client.UpdateSecret(cmd.Context(), *existing.Id, updated) if err != nil { return err } + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(result) + } + fmt.Fprintf(os.Stderr, "Secret %q updated.\n", args[0]) return nil })), @@ -220,14 +228,8 @@ func newSecretDeleteCmd() *cobra.Command { 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 := cmdutil.Confirm(ctx.Formatter, fmt.Sprintf("Delete secret %q?", args[0]), flagYes); err != nil { + return err } if err := ctx.Client.DeleteSecret(cmd.Context(), *secret.Id); err != nil { diff --git a/cmd/stackdome/signup.go b/cmd/stackdome/signup.go index 825c925..ff1ca43 100644 --- a/cmd/stackdome/signup.go +++ b/cmd/stackdome/signup.go @@ -48,11 +48,6 @@ func newSignupCmd() *cobra.Command { 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 @@ -62,11 +57,10 @@ func newSignupCmd() *cobra.Command { 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 { + if err := persistLogin(cmd, c, cfg); err != nil { return err } diff --git a/cmd/stackdome/stack.go b/cmd/stackdome/stack.go index b266f83..24a13d8 100644 --- a/cmd/stackdome/stack.go +++ b/cmd/stackdome/stack.go @@ -49,8 +49,8 @@ func newStackListCmd() *cobra.Command { marker = "*" } state := "Unknown" - if s.Status != nil && s.Status.State != nil { - state = *s.Status.State + if rel := output.StackRelease(&s); rel != nil && rel.State != nil { + state = string(*rel.State) } id := "" if s.Id != nil { @@ -82,7 +82,12 @@ func newStackInfoCmd() *cobra.Command { return ctx.Formatter.PrintStructured(stack) } - output.RenderStackStatus(os.Stdout, stack, true) + live, err := ctx.Client.GetStackLiveStatus(cmd.Context(), stack) + if err != nil { + return err + } + + output.RenderStackStatus(os.Stdout, stack, live, true) return nil })), } @@ -104,14 +109,8 @@ func newStackDeleteCmd() *cobra.Command { 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 := cmdutil.Confirm(ctx.Formatter, fmt.Sprintf("Delete stack %q?", stack.Name), flagYes); err != nil { + return err } if err := ctx.Client.DeleteStack(cmd.Context(), *stack.Id); err != nil { @@ -119,8 +118,7 @@ func newStackDeleteCmd() *cobra.Command { } if ctx.Config.CurrentStack == *stack.Id { - ctx.Config.CurrentStack = "" - _ = ctx.Config.Save() + _ = ctx.Config.SetCurrentStack("") } fmt.Fprintf(os.Stderr, "Stack %q deletion initiated.\n", stack.Name) diff --git a/cmd/stackdome/status.go b/cmd/stackdome/status.go index 82f8228..51a3cfc 100644 --- a/cmd/stackdome/status.go +++ b/cmd/stackdome/status.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" "github.com/stackdome/cli/internal/cmdutil" - clierrors "github.com/stackdome/cli/internal/errors" "github.com/stackdome/cli/internal/output" ) @@ -21,22 +20,9 @@ func newStatusCmd() *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 - } + stackID, err := resolveStackID(ctx, cmd, flagStack) + if err != nil { + return err } if flagWatch { @@ -52,14 +38,19 @@ func newStatusCmd() *cobra.Command { return ctx.Formatter.PrintStructured(stack) } - output.RenderStackStatus(os.Stdout, stack, flagConditions) + live, err := ctx.Client.GetStackLiveStatus(cmd.Context(), stack) + if err != nil { + return err + } + + output.RenderStackStatus(os.Stdout, stack, live, 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)") + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") return cmd } @@ -74,9 +65,25 @@ func watchStatus(ctx *cmdutil.CommandContext, cmd *cobra.Command, stackID string return err } - // Clear screen - os.Stdout.WriteString("\033[2J\033[H") - output.RenderStackStatus(os.Stdout, stack, showConditions) + // Structured mode emits one object per tick — no redraw, no escape + // codes, so `status -w -o json` stays parseable as it streams. + if !ctx.Formatter.IsTable() { + if err := ctx.Formatter.PrintStructured(stack); err != nil { + return err + } + } else { + live, err := ctx.Client.GetStackLiveStatus(cmd.Context(), stack) + if err != nil { + return err + } + + // Clear screen — only meaningful on a terminal; escape codes would + // otherwise corrupt piped/redirected output. + if output.IsTTY() { + os.Stdout.WriteString("\033[2J\033[H") + } + output.RenderStackStatus(os.Stdout, stack, live, showConditions) + } select { case <-cmd.Context().Done(): diff --git a/cmd/stackdome/token.go b/cmd/stackdome/token.go new file mode 100644 index 0000000..908a7dc --- /dev/null +++ b/cmd/stackdome/token.go @@ -0,0 +1,203 @@ +package main + +import ( + "fmt" + "os" + "strings" + "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 newTokenCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "token", + Short: "Manage API tokens", + Long: "Manage personal access tokens for headless and CI access.\n\nUse `stackdome token scopes` to discover valid scopes.", + } + + cmd.AddCommand(newTokenCreateCmd()) + cmd.AddCommand(newTokenListCmd()) + cmd.AddCommand(newTokenDeleteCmd()) + cmd.AddCommand(newTokenScopesCmd()) + return cmd +} + +func newTokenCreateCmd() *cobra.Command { + var ( + flagScopes []string + flagResources []string + flagExpires string + ) + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create an API token", + Long: "Create an API token. The token value is shown once and cannot be retrieved again.", + Example: " stackdome token create ci --scope 'stacks:*' --scope secrets:read --expires 720h\n" + + " stackdome token create agent --scope '*:*' -o json", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + if len(flagScopes) == 0 { + return clierrors.ValidationError("At least one --scope is required (see `stackdome token scopes`)") + } + expiresAt, err := parseExpiry(flagExpires, time.Now()) + if err != nil { + return err + } + + token, err := ctx.Client.CreateAPIToken(cmd.Context(), args[0], flagScopes, flagResources, expiresAt) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(token) + } + + fmt.Fprintf(os.Stderr, "Token %q created. Save it now — it will not be shown again.\n\n", token.GetName()) + fmt.Println(token.GetToken()) + if t := token.GetExpiresAt(); !t.IsZero() { + fmt.Fprintf(os.Stderr, "\nExpires: %s\n", t.Local().Format(time.RFC3339)) + } + return nil + })), + } + + cmd.Flags().StringArrayVar(&flagScopes, "scope", nil, "Scope as resource:action, e.g. stacks:* (repeatable)") + cmd.Flags().StringArrayVar(&flagResources, "resource-id", nil, "Restrict the token to specific resource IDs (repeatable)") + cmd.Flags().StringVar(&flagExpires, "expires", "", "Lifetime as a duration, e.g. 720h (default: never)") + + return cmd +} + +func newTokenListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List API tokens", + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + tokens, err := ctx.Client.ListAPITokens(cmd.Context()) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(tokens) + } + + if len(tokens) == 0 { + fmt.Fprintln(os.Stderr, "No API tokens found.") + return nil + } + + tbl := ctx.Formatter.NewTable("ID", "NAME", "PREFIX", "SCOPES", "EXPIRES", "LAST USED") + for _, t := range tokens { + tbl.AddRow( + t.GetId(), + t.GetName(), + dashIfEmpty(t.GetTokenPrefix()), + dashIfEmpty(strings.Join(t.Scopes, ", ")), + tokenExpiry(t.ExpiresAt), + tokenLastUsed(t.LastUsedAt), + ) + } + tbl.Render() + return nil + })), + } +} + +func newTokenDeleteCmd() *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an API token", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + if _, err := cmdutil.Confirm(ctx.Formatter, fmt.Sprintf("Delete API token %q?", args[0]), flagYes); err != nil { + return err + } + + if err := ctx.Client.DeleteAPIToken(cmd.Context(), args[0]); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "API token %q deleted.\n", args[0]) + return nil + })), + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation") + return cmd +} + +func newTokenScopesCmd() *cobra.Command { + return &cobra.Command{ + Use: "scopes", + Short: "List valid token scopes", + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + scopes, err := ctx.Client.ListTokenScopes(cmd.Context()) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(scopes) + } + + tbl := ctx.Formatter.NewTable("RESOURCE", "ACTIONS") + for _, s := range scopes.Items { + tbl.AddRow(s.GetResource(), strings.Join(s.Actions, ", ")) + } + tbl.Render() + + if full := scopes.GetFullAccessScope(); full != "" { + fmt.Fprintf(os.Stderr, "\nFull access: %s\n", full) + } + return nil + })), + } +} + +// parseExpiry turns a --expires duration into an absolute timestamp; empty means +// the token never expires. +func parseExpiry(expires string, now time.Time) (*time.Time, error) { + if expires == "" { + return nil, nil + } + d, err := time.ParseDuration(expires) + if err != nil { + return nil, clierrors.ValidationError(fmt.Sprintf("invalid --expires %q, expected a duration like 720h", expires)) + } + if d <= 0 { + return nil, clierrors.ValidationError("--expires must be a positive duration") + } + t := now.Add(d) + return &t, nil +} + +// tokenExpiry prints an absolute date — TimeAgo only reads well for the past. +func tokenExpiry(t *time.Time) string { + if t == nil || t.IsZero() { + return "never" + } + return t.Local().Format(time.RFC3339) +} + +func tokenLastUsed(t *time.Time) string { + if t == nil || t.IsZero() { + return "never" + } + return output.TimeAgo(*t) +} + +func dashIfEmpty(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/cmd/stackdome/token_test.go b/cmd/stackdome/token_test.go new file mode 100644 index 0000000..e840bfb --- /dev/null +++ b/cmd/stackdome/token_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" + "time" +) + +func TestParseExpiry(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + in string + want *time.Time + wantErr bool + }{ + {in: "", want: nil}, + {in: "720h", want: ptr(now.Add(720 * time.Hour))}, + {in: "30m", want: ptr(now.Add(30 * time.Minute))}, + {in: "30d", wantErr: true}, // Go durations have no day unit + {in: "yesterday", wantErr: true}, + {in: "0h", wantErr: true}, + {in: "-1h", wantErr: true}, + } + + for _, tc := range tests { + got, err := parseExpiry(tc.in, now) + if tc.wantErr { + if err == nil { + t.Errorf("parseExpiry(%q) = %v, want error", tc.in, got) + } + continue + } + if err != nil { + t.Errorf("parseExpiry(%q): %v", tc.in, err) + continue + } + if (got == nil) != (tc.want == nil) || (got != nil && !got.Equal(*tc.want)) { + t.Errorf("parseExpiry(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func ptr(t time.Time) *time.Time { return &t } diff --git a/cmd/stackdome/version.go b/cmd/stackdome/version.go index bc187ca..3724fce 100644 --- a/cmd/stackdome/version.go +++ b/cmd/stackdome/version.go @@ -1,10 +1,10 @@ package main import ( - "fmt" "runtime" "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" ) var ( @@ -17,13 +17,26 @@ 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) + RunE: cmdutil.WithContext(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + info := struct { + Version string `json:"version"` + Commit string `json:"commit"` + Built string `json:"built"` + Go string `json:"go"` + OSArch string `json:"os_arch"` + }{Version, GitCommit, BuildDate, runtime.Version(), runtime.GOOS + "/" + runtime.GOARCH} + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(info) + } + + f := ctx.Formatter + f.Printf("stackdome %s\n", info.Version) + f.Printf(" commit: %s\n", info.Commit) + f.Printf(" built: %s\n", info.Built) + f.Printf(" go: %s\n", info.Go) + f.Printf(" os/arch: %s\n", info.OSArch) return nil - }, + }), } } diff --git a/cmd/stackdome/volume.go b/cmd/stackdome/volume.go index b1da211..55ef197 100644 --- a/cmd/stackdome/volume.go +++ b/cmd/stackdome/volume.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" "github.com/spf13/cobra" "github.com/stackdome/cli/internal/cmdutil" clierrors "github.com/stackdome/cli/internal/errors" @@ -17,16 +17,24 @@ func newVolumeCmd() *cobra.Command { } cmd.AddCommand(newVolumeListCmd()) + cmd.AddCommand(newVolumeCreateCmd()) cmd.AddCommand(newVolumeDeleteCmd()) return cmd } func newVolumeListCmd() *cobra.Command { - return &cobra.Command{ + var flagStack string + + cmd := &cobra.Command{ Use: "list", - Short: "List volumes", + Short: "List volumes of a stack", RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { - volumes, err := ctx.Client.ListVolumes(cmd.Context()) + stackID, err := resolveStackID(ctx, cmd, flagStack) + if err != nil { + return err + } + + volumes, err := ctx.Client.ListVolumes(cmd.Context(), stackID) if err != nil { return err } @@ -53,17 +61,62 @@ func newVolumeListCmd() *cobra.Command { return nil })), } + + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") + return cmd +} + +func newVolumeCreateCmd() *cobra.Command { + var ( + flagSize string + flagAccessMode string + ) + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a volume", + Args: cobra.ExactArgs(1), + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + if flagSize == "" { + return clierrors.ValidationError("--size is required (e.g. 5Gi)") + } + + volume, err := ctx.Client.CreateVolume(cmd.Context(), args[0], flagSize, flagAccessMode) + if err != nil { + return err + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(volume) + } + + fmt.Fprintf(os.Stderr, "Volume %q created.\n", volume.Name) + return nil + })), + } + + cmd.Flags().StringVar(&flagSize, "size", "", "Volume size (e.g. 5Gi)") + cmd.Flags().StringVar(&flagAccessMode, "access-mode", "ReadWriteOnce", "Access mode (ReadWriteOnce, ReadWriteMany, ReadOnlyMany)") + return cmd } func newVolumeDeleteCmd() *cobra.Command { - var flagYes bool + var ( + flagYes bool + flagStack string + ) 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]) + stackID, err := resolveStackID(ctx, cmd, flagStack) + if err != nil { + return err + } + + volume, err := ctx.Client.FindVolumeByName(cmd.Context(), stackID, args[0]) if err != nil { return err } @@ -71,14 +124,8 @@ func newVolumeDeleteCmd() *cobra.Command { 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 := cmdutil.Confirm(ctx.Formatter, fmt.Sprintf("Delete volume %q?", args[0]), flagYes); err != nil { + return err } if err := ctx.Client.DeleteVolume(cmd.Context(), *volume.Id); err != nil { @@ -91,6 +138,7 @@ func newVolumeDeleteCmd() *cobra.Command { } cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation") + cmd.Flags().StringVarP(&flagStack, "stack", "s", "", "Stack name (overrides current context)") return cmd } diff --git a/cmd/stackdome/whoami.go b/cmd/stackdome/whoami.go new file mode 100644 index 0000000..de1172b --- /dev/null +++ b/cmd/stackdome/whoami.go @@ -0,0 +1,73 @@ +package main + +import ( + "strings" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/cmdutil" +) + +type whoamiInfo struct { + User string `json:"user"` + Email string `json:"email,omitempty"` + Org string `json:"organization_id"` + Project string `json:"project,omitempty"` + AuthMethod string `json:"auth_method"` + TokenSource string `json:"token_source"` + ServerURL string `json:"server_url"` + Stack string `json:"current_stack,omitempty"` +} + +func newWhoamiCmd() *cobra.Command { + return &cobra.Command{ + Use: "whoami", + Short: "Show the authenticated user, org, project, and auth method", + Long: "Show which credentials are in play. Run this first to verify a token works.", + RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, args []string) error { + user, err := ctx.Client.GetCurrentUser(cmd.Context()) + if err != nil { + return err + } + + authMethod := "session (jwt)" + if strings.HasPrefix(ctx.Config.AccessToken, "sdm_") { + authMethod = "api token" + } + tokenSource := "config file" + if ctx.Config.TokenFromEnv() { + tokenSource = "STACKDOME_TOKEN" + } + + info := whoamiInfo{ + User: userDisplayName(user), + Email: user.GetEmail(), + Org: ctx.Config.OrganizationID, + Project: ctx.Config.ProjectName, + AuthMethod: authMethod, + TokenSource: tokenSource, + ServerURL: ctx.Config.ServerURL, + Stack: ctx.Config.CurrentStack, + } + + if !ctx.Formatter.IsTable() { + return ctx.Formatter.PrintStructured(info) + } + + f := ctx.Formatter + f.Printf("User: %s\n", info.User) + if info.Email != "" && info.Email != info.User { + f.Printf("Email: %s\n", info.Email) + } + f.Printf("Org: %s\n", info.Org) + if info.Project != "" { + f.Printf("Project: %s\n", info.Project) + } + f.Printf("Auth: %s (from %s)\n", info.AuthMethod, info.TokenSource) + f.Printf("Server: %s\n", info.ServerURL) + if info.Stack != "" { + f.Printf("Stack: %s\n", info.Stack) + } + return nil + })), + } +} diff --git a/go.mod b/go.mod index ac2b0a4..a089aa6 100644 --- a/go.mod +++ b/go.mod @@ -3,138 +3,53 @@ module github.com/stackdome/cli go 1.25.0 require ( - github.com/ashishmax31/stackdome-api-server v0.0.0-00010101000000-000000000000 + github.com/Stackdome/stackdome v0.0.1-alpha.0.20260805180415-8ce931c66403 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/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 - 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 + golang.org/x/term v0.44.0 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.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-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 - 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.5.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // 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.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-20241129210726-2c02b8208cf8 // indirect - github.com/golang/protobuf v1.5.4 // 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-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.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 - github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/josharian/intern v1.0.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // 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/kr/text v0.2.0 // indirect + github.com/lib/pq v1.10.9 // 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 - 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.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/samber/lo v1.53.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/x448/float16 v0.8.4 // 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 - go.opentelemetry.io/otel/trace v1.43.0 // indirect - go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // 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-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 + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/cli-runtime v0.34.2 // indirect - k8s.io/component-base v0.35.2 // indirect + gorm.io/gorm v1.25.12 // indirect + k8s.io/api v0.35.2 // indirect + k8s.io/apimachinery v0.35.2 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // 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 + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) - -replace soradev.io/cluster-agent => ../cluster-agent - -replace github.com/ashishmax31/stackdome-api-server => ../api-server/api-server diff --git a/go.sum b/go.sum index d8e3e2b..829f2b2 100644 --- a/go.sum +++ b/go.sum @@ -1,211 +1,11 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= -cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= -cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= -cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= -cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= -cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= -cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= -cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= -cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= -cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= -cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU= -cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA= -cloud.google.com/go v0.111.0 h1:YHLKNupSD1KqjDbQ3+LVdQ81h/UJbJyZG203cEfnQgM= -cloud.google.com/go v0.111.0/go.mod h1:0mibmpKP1TyOOFYQY5izo0LnT+ecvOQ0Sg3OdmMiNRU= -cloud.google.com/go/aiplatform v1.22.0/go.mod h1:ig5Nct50bZlzV6NvKaTwmplLLddFx0YReh9WfTO5jKw= -cloud.google.com/go/aiplatform v1.24.0/go.mod h1:67UUvRBKG6GTayHKV8DBv2RtR1t93YRu5B1P3x99mYY= -cloud.google.com/go/analytics v0.11.0/go.mod h1:DjEWCu41bVbYcKyvlws9Er60YE4a//bK6mnhWvQeFNI= -cloud.google.com/go/analytics v0.12.0/go.mod h1:gkfj9h6XRf9+TS4bmuhPEShsh3hH8PAZzm/41OOhQd4= -cloud.google.com/go/area120 v0.5.0/go.mod h1:DE/n4mp+iqVyvxHN41Vf1CR602GiHQjFPusMFW6bGR4= -cloud.google.com/go/area120 v0.6.0/go.mod h1:39yFJqWVgm0UZqWTOdqkLhjoC7uFfgXRC8g/ZegeAh0= -cloud.google.com/go/artifactregistry v1.6.0/go.mod h1:IYt0oBPSAGYj/kprzsBjZ/4LnG/zOcHyFHjWPCi6SAQ= -cloud.google.com/go/artifactregistry v1.7.0/go.mod h1:mqTOFOnGZx8EtSqK/ZWcsm/4U8B77rbcLP6ruDU2Ixk= -cloud.google.com/go/asset v1.5.0/go.mod h1:5mfs8UvcM5wHhqtSv8J1CtxxaQq3AdBxxQi2jGW/K4o= -cloud.google.com/go/asset v1.7.0/go.mod h1:YbENsRK4+xTiL+Ofoj5Ckf+O17kJtgp3Y3nn4uzZz5s= -cloud.google.com/go/asset v1.8.0/go.mod h1:mUNGKhiqIdbr8X7KNayoYvyc4HbbFO9URsjbytpUaW0= -cloud.google.com/go/assuredworkloads v1.5.0/go.mod h1:n8HOZ6pff6re5KYfBXcFvSViQjDwxFkAkmUFffJRbbY= -cloud.google.com/go/assuredworkloads v1.6.0/go.mod h1:yo2YOk37Yc89Rsd5QMVECvjaMKymF9OP+QXWlKXUkXw= -cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVoYoxeLBoj4XkKYscNI= -cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0= -cloud.google.com/go/automl v1.6.0/go.mod h1:ugf8a6Fx+zP0D59WLhqgTDsQI9w07o64uf/Is3Nh5p8= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/bigquery v1.42.0/go.mod h1:8dRTJxhtG+vwBKzE5OseQn/hiydoQN3EedCaOdYmxRA= -cloud.google.com/go/billing v1.4.0/go.mod h1:g9IdKBEFlItS8bTtlrZdVLWSSdSyFUZKXNS02zKMOZY= -cloud.google.com/go/billing v1.5.0/go.mod h1:mztb1tBc3QekhjSgmpf/CV4LzWXLzCArwpLmP2Gm88s= -cloud.google.com/go/binaryauthorization v1.1.0/go.mod h1:xwnoWu3Y84jbuHa0zd526MJYmtnVXn0syOjaJgy4+dM= -cloud.google.com/go/binaryauthorization v1.2.0/go.mod h1:86WKkJHtRcv5ViNABtYMhhNWRrD1Vpi//uKEy7aYEfI= -cloud.google.com/go/cloudtasks v1.5.0/go.mod h1:fD92REy1x5woxkKEkLdvavGnPJGEn8Uic9nWuLzqCpY= -cloud.google.com/go/cloudtasks v1.6.0/go.mod h1:C6Io+sxuke9/KNRkbQpihnW93SWDU3uXt92nu85HkYI= -cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= -cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= -cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= -cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= -cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= -cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U= -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= -cloud.google.com/go/datacatalog v1.5.0/go.mod h1:M7GPLNQeLfWqeIm3iuiruhPzkt65+Bx8dAKvScX8jvs= -cloud.google.com/go/datacatalog v1.6.0/go.mod h1:+aEyF8JKg+uXcIdAmmaMUmZ3q1b/lKLtXCmXdnc0lbc= -cloud.google.com/go/dataflow v0.6.0/go.mod h1:9QwV89cGoxjjSR9/r7eFDqqjtvbKxAK2BaYU6PVk9UM= -cloud.google.com/go/dataflow v0.7.0/go.mod h1:PX526vb4ijFMesO1o202EaUmouZKBpjHsTlCtB4parQ= -cloud.google.com/go/dataform v0.3.0/go.mod h1:cj8uNliRlHpa6L3yVhDOBrUXH+BPAO1+KFMQQNSThKo= -cloud.google.com/go/dataform v0.4.0/go.mod h1:fwV6Y4Ty2yIFL89huYlEkwUPtS7YZinZbzzj5S9FzCE= -cloud.google.com/go/datalabeling v0.5.0/go.mod h1:TGcJ0G2NzcsXSE/97yWjIZO0bXj0KbVlINXMG9ud42I= -cloud.google.com/go/datalabeling v0.6.0/go.mod h1:WqdISuk/+WIGeMkpw/1q7bK/tFEZxsrFJOJdY2bXvTQ= -cloud.google.com/go/dataqna v0.5.0/go.mod h1:90Hyk596ft3zUQ8NkFfvICSIfHFh1Bc7C4cK3vbhkeo= -cloud.google.com/go/dataqna v0.6.0/go.mod h1:1lqNpM7rqNLVgWBJyk5NF6Uen2PHym0jtVJonplVsDA= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/datastream v1.2.0/go.mod h1:i/uTP8/fZwgATHS/XFu0TcNUhuA0twZxxQ3EyCUQMwo= -cloud.google.com/go/datastream v1.3.0/go.mod h1:cqlOX8xlyYF/uxhiKn6Hbv6WjwPPuI9W2M9SAXwaLLQ= -cloud.google.com/go/dialogflow v1.15.0/go.mod h1:HbHDWs33WOGJgn6rfzBW1Kv807BE3O1+xGbn59zZWI4= -cloud.google.com/go/dialogflow v1.16.1/go.mod h1:po6LlzGfK+smoSmTBnbkIZY2w8ffjz/RcGSS+sh1el0= -cloud.google.com/go/dialogflow v1.17.0/go.mod h1:YNP09C/kXA1aZdBgC/VtXX74G/TKn7XVCcVumTflA+8= -cloud.google.com/go/documentai v1.7.0/go.mod h1:lJvftZB5NRiFSX4moiye1SMxHx0Bc3x1+p9e/RfXYiU= -cloud.google.com/go/documentai v1.8.0/go.mod h1:xGHNEB7CtsnySCNrCFdCyyMz44RhFEEX2Q7UD0c5IhU= -cloud.google.com/go/domains v0.6.0/go.mod h1:T9Rz3GasrpYk6mEGHh4rymIhjlnIuB4ofT1wTxDeT4Y= -cloud.google.com/go/domains v0.7.0/go.mod h1:PtZeqS1xjnXuRPKE/88Iru/LdfoRyEHYA9nFQf4UKpg= -cloud.google.com/go/edgecontainer v0.1.0/go.mod h1:WgkZ9tp10bFxqO8BLPqv2LlfmQF1X8lZqwW4r1BTajk= -cloud.google.com/go/edgecontainer v0.2.0/go.mod h1:RTmLijy+lGpQ7BXuTDa4C4ssxyXT34NIuHIgKuP4s5w= -cloud.google.com/go/functions v1.6.0/go.mod h1:3H1UA3qiIPRWD7PeZKLvHZ9SaQhR26XIJcC0A5GbvAk= -cloud.google.com/go/functions v1.7.0/go.mod h1:+d+QBcWM+RsrgZfV9xo6KfA1GlzJfxcfZcRPEhDDfzg= -cloud.google.com/go/gaming v1.5.0/go.mod h1:ol7rGcxP/qHTRQE/RO4bxkXq+Fix0j6D4LFPzYTIrDM= -cloud.google.com/go/gaming v1.6.0/go.mod h1:YMU1GEvA39Qt3zWGyAVA9bpYz/yAhTvaQ1t2sK4KPUA= -cloud.google.com/go/gkeconnect v0.5.0/go.mod h1:c5lsNAg5EwAy7fkqX/+goqFsU1Da/jQFqArp+wGNr/o= -cloud.google.com/go/gkeconnect v0.6.0/go.mod h1:Mln67KyU/sHJEBY8kFZ0xTeyPtzbq9StAVvEULYK16A= -cloud.google.com/go/gkehub v0.9.0/go.mod h1:WYHN6WG8w9bXU0hqNxt8rm5uxnk8IH+lPY9J2TV7BK0= -cloud.google.com/go/gkehub v0.10.0/go.mod h1:UIPwxI0DsrpsVoWpLB0stwKCP+WFVG9+y977wO+hBH0= -cloud.google.com/go/grafeas v0.2.0/go.mod h1:KhxgtF2hb0P191HlY5besjYm6MqTSTj3LSI+M+ByZHc= -cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= -cloud.google.com/go/iam v0.5.0/go.mod h1:wPU9Vt0P4UmCux7mqtRu6jcpPAb74cP1fh50J3QpkUc= -cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI= -cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= -cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic= -cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI= -cloud.google.com/go/lifesciences v0.5.0/go.mod h1:3oIKy8ycWGPUyZDR/8RNnTOYevhaMLqh5vLUXs9zvT8= -cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6t/iPhY2Tyfu08= -cloud.google.com/go/mediatranslation v0.5.0/go.mod h1:jGPUhGTybqsPQn91pNXw0xVHfuJ3leR1wj37oU3y1f4= -cloud.google.com/go/mediatranslation v0.6.0/go.mod h1:hHdBCTYNigsBxshbznuIMFNe5QXEowAuNmmC7h8pu5w= -cloud.google.com/go/memcache v1.4.0/go.mod h1:rTOfiGZtJX1AaFUrOgsMHX5kAzaTQ8azHiuDoTPzNsE= -cloud.google.com/go/memcache v1.5.0/go.mod h1:dk3fCK7dVo0cUU2c36jKb4VqKPS22BTkf81Xq617aWM= -cloud.google.com/go/metastore v1.5.0/go.mod h1:2ZNrDcQwghfdtCwJ33nM0+GrBGlVuh8rakL3vdPY3XY= -cloud.google.com/go/metastore v1.6.0/go.mod h1:6cyQTls8CWXzk45G55x57DVQ9gWg7RiH65+YgPsNh9s= -cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA= -cloud.google.com/go/networkconnectivity v1.5.0/go.mod h1:3GzqJx7uhtlM3kln0+x5wyFvuVH1pIBJjhCpjzSt75o= -cloud.google.com/go/networksecurity v0.5.0/go.mod h1:xS6fOCoqpVC5zx15Z/MqkfDwH4+m/61A3ODiDV1xmiQ= -cloud.google.com/go/networksecurity v0.6.0/go.mod h1:Q5fjhTr9WMI5mbpRYEbiexTzROf7ZbDzvzCrNl14nyU= -cloud.google.com/go/notebooks v1.2.0/go.mod h1:9+wtppMfVPUeJ8fIWPOq1UnATHISkGXGqTkxeieQ6UY= -cloud.google.com/go/notebooks v1.3.0/go.mod h1:bFR5lj07DtCPC7YAAJ//vHskFBxA5JzYlH68kXVdk34= -cloud.google.com/go/osconfig v1.7.0/go.mod h1:oVHeCeZELfJP7XLxcBGTMBvRO+1nQ5tFG9VQTmYS2Fs= -cloud.google.com/go/osconfig v1.8.0/go.mod h1:EQqZLu5w5XA7eKizepumcvWx+m8mJUhEwiPqWiZeEdg= -cloud.google.com/go/oslogin v1.4.0/go.mod h1:YdgMXWRaElXz/lDk1Na6Fh5orF7gvmJ0FGLIs9LId4E= -cloud.google.com/go/oslogin v1.5.0/go.mod h1:D260Qj11W2qx/HVF29zBg+0fd6YCSjSqLUkY/qEenQU= -cloud.google.com/go/phishingprotection v0.5.0/go.mod h1:Y3HZknsK9bc9dMi+oE8Bim0lczMU6hrX0UpADuMefr0= -cloud.google.com/go/phishingprotection v0.6.0/go.mod h1:9Y3LBLgy0kDTcYET8ZH3bq/7qni15yVUoAxiFxnlSUA= -cloud.google.com/go/privatecatalog v0.5.0/go.mod h1:XgosMUvvPyxDjAVNDYxJ7wBW8//hLDDYmnsNcMGq1K0= -cloud.google.com/go/privatecatalog v0.6.0/go.mod h1:i/fbkZR0hLN29eEWiiwue8Pb+GforiEIBnV9yrRUOKI= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/recaptchaenterprise v1.3.1/go.mod h1:OdD+q+y4XGeAlxRaMn1Y7/GveP6zmq76byL6tjPE7d4= -cloud.google.com/go/recaptchaenterprise/v2 v2.1.0/go.mod h1:w9yVqajwroDNTfGuhmOjPDN//rZGySaf6PtFVcSCa7o= -cloud.google.com/go/recaptchaenterprise/v2 v2.2.0/go.mod h1:/Zu5jisWGeERrd5HnlS3EUGb/D335f9k51B/FVil0jk= -cloud.google.com/go/recaptchaenterprise/v2 v2.3.0/go.mod h1:O9LwGCjrhGHBQET5CA7dd5NwwNQUErSgEDit1DLNTdo= -cloud.google.com/go/recommendationengine v0.5.0/go.mod h1:E5756pJcVFeVgaQv3WNpImkFP8a+RptV6dDLGPILjvg= -cloud.google.com/go/recommendationengine v0.6.0/go.mod h1:08mq2umu9oIqc7tDy8sx+MNJdLG0fUi3vaSVbztHgJ4= -cloud.google.com/go/recommender v1.5.0/go.mod h1:jdoeiBIVrJe9gQjwd759ecLJbxCDED4A6p+mqoqDvTg= -cloud.google.com/go/recommender v1.6.0/go.mod h1:+yETpm25mcoiECKh9DEScGzIRyDKpZ0cEhWGo+8bo+c= -cloud.google.com/go/redis v1.7.0/go.mod h1:V3x5Jq1jzUcg+UNsRvdmsfuFnit1cfe3Z/PGyq/lm4Y= -cloud.google.com/go/redis v1.8.0/go.mod h1:Fm2szCDavWzBk2cDKxrkmWBqoCiL1+Ctwq7EyqBCA/A= -cloud.google.com/go/retail v1.8.0/go.mod h1:QblKS8waDmNUhghY2TI9O3JLlFk8jybHeV4BF19FrE4= -cloud.google.com/go/retail v1.9.0/go.mod h1:g6jb6mKuCS1QKnH/dpu7isX253absFl6iE92nHwlBUY= -cloud.google.com/go/scheduler v1.4.0/go.mod h1:drcJBmxF3aqZJRhmkHQ9b3uSSpQoltBPGPxGAWROx6s= -cloud.google.com/go/scheduler v1.5.0/go.mod h1:ri073ym49NW3AfT6DZi21vLZrG07GXr5p3H1KxN5QlI= -cloud.google.com/go/secretmanager v1.6.0/go.mod h1:awVa/OXF6IiyaU1wQ34inzQNc4ISIDIrId8qE5QGgKA= -cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4= -cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0= -cloud.google.com/go/security v1.8.0/go.mod h1:hAQOwgmaHhztFhiQ41CjDODdWP0+AE1B3sX4OFlq+GU= -cloud.google.com/go/securitycenter v1.13.0/go.mod h1:cv5qNAqjY84FCN6Y9z28WlkKXyWsgLO832YiWwkCWcU= -cloud.google.com/go/securitycenter v1.14.0/go.mod h1:gZLAhtyKv85n52XYWt6RmeBdydyxfPeTrpToDPw4Auc= -cloud.google.com/go/servicedirectory v1.4.0/go.mod h1:gH1MUaZCgtP7qQiI+F+A+OpeKF/HQWgtAddhTbhL2bs= -cloud.google.com/go/servicedirectory v1.5.0/go.mod h1:QMKFL0NUySbpZJ1UZs3oFAmdvVxhhxB6eJ/Vlp73dfg= -cloud.google.com/go/speech v1.6.0/go.mod h1:79tcr4FHCimOp56lwC01xnt/WPJZc4v3gzyT7FoBkCM= -cloud.google.com/go/speech v1.7.0/go.mod h1:KptqL+BAQIhMsj1kOP2la5DSEEerPDuOP/2mmkhHhZQ= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= -cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc= -cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s= -cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM= -cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E= -cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw= -cloud.google.com/go/talent v1.2.0/go.mod h1:MoNF9bhFQbiJ6eFD3uSsg0uBALw4n4gaCaEjBw9zo8g= -cloud.google.com/go/videointelligence v1.6.0/go.mod h1:w0DIDlVRKtwPCn/C4iwZIJdvC69yInhW0cfi+p546uU= -cloud.google.com/go/videointelligence v1.7.0/go.mod h1:k8pI/1wAhjznARtVT9U1llUaFNPh7muw8QyOUpavru4= -cloud.google.com/go/vision v1.2.0/go.mod h1:SmNwgObm5DpFBme2xpyOyasvBc1aPdjvMk2bBk0tKD0= -cloud.google.com/go/vision/v2 v2.2.0/go.mod h1:uCdV4PpN1S0jyCyq8sIM42v2Y6zOLkZs+4R9LrGYwFo= -cloud.google.com/go/vision/v2 v2.3.0/go.mod h1:UO61abBx9QRMFkNBbf1D8B1LXdS2cGiiCRx0vSpZoUo= -cloud.google.com/go/webrisk v1.4.0/go.mod h1:Hn8X6Zr+ziE2aNd8SliSDWpEnSS1u4R9+xXZmFiHmGE= -cloud.google.com/go/webrisk v1.5.0/go.mod h1:iPG6fr52Tv7sGk0H6qUFzmL3HHZev1htXuWDEEsqMTg= -cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0= -cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M= -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= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -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/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Stackdome/stackdome v0.0.1-alpha.0.20260805180415-8ce931c66403 h1:oqV8D0bSJTAam7gFm8BFt0V+RTsrPL0RfqHB0RUA/hs= +github.com/Stackdome/stackdome v0.0.1-alpha.0.20260805180415-8ce931c66403/go.mod h1:domgN80PtgYKxEH5WIqxMQfNAzzc2tN0IaR5AEou2r4= 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= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 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= @@ -214,996 +14,132 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll 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/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 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= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 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= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= -github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= -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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 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= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp4Mit+3FDh548oRqwVgNsHA= -github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= -github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= -github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -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= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -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= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -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/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= -github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -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 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= 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= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= -github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= -github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= -github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= -github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= -github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= -github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= -github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= -github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo= -github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY= -github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= -github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= -github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= -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= -github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY= -github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc= -github.com/hashicorp/go-getter v1.7.4 h1:3yQjWuxICvSpYwqSayAdKRFcvBl1y/vogCxczWSmix0= -github.com/hashicorp/go-getter v1.7.4/go.mod h1:W7TalhMmbPmsSMdNjD0ZskARur/9GJ17cfHTRtXV744= -github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= -github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= -github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= -github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= -github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= -github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 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= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -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= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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= -github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= -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 h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= 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= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= -github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= -github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= -github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= -github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= -github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= +github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= +github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -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= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -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= -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/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 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= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -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= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20231226003508-02704c960a9b h1:kLiC65FbiHWFAOu+lxwNPujcsl8VYyTYYEZnsOO1WK4= -golang.org/x/exp v0.0.0-20231226003508-02704c960a9b/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -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= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= -golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= -golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -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= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -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= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -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/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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= -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= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -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= -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= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= -google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= -google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= -google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= -google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= -google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= -google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= -google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= -google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= -google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= -google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= -google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= -google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= -google.golang.org/api v0.77.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= -google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= -google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= -google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o= -google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g= -google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= -google.golang.org/api v0.93.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= -google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI= -google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= -google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= -google.golang.org/api v0.98.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= -google.golang.org/api v0.100.0/go.mod h1:ZE3Z2+ZOr87Rx7dqFsdRQkRBk36kDtp/h+QpHbB7a70= -google.golang.org/api v0.149.0 h1:b2CqT6kG+zqJIVKRQ3ELJVLN1PwHZ6DJ3dW8yl82rgY= -google.golang.org/api v0.149.0/go.mod h1:Mwn1B7JTXrzXtnvmzQE2BD6bYZQ8DShKZDZbeN9I7qI= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= -google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= -google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= -google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE= -google.golang.org/genproto v0.0.0-20220801145646-83ce21fca29f/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc= -google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220817144833-d7fd3f11b9b1/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220829144015-23454907ede3/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220913154956-18f8339a66a5/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220914142337-ca0e39ece12f/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220916172020-2692e8806bfa/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006/go.mod h1:ht8XFiar2npT/g4vkk7O0WYS1sHOHbdujxbEp7CJWbw= -google.golang.org/genproto v0.0.0-20220926165614-551eb538f295/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= -google.golang.org/genproto v0.0.0-20220926220553-6981cbe3cfce/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= -google.golang.org/genproto v0.0.0-20221010155953-15ba04fc1c0e/go.mod h1:3526vdqwhZAwq4wsRUaVG555sVgsNmIjRtO7t/JH29U= -google.golang.org/genproto v0.0.0-20221014173430-6e2ab493f96b/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= -google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= -google.golang.org/genproto v0.0.0-20221025140454-527a21cfbd71/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= -google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac h1:ZL/Teoy/ZGnzyrqK/Optxxp2pmVh+fmJ97slxSRyzUg= -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= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= -google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -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= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -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= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= 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= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= -gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -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= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= 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 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= 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 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= 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 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= 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 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= -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 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= 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/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/client/addons.go b/internal/client/addons.go new file mode 100644 index 0000000..921da5c --- /dev/null +++ b/internal/client/addons.go @@ -0,0 +1,96 @@ +package client + +import ( + "context" + + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" +) + +func (c *Client) ListPostgresAddons(ctx context.Context) ([]openapi.PostgresAddon, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdProjectsProjectNameAddonsPostgresGet(ctx, c.orgID, c.projectName). + Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to list postgres addons") + } + return resp.Items, nil +} + +func (c *Client) CreatePostgresAddon(ctx context.Context, addon openapi.PostgresAddon) (*openapi.PostgresAddon, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdProjectsProjectNameAddonsPostgresPost(ctx, c.orgID, c.projectName). + PostgresAddon(addon).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to create postgres addon") + } + return resp, nil +} + +func (c *Client) GetPostgresAddon(ctx context.Context, addonID string) (*openapi.PostgresAddon, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdProjectsProjectNameAddonsPostgresIdGet(ctx, c.orgID, c.projectName, addonID). + Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to get postgres addon") + } + return resp, nil +} + +func (c *Client) DeletePostgresAddon(ctx context.Context, addonID string) error { + _, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdProjectsProjectNameAddonsPostgresIdDelete(ctx, c.orgID, c.projectName, addonID). + Execute() + if err != nil { + return WrapError(httpResp, err, "Failed to delete postgres addon") + } + return nil +} + +// BackupPostgresAddon triggers an on-demand backup. The server-side backup +// service is still a stub, so failures here are surfaced verbatim. +func (c *Client) BackupPostgresAddon(ctx context.Context, addonID, description string) (*openapi.ApiV1OrganizationsOrgIdProjectsProjectNameAddonsPostgresIdActionsBackupPost202Response, error) { + body := openapi.ApiV1OrganizationsOrgIdProjectsProjectNameAddonsPostgresIdActionsBackupPostRequest{} + if description != "" { + body.Description = &description + } + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdProjectsProjectNameAddonsPostgresIdActionsBackupPost(ctx, c.orgID, c.projectName, addonID). + ApiV1OrganizationsOrgIdProjectsProjectNameAddonsPostgresIdActionsBackupPostRequest(body).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to trigger backup") + } + return resp, nil +} + +func (c *Client) ListPostgresBackups(ctx context.Context, addonID string) ([]openapi.PostgresBackup, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdProjectsProjectNameAddonsPostgresIdBackupsGet(ctx, c.orgID, c.projectName, addonID). + Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to list backups") + } + return resp.GetItems(), nil +} + +func (c *Client) GetPostgresCredentials(ctx context.Context, addonID, database string, superuser bool) (*openapi.PostgresCredentials, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdProjectsProjectNameAddonsPostgresIdCredentialsDatabaseGet(ctx, c.orgID, c.projectName, addonID, database). + Superuser(superuser).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to get credentials") + } + return resp, nil +} + +func (c *Client) FindPostgresAddonByName(ctx context.Context, name string) (*openapi.PostgresAddon, error) { + addons, err := c.ListPostgresAddons(ctx) + if err != nil { + return nil, err + } + for i := range addons { + if addons[i].Name == name { + return &addons[i], nil + } + } + return nil, nil +} diff --git a/internal/client/auth.go b/internal/client/auth.go index f64f2c6..0330339 100644 --- a/internal/client/auth.go +++ b/internal/client/auth.go @@ -3,7 +3,7 @@ package client import ( "context" - serverapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + serverapi "github.com/Stackdome/stackdome/pkg/api/openapi" clierrors "github.com/stackdome/cli/internal/errors" ) @@ -69,26 +69,48 @@ func (c *Client) GetCurrentUser(ctx context.Context) (*serverapi.User, error) { 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() +func (c *Client) ListCurrentUserProjects(ctx context.Context) ([]serverapi.Project, error) { + resp, httpResp, err := c.apiClient.DefaultApi.ApiV1UsersCurrentProjectsGet(ctx).Execute() if err != nil { - return nil, WrapError(httpResp, err, "Failed to get teams") + return nil, WrapError(httpResp, err, "Failed to get projects") } return resp.Items, nil } -func (c *Client) ResolveDefaultTeam(ctx context.Context, orgID string) (string, error) { - teams, err := c.GetOrgTeams(ctx, orgID) +func (c *Client) ListOrgProjects(ctx context.Context, orgID string) ([]serverapi.Project, error) { + resp, httpResp, err := c.apiClient.DefaultApi.ApiV1OrganizationsOrgIdProjectsGet(ctx, orgID).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to get organisation projects") + } + return resp.Items, nil +} + +// ResolveDefaultProject picks the project the CLI operates in. Projects are not +// exposed in the CLI UX, so we resolve one silently: the default project if the +// API marks one, otherwise the first. +func (c *Client) ResolveDefaultProject(ctx context.Context, orgID string) (string, error) { + projects, err := c.ListCurrentUserProjects(ctx) if err != nil { return "", err } - for _, t := range teams { - if t.DefaultTeam != nil && *t.DefaultTeam { - return t.Name, nil + + // Server bug: GET /users/current/projects answers {"items":[],"total":0} on + // deployed servers even when the user has a default project, which made + // login fail outright. GET /organizations/{org_id}/projects is correct. + // Drop this fallback once the per-user endpoint is fixed. + if len(projects) == 0 && orgID != "" { + if projects, err = c.ListOrgProjects(ctx, orgID); err != nil { + return "", err + } + } + + for _, p := range projects { + if p.DefaultProject != nil && *p.DefaultProject { + return p.Name, nil } } - if len(teams) > 0 { - return teams[0].Name, nil + if len(projects) > 0 { + return projects[0].Name, nil } - return "", clierrors.New("No teams found for your account.") + return "", clierrors.New("No projects found for your account.") } diff --git a/internal/client/auth_test.go b/internal/client/auth_test.go new file mode 100644 index 0000000..9d087d3 --- /dev/null +++ b/internal/client/auth_test.go @@ -0,0 +1,70 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +// Deployed servers answer GET /users/current/projects with an empty list even +// when the user has a default project, so ResolveDefaultProject falls back to +// the organisation's project list. +func TestResolveDefaultProjectFallsBackToOrgProjects(t *testing.T) { + var paths []string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/users/current/projects": + fmt.Fprint(w, `{"items":[],"total":0}`) + case "/api/v1/organizations/org-1/projects": + fmt.Fprint(w, `{"items":[{"name":"other"},{"name":"the-default","default_project":true}],"total":2}`) + default: + t.Errorf("unexpected request to %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := New(ts.URL, WithTokens("access", "")) + + name, err := c.ResolveDefaultProject(context.Background(), "org-1") + if err != nil { + t.Fatalf("ResolveDefaultProject: %v", err) + } + if name != "the-default" { + t.Errorf("project = %q, want the-default", name) + } + if len(paths) != 2 || paths[1] != "/api/v1/organizations/org-1/projects" { + t.Errorf("requests = %v, want the org projects endpoint consulted second", paths) + } +} + +// The per-user endpoint is still the primary source: when it answers, the org +// endpoint must not be touched. +func TestResolveDefaultProjectPrefersCurrentUserProjects(t *testing.T) { + var paths []string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"items":[{"name":"mine"}],"total":1}`) + })) + defer ts.Close() + + c := New(ts.URL, WithTokens("access", "")) + + name, err := c.ResolveDefaultProject(context.Background(), "org-1") + if err != nil { + t.Fatalf("ResolveDefaultProject: %v", err) + } + if name != "mine" { + t.Errorf("project = %q, want mine", name) + } + if len(paths) != 1 { + t.Errorf("requests = %v, want only the per-user endpoint", paths) + } +} diff --git a/internal/client/builds.go b/internal/client/builds.go index 2b61ca5..f2ca6aa 100644 --- a/internal/client/builds.go +++ b/internal/client/builds.go @@ -2,13 +2,22 @@ package client import ( "context" + "fmt" + "io" - openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" ) +// StreamBuildLogs opens the build's SSE log stream. Same frame shape as +// StreamLogs — feed the reader to ParseSSEStream. +func (c *Client) StreamBuildLogs(ctx context.Context, stackID, buildID string, opts LogOptions) (io.ReadCloser, error) { + path := fmt.Sprintf("/api/v1/organizations/%s/projects/%s/stacks/%s/builds/%s/logs", c.orgID, c.projectName, stackID, buildID) + return c.openLogStream(ctx, path, opts) +} + 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). + ApiV1OrganizationsOrgIdProjectsProjectNameStacksIdBuildsGet(ctx, c.orgID, c.projectName, stackID). Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to list builds") @@ -18,7 +27,7 @@ func (c *Client) ListBuilds(ctx context.Context, stackID string) ([]openapi.Imag 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). + ApiV1OrganizationsOrgIdProjectsProjectNameStacksIdResourcesResourceNameBuildsGet(ctx, c.orgID, c.projectName, stackID, resourceName). Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to list builds for resource") @@ -28,7 +37,7 @@ func (c *Client) ListResourceBuilds(ctx context.Context, stackID, resourceName s 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). + ApiV1OrganizationsOrgIdProjectsProjectNameStacksIdBuildsBuildIdGet(ctx, c.orgID, c.projectName, stackID, buildID). Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to get build") diff --git a/internal/client/builds_test.go b/internal/client/builds_test.go new file mode 100644 index 0000000..7ee58c8 --- /dev/null +++ b/internal/client/builds_test.go @@ -0,0 +1,91 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// TestStreamBuildLogs pins the build-log URL (path + query) and the SSE frames +// the caller sees. +func TestStreamBuildLogs(t *testing.T) { + var gotURL string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotURL = r.URL.String() + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: step 1\n\ndata: step 2\n\nevent: end\ndata: \n\n") + })) + defer ts.Close() + + c := New(ts.URL, WithTokens("access", ""), WithOrgAndProject("org-1", "proj-1")) + + stream, err := c.StreamBuildLogs(context.Background(), "stack-1", "build-1", LogOptions{Follow: true, Tail: 200}) + if err != nil { + t.Fatalf("StreamBuildLogs: %v", err) + } + defer stream.Close() + + var lines []string + var ended bool + if err := ParseSSEStream(stream, func(e SSEEvent) error { + if e.Event == "end" { + ended = true + return nil + } + lines = append(lines, e.Data) + return nil + }); err != nil { + t.Fatalf("ParseSSEStream: %v", err) + } + + want := "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/builds/build-1/logs?tail=200&follow=true" + if gotURL != want { + t.Errorf("URL = %q, want %q", gotURL, want) + } + if len(lines) != 2 || lines[0] != "step 1" || lines[1] != "step 2" { + t.Errorf("lines = %v, want [step 1 step 2]", lines) + } + if !ended { + t.Error("missing end event") + } +} + +// A followed log stream must outlive the client's whole-request timeout — only +// the context ends it. +func TestStreamLogsFollowOutlivesClientTimeout(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: first\n\n") + w.(http.Flusher).Flush() + time.Sleep(150 * time.Millisecond) + fmt.Fprint(w, "data: second\n\nevent: end\ndata: \n\n") + })) + defer ts.Close() + + c := New(ts.URL, WithTokens("access", ""), WithOrgAndProject("org-1", "proj-1")) + c.cfg.HTTPClient.Timeout = 50 * time.Millisecond + + stream, err := c.StreamLogs(context.Background(), "stack-1", "web", LogOptions{Follow: true, Tail: 10}) + if err != nil { + t.Fatalf("StreamLogs: %v", err) + } + defer stream.Close() + + var lines []string + if err := ParseSSEStream(stream, func(e SSEEvent) error { + if e.Event != "end" { + lines = append(lines, e.Data) + } + return nil + }); err != nil { + t.Fatalf("ParseSSEStream: %v", err) + } + + if len(lines) != 2 || lines[1] != "second" { + t.Errorf("lines = %v, want [first second] — stream was cut by the client timeout", lines) + } +} diff --git a/internal/client/client.go b/internal/client/client.go index e0c9de4..d70f47f 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -1,16 +1,23 @@ package client import ( + "bytes" "context" "crypto/tls" "encoding/json" + "errors" "fmt" + "io" "net" "net/http" "net/url" + "os" + "regexp" + "strings" + "sync" "time" - serverapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + serverapi "github.com/Stackdome/stackdome/pkg/api/openapi" clierrors "github.com/stackdome/cli/internal/errors" ) @@ -22,7 +29,7 @@ type Client struct { accessToken string refreshToken string orgID string - teamName string + projectName string baseURL string onTokenRefresh func(accessToken, refreshToken string) error } @@ -49,10 +56,10 @@ func WithTokens(accessToken, refreshToken string) Option { } } -func WithOrgAndTeam(orgID, teamName string) Option { +func WithOrgAndProject(orgID, projectName string) Option { return func(c *Client) { c.orgID = orgID - c.teamName = teamName + c.projectName = projectName } } @@ -81,11 +88,120 @@ func New(baseURL string, opts ...Option) *Client { opt(c) } + base := cfg.HTTPClient.Transport + if base == nil { + base = http.DefaultTransport + } + cfg.HTTPClient.Transport = &refreshTransport{base: base, client: c} + c.apiClient = serverapi.NewAPIClient(cfg) c.applyAuth() return c } +// noRetryKey marks the refresh call itself, so a 401 on it cannot recurse. +type noRetryKey struct{} + +// refreshTransport turns a 401 into one refresh + one retry. The server rotates +// the refresh token on every use, so the new pair is persisted immediately. +type refreshTransport struct { + base http.RoundTripper + client *Client + mu sync.Mutex +} + +func (t *refreshTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.base.RoundTrip(req) + if err != nil || !shouldRefresh(resp) { + return resp, err + } + if req.Context().Value(noRetryKey{}) != nil || !t.client.canRefresh() { + return resp, nil + } + + // Buffer the body up front: a request we cannot replay is not retryable. + var body io.ReadCloser + if req.Body != nil { + if req.GetBody == nil { + return resp, nil + } + if body, err = req.GetBody(); err != nil { + return resp, nil + } + } + + stale := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ") + t.mu.Lock() + // Skip if a concurrent request already rotated the pair — reusing a spent + // refresh token would revoke the session. + if t.client.accessToken == stale { + err = t.client.TryRefreshToken(context.WithValue(req.Context(), noRetryKey{}, true)) + } + token := t.client.accessToken + t.mu.Unlock() + + // A persist failure is not a refresh failure: the pair already rotated and + // the old refresh token is revoked server-side, so retrying with the live + // token is strictly better than failing a command with valid credentials. + if errors.Is(err, errPersistTokens) { + fmt.Fprintf(os.Stderr, "warning: %v; you may need to log in again\n", err) + } else if err != nil || token == stale { + if body != nil { + body.Close() + } + return resp, nil + } + + resp.Body.Close() + retry := req.Clone(req.Context()) + retry.Body = body + retry.Header.Set("Authorization", "Bearer "+token) + return t.base.RoundTrip(retry) +} + +// tokenReason matches the server's wording for an expired or unparseable access +// token — same patterns the web UI refreshes on. +var tokenReason = regexp.MustCompile(`(?i)token parse error|token (is )?expired|expired by`) + +// shouldRefresh reports whether resp is an access-token rejection worth one +// refresh + retry. +// +// Server contract: expired/invalid access tokens return 403 with a token reason +// (matches web UI's shouldRefresh — frontend/src/api/client.ts). A 403 therefore +// only counts when the body says so; genuine permission denials fall through +// with their body buffered and restored, byte-identical for error rendering. +func shouldRefresh(resp *http.Response) bool { + if resp.StatusCode == http.StatusUnauthorized { + return true + } + if resp.StatusCode != http.StatusForbidden || resp.Body == nil { + return false + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) + if err != nil { + return false + } + var apiErr struct { + Reason string `json:"reason"` + Items []struct { + Reason string `json:"reason"` + } `json:"items"` + } + _ = json.Unmarshal(body, &apiErr) + if len(apiErr.Items) > 0 && tokenReason.MatchString(apiErr.Items[0].Reason) { + return true + } + return tokenReason.MatchString(apiErr.Reason) +} + +// canRefresh reports whether a refresh is even meaningful: env tokens and +// `sdm_` personal access tokens come without a refresh pair. +func (c *Client) canRefresh() bool { + return c.refreshToken != "" && !strings.HasPrefix(c.accessToken, "sdm_") +} + func (c *Client) API() *serverapi.DefaultApiService { return c.apiClient.DefaultApi } @@ -102,16 +218,16 @@ func (c *Client) SetTokens(accessToken, refreshToken string) { c.applyAuth() } -func (c *Client) SetOrgAndTeam(orgID, teamName string) { +func (c *Client) SetOrgAndProject(orgID, projectName string) { c.orgID = orgID - c.teamName = teamName + c.projectName = projectName } -func (c *Client) OrgID() string { return c.orgID } -func (c *Client) TeamName() string { return c.teamName } +func (c *Client) OrgID() string { return c.orgID } +func (c *Client) ProjectName() string { return c.projectName } -func (c *Client) TeamPath() string { - return fmt.Sprintf("/organizations/%s/teams/%s", c.orgID, c.teamName) +func (c *Client) ProjectPath() string { + return fmt.Sprintf("/organizations/%s/projects/%s", c.orgID, c.projectName) } func (c *Client) TryRefreshToken(ctx context.Context) error { @@ -130,11 +246,17 @@ func (c *Client) TryRefreshToken(ctx context.Context) error { c.applyAuth() if c.onTokenRefresh != nil { - return c.onTokenRefresh(c.accessToken, c.refreshToken) + if err := c.onTokenRefresh(c.accessToken, c.refreshToken); err != nil { + return fmt.Errorf("%w: %w", errPersistTokens, err) + } } return nil } +// errPersistTokens marks the case where the refresh itself succeeded but saving +// the rotated pair did not — the credentials in memory are still good. +var errPersistTokens = errors.New("could not save refreshed credentials") + func WrapError(httpResp *http.Response, err error, message string) error { if httpResp != nil { reason := extractAPIReason(err) diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..6810c4b --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,310 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + clierrors "github.com/stackdome/cli/internal/errors" +) + +// refreshServer serves /api/v1/config, rejecting anything but wantToken with a +// 401, and rotates the token pair on /api/v1/auth/refresh. +type refreshServer struct { + t *testing.T + wantToken string + authHits int + configHits int + seenAuth []string + bodies []string + // rejectStatus/rejectBody override the rejection shape (default 401 + + // "token expired") so tests can exercise the 403-with-token-reason contract. + rejectStatus int + rejectBody string +} + +func (s *refreshServer) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/auth/refresh": + s.authHits++ + var req struct { + RefreshToken string `json:"refreshToken"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.t.Errorf("refresh body: %v", err) + } + if req.RefreshToken != "refresh-old" { + w.WriteHeader(http.StatusUnauthorized) + return + } + s.wantToken = "access-new" + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "token": "access-new", + "refreshToken": "refresh-new", + }) + default: + s.configHits++ + w.Header().Set("Content-Type", "application/json") + s.seenAuth = append(s.seenAuth, r.Header.Get("Authorization")) + if b, _ := io.ReadAll(r.Body); len(b) > 0 { + s.bodies = append(s.bodies, string(b)) + } + if r.Header.Get("Authorization") != "Bearer "+s.wantToken { + status, body := s.rejectStatus, s.rejectBody + if status == 0 { + status, body = http.StatusUnauthorized, `{"reason":"token expired"}` + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + return + } + _, _ = w.Write([]byte(`{}`)) + } + }) +} + +func TestRefreshOn401RetriesWithNewToken(t *testing.T) { + srv := &refreshServer{t: t, wantToken: "access-new"} + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + var gotAccess, gotRefresh string + c := New(ts.URL, + WithTokens("access-old", "refresh-old"), + WithTokenRefreshCallback(func(a, r string) error { + gotAccess, gotRefresh = a, r + return nil + }), + ) + + _, httpResp, err := c.API().ApiV1ConfigGet(context.Background()).Execute() + if err != nil { + t.Fatalf("request failed after refresh: %v", err) + } + if httpResp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", httpResp.StatusCode) + } + if srv.authHits != 1 { + t.Errorf("refresh endpoint hit %d times, want 1", srv.authHits) + } + if gotAccess != "access-new" || gotRefresh != "refresh-new" { + t.Errorf("persist callback got (%q, %q), want (access-new, refresh-new)", gotAccess, gotRefresh) + } + if c.accessToken != "access-new" || c.refreshToken != "refresh-new" { + t.Errorf("client tokens = (%q, %q), want (access-new, refresh-new)", c.accessToken, c.refreshToken) + } + if len(srv.seenAuth) != 2 || srv.seenAuth[1] != "Bearer access-new" { + t.Errorf("request auth headers = %v, want retry to carry access-new", srv.seenAuth) + } +} + +// A PAT (`login --token`) has no refresh pair: the 401 must surface untouched, +// as an auth error with exit code 2. +func TestNoRefreshTokenSurfaces401(t *testing.T) { + srv := &refreshServer{t: t, wantToken: "access-new"} + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + refreshed := false + c := New(ts.URL, + WithTokens("sdm_pat", ""), + WithTokenRefreshCallback(func(string, string) error { refreshed = true; return nil }), + ) + + _, httpResp, err := c.API().ApiV1ConfigGet(context.Background()).Execute() + if err == nil { + t.Fatal("expected 401 error") + } + if httpResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", httpResp.StatusCode) + } + if srv.authHits != 0 || refreshed { + t.Errorf("refresh attempted for PAT: authHits=%d refreshed=%v", srv.authHits, refreshed) + } + + var cliErr *clierrors.CLIError + wrapped := WrapError(httpResp, err, "get config") + if !errors.As(wrapped, &cliErr) || cliErr.ExitCode != clierrors.ExitAuth { + t.Fatalf("WrapError = %#v, want CLIError with exit code %d", wrapped, clierrors.ExitAuth) + } +} + +// A failure to write the rotated pair to disk must not fail the command: the +// refresh succeeded, so retry with the live token and warn on stderr. +func TestPersistFailureStillRetries(t *testing.T) { + srv := &refreshServer{t: t, wantToken: "access-new"} + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + stderr, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stderr + os.Stderr = w + defer func() { os.Stderr = old }() + + c := New(ts.URL, + WithTokens("access-old", "refresh-old"), + WithTokenRefreshCallback(func(string, string) error { + return fmt.Errorf("config is read-only") + }), + ) + + _, httpResp, err := c.API().ApiV1ConfigGet(context.Background()).Execute() + w.Close() + warning, _ := io.ReadAll(stderr) + + if err != nil || httpResp.StatusCode != http.StatusOK { + t.Fatalf("want retry to succeed, got status=%v err=%v", httpResp.StatusCode, err) + } + if !strings.Contains(string(warning), "could not save refreshed credentials: config is read-only") { + t.Errorf("stderr = %q, want a persist warning naming the cause", warning) + } +} + +// An `sdm_`-prefixed PAT carries no refresh semantics even if a stale refresh +// token is lying around in the config file. +func TestPATPrefixSkipsRefresh(t *testing.T) { + srv := &refreshServer{t: t, wantToken: "access-new"} + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + c := New(ts.URL, WithTokens("sdm_pat", "refresh-old")) + _, httpResp, err := c.API().ApiV1ConfigGet(context.Background()).Execute() + if err == nil || httpResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("want 401, got status=%v err=%v", httpResp.StatusCode, err) + } + if srv.authHits != 0 { + t.Errorf("refresh attempted for sdm_ token (%d hits)", srv.authHits) + } +} + +// The retry must replay the original request body, not send an empty one. +func TestRetryReplaysRequestBody(t *testing.T) { + srv := &refreshServer{t: t, wantToken: "access-new"} + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + c := New(ts.URL, WithTokens("access-old", "refresh-old")) + + req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/stacks", strings.NewReader(`{"name":"web"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer access-old") + resp, err := c.cfg.HTTPClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if len(srv.bodies) != 2 || srv.bodies[1] != `{"name":"web"}` { + t.Errorf("bodies = %v, want the payload replayed on retry", srv.bodies) + } +} + +// A refresh that itself fails must not recurse: one refresh attempt, original +// 401 surfaced. +func TestFailedRefreshDoesNotRecurse(t *testing.T) { + srv := &refreshServer{t: t, wantToken: "access-new"} + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + c := New(ts.URL, WithTokens("access-old", "refresh-stale")) + _, httpResp, err := c.API().ApiV1ConfigGet(context.Background()).Execute() + if err == nil || httpResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("want 401, got status=%v err=%v", httpResp.StatusCode, err) + } + if srv.authHits != 1 { + t.Errorf("refresh hit %d times, want exactly 1", srv.authHits) + } +} + +// Server contract: an expired or unparseable access token comes back as a 403 +// carrying a token reason, so those must still refresh and retry — same set the +// web UI refreshes on (frontend/src/api/client.ts). +func TestRefreshOn403TokenReason(t *testing.T) { + for name, body := range map[string]string{ + "lowercase": `{"code":403,"id":"forbidden","kind":"auth","reason":"token expired"}`, + "capitalized": `{"code":403,"id":"forbidden","kind":"auth","reason":"Token is expired"}`, + "expired by": `{"code":403,"id":"forbidden","kind":"auth","reason":"token is expired by 3m0s"}`, + "parse error": `{"code":403,"id":"forbidden","kind":"auth","reason":"token parse error"}`, + "items": `{"code":403,"id":"forbidden","kind":"auth","items":[{"reason":"Token is expired"}]}`, + } { + t.Run(name, func(t *testing.T) { + srv := &refreshServer{ + t: t, + wantToken: "access-new", + rejectStatus: http.StatusForbidden, + rejectBody: body, + } + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + c := New(ts.URL, WithTokens("access-old", "refresh-old")) + + _, httpResp, err := c.API().ApiV1ConfigGet(context.Background()).Execute() + if err != nil { + t.Fatalf("request failed after refresh: %v", err) + } + if httpResp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", httpResp.StatusCode) + } + if srv.authHits != 1 { + t.Errorf("refresh endpoint hit %d times, want 1", srv.authHits) + } + if len(srv.seenAuth) != 2 || srv.seenAuth[1] != "Bearer access-new" { + t.Errorf("request auth headers = %v, want retry to carry access-new", srv.seenAuth) + } + }) + } +} + +// A real permission denial is not an expired token: no refresh, and the body +// must reach the caller verbatim so the reason still renders. +func TestPlain403DoesNotRefresh(t *testing.T) { + const denied = `{"code":403,"id":"forbidden","kind":"auth","reason":"insufficient permissions"}` + srv := &refreshServer{ + t: t, + wantToken: "nobody", + rejectStatus: http.StatusForbidden, + rejectBody: denied, + } + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + c := New(ts.URL, WithTokens("access-old", "refresh-old")) + + _, httpResp, err := c.API().ApiV1ConfigGet(context.Background()).Execute() + if err == nil { + t.Fatal("expected 403 error") + } + if httpResp.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want 403", httpResp.StatusCode) + } + if srv.authHits != 0 { + t.Errorf("refresh attempted for a plain 403 (%d hits)", srv.authHits) + } + if srv.configHits != 1 { + t.Errorf("request sent %d times, want 1 (no retry)", srv.configHits) + } + if got := extractAPIReason(err); got != "insufficient permissions" { + t.Errorf("reason = %q, want %q — body was not preserved", got, "insufficient permissions") + } + if b, ok := err.(bodyer); !ok || string(b.Body()) != denied { + t.Errorf("error body = %q, want verbatim %q", b.Body(), denied) + } +} diff --git a/internal/client/logs.go b/internal/client/logs.go index fe2b6ef..e9ac60d 100644 --- a/internal/client/logs.go +++ b/internal/client/logs.go @@ -17,12 +17,16 @@ type LogOptions struct { } 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) + path := fmt.Sprintf("/api/v1/organizations/%s/projects/%s/stacks/%s", c.orgID, c.projectName, stackID) if resourceName != "" { path += fmt.Sprintf("/resources/%s", resourceName) } path += "/logs" + return c.openLogStream(ctx, path, opts) +} + +func (c *Client) openLogStream(ctx context.Context, path string, opts LogOptions) (io.ReadCloser, error) { query := "?tail=" + strconv.Itoa(int(opts.Tail)) if opts.Follow { query += "&follow=true" @@ -41,9 +45,12 @@ func (c *Client) StreamLogs(ctx context.Context, stackID, resourceName string, o req.Header.Set("Authorization", "Bearer "+c.accessToken) req.Header.Set("Accept", "text/event-stream") - httpClient := c.cfg.HTTPClient - if httpClient == nil { - httpClient = http.DefaultClient + // `-f` follows a log stream indefinitely; reuse the configured transport (so + // token refresh still applies) but not its 30s whole-request timeout. The + // context governs the stream's lifetime. + httpClient := &http.Client{} + if c.cfg.HTTPClient != nil { + httpClient.Transport = c.cfg.HTTPClient.Transport } resp, err := httpClient.Do(req) diff --git a/internal/client/releases.go b/internal/client/releases.go new file mode 100644 index 0000000..173c9ec --- /dev/null +++ b/internal/client/releases.go @@ -0,0 +1,199 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" + clierrors "github.com/stackdome/cli/internal/errors" +) + +// reconnectBackoff is the pause before retrying a *failed* reconnect. A clean +// drop reconnects immediately. +const reconnectBackoff = time.Second + +// maxReconnectAttempts bounds consecutive failed reconnects; any delivered +// event resets the counter. +const maxReconnectAttempts = 5 + +func (c *Client) ListReleases(ctx context.Context, stackID string) ([]openapi.StackRelease, error) { + resp, httpResp, err := c.apiClient.ReleasesApi. + ListReleases(ctx, c.orgID, c.projectName, stackID).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to list releases") + } + return resp.Items, nil +} + +// CreateRelease starts a new release from the stack's current document. An +// apply only stores the document — this is what actually rolls it out. +func (c *Client) CreateRelease(ctx context.Context, stackID string) (*openapi.StackRelease, error) { + resp, httpResp, err := c.apiClient.ReleasesApi. + CreateRelease(ctx, c.orgID, c.projectName, stackID). + CreateReleaseRequest(openapi.CreateReleaseRequest{}).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to create release") + } + return resp, nil +} + +func (c *Client) GetRelease(ctx context.Context, stackID, releaseID string) (*openapi.StackReleaseDetail, error) { + resp, httpResp, err := c.apiClient.ReleasesApi. + GetRelease(ctx, c.orgID, c.projectName, stackID, releaseID).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to get release") + } + return resp, nil +} + +func (c *Client) CancelRelease(ctx context.Context, stackID, releaseID string) error { + httpResp, err := c.apiClient.ReleasesApi. + CancelRelease(ctx, c.orgID, c.projectName, stackID, releaseID).Execute() + if err != nil { + return WrapError(httpResp, err, "Failed to cancel release") + } + return nil +} + +func (c *Client) ListReleaseEvents(ctx context.Context, stackID, releaseID string, afterSequence int32) ([]openapi.ReleaseEvent, error) { + resp, httpResp, err := c.apiClient.ReleasesApi. + ListReleaseEvents(ctx, c.orgID, c.projectName, stackID, releaseID). + AfterSequence(afterSequence).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to list release events") + } + return resp.Items, nil +} + +// errStreamEnd marks the server's terminal `event: end` — a clean close, not a +// dropped connection, so the stream must not reconnect. +var errStreamEnd = errors.New("stream ended") + +// StreamReleaseEvents follows a release's SSE event stream, resuming from the +// last sequence seen when the connection drops. The channel closes when the +// server signals `event: end`, the context is cancelled, or reconnection gives +// up. Events are delivered exactly once. +func (c *Client) StreamReleaseEvents(ctx context.Context, stackID, releaseID string, afterSequence int32) (<-chan SSEEvent, error) { + body, err := c.openReleaseEventStream(ctx, stackID, releaseID, afterSequence) + if err != nil { + return nil, err + } + + out := make(chan SSEEvent) + go func() { + defer close(out) + + last := afterSequence + attempts := 0 + + for { + progressed := false + err := ParseSSEStream(body, func(e SSEEvent) error { + if e.Event == "end" { + return errStreamEnd + } + if seq, ok := eventSequence(e.Data); ok { + if seq <= last { + return nil // already delivered before the drop + } + last = seq + } + select { + case out <- e: + progressed = true + case <-ctx.Done(): + return ctx.Err() + } + if e.Event == "error" { + return errStreamEnd + } + return nil + }) + body.Close() + + if err != nil || ctx.Err() != nil { + return // clean end, error event, or cancellation + } + + // EOF without an end event: the connection dropped mid-release. + // Only a round that delivered nothing counts against the budget, + // so a healthy but flaky stream reconnects indefinitely while a + // server that instantly EOFs cannot spin us hot. + if progressed { + attempts = 0 + } else { + attempts++ + } + if attempts > maxReconnectAttempts { + select { + case out <- SSEEvent{Event: "error", Data: "lost connection to release event stream"}: + case <-ctx.Done(): + } + return + } + if attempts > 1 { + select { + case <-time.After(reconnectBackoff): + case <-ctx.Done(): + return + } + } + body, err = c.openReleaseEventStream(ctx, stackID, releaseID, last) + if err != nil { + body = io.NopCloser(emptyReader{}) + } + } + }() + + return out, nil +} + +type emptyReader struct{} + +func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF } + +func (c *Client) openReleaseEventStream(ctx context.Context, stackID, releaseID string, afterSequence int32) (io.ReadCloser, error) { + url := fmt.Sprintf("%s/api/v1/organizations/%s/projects/%s/stacks/%s/releases/%s/events/stream?after_sequence=%d", + c.baseURL, c.orgID, c.projectName, stackID, releaseID, afterSequence) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, clierrors.Wrap(err, "Failed to create release event request") + } + req.Header.Set("Authorization", "Bearer "+c.accessToken) + req.Header.Set("Accept", "text/event-stream") + + // A release can be quiet for minutes; reuse the configured transport (so + // token refresh still applies) but not its 30s whole-request timeout. + httpClient := &http.Client{} + if c.cfg.HTTPClient != nil { + httpClient.Transport = c.cfg.HTTPClient.Transport + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, clierrors.Wrap(err, "Failed to connect to release event stream") + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, clierrors.FromHTTP(resp.StatusCode, "Release event streaming failed") + } + return resp.Body, nil +} + +// eventSequence pulls the monotonic sequence out of an event payload so a +// reconnect can resume from it. +func eventSequence(data string) (int32, bool) { + var payload struct { + Sequence *int32 `json:"sequence"` + } + if json.Unmarshal([]byte(data), &payload) != nil || payload.Sequence == nil { + return 0, false + } + return *payload.Sequence, true +} diff --git a/internal/client/releases_test.go b/internal/client/releases_test.go new file mode 100644 index 0000000..6a1a933 --- /dev/null +++ b/internal/client/releases_test.go @@ -0,0 +1,92 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +// TestStreamReleaseEventsResumesAfterDrop: the server hands out events 1-3 then +// drops the connection mid-release. The client must reconnect from where it +// left off (?after_sequence=3) and deliver 4-5 — every event exactly once. +func TestStreamReleaseEventsResumesAfterDrop(t *testing.T) { + var mu sync.Mutex + var seenAfter []string + attempt := 0 + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + attempt++ + n := attempt + seenAfter = append(seenAfter, r.URL.Query().Get("after_sequence")) + mu.Unlock() + + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + + if n == 1 { + for seq := 1; seq <= 3; seq++ { + fmt.Fprintf(w, "id: %d\ndata: {\"sequence\":%d,\"message\":\"step %d\"}\n\n", seq, seq, seq) + } + flusher.Flush() + return // handler returns == connection drops, no "end" event + } + + for seq := 4; seq <= 5; seq++ { + fmt.Fprintf(w, "id: %d\ndata: {\"sequence\":%d,\"message\":\"step %d\"}\n\n", seq, seq, seq) + } + fmt.Fprint(w, "event: end\ndata: {}\n\n") + flusher.Flush() + })) + defer ts.Close() + + c := New(ts.URL, WithTokens("access", ""), WithOrgAndProject("org-1", "proj-1")) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + events, err := c.StreamReleaseEvents(ctx, "stack-1", "rel-1", 0) + if err != nil { + t.Fatalf("StreamReleaseEvents: %v", err) + } + + var got []string + for e := range events { + if e.Event == "error" { + t.Fatalf("stream error event: %s", e.Data) + } + got = append(got, e.Data) + } + + want := []string{ + `{"sequence":1,"message":"step 1"}`, + `{"sequence":2,"message":"step 2"}`, + `{"sequence":3,"message":"step 3"}`, + `{"sequence":4,"message":"step 4"}`, + `{"sequence":5,"message":"step 5"}`, + } + if len(got) != len(want) { + t.Fatalf("expected %d events, got %d: %v", len(want), len(got), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("event %d: got %q, want %q", i, got[i], want[i]) + } + } + + mu.Lock() + defer mu.Unlock() + if len(seenAfter) != 2 { + t.Fatalf("expected 2 connections, got %d", len(seenAfter)) + } + if seenAfter[0] != "0" { + t.Errorf("first connect after_sequence = %q, want %q", seenAfter[0], "0") + } + if seenAfter[1] != "3" { + t.Errorf("reconnect after_sequence = %q, want %q", seenAfter[1], "3") + } +} diff --git a/internal/client/secrets.go b/internal/client/secrets.go index 913cb02..15fda1f 100644 --- a/internal/client/secrets.go +++ b/internal/client/secrets.go @@ -3,12 +3,12 @@ package client import ( "context" - openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + openapi "github.com/Stackdome/stackdome/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). + ApiV1OrganizationsOrgIdProjectsProjectNameSecretsGet(ctx, c.orgID, c.projectName). Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to list secrets") @@ -18,7 +18,7 @@ func (c *Client) ListSecrets(ctx context.Context) ([]openapi.Secret, error) { 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). + ApiV1OrganizationsOrgIdProjectsProjectNameSecretsIdGet(ctx, c.orgID, c.projectName, secretID). Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to get secret") @@ -28,7 +28,7 @@ func (c *Client) GetSecret(ctx context.Context, secretID string) (*openapi.Secre 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). + ApiV1OrganizationsOrgIdProjectsProjectNameSecretsPost(ctx, c.orgID, c.projectName). Secret(secret).Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to create secret") @@ -38,7 +38,7 @@ func (c *Client) CreateSecret(ctx context.Context, secret openapi.Secret) (*open 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). + ApiV1OrganizationsOrgIdProjectsProjectNameSecretsIdPut(ctx, c.orgID, c.projectName, secretID). Secret(secret).Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to update secret") @@ -48,7 +48,7 @@ func (c *Client) UpdateSecret(ctx context.Context, secretID string, secret opena func (c *Client) DeleteSecret(ctx context.Context, secretID string) error { httpResp, err := c.apiClient.DefaultApi. - ApiV1OrganizationsOrgIdTeamsTeamNameSecretsIdDelete(ctx, c.orgID, c.teamName, secretID). + ApiV1OrganizationsOrgIdProjectsProjectNameSecretsIdDelete(ctx, c.orgID, c.projectName, secretID). Execute() if err != nil { return WrapError(httpResp, err, "Failed to delete secret") diff --git a/internal/client/sse.go b/internal/client/sse.go index b53d538..3ab5340 100644 --- a/internal/client/sse.go +++ b/internal/client/sse.go @@ -11,6 +11,13 @@ type SSEEvent struct { Data string } +// IsEnd reports a stream terminator rather than payload. The server always +// closes a stream with an "end" event (its payload happens to be `{}`, which +// is what leaked into log output when the frame was printed). +func (e SSEEvent) IsEnd() bool { + return e.Event == "end" +} + func ParseSSEStream(r io.Reader, fn func(SSEEvent) error) error { scanner := bufio.NewScanner(r) var event SSEEvent diff --git a/internal/client/sse_test.go b/internal/client/sse_test.go index 2253e33..db4e0df 100644 --- a/internal/client/sse_test.go +++ b/internal/client/sse_test.go @@ -100,3 +100,21 @@ func TestParseSSEStream_CallbackError(t *testing.T) { type testErr struct{ msg string } func (e *testErr) Error() string { return e.msg } + +func TestSSEEventIsEnd(t *testing.T) { + cases := []struct { + event SSEEvent + want bool + }{ + {SSEEvent{Event: "end", Data: "{}"}, true}, + {SSEEvent{Event: "end"}, true}, + {SSEEvent{Data: "{}"}, false}, // a log line that happens to be `{}` still prints + {SSEEvent{Data: "[web]: hello"}, false}, + {SSEEvent{Data: ""}, false}, + } + for _, c := range cases { + if got := c.event.IsEnd(); got != c.want { + t.Errorf("IsEnd(%+v) = %v, want %v", c.event, got, c.want) + } + } +} diff --git a/internal/client/stacks.go b/internal/client/stacks.go index 2967736..500939b 100644 --- a/internal/client/stacks.go +++ b/internal/client/stacks.go @@ -3,12 +3,12 @@ package client import ( "context" - openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + openapi "github.com/Stackdome/stackdome/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). + ApiV1OrganizationsOrgIdProjectsProjectNameStacksPost(ctx, c.orgID, c.projectName). Stack(stack).Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to create stack") @@ -16,9 +16,23 @@ func (c *Client) CreateStack(ctx context.Context, stack openapi.Stack) (*openapi return resp, nil } +// ApplyStack upserts a stack by name from a whole stack document. It replaces +// the read-modify-write of FindStackByName + POST/PUT: the server reconciles +// against the document (create if absent), so there is no race and no readOnly +// field to strip. +func (c *Client) ApplyStack(ctx context.Context, stack openapi.Stack) (*openapi.Stack, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApplyStackByName(ctx, c.orgID, c.projectName). + Stack(stack).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to apply 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() + ApiV1OrganizationsOrgIdProjectsProjectNameStacksIdGet(ctx, c.orgID, c.projectName, stackID).Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to get stack") } @@ -27,7 +41,7 @@ func (c *Client) GetStack(ctx context.Context, stackID string) (*openapi.Stack, 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). + ApiV1OrganizationsOrgIdProjectsProjectNameStacksIdPut(ctx, c.orgID, c.projectName, stackID). Stack(stack).Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to update stack") @@ -37,7 +51,7 @@ func (c *Client) UpdateStack(ctx context.Context, stackID string, stack openapi. func (c *Client) DeleteStack(ctx context.Context, stackID string) error { _, httpResp, err := c.apiClient.DefaultApi. - ApiV1OrganizationsOrgIdTeamsTeamNameStacksIdDelete(ctx, c.orgID, c.teamName, stackID).Execute() + ApiV1OrganizationsOrgIdProjectsProjectNameStacksIdDelete(ctx, c.orgID, c.projectName, stackID).Execute() if err != nil { return WrapError(httpResp, err, "Failed to delete stack") } @@ -46,7 +60,7 @@ func (c *Client) DeleteStack(ctx context.Context, stackID string) error { func (c *Client) ListStacks(ctx context.Context) ([]openapi.Stack, error) { resp, httpResp, err := c.apiClient.DefaultApi. - ApiV1OrganizationsOrgIdTeamsTeamNameStacksGet(ctx, c.orgID, c.teamName).Execute() + ApiV1OrganizationsOrgIdProjectsProjectNameStacksGet(ctx, c.orgID, c.projectName).Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to list stacks") } @@ -55,7 +69,7 @@ func (c *Client) ListStacks(ctx context.Context) ([]openapi.Stack, error) { 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() + ApiV1OrganizationsOrgIdProjectsProjectNameStacksIdResourcesGet(ctx, c.orgID, c.projectName, stackID).Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to get stack resources") } @@ -64,7 +78,7 @@ func (c *Client) GetStackResources(ctx context.Context, stackID string) ([]opena 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). + ApiV1OrganizationsOrgIdProjectsProjectNameStacksIdResourcesResourceNameActionsRestartPost(ctx, c.orgID, c.projectName, stackID, resourceName). Execute() if err != nil { return nil, WrapError(httpResp, err, "Failed to restart resource") @@ -72,6 +86,29 @@ func (c *Client) RestartResource(ctx context.Context, stackID, resourceName stri return resp, nil } +// GetStackLiveStatus returns the runtime status of a stack's resources, which +// now lives on the stack's release rather than on the stack itself. Returns nil +// when the stack has no release yet. +// +// It reports on the converged release — what is actually serving — falling back +// to the latest release only when nothing has converged yet. +func (c *Client) GetStackLiveStatus(ctx context.Context, stack *openapi.Stack) (*openapi.ReleaseLiveStatus, error) { + rel := stack.ConvergedRelease + if rel == nil { + rel = stack.LatestRelease + } + if rel == nil || rel.Id == nil || stack.Id == nil { + return nil, nil + } + + resp, httpResp, err := c.apiClient.ReleasesApi. + GetRelease(ctx, c.orgID, c.projectName, *stack.Id, *rel.Id).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to get release status") + } + return resp.LiveStatus, 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/client/tokens.go b/internal/client/tokens.go new file mode 100644 index 0000000..da89598 --- /dev/null +++ b/internal/client/tokens.go @@ -0,0 +1,58 @@ +package client + +import ( + "context" + "time" + + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" +) + +// CreateAPIToken mints a personal access token. The raw `sdm_` value comes back +// exactly once — the server keeps only its hash. +func (c *Client) CreateAPIToken(ctx context.Context, name string, scopes []string, resourceIDs []string, expiresAt *time.Time) (*openapi.APITokenCreateResponse, error) { + req := openapi.APITokenCreateRequest{ + Name: name, + Scopes: scopes, + ResourceIds: resourceIDs, + ExpiresAt: expiresAt, + } + + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1ApiTokensPost(ctx). + APITokenCreateRequest(req). + Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to create API token") + } + return resp, nil +} + +func (c *Client) ListAPITokens(ctx context.Context) ([]openapi.APIToken, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1ApiTokensGet(ctx). + Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to list API tokens") + } + return resp.GetItems(), nil +} + +func (c *Client) DeleteAPIToken(ctx context.Context, tokenID string) error { + httpResp, err := c.apiClient.DefaultApi. + ApiV1ApiTokensIdDelete(ctx, tokenID). + Execute() + if err != nil { + return WrapError(httpResp, err, "Failed to delete API token") + } + return nil +} + +func (c *Client) ListTokenScopes(ctx context.Context) (*openapi.ScopeList, error) { + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1ApiTokensScopesGet(ctx). + Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to list token scopes") + } + return resp, nil +} diff --git a/internal/client/tokens_test.go b/internal/client/tokens_test.go new file mode 100644 index 0000000..87c8b7e --- /dev/null +++ b/internal/client/tokens_test.go @@ -0,0 +1,92 @@ +package client + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// The create call must send name/scopes/expiry as the API expects and hand back +// the raw token verbatim — it is unrecoverable after this response. +func TestCreateAPIToken(t *testing.T) { + var gotBody map[string]any + var gotPath, gotMethod string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotMethod = r.URL.Path, r.Method + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"sdm_deadbeef","id":"tok-1","name":"ci","token_prefix":"sdm_dead"}`)) + })) + defer ts.Close() + + expires := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) + c := New(ts.URL, WithTokens("access", "")) + resp, err := c.CreateAPIToken(context.Background(), "ci", []string{"stacks:*", "secrets:read"}, nil, &expires) + if err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + + if gotMethod != http.MethodPost || gotPath != "/api/v1/api-tokens" { + t.Errorf("request = %s %s, want POST /api/v1/api-tokens", gotMethod, gotPath) + } + if gotBody["name"] != "ci" { + t.Errorf("name = %v, want ci", gotBody["name"]) + } + if scopes, _ := gotBody["scopes"].([]any); len(scopes) != 2 || scopes[0] != "stacks:*" { + t.Errorf("scopes = %v, want [stacks:* secrets:read]", gotBody["scopes"]) + } + if gotBody["expires_at"] == nil { + t.Error("expires_at missing from request body") + } + if _, ok := gotBody["resource_ids"]; ok { + t.Errorf("resource_ids sent despite being empty: %v", gotBody["resource_ids"]) + } + if resp.GetToken() != "sdm_deadbeef" || resp.GetTokenPrefix() != "sdm_dead" { + t.Errorf("response = %+v, want raw token and prefix preserved", resp) + } +} + +func TestListAPITokensAndErrors(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/api-tokens": + _, _ = w.Write([]byte(`{"items":[{"id":"tok-1","name":"ci","token_prefix":"sdm_dead","scopes":["stacks:*"]}]}`)) + case "/api/v1/api-tokens/scopes": + _, _ = w.Write([]byte(`{"full_access_scope":"*:*","items":[{"resource":"stacks","actions":["read","write"]}]}`)) + default: // /api/v1/api-tokens/{id} DELETE + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"reason":"token not found"}`)) + } + })) + defer ts.Close() + + c := New(ts.URL, WithTokens("access", "")) + ctx := context.Background() + + tokens, err := c.ListAPITokens(ctx) + if err != nil { + t.Fatalf("ListAPITokens: %v", err) + } + if len(tokens) != 1 || tokens[0].GetTokenPrefix() != "sdm_dead" { + t.Errorf("tokens = %+v, want one token with prefix sdm_dead", tokens) + } + + scopes, err := c.ListTokenScopes(ctx) + if err != nil { + t.Fatalf("ListTokenScopes: %v", err) + } + if scopes.GetFullAccessScope() != "*:*" || len(scopes.Items) != 1 { + t.Errorf("scopes = %+v, want full access *:* and one resource", scopes) + } + + if err := c.DeleteAPIToken(ctx, "missing"); err == nil { + t.Fatal("DeleteAPIToken on 404 = nil, want error") + } +} diff --git a/internal/client/volumes.go b/internal/client/volumes.go index 74a9a6f..6805289 100644 --- a/internal/client/volumes.go +++ b/internal/client/volumes.go @@ -6,12 +6,19 @@ import ( "fmt" "net/http" - openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + openapi "github.com/Stackdome/stackdome/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) +// ListVolumes lists the volumes of a stack. +// +// Hand-rolled on purpose: `GET .../stacks/{id}/volumes` exists in the router but +// is missing from the OpenAPI spec (config/openapi/stackdome_api.yaml), so the +// generated client has no method for it. The generated client only exposes +// project-level volume POST/GET-by-id/DELETE. Replace this once the spec gains +// the list operation. +func (c *Client) ListVolumes(ctx context.Context, stackID string) ([]openapi.Volume, error) { + url := fmt.Sprintf("%s/api/v1/organizations/%s/projects/%s/stacks/%s/volumes", c.baseURL, c.orgID, c.projectName, stackID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -42,9 +49,26 @@ func (c *Client) ListVolumes(ctx context.Context) ([]openapi.Volume, error) { return list.GetItems(), nil } +func (c *Client) CreateVolume(ctx context.Context, name, size, accessMode string) (*openapi.Volume, error) { + volume := openapi.Volume{ + Name: name, + Spec: openapi.VolumeSpec{ + Size: size, + AccessMode: openapi.VolumeAccessMode(accessMode), + }, + } + resp, httpResp, err := c.apiClient.DefaultApi. + ApiV1OrganizationsOrgIdProjectsProjectNameVolumesPost(ctx, c.orgID, c.projectName). + Volume(volume).Execute() + if err != nil { + return nil, WrapError(httpResp, err, "Failed to create volume") + } + return resp, nil +} + func (c *Client) DeleteVolume(ctx context.Context, volumeID string) error { httpResp, err := c.apiClient.DefaultApi. - ApiV1OrganizationsOrgIdTeamsTeamNameVolumesIdDelete(ctx, c.orgID, c.teamName, volumeID). + ApiV1OrganizationsOrgIdProjectsProjectNameVolumesIdDelete(ctx, c.orgID, c.projectName, volumeID). Execute() if err != nil { return WrapError(httpResp, err, "Failed to delete volume") @@ -52,8 +76,8 @@ func (c *Client) DeleteVolume(ctx context.Context, volumeID string) error { return nil } -func (c *Client) FindVolumeByName(ctx context.Context, name string) (*openapi.Volume, error) { - volumes, err := c.ListVolumes(ctx) +func (c *Client) FindVolumeByName(ctx context.Context, stackID, name string) (*openapi.Volume, error) { + volumes, err := c.ListVolumes(ctx, stackID) if err != nil { return nil, err } diff --git a/internal/cmdutil/context.go b/internal/cmdutil/context.go index 8086a67..50ddc12 100644 --- a/internal/cmdutil/context.go +++ b/internal/cmdutil/context.go @@ -23,16 +23,21 @@ func NewCommandContext(cfg *config.Config, format output.Format, logLevel slog.L var c *client.Client if cfg.IsLoggedIn() { - c = client.New(cfg.ServerURL, + opts := []client.Option{ client.WithTokens(cfg.AccessToken, cfg.RefreshToken), - client.WithOrgAndTeam(cfg.OrganizationID, cfg.TeamName), + client.WithOrgAndProject(cfg.OrganizationID, cfg.ProjectName), client.WithInsecure(cfg.Insecure), - client.WithTokenRefreshCallback(func(accessToken, refreshToken string) error { + } + // An env token has no refresh token behind it, and persisting one + // would write ephemeral credentials to disk. + if !cfg.TokenFromEnv() { + opts = append(opts, client.WithTokenRefreshCallback(func(accessToken, refreshToken string) error { cfg.AccessToken = accessToken cfg.RefreshToken = refreshToken return cfg.Save() - }), - ) + })) + } + c = client.New(cfg.ServerURL, opts...) } return &CommandContext{ diff --git a/internal/cmdutil/middleware.go b/internal/cmdutil/middleware.go index f1ae64c..a35c27a 100644 --- a/internal/cmdutil/middleware.go +++ b/internal/cmdutil/middleware.go @@ -2,6 +2,7 @@ package cmdutil import ( "github.com/spf13/cobra" + clierrors "github.com/stackdome/cli/internal/errors" ) type contextKey struct{} @@ -28,20 +29,52 @@ func RequireAuth(fn RunEWithContext) RunEWithContext { if err := ctx.Config.RequireAuth(); err != nil { return err } + if err := resolveScope(ctx, cmd); 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 - } +// resolveScope fills in the org/project the client scopes its calls to. With +// STACKDOME_TOKEN and no config file there is nothing on disk to read them +// from, so resolve them from the API once, in memory only — unless +// STACKDOME_ORG and STACKDOME_PROJECT already supplied both, in which case no +// discovery call is made at all (a scoped API token may not be allowed one). +func resolveScope(ctx *CommandContext, cmd *cobra.Command) error { + if ctx.Config.OrganizationID != "" && ctx.Config.ProjectName != "" { + return nil + } + + if ctx.Config.OrganizationID == "" { + user, err := ctx.Client.GetCurrentUser(cmd.Context()) + if err != nil { + return scopeError(ctx, err) } - return fn(ctx, cmd, args, stackName) + ctx.Config.OrganizationID = user.GetOrganisationId() + } + if ctx.Config.ProjectName == "" { + name, err := ctx.Client.ResolveDefaultProject(cmd.Context(), ctx.Config.OrganizationID) + if err != nil { + return scopeError(ctx, err) + } + ctx.Config.ProjectName = name + } + + ctx.Client.SetOrgAndProject(ctx.Config.OrganizationID, ctx.Config.ProjectName) + return nil +} + +// scopeError rewrites a discovery failure for token auth: "run stackdome login" +// is meaningless for an API token, which most likely just lacks the scope to +// read projects. +func scopeError(ctx *CommandContext, err error) error { + if !ctx.Config.TokenFromEnv() { + return err } + return clierrors.Wrap(err, + "Could not determine your organisation and project: the API token may lack project read scope. "+ + "Set STACKDOME_ORG and STACKDOME_PROJECT to skip discovery."). + WithCode("SCOPE_UNRESOLVED"). + WithExitCode(clierrors.ExitAuth) } diff --git a/internal/cmdutil/middleware_test.go b/internal/cmdutil/middleware_test.go new file mode 100644 index 0000000..9f4aeae --- /dev/null +++ b/internal/cmdutil/middleware_test.go @@ -0,0 +1,63 @@ +package cmdutil + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stackdome/cli/internal/config" + clierrors "github.com/stackdome/cli/internal/errors" +) + +// With org and project supplied (e.g. via STACKDOME_ORG / STACKDOME_PROJECT) +// no discovery call may be made — a scoped API token is not allowed one. The +// nil Client makes any API call panic, which is the assertion. +func TestResolveScopeSkipsDiscoveryWhenScopeKnown(t *testing.T) { + t.Setenv("STACKDOME_CONFIG", t.TempDir()+"/missing.json") + t.Setenv("STACKDOME_TOKEN", "sdm_abc") + t.Setenv("STACKDOME_URL", "https://hub.example") + t.Setenv("STACKDOME_ORG", "org-123") + t.Setenv("STACKDOME_PROJECT", "default") + + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + ctx := &CommandContext{Config: cfg} + if err := resolveScope(ctx, &cobra.Command{}); err != nil { + t.Fatalf("resolveScope: %v", err) + } +} + +func TestScopeErrorForEnvToken(t *testing.T) { + t.Setenv("STACKDOME_CONFIG", t.TempDir()+"/missing.json") + t.Setenv("STACKDOME_TOKEN", "sdm_abc") + t.Setenv("STACKDOME_URL", "https://hub.example") + + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + orig := clierrors.FromHTTP(403, "") + got := scopeError(&CommandContext{Config: cfg}, orig) + msg := clierrors.UserMessage(got) + if strings.Contains(msg, "stackdome login") { + t.Errorf("token auth error still suggests login: %q", msg) + } + if !strings.Contains(msg, "STACKDOME_ORG") || !strings.Contains(msg, "scope") { + t.Errorf("error does not explain the token scope problem: %q", msg) + } + if clierrors.ExitCodeFrom(got) != clierrors.ExitAuth { + t.Errorf("exit code = %d, want %d", clierrors.ExitCodeFrom(got), clierrors.ExitAuth) + } +} + +// Session (file) auth keeps the original message — "run stackdome login" is +// the right advice there. +func TestScopeErrorPassesThroughForSessionAuth(t *testing.T) { + cfg := &config.Config{ServerURL: "https://hub.example", AccessToken: "file_tok"} + orig := clierrors.FromHTTP(403, "") + if got := scopeError(&CommandContext{Config: cfg}, orig); got != error(orig) { + t.Errorf("scopeError rewrote a session-auth error: %v", got) + } +} diff --git a/internal/cmdutil/prompt.go b/internal/cmdutil/prompt.go new file mode 100644 index 0000000..cbaad90 --- /dev/null +++ b/internal/cmdutil/prompt.go @@ -0,0 +1,41 @@ +package cmdutil + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + + clierrors "github.com/stackdome/cli/internal/errors" + "github.com/stackdome/cli/internal/output" + "golang.org/x/term" +) + +// Confirm asks for a y/N confirmation on stderr. assumeYes (--yes) skips the +// prompt entirely; a non-interactive stdin without --yes is an error rather +// than a guess. The formatter is accepted for call-site symmetry only — +// prompts always go to stderr so they never pollute -o json output. +func Confirm(f *output.Formatter, prompt string, assumeYes bool) (bool, error) { + return ConfirmFrom(os.Stdin, term.IsTerminal(int(os.Stdin.Fd())), assumeYes, os.Stderr, prompt) +} + +// ConfirmFrom is the testable core of Confirm. +func ConfirmFrom(r io.Reader, isTTY, assumeYes bool, w io.Writer, prompt string) (bool, error) { + if assumeYes { + return true, nil + } + if !isTTY { + return false, clierrors.ValidationError("confirmation required; pass --yes in non-interactive mode") + } + + fmt.Fprintf(w, "%s [y/N]: ", prompt) + scanner := bufio.NewScanner(r) + scanner.Scan() + + switch strings.ToLower(strings.TrimSpace(scanner.Text())) { + case "y", "yes": + return true, nil + } + return false, clierrors.ErrUserCanceled +} diff --git a/internal/cmdutil/prompt_test.go b/internal/cmdutil/prompt_test.go new file mode 100644 index 0000000..a6b1065 --- /dev/null +++ b/internal/cmdutil/prompt_test.go @@ -0,0 +1,47 @@ +package cmdutil + +import ( + "errors" + "io" + "strings" + "testing" + + clierrors "github.com/stackdome/cli/internal/errors" +) + +func TestConfirmNonTTYWithoutYesErrors(t *testing.T) { + _, err := ConfirmFrom(strings.NewReader(""), false /*isTTY*/, false /*assumeYes*/, io.Discard, "delete?") + if err == nil { + t.Fatal("expected an error when stdin is not a TTY and --yes is absent") + } + if code := clierrors.ExitCodeFrom(err); code != clierrors.ExitValidation { + t.Errorf("exit code = %d, want %d", code, clierrors.ExitValidation) + } +} + +func TestConfirmNoReturnsCanceled(t *testing.T) { + ok, err := ConfirmFrom(strings.NewReader("n\n"), true, false, io.Discard, "delete?") + if ok { + t.Error("expected ok=false") + } + if !errors.Is(err, clierrors.ErrUserCanceled) { + t.Fatalf("err = %v, want ErrUserCanceled", err) + } + if code := clierrors.ExitCodeFrom(err); code != clierrors.ExitUserCanceled { + t.Errorf("exit code = %d, want %d", code, clierrors.ExitUserCanceled) + } +} + +func TestConfirmYes(t *testing.T) { + ok, err := ConfirmFrom(strings.NewReader("y\n"), true, false, io.Discard, "delete?") + if err != nil || !ok { + t.Fatalf("ok=%v err=%v, want true/nil", ok, err) + } +} + +func TestConfirmAssumeYesSkipsPrompt(t *testing.T) { + ok, err := ConfirmFrom(strings.NewReader(""), false, true, io.Discard, "delete?") + if err != nil || !ok { + t.Fatalf("ok=%v err=%v, want true/nil", ok, err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 2df0c0b..d5b327a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,12 +21,45 @@ type Config struct { AccessToken string `json:"access_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` OrganizationID string `json:"organization_id,omitempty"` - TeamName string `json:"team_name,omitempty"` + ProjectName string `json:"project_name,omitempty"` Username string `json:"username,omitempty"` CurrentStack string `json:"current_stack,omitempty"` Insecure bool `json:"insecure,omitempty"` path string `json:"-"` + + // What the file held and what the environment overrode it with, so Save + // can drop the ephemeral STACKDOME_URL / STACKDOME_TOKEN values — but only + // while they are still the values in play. + fileServerURL string `json:"-"` + fileAccessToken string `json:"-"` + fileRefreshToken string `json:"-"` + fileOrgID string `json:"-"` + fileProjectName string `json:"-"` + envServerURL string `json:"-"` + envAccessToken string `json:"-"` + envOrgID string `json:"-"` + envProjectName string `json:"-"` +} + +// TokenFromEnv reports whether the access token in play is STACKDOME_TOKEN. +// False once login/signup replaces it — that token is a real credential and +// belongs on disk. Env tokens are not refreshable: no refresh token comes +// with them, and they must never be written out. +func (c *Config) TokenFromEnv() bool { + return c.envAccessToken != "" && c.AccessToken == c.envAccessToken +} + +// AdoptEnvValues drops the env latches so Save writes the current values to +// disk verbatim. login/signup call it: an explicit login must persist a full +// config even when the values happen to equal STACKDOME_URL / STACKDOME_TOKEN. +func (c *Config) AdoptEnvValues() { + c.envServerURL, c.envAccessToken = "", "" + c.envOrgID, c.envProjectName = "", "" +} + +func (c *Config) urlFromEnv() bool { + return c.envServerURL != "" && c.ServerURL == c.envServerURL } func DefaultPath() (string, error) { @@ -45,7 +78,39 @@ func Load() (*Config, error) { if err != nil { return nil, err } - return LoadFrom(path) + cfg, err := LoadFrom(path) + if err != nil { + return nil, err + } + cfg.applyEnv() + return cfg, nil +} + +// applyEnv overlays STACKDOME_URL / STACKDOME_TOKEN / STACKDOME_ORG / +// STACKDOME_PROJECT on top of the file config. A token from the environment +// stands alone: no refresh token comes with it. Org/project from the +// environment let a scoped API token skip project discovery entirely. +func (c *Config) applyEnv() { + c.fileServerURL, c.fileAccessToken, c.fileRefreshToken = c.ServerURL, c.AccessToken, c.RefreshToken + c.fileOrgID, c.fileProjectName = c.OrganizationID, c.ProjectName + + if v := os.Getenv("STACKDOME_URL"); v != "" { + c.ServerURL = v + c.envServerURL = v + } + if v := os.Getenv("STACKDOME_TOKEN"); v != "" { + c.AccessToken = v + c.RefreshToken = "" + c.envAccessToken = v + } + if v := os.Getenv("STACKDOME_ORG"); v != "" { + c.OrganizationID = v + c.envOrgID = v + } + if v := os.Getenv("STACKDOME_PROJECT"); v != "" { + c.ProjectName = v + c.envProjectName = v + } } func LoadFrom(path string) (*Config, error) { @@ -62,6 +127,17 @@ func LoadFrom(path string) (*Config, error) { if err := json.Unmarshal(data, cfg); err != nil { return nil, clierrors.Wrapf(err, "Failed to parse config from %s", path) } + + // Teams were renamed to projects; adopt the legacy key from older configs. + if cfg.ProjectName == "" { + var legacy struct { + TeamName string `json:"team_name"` + } + if json.Unmarshal(data, &legacy) == nil { + cfg.ProjectName = legacy.TeamName + } + } + cfg.path = path return cfg, nil } @@ -80,7 +156,23 @@ func (c *Config) Save() error { return clierrors.Wrapf(err, "Failed to create config directory %s", dir) } - data, err := json.MarshalIndent(c, "", " ") + // Env overrides are ephemeral — write back what the file had, unless + // login/signup has since replaced the value with a real credential. + out := *c + if c.urlFromEnv() { + out.ServerURL = c.fileServerURL + } + if c.TokenFromEnv() { + out.AccessToken, out.RefreshToken = c.fileAccessToken, c.fileRefreshToken + } + if c.envOrgID != "" && c.OrganizationID == c.envOrgID { + out.OrganizationID = c.fileOrgID + } + if c.envProjectName != "" && c.ProjectName == c.envProjectName { + out.ProjectName = c.fileProjectName + } + + data, err := json.MarshalIndent(&out, "", " ") if err != nil { return clierrors.Wrap(err, "Failed to serialize config") } @@ -124,6 +216,11 @@ func (c *Config) RequireStack() (string, error) { func (c *Config) SetCurrentStack(name string) error { c.CurrentStack = name + // An env-token session is stateless by design: don't create (or fail on) + // a config file just to remember the stack for a process that's exiting. + if c.TokenFromEnv() { + return nil + } return c.Save() } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..cd76c54 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,220 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeConfig(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestEnvVarOverride(t *testing.T) { + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "missing.json")) + t.Setenv("STACKDOME_TOKEN", "sdm_abc") + t.Setenv("STACKDOME_URL", "https://hub.example") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.AccessToken != "sdm_abc" { + t.Errorf("AccessToken = %q, want sdm_abc", cfg.AccessToken) + } + if cfg.ServerURL != "https://hub.example" { + t.Errorf("ServerURL = %q, want https://hub.example", cfg.ServerURL) + } + if err := cfg.RequireAuth(); err != nil { + t.Errorf("RequireAuth with env credentials and no config file: %v", err) + } +} + +// Env credentials are ephemeral: saving unrelated state (e.g. current stack) +// must not leak the env token or URL into the config file. +func TestEnvVarsNotPersisted(t *testing.T) { + path := writeConfig(t, `{"server_url":"https://file","access_token":"file_tok"}`) + t.Setenv("STACKDOME_CONFIG", path) + t.Setenv("STACKDOME_TOKEN", "sdm_abc") + t.Setenv("STACKDOME_URL", "https://hub.example") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + cfg.CurrentStack = "web" + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "sdm_abc") || strings.Contains(string(data), "hub.example") { + t.Errorf("env values persisted to disk: %s", data) + } + if !strings.Contains(string(data), "file_tok") { + t.Errorf("file token was clobbered: %s", data) + } +} + +// A login performed while STACKDOME_TOKEN is set must persist the credential +// it just obtained, not the stale one the file already held. +func TestLoginWhileEnvTokenSetPersistsNewToken(t *testing.T) { + path := writeConfig(t, `{"server_url":"https://file","access_token":"old_tok"}`) + t.Setenv("STACKDOME_CONFIG", path) + t.Setenv("STACKDOME_TOKEN", "sdm_abc") + t.Setenv("STACKDOME_URL", "https://hub.example") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + cfg.ServerURL = "https://new" + cfg.AccessToken = "new_tok" + cfg.RefreshToken = "new_refresh" + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"new_tok", "new_refresh", "https://new"} { + if !strings.Contains(string(data), want) { + t.Errorf("expected %q in saved config: %s", want, data) + } + } + if strings.Contains(string(data), "old_tok") { + t.Errorf("stale token persisted over the new one: %s", data) + } + + // `STACKDOME_URL=X stackdome login --url X --token $STACKDOME_TOKEN` logs in + // with exactly the env values. Save's latch would read that as "still the + // ephemeral env value" and write an empty config, so login clears the latch. + t.Run("values identical to the environment", func(t *testing.T) { + path := writeConfig(t, `{}`) + t.Setenv("STACKDOME_CONFIG", path) + t.Setenv("STACKDOME_TOKEN", "sdm_abc") + t.Setenv("STACKDOME_URL", "https://hub.example") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + cfg.ServerURL = "https://hub.example" + cfg.AccessToken = "sdm_abc" + cfg.AdoptEnvValues() + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"sdm_abc", "https://hub.example"} { + if !strings.Contains(string(data), want) { + t.Errorf("expected %q in saved config: %s", want, data) + } + } + }) +} + +// An env-token session must not create or depend on a config file. +func TestSetCurrentStackWithEnvTokenDoesNotWriteFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.json") + t.Setenv("STACKDOME_CONFIG", path) + t.Setenv("STACKDOME_TOKEN", "sdm_abc") + t.Setenv("STACKDOME_URL", "https://hub.example") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if err := cfg.SetCurrentStack("web"); err != nil { + t.Fatalf("SetCurrentStack: %v", err) + } + if cfg.CurrentStack != "web" { + t.Errorf("CurrentStack = %q, want web (in memory)", cfg.CurrentStack) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("config file was created at %s", path) + } +} + +func TestLoadFrom_AdoptsLegacyTeamName(t *testing.T) { + cfg, err := LoadFrom(writeConfig(t, `{"server_url":"https://x","team_name":"acme"}`)) + if err != nil { + t.Fatal(err) + } + if cfg.ProjectName != "acme" { + t.Errorf("expected legacy team_name to become project_name, got %q", cfg.ProjectName) + } +} + +func TestLoadFrom_PrefersProjectName(t *testing.T) { + cfg, err := LoadFrom(writeConfig(t, `{"server_url":"https://x","team_name":"old","project_name":"new"}`)) + if err != nil { + t.Fatal(err) + } + if cfg.ProjectName != "new" { + t.Errorf("expected project_name to win, got %q", cfg.ProjectName) + } +} + +// STACKDOME_ORG / STACKDOME_PROJECT let a scoped API token supply the scope the +// CLI would otherwise have to discover through the API. +func TestOrgAndProjectFromEnv(t *testing.T) { + t.Setenv("STACKDOME_CONFIG", filepath.Join(t.TempDir(), "missing.json")) + t.Setenv("STACKDOME_TOKEN", "sdm_abc") + t.Setenv("STACKDOME_URL", "https://hub.example") + t.Setenv("STACKDOME_ORG", "org-123") + t.Setenv("STACKDOME_PROJECT", "default") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.OrganizationID != "org-123" { + t.Errorf("OrganizationID = %q, want org-123", cfg.OrganizationID) + } + if cfg.ProjectName != "default" { + t.Errorf("ProjectName = %q, want default", cfg.ProjectName) + } +} + +func TestOrgAndProjectFromEnvNotPersisted(t *testing.T) { + path := writeConfig(t, `{"server_url":"https://file","access_token":"file_tok","organization_id":"file-org","project_name":"file-proj"}`) + t.Setenv("STACKDOME_CONFIG", path) + t.Setenv("STACKDOME_ORG", "env-org") + t.Setenv("STACKDOME_PROJECT", "env-proj") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + cfg.CurrentStack = "web" + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "env-org") || strings.Contains(string(data), "env-proj") { + t.Errorf("env scope persisted to disk: %s", data) + } + if !strings.Contains(string(data), "file-org") || !strings.Contains(string(data), "file-proj") { + t.Errorf("file scope was clobbered: %s", data) + } +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 6609b5d..0eb2470 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -14,6 +14,14 @@ const ( ExitUserCanceled = 130 ) +// ErrUserCanceled is returned when the user declines a confirmation prompt. +// ExitCodeFrom maps it to 130, the conventional "interrupted by user" code. +var ErrUserCanceled = &CLIError{ + Message: "Aborted.", + Code: "USER_CANCELED", + ExitCode: ExitUserCanceled, +} + type CLIError struct { Message string Detail string diff --git a/internal/output/color.go b/internal/output/color.go index 8f51867..b33ac6d 100644 --- a/internal/output/color.go +++ b/internal/output/color.go @@ -24,12 +24,14 @@ func SetNoColor(v bool) { noColor = v } -func isTTY() bool { +// IsTTY reports whether stdout is an interactive terminal. Guard anything that +// only makes sense on a terminal (colors, screen clears, spinners) with it. +func IsTTY() bool { return term.IsTerminal(int(os.Stdout.Fd())) } func colorize(color, text string) string { - if noColor || !isTTY() { + if noColor || !IsTTY() { return text } return color + text + reset @@ -52,11 +54,11 @@ func TabEscape(s string) string { func StateColor(state string) string { switch state { - case "Ready": + case "Ready", "Released", "Running": return Green(state) - case "Pending": + case "Pending", "InProgress", "Progressing": return Yellow(state) - case "Failed", "Error": + case "Failed", "Error", "Degraded", "Unavailable": return Red(state) case "Deleting": return Magenta(state) diff --git a/internal/output/formatter.go b/internal/output/formatter.go index 203f9eb..aa1d32d 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/lipgloss" lgTable "github.com/charmbracelet/lipgloss/table" + clierrors "github.com/stackdome/cli/internal/errors" "gopkg.in/yaml.v3" ) @@ -28,7 +29,7 @@ func ParseFormat(s string) (Format, error) { case "yaml": return FormatYAML, nil default: - return "", fmt.Errorf("unknown output format %q (valid: table, json, yaml)", s) + return "", clierrors.ValidationError(fmt.Sprintf("unknown output format %q (valid: table, json, yaml)", s)) } } @@ -50,11 +51,22 @@ func (f *Formatter) PrintJSON(v any) error { return enc.Encode(v) } +// PrintYAML routes through JSON first so the API types' `json:` tags decide the +// key names — yaml.Marshal alone would emit lowercased Go field names. func (f *Formatter) PrintYAML(v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + var generic any + if err := json.Unmarshal(b, &generic); err != nil { + return err + } + enc := yaml.NewEncoder(f.Writer) enc.SetIndent(2) defer enc.Close() - return enc.Encode(v) + return enc.Encode(generic) } func (f *Formatter) PrintStructured(v any) error { diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go new file mode 100644 index 0000000..2689161 --- /dev/null +++ b/internal/output/formatter_test.go @@ -0,0 +1,31 @@ +package output + +import ( + "bytes" + "strings" + "testing" +) + +// The API types carry only `json:` tags, so YAML must go through JSON to keep +// the wire names instead of emitting lowercased Go field names. +func TestPrintYAMLUsesJSONTags(t *testing.T) { + var buf bytes.Buffer + f := &Formatter{Format: FormatYAML, Writer: &buf} + + v := struct { + ServerURL string `json:"server_url"` + Hidden string `json:"-"` + }{ServerURL: "https://example.test", Hidden: "secret"} + + if err := f.PrintYAML(v); err != nil { + t.Fatalf("PrintYAML: %v", err) + } + + got := buf.String() + if !strings.Contains(got, "server_url: https://example.test") { + t.Errorf("want json tag name in output, got:\n%s", got) + } + if strings.Contains(got, "secret") { + t.Errorf("json:\"-\" field leaked into YAML:\n%s", got) + } +} diff --git a/internal/output/spinner.go b/internal/output/spinner.go index 760619b..44f4e2d 100644 --- a/internal/output/spinner.go +++ b/internal/output/spinner.go @@ -24,7 +24,7 @@ func NewSpinner(message string) *Spinner { } func (s *Spinner) Start() { - if !isTTY() { + if !IsTTY() { fmt.Fprintf(os.Stderr, "%s\n", s.message) return } diff --git a/internal/output/status.go b/internal/output/status.go index 5948e2e..2ebb209 100644 --- a/internal/output/status.go +++ b/internal/output/status.go @@ -6,26 +6,54 @@ import ( "strings" "time" - openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" ) -func RenderStackStatus(w io.Writer, stack *openapi.Stack, showConditions bool) { +// RenderStackStatus prints a stack and its resources. Runtime status now lives on +// the stack's release (live), not on the Stack/StackResource entities, so live may +// be nil when the stack has never been released. +func RenderStackStatus(w io.Writer, stack *openapi.Stack, live *openapi.ReleaseLiveStatus, showConditions bool) { + rel := StackRelease(stack) + state := "Unknown" - if stack.Status != nil && stack.Status.State != nil { - state = *stack.Status.State + if rel != nil && rel.State != nil { + state = string(*rel.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) + if rel != nil && rel.Message != nil && *rel.Message != "" { + fmt.Fprintf(w, " %s\n\n", *rel.Message) } - renderResourceTable(w, stack.Spec.StackResources) + renderResourceTable(w, stack.Spec.StackResources, live) - renderFailures(w, stack.Spec.StackResources, showConditions) + renderFailures(w, stack.Spec.StackResources, live, showConditions) +} + +// StackRelease returns the release that describes what the stack is actually +// serving: the converged one, falling back to the latest only when nothing has +// converged yet. Must match client.GetStackLiveStatus, which picks the release +// the resource rows come from — otherwise the header and the rows describe +// different releases. +func StackRelease(stack *openapi.Stack) *openapi.ReleaseSummary { + if stack.ConvergedRelease != nil { + return stack.ConvergedRelease + } + return stack.LatestRelease +} + +// ResourceStatus returns the live status of a named stack resource, if any. +func ResourceStatus(live *openapi.ReleaseLiveStatus, name string) *openapi.StackResourceStatus { + if live == nil || live.Resources == nil { + return nil + } + if st, ok := (*live.Resources)[name]; ok { + return &st + } + return nil } var ( @@ -33,14 +61,15 @@ var ( cellStyle = lipgloss.NewStyle().PaddingRight(2) ) -func renderResourceTable(w io.Writer, resources []openapi.StackResource) { +func renderResourceTable(w io.Writer, resources []openapi.StackResource, live *openapi.ReleaseLiveStatus) { var rows [][]string for _, res := range resources { + st := ResourceStatus(live, res.Name) rows = append(rows, []string{ res.Name, - StateColor(resourceState(&res)), + StateColor(resourceState(st)), formatPorts(res.Ports), - formatURL(&res), + formatURL(st), }) } @@ -59,41 +88,37 @@ func renderResourceTable(w io.Writer, resources []openapi.StackResource) { fmt.Fprintln(w, t) } -func renderFailures(w io.Writer, resources []openapi.StackResource, showConditions bool) { +func renderFailures(w io.Writer, resources []openapi.StackResource, live *openapi.ReleaseLiveStatus, 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 - } - } + st := ResourceStatus(live, res.Name) + if st == nil || st.LastFailure == nil { continue } if !hasFailures { fmt.Fprintf(w, "%s\n\n", Bold("FAILURES:")) hasFailures = true } - renderResourceFailure(w, &res) + renderResourceFailure(w, res.Name, st) } if showConditions { for _, res := range resources { - if res.Status != nil && len(res.Status.Conditions) > 0 { - renderConditions(w, &res) + if st := ResourceStatus(live, res.Name); st != nil && len(st.Conditions) > 0 { + renderConditions(w, res.Name, st) } } } } -func renderResourceFailure(w io.Writer, res *openapi.StackResource) { - failure := res.Status.LastFailure +func renderResourceFailure(w io.Writer, name string, status *openapi.StackResourceStatus) { + failure := status.LastFailure failureType := "" if failure.Type != nil { failureType = *failure.Type } - fmt.Fprintf(w, " %s — %s\n", Bold(res.Name), Red(failureType)) + fmt.Fprintf(w, " %s — %s\n", Bold(name), Red(failureType)) if failure.Container != nil { renderContainerFailure(w, "Container", failure.Container) @@ -105,8 +130,8 @@ func renderResourceFailure(w io.Writer, res *openapi.StackResource) { renderBuildFailure(w, failure.Build) } - if len(res.Status.Conditions) > 0 { - renderLastConditions(w, res.Status.Conditions, 3) + if len(status.Conditions) > 0 { + renderLastConditions(w, status.Conditions, 3) } fmt.Fprintln(w) @@ -191,11 +216,11 @@ func conditionRow(c openapi.Condition) []string { 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)) +func renderConditions(w io.Writer, name string, status *openapi.StackResourceStatus) { + fmt.Fprintf(w, "\n%s conditions:\n", Bold(name)) var rows [][]string - for _, c := range res.Status.Conditions { + for _, c := range status.Conditions { rows = append(rows, conditionRow(c)) } @@ -210,11 +235,11 @@ func renderConditions(w io.Writer, res *openapi.StackResource) { fmt.Fprintln(w, t) } -func resourceState(res *openapi.StackResource) string { - if res.Status == nil || res.Status.State == nil { +func resourceState(status *openapi.StackResourceStatus) string { + if status == nil || status.State == nil { return "Unknown" } - return *res.Status.State + return *status.State } func formatPorts(ports []openapi.Port) string { @@ -224,7 +249,7 @@ func formatPorts(ports []openapi.Port) string { parts := make([]string, 0, len(ports)) for _, p := range ports { proto := "HTTP" - if p.Protocol != nil { + if p.Protocol != nil && *p.Protocol != "" { proto = *p.Protocol } s := fmt.Sprintf("%d/%s", p.Number, proto) @@ -236,12 +261,12 @@ func formatPorts(ports []openapi.Port) string { return strings.Join(parts, ", ") } -func formatURL(res *openapi.StackResource) string { - if res.Status == nil || len(res.Status.PublicIngress) == 0 { +func formatURL(status *openapi.StackResourceStatus) string { + if status == nil || len(status.PublicIngress) == 0 { return "-" } urls := make([]string, 0) - for _, ing := range res.Status.PublicIngress { + for _, ing := range status.PublicIngress { if ing.Url != nil && *ing.Url != "" { urls = append(urls, *ing.Url) } diff --git a/internal/output/status_test.go b/internal/output/status_test.go new file mode 100644 index 0000000..075b1ca --- /dev/null +++ b/internal/output/status_test.go @@ -0,0 +1,21 @@ +package output + +import ( + "testing" + + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" +) + +func TestFormatPortsDefaultsProtocol(t *testing.T) { + empty := "" + tcp := "TCP" + got := formatPorts([]openapi.Port{ + {Number: 80, Protocol: &empty}, + {Number: 443}, + {Number: 5432, Protocol: &tcp}, + }) + want := "80/HTTP, 443/HTTP, 5432/TCP" + if got != want { + t.Errorf("formatPorts = %q, want %q", got, want) + } +} diff --git a/internal/stackfile/compose.go b/internal/stackfile/compose.go index 47eeff3..f3d20d9 100644 --- a/internal/stackfile/compose.go +++ b/internal/stackfile/compose.go @@ -44,19 +44,23 @@ func FindComposeFile(dir string) string { return "" } -func FromCompose(path, appName string) (*Stackfile, error) { +// FromCompose converts a docker-compose file into a stackfile. The second +// return value maps resource name -> the compose `env_file` it referenced: +// yaml.Marshal of a Stackfile cannot emit that key, so the caller reports them +// instead of dropping them silently. +func FromCompose(path, appName string) (*Stackfile, map[string]string, error) { data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("failed to read %s: %w", path, err) + return nil, 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) + return nil, 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) + return nil, nil, fmt.Errorf("no services found in %s", path) } sf := &Stackfile{ @@ -64,10 +68,13 @@ func FromCompose(path, appName string) (*Stackfile, error) { Resources: make(map[string]Resource), Volumes: make(map[string]VolumeDef), } + envFiles := make(map[string]string) for name, svc := range compose.Services { - res := convertService(svc) - sf.Resources[name] = res + sf.Resources[name] = convertService(svc) + if ref := parseEnvFileRef(svc.EnvFile); ref != "" { + envFiles[name] = ref + } } for volName := range compose.Volumes { @@ -76,7 +83,7 @@ func FromCompose(path, appName string) (*Stackfile, error) { collectNamedVolumes(sf) - return sf, nil + return sf, envFiles, nil } func convertService(svc composeService) Resource { @@ -87,14 +94,9 @@ func convertService(svc composeService) Resource { 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 } @@ -405,19 +407,6 @@ func parseDependsOn(raw any) []string { 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 { diff --git a/internal/stackfile/connection_builders_test.go b/internal/stackfile/connection_builders_test.go deleted file mode 100644 index bd66004..0000000 --- a/internal/stackfile/connection_builders_test.go +++ /dev/null @@ -1,571 +0,0 @@ -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 deleted file mode 100644 index ef16549..0000000 --- a/internal/stackfile/convert.go +++ /dev/null @@ -1,518 +0,0 @@ -package stackfile - -import ( - "regexp" - "strings" - - openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" - "github.com/samber/lo" - "k8s.io/utils/ptr" -) - -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{ - 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, res.Command, res.Args) - 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, 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} - switch { - case isSelfRef(value): - output := extractSelfOutput(value) - ev.SelfOutput = ptr.To(output) - case hasResourceRef(value): - continue - default: - ev.Value = ptr.To(value) - } - envVars = append(envVars, ev) - } - - if len(envVars) > 0 { - cfg.EnvironmentVariables = envVars - } - - return cfg -} - -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 - } - 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 { - refsInCurrentEnv := findRefs(value) - if len(refsInCurrentEnv) == 0 { - continue - } - - _, 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: 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) - } - - 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 secretName, 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", - Name: ptr.To(secretName), - }, - 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 addonName, addon := range addons { - mappings := buildAddonMappings(addon.Env) - - conn := openapi.StackConnection{ - Kind: "env", - From: openapi.TopologyNodeRef{ - Type: "addon/" + addon.Type, - Name: ptr.To(addonName), - }, - To: openapi.TopologyNodeRef{ - Type: "stack_resource", - Name: ptr.To(targetResource), - }, - Mappings: mappings, - Config: buildAddonConfig(addon), - } - - connections = append(connections, conn) - } - return connections -} - -type addonRef struct { - Output string - RawMatch string -} - -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 - } - 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, - } -} - -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 deleted file mode 100644 index 2ade01c..0000000 --- a/internal/stackfile/convert_test.go +++ /dev/null @@ -1,861 +0,0 @@ -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", - Ports: []PortDef{ - {Name: "http", Port: 8080, Public: true, Subdomain: "app"}, - }, - 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_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 }}", - "LITERAL_VALUE": "hello", - }, - }, - "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_HOST" { - t.Error("DB_HOST should not be in execution config") - } - } - } - } - - 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) != 1 { - t.Errorf("expected 1 mapping, got %d", len(conn.Mappings)) - } - 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)") - } - } - } - } - - 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 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) - } - } - } - 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_URL": "redis://{{ redis.host }}:6379", - }, - }, - "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.Name == "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: 8080, Protocol: "HTTP", Public: true, Subdomain: "infisical"}, - }, - Env: map[string]string{ - "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_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"}}, - Env: map[string]string{"ALLOW_EMPTY_PASSWORD": "yes"}, - Volumes: []VolumeMountDef{{Name: "redis-data", Path: "/data"}}, - }, - }, - 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)) - } - - 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++ - } - } - 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) - } - - 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 != "6c1fe4e407b8911c104518103505b218" { - t.Error("expected ENCRYPTION_KEY as literal") - } - 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)") - } - } - } -} - -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.Name { - 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 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) - } - } -} - -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.Name == "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 deleted file mode 100644 index 87fbd5b..0000000 --- a/internal/stackfile/fixture_test.go +++ /dev/null @@ -1,230 +0,0 @@ -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 != "6c1fe4e407b8911c104518103505b218" { - t.Error("ENCRYPTION_KEY should be literal") - } - if _, ok := envMap["DB_CONNECTION_URI"]; ok { - t.Error("DB_CONNECTION_URI 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.Name == "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 index aaaf76e..9aed3a5 100644 --- a/internal/stackfile/json.go +++ b/internal/stackfile/json.go @@ -4,7 +4,7 @@ import ( "encoding/json" "os" - openapi "github.com/ashishmax31/stackdome-api-server/pkg/api/openapi" + openapi "github.com/Stackdome/stackdome/pkg/api/openapi" clierrors "github.com/stackdome/cli/internal/errors" ) diff --git a/internal/stackfile/load.go b/internal/stackfile/load.go new file mode 100644 index 0000000..7167b71 --- /dev/null +++ b/internal/stackfile/load.go @@ -0,0 +1,108 @@ +// Package stackfile is the CLI's thin layer over the hub's canonical stackfile +// package: file loading with CLI-shaped errors, the `env_file` convenience the +// hub schema does not carry, the docker-compose converter, and raw stack JSON. +// Schema, validation and conversion live in the hub package — never fork them. +package stackfile + +import ( + "os" + "path/filepath" + "strings" + + hub "github.com/Stackdome/stackdome/pkg/stackfile" + "github.com/joho/godotenv" + clierrors "github.com/stackdome/cli/internal/errors" + "gopkg.in/yaml.v3" +) + +type ( + Stackfile = hub.Stackfile + Resource = hub.Resource + BuildConfig = hub.BuildConfig + PortDef = hub.PortDef + VolumeDef = hub.VolumeDef + VolumeMountDef = hub.VolumeMountDef + SecretMapping = hub.SecretMapping + AddonConnectionConfig = hub.AddonConnectionConfig + Resolver = hub.Resolver +) + +var ( + Validate = hub.Validate + ResolveStack = hub.ResolveStack +) + +// Load reads, validates and returns the stackfile at path. Any `env_file` +// references are resolved relative to the stackfile's directory. +func Load(path string) (*Stackfile, error) { + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".yaml" && ext != ".yml" { + return nil, clierrors.ValidationError("Unsupported file format: " + ext + " (expected .yaml or .yml)") + } + + 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) + } + + sf, err := hub.Load(data) + if err != nil { + return nil, clierrors.ValidationError(err.Error()) + } + + if err := applyEnvFiles(sf, data, filepath.Dir(path)); err != nil { + return nil, err + } + + return sf, nil +} + +// applyEnvFiles merges each resource's `env_file` into its env map. The key is +// a CLI-only extension, so it is read off the raw YAML rather than the hub's +// Resource type. Values already set in `env:` win. +func applyEnvFiles(sf *Stackfile, data []byte, baseDir string) error { + var raw struct { + Resources map[string]struct { + EnvFile string `yaml:"env_file"` + } `yaml:"resources"` + } + if err := yaml.Unmarshal(data, &raw); err != nil { + return clierrors.Wrap(err, "Failed to parse stackfile") + } + + for name, r := range raw.Resources { + if r.EnvFile == "" { + continue + } + envPath := r.EnvFile + if !filepath.IsAbs(envPath) { + envPath = filepath.Join(baseDir, envPath) + } + fileEnv, err := godotenv.Read(envPath) + if err != nil { + return clierrors.Wrapf(err, "Failed to read env_file for resource %q", name) + } + + res, ok := sf.Resources[name] + if !ok { + continue + } + if res.Env == nil { + res.Env = make(map[string]string, len(fileEnv)) + } + for k, v := range fileEnv { + if v == "" { + continue + } + if _, exists := res.Env[k]; !exists { + res.Env[k] = v + } + } + sf.Resources[name] = res + } + + return nil +} diff --git a/internal/stackfile/load_test.go b/internal/stackfile/load_test.go new file mode 100644 index 0000000..38b450e --- /dev/null +++ b/internal/stackfile/load_test.go @@ -0,0 +1,142 @@ +package stackfile_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Stackdome/stackdome/pkg/models" + "github.com/stackdome/cli/internal/stackfile" + "gopkg.in/yaml.v3" +) + +const singlePortSelfRef = `name: demo +resources: + web: + image: nginx:latest + ports: + - name: http + port: 3000 + public: true + env: + PUBLIC_URL: "{{ self.public_url }}" +` + +func write(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// A single-port resource drops the port suffix: the self ref is +// `{{ self.public_url }}` and the emitted self_output must be one of the +// declared outputs the server validator builds from the same resource. +func TestSelfPublicURLSinglePort(t *testing.T) { + sf, err := stackfile.Load(write(t, "stackfile.yaml", singlePortSelfRef)) + if err != nil { + t.Fatalf("load: %v", err) + } + + stack, err := sf.ToStack() + if err != nil { + t.Fatalf("to stack: %v", err) + } + + res := stack.Spec.StackResources[0] + env := res.ExecutionConfig.EnvironmentVariables[0] + if env.Name != "PUBLIC_URL" || env.SelfOutput == nil { + t.Fatalf("expected PUBLIC_URL with a self_output, got %+v", env) + } + if *env.SelfOutput != models.OutputNamePublicURL { + t.Fatalf("self_output = %q, want %q", *env.SelfOutput, models.OutputNamePublicURL) + } + + // Same check the server's stackresource validator runs: self_output must + // appear in the resource's declared outputs. + declared := models.StackResourceOutputDescriptors(&models.StackResource{ + Name: res.Name, + Ports: models.Ports{{Name: "http", Number: 3000, ExposedToPublic: true}}, + }) + for _, d := range declared { + if d.Name == *env.SelfOutput { + return + } + } + t.Fatalf("self_output %q is not a declared output: %+v", *env.SelfOutput, declared) +} + +// The old CLI spelling — it 400'd on deploy because the server never declares +// a `public.http.url` output for a single-port resource. +func TestSelfPublicURLOldSpellingRejected(t *testing.T) { + bad := strings.Replace(singlePortSelfRef, "self.public_url", "self.public.http.url", 1) + _, err := stackfile.Load(write(t, "stackfile.yaml", bad)) + if err == nil { + t.Fatal("expected validation to reject 'self.public.http.url'") + } + if !strings.Contains(err.Error(), "public_url") { + t.Fatalf("error should point at the valid outputs, got: %v", err) + } +} + +func TestLoadMergesEnvFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "web.env"), []byte("FROM_FILE=yes\nPUBLIC_URL=ignored\n"), 0o644); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "stackfile.yaml") + if err := os.WriteFile(path, []byte(singlePortSelfRef+" env_file: web.env\n"), 0o644); err != nil { + t.Fatal(err) + } + + sf, err := stackfile.Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + env := sf.Resources["web"].Env + if env["FROM_FILE"] != "yes" { + t.Fatalf("env_file value not merged: %v", env) + } + if env["PUBLIC_URL"] != "{{ self.public_url }}" { + t.Fatalf("env_file must not override env: %v", env) + } +} + +// The compose converter must emit YAML the hub package accepts. +func TestFromComposeOutputValidates(t *testing.T) { + compose := write(t, "docker-compose.yaml", `services: + web: + image: nginx:latest + ports: + - "8080:3000" + environment: + APP_ENV: production + db: + image: postgres:16 + ports: + - "5432:5432" + volumes: + - db-data:/var/lib/postgresql/data +volumes: + db-data: +`) + + sf, envFiles, err := stackfile.FromCompose(compose, "demo") + if err != nil { + t.Fatalf("from compose: %v", err) + } + if len(envFiles) != 0 { + t.Fatalf("unexpected env files: %v", envFiles) + } + + out, err := yaml.Marshal(sf) + if err != nil { + t.Fatal(err) + } + if _, err := stackfile.Load(write(t, "stackfile.yaml", string(out))); err != nil { + t.Fatalf("generated stackfile does not validate: %v\n%s", err, out) + } +} diff --git a/internal/stackfile/parse.go b/internal/stackfile/parse.go deleted file mode 100644 index 74e2c27..0000000 --- a/internal/stackfile/parse.go +++ /dev/null @@ -1,310 +0,0 @@ -package stackfile - -import ( - "os" - "path/filepath" - "strings" - - "github.com/joho/godotenv" - 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 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 { - 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 + "'") - } - } - - 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 deleted file mode 100644 index 36b8137..0000000 --- a/internal/stackfile/resolve.go +++ /dev/null @@ -1,54 +0,0 @@ -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/build_from_source.yaml b/internal/stackfile/testdata/build_from_source.yaml deleted file mode 100644 index 5560361..0000000 --- a/internal/stackfile/testdata/build_from_source.yaml +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index f1f7adb..0000000 --- a/internal/stackfile/testdata/infisical.yaml +++ /dev/null @@ -1,57 +0,0 @@ -name: infisical - -resources: - infisical: - image: infisical/infisical:latest - ports: - - name: http - 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 }}" - 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 - - db: - image: postgres:14-alpine - ports: - - name: postgres - port: 5432 - protocol: TCP - env: - POSTGRES_USER: infisical - POSTGRES_PASSWORD: infisical - 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 - env: - ALLOW_EMPTY_PASSWORD: "yes" - volumes: - - name: redis-data - path: /data - -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 deleted file mode 100644 index 7e8d9da..0000000 --- a/internal/stackfile/testdata/kitchen_sink.yaml +++ /dev/null @@ -1,61 +0,0 @@ -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_URL: "redis://{{ redis.host }}:6379" - 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_URL: "redis://{{ redis.host }}:6379" - 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/simple_nginx.yaml b/internal/stackfile/testdata/simple_nginx.yaml deleted file mode 100644 index 9a1f35d..0000000 --- a/internal/stackfile/testdata/simple_nginx.yaml +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 8dfdeec..0000000 --- a/internal/stackfile/testdata/with_addon.yaml +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 1ef4202..0000000 --- a/internal/stackfile/testdata/with_addon_superuser.yaml +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index e7db917..0000000 --- a/internal/stackfile/testdata/with_secrets.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index e4d924a..0000000 --- a/internal/stackfile/types.go +++ /dev/null @@ -1,103 +0,0 @@ -package stackfile - -import "gopkg.in/yaml.v3" - -const ( - PostgresAddonType = "postgres" -) - -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"` - 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 - 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,omitempty"` - 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 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:"-"` -} - -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 PostgresAddonType: - var pg PostgresAddonConfig - if err := node.Decode(&pg); err != nil { - return err - } - a.Postgres = &pg - } - - return nil -}