Compare commits
4
Commits
master
..
984fdfdb79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984fdfdb79 | ||
|
|
03f089c9c3 | ||
|
|
5a75b3e7cd | ||
|
|
c6d945cfcc |
@@ -1,6 +1,5 @@
|
|||||||
# Binaries
|
# Binaries
|
||||||
ry
|
ry
|
||||||
/cli
|
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## Safety Rules
|
|
||||||
- **Branch safety**: If the current branch is NOT a dev branch (name does NOT contain `feat`, `refactor`, `docs`, `fix`), do NOT commit directly — warn the user first.
|
|
||||||
- **Commit SOP**: Before every `git commit`, run `git branch --show-current`. If the branch is not a dev branch, ask the user for a branch name, create it, and commit there.
|
|
||||||
- **Branch naming**: Dev branches must contain `feat`, `refactor`, `docs`, or `fix`.
|
|
||||||
- **CHANGELOG**: Core changes → `docs/CHANGELOG.md` only. Higher versions on TOP, lower versions below.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# ry — Multi-Cloud CLI
|
|
||||||
|
|
||||||
A `gh`-style CLI for managing cloud resources across providers.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go install git.yoresee.cc/nightstar/ry/cmd/ry@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ry provider add rainyun
|
|
||||||
# Enter your API key when prompted
|
|
||||||
|
|
||||||
ry server list
|
|
||||||
ry server start <id>
|
|
||||||
ry server vnc <id>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
- `ry provider` — manage cloud providers (add, use, list)
|
|
||||||
- `ry server` — manage cloud servers (list, get, start, stop, reboot, reinstall, reset-password, vnc)
|
|
||||||
- `ry domain` — manage domains and DNS records
|
|
||||||
- `ry storage` — manage object storage and buckets
|
|
||||||
- `ry billing` — view orders
|
|
||||||
- `ry config` — manage configuration (show, set, path)
|
|
||||||
- `ry completion` — shell completion for bash/zsh/fish
|
|
||||||
|
|
||||||
## Global Flags
|
|
||||||
|
|
||||||
| Flag | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `-p, --provider` | Override cloud provider |
|
|
||||||
| `-o, --output` | Output format: table, json, yaml |
|
|
||||||
| `--raw` | Raw API response |
|
|
||||||
| `--debug` | Debug output |
|
|
||||||
|
|
||||||
## Supported Providers
|
|
||||||
|
|
||||||
- **雨云 (Rainyun)** — full support
|
|
||||||
- More coming soon
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
`~/.config/ry/config.toml`
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/output"
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/provider"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Cmd(prov *provider.Provider, out **output.Printer) *cobra.Command {
|
|
||||||
billingCmd := &cobra.Command{
|
|
||||||
Use: "billing",
|
|
||||||
Short: "Billing and orders",
|
|
||||||
}
|
|
||||||
|
|
||||||
ordersCmd := &cobra.Command{
|
|
||||||
Use: "orders",
|
|
||||||
Short: "List orders",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Billing()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("billing not supported by current provider")
|
|
||||||
}
|
|
||||||
orders, err := svc.Orders(cmd.Context())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return (*out).Print(orders)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
billingCmd.AddCommand(ordersCmd)
|
|
||||||
|
|
||||||
return billingCmd
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package configcmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/config"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Cmd(cfg *config.Config, cfgPath string) *cobra.Command {
|
|
||||||
configCmd := &cobra.Command{
|
|
||||||
Use: "config",
|
|
||||||
Short: "Manage ry configuration",
|
|
||||||
}
|
|
||||||
|
|
||||||
configShowCmd := &cobra.Command{
|
|
||||||
Use: "show",
|
|
||||||
Short: "Show current configuration",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
fmt.Printf("Config file: %s\n", cfgPath)
|
|
||||||
fmt.Printf("Current provider: %s\n", cfg.CurrentProvider)
|
|
||||||
fmt.Printf("Output format: %s\n", cfg.Output)
|
|
||||||
fmt.Println("Providers:")
|
|
||||||
for name, pc := range cfg.Providers {
|
|
||||||
masked := pc.APIKey
|
|
||||||
if len(masked) > 4 {
|
|
||||||
masked = masked[:2] + strings.Repeat("*", len(masked)-4) + masked[len(masked)-2:]
|
|
||||||
}
|
|
||||||
marker := " "
|
|
||||||
if name == cfg.CurrentProvider {
|
|
||||||
marker = "*"
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s %s: api_key=%s\n", marker, name, masked)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
configSetCmd := &cobra.Command{
|
|
||||||
Use: "set <key> <value>",
|
|
||||||
Short: "Set a config value",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
key, value := args[0], args[1]
|
|
||||||
switch key {
|
|
||||||
case "output":
|
|
||||||
cfg.Output = value
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unknown config key: %s", key)
|
|
||||||
}
|
|
||||||
if err := config.Save(cfgPath, cfg); err != nil {
|
|
||||||
return fmt.Errorf("save config: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Printf("Set %s = %s\n", key, value)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
configPathCmd := &cobra.Command{
|
|
||||||
Use: "path",
|
|
||||||
Short: "Print config file path",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
fmt.Println(cfgPath)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
configCmd.AddCommand(configShowCmd)
|
|
||||||
configCmd.AddCommand(configSetCmd)
|
|
||||||
configCmd.AddCommand(configPathCmd)
|
|
||||||
|
|
||||||
return configCmd
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/model"
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/output"
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/provider"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Cmd(prov *provider.Provider, out **output.Printer) *cobra.Command {
|
|
||||||
domainCmd := &cobra.Command{
|
|
||||||
Use: "domain",
|
|
||||||
Short: "Manage domains",
|
|
||||||
}
|
|
||||||
|
|
||||||
domainListCmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List domains",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Domain()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("domain management not supported by current provider")
|
|
||||||
}
|
|
||||||
domains, err := svc.List(cmd.Context())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return (*out).Print(domains)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
dnsCmd := &cobra.Command{
|
|
||||||
Use: "dns <domain>",
|
|
||||||
Short: "Manage DNS records",
|
|
||||||
Args: cobra.MinimumNArgs(1),
|
|
||||||
}
|
|
||||||
|
|
||||||
dnsListCmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List DNS records",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Domain()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("domain management not supported")
|
|
||||||
}
|
|
||||||
records, err := svc.DNSList(cmd.Context(), args[0])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return (*out).Print(records)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
dnsAddCmd := &cobra.Command{
|
|
||||||
Use: "add",
|
|
||||||
Short: "Add a DNS record",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Domain()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("domain management not supported")
|
|
||||||
}
|
|
||||||
recType, _ := cmd.Flags().GetString("type")
|
|
||||||
name, _ := cmd.Flags().GetString("name")
|
|
||||||
value, _ := cmd.Flags().GetString("value")
|
|
||||||
ttl, _ := cmd.Flags().GetInt("ttl")
|
|
||||||
return svc.DNSAdd(cmd.Context(), args[0],
|
|
||||||
model.DNSRecord{Type: recType, Name: name, Value: value, TTL: ttl})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
dnsDeleteCmd := &cobra.Command{
|
|
||||||
Use: "delete <record-id>",
|
|
||||||
Short: "Delete a DNS record",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Domain()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("domain management not supported")
|
|
||||||
}
|
|
||||||
return svc.DNSDelete(cmd.Context(), args[0], args[1])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
dnsAddCmd.Flags().String("type", "A", "Record type (A, CNAME, TXT)")
|
|
||||||
dnsAddCmd.Flags().String("name", "@", "Record name")
|
|
||||||
dnsAddCmd.Flags().String("value", "", "Record value")
|
|
||||||
dnsAddCmd.Flags().Int("ttl", 600, "TTL in seconds")
|
|
||||||
dnsAddCmd.MarkFlagRequired("value")
|
|
||||||
|
|
||||||
dnsCmd.AddCommand(dnsListCmd)
|
|
||||||
dnsCmd.AddCommand(dnsAddCmd)
|
|
||||||
dnsCmd.AddCommand(dnsDeleteCmd)
|
|
||||||
domainCmd.AddCommand(domainListCmd)
|
|
||||||
domainCmd.AddCommand(dnsCmd)
|
|
||||||
|
|
||||||
return domainCmd
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package providercmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/config"
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/provider"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Cmd(cfg *config.Config, cfgPath string) *cobra.Command {
|
|
||||||
providerCmd := &cobra.Command{
|
|
||||||
Use: "provider",
|
|
||||||
Short: "Manage cloud providers",
|
|
||||||
}
|
|
||||||
|
|
||||||
providerListCmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List configured providers",
|
|
||||||
Annotations: map[string]string{"skipProvider": "true"},
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
current := ""
|
|
||||||
if cfg != nil {
|
|
||||||
current = cfg.CurrentProvider
|
|
||||||
}
|
|
||||||
for _, name := range provider.List() {
|
|
||||||
marker := " "
|
|
||||||
if name == current {
|
|
||||||
marker = "*"
|
|
||||||
}
|
|
||||||
fmt.Printf("%s %s\n", marker, name)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
providerUseCmd := &cobra.Command{
|
|
||||||
Use: "use <name>",
|
|
||||||
Short: "Switch the current cloud provider",
|
|
||||||
Annotations: map[string]string{"skipProvider": "true"},
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
if _, ok := cfg.Providers[name]; !ok {
|
|
||||||
return fmt.Errorf("provider %q not configured — run 'ry provider add %s' first", name, name)
|
|
||||||
}
|
|
||||||
cfg.CurrentProvider = name
|
|
||||||
if err := config.Save(cfgPath, cfg); err != nil {
|
|
||||||
return fmt.Errorf("save config: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Printf("Switched to provider %q\n", name)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
providerAddCmd := &cobra.Command{
|
|
||||||
Use: "add <name>",
|
|
||||||
Short: "Add a new cloud provider",
|
|
||||||
Annotations: map[string]string{"skipProvider": "true"},
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
fmt.Printf("Adding provider %q...\n", name)
|
|
||||||
fmt.Print("Enter API Key: ")
|
|
||||||
var apiKey string
|
|
||||||
fmt.Scanln(&apiKey)
|
|
||||||
if apiKey == "" {
|
|
||||||
return fmt.Errorf("API Key is required")
|
|
||||||
}
|
|
||||||
if cfg.Providers == nil {
|
|
||||||
cfg.Providers = make(map[string]config.ProviderConfig)
|
|
||||||
}
|
|
||||||
cfg.Providers[name] = config.ProviderConfig{APIKey: apiKey}
|
|
||||||
if cfg.CurrentProvider == "" {
|
|
||||||
cfg.CurrentProvider = name
|
|
||||||
}
|
|
||||||
if err := config.Save(cfgPath, cfg); err != nil {
|
|
||||||
return fmt.Errorf("save config: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Printf("Provider %q added successfully\n", name)
|
|
||||||
if cfg.CurrentProvider == name {
|
|
||||||
fmt.Printf("Set as current provider\n")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
providerCmd.AddCommand(providerListCmd)
|
|
||||||
providerCmd.AddCommand(providerUseCmd)
|
|
||||||
providerCmd.AddCommand(providerAddCmd)
|
|
||||||
|
|
||||||
return providerCmd
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/output"
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/provider"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Cmd(prov *provider.Provider, out **output.Printer, raw *bool) *cobra.Command {
|
|
||||||
serverCmd := &cobra.Command{
|
|
||||||
Use: "server",
|
|
||||||
Short: "Manage cloud servers",
|
|
||||||
}
|
|
||||||
|
|
||||||
serverListCmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List servers",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Server()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("server management not supported by current provider")
|
|
||||||
}
|
|
||||||
servers, err := svc.List(cmd.Context())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if *raw {
|
|
||||||
var r []any
|
|
||||||
for _, s := range servers {
|
|
||||||
r = append(r, s.Raw)
|
|
||||||
}
|
|
||||||
return (*out).Print(r)
|
|
||||||
}
|
|
||||||
return (*out).Print(servers)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
serverGetCmd := &cobra.Command{
|
|
||||||
Use: "get <id>",
|
|
||||||
Short: "Get server details",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Server()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("server management not supported")
|
|
||||||
}
|
|
||||||
s, err := svc.Get(cmd.Context(), args[0])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if *raw {
|
|
||||||
return (*out).Print(s.Raw)
|
|
||||||
}
|
|
||||||
return (*out).Print(s)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
serverStartCmd := &cobra.Command{
|
|
||||||
Use: "start <id>",
|
|
||||||
Short: "Start a server",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Server()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("server management not supported")
|
|
||||||
}
|
|
||||||
if err := svc.Start(cmd.Context(), args[0]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("Server %s started\n", args[0])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
serverStopCmd := &cobra.Command{
|
|
||||||
Use: "stop <id>",
|
|
||||||
Short: "Stop a server",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Server()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("server management not supported")
|
|
||||||
}
|
|
||||||
if err := svc.Stop(cmd.Context(), args[0]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("Server %s stopped\n", args[0])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
serverRebootCmd := &cobra.Command{
|
|
||||||
Use: "reboot <id>",
|
|
||||||
Short: "Reboot a server",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Server()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("server management not supported")
|
|
||||||
}
|
|
||||||
if err := svc.Reboot(cmd.Context(), args[0]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("Server %s rebooted\n", args[0])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
serverReinstallCmd := &cobra.Command{
|
|
||||||
Use: "reinstall <id>",
|
|
||||||
Short: "Reinstall server OS",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Server()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("server management not supported")
|
|
||||||
}
|
|
||||||
osTemplate, _ := cmd.Flags().GetString("os")
|
|
||||||
if osTemplate == "" {
|
|
||||||
return fmt.Errorf("--os flag is required (e.g. --os ubuntu-22.04)")
|
|
||||||
}
|
|
||||||
if err := svc.Reinstall(cmd.Context(), args[0], osTemplate); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("Server %s reinstalling with %s\n", args[0], osTemplate)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
serverResetPasswordCmd := &cobra.Command{
|
|
||||||
Use: "reset-password <id>",
|
|
||||||
Short: "Reset server password",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Server()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("server management not supported")
|
|
||||||
}
|
|
||||||
if err := svc.ResetPassword(cmd.Context(), args[0]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("Password reset for server %s\n", args[0])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
serverVNCCmd := &cobra.Command{
|
|
||||||
Use: "vnc <id>",
|
|
||||||
Short: "Get VNC connection URL",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Server()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("server management not supported")
|
|
||||||
}
|
|
||||||
url, err := svc.GetVNC(cmd.Context(), args[0])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Println(url)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
serverReinstallCmd.Flags().String("os", "", "OS template name (required)")
|
|
||||||
serverReinstallCmd.MarkFlagRequired("os")
|
|
||||||
|
|
||||||
serverCmd.AddCommand(serverListCmd)
|
|
||||||
serverCmd.AddCommand(serverGetCmd)
|
|
||||||
serverCmd.AddCommand(serverStartCmd)
|
|
||||||
serverCmd.AddCommand(serverStopCmd)
|
|
||||||
serverCmd.AddCommand(serverRebootCmd)
|
|
||||||
serverCmd.AddCommand(serverReinstallCmd)
|
|
||||||
serverCmd.AddCommand(serverResetPasswordCmd)
|
|
||||||
serverCmd.AddCommand(serverVNCCmd)
|
|
||||||
|
|
||||||
return serverCmd
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/output"
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/provider"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Cmd(prov *provider.Provider, out **output.Printer) *cobra.Command {
|
|
||||||
storageCmd := &cobra.Command{
|
|
||||||
Use: "storage",
|
|
||||||
Short: "Manage object storage",
|
|
||||||
}
|
|
||||||
|
|
||||||
storageListCmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List storage instances",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Storage()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("storage management not supported by current provider")
|
|
||||||
}
|
|
||||||
instances, err := svc.List(cmd.Context())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return (*out).Print(instances)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
bucketCmd := &cobra.Command{
|
|
||||||
Use: "bucket <instance-id>",
|
|
||||||
Short: "Manage buckets",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
}
|
|
||||||
|
|
||||||
bucketListCmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List buckets",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Storage()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("storage management not supported")
|
|
||||||
}
|
|
||||||
buckets, err := svc.BucketList(cmd.Context(), args[0])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return (*out).Print(buckets)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
bucketCreateCmd := &cobra.Command{
|
|
||||||
Use: "create <name>",
|
|
||||||
Short: "Create a bucket",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
svc := (*prov).Storage()
|
|
||||||
if svc == nil {
|
|
||||||
return fmt.Errorf("storage management not supported")
|
|
||||||
}
|
|
||||||
if err := svc.BucketCreate(cmd.Context(), args[0], args[1]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("Bucket %q created\n", args[1])
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
bucketCmd.AddCommand(bucketListCmd)
|
|
||||||
bucketCmd.AddCommand(bucketCreateCmd)
|
|
||||||
storageCmd.AddCommand(storageListCmd)
|
|
||||||
storageCmd.AddCommand(bucketCmd)
|
|
||||||
|
|
||||||
return storageCmd
|
|
||||||
}
|
|
||||||
-126
@@ -1,126 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/config"
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/output"
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/provider"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/cmd/cli/commands/billing"
|
|
||||||
"git.yoresee.cc/nightstar/ry/cmd/cli/commands/configcmd"
|
|
||||||
"git.yoresee.cc/nightstar/ry/cmd/cli/commands/domain"
|
|
||||||
"git.yoresee.cc/nightstar/ry/cmd/cli/commands/providercmd"
|
|
||||||
"git.yoresee.cc/nightstar/ry/cmd/cli/commands/server"
|
|
||||||
"git.yoresee.cc/nightstar/ry/cmd/cli/commands/storage"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
cfg *config.Config
|
|
||||||
cfgPath string
|
|
||||||
prov provider.Provider
|
|
||||||
out *output.Printer
|
|
||||||
flagProvider string
|
|
||||||
flagOutput string
|
|
||||||
flagRaw bool
|
|
||||||
flagVerbose bool
|
|
||||||
)
|
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
|
||||||
Use: "ry",
|
|
||||||
Short: "Multi-cloud CLI — manage cloud resources from your terminal",
|
|
||||||
Long: `ry is a multi-cloud CLI that lets you manage cloud resources from your terminal.
|
|
||||||
|
|
||||||
Use "ry [command] --help" for more information about a command.`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return cmd.Help()
|
|
||||||
},
|
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
var err error
|
|
||||||
cfg, err = config.Load(cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("load config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Annotations != nil && cmd.Annotations["skipProvider"] == "true" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
providerName := cfg.CurrentProvider
|
|
||||||
if flagProvider != "" {
|
|
||||||
providerName = flagProvider
|
|
||||||
}
|
|
||||||
if env := os.Getenv("RY_PROVIDER"); env != "" {
|
|
||||||
providerName = env
|
|
||||||
}
|
|
||||||
|
|
||||||
if providerName == "" {
|
|
||||||
return fmt.Errorf("no provider configured — run 'ry provider add <name>' first")
|
|
||||||
}
|
|
||||||
|
|
||||||
pc := cfg.Providers[providerName]
|
|
||||||
prov, err = provider.New(providerName, provider.ProviderConfig{
|
|
||||||
APIKey: pc.APIKey,
|
|
||||||
AccessKeyID: pc.AccessKeyID,
|
|
||||||
AccessKeySecret: pc.AccessKeySecret,
|
|
||||||
Endpoint: pc.Endpoint,
|
|
||||||
Debug: flagVerbose,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
outputFormat := cfg.Output
|
|
||||||
if flagOutput != "" {
|
|
||||||
outputFormat = flagOutput
|
|
||||||
}
|
|
||||||
if flagRaw {
|
|
||||||
outputFormat = "raw"
|
|
||||||
}
|
|
||||||
out = output.New(outputFormat, os.Stdout)
|
|
||||||
if flagVerbose {
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] provider: %s\n", providerName)
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] config: %s\n", cfgPath)
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] output: %s\n", outputFormat)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cfgPath = config.DefaultPath()
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&flagProvider, "provider", "p", "", "Cloud provider to use")
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "", "Output format: table, json, yaml, raw")
|
|
||||||
rootCmd.PersistentFlags().BoolVar(&flagRaw, "raw", false, "Output raw API response")
|
|
||||||
rootCmd.PersistentFlags().BoolVarP(&flagVerbose, "verbose", "v", false, "Enable verbose output")
|
|
||||||
|
|
||||||
completionCmd := &cobra.Command{
|
|
||||||
Use: "completion [bash|zsh|fish]",
|
|
||||||
Short: "Generate shell completion script",
|
|
||||||
Annotations: map[string]string{"skipProvider": "true"},
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
switch args[0] {
|
|
||||||
case "bash":
|
|
||||||
return rootCmd.GenBashCompletion(os.Stdout)
|
|
||||||
case "zsh":
|
|
||||||
return rootCmd.GenZshCompletion(os.Stdout)
|
|
||||||
case "fish":
|
|
||||||
return rootCmd.GenFishCompletion(os.Stdout, true)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported shell: %s", args[0])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
rootCmd.AddCommand(completionCmd)
|
|
||||||
|
|
||||||
rootCmd.AddCommand(configcmd.Cmd(cfg, cfgPath))
|
|
||||||
rootCmd.AddCommand(providercmd.Cmd(cfg, cfgPath))
|
|
||||||
rootCmd.AddCommand(server.Cmd(&prov, &out, &flagRaw))
|
|
||||||
rootCmd.AddCommand(storage.Cmd(&prov, &out))
|
|
||||||
rootCmd.AddCommand(domain.Cmd(&prov, &out))
|
|
||||||
rootCmd.AddCommand(billing.Cmd(&prov, &out))
|
|
||||||
}
|
|
||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
_ "git.yoresee.cc/nightstar/ry/internal/provider/rainyun"
|
"git.yoresee.cc/nightstar/ry/internal/cmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
# Verbose/Debug Mode Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** Wire up `--debug`/`--verbose` flags to print HTTP request details and runtime info to stderr.
|
|
||||||
|
|
||||||
**Architecture:** Add `Debug bool` to `ProviderConfig`, pass it through to the HTTP client. Client's `do()` method logs request/response when debug is on. Root command prints runtime info in `PersistentPreRunE`.
|
|
||||||
|
|
||||||
**Tech Stack:** Go, cobra, net/http
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- All debug output goes to `os.Stderr`
|
|
||||||
- `--verbose` is an alias for `--debug` (both set `flagDebug`)
|
|
||||||
- Debug prefix: `[debug]`
|
|
||||||
- Existing tests must pass after each task
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Add Debug field to ProviderConfig
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/provider/provider.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces: `ProviderConfig.Debug bool` — consumed by Task 3 and Task 4
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add Debug field**
|
|
||||||
|
|
||||||
```go
|
|
||||||
// internal/provider/provider.go — add to ProviderConfig
|
|
||||||
type ProviderConfig struct {
|
|
||||||
APIKey string
|
|
||||||
AccessKeyID string
|
|
||||||
AccessKeySecret string
|
|
||||||
Endpoint string
|
|
||||||
Debug bool
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify build**
|
|
||||||
|
|
||||||
Run: `go build ./...`
|
|
||||||
Expected: PASS (field addition is backward-compatible)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/provider/provider.go
|
|
||||||
git commit -m "feat: add Debug field to ProviderConfig"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Update client to accept and use debug flag
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/provider/rainyun/client.go`
|
|
||||||
- Modify: `internal/provider/rainyun/client_test.go` — update `newClient` calls
|
|
||||||
- Modify: `internal/provider/rainyun/server_test.go` — update `newClient` calls
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `debug bool` parameter
|
|
||||||
- Produces: `newClient(endpoint, apiKey string, debug bool) *client`
|
|
||||||
- Produces: stderr debug output with `[debug]` prefix
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update client struct and newClient**
|
|
||||||
|
|
||||||
```go
|
|
||||||
// internal/provider/rainyun/client.go
|
|
||||||
|
|
||||||
type client struct {
|
|
||||||
httpClient *http.Client
|
|
||||||
baseURL string
|
|
||||||
apiKey string
|
|
||||||
debug bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newClient(endpoint, apiKey string, debug bool) *client {
|
|
||||||
if endpoint == "" {
|
|
||||||
endpoint = defaultEndpoint
|
|
||||||
}
|
|
||||||
return &client{
|
|
||||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
baseURL: endpoint,
|
|
||||||
apiKey: apiKey,
|
|
||||||
debug: debug,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add debug logging to do()**
|
|
||||||
|
|
||||||
Add to top of `client.go` imports: `"os"`, `"time"` (time already imported).
|
|
||||||
|
|
||||||
In `do()`, after building the URL and before creating the request, add:
|
|
||||||
|
|
||||||
```go
|
|
||||||
if c.debug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] > %s %s\n", method, u)
|
|
||||||
if body != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] %s\n", bodyReader.(*bytes.Reader). // wrong
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Actually, the body bytes are already in `b` at that point. Better approach — log after marshaling:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (c *client) do(ctx context.Context, method, path string, body, result any, query map[string]string) error {
|
|
||||||
u := c.baseURL + path
|
|
||||||
|
|
||||||
if len(query) > 0 {
|
|
||||||
params := url.Values{}
|
|
||||||
for k, v := range query {
|
|
||||||
params.Set(k, v)
|
|
||||||
}
|
|
||||||
u += "?" + params.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
var bodyReader io.Reader
|
|
||||||
var bodyBytes []byte
|
|
||||||
if body != nil {
|
|
||||||
b, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal request: %w", err)
|
|
||||||
}
|
|
||||||
bodyBytes = b
|
|
||||||
bodyReader = bytes.NewReader(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.debug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] > %s %s\n", method, u)
|
|
||||||
if bodyBytes != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] %s\n", bodyBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, u, bodyReader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("X-Api-Key", c.apiKey)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.debug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] < %d %s %s\n", resp.StatusCode, http.StatusText(resp.StatusCode), time.Since(start).Round(time.Millisecond))
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] %s\n", respBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... rest unchanged
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update test calls to newClient**
|
|
||||||
|
|
||||||
In `client_test.go`, change all `newClient(srv.URL, "sk-test")` to `newClient(srv.URL, "sk-test", false)`.
|
|
||||||
|
|
||||||
In `server_test.go`, change all `newClient(srv.URL, "sk-test")` to `newClient(srv.URL, "sk-test", false)`.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests**
|
|
||||||
|
|
||||||
Run: `go test ./internal/provider/rainyun/...`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/provider/rainyun/
|
|
||||||
git commit -m "feat: HTTP debug logging in client.do()"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Pass debug flag from provider to client
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/provider/rainyun/rainyun.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `ProviderConfig.Debug` (from Task 1)
|
|
||||||
- Consumes: `newClient(endpoint, apiKey string, debug bool)` (from Task 2)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update New()**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func New(cfg provider.ProviderConfig) (provider.Provider, error) {
|
|
||||||
c := newClient(cfg.Endpoint, cfg.APIKey, cfg.Debug)
|
|
||||||
// ... rest unchanged
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests**
|
|
||||||
|
|
||||||
Run: `go test ./internal/provider/rainyun/...`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/provider/rainyun/rainyun.go
|
|
||||||
git commit -m "feat: pass Debug flag from config to client"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Wire --verbose alias and runtime info in root
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `cmd/cli/root.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `flagDebug` (existing, now wired)
|
|
||||||
- Consumes: `ProviderConfig.Debug` (from Task 1)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add --verbose alias**
|
|
||||||
|
|
||||||
In `init()`, add after the `--debug` flag registration:
|
|
||||||
|
|
||||||
```go
|
|
||||||
rootCmd.PersistentFlags().BoolVarP(&flagDebug, "verbose", "v", false, "Enable verbose/debug output")
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: cobra doesn't support two flags binding to the same var directly. Instead, use `MarkHidden` or just register `--verbose` as the primary and `--debug` as hidden alias. Simplest approach:
|
|
||||||
|
|
||||||
```go
|
|
||||||
rootCmd.PersistentFlags().BoolVarP(&flagDebug, "verbose", "v", false, "Enable verbose/debug output (same as --debug)")
|
|
||||||
rootCmd.PersistentFlags().BoolVar(&flagDebug, "debug", false, "Enable debug output (same as --verbose)")
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Wire flagDebug into ProviderConfig**
|
|
||||||
|
|
||||||
In `PersistentPreRunE`, after creating `pc`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
prov, err = provider.New(providerName, provider.ProviderConfig{
|
|
||||||
APIKey: pc.APIKey,
|
|
||||||
AccessKeyID: pc.AccessKeyID,
|
|
||||||
AccessKeySecret: pc.AccessKeySecret,
|
|
||||||
Endpoint: pc.Endpoint,
|
|
||||||
Debug: flagDebug,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Print runtime info when debug**
|
|
||||||
|
|
||||||
At the end of `PersistentPreRunE`, after creating `out`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
if flagDebug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] provider: %s\n", providerName)
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] config: %s\n", cfgPath)
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] output: %s\n", outputFormat)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run full test suite**
|
|
||||||
|
|
||||||
Run: `go test ./...`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add cmd/cli/root.go
|
|
||||||
git commit -m "feat: wire --verbose/--debug flags with runtime info"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Manual verification
|
|
||||||
|
|
||||||
- [ ] **Step 1: Build and test verbose output**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go build -o ry ./cmd/cli/
|
|
||||||
./ry --verbose server list 2>&1 | head -20
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected stderr output:
|
|
||||||
```
|
|
||||||
[debug] provider: rainyun
|
|
||||||
[debug] config: /path/to/config.json
|
|
||||||
[debug] output: table
|
|
||||||
[debug] > GET /product/rcs/?Page=1&PageSize=999&PerPage=999
|
|
||||||
[debug] < 200 OK 123ms
|
|
||||||
[debug] {"code":200,"data":{...}}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify --debug works too**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./ry --debug server list 2>&1 | head -5
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: same `[debug]` output
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify no debug output without flag**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./ry server list 2>/dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: clean table output, no debug lines
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
# 雨云 (Rainyun) Provider
|
|
||||||
|
|
||||||
> API 文档:https://api.rainyun.com | OpenAPI Spec:https://api.rainyun.com/openapi.json
|
|
||||||
> 认证方式:`X-Api-Key` Header | Base URL:`https://api.v2.rainyun.com`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 接入状态
|
|
||||||
|
|
||||||
| 图标 | 含义 |
|
|
||||||
|------|------|
|
|
||||||
| ✅ | 已接入 CLI |
|
|
||||||
| ⬜ | 未接入 |
|
|
||||||
| 🚫 | 不在计划(非主流场景) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 云服务器 RCS
|
|
||||||
|
|
||||||
> `ry server <action>`
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| GET | `/product/rcs/` | 获取云服务器列表 | ✅ `server list` |
|
|
||||||
| POST | `/product/rcs/` | 创建云服务器 | ⬜ |
|
|
||||||
| GET | `/product/rcs/:id/` | 获取 RCS 详情 | ✅ `server get` |
|
|
||||||
| POST | `/product/rcs/:id/backup/` | 创建备份 | ⬜ |
|
|
||||||
| DELETE | `/product/rcs/:id/backup/:bid/` | 删除备份 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/backup/:bid/cancel` | 取消备份 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/backup/:bid/restore` | 还原备份 | ⬜ |
|
|
||||||
| PATCH | `/product/rcs/:id/backup/setting` | 设置备份选项 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/bridge_setintip` | 桥接模式设置内网 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/changeos` | 重装系统 | ✅ `server reinstall` |
|
|
||||||
| POST | `/product/rcs/:id/edisk/` | 管理弹性云盘 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/eip/` | 创建绑定弹性 IP | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/eip/change` | 更换 IP | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/eip/description` | 设置 IP 描述 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/eip/discard` | 放弃 IP | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/fai-send` | 快速安装 APP | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/firewall/mode` | 设置防火墙规则 | ⬜ |
|
|
||||||
| GET | `/product/rcs/:id/firewall/rule` | 获取防火墙规则 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/firewall/rule` | 创建防火墙规则 | ⬜ |
|
|
||||||
| DELETE | `/product/rcs/:id/firewall/rule/:ruleId` | 删除防火墙规则 | ⬜ |
|
|
||||||
| PUT | `/product/rcs/:id/firewall/rule/:ruleId/pos` | 移动防火墙优先级 | ⬜ |
|
|
||||||
| GET | `/product/rcs/:id/firewall/sync_time` | 防火墙同步时间 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/free` | 释放 | ⬜ |
|
|
||||||
| GET | `/product/rcs/:id/monitor` | 监控数据 | ⬜ |
|
|
||||||
| DELETE | `/product/rcs/:id/nat` | 删除 NAT 映射 | ⬜ |
|
|
||||||
| GET | `/product/rcs/:id/nat` | 添加 NAT 映射 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/reboot` | 重启 | ✅ `server reboot` |
|
|
||||||
| GET | `/product/rcs/:id/renew/` | 续费价格 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/renew/` | 续费 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/renew/option` | 自动续费选项 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/reset-password` | 重置密码 | ✅ `server reset-password` |
|
|
||||||
| POST | `/product/rcs/:id/start` | 开机 | ✅ `server start` |
|
|
||||||
| POST | `/product/rcs/:id/stop` | 关机 | ✅ `server stop` |
|
|
||||||
| PATCH | `/product/rcs/:id/tag` | 设置标签 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/to-bridge` | 转桥接 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/toggle_dgpu` | 充流量 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/toggle_primary_gpu` | 充流量 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/traffic/charge` | 充流量 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/traffic/limit` | 限流 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/upgrade` | 升级 | ✅ `server upgrade` |
|
|
||||||
| GET | `/product/rcs/:id/usage` | 使用情况 | ⬜ |
|
|
||||||
| GET | `/product/rcs/:id/vnc` | VNC 连接 | ✅ `server vnc` |
|
|
||||||
| PATCH | `/product/rcs/:id/vnet` | 子网改名 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/vnet` | 创建子网 | ⬜ |
|
|
||||||
| POST | `/product/rcs/:id/webbar/charge` | 充流量 | ⬜ |
|
|
||||||
| GET | `/product/rcs/discount-percent` | 折扣比率 | ⬜ |
|
|
||||||
| GET | `/product/rcs/os-templates` | 操作系统列表 | ⬜ |
|
|
||||||
| GET | `/product/rcs/plans` | 套餐列表 | ⬜ |
|
|
||||||
| GET | `/product/rcs/price` | 价格 | ⬜ |
|
|
||||||
| GET | `/product/rcs/usage` | 使用情况列表 | ⬜ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 域名 Domain
|
|
||||||
|
|
||||||
> `ry domain <action>`
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| GET | `/product/domain/` | 域名列表 | ✅ `domain list` |
|
|
||||||
| GET | `/product/domain/:id` | 域名详情 | ⬜ |
|
|
||||||
| GET | `/product/domain/:id/cert` | 下载证书 | ⬜ |
|
|
||||||
| PATCH | `/product/domain/:id/dns` | 修改 DNS | ⬜ |
|
|
||||||
| POST | `/product/domain/:id/dns` | 添加 DNS | ✅ `domain dns add` |
|
|
||||||
| DELETE | `/product/domain/:id/dns/` | 删除 DNS | ✅ `domain dns delete` |
|
|
||||||
| GET | `/product/domain/:id/dns/` | DNS 记录列表 | ✅ `domain dns list` |
|
|
||||||
| GET | `/product/domain/:id/dnssec` | DNSSEC 详情 | ⬜ |
|
|
||||||
| POST | `/product/domain/:id/dnssec` | 添加 DNSSEC | ⬜ |
|
|
||||||
| POST | `/product/domain/:id/dnssec/delete` | 删除 DNSSEC | ⬜ |
|
|
||||||
| POST | `/product/domain/:id/dnssec/sync` | 同步 DNSSEC | ⬜ |
|
|
||||||
| PUT | `/product/domain/:id/lock/disable` | 关闭锁定 | ⬜ |
|
|
||||||
| PUT | `/product/domain/:id/lock/enable` | 开启锁定 | ⬜ |
|
|
||||||
| POST | `/product/domain/:id/nameservers` | 修改 NS | ⬜ |
|
|
||||||
| POST | `/product/domain/:id/nameservers/reset` | 重置 NS | ⬜ |
|
|
||||||
| GET | `/product/domain/:id/password` | 管理密码 | ⬜ |
|
|
||||||
| POST | `/product/domain/:id/password` | 更新密码 | ⬜ |
|
|
||||||
| POST | `/product/domain/:id/renew` | 续费 | ⬜ |
|
|
||||||
| GET | `/product/domain/:id/renew-price` | 续费价格 | ⬜ |
|
|
||||||
| POST | `/product/domain/:id/transfer` | 过户 | ⬜ |
|
|
||||||
| DELETE | `/product/domain/certify` | 删除认证 | ⬜ |
|
|
||||||
| GET | `/product/domain/certify` | 已验证域名 | ⬜ |
|
|
||||||
| POST | `/product/domain/certify` | 添加认证 | ⬜ |
|
|
||||||
| POST | `/product/domain/certify/verify` | 认证校验 | ⬜ |
|
|
||||||
| POST | `/product/domain/check` | 检查可注册 | ⬜ |
|
|
||||||
| DELETE | `/product/domain/free_subdomain` | 删除免费二级 | ⬜ |
|
|
||||||
| GET | `/product/domain/free_subdomain` | 免费二级列表 | ⬜ |
|
|
||||||
| POST | `/product/domain/free_subdomain` | 创建免费二级 | ⬜ |
|
|
||||||
| POST | `/product/domain/free_subdomain/proxy` | CDN 设置 | ⬜ |
|
|
||||||
| GET | `/product/domain/free_subdomain/usable` | 可用免费域名 | ⬜ |
|
|
||||||
| POST | `/product/domain/register` | 注册域名 | ⬜ |
|
|
||||||
| PATCH | `/product/domain/template` | 编辑模板 | ⬜ |
|
|
||||||
| DELETE | `/product/domain/template/` | 删除模板 | ⬜ |
|
|
||||||
| GET | `/product/domain/template/` | 模板列表 | ⬜ |
|
|
||||||
| GET | `/product/domain/template/detail/` | 模板详情 | ⬜ |
|
|
||||||
| GET | `/product/domain/whitelist` | 白名单 | ⬜ |
|
|
||||||
| POST | `/product/domain/whitelist` | 添加白名单 | ⬜ |
|
|
||||||
| GET | `/product/domain/whois` | WHOIS 查询 | ⬜ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 对象存储 ROS
|
|
||||||
|
|
||||||
> `ry storage <action>`
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| GET | `/product/ros/instance` | 实例列表 | ✅ `storage list` |
|
|
||||||
| POST | `/product/ros/instance` | 创建实例 | ⬜ |
|
|
||||||
| GET | `/product/ros/instance/:id` | 实例详情 | ⬜ |
|
|
||||||
| POST | `/product/ros/instance/:id/regenerate-keys` | 重新生成密钥 | ⬜ |
|
|
||||||
| POST | `/product/ros/instance/:id/renew` | 续费 | ⬜ |
|
|
||||||
| POST | `/product/ros/instance/:id/renew/option` | 自动续费 | ⬜ |
|
|
||||||
| POST | `/product/ros/instance/:id/scale` | 缩放 | ⬜ |
|
|
||||||
| PATCH | `/product/ros/instance/:id/tag` | 设置标签 | ⬜ |
|
|
||||||
| POST | `/product/ros/instance/:id/toggle-extra-accounting` | 弹性计费 | ⬜ |
|
|
||||||
| POST | `/product/ros/instance/:id/toggle-public-access` | 匿名访问 | ⬜ |
|
|
||||||
| GET | `/product/ros/bucket` | 桶列表 | ✅ `storage bucket list` |
|
|
||||||
| POST | `/product/ros/bucket` | 创建桶 | ✅ `storage bucket create` |
|
|
||||||
| DELETE | `/product/ros/bucket/:id` | 删除桶 | ⬜ |
|
|
||||||
| GET | `/product/ros/bucket/:id` | 桶详情 | ⬜ |
|
|
||||||
| GET | `/product/ros/bucket/:id/monitor` | 实例监控 | ⬜ |
|
|
||||||
| POST | `/product/ros/bucket/:id/regenerate-keys` | 重新生成密钥 | ⬜ |
|
|
||||||
| POST | `/product/ros/bucket/:id/request-log` | 请求日志 | ⬜ |
|
|
||||||
| POST | `/product/ros/bucket/:id/toggle-public-access` | 匿名访问 | ⬜ |
|
|
||||||
| GET | `/product/ros/plans` | 套餐列表 | ⬜ |
|
|
||||||
| GET | `/product/ros/price` | 价格 | ⬜ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 计费 Expense
|
|
||||||
|
|
||||||
> `ry billing <action>`
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| GET | `/expense/orders/list` | 订单列表 | ✅ `billing orders` |
|
|
||||||
| POST | `/expense/orders/pay` | 支付订单 | ⬜ |
|
|
||||||
| POST | `/expense/product/transfer` | 产品转移 | ⬜ |
|
|
||||||
| POST | `/expense/unsubscribe/` | 退订 | ⬜ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CDN
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| POST | `/product/cdn` | 创建 CDN | ⬜ |
|
|
||||||
| GET | `/product/cdn/` | CDN 列表 | ⬜ |
|
|
||||||
| DELETE | `/product/cdn/:id` | 删除 CDN | ⬜ |
|
|
||||||
| GET | `/product/cdn/:id` | CDN 详情 | ⬜ |
|
|
||||||
| PATCH | `/product/cdn/:id` | 修改 CDN | ⬜ |
|
|
||||||
| GET | `/product/cdn/:id/metrics` | 监控 | ⬜ |
|
|
||||||
| GET | `/product/cdn/pack` | 流量包列表 | ⬜ |
|
|
||||||
| POST | `/product/cdn/pack` | 购买流量包 | ⬜ |
|
|
||||||
| GET | `/product/cdn/pack-plans` | 流量包套餐 | ⬜ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 游戏云 RGS
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| GET | `/product/rgs/` | 游戏云列表 | ⬜ |
|
|
||||||
| POST | `/product/rgs/` | 创建游戏云 | ⬜ |
|
|
||||||
| GET | `/product/rgs/:id/` | 详情 | ⬜ |
|
|
||||||
| POST | `/product/rgs/:id/start` | 开机 | ⬜ |
|
|
||||||
| POST | `/product/rgs/:id/stop` | 关机 | ⬜ |
|
|
||||||
| POST | `/product/rgs/:id/reboot` | 重启 | ⬜ |
|
|
||||||
| POST | `/product/rgs/:id/changeos` | 重装 | ⬜ |
|
|
||||||
| ... | *(共 75 个端点)* | | ⬜ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 裸金属 RBM
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| GET | `/product/rbm` | RBM 列表 | ⬜ |
|
|
||||||
| POST | `/product/rbm/` | 创建 RBM | ⬜ |
|
|
||||||
| POST | `/product/rbm/:id/poweroff` | 关机 | ⬜ |
|
|
||||||
| POST | `/product/rbm/:id/poweron` | 开机 | ⬜ |
|
|
||||||
| ... | *(共 23 个端点)* | | ⬜ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 虚拟主机 RVH
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| GET | `/product/rvh/` | 虚拟主机列表 | ⬜ |
|
|
||||||
| POST | `/product/rvh/` | 创建 | ⬜ |
|
|
||||||
| ... | *(共 25 个端点)* | | ⬜ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 云应用 RCA
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| ... | *(共 62 个端点)* | | 🚫 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## RCDN
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| ... | *(共 22 个端点)* | | ⬜ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SSL 证书
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| ... | *(共 6 个端点)* | | ⬜ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 工单 WorkOrder
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| ... | *(共 18 个端点)* | | 🚫 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 用户 / VIP / 积分 / 商城
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| ... | *(共 60+ 个端点)* | | 🚫 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 统计
|
|
||||||
|
|
||||||
| 类别 | 总数 | 已接入 | 占比 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| 云服务器 RCS | 50 | 8 | 16% |
|
|
||||||
| 域名 Domain | 38 | 4 | 11% |
|
|
||||||
| 对象存储 ROS | 24 | 3 | 13% |
|
|
||||||
| 计费 Expense | 4 | 1 | 25% |
|
|
||||||
| CDN | 9 | 0 | 0% |
|
|
||||||
| 游戏云 RGS | 75 | 0 | 0% |
|
|
||||||
| 裸金属 RBM | 23 | 0 | 0% |
|
|
||||||
| 虚拟主机 RVH | 25 | 0 | 0% |
|
|
||||||
| 云应用 RCA | 62 | 0 | 0% |
|
|
||||||
| RCDN | 22 | 0 | 0% |
|
|
||||||
| SSL 证书 | 6 | 0 | 0% |
|
|
||||||
| 工单 WorkOrder | 18 | 0 | 0% |
|
|
||||||
| 用户/VIP/积分 | 60+ | 0 | 0% |
|
|
||||||
| **总计** | **~416** | **16** | **~4%** |
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Verbose/Debug Mode Design
|
|
||||||
|
|
||||||
**Date:** 2026-07-28
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Wire up the existing `--debug` flag (currently unused) and add `--verbose` as an alias. When enabled, print detailed runtime and HTTP request information to stderr.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
4 files modified, 0 new files.
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
### 1. `internal/provider/provider.go` — ProviderConfig
|
|
||||||
|
|
||||||
Add `Debug bool` field to `ProviderConfig`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ProviderConfig struct {
|
|
||||||
APIKey string
|
|
||||||
AccessKeyID string
|
|
||||||
AccessKeySecret string
|
|
||||||
Endpoint string
|
|
||||||
Debug bool
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `cmd/cli/root.go` — Flag + Runtime Info
|
|
||||||
|
|
||||||
- Register `--verbose` as alias for `--debug` (both set `flagDebug`)
|
|
||||||
- In `PersistentPreRunE`, when debug is on, print to stderr:
|
|
||||||
- Provider name
|
|
||||||
- Config file path
|
|
||||||
- Output format
|
|
||||||
|
|
||||||
### 3. `internal/provider/rainyun/client.go` — HTTP Logging
|
|
||||||
|
|
||||||
- Add `debug bool` field to `client` struct
|
|
||||||
- Update `newClient(endpoint, apiKey string, debug bool)` signature
|
|
||||||
- In `do()`, when `c.debug` is true, print to `os.Stderr`:
|
|
||||||
- Request: `> METHOD /path` + request body (if any)
|
|
||||||
- Response: `< StatusCode StatusText Duration` + response body
|
|
||||||
|
|
||||||
### 4. `internal/provider/rainyun/rainyun.go` — Pass Debug
|
|
||||||
|
|
||||||
- `New()` passes `cfg.Debug` to `newClient()`
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
All debug output goes to **stderr** to avoid polluting stdout data pipeline.
|
|
||||||
|
|
||||||
```
|
|
||||||
[debug] provider: rainyun
|
|
||||||
[debug] config: /Users/user/.config/ry/config.json
|
|
||||||
[debug] output: table
|
|
||||||
[debug] > GET /api/v2/servers
|
|
||||||
[debug] < 200 OK 123ms
|
|
||||||
[debug] {"code":200,"data":[...]}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `internal/provider/provider.go` | Add `Debug` to `ProviderConfig` |
|
|
||||||
| `cmd/cli/root.go` | Wire `--verbose`, print runtime info |
|
|
||||||
| `internal/provider/rainyun/client.go` | HTTP request/response logging |
|
|
||||||
| `internal/provider/rainyun/rainyun.go` | Pass `cfg.Debug` to client |
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "ry",
|
||||||
|
Short: "Multi-cloud CLI, like gh for cloud providers",
|
||||||
|
}
|
||||||
|
|
||||||
|
func Execute() error {
|
||||||
|
return rootCmd.Execute()
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type Order struct {
|
|
||||||
ID string `json:"id" table:"ID"`
|
|
||||||
Product string `json:"product" table:"PRODUCT"`
|
|
||||||
Amount float64 `json:"amount" table:"AMOUNT"`
|
|
||||||
Status string `json:"status" table:"STATUS"`
|
|
||||||
Created time.Time `json:"created" table:"CREATED"`
|
|
||||||
Raw any `json:"-"`
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
type CDNInstance struct {
|
|
||||||
ID string `json:"id" table:"ID"`
|
|
||||||
Domain string `json:"domain" table:"DOMAIN"`
|
|
||||||
Status string `json:"status" table:"STATUS"`
|
|
||||||
Raw any `json:"-"`
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type Domain struct {
|
|
||||||
ID string `json:"id" table:"ID"`
|
|
||||||
Name string `json:"name" table:"DOMAIN"`
|
|
||||||
Status string `json:"status" table:"STATUS"`
|
|
||||||
ExpireAt time.Time `json:"expires" table:"EXPIRES"`
|
|
||||||
Raw any `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DNSRecord struct {
|
|
||||||
ID string `json:"id" table:"ID"`
|
|
||||||
Type string `json:"type" table:"TYPE"`
|
|
||||||
Name string `json:"name" table:"NAME"`
|
|
||||||
Value string `json:"value" table:"VALUE"`
|
|
||||||
TTL int `json:"ttl" table:"TTL"`
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
type Error struct {
|
|
||||||
Code int
|
|
||||||
Message string
|
|
||||||
Detail string
|
|
||||||
Raw []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Error) Error() string {
|
|
||||||
return e.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Error) Debug() string {
|
|
||||||
return fmt.Sprintf("%s\n%s", e.Detail, string(e.Raw))
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type Server struct {
|
|
||||||
ID string `json:"id" table:"ID"`
|
|
||||||
Name string `json:"name" table:"NAME"`
|
|
||||||
Status string `json:"status" table:"STATUS"`
|
|
||||||
IP string `json:"ip" table:"IP"`
|
|
||||||
CPU int `json:"cpu" table:"CPU"`
|
|
||||||
Memory int `json:"memory" table:"MEM"`
|
|
||||||
Disk int `json:"disk" table:"DISK"`
|
|
||||||
OS string `json:"os" table:"OS"`
|
|
||||||
Region string `json:"region" table:"REGION"`
|
|
||||||
ExpireAt time.Time `json:"expires" table:"EXPIRES"`
|
|
||||||
Raw any `json:"-"`
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
type StorageInstance struct {
|
|
||||||
ID string `json:"id" table:"ID"`
|
|
||||||
Name string `json:"name" table:"NAME"`
|
|
||||||
Status string `json:"status" table:"STATUS"`
|
|
||||||
Raw any `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Bucket struct {
|
|
||||||
ID string `json:"id" table:"ID"`
|
|
||||||
Name string `json:"name" table:"NAME"`
|
|
||||||
Region string `json:"region" table:"REGION"`
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProviderConfig struct {
|
|
||||||
APIKey string
|
|
||||||
AccessKeyID string
|
|
||||||
AccessKeySecret string
|
|
||||||
Endpoint string
|
|
||||||
Debug bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type Provider interface {
|
|
||||||
Name() string
|
|
||||||
DisplayName() string
|
|
||||||
ValidateAuth(ctx context.Context) error
|
|
||||||
Server() ServerService
|
|
||||||
Domain() DomainService
|
|
||||||
Storage() StorageService
|
|
||||||
CDN() CDNService
|
|
||||||
Billing() BillingService
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerService interface {
|
|
||||||
List(ctx context.Context) ([]model.Server, error)
|
|
||||||
Get(ctx context.Context, id string) (*model.Server, error)
|
|
||||||
Start(ctx context.Context, id string) error
|
|
||||||
Stop(ctx context.Context, id string) error
|
|
||||||
Reboot(ctx context.Context, id string) error
|
|
||||||
Reinstall(ctx context.Context, id, osTemplate string) error
|
|
||||||
ResetPassword(ctx context.Context, id string) error
|
|
||||||
GetVNC(ctx context.Context, id string) (string, error)
|
|
||||||
Upgrade(ctx context.Context, id, plan string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type DomainService interface {
|
|
||||||
List(ctx context.Context) ([]model.Domain, error)
|
|
||||||
DNSList(ctx context.Context, domain string) ([]model.DNSRecord, error)
|
|
||||||
DNSAdd(ctx context.Context, domain string, record model.DNSRecord) error
|
|
||||||
DNSDelete(ctx context.Context, domain string, recordID string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type StorageService interface {
|
|
||||||
List(ctx context.Context) ([]model.StorageInstance, error)
|
|
||||||
BucketList(ctx context.Context, instanceID string) ([]model.Bucket, error)
|
|
||||||
BucketCreate(ctx context.Context, instanceID, name string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type CDNService interface {
|
|
||||||
List(ctx context.Context) ([]model.CDNInstance, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type BillingService interface {
|
|
||||||
Orders(ctx context.Context) ([]model.Order, error)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package rainyun
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BillingService struct {
|
|
||||||
client *client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BillingService) Orders(ctx context.Context) ([]model.Order, error) {
|
|
||||||
type rainyunOrder struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
ProductName string `json:"product_name"`
|
|
||||||
Amount float64 `json:"amount"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
CreateDate string `json:"create_date"`
|
|
||||||
}
|
|
||||||
var raw []rainyunOrder
|
|
||||||
if err := b.client.getList(ctx, "/expense/orders/list", &raw); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
orders := make([]model.Order, len(raw))
|
|
||||||
for i, r := range raw {
|
|
||||||
t, _ := time.Parse("2006-01-02 15:04:05", r.CreateDate)
|
|
||||||
orders[i] = model.Order{
|
|
||||||
ID: fmt.Sprintf("%d", r.ID),
|
|
||||||
Product: r.ProductName,
|
|
||||||
Amount: r.Amount,
|
|
||||||
Status: r.Status,
|
|
||||||
Created: t,
|
|
||||||
Raw: r,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return orders, nil
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
package rainyun
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultEndpoint = "https://api.v2.rainyun.com"
|
|
||||||
|
|
||||||
type client struct {
|
|
||||||
httpClient *http.Client
|
|
||||||
baseURL string
|
|
||||||
apiKey string
|
|
||||||
debug bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newClient(endpoint, apiKey string, debug bool) *client {
|
|
||||||
if endpoint == "" {
|
|
||||||
endpoint = defaultEndpoint
|
|
||||||
}
|
|
||||||
return &client{
|
|
||||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
baseURL: endpoint,
|
|
||||||
apiKey: apiKey,
|
|
||||||
debug: debug,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type apiResponse struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
Data json.RawMessage `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) get(ctx context.Context, path string, result any) error {
|
|
||||||
return c.do(ctx, "GET", path, nil, result, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) getWithQuery(ctx context.Context, path string, query map[string]string, result any) error {
|
|
||||||
return c.do(ctx, "GET", path, nil, result, query)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getList sends a GET request to a Rainyun list endpoint and extracts the "records" field.
|
|
||||||
func (c *client) getList(ctx context.Context, path string, result any) error {
|
|
||||||
var wrapper listResponse
|
|
||||||
if err := c.do(ctx, "GET", path, nil, &wrapper, listOptions()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Unmarshal the records array into the result
|
|
||||||
recordsJSON, err := json.Marshal(wrapper.Records)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal records: %w", err)
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(recordsJSON, result); err != nil {
|
|
||||||
return fmt.Errorf("unmarshal records: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) post(ctx context.Context, path string, body, result any) error {
|
|
||||||
return c.do(ctx, "POST", path, body, result, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) patch(ctx context.Context, path string, body, result any) error {
|
|
||||||
return c.do(ctx, "PATCH", path, body, result, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) delete(ctx context.Context, path string, result any) error {
|
|
||||||
return c.do(ctx, "DELETE", path, nil, result, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) do(ctx context.Context, method, path string, body, result any, query map[string]string) error {
|
|
||||||
u := c.baseURL + path
|
|
||||||
|
|
||||||
if len(query) > 0 {
|
|
||||||
params := url.Values{}
|
|
||||||
for k, v := range query {
|
|
||||||
params.Set(k, v)
|
|
||||||
}
|
|
||||||
u += "?" + params.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
var bodyReader io.Reader
|
|
||||||
var bodyBytes []byte
|
|
||||||
if body != nil {
|
|
||||||
b, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal request: %w", err)
|
|
||||||
}
|
|
||||||
bodyBytes = b
|
|
||||||
bodyReader = bytes.NewReader(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.debug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] > %s %s\n", method, u)
|
|
||||||
if bodyBytes != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] %s\n", bodyBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, u, bodyReader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("X-Api-Key", c.apiKey)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.debug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] < %d %s %s\n", resp.StatusCode, http.StatusText(resp.StatusCode), time.Since(start).Round(time.Millisecond))
|
|
||||||
fmt.Fprintf(os.Stderr, "[debug] %s\n", respBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiResp apiResponse
|
|
||||||
if err := json.Unmarshal(respBody, &apiResp); err == nil {
|
|
||||||
// Rainyun uses code:200 and code:0 both as success indicators
|
|
||||||
isError := resp.StatusCode >= 400 || (apiResp.Code != 0 && apiResp.Code != 200)
|
|
||||||
if isError {
|
|
||||||
return &APIError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Method: method,
|
|
||||||
URL: u,
|
|
||||||
Body: respBody,
|
|
||||||
APICode: apiResp.Code,
|
|
||||||
APIMsg: apiResp.Msg,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if resp.StatusCode >= 400 {
|
|
||||||
return &APIError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Method: method,
|
|
||||||
URL: u,
|
|
||||||
Body: respBody,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != nil && apiResp.Data != nil {
|
|
||||||
if err := json.Unmarshal(apiResp.Data, result); err != nil {
|
|
||||||
return fmt.Errorf("unmarshal data: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type APIError struct {
|
|
||||||
StatusCode int
|
|
||||||
Method string
|
|
||||||
URL string
|
|
||||||
Body []byte
|
|
||||||
APICode int
|
|
||||||
APIMsg string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *APIError) Error() string {
|
|
||||||
msg := e.APIMsg
|
|
||||||
if msg == "" {
|
|
||||||
// 非 JSON 响应,截取 body 前 200 字符
|
|
||||||
body := string(e.Body)
|
|
||||||
if len(body) > 200 {
|
|
||||||
body = body[:200] + "..."
|
|
||||||
}
|
|
||||||
if body != "" {
|
|
||||||
msg = body
|
|
||||||
} else {
|
|
||||||
msg = http.StatusText(e.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *APIError) Detail() string {
|
|
||||||
return fmt.Sprintf("%s %s [%d]", e.Method, e.URL, e.StatusCode)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package rainyun
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestClientAuthHeader(t *testing.T) {
|
|
||||||
var gotKey string
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gotKey = r.Header.Get("X-Api-Key")
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"code":0,"data":{"id":1,"name":"test"}}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
c := newClient(srv.URL, "sk-test", false)
|
|
||||||
var result struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
if err := c.get(context.Background(), "/test", &result); err != nil {
|
|
||||||
t.Fatalf("get() error: %v", err)
|
|
||||||
}
|
|
||||||
if gotKey != "sk-test" {
|
|
||||||
t.Errorf("X-Api-Key = %q, want %q", gotKey, "sk-test")
|
|
||||||
}
|
|
||||||
if result.Name != "test" {
|
|
||||||
t.Errorf("Name = %q, want %q", result.Name, "test")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientAPIError(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(404)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"code":1002,"msg":"not found"}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
c := newClient(srv.URL, "sk-test", false)
|
|
||||||
var result any
|
|
||||||
err := c.get(context.Background(), "/error", &result)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for 404")
|
|
||||||
}
|
|
||||||
apiErr, ok := err.(*APIError)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected *APIError, got %T", err)
|
|
||||||
}
|
|
||||||
if apiErr.StatusCode != 404 {
|
|
||||||
t.Errorf("error code = %d, want 404", apiErr.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package rainyun
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DomainService struct {
|
|
||||||
client *client
|
|
||||||
}
|
|
||||||
|
|
||||||
type rainyunDomain struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
DomainName string `json:"domain_name"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
ExpireDate string `json:"expire_date"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DomainService) List(ctx context.Context) ([]model.Domain, error) {
|
|
||||||
var raw []rainyunDomain
|
|
||||||
if err := d.client.getList(ctx, "/product/domain/", &raw); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
domains := make([]model.Domain, len(raw))
|
|
||||||
for i, r := range raw {
|
|
||||||
domains[i] = model.Domain{
|
|
||||||
ID: fmt.Sprintf("%d", r.ID),
|
|
||||||
Name: r.DomainName,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return domains, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DomainService) DNSList(ctx context.Context, domain string) ([]model.DNSRecord, error) {
|
|
||||||
type rainyunDNSRecord struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
RecordType string `json:"record_type"`
|
|
||||||
Host string `json:"host"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
TTL int `json:"ttl"`
|
|
||||||
}
|
|
||||||
var raw []rainyunDNSRecord
|
|
||||||
path := fmt.Sprintf("/product/domain/%s/dns/", domain)
|
|
||||||
if err := d.client.get(ctx, path, &raw); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
records := make([]model.DNSRecord, len(raw))
|
|
||||||
for i, r := range raw {
|
|
||||||
records[i] = model.DNSRecord{
|
|
||||||
ID: fmt.Sprintf("%d", r.ID),
|
|
||||||
Type: r.RecordType,
|
|
||||||
Name: r.Host,
|
|
||||||
Value: r.Value,
|
|
||||||
TTL: r.TTL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return records, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DomainService) DNSAdd(ctx context.Context, domain string, record model.DNSRecord) error {
|
|
||||||
body := map[string]any{
|
|
||||||
"record_type": record.Type,
|
|
||||||
"host": record.Name,
|
|
||||||
"value": record.Value,
|
|
||||||
"ttl": record.TTL,
|
|
||||||
}
|
|
||||||
path := fmt.Sprintf("/product/domain/%s/dns", domain)
|
|
||||||
return d.client.post(ctx, path, body, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DomainService) DNSDelete(ctx context.Context, domain string, recordID string) error {
|
|
||||||
path := fmt.Sprintf("/product/domain/%s/dns/%s", domain, recordID)
|
|
||||||
return d.client.delete(ctx, path, nil)
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package rainyun
|
|
||||||
|
|
||||||
// listOptions returns the standard pagination query for Rainyun list endpoints.
|
|
||||||
// Format verified via browser DevTools capture of the Rainyun web console:
|
|
||||||
//
|
|
||||||
// https://api.v2.rainyun.com/product/rcs/?options={url-encoded JSON}
|
|
||||||
//
|
|
||||||
// The decoded JSON is: {"columnFilters":{},"sort":[],"page":1,"perPage":20}
|
|
||||||
func listOptions() map[string]string {
|
|
||||||
return map[string]string{
|
|
||||||
"options": `{"columnFilters":{},"sort":[],"page":1,"perPage":50}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// listResponse is the JSON envelope returned by Rainyun list endpoints.
|
|
||||||
// API uses PascalCase: {"code":200,"data":{"Records":[...],"TotalRecords":N}}
|
|
||||||
type listResponse struct {
|
|
||||||
Records []any `json:"Records"`
|
|
||||||
TotalRecords int `json:"TotalRecords"`
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package rainyun
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/provider"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
provider.Register("rainyun", New)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rainyun provider — 雨云 cloud platform.
|
|
||||||
type Rainyun struct {
|
|
||||||
client *client
|
|
||||||
server *ServerService
|
|
||||||
domain *DomainService
|
|
||||||
storage *StorageService
|
|
||||||
billing *BillingService
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a Rainyun provider from config.
|
|
||||||
func New(cfg provider.ProviderConfig) (provider.Provider, error) {
|
|
||||||
c := newClient(cfg.Endpoint, cfg.APIKey, cfg.Debug)
|
|
||||||
r := &Rainyun{client: c}
|
|
||||||
r.server = &ServerService{client: c}
|
|
||||||
r.domain = &DomainService{client: c}
|
|
||||||
r.storage = &StorageService{client: c}
|
|
||||||
r.billing = &BillingService{client: c}
|
|
||||||
return r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Rainyun) Name() string { return "rainyun" }
|
|
||||||
func (r *Rainyun) DisplayName() string { return "雨云" }
|
|
||||||
|
|
||||||
// ValidateAuth checks the API key by hitting the user info endpoint.
|
|
||||||
func (r *Rainyun) ValidateAuth(ctx context.Context) error {
|
|
||||||
var data any
|
|
||||||
return r.client.get(ctx, "/user/", &data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Rainyun) Server() provider.ServerService {
|
|
||||||
if r.server == nil {
|
|
||||||
r.server = &ServerService{client: r.client}
|
|
||||||
}
|
|
||||||
return r.server
|
|
||||||
}
|
|
||||||
func (r *Rainyun) Domain() provider.DomainService { return r.domain }
|
|
||||||
func (r *Rainyun) Storage() provider.StorageService { return r.storage }
|
|
||||||
func (r *Rainyun) CDN() provider.CDNService { return nil }
|
|
||||||
func (r *Rainyun) Billing() provider.BillingService { return r.billing }
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package rainyun
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ServerService implements provider.ServerService for 雨云.
|
|
||||||
type ServerService struct {
|
|
||||||
client *client
|
|
||||||
}
|
|
||||||
|
|
||||||
// rainyunServer mirrors the Rainyun RCS API JSON fields (PascalCase).
|
|
||||||
type rainyunServer struct {
|
|
||||||
ID int `json:"ID"`
|
|
||||||
HostName string `json:"HostName"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
MainIPv4 string `json:"MainIPv4"`
|
|
||||||
CPU int `json:"CPU"`
|
|
||||||
Memory int `json:"memory"`
|
|
||||||
Disk int `json:"disk"`
|
|
||||||
OsName string `json:"OsName"`
|
|
||||||
Zone string `json:"Zone"`
|
|
||||||
ExpireDate int64 `json:"ExpDate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServerService) List(ctx context.Context) ([]model.Server, error) {
|
|
||||||
var raw []rainyunServer
|
|
||||||
if err := s.client.getList(ctx, "/product/rcs/", &raw); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
servers := make([]model.Server, len(raw))
|
|
||||||
for i, r := range raw {
|
|
||||||
servers[i] = toServer(r)
|
|
||||||
}
|
|
||||||
return servers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServerService) Get(ctx context.Context, id string) (*model.Server, error) {
|
|
||||||
var resp struct {
|
|
||||||
Data rainyunServer `json:"Data"`
|
|
||||||
}
|
|
||||||
path := fmt.Sprintf("/product/rcs/%s/", id)
|
|
||||||
if err := s.client.get(ctx, path, &resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sv := toServer(resp.Data)
|
|
||||||
return &sv, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServerService) Start(ctx context.Context, id string) error {
|
|
||||||
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/start", id), nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServerService) Stop(ctx context.Context, id string) error {
|
|
||||||
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/stop", id), nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServerService) Reboot(ctx context.Context, id string) error {
|
|
||||||
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/reboot", id), nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServerService) Reinstall(ctx context.Context, id, osTemplate string) error {
|
|
||||||
body := map[string]string{"os": osTemplate}
|
|
||||||
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/changeos", id), body, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServerService) ResetPassword(ctx context.Context, id string) error {
|
|
||||||
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/reset-password", id), nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServerService) GetVNC(ctx context.Context, id string) (string, error) {
|
|
||||||
var resp struct {
|
|
||||||
VNCURL string `json:"vnc_url"`
|
|
||||||
}
|
|
||||||
if err := s.client.get(ctx, fmt.Sprintf("/product/rcs/%s/vnc", id), &resp); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return resp.VNCURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServerService) Upgrade(ctx context.Context, id, plan string) error {
|
|
||||||
body := map[string]string{"plan": plan}
|
|
||||||
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/upgrade", id), body, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// toServer maps a rainyunServer to the model.Server model.
|
|
||||||
func toServer(r rainyunServer) model.Server {
|
|
||||||
expire := time.Unix(r.ExpireDate, 0)
|
|
||||||
return model.Server{
|
|
||||||
ID: fmt.Sprintf("%d", r.ID),
|
|
||||||
Name: r.HostName,
|
|
||||||
Status: r.Status,
|
|
||||||
IP: r.MainIPv4,
|
|
||||||
CPU: r.CPU,
|
|
||||||
Memory: r.Memory,
|
|
||||||
Disk: r.Disk,
|
|
||||||
OS: r.OsName,
|
|
||||||
Region: r.Zone,
|
|
||||||
ExpireAt: expire,
|
|
||||||
Raw: r,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package rainyun
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestServerList(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "GET" || r.URL.Path != "/product/rcs/" {
|
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
||||||
}
|
|
||||||
resp := map[string]any{
|
|
||||||
"code": 0,
|
|
||||||
"data": map[string]any{
|
|
||||||
"Records": []map[string]any{
|
|
||||||
{
|
|
||||||
"ID": 12001,
|
|
||||||
"HostName": "web-server",
|
|
||||||
"status": "running",
|
|
||||||
"MainIPv4": "1.2.3.4",
|
|
||||||
"CPU": 2,
|
|
||||||
"memory": 4096,
|
|
||||||
"disk": 50,
|
|
||||||
"OsName": "Ubuntu 22.04",
|
|
||||||
"Zone": "cn-sy1",
|
|
||||||
"ExpDate": 1756656000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"TotalRecords": 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
json.NewEncoder(w).Encode(resp)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
rain := &Rainyun{client: newClient(srv.URL, "sk-test", false)}
|
|
||||||
svc := rain.Server()
|
|
||||||
servers, err := svc.List(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("List() error: %v", err)
|
|
||||||
}
|
|
||||||
if len(servers) != 1 {
|
|
||||||
t.Fatalf("len = %d, want 1", len(servers))
|
|
||||||
}
|
|
||||||
s := servers[0]
|
|
||||||
if s.Name != "web-server" {
|
|
||||||
t.Errorf("Name = %q", s.Name)
|
|
||||||
}
|
|
||||||
if s.Status != "running" {
|
|
||||||
t.Errorf("Status = %q", s.Status)
|
|
||||||
}
|
|
||||||
if s.CPU != 2 {
|
|
||||||
t.Errorf("CPU = %d", s.CPU)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServerStart(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" || r.URL.Path != "/product/rcs/12001/start" {
|
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
||||||
}
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"code": 0})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
rain := &Rainyun{client: newClient(srv.URL, "sk-test", false)}
|
|
||||||
err := rain.Server().Start(context.Background(), "12001")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Start() error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServerVNC(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
resp := map[string]any{
|
|
||||||
"code": 0,
|
|
||||||
"data": map[string]any{"vnc_url": "https://vnc.example.com/abc"},
|
|
||||||
}
|
|
||||||
json.NewEncoder(w).Encode(resp)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
rain := &Rainyun{client: newClient(srv.URL, "sk-test", false)}
|
|
||||||
url, err := rain.Server().GetVNC(context.Background(), "12001")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetVNC() error: %v", err)
|
|
||||||
}
|
|
||||||
if url != "https://vnc.example.com/abc" {
|
|
||||||
t.Errorf("VNC url = %q", url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package rainyun
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.yoresee.cc/nightstar/ry/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StorageService struct {
|
|
||||||
client *client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StorageService) List(ctx context.Context) ([]model.StorageInstance, error) {
|
|
||||||
var raw []struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
if err := s.client.getList(ctx, "/product/ros/instance", &raw); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
instances := make([]model.StorageInstance, len(raw))
|
|
||||||
for i, r := range raw {
|
|
||||||
instances[i] = model.StorageInstance{
|
|
||||||
ID: fmt.Sprintf("%d", r.ID),
|
|
||||||
Name: r.Name,
|
|
||||||
Raw: r,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return instances, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StorageService) BucketList(ctx context.Context, instanceID string) ([]model.Bucket, error) {
|
|
||||||
type rainyunBucket struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Region string `json:"region"`
|
|
||||||
}
|
|
||||||
var raw []rainyunBucket
|
|
||||||
path := fmt.Sprintf("/product/ros/bucket/%s", instanceID)
|
|
||||||
if err := s.client.get(ctx, path, &raw); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
buckets := make([]model.Bucket, len(raw))
|
|
||||||
for i, r := range raw {
|
|
||||||
buckets[i] = model.Bucket{
|
|
||||||
ID: fmt.Sprintf("%d", r.ID),
|
|
||||||
Name: r.Name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buckets, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StorageService) BucketCreate(ctx context.Context, instanceID, name string) error {
|
|
||||||
body := map[string]string{"name": name}
|
|
||||||
path := fmt.Sprintf("/product/ros/bucket/%s", instanceID)
|
|
||||||
return s.client.post(ctx, path, body, nil)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Factory func(cfg ProviderConfig) (Provider, error)
|
|
||||||
|
|
||||||
var (
|
|
||||||
registry = map[string]Factory{}
|
|
||||||
registryMu sync.RWMutex
|
|
||||||
)
|
|
||||||
|
|
||||||
func Register(name string, factory Factory) {
|
|
||||||
registryMu.Lock()
|
|
||||||
defer registryMu.Unlock()
|
|
||||||
registry[name] = factory
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(name string, cfg ProviderConfig) (Provider, error) {
|
|
||||||
registryMu.RLock()
|
|
||||||
factory, ok := registry[name]
|
|
||||||
registryMu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unknown provider: %s (run 'ry provider add %s' first)", name, name)
|
|
||||||
}
|
|
||||||
return factory(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func List() []string {
|
|
||||||
registryMu.RLock()
|
|
||||||
defer registryMu.RUnlock()
|
|
||||||
names := make([]string, 0, len(registry))
|
|
||||||
for name := range registry {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mockProvider struct {
|
|
||||||
name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockProvider) Name() string { return m.name }
|
|
||||||
func (m *mockProvider) DisplayName() string { return "Mock" }
|
|
||||||
func (m *mockProvider) ValidateAuth(ctx context.Context) error { return nil }
|
|
||||||
func (m *mockProvider) Server() ServerService { return nil }
|
|
||||||
func (m *mockProvider) Domain() DomainService { return nil }
|
|
||||||
func (m *mockProvider) Storage() StorageService { return nil }
|
|
||||||
func (m *mockProvider) CDN() CDNService { return nil }
|
|
||||||
func (m *mockProvider) Billing() BillingService { return nil }
|
|
||||||
|
|
||||||
func TestRegistryRegisterAndNew(t *testing.T) {
|
|
||||||
Register("mock", func(cfg ProviderConfig) (Provider, error) {
|
|
||||||
return &mockProvider{name: "mock"}, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
p, err := New("mock", ProviderConfig{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error: %v", err)
|
|
||||||
}
|
|
||||||
if p.Name() != "mock" {
|
|
||||||
t.Errorf("Name() = %q, want %q", p.Name(), "mock")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryUnknown(t *testing.T) {
|
|
||||||
_, err := New("unknown", ProviderConfig{})
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for unknown provider")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistryList(t *testing.T) {
|
|
||||||
Register("list-test", func(cfg ProviderConfig) (Provider, error) {
|
|
||||||
return &mockProvider{name: "list-test"}, nil
|
|
||||||
})
|
|
||||||
names := List()
|
|
||||||
found := false
|
|
||||||
for _, n := range names {
|
|
||||||
if n == "list-test" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Errorf("List() should contain 'list-test', got %v", names)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user