From 20651b212c3a716827a23b2744c9dc830ea3abce Mon Sep 17 00:00:00 2001 From: sanchpet Date: Tue, 14 Jul 2026 10:02:36 +0300 Subject: [PATCH 1/5] feat: add balancer command group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the SDK's cloud load-balancer service (client.Balancer, endpoint /balancer) as a top-level `balancer` group: - list — the account's balancers (table + json) - config — order catalog (plans, protocols) + isCreateEnable - create — order a balancer; guarded by the confirm prompt (bills) - edit — reconfigure in place; confirm unless --yes - remove — cancel a balancer; confirm unless --yes (destructive) Servers and rules are supplied as repeatable structured flags (--server ip[,weight[,vpsName]], --rule protoBal:portBal:protoSrv:portSrv), parsed into the SDK's Server/Rule structs. Mutating and destructive commands route through the shared confirm helper. Every command renders through render() keeping the table and json paths in sync. Assisted-By: Claude Signed-off-by: sanchpet --- internal/cmd/balancer.go | 16 ++++++ internal/cmd/balancer_config.go | 57 ++++++++++++++++++++ internal/cmd/balancer_create.go | 94 +++++++++++++++++++++++++++++++++ internal/cmd/balancer_edit.go | 85 +++++++++++++++++++++++++++++ internal/cmd/balancer_flags.go | 59 +++++++++++++++++++++ internal/cmd/balancer_list.go | 40 ++++++++++++++ internal/cmd/balancer_remove.go | 39 ++++++++++++++ internal/cmd/balancer_test.go | 30 +++++++++++ 8 files changed, 420 insertions(+) create mode 100644 internal/cmd/balancer.go create mode 100644 internal/cmd/balancer_config.go create mode 100644 internal/cmd/balancer_create.go create mode 100644 internal/cmd/balancer_edit.go create mode 100644 internal/cmd/balancer_flags.go create mode 100644 internal/cmd/balancer_list.go create mode 100644 internal/cmd/balancer_remove.go create mode 100644 internal/cmd/balancer_test.go diff --git a/internal/cmd/balancer.go b/internal/cmd/balancer.go new file mode 100644 index 0000000..0054226 --- /dev/null +++ b/internal/cmd/balancer.go @@ -0,0 +1,16 @@ +package cmd + +import "github.com/spf13/cobra" + +// balancerCmd groups the cloud load-balancer service (endpoint /balancer): +// list the account's balancers, show the order catalog, and the +// create/edit/remove lifecycle. Like the VPS group, it lives on the cloud +// account, so bind it to that profile once and every subcommand inherits it. +var balancerCmd = &cobra.Command{ + Use: "balancer", + Short: "Manage cloud load balancers (list, config, create, edit, remove)", +} + +func init() { + rootCmd.AddCommand(balancerCmd) +} diff --git a/internal/cmd/balancer_config.go b/internal/cmd/balancer_config.go new file mode 100644 index 0000000..5273b9d --- /dev/null +++ b/internal/cmd/balancer_config.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" +) + +var balancerConfigCmd = &cobra.Command{ + Use: "config", + Short: "Show the load-balancer order catalog (plans, protocols)", + Long: `Lists the plans and front-end protocols used by 'sweb balancer create', and +reports whether ordering a new balancer is currently available (isCreateEnable).`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := client() + if err != nil { + return err + } + cfg, err := c.Balancer.AvailableConfig(cmd.Context()) + if err != nil { + return err + } + enabled, err := c.Balancer.IsCreateEnable(cmd.Context()) + if err != nil { + return err + } + // Carry both the catalog and the availability flag on the json path. + data := struct { + CreateEnabled bool `json:"createEnabled"` + *balancerConfig + }{enabled, (*balancerConfig)(cfg)} + return render(cmd, data, func(w io.Writer) { + fmt.Fprintf(w, "CREATE AVAILABLE\t%t\n\n", enabled) + fmt.Fprintln(w, "PLANS") + fmt.Fprintln(w, "ID\tTAG\tTITLE\tPRICE/mo") + for _, p := range cfg.Plans { + fmt.Fprintf(w, "%s\t%s\t%s\t%g\n", p.ID, p.Tag, p.Title, float64(p.Price)) + } + fmt.Fprintln(w, "\nPROTOCOLS (front-end → allowed back-ends)") + fmt.Fprintln(w, "ID\tNAME\tRESTRICTIONS") + for _, pr := range cfg.Protocols { + r := strings.Join(pr.Restrictions, ",") + if r == "" { + r = "-" + } + fmt.Fprintf(w, "%s\t%s\t%s\n", pr.ID, pr.Name, r) + } + }) + }, +} + +func init() { + balancerCmd.AddCommand(balancerConfigCmd) +} diff --git a/internal/cmd/balancer_create.go b/internal/cmd/balancer_create.go new file mode 100644 index 0000000..3d85096 --- /dev/null +++ b/internal/cmd/balancer_create.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "fmt" + + "github.com/sanchpet/sweb-go-sdk/balancer" + "github.com/spf13/cobra" +) + +var balancerCreateCmd = &cobra.Command{ + Use: "create", + Short: "Order a new load balancer (mutates — bills your account)", + Long: `Order a new load balancer. --datacenter, --type, --plan and at least one +--server and one --rule are required. + + --server ip[,weight[,vpsName]] back-end server (repeatable, max 20) + weight (1..5) applies to type roundrobin + --rule protoBal:portBal:protoSrv:portSrv forwarding rule (repeatable) + +See 'sweb balancer config' for the plan ids and protocols. +This call MUTATES and BILLS your account. You are asked to confirm unless --yes.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + f := cmd.Flags() + dc, _ := f.GetInt("datacenter") + typ, _ := f.GetString("type") + plan, _ := f.GetInt("plan") + serverVals, _ := f.GetStringArray("server") + ruleVals, _ := f.GetStringArray("rule") + alias, _ := f.GetString("alias") + healthCheck, _ := f.GetBool("health-check") + proxyProto, _ := f.GetBool("proxy-proto") + keepalive, _ := f.GetBool("keepalive") + saveSession, _ := f.GetBool("save-session") + firstOrder, _ := f.GetBool("first-order") + + if dc == 0 || typ == "" || plan == 0 || len(serverVals) == 0 || len(ruleVals) == 0 { + return fmt.Errorf("--datacenter, --type, --plan and at least one --server and --rule are required") + } + servers, err := parseServers(serverVals) + if err != nil { + return err + } + rules, err := parseRules(ruleVals) + if err != nil { + return err + } + + if !confirmed(cmd, "Order a new load balancer? This bills your account.", "Order") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + + c, err := client() + if err != nil { + return err + } + if err := c.Balancer.Create(cmd.Context(), balancer.CreateOptions{ + Datacenter: dc, + Type: typ, + Servers: servers, + Rules: rules, + PlanID: plan, + HealthCheck: healthCheck, + ProxyProto: proxyProto, + Keepalive: keepalive, + SaveSession: saveSession, + Alias: alias, + IsFirstOrder: firstOrder, + }); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Balancer order started") + return nil + }, +} + +func init() { + f := balancerCreateCmd.Flags() + f.Int("datacenter", 0, "datacenter ID — see `sweb vps config`") + f.String("type", "", "balancing algorithm: roundrobin|leastconn") + f.Int("plan", 0, "tariff plan ID — see `sweb balancer config`") + f.StringArray("server", nil, "back-end server ip[,weight[,vpsName]] (repeatable, max 20)") + f.StringArray("rule", nil, "forwarding rule protoBal:portBal:protoSrv:portSrv (repeatable)") + f.String("alias", "", "human-readable name for the balancer (optional)") + f.Bool("health-check", false, "enable back-end health checks") + f.Bool("proxy-proto", false, "enable the PROXY protocol") + f.Bool("keepalive", false, "enable keepalive") + f.Bool("save-session", false, "enable session persistence") + f.Bool("first-order", false, "mark this as a first order (isFirstOrder)") + f.Bool("yes", false, "skip the confirmation prompt") + + balancerCmd.AddCommand(balancerCreateCmd) +} diff --git a/internal/cmd/balancer_edit.go b/internal/cmd/balancer_edit.go new file mode 100644 index 0000000..86b9a1d --- /dev/null +++ b/internal/cmd/balancer_edit.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "fmt" + + "github.com/sanchpet/sweb-go-sdk/balancer" + "github.com/spf13/cobra" +) + +var balancerEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Reconfigure a load balancer in place (mutates)", + Long: `Reconfigure a load balancer. is a Balancer.BillingID from +'sweb balancer list'. --type and at least one --server and one --rule are +required (edit replaces the full server/rule set). + + --server ip[,weight[,vpsName]] back-end server (repeatable) + --rule protoBal:portBal:protoSrv:portSrv forwarding rule (repeatable) + +You are asked to confirm unless --yes is given.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + f := cmd.Flags() + typ, _ := f.GetString("type") + serverVals, _ := f.GetStringArray("server") + ruleVals, _ := f.GetStringArray("rule") + alias, _ := f.GetString("alias") + healthCheck, _ := f.GetBool("health-check") + proxyProto, _ := f.GetBool("proxy-proto") + keepalive, _ := f.GetBool("keepalive") + saveSession, _ := f.GetBool("save-session") + + if typ == "" || len(serverVals) == 0 || len(ruleVals) == 0 { + return fmt.Errorf("--type and at least one --server and --rule are required") + } + servers, err := parseServers(serverVals) + if err != nil { + return err + } + rules, err := parseRules(ruleVals) + if err != nil { + return err + } + + if !confirmed(cmd, fmt.Sprintf("Reconfigure balancer %q?", args[0]), "Edit") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + + c, err := client() + if err != nil { + return err + } + if err := c.Balancer.Edit(cmd.Context(), balancer.EditOptions{ + BillingID: args[0], + Type: typ, + Servers: servers, + Rules: rules, + HealthCheck: healthCheck, + ProxyProto: proxyProto, + Keepalive: keepalive, + SaveSession: saveSession, + Alias: alias, + }); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Edited", args[0]) + return nil + }, +} + +func init() { + f := balancerEditCmd.Flags() + f.String("type", "", "balancing algorithm: roundrobin|leastconn") + f.StringArray("server", nil, "back-end server ip[,weight[,vpsName]] (repeatable)") + f.StringArray("rule", nil, "forwarding rule protoBal:portBal:protoSrv:portSrv (repeatable)") + f.String("alias", "", "human-readable name for the balancer (optional)") + f.Bool("health-check", false, "enable back-end health checks") + f.Bool("proxy-proto", false, "enable the PROXY protocol") + f.Bool("keepalive", false, "enable keepalive") + f.Bool("save-session", false, "enable session persistence") + f.Bool("yes", false, "skip the confirmation prompt") + + balancerCmd.AddCommand(balancerEditCmd) +} diff --git a/internal/cmd/balancer_flags.go b/internal/cmd/balancer_flags.go new file mode 100644 index 0000000..c5bf1b3 --- /dev/null +++ b/internal/cmd/balancer_flags.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/sanchpet/sweb-go-sdk/balancer" + "github.com/sanchpet/sweb-go-sdk/flex" +) + +// balancerConfig aliases balancer.Config so `balancer config` can embed the +// catalog next to the createEnabled flag on the json output path. +type balancerConfig = balancer.Config + +// parseServers turns repeatable --server values into balancer.Server entries. +// Format: "ip[,weight]" — weight (1..5) applies only to the roundrobin type and +// defaults to 0 (absent) when omitted. +func parseServers(vals []string) ([]balancer.Server, error) { + servers := make([]balancer.Server, 0, len(vals)) + for _, v := range vals { + fields := strings.Split(v, ",") + s := balancer.Server{IP: strings.TrimSpace(fields[0])} + if s.IP == "" { + return nil, fmt.Errorf("--server %q: missing IP", v) + } + if len(fields) > 1 && strings.TrimSpace(fields[1]) != "" { + weight, err := strconv.Atoi(strings.TrimSpace(fields[1])) + if err != nil { + return nil, fmt.Errorf("--server %q: bad weight: %w", v, err) + } + s.Weight = flex.Int(weight) + } + if len(fields) > 2 { + s.VPSName = strings.TrimSpace(fields[2]) + } + servers = append(servers, s) + } + return servers, nil +} + +// parseRules turns repeatable --rule values into balancer.Rule entries. +// Format: "protoBalancer:portBalancer:protoServer:portServer". +func parseRules(vals []string) ([]balancer.Rule, error) { + rules := make([]balancer.Rule, 0, len(vals)) + for _, v := range vals { + fields := strings.Split(v, ":") + if len(fields) != 4 { + return nil, fmt.Errorf("--rule %q: want protoBalancer:portBalancer:protoServer:portServer", v) + } + rules = append(rules, balancer.Rule{ + ProtocolBalancer: strings.TrimSpace(fields[0]), + PortBalancer: strings.TrimSpace(fields[1]), + ProtocolServer: strings.TrimSpace(fields[2]), + PortServer: strings.TrimSpace(fields[3]), + }) + } + return rules, nil +} diff --git a/internal/cmd/balancer_list.go b/internal/cmd/balancer_list.go new file mode 100644 index 0000000..3b1696f --- /dev/null +++ b/internal/cmd/balancer_list.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" +) + +var balancerListCmd = &cobra.Command{ + Use: "list", + Short: "List the account's load balancers", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := client() + if err != nil { + return err + } + bals, err := c.Balancer.List(cmd.Context()) + if err != nil { + return err + } + return render(cmd, bals, func(w io.Writer) { + fmt.Fprintln(w, "BILLING_ID\tNAME\tTYPE\tPLAN\tIP\tPRICE/mo\tACTIVE\tACTION") + for _, b := range bals { + action := b.CurrentAction + if action == "" { + action = "-" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\t%t\t%s\n", + b.BillingID, b.Name, b.Type, b.PlanName, b.IPBalancer, + int64(b.Price), b.Active, action) + } + }) + }, +} + +func init() { + balancerCmd.AddCommand(balancerListCmd) +} diff --git a/internal/cmd/balancer_remove.go b/internal/cmd/balancer_remove.go new file mode 100644 index 0000000..56dc439 --- /dev/null +++ b/internal/cmd/balancer_remove.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var balancerRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove (cancel) a load balancer — destructive", + Long: `Remove a load balancer via the "remove" method. is a +Balancer.BillingID from 'sweb balancer list'. + +This is DESTRUCTIVE. You are asked to confirm unless --yes is given.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + billingID := args[0] + if !confirmed(cmd, fmt.Sprintf("Remove balancer %q? This cannot be undone.", billingID), "Remove") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + + c, err := client() + if err != nil { + return err + } + if err := c.Balancer.Remove(cmd.Context(), billingID); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Removed", billingID) + return nil + }, +} + +func init() { + balancerRemoveCmd.Flags().Bool("yes", false, "skip the confirmation prompt") + balancerCmd.AddCommand(balancerRemoveCmd) +} diff --git a/internal/cmd/balancer_test.go b/internal/cmd/balancer_test.go new file mode 100644 index 0000000..ef8597f --- /dev/null +++ b/internal/cmd/balancer_test.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestBalancerCommandTree(t *testing.T) { + if !subNames(rootCmd)["balancer"] { + t.Fatal("root is missing subcommand \"balancer\"") + } + + var balancer *cobra.Command + for _, c := range rootCmd.Commands() { + if c.Name() == "balancer" { + balancer = c + } + } + if balancer == nil { + t.Fatal("balancer command not registered") + } + + bsub := subNames(balancer) + for _, n := range []string{"list", "config", "create", "edit", "remove"} { + if !bsub[n] { + t.Errorf("balancer is missing subcommand %q", n) + } + } +} From 44592813a70bd659dc9721cf2c48d34f69d84a66 Mon Sep 17 00:00:00 2001 From: sanchpet Date: Tue, 14 Jul 2026 10:05:36 +0300 Subject: [PATCH 2/5] feat: add dbaas command group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the SDK's managed-database service (client.DBaaS, endpoint /dbaas) as a top-level command group, keeping the CLI a thin presentation layer. Reads: 'dbaas list' (clusters) and 'dbaas config' (engine/plan catalog). Lifecycle: 'dbaas create' (bills — plan by id or via the constructor from --cpu/--memory/--storage/--replicas), 'dbaas edit', and the destructive 'dbaas remove' / 'dbaas delete-database'. Mutating and destructive ops confirm via the shared huh helper unless --yes; every command renders through render() with paired table/json paths. First-order plumbing (getFirstOrderInfo/removeFirst/setUpgradeAgree/ validateUsers) is intentionally left unsurfaced — it is the promotional onboarding flow, not part of the core cluster lifecycle. Assisted-By: Claude Signed-off-by: sanchpet --- internal/cmd/dbaas.go | 14 +++ internal/cmd/dbaas_config.go | 47 +++++++++++ internal/cmd/dbaas_create.go | 117 ++++++++++++++++++++++++++ internal/cmd/dbaas_delete_database.go | 39 +++++++++ internal/cmd/dbaas_edit.go | 66 +++++++++++++++ internal/cmd/dbaas_list.go | 44 ++++++++++ internal/cmd/dbaas_remove.go | 37 ++++++++ internal/cmd/dbaas_test.go | 29 +++++++ 8 files changed, 393 insertions(+) create mode 100644 internal/cmd/dbaas.go create mode 100644 internal/cmd/dbaas_config.go create mode 100644 internal/cmd/dbaas_create.go create mode 100644 internal/cmd/dbaas_delete_database.go create mode 100644 internal/cmd/dbaas_edit.go create mode 100644 internal/cmd/dbaas_list.go create mode 100644 internal/cmd/dbaas_remove.go create mode 100644 internal/cmd/dbaas_test.go diff --git a/internal/cmd/dbaas.go b/internal/cmd/dbaas.go new file mode 100644 index 0000000..eaeb066 --- /dev/null +++ b/internal/cmd/dbaas.go @@ -0,0 +1,14 @@ +package cmd + +import "github.com/spf13/cobra" + +// dbaasCmd groups the managed-database (DBaaS) service — clusters, the +// create-page catalog, and cluster/database lifecycle (endpoint /dbaas). +var dbaasCmd = &cobra.Command{ + Use: "dbaas", + Short: "Manage managed databases (DBaaS clusters)", +} + +func init() { + rootCmd.AddCommand(dbaasCmd) +} diff --git a/internal/cmd/dbaas_config.go b/internal/cmd/dbaas_config.go new file mode 100644 index 0000000..2b13bd8 --- /dev/null +++ b/internal/cmd/dbaas_config.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "fmt" + "io" + "sort" + + "github.com/spf13/cobra" +) + +var dbaasConfigCmd = &cobra.Command{ + Use: "config", + Short: "Show the cluster-creation catalog (engines and plans)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := client() + if err != nil { + return err + } + cfg, err := c.DBaaS.AvailableConfig(cmd.Context()) + if err != nil { + return err + } + return render(cmd, cfg, func(w io.Writer) { + fmt.Fprintln(w, "ENGINE\tVERSION") + engines := make([]string, 0, len(cfg.Engines)) + for name := range cfg.Engines { + engines = append(engines, name) + } + sort.Strings(engines) + for _, name := range engines { + for _, e := range cfg.Engines[name] { + fmt.Fprintf(w, "%s\t%s\n", name, e.Version) + } + } + fmt.Fprintln(w, "\nPLAN_ID\tNAME\tCPU\tMEM_GB\tSTORAGE_GB") + for _, p := range cfg.Plans { + fmt.Fprintf(w, "%d\t%s\t%d\t%d\t%d\n", + int64(p.ID), p.Name, int64(p.CPU), int64(p.Memory), int64(p.Storage)) + } + }) + }, +} + +func init() { + dbaasCmd.AddCommand(dbaasConfigCmd) +} diff --git a/internal/cmd/dbaas_create.go b/internal/cmd/dbaas_create.go new file mode 100644 index 0000000..3125d37 --- /dev/null +++ b/internal/cmd/dbaas_create.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/sanchpet/sweb-go-sdk/dbaas" + "github.com/spf13/cobra" +) + +var dbaasCreateCmd = &cobra.Command{ + Use: "create", + Short: "Provision a new managed-database cluster (mutates — bills your account)", + Long: `Provision a new managed-database cluster. Pick the plan one of two ways: + + • a stock plan: --plan (see 'sweb dbaas config') + • the configurator: --cpu N --memory N --storage N [--replicas N] + (builds a custom plan; memory and storage are in GB; + replicas default to 0 = master only) + +--engine, --version and at least one --user are always required. Each --user is +"name:password" and may be repeated. + +This call MUTATES and BILLS your account. You are asked to confirm unless --yes.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + f := cmd.Flags() + engine, _ := f.GetString("engine") + version, _ := f.GetString("version") + plan, _ := f.GetInt("plan") + cpu, _ := f.GetInt("cpu") + memory, _ := f.GetInt("memory") + storage, _ := f.GetInt("storage") + replicas, _ := f.GetInt("replicas") + displayName, _ := f.GetString("name") + userSpecs, _ := f.GetStringArray("user") + + if engine == "" || version == "" { + return fmt.Errorf("--engine and --version are required") + } + if len(userSpecs) == 0 { + return fmt.Errorf("at least one --user name:password is required") + } + users, err := parseDBaaSUsers(userSpecs) + if err != nil { + return err + } + + c, err := client() + if err != nil { + return err + } + + // Configurator mode: resolve a custom plan id from --cpu/--memory/--storage. + if plan == 0 { + if cpu == 0 || memory == 0 || storage == 0 { + return fmt.Errorf("provide --plan, or --cpu/--memory/--storage to build a configurator plan") + } + id, err := c.DBaaS.ConstructorPlanID(cmd.Context(), cpu, memory, storage, replicas) + if err != nil { + return fmt.Errorf("resolve configurator plan: %w", err) + } + plan = int(id) + fmt.Fprintf(cmd.ErrOrStderr(), "configurator %dcpu/%dGB/%dGB (%d replicas) → plan %d\n", + cpu, memory, storage, replicas, plan) + } + + if !confirmed(cmd, "Create this managed-database cluster? This mutates and bills your account.", "Create") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + + req := dbaas.CreateInstanceRequest{ + EngineType: engine, + EngineVersion: version, + Users: users, + PlanID: plan, + DisplayName: displayName, + } + res, err := c.DBaaS.CreateInstance(cmd.Context(), req) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), string(res)) + return nil + }, +} + +// parseDBaaSUsers parses "name:password" specs into UserCredentials. The +// password may contain ':' (split on the first colon only). +func parseDBaaSUsers(specs []string) ([]dbaas.UserCredentials, error) { + users := make([]dbaas.UserCredentials, 0, len(specs)) + for _, s := range specs { + name, password, ok := strings.Cut(s, ":") + if !ok || name == "" || password == "" { + return nil, fmt.Errorf("invalid --user %q: want name:password", s) + } + users = append(users, dbaas.UserCredentials{Name: name, Password: password}) + } + return users, nil +} + +func init() { + f := dbaasCreateCmd.Flags() + f.String("engine", "", "engine type, e.g. PostgreSQL or MySQL (see `sweb dbaas config`)") + f.String("version", "", "engine version (see `sweb dbaas config`)") + f.Int("plan", 0, "stock plan ID (planId) — see `sweb dbaas config`") + f.Int("cpu", 0, "configurator: CPU cores") + f.Int("memory", 0, "configurator: memory in GB") + f.Int("storage", 0, "configurator: storage in GB") + f.Int("replicas", 0, "configurator: replica count (0 = master only)") + f.String("name", "", "display name for the cluster") + f.StringArray("user", nil, "cluster user as name:password (repeatable)") + f.Bool("yes", false, "skip the confirmation prompt") + + dbaasCmd.AddCommand(dbaasCreateCmd) +} diff --git a/internal/cmd/dbaas_delete_database.go b/internal/cmd/dbaas_delete_database.go new file mode 100644 index 0000000..a55d3df --- /dev/null +++ b/internal/cmd/dbaas_delete_database.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var dbaasDeleteDatabaseCmd = &cobra.Command{ + Use: "delete-database ", + Short: "Delete a single database from a cluster — destructive", + Long: `Delete one database from a cluster via the "deleteDatabase" method. + and are from 'sweb dbaas list' (db-name is the technical +Name, not the display name). + +This is DESTRUCTIVE. You are asked to confirm unless --yes is given.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + billingID, dbName := args[0], args[1] + c, err := client() + if err != nil { + return err + } + if !confirmed(cmd, fmt.Sprintf("Delete database %q from cluster %q? This cannot be undone.", dbName, billingID), "Delete") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + if _, err := c.DBaaS.DeleteDatabase(cmd.Context(), billingID, dbName); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Deleted database", dbName, "from", billingID) + return nil + }, +} + +func init() { + dbaasDeleteDatabaseCmd.Flags().Bool("yes", false, "skip the confirmation prompt") + dbaasCmd.AddCommand(dbaasDeleteDatabaseCmd) +} diff --git a/internal/cmd/dbaas_edit.go b/internal/cmd/dbaas_edit.go new file mode 100644 index 0000000..20c25e5 --- /dev/null +++ b/internal/cmd/dbaas_edit.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "fmt" + + "github.com/sanchpet/sweb-go-sdk/dbaas" + "github.com/spf13/cobra" +) + +var dbaasEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit a managed-database cluster (users, plan, name)", + Long: `Edit a cluster's users, plan or display name via the "editInstance" method. + is from 'sweb dbaas list'. An omitted flag leaves that facet +unchanged. + +--user follows the edit semantics: a user with a password is created, and any +existing user absent from the given list is removed; pass no --user to leave the +user set untouched. Each --user is "name:password" and may be repeated. + +This MUTATES the cluster. You are asked to confirm unless --yes.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + f := cmd.Flags() + plan, _ := f.GetInt("plan") + displayName, _ := f.GetString("name") + userSpecs, _ := f.GetStringArray("user") + + req := dbaas.EditInstanceRequest{ + BillingID: args[0], + PlanID: plan, + DisplayName: displayName, + } + if len(userSpecs) > 0 { + users, err := parseDBaaSUsers(userSpecs) + if err != nil { + return err + } + req.Users = users + } + + c, err := client() + if err != nil { + return err + } + if !confirmed(cmd, fmt.Sprintf("Edit cluster %q?", args[0]), "Edit") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + if err := c.DBaaS.EditInstance(cmd.Context(), req); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Edited", args[0]) + return nil + }, +} + +func init() { + f := dbaasEditCmd.Flags() + f.Int("plan", 0, "new plan ID (planId) — see `sweb dbaas config`") + f.String("name", "", "new display name for the cluster") + f.StringArray("user", nil, "cluster user as name:password (repeatable; edit semantics)") + f.Bool("yes", false, "skip the confirmation prompt") + + dbaasCmd.AddCommand(dbaasEditCmd) +} diff --git a/internal/cmd/dbaas_list.go b/internal/cmd/dbaas_list.go new file mode 100644 index 0000000..8e37a3a --- /dev/null +++ b/internal/cmd/dbaas_list.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" +) + +var dbaasListCmd = &cobra.Command{ + Use: "list", + Short: "List the account's managed-database clusters", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := client() + if err != nil { + return err + } + idx, err := c.DBaaS.List(cmd.Context()) + if err != nil { + return err + } + return render(cmd, idx, func(w io.Writer) { + fmt.Fprintln(w, "BILLING_ID\tNAME\tENGINE\tPLAN\tIP\tPRICE/mo\tSTATUS\tACTION") + for _, in := range idx.Instances { + name := in.DisplayName + if name == "" { + name = in.Name + } + action := in.CurrentAction + if action == "" { + action = "-" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%.2f\t%s\t%s\n", + in.BillingID, name, in.Engine, in.Plan.Name, in.IP, + float64(in.Price), in.Status, action) + } + }) + }, +} + +func init() { + dbaasCmd.AddCommand(dbaasListCmd) +} diff --git a/internal/cmd/dbaas_remove.go b/internal/cmd/dbaas_remove.go new file mode 100644 index 0000000..22a25bb --- /dev/null +++ b/internal/cmd/dbaas_remove.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var dbaasRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a managed-database cluster — destructive", + Long: `Remove a cluster via the "removeInstance" method. is from +'sweb dbaas list'. This removes the cluster and all of its databases. + +This is DESTRUCTIVE. You are asked to confirm unless --yes is given.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + if !confirmed(cmd, fmt.Sprintf("Remove cluster %q? This cannot be undone.", args[0]), "Remove") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + if _, err := c.DBaaS.RemoveInstance(cmd.Context(), args[0]); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Removed", args[0]) + return nil + }, +} + +func init() { + dbaasRemoveCmd.Flags().Bool("yes", false, "skip the confirmation prompt") + dbaasCmd.AddCommand(dbaasRemoveCmd) +} diff --git a/internal/cmd/dbaas_test.go b/internal/cmd/dbaas_test.go new file mode 100644 index 0000000..208948f --- /dev/null +++ b/internal/cmd/dbaas_test.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestDBaaSCommandTree(t *testing.T) { + if !subNames(rootCmd)["dbaas"] { + t.Fatal("root is missing subcommand \"dbaas\"") + } + + var dbaas *cobra.Command + for _, c := range rootCmd.Commands() { + if c.Name() == "dbaas" { + dbaas = c + } + } + if dbaas == nil { + t.Fatal("dbaas command not registered") + } + sub := subNames(dbaas) + for _, n := range []string{"list", "config", "create", "edit", "remove", "delete-database"} { + if !sub[n] { + t.Errorf("dbaas is missing subcommand %q", n) + } + } +} From b61c3a0f7073e33c6cfa984646941b7d2e928806 Mon Sep 17 00:00:00 2001 From: sanchpet Date: Tue, 14 Jul 2026 10:05:59 +0300 Subject: [PATCH 3/5] feat: add monitoring command group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the SDK's uptime-monitoring services as a top-level `monitoring` group. Three SDK services back it: the subscription/tariff (/monitoring), the checks (/monitoring/checks), and the notification contacts (/monitoring/contacts). - monitoring plans/enable/disable/change — the subscription; the billing toggles confirm unless --yes. - monitoring check {list,types,show,create,edit,activate,deactivate, remove,history} — the check CRUD, with create mapping Spec fields to flags and confirming (it bills the check quota); remove confirms. - monitoring contact {list,add-email,add-phone,add-telegram,edit,remove, verify,verify-status} — the contact lifecycle; add-telegram folds in the verify-code request; remove confirms. Assisted-By: Claude Signed-off-by: sanchpet --- internal/cmd/monitoring.go | 16 +++ internal/cmd/monitoring_check.go | 30 +++++ internal/cmd/monitoring_check_create.go | 83 ++++++++++++++ internal/cmd/monitoring_check_edit.go | 39 +++++++ internal/cmd/monitoring_check_history.go | 50 +++++++++ internal/cmd/monitoring_check_list.go | 49 ++++++++ internal/cmd/monitoring_check_show.go | 45 ++++++++ internal/cmd/monitoring_check_toggle.go | 81 ++++++++++++++ internal/cmd/monitoring_check_types.go | 34 ++++++ internal/cmd/monitoring_contact.go | 16 +++ internal/cmd/monitoring_contact_add.go | 75 +++++++++++++ internal/cmd/monitoring_contact_edit.go | 61 ++++++++++ internal/cmd/monitoring_contact_list.go | 58 ++++++++++ internal/cmd/monitoring_contact_verify.go | 48 ++++++++ internal/cmd/monitoring_plans.go | 129 ++++++++++++++++++++++ internal/cmd/monitoring_test.go | 62 +++++++++++ 16 files changed, 876 insertions(+) create mode 100644 internal/cmd/monitoring.go create mode 100644 internal/cmd/monitoring_check.go create mode 100644 internal/cmd/monitoring_check_create.go create mode 100644 internal/cmd/monitoring_check_edit.go create mode 100644 internal/cmd/monitoring_check_history.go create mode 100644 internal/cmd/monitoring_check_list.go create mode 100644 internal/cmd/monitoring_check_show.go create mode 100644 internal/cmd/monitoring_check_toggle.go create mode 100644 internal/cmd/monitoring_check_types.go create mode 100644 internal/cmd/monitoring_contact.go create mode 100644 internal/cmd/monitoring_contact_add.go create mode 100644 internal/cmd/monitoring_contact_edit.go create mode 100644 internal/cmd/monitoring_contact_list.go create mode 100644 internal/cmd/monitoring_contact_verify.go create mode 100644 internal/cmd/monitoring_plans.go create mode 100644 internal/cmd/monitoring_test.go diff --git a/internal/cmd/monitoring.go b/internal/cmd/monitoring.go new file mode 100644 index 0000000..43c7fc9 --- /dev/null +++ b/internal/cmd/monitoring.go @@ -0,0 +1,16 @@ +package cmd + +import "github.com/spf13/cobra" + +// monitoringCmd groups the uptime-monitoring services. Three SDK services back +// it: the monitoring subscription/tariff (endpoint /monitoring), the checks +// (`monitoring check`, endpoint /monitoring/checks), and the notification +// contacts (`monitoring contact`, endpoint /monitoring/contacts). +var monitoringCmd = &cobra.Command{ + Use: "monitoring", + Short: "Manage uptime monitoring (plans, checks, contacts)", +} + +func init() { + rootCmd.AddCommand(monitoringCmd) +} diff --git a/internal/cmd/monitoring_check.go b/internal/cmd/monitoring_check.go new file mode 100644 index 0000000..8050c41 --- /dev/null +++ b/internal/cmd/monitoring_check.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" +) + +// checkCmd groups the monitoring-check operations (endpoint /monitoring/checks): +// listing checks, the reference dictionaries, the create/edit lifecycle, the +// activate/deactivate toggle, removal, and check history. It hangs off the +// monitoring group. +var checkCmd = &cobra.Command{ + Use: "check", + Short: "Manage monitoring checks", +} + +// checkID parses a check's positional argument. +func checkID(arg string) (int, error) { + id, err := strconv.Atoi(arg) + if err != nil { + return 0, fmt.Errorf("check id must be an integer: %q", arg) + } + return id, nil +} + +func init() { + monitoringCmd.AddCommand(checkCmd) +} diff --git a/internal/cmd/monitoring_check_create.go b/internal/cmd/monitoring_check_create.go new file mode 100644 index 0000000..06f3702 --- /dev/null +++ b/internal/cmd/monitoring_check_create.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "fmt" + + "github.com/sanchpet/sweb-go-sdk/monitoring/checks" + "github.com/spf13/cobra" +) + +// addCheckSpecFlags registers the flags shared by `check create` and +// `check edit`, one per Spec field that makes sense on the command line. --type +// is create-only (edit is keyed by id), so it is registered by the callers. +func addCheckSpecFlags(cmd *cobra.Command) { + cmd.Flags().String("target", "", "URL or IP to check") + cmd.Flags().String("name", "", "display name") + cmd.Flags().Int("interval", 0, "interval id (see 'monitoring check' intervals via getInfo)") + cmd.Flags().IntSlice("contact", nil, "contact id to notify (repeatable)") + cmd.Flags().Int("port", 0, "port to check (Port checks only)") + cmd.Flags().Bool("ssl", false, "use SSL (HTTP checks)") + cmd.Flags().StringSlice("keyword", nil, "keyword to match (HTTP checks; repeatable)") + cmd.Flags().Int("keyword-mode", 0, "keyword mode id (HTTP checks)") +} + +// checkSpecFromFlags builds a checks.Spec from the shared flags. --target and +// --name are required; their absence is reported. +func checkSpecFromFlags(cmd *cobra.Command) (checks.Spec, error) { + target, _ := cmd.Flags().GetString("target") + name, _ := cmd.Flags().GetString("name") + if target == "" || name == "" { + return checks.Spec{}, fmt.Errorf("--target and --name are required") + } + contacts, _ := cmd.Flags().GetIntSlice("contact") + keywords, _ := cmd.Flags().GetStringSlice("keyword") + ssl, _ := cmd.Flags().GetBool("ssl") + return checks.Spec{ + Type: flagInt(cmd, "type"), + Target: target, + Name: name, + Interval: flagInt(cmd, "interval"), + ContactIDs: contacts, + Port: flagInt(cmd, "port"), + SSL: ssl, + Keywords: keywords, + KeywordMode: flagInt(cmd, "keyword-mode"), + }, nil +} + +var checkCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a monitoring check — this BILLS against the check quota", + Long: `Create a monitoring check (method "create"). This BILLS against the +check quota; you are asked to confirm unless --yes. + +--type is the check type id (1 Ping, 2 HTTP, 3 Port); --port/--ssl/--keyword/ +--keyword-mode are only meaningful for the matching type.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + spec, err := checkSpecFromFlags(cmd) + if err != nil { + return err + } + c, err := client() + if err != nil { + return err + } + if !confirmed(cmd, fmt.Sprintf("Create check %q? This bills against your check quota.", spec.Name), "Create") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + if err := c.MonitoringChecks.Create(cmd.Context(), spec); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Created check:", spec.Name) + return nil + }, +} + +func init() { + addCheckSpecFlags(checkCreateCmd) + checkCreateCmd.Flags().Int("type", 0, "check type id (1 Ping, 2 HTTP, 3 Port)") + checkCreateCmd.Flags().Bool("yes", false, "skip the confirmation prompt") + checkCmd.AddCommand(checkCreateCmd) +} diff --git a/internal/cmd/monitoring_check_edit.go b/internal/cmd/monitoring_check_edit.go new file mode 100644 index 0000000..f150bcd --- /dev/null +++ b/internal/cmd/monitoring_check_edit.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var checkEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit an existing monitoring check", + Long: `Update an existing check (method "edit"). The flags mirror +'monitoring check create' minus --type (edit is keyed by id).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := checkID(args[0]) + if err != nil { + return err + } + spec, err := checkSpecFromFlags(cmd) + if err != nil { + return err + } + c, err := client() + if err != nil { + return err + } + if err := c.MonitoringChecks.Edit(cmd.Context(), id, spec); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Edited check", id) + return nil + }, +} + +func init() { + addCheckSpecFlags(checkEditCmd) + checkCmd.AddCommand(checkEditCmd) +} diff --git a/internal/cmd/monitoring_check_history.go b/internal/cmd/monitoring_check_history.go new file mode 100644 index 0000000..88fb4fe --- /dev/null +++ b/internal/cmd/monitoring_check_history.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "io" + + "github.com/sanchpet/sweb-go-sdk/monitoring/checks" + "github.com/spf13/cobra" +) + +var checkHistoryCmd = &cobra.Command{ + Use: "history ", + Short: "Show a check's event history", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := checkID(args[0]) + if err != nil { + return err + } + c, err := client() + if err != nil { + return err + } + start, _ := cmd.Flags().GetString("start") + finish, _ := cmd.Flags().GetString("finish") + res, err := c.MonitoringChecks.History(cmd.Context(), id, &checks.HistoryOptions{ + StartDate: start, + FinishDate: finish, + Page: flagInt(cmd, "page"), + PerPage: flagInt(cmd, "per-page"), + }) + if err != nil { + return err + } + return render(cmd, res, func(w io.Writer) { + fmt.Fprintln(w, "ID\tCHECK\tTIMESTAMP\tSUCCESS") + for _, e := range res.List { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.ID, e.CheckID, e.TS, e.Success) + } + }) + }, +} + +func init() { + checkHistoryCmd.Flags().String("start", "", "inclusive lower bound on the event date") + checkHistoryCmd.Flags().String("finish", "", "inclusive upper bound on the event date") + checkHistoryCmd.Flags().Int("page", 0, "page of results (1-based; 0 lets the API default)") + checkHistoryCmd.Flags().Int("per-page", 0, "rows per page (0 lets the API default)") + checkCmd.AddCommand(checkHistoryCmd) +} diff --git a/internal/cmd/monitoring_check_list.go b/internal/cmd/monitoring_check_list.go new file mode 100644 index 0000000..8ec8b45 --- /dev/null +++ b/internal/cmd/monitoring_check_list.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + "io" + + "github.com/sanchpet/sweb-go-sdk/monitoring/checks" + "github.com/spf13/cobra" +) + +var checkListCmd = &cobra.Command{ + Use: "list", + Short: "List the account's monitoring checks", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := client() + if err != nil { + return err + } + res, err := c.MonitoringChecks.Index(cmd.Context(), &checks.ListOptions{ + Page: flagInt(cmd, "page"), + PerPage: flagInt(cmd, "per-page"), + }) + if err != nil { + return err + } + return render(cmd, res, func(w io.Writer) { + fmt.Fprintln(w, "ID\tNAME\tTYPE\tSTATUS\tDISABLED") + for _, ch := range res.List { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%t\n", + ch.ID, ch.Name, ch.Type, onOffStatus(ch.Status), ch.Disabled) + } + }) + }, +} + +// onOffStatus renders a check's active flag as active/disabled. +func onOffStatus(active bool) string { + if active { + return "active" + } + return "disabled" +} + +func init() { + checkListCmd.Flags().Int("page", 0, "page of results (1-based; 0 lets the API default)") + checkListCmd.Flags().Int("per-page", 0, "rows per page (0 lets the API default)") + checkCmd.AddCommand(checkListCmd) +} diff --git a/internal/cmd/monitoring_check_show.go b/internal/cmd/monitoring_check_show.go new file mode 100644 index 0000000..e134d60 --- /dev/null +++ b/internal/cmd/monitoring_check_show.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" +) + +var checkShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show a check's full configuration and attached contacts", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := checkID(args[0]) + if err != nil { + return err + } + c, err := client() + if err != nil { + return err + } + info, err := c.MonitoringChecks.GetFullCheckInfo(cmd.Context(), id) + if err != nil { + return err + } + return render(cmd, info, func(w io.Writer) { + fmt.Fprintf(w, "ID\t%d\n", int64(info.ID)) + fmt.Fprintf(w, "NAME\t%s\n", info.Name) + fmt.Fprintf(w, "TYPE\t%d\n", int64(info.Type)) + fmt.Fprintf(w, "STATUS\t%s\n", onOffStatus(info.Status)) + for _, s := range info.Settings { + fmt.Fprintf(w, "SETTING/%s\t%s\n", s.Type, s.Value) + } + for _, ct := range info.Contacts { + fmt.Fprintf(w, "CONTACT\t%d\t%s\t%s\t%s\tverified=%t\n", + int64(ct.ID), ct.Type, ct.Name, ct.Value, ct.Verified) + } + }) + }, +} + +func init() { + checkCmd.AddCommand(checkShowCmd) +} diff --git a/internal/cmd/monitoring_check_toggle.go b/internal/cmd/monitoring_check_toggle.go new file mode 100644 index 0000000..5287bb9 --- /dev/null +++ b/internal/cmd/monitoring_check_toggle.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var checkActivateCmd = &cobra.Command{ + Use: "activate ", + Short: "Enable a monitoring check", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := checkID(args[0]) + if err != nil { + return err + } + c, err := client() + if err != nil { + return err + } + if err := c.MonitoringChecks.Activate(cmd.Context(), id); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Activated check", id) + return nil + }, +} + +var checkDeactivateCmd = &cobra.Command{ + Use: "deactivate ", + Short: "Disable a monitoring check", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := checkID(args[0]) + if err != nil { + return err + } + c, err := client() + if err != nil { + return err + } + if err := c.MonitoringChecks.Deactivate(cmd.Context(), id); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Deactivated check", id) + return nil + }, +} + +var checkRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a monitoring check — destructive", + Long: `Delete a monitoring check (method "remove"). This is DESTRUCTIVE; you +are asked to confirm unless --yes.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := checkID(args[0]) + if err != nil { + return err + } + c, err := client() + if err != nil { + return err + } + if !confirmed(cmd, fmt.Sprintf("Remove check %d? This cannot be undone.", id), "Remove") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + if err := c.MonitoringChecks.Remove(cmd.Context(), id); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Removed check", id) + return nil + }, +} + +func init() { + checkRemoveCmd.Flags().Bool("yes", false, "skip the confirmation prompt") + checkCmd.AddCommand(checkActivateCmd, checkDeactivateCmd, checkRemoveCmd) +} diff --git a/internal/cmd/monitoring_check_types.go b/internal/cmd/monitoring_check_types.go new file mode 100644 index 0000000..c6acf01 --- /dev/null +++ b/internal/cmd/monitoring_check_types.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" +) + +var checkTypesCmd = &cobra.Command{ + Use: "types", + Short: "List the available check types (Ping, HTTP, Port)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := client() + if err != nil { + return err + } + types, err := c.MonitoringChecks.GetTypes(cmd.Context()) + if err != nil { + return err + } + return render(cmd, types, func(w io.Writer) { + fmt.Fprintln(w, "ID\tCODE\tNAME") + for _, t := range types { + fmt.Fprintf(w, "%s\t%s\t%s\n", t.ID, t.Code, t.Name) + } + }) + }, +} + +func init() { + checkCmd.AddCommand(checkTypesCmd) +} diff --git a/internal/cmd/monitoring_contact.go b/internal/cmd/monitoring_contact.go new file mode 100644 index 0000000..4d86461 --- /dev/null +++ b/internal/cmd/monitoring_contact.go @@ -0,0 +1,16 @@ +package cmd + +import "github.com/spf13/cobra" + +// contactCmd groups the monitoring-contact operations (endpoint +// /monitoring/contacts): listing contacts, adding email/phone/Telegram +// contacts, editing and removing them, and the Telegram verification flow. It +// hangs off the monitoring group. +var contactCmd = &cobra.Command{ + Use: "contact", + Short: "Manage monitoring notification contacts", +} + +func init() { + monitoringCmd.AddCommand(contactCmd) +} diff --git a/internal/cmd/monitoring_contact_add.go b/internal/cmd/monitoring_contact_add.go new file mode 100644 index 0000000..dfd28d8 --- /dev/null +++ b/internal/cmd/monitoring_contact_add.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" +) + +var contactAddEmailCmd = &cobra.Command{ + Use: "add-email ", + Short: "Add an email notification contact", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + id, err := c.MonitoringContacts.AddEmail(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Added email contact", id) + return nil + }, +} + +var contactAddPhoneCmd = &cobra.Command{ + Use: "add-phone ", + Short: "Add a phone notification contact", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + id, err := c.MonitoringContacts.AddPhone(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Added phone contact", id) + return nil + }, +} + +var contactAddTelegramCmd = &cobra.Command{ + Use: "add-telegram ", + Short: "Add a Telegram notification contact and request its verification code", + Long: `Add a Telegram contact (method "addTelegram"), then request its +verification code (method "requestTelegramVerifyCode"). Send the printed code to +the SpaceWeb bot, then confirm with 'monitoring contact verify '.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + id, err := c.MonitoringContacts.AddTelegram(cmd.Context(), args[0]) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Added Telegram contact", id) + code, err := c.MonitoringContacts.RequestTelegramVerifyCode(cmd.Context(), strconv.FormatInt(id, 10)) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), + "Send this code to the SpaceWeb bot, then run 'monitoring contact verify %d ':\n%s\n", id, code) + return nil + }, +} + +func init() { + contactCmd.AddCommand(contactAddEmailCmd, contactAddPhoneCmd, contactAddTelegramCmd) +} diff --git a/internal/cmd/monitoring_contact_edit.go b/internal/cmd/monitoring_contact_edit.go new file mode 100644 index 0000000..c21b218 --- /dev/null +++ b/internal/cmd/monitoring_contact_edit.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var contactEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit a contact's value and name", + Long: `Update a contact's value and name (method "editContact"). For a +Telegram contact only --name applies (its value is set by verification).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + value, _ := cmd.Flags().GetString("value") + name, _ := cmd.Flags().GetString("name") + if name == "" { + return fmt.Errorf("--name is required") + } + c, err := client() + if err != nil { + return err + } + if err := c.MonitoringContacts.EditContact(cmd.Context(), args[0], value, name); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Edited contact", args[0]) + return nil + }, +} + +var contactRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a notification contact — destructive", + Long: `Delete a monitoring contact (method "deleteContact"). This is +DESTRUCTIVE; you are asked to confirm unless --yes.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + if !confirmed(cmd, fmt.Sprintf("Remove contact %s? This cannot be undone.", args[0]), "Remove") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + if err := c.MonitoringContacts.DeleteContact(cmd.Context(), args[0]); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Removed contact", args[0]) + return nil + }, +} + +func init() { + contactEditCmd.Flags().String("value", "", "new contact value (email address or phone)") + contactEditCmd.Flags().String("name", "", "new display name") + contactRemoveCmd.Flags().Bool("yes", false, "skip the confirmation prompt") + contactCmd.AddCommand(contactEditCmd, contactRemoveCmd) +} diff --git a/internal/cmd/monitoring_contact_list.go b/internal/cmd/monitoring_contact_list.go new file mode 100644 index 0000000..942e6e4 --- /dev/null +++ b/internal/cmd/monitoring_contact_list.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + "io" + + "github.com/sanchpet/sweb-go-sdk/monitoring/contacts" + "github.com/spf13/cobra" +) + +var contactListCmd = &cobra.Command{ + Use: "list", + Short: "List the account's monitoring contacts", + Long: `List the account's monitoring contacts (method "index"). Pass --all to +use "getAllContacts", which also returns admin contacts.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := client() + if err != nil { + return err + } + if all, _ := cmd.Flags().GetBool("all"); all { + list, err := c.MonitoringContacts.GetAllContacts(cmd.Context()) + if err != nil { + return err + } + return render(cmd, list, func(w io.Writer) { + writeContactRows(w, list) + }) + } + res, err := c.MonitoringContacts.Index(cmd.Context(), &contacts.ListOptions{ + Page: flagInt(cmd, "page"), + PerPage: flagInt(cmd, "per-page"), + }) + if err != nil { + return err + } + return render(cmd, res, func(w io.Writer) { + writeContactRows(w, res.List) + }) + }, +} + +// writeContactRows prints the shared contact table for both list paths. +func writeContactRows(w io.Writer, list []contacts.Contact) { + fmt.Fprintln(w, "ID\tTYPE\tNAME\tVALUE\tVERIFIED\tADMIN") + for _, ct := range list { + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%t\t%t\n", + int64(ct.ID), ct.Type, ct.Name, ct.Value, ct.Verified, ct.Admin) + } +} + +func init() { + contactListCmd.Flags().Bool("all", false, "list every contact including admin contacts (getAllContacts)") + contactListCmd.Flags().Int("page", 0, "page of results (1-based; 0 lets the API default)") + contactListCmd.Flags().Int("per-page", 0, "rows per page (0 lets the API default)") + contactCmd.AddCommand(contactListCmd) +} diff --git a/internal/cmd/monitoring_contact_verify.go b/internal/cmd/monitoring_contact_verify.go new file mode 100644 index 0000000..72ea4dc --- /dev/null +++ b/internal/cmd/monitoring_contact_verify.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" +) + +var contactVerifyCmd = &cobra.Command{ + Use: "verify ", + Short: "Confirm a contact with its verification code", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + if err := c.MonitoringContacts.VerifyContact(cmd.Context(), args[0], args[1]); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Verified contact", args[0]) + return nil + }, +} + +var contactVerifyStatusCmd = &cobra.Command{ + Use: "verify-status ", + Short: "Report whether a contact is verified", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + verified, err := c.MonitoringContacts.IsVerified(cmd.Context(), args[0]) + if err != nil { + return err + } + return render(cmd, map[string]bool{"verified": verified}, func(w io.Writer) { + fmt.Fprintf(w, "VERIFIED\t%t\n", verified) + }) + }, +} + +func init() { + contactCmd.AddCommand(contactVerifyCmd, contactVerifyStatusCmd) +} diff --git a/internal/cmd/monitoring_plans.go b/internal/cmd/monitoring_plans.go new file mode 100644 index 0000000..d3c8b29 --- /dev/null +++ b/internal/cmd/monitoring_plans.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "fmt" + "io" + "strconv" + + "github.com/spf13/cobra" +) + +var monitoringPlansCmd = &cobra.Command{ + Use: "plans", + Short: "List the available monitoring tariff plans", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := client() + if err != nil { + return err + } + plans, err := c.Monitoring.Plans(cmd.Context()) + if err != nil { + return err + } + return render(cmd, plans, func(w io.Writer) { + fmt.Fprintln(w, "ID\tNAME\tCHECKS\tSMS\tPRICE") + for _, p := range plans { + fmt.Fprintf(w, "%d\t%s\t%d\t%d\t%.2f\n", + int64(p.ID), p.Name, int64(p.Checks), int64(p.SMS), float64(p.Price)) + } + }) + }, +} + +// planID parses the shared positional argument. +func planID(arg string) (int, error) { + id, err := strconv.Atoi(arg) + if err != nil { + return 0, fmt.Errorf("plan id must be an integer: %q", arg) + } + return id, nil +} + +var monitoringEnableCmd = &cobra.Command{ + Use: "enable ", + Short: "Subscribe to a monitoring plan — this BILLS", + Long: `Subscribe the account to the monitoring tariff with the given plan id +(method "enable"). This BILLS; you are asked to confirm unless --yes.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := planID(args[0]) + if err != nil { + return err + } + c, err := client() + if err != nil { + return err + } + if !confirmed(cmd, fmt.Sprintf("Enable monitoring plan %d? This will bill your account.", id), "Enable") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + if err := c.Monitoring.Enable(cmd.Context(), id); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Enabled monitoring plan", id) + return nil + }, +} + +var monitoringDisableCmd = &cobra.Command{ + Use: "disable ", + Short: "Cancel the monitoring subscription", + Long: `Cancel the monitoring tariff subscription (method "disable"). You are +asked to confirm unless --yes.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := planID(args[0]) + if err != nil { + return err + } + c, err := client() + if err != nil { + return err + } + if !confirmed(cmd, fmt.Sprintf("Disable monitoring plan %d?", id), "Disable") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + if err := c.Monitoring.Disable(cmd.Context(), id); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Disabled monitoring plan", id) + return nil + }, +} + +var monitoringChangeCmd = &cobra.Command{ + Use: "change ", + Short: "Switch the monitoring subscription to another plan — may BILL", + Long: `Switch the monitoring subscription to a different tariff plan (method +"change"). This may BILL; you are asked to confirm unless --yes.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := planID(args[0]) + if err != nil { + return err + } + c, err := client() + if err != nil { + return err + } + if !confirmed(cmd, fmt.Sprintf("Change monitoring subscription to plan %d? This may bill your account.", id), "Change") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + if err := c.Monitoring.Change(cmd.Context(), id); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Changed monitoring subscription to plan", id) + return nil + }, +} + +func init() { + for _, c := range []*cobra.Command{monitoringEnableCmd, monitoringDisableCmd, monitoringChangeCmd} { + c.Flags().Bool("yes", false, "skip the confirmation prompt") + } + monitoringCmd.AddCommand(monitoringPlansCmd, monitoringEnableCmd, monitoringDisableCmd, monitoringChangeCmd) +} diff --git a/internal/cmd/monitoring_test.go b/internal/cmd/monitoring_test.go new file mode 100644 index 0000000..91934a0 --- /dev/null +++ b/internal/cmd/monitoring_test.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestMonitoringCommandTree(t *testing.T) { + var monitoring *cobra.Command + for _, c := range rootCmd.Commands() { + if c.Name() == "monitoring" { + monitoring = c + } + } + if monitoring == nil { + t.Fatal("monitoring command not registered") + } + + // monitoring carries the plan/subscription commands plus the check and + // contact subgroups. + msub := subNames(monitoring) + for _, n := range []string{"plans", "enable", "disable", "change", "check", "contact"} { + if !msub[n] { + t.Errorf("monitoring is missing subcommand %q", n) + } + } + + // check carries the CRUD + toggle + read commands. + var check *cobra.Command + for _, c := range monitoring.Commands() { + if c.Name() == "check" { + check = c + } + } + if check == nil { + t.Fatal("monitoring check command not registered") + } + csub := subNames(check) + for _, n := range []string{"list", "types", "show", "create", "edit", "activate", "deactivate", "remove", "history"} { + if !csub[n] { + t.Errorf("monitoring check is missing subcommand %q", n) + } + } + + // contact carries the add/edit/remove lifecycle plus verification. + var contact *cobra.Command + for _, c := range monitoring.Commands() { + if c.Name() == "contact" { + contact = c + } + } + if contact == nil { + t.Fatal("monitoring contact command not registered") + } + ctsub := subNames(contact) + for _, n := range []string{"list", "add-email", "add-phone", "add-telegram", "edit", "remove", "verify", "verify-status"} { + if !ctsub[n] { + t.Errorf("monitoring contact is missing subcommand %q", n) + } + } +} From cae50c42295aa4340d1b719f67329cfd46910c32 Mon Sep 17 00:00:00 2001 From: sanchpet Date: Tue, 14 Jul 2026 10:06:46 +0300 Subject: [PATCH 4/5] feat: add cloud ssl command group Surface the SDK's cloud/account SSL-certificate service (client.SSL, endpoint /vps/ssl) as the top-level `ssl` group: list, order-list, download, prolong-info, autoprolong, order and remove. This is a separate SpaceWeb service from the existing `hosting ssl` group (client.VHSSL, endpoint /vh/ssl), which covers shared-hosting certificates. The two panels are distinct accounts, so they get distinct command groups; the group short-description says so. The Go identifier is cloudSSLCmd to avoid the sslCmd used by `hosting ssl`. Mutating/destructive/paid ops confirm via the shared confirm helper unless --yes: `order` states it bills before submitting a paid cert, `remove` warns it is irreversible. Assisted-By: Claude Signed-off-by: sanchpet --- internal/cmd/ssl.go | 23 +++++++ internal/cmd/ssl_autoprolong.go | 47 ++++++++++++++ internal/cmd/ssl_download.go | 103 +++++++++++++++++++++++++++++++ internal/cmd/ssl_list.go | 50 +++++++++++++++ internal/cmd/ssl_order.go | 66 ++++++++++++++++++++ internal/cmd/ssl_order_list.go | 34 ++++++++++ internal/cmd/ssl_prolong_info.go | 50 +++++++++++++++ internal/cmd/ssl_remove.go | 44 +++++++++++++ internal/cmd/ssl_test.go | 28 +++++++++ 9 files changed, 445 insertions(+) create mode 100644 internal/cmd/ssl.go create mode 100644 internal/cmd/ssl_autoprolong.go create mode 100644 internal/cmd/ssl_download.go create mode 100644 internal/cmd/ssl_list.go create mode 100644 internal/cmd/ssl_order.go create mode 100644 internal/cmd/ssl_order_list.go create mode 100644 internal/cmd/ssl_prolong_info.go create mode 100644 internal/cmd/ssl_remove.go create mode 100644 internal/cmd/ssl_test.go diff --git a/internal/cmd/ssl.go b/internal/cmd/ssl.go new file mode 100644 index 0000000..253746f --- /dev/null +++ b/internal/cmd/ssl.go @@ -0,0 +1,23 @@ +package cmd + +import "github.com/spf13/cobra" + +// cloudSSLFlagString reads a string flag, ignoring the lookup error (unset → ""). +func cloudSSLFlagString(cmd *cobra.Command, name string) string { + v, _ := cmd.Flags().GetString(name) + return v +} + +// cloudSSLCmd groups the cloud/account SSL-certificate service (SDK /vps/ssl): +// list and order certificates, download an issued archive, inspect and toggle +// prolongation, and remove a certificate. The Go identifier avoids the `sslCmd` +// used by the shared-hosting group (`hosting ssl`, SDK /vh/ssl); the two are +// distinct SpaceWeb services. +var cloudSSLCmd = &cobra.Command{ + Use: "ssl", + Short: "Manage cloud/account SSL certificates — order, download, prolong; distinct from `hosting ssl`", +} + +func init() { + rootCmd.AddCommand(cloudSSLCmd) +} diff --git a/internal/cmd/ssl_autoprolong.go b/internal/cmd/ssl_autoprolong.go new file mode 100644 index 0000000..03cc2f5 --- /dev/null +++ b/internal/cmd/ssl_autoprolong.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" +) + +var cloudSSLAutoprolongCmd = &cobra.Command{ + Use: "autoprolong ", + Short: "Enable or disable a certificate's auto-prolongation", + Long: `Toggle a certificate's auto-prolongation via the "editAutoprolong" method. + + is a certificate id (see 'sweb ssl list'); pass exactly one of --enable or +--disable.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("id must be an integer: %w", err) + } + enable, _ := cmd.Flags().GetBool("enable") + disable, _ := cmd.Flags().GetBool("disable") + if enable == disable { + return fmt.Errorf("pass exactly one of --enable or --disable") + } + res, err := c.SSL.EditAutoprolong(cmd.Context(), id, enable) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Set auto-prolongation for certificate %d to %s (%s)\n", id, yesNo(enable), res) + return nil + }, +} + +func init() { + cloudSSLAutoprolongCmd.Flags().Bool("enable", false, "enable auto-prolongation") + cloudSSLAutoprolongCmd.Flags().Bool("disable", false, "disable auto-prolongation") + cloudSSLAutoprolongCmd.MarkFlagsMutuallyExclusive("enable", "disable") + + cloudSSLCmd.AddCommand(cloudSSLAutoprolongCmd) +} diff --git a/internal/cmd/ssl_download.go b/internal/cmd/ssl_download.go new file mode 100644 index 0000000..e94cac2 --- /dev/null +++ b/internal/cmd/ssl_download.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/charmbracelet/huh" + "github.com/sanchpet/sweb-go-sdk/ssl" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var cloudSSLDownloadCmd = &cobra.Command{ + Use: "download ", + Short: "Download an issued certificate's archive files", + Long: `Download an issued certificate's files via the "download" method. + + is a certificate id (see 'sweb ssl list'); the account password is required +and read from --password or a masked prompt. The archive carries the private key +— by default the files are written to --dir (current directory); with -o json the +base64 content is emitted instead. Handle the key material accordingly.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("id must be an integer: %w", err) + } + password, err := cloudSSLResolvePassword(cmd) + if err != nil { + return err + } + files, err := c.SSL.Download(cmd.Context(), id, password) + if err != nil { + return err + } + // -o json emits the archive verbatim (base64 content included). + if viper.GetString("output") == "json" { + return render(cmd, files, nil) + } + dir, _ := cmd.Flags().GetString("dir") + if dir == "" { + dir = "." + } + for _, f := range files { + body, err := cloudSSLDecodeContent(f) + if err != nil { + return fmt.Errorf("decode %s: %w", f.Name, err) + } + path := filepath.Join(dir, filepath.Base(f.Name)) + if err := os.WriteFile(path, body, 0o600); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Wrote", path) + } + return nil + }, +} + +// cloudSSLResolvePassword returns the account password for a download: the +// --password flag when set, otherwise a masked interactive prompt. An empty +// result (non-interactive with no flag) is an error, since the API requires one. +func cloudSSLResolvePassword(cmd *cobra.Command) (string, error) { + if pw, _ := cmd.Flags().GetString("password"); pw != "" { + return pw, nil + } + var pw string + if err := huh.NewInput(). + Title("Account password"). + EchoMode(huh.EchoModePassword). + Value(&pw). + Run(); err != nil { + return "", err + } + if pw == "" { + return "", fmt.Errorf("a password is required: pass --password or enter one at the prompt") + } + return pw, nil +} + +// cloudSSLDecodeContent decodes a downloaded certificate file body, +// base64-decoding it when the mimetype declares base64 encoding and returning it +// verbatim otherwise. +func cloudSSLDecodeContent(f ssl.CertFile) ([]byte, error) { + if strings.Contains(strings.ToLower(f.Mimetype), "base64") { + return base64.StdEncoding.DecodeString(f.Content) + } + return []byte(f.Content), nil +} + +func init() { + cloudSSLDownloadCmd.Flags().String("password", "", "account password (prompted if unset)") + cloudSSLDownloadCmd.Flags().String("dir", "", "directory to write the archive files (default current)") + + cloudSSLCmd.AddCommand(cloudSSLDownloadCmd) +} diff --git a/internal/cmd/ssl_list.go b/internal/cmd/ssl_list.go new file mode 100644 index 0000000..ce88e5c --- /dev/null +++ b/internal/cmd/ssl_list.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "io" + + "github.com/sanchpet/sweb-go-sdk/ssl" + "github.com/spf13/cobra" +) + +var cloudSSLListCmd = &cobra.Command{ + Use: "list", + Short: "List the account's SSL certificates", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := client() + if err != nil { + return err + } + res, err := c.SSL.List(cmd.Context(), &ssl.ListOptions{ + Page: flagInt(cmd, "page"), + PerPage: flagInt(cmd, "per-page"), + OrderField: cloudSSLFlagString(cmd, "order-field"), + OrderDirect: cloudSSLFlagString(cmd, "order-direct"), + }) + if err != nil { + return err + } + return render(cmd, res, func(w io.Writer) { + fmt.Fprintln(w, "ID\tDOMAIN\tNAME\tSTATUS\tIP\tVALID TO\tAUTOPROLONG") + if res == nil { + return + } + for _, cert := range res.List { + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", + int64(cert.ID), cert.Domain, cert.Name, cert.Status, cert.IP, + cert.ValidTo, yesNo(cert.Autoprolong)) + } + }) + }, +} + +func init() { + cloudSSLListCmd.Flags().Int("page", 0, "1-based page number") + cloudSSLListCmd.Flags().Int("per-page", 0, "records per page") + cloudSSLListCmd.Flags().String("order-field", "", "sort field: id|valid_to|fqdn|status|ip") + cloudSSLListCmd.Flags().String("order-direct", "", "sort direction: asc|desc") + + cloudSSLCmd.AddCommand(cloudSSLListCmd) +} diff --git a/internal/cmd/ssl_order.go b/internal/cmd/ssl_order.go new file mode 100644 index 0000000..7b9ec48 --- /dev/null +++ b/internal/cmd/ssl_order.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/sanchpet/sweb-go-sdk/ssl" + "github.com/spf13/cobra" +) + +var cloudSSLOrderCmd = &cobra.Command{ + Use: "order ", + Short: "Order a certificate — bills the account", + Long: `Order a certificate via the "orderSubmit" method. + + is the fully-qualified domain to cover, is a product +id from 'sweb ssl order-list', and is the confirmation mailbox. +Flags carry the optional order fields (--person-id, --company-link, --subdomain, +--autoprolong, --old-certificate-id, --from-prolongation). + +This orders a PAID certificate and BILLS the account — you are asked to confirm +unless --yes is given.`, + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + domain := args[0] + certificateID, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("certificate-id must be an integer: %w", err) + } + confirmMail := args[2] + opts := &ssl.OrderSubmitOptions{ + PersonID: flagInt(cmd, "person-id"), + CompanyPageLink: cloudSSLFlagString(cmd, "company-link"), + Subdomain: cloudSSLFlagString(cmd, "subdomain"), + OldCertificateID: flagInt(cmd, "old-certificate-id"), + } + opts.Autoprolong, _ = cmd.Flags().GetBool("autoprolong") + opts.FromProlongation, _ = cmd.Flags().GetBool("from-prolongation") + if !confirmed(cmd, fmt.Sprintf("Order a paid certificate for %s? This bills the account.", domain), "Order") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + res, err := c.SSL.OrderSubmit(cmd.Context(), domain, certificateID, confirmMail, opts) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Ordered a certificate for %s (%s)\n", domain, res) + return nil + }, +} + +func init() { + cloudSSLOrderCmd.Flags().Int("person-id", 0, "domain-person id") + cloudSSLOrderCmd.Flags().String("company-link", "", `"about the company" URL (EV/OV)`) + cloudSSLOrderCmd.Flags().String("subdomain", "", "subdomain the certificate covers") + cloudSSLOrderCmd.Flags().Bool("autoprolong", false, "enable auto-prolongation on the new certificate") + cloudSSLOrderCmd.Flags().Int("old-certificate-id", 0, "prior domain-person id (prolongation)") + cloudSSLOrderCmd.Flags().Bool("from-prolongation", false, "order originates from a prolongation confirmation") + cloudSSLOrderCmd.Flags().Bool("yes", false, "skip the confirmation prompt") + + cloudSSLCmd.AddCommand(cloudSSLOrderCmd) +} diff --git a/internal/cmd/ssl_order_list.go b/internal/cmd/ssl_order_list.go new file mode 100644 index 0000000..50e3171 --- /dev/null +++ b/internal/cmd/ssl_order_list.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" +) + +var cloudSSLOrderListCmd = &cobra.Command{ + Use: "order-list", + Short: "List the certificate products available for order", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + c, err := client() + if err != nil { + return err + } + list, err := c.SSL.OrderList(cmd.Context()) + if err != nil { + return err + } + return render(cmd, list, func(w io.Writer) { + fmt.Fprintln(w, "ID\tNAME\tTYPE\tADVANTAGE") + for _, o := range list { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", o.ID, o.Name, o.Type, o.AdvantageText) + } + }) + }, +} + +func init() { + cloudSSLCmd.AddCommand(cloudSSLOrderListCmd) +} diff --git a/internal/cmd/ssl_prolong_info.go b/internal/cmd/ssl_prolong_info.go new file mode 100644 index 0000000..100bf96 --- /dev/null +++ b/internal/cmd/ssl_prolong_info.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "io" + "strconv" + + "github.com/spf13/cobra" +) + +var cloudSSLProlongInfoCmd = &cobra.Command{ + Use: "prolong-info ", + Short: "Show the prolongation options for a certificate", + Long: `Show a certificate's prolongation options via the "getProlongInfo" method. + + is a certificate id (see 'sweb ssl list'); the result lists the per-period +prices and the product ids.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("id must be an integer: %w", err) + } + info, err := c.SSL.ProlongInfo(cmd.Context(), id) + if err != nil { + return err + } + return render(cmd, info, func(w io.Writer) { + if info == nil { + fmt.Fprintln(w, "no prolongation offered") + return + } + fmt.Fprintf(w, "TITLE\t%s\n", info.Title) + fmt.Fprintf(w, "CURRENT ID\t%d\n", int64(info.CurrentCertificateID)) + fmt.Fprintf(w, "FREE\t%s\n", yesNo(info.IsFreeCertificate)) + fmt.Fprintln(w, "PERIOD(MONTHS)\tPRICE\tPRODUCT ID") + for period, price := range info.Prices { + fmt.Fprintf(w, "%s\t%.2f\t%s\n", period, float64(price), info.IDs[period]) + } + }) + }, +} + +func init() { + cloudSSLCmd.AddCommand(cloudSSLProlongInfoCmd) +} diff --git a/internal/cmd/ssl_remove.go b/internal/cmd/ssl_remove.go new file mode 100644 index 0000000..6ad9bd7 --- /dev/null +++ b/internal/cmd/ssl_remove.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" +) + +var cloudSSLRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Delete a certificate — destructive", + Long: `Delete a certificate via the "removeCertificate" method. + + is a certificate id (see 'sweb ssl list'). This is DESTRUCTIVE. You are +asked to confirm unless --yes is given.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + id, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("id must be an integer: %w", err) + } + if !confirmed(cmd, fmt.Sprintf("Remove certificate %d? This cannot be undone.", id), "Remove") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + res, err := c.SSL.RemoveCertificate(cmd.Context(), id) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Removed certificate %d (%s)\n", id, res) + return nil + }, +} + +func init() { + cloudSSLRemoveCmd.Flags().Bool("yes", false, "skip the confirmation prompt") + + cloudSSLCmd.AddCommand(cloudSSLRemoveCmd) +} diff --git a/internal/cmd/ssl_test.go b/internal/cmd/ssl_test.go new file mode 100644 index 0000000..02bc398 --- /dev/null +++ b/internal/cmd/ssl_test.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// TestCloudSSLCommandTree asserts the cloud/account SSL group (`sweb ssl`, +// SDK /vps/ssl) is registered on the root with its full leaf set — distinct +// from the shared-hosting `hosting ssl` group (SDK /vh/ssl). +func TestCloudSSLCommandTree(t *testing.T) { + var sslGroup *cobra.Command + for _, c := range rootCmd.Commands() { + if c.Name() == "ssl" { + sslGroup = c + } + } + if sslGroup == nil { + t.Fatal("ssl command not registered on root") + } + sub := subNames(sslGroup) + for _, n := range []string{"list", "order-list", "download", "prolong-info", "autoprolong", "order", "remove"} { + if !sub[n] { + t.Errorf("ssl is missing subcommand %q", n) + } + } +} From 4b6f592b5afc9b92927865c55a81930fde645b63 Mon Sep 17 00:00:00 2001 From: sanchpet Date: Tue, 14 Jul 2026 10:09:10 +0300 Subject: [PATCH 5/5] feat: bind cloud-tier groups to profiles + assert in tree Register balancer/dbaas/monitoring/ssl as profile-bindable API command groups and add them to the root command-tree assertion. Assisted-By: Claude Signed-off-by: sanchpet --- internal/cmd/cmd_test.go | 2 +- internal/cmd/profile.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index a0772d2..cc7425d 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -16,7 +16,7 @@ func subNames(c *cobra.Command) map[string]bool { func TestCommandTree(t *testing.T) { root := subNames(rootCmd) - for _, n := range []string{"configure", "vps", "token", "dns", "domains", "profile"} { + for _, n := range []string{"configure", "vps", "token", "dns", "domains", "profile", "balancer", "dbaas", "monitoring", "ssl"} { if !root[n] { t.Errorf("root is missing subcommand %q", n) } diff --git a/internal/cmd/profile.go b/internal/cmd/profile.go index b805d6b..5a86fa1 100644 --- a/internal/cmd/profile.go +++ b/internal/cmd/profile.go @@ -16,7 +16,7 @@ const defaultProfile = "default" // apiCommandGroups are the top-level command groups a profile can be bound to // (SpaceWeb serves both panels from one api.sweb.ru, so a binding only changes // which credentials are used, never the endpoint). -var apiCommandGroups = []string{"vps", "dns", "domains", "hosting", "pay", "tariff"} +var apiCommandGroups = []string{"vps", "dns", "domains", "hosting", "pay", "tariff", "balancer", "dbaas", "monitoring", "ssl"} // activeProfile is the profile resolved for the running command. It is set once // by the root PersistentPreRunE before any RunE executes, so client() need not