From 7daec8952ec9c4d83a16130e78cb729d1eb5227e Mon Sep 17 00:00:00 2001 From: David Orozco Date: Mon, 3 Mar 2025 08:15:27 -0500 Subject: [PATCH 1/2] add make-local subcommand --- internal/command/sandbox/command.go | 1 + internal/command/sandbox/make-local.go | 152 +++++++++++++++++++++++++ internal/config/sandbox.go | 22 ++++ internal/utils/randomString.go | 18 +++ 4 files changed, 193 insertions(+) create mode 100644 internal/command/sandbox/make-local.go create mode 100644 internal/utils/randomString.go diff --git a/internal/command/sandbox/command.go b/internal/command/sandbox/command.go index 4f95be7a..57323674 100644 --- a/internal/command/sandbox/command.go +++ b/internal/command/sandbox/command.go @@ -20,6 +20,7 @@ func New(api *config.API) *cobra.Command { newList(cfg), newApply(cfg), newDelete(cfg), + newMakeLocal(cfg), ) return cmd diff --git a/internal/command/sandbox/make-local.go b/internal/command/sandbox/make-local.go new file mode 100644 index 00000000..a2e1c4b8 --- /dev/null +++ b/internal/command/sandbox/make-local.go @@ -0,0 +1,152 @@ +package sandbox + +import ( + "fmt" + "github.com/signadot/cli/internal/utils" + "github.com/signadot/cli/internal/utils/system" + "github.com/signadot/go-sdk/models" + "github.com/signadot/libconnect/common/processes" + "io" + "path/filepath" + "strings" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + "github.com/signadot/go-sdk/client/sandboxes" + "github.com/spf13/cobra" +) + +func newMakeLocal(sandbox *config.Sandbox) *cobra.Command { + cfg := &config.SandboxMakeLocal{Sandbox: sandbox} + + cmd := &cobra.Command{ + Use: "make-local", + Short: "Make a local sandbox", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + return makeLocal(cfg, cmd.OutOrStdout()) + }, + } + cfg.AddFlags(cmd) + + return cmd +} + +func makeLocal(cfg *config.SandboxMakeLocal, out io.Writer) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + + if err := checkLocalConnected(); err != nil { + return err + } + + // TODO: Prompt interactive selection for workload + + sandboxName := getSandboxName(cfg) + + workloadKind := "Deployment" + namespace, name, err := getWorkloadParts(cfg.Workload) + if err != nil { + return err + } + + local := models.Local{ + From: &models.LocalFrom{ + Kind: &workloadKind, + Name: &name, + Namespace: &namespace, + }, + Mappings: nil, + Name: fmt.Sprintf("local-%s-%s", namespace, name), + } + + sandbox := models.Sandbox{ + CreatedAt: "", + Defaults: nil, + Endpoints: nil, + Name: sandboxName, + RoutingKey: "", + Spec: &models.SandboxSpec{ + Cluster: &cfg.Cluster, + DefaultRouteGroup: nil, + Description: "Generated local sandbox with signadot sandbox make-local", + DisableSandboxTrafficManager: false, + Endpoints: nil, + Forks: nil, + Labels: nil, + Local: []*models.Local{&local}, + LocalMachineID: "", + Resources: nil, + TTL: nil, + }, + Status: nil, + UpdatedAt: "", + } + + params := sandboxes.NewApplySandboxParams().WithOrgName(cfg.Org). + WithSandboxName(sandboxName). + WithData(&sandbox) + resp, err := cfg.Client.Sandboxes.ApplySandbox(params, nil) + if err != nil { + return err + } + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printSandboxDetails(cfg.Sandbox, out, resp.Payload) + case config.OutputFormatJSON: + return print.RawJSON(out, resp.Payload) + case config.OutputFormatYAML: + return print.RawYAML(out, resp.Payload) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} + +func checkLocalConnected() error { + signadotDir, err := system.GetSignadotDir() + if err != nil { + return err + } + + // Make sure the sandbox manager is running + pidfile := filepath.Join(signadotDir, config.SandboxManagerPIDFile) + isRunning, err := processes.IsDaemonRunning(pidfile) + if err != nil { + return err + } + if !isRunning { + return fmt.Errorf("signadot is not connected\n") + } + + return nil +} + +func getSandboxName(cfg *config.SandboxMakeLocal) string { + if cfg.Name != "" { + return cfg.Name + } + + return fmt.Sprintf("autogenerated-sandbox-%s", utils.RandomString(6)) +} + +func getWorkloadParts(rawWorkload string) (string, string, error) { + if rawWorkload == "" { + return "", "", fmt.Errorf("no workload defined\n") + } + + parts := strings.Split(rawWorkload, "/") + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid workload format (expected namespace/name)") + } + + namespace := parts[0] + workloadName := parts[1] + + if namespace == "" || workloadName == "" { + return "", "", fmt.Errorf("namespace or workload name cannot be empty") + } + + return namespace, workloadName, nil +} diff --git a/internal/config/sandbox.go b/internal/config/sandbox.go index 6e9b08c7..dbf0f198 100644 --- a/internal/config/sandbox.go +++ b/internal/config/sandbox.go @@ -54,3 +54,25 @@ type SandboxGet struct { type SandboxList struct { *Sandbox } + +type SandboxMakeLocal struct { + *Sandbox + + // Flags + Name string + Cluster string + Workload string + Unprivileged bool + PortMappings []string +} + +func (c *SandboxMakeLocal) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringVarP(&c.Workload, "workload", "w", "", "workload to be forked") + cmd.Flags().StringVar(&c.Cluster, "cluster", "", "cluster associated with the sandbox") + cmd.Flags().StringArrayVar(&c.PortMappings, "port-mapping", []string{}, "workload to be forked") + cmd.Flags().StringVar(&c.Name, "name", "", "name of the generated sandbox") + + cmd.Flags().BoolVar(&c.Unprivileged, "unprivileged", false, "Provide mappings from workload ports to tcp addresses to which the local workstation can listen") + + cmd.MarkFlagRequired("cluster") +} diff --git a/internal/utils/randomString.go b/internal/utils/randomString.go new file mode 100644 index 00000000..09a0ed0f --- /dev/null +++ b/internal/utils/randomString.go @@ -0,0 +1,18 @@ +package utils + +import ( + "math/rand" + "time" +) + +func RandomString(n int) string { + rand.Seed(time.Now().UnixNano()) + letters := []rune("abcdefghijklmnopqrstuvwxyz0123456789") + b := make([]rune, n) + + for i := 0; i < n; i++ { + b[i] = letters[rand.Intn(len(letters))] + } + + return string(b) +} From ec7668e0d630539194d3fde1bf5eff5fcb4e2f17 Mon Sep 17 00:00:00 2001 From: David Orozco Date: Mon, 3 Mar 2025 09:28:11 -0500 Subject: [PATCH 2/2] add wait and fix local connect proper state --- internal/command/sandbox/apply.go | 9 +- internal/command/sandbox/make-local.go | 196 +++++++++++++++++-------- internal/command/sandbox/printers.go | 2 +- internal/config/sandbox.go | 8 +- 4 files changed, 150 insertions(+), 65 deletions(-) diff --git a/internal/command/sandbox/apply.go b/internal/command/sandbox/apply.go index cf299c34..e4ef3cc4 100644 --- a/internal/command/sandbox/apply.go +++ b/internal/command/sandbox/apply.go @@ -102,19 +102,20 @@ func apply(cfg *config.SandboxApply, out, log io.Writer, args []string) error { // store latest resp for output below resp, err = waitForReady(cfg, log, resp) if err != nil { - writeOutput(cfg, out, resp) + writeOutput(cfg.Sandbox, out, resp) fmt.Fprintf(log, "\nThe sandbox was applied, but it may not be ready yet. To check status, run:\n\n") fmt.Fprintf(log, " signadot sandbox get %v\n\n", req.Name) return err } - writeOutput(cfg, out, resp) + writeOutput(cfg.Sandbox, out, resp) fmt.Fprintf(log, "\nThe sandbox %q was applied and is ready.\n", resp.Name) return nil } - return writeOutput(cfg, out, resp) + + return writeOutput(cfg.Sandbox, out, resp) } -func writeOutput(cfg *config.SandboxApply, out io.Writer, resp *models.Sandbox) error { +func writeOutput(cfg *config.Sandbox, out io.Writer, resp *models.Sandbox) error { switch cfg.OutputFormat { case config.OutputFormatDefault: // Print info on how to access the sandbox. diff --git a/internal/command/sandbox/make-local.go b/internal/command/sandbox/make-local.go index a2e1c4b8..d5bb6c69 100644 --- a/internal/command/sandbox/make-local.go +++ b/internal/command/sandbox/make-local.go @@ -2,17 +2,20 @@ package sandbox import ( "fmt" - "github.com/signadot/cli/internal/utils" - "github.com/signadot/cli/internal/utils/system" - "github.com/signadot/go-sdk/models" - "github.com/signadot/libconnect/common/processes" "io" "path/filepath" + "strconv" "strings" "github.com/signadot/cli/internal/config" + sbmapi "github.com/signadot/cli/internal/locald/api/sandboxmanager" + sbmgr "github.com/signadot/cli/internal/locald/sandboxmanager" "github.com/signadot/cli/internal/print" + "github.com/signadot/cli/internal/utils" + "github.com/signadot/cli/internal/utils/system" "github.com/signadot/go-sdk/client/sandboxes" + "github.com/signadot/go-sdk/models" + "github.com/signadot/libconnect/common/processes" "github.com/spf13/cobra" ) @@ -22,17 +25,21 @@ func newMakeLocal(sandbox *config.Sandbox) *cobra.Command { cmd := &cobra.Command{ Use: "make-local", Short: "Make a local sandbox", - Args: cobra.ExactArgs(0), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return makeLocal(cfg, cmd.OutOrStdout()) + var name string + if len(args) == 1 { + name = args[0] + } + return makeLocal(cfg, cmd.OutOrStdout(), name) }, } - cfg.AddFlags(cmd) + cfg.AddFlags(cmd) return cmd } -func makeLocal(cfg *config.SandboxMakeLocal, out io.Writer) error { +func makeLocal(cfg *config.SandboxMakeLocal, out io.Writer, name string) error { if err := cfg.InitAPIConfig(); err != nil { return err } @@ -41,57 +48,54 @@ func makeLocal(cfg *config.SandboxMakeLocal, out io.Writer) error { return err } - // TODO: Prompt interactive selection for workload + sandboxName := resolveSandboxName(name) - sandboxName := getSandboxName(cfg) + namespace, workloadName, err := parseWorkload(cfg.Workload) + if err != nil { + return err + } - workloadKind := "Deployment" - namespace, name, err := getWorkloadParts(cfg.Workload) + localMappings, err := parseLocalPortMappings(cfg.PortMappings) if err != nil { return err } - local := models.Local{ - From: &models.LocalFrom{ - Kind: &workloadKind, - Name: &name, - Namespace: &namespace, - }, - Mappings: nil, - Name: fmt.Sprintf("local-%s-%s", namespace, name), + if err := verifySandboxManager(cfg.Cluster); err != nil { + return err } - sandbox := models.Sandbox{ - CreatedAt: "", - Defaults: nil, - Endpoints: nil, - Name: sandboxName, - RoutingKey: "", - Spec: &models.SandboxSpec{ - Cluster: &cfg.Cluster, - DefaultRouteGroup: nil, - Description: "Generated local sandbox with signadot sandbox make-local", - DisableSandboxTrafficManager: false, - Endpoints: nil, - Forks: nil, - Labels: nil, - Local: []*models.Local{&local}, - LocalMachineID: "", - Resources: nil, - TTL: nil, - }, - Status: nil, - UpdatedAt: "", + sbx, err := buildLocalSandbox(cfg, sandboxName, namespace, workloadName, localMappings) + if err != nil { + return err } - params := sandboxes.NewApplySandboxParams().WithOrgName(cfg.Org). + params := sandboxes.NewApplySandboxParams(). + WithOrgName(cfg.Org). WithSandboxName(sandboxName). - WithData(&sandbox) + WithData(sbx) + resp, err := cfg.Client.Sandboxes.ApplySandbox(params, nil) if err != nil { return err } + if cfg.Wait { + sbxApply := &config.SandboxApply{ + Wait: cfg.Wait, + WaitTimeout: cfg.WaitTimeout, + Sandbox: cfg.Sandbox, + } + + waited, waitErr := waitForReady(sbxApply, out, resp.Payload) + if waitErr != nil { + // The sandbox was applied but may not be fully ready + writeOutput(cfg.Sandbox, out, waited) + fmt.Fprintf(out, "\nThe sandbox was applied, but it may not be ready yet. "+ + "To check status, run:\n\n signadot sandbox get %v\n\n", sandboxName) + return waitErr + } + } + switch cfg.OutputFormat { case config.OutputFormatDefault: return printSandboxDetails(cfg.Sandbox, out, resp.Payload) @@ -109,9 +113,8 @@ func checkLocalConnected() error { if err != nil { return err } - - // Make sure the sandbox manager is running pidfile := filepath.Join(signadotDir, config.SandboxManagerPIDFile) + isRunning, err := processes.IsDaemonRunning(pidfile) if err != nil { return err @@ -119,34 +122,111 @@ func checkLocalConnected() error { if !isRunning { return fmt.Errorf("signadot is not connected\n") } - return nil } -func getSandboxName(cfg *config.SandboxMakeLocal) string { - if cfg.Name != "" { - return cfg.Name +func resolveSandboxName(name string) string { + if len(name) > 0 { + return name } - return fmt.Sprintf("autogenerated-sandbox-%s", utils.RandomString(6)) } -func getWorkloadParts(rawWorkload string) (string, string, error) { - if rawWorkload == "" { +func parseWorkload(raw string) (string, string, error) { + if raw == "" { return "", "", fmt.Errorf("no workload defined\n") } - parts := strings.Split(rawWorkload, "/") + parts := strings.Split(raw, "/") if len(parts) != 2 { return "", "", fmt.Errorf("invalid workload format (expected namespace/name)") } - namespace := parts[0] - workloadName := parts[1] - - if namespace == "" || workloadName == "" { + ns := parts[0] + wl := parts[1] + if ns == "" || wl == "" { return "", "", fmt.Errorf("namespace or workload name cannot be empty") } - return namespace, workloadName, nil + return ns, wl, nil +} + +func parseLocalPortMappings(portMappings []string) ([]*models.LocalPortMapping, error) { + var mappings []*models.LocalPortMapping + for _, m := range portMappings { + parts := strings.SplitN(m, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid port mapping %q", m) + } + + containerPort, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid container port %q: %v", parts[0], err) + } + + mappings = append(mappings, &models.LocalPortMapping{ + Port: containerPort, + ToLocal: parts[1], + }) + } + return mappings, nil +} + +// verifySandboxManager checks that sandbox-manager is ready and that the connected cluster matches the config. +func verifySandboxManager(expectedCluster string) error { + status, err := sbmgr.GetStatus() + if err != nil { + return err + } + + ciConfig, err := sbmapi.ToCIConfig(status.CiConfig) + if err != nil { + return fmt.Errorf("couldn't unmarshal ci-config from sandboxmanager status, %v", err) + } + + connectErrs := sbmgr.CheckStatusConnectErrors(status, ciConfig) + if len(connectErrs) != 0 { + return fmt.Errorf("sandboxmanager is still starting") + } + + if expectedCluster != ciConfig.ConnectionConfig.Cluster { + return fmt.Errorf("sandbox spec cluster %q does not match connected cluster (%q)", + expectedCluster, ciConfig.ConnectionConfig.Cluster) + } + return nil +} + +// buildLocalSandbox constructs a Sandbox object for "local" usage. +func buildLocalSandbox( + cfg *config.SandboxMakeLocal, + name, namespace, workloadName string, + localMappings []*models.LocalPortMapping, +) (*models.Sandbox, error) { + localMachineID, err := system.GetMachineID() + if err != nil { + return nil, err + } + + workloadKind := "Deployment" + localSpec := &models.Local{ + From: &models.LocalFrom{ + Kind: &workloadKind, + Name: &workloadName, + Namespace: &namespace, + }, + Mappings: localMappings, + Name: fmt.Sprintf("local-%s-%s", namespace, workloadName), + } + + sbx := &models.Sandbox{ + Name: name, + Spec: &models.SandboxSpec{ + Cluster: &cfg.Cluster, + Description: "Generated local sandbox with signadot sandbox make-local", + Local: []*models.Local{localSpec}, + LocalMachineID: localMachineID, + }, + } + + return sbx, nil } diff --git a/internal/command/sandbox/printers.go b/internal/command/sandbox/printers.go index aa1e956d..13583108 100644 --- a/internal/command/sandbox/printers.go +++ b/internal/command/sandbox/printers.go @@ -47,8 +47,8 @@ func printSandboxTable(out io.Writer, sbs []*models.Sandbox) error { func printSandboxDetails(cfg *config.Sandbox, out io.Writer, sb *models.Sandbox) error { tw := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0) - fmt.Fprintf(tw, "ID:\t%s\n", sb.RoutingKey) fmt.Fprintf(tw, "Name:\t%s\n", sb.Name) + fmt.Fprintf(tw, "Routing Key:\t%s\n", sb.RoutingKey) fmt.Fprintf(tw, "Description:\t%s\n", sb.Spec.Description) fmt.Fprintf(tw, "Cluster:\t%s\n", *sb.Spec.Cluster) fmt.Fprintf(tw, "Created:\t%s\n", utils.FormatTimestamp(sb.CreatedAt)) diff --git a/internal/config/sandbox.go b/internal/config/sandbox.go index dbf0f198..76bc75e0 100644 --- a/internal/config/sandbox.go +++ b/internal/config/sandbox.go @@ -59,20 +59,24 @@ type SandboxMakeLocal struct { *Sandbox // Flags - Name string Cluster string Workload string Unprivileged bool PortMappings []string + + Wait bool + WaitTimeout time.Duration } func (c *SandboxMakeLocal) AddFlags(cmd *cobra.Command) { cmd.Flags().StringVarP(&c.Workload, "workload", "w", "", "workload to be forked") cmd.Flags().StringVar(&c.Cluster, "cluster", "", "cluster associated with the sandbox") cmd.Flags().StringArrayVar(&c.PortMappings, "port-mapping", []string{}, "workload to be forked") - cmd.Flags().StringVar(&c.Name, "name", "", "name of the generated sandbox") cmd.Flags().BoolVar(&c.Unprivileged, "unprivileged", false, "Provide mappings from workload ports to tcp addresses to which the local workstation can listen") + cmd.Flags().BoolVar(&c.Wait, "wait", true, "wait for the sandbox status to be Ready before returning") + cmd.Flags().DurationVar(&c.WaitTimeout, "wait-timeout", 3*time.Minute, "timeout when waiting for the sandbox to be Ready") + cmd.MarkFlagRequired("cluster") }