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/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..d5bb6c69 --- /dev/null +++ b/internal/command/sandbox/make-local.go @@ -0,0 +1,232 @@ +package sandbox + +import ( + "fmt" + "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" +) + +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.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var name string + if len(args) == 1 { + name = args[0] + } + return makeLocal(cfg, cmd.OutOrStdout(), name) + }, + } + + cfg.AddFlags(cmd) + return cmd +} + +func makeLocal(cfg *config.SandboxMakeLocal, out io.Writer, name string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + + if err := checkLocalConnected(); err != nil { + return err + } + + sandboxName := resolveSandboxName(name) + + namespace, workloadName, err := parseWorkload(cfg.Workload) + if err != nil { + return err + } + + localMappings, err := parseLocalPortMappings(cfg.PortMappings) + if err != nil { + return err + } + + if err := verifySandboxManager(cfg.Cluster); err != nil { + return err + } + + sbx, err := buildLocalSandbox(cfg, sandboxName, namespace, workloadName, localMappings) + if err != nil { + return err + } + + params := sandboxes.NewApplySandboxParams(). + WithOrgName(cfg.Org). + WithSandboxName(sandboxName). + 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) + 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 + } + 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 resolveSandboxName(name string) string { + if len(name) > 0 { + return name + } + return fmt.Sprintf("autogenerated-sandbox-%s", utils.RandomString(6)) +} + +func parseWorkload(raw string) (string, string, error) { + if raw == "" { + return "", "", fmt.Errorf("no workload defined\n") + } + + parts := strings.Split(raw, "/") + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid workload format (expected namespace/name)") + } + + ns := parts[0] + wl := parts[1] + if ns == "" || wl == "" { + return "", "", fmt.Errorf("namespace or workload name cannot be empty") + } + + 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 6e9b08c7..76bc75e0 100644 --- a/internal/config/sandbox.go +++ b/internal/config/sandbox.go @@ -54,3 +54,29 @@ type SandboxGet struct { type SandboxList struct { *Sandbox } + +type SandboxMakeLocal struct { + *Sandbox + + // Flags + 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().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") +} 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) +}