Compare commits

..
34 Commits
Author SHA1 Message Date
XingfenD 998a47193d Merge branch 'feat/verbose-debug' into master 2026-07-30 01:04:48 +08:00
XingfenD 38d5b3f338 fix: unwrap nested Data field in server Get response 2026-07-29 00:54:28 +08:00
XingfenD 317dc9c4e5 fix: correct rainyunServer JSON field names to match actual API (PascalCase) 2026-07-29 00:39:28 +08:00
XingfenD e6e9ad01e1 refactor: remove --debug flag, keep --verbose/-v only 2026-07-29 00:04:16 +08:00
XingfenD 468262305e feat: wire --verbose/--debug flags with runtime info 2026-07-28 23:59:05 +08:00
XingfenD 9faf881efa feat: HTTP debug logging in client.do() 2026-07-28 23:57:24 +08:00
XingfenD b7085af9d3 feat: add Debug field to ProviderConfig 2026-07-28 23:55:50 +08:00
XingfenD 86ced3e4bb docs: verbose/debug implementation plan 2026-07-28 23:53:44 +08:00
XingfenD 72bbb4c8a0 docs: verbose/debug mode design spec 2026-07-28 23:52:23 +08:00
NightStar 93cea77bba Merge branch 'refactor/extract-model-package' 2026-07-28 19:34:24 +08:00
NightStar bbba314127 refactor: extract domain models from provider into internal/model
- Move Server/Domain/DNSRecord/Bucket/StorageInstance/CDNInstance/Order/Error structs to internal/model/
- Provider interfaces now reference model.* types
- Rainyun provider implementations import model package for data types
- CLI commands updated to use model.DNSRecord directly

Separates pure data structures from service contract interfaces,
following hexagonal architecture: models (entities) live independently
from ports (provider interfaces) and adapters (rainyun/* implementations).
2026-07-28 19:31:22 +08:00
NightStar ccc9f27130 Merge branch 'docs/clean-agents-webui' 2026-07-28 19:16:00 +08:00
NightStar 2c13f4c5d9 docs: remove webui-related rules from AGENTS.md
The cross-domain guard and release branch naming patterns referenced
cmd/webui/ which doesn't exist in this repo — they were copied from
another project template.
2026-07-28 19:14:52 +08:00
XingfenD 0ba0430c63 update basic AGENTS.md 2026-07-28 18:49:32 +08:00
XingfenD 71a842a327 refactor: move cmd handlers to cmd/cli/commands/ following tag_file architecture 2026-07-28 18:41:12 +08:00
NightStar 9c3eba2327 fix: PascalCase JSON fields + code:200 success + Unix timestamp ExpDate 2026-07-28 17:09:39 +08:00
NightStar 7190d93edf fix: add getList with records wrapper + correct options format from browser capture 2026-07-28 17:03:59 +08:00
NightStar 284a503541 fix: query params support + remove duplicate response wrappers + fix tests 2026-07-28 16:45:45 +08:00
NightStar 4e348031a6 fix: API error message — parse JSON error body, show actual API message instead of raw status 2026-07-28 16:42:00 +08:00
NightStar 51d1e82ac5 chore: reorganize docs — spec/plan/provider subdirs 2026-07-28 16:33:08 +08:00
NightStar 2c2d999bf2 docs: Rainyun API 接入状态 — 363 个端点,16 个已接入 2026-07-28 16:27:25 +08:00
NightStar 146ad50295 feat: completion + README — polish and docs 2026-07-28 16:12:14 +08:00
NightStar 096492dd82 feat: domain/storage/billing/config cmds + rainyun domain/storage/billing services 2026-07-28 16:11:03 +08:00
NightStar b0fbc3e710 feat: server cmd — list/get/start/stop/reboot/reinstall/reset-password/vnc 2026-07-28 16:08:50 +08:00
NightStar 1bff97b434 feat: provider cmd — list, use, add with interactive key input 2026-07-28 16:08:05 +08:00
NightStar b4bc96b44d feat: cobra root — config integration, global flags, provider resolution 2026-07-28 16:06:54 +08:00
NightStar abed5bf2a9 feat: cobra root — config integration, global flags, provider resolution 2026-07-28 16:05:06 +08:00
NightStar 7a19c154d0 feat: rainyun server service — list/get/start/stop/reboot/vnc/upgrade 2026-07-28 16:02:59 +08:00
NightStar b817c56e9a feat: rainyun http client — adapter pattern with auth injection 2026-07-28 16:00:33 +08:00
NightStar fc48e5f4a3 feat: provider interface + registry — factory pattern for multi-cloud 2026-07-28 15:59:08 +08:00
NightStar 8c901099c3 chore: add .gitignore, remove tracked binary 2026-07-28 15:57:51 +08:00
NightStar dd1c2ef3db feat: output system — table/json/yaml/raw printers with struct tag driven tables 2026-07-28 15:54:18 +08:00
NightStar d815ec1e9a feat: config system — TOML load/save with provider auth map 2026-07-28 15:50:57 +08:00
NightStar fd36c8da23 feat: project scaffold — go module, cobra root, dir layout 2026-07-28 15:49:31 +08:00
43 changed files with 2649 additions and 16 deletions
+20
View File
@@ -0,0 +1,20 @@
# Binaries
ry
/cli
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of go coverage
*.out
# IDE
.idea/
.vscode/
*.swp
*.swo
+7
View File
@@ -0,0 +1,7 @@
# 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.
+48
View File
@@ -0,0 +1,48 @@
# 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`
+37
View File
@@ -0,0 +1,37 @@
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
}
+75
View File
@@ -0,0 +1,75 @@
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
}
+100
View File
@@ -0,0 +1,100 @@
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
}
+94
View File
@@ -0,0 +1,94 @@
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
}
+181
View File
@@ -0,0 +1,181 @@
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
}
+79
View File
@@ -0,0 +1,79 @@
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
}
+2 -2
View File
@@ -4,11 +4,11 @@ import (
"fmt"
"os"
"git.yoresee.cc/nightstar/ry/internal/cmd"
_ "git.yoresee.cc/nightstar/ry/internal/provider/rainyun"
)
func main() {
if err := cmd.Execute(); err != nil {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
+126
View File
@@ -0,0 +1,126 @@
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))
}
+316
View File
@@ -0,0 +1,316 @@
# 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
+276
View File
@@ -0,0 +1,276 @@
# 雨云 (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%** |
@@ -0,0 +1,70 @@
# 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 |
+1
View File
@@ -5,6 +5,7 @@ go 1.18
require (
github.com/BurntSushi/toml v1.6.0
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
+3
View File
@@ -9,4 +9,7 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-14
View File
@@ -1,14 +0,0 @@
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()
}
+12
View File
@@ -0,0 +1,12 @@
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:"-"`
}
+8
View File
@@ -0,0 +1,8 @@
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:"-"`
}
+19
View File
@@ -0,0 +1,19 @@
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"`
}
+18
View File
@@ -0,0 +1,18 @@
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))
}
+17
View File
@@ -0,0 +1,17 @@
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:"-"`
}
+14
View File
@@ -0,0 +1,14 @@
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"`
}
+12
View File
@@ -0,0 +1,12 @@
package output
import (
"encoding/json"
"io"
)
func printJSON(w io.Writer, data any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(data)
}
+33
View File
@@ -0,0 +1,33 @@
package output
import (
"fmt"
"io"
)
type Printer struct {
format string
writer io.Writer
}
func New(format string, w io.Writer) *Printer {
return &Printer{format: format, writer: w}
}
func (p *Printer) Print(data any) error {
switch p.format {
case "table":
return printTable(p.writer, data)
case "json":
return printJSON(p.writer, data)
case "yaml":
return printYAML(p.writer, data)
default:
return fmt.Errorf("unknown output format: %s", p.format)
}
}
func (p *Printer) PrintRaw(raw []byte) error {
_, err := fmt.Fprintln(p.writer, string(raw))
return err
}
+96
View File
@@ -0,0 +1,96 @@
package output
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
type testItem struct {
ID string `json:"id" table:"ID"`
Name string `json:"name" table:"NAME"`
Age int `json:"age" table:"AGE"`
}
func TestTableFormat(t *testing.T) {
items := []testItem{
{ID: "1", Name: "Alice", Age: 30},
{ID: "2", Name: "Bob", Age: 25},
}
var buf bytes.Buffer
printer := New("table", &buf)
if err := printer.Print(items); err != nil {
t.Fatalf("Print table error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "ID") || !strings.Contains(out, "Alice") {
t.Errorf("table output missing headers or data:\n%s", out)
}
}
func TestJSONFormat(t *testing.T) {
items := []testItem{
{ID: "1", Name: "Alice", Age: 30},
}
var buf bytes.Buffer
printer := New("json", &buf)
if err := printer.Print(items); err != nil {
t.Fatalf("Print json error: %v", err)
}
var parsed []testItem
if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
if len(parsed) != 1 || parsed[0].ID != "1" {
t.Errorf("JSON content mismatch: %+v", parsed)
}
}
func TestYAMLFormat(t *testing.T) {
items := []testItem{
{ID: "1", Name: "Alice", Age: 30},
}
var buf bytes.Buffer
printer := New("yaml", &buf)
if err := printer.Print(items); err != nil {
t.Fatalf("Print yaml error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "id:") || !strings.Contains(out, "Alice") {
t.Errorf("yaml output missing data:\n%s", out)
}
}
func TestRawFormat(t *testing.T) {
raw := []byte(`{"raw":"data"}`)
var buf bytes.Buffer
printer := New("raw", &buf)
if err := printer.PrintRaw(raw); err != nil {
t.Fatalf("PrintRaw error: %v", err)
}
if buf.String() != `{"raw":"data"}`+"\n" {
t.Errorf("raw output = %q", buf.String())
}
}
func TestSingleItem(t *testing.T) {
item := testItem{ID: "1", Name: "Solo", Age: 99}
var buf bytes.Buffer
printer := New("json", &buf)
if err := printer.Print(item); err != nil {
t.Fatalf("Print single item error: %v", err)
}
if !strings.Contains(buf.String(), "Solo") {
t.Errorf("single item output missing data:\n%s", buf.String())
}
}
func TestUnknownFormat(t *testing.T) {
var buf bytes.Buffer
printer := New("unknown", &buf)
err := printer.Print([]testItem{})
if err == nil {
t.Error("expected error for unknown format")
}
}
+120
View File
@@ -0,0 +1,120 @@
package output
import (
"fmt"
"io"
"reflect"
"strings"
)
func printTable(w io.Writer, data any) error {
v := reflect.ValueOf(data)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() == reflect.Slice {
return printTableSlice(w, v)
}
return printTableSingle(w, v)
}
func printTableSlice(w io.Writer, v reflect.Value) error {
if v.Len() == 0 {
fmt.Fprintln(w, "(empty)")
return nil
}
elemType := v.Index(0).Type()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
headers, cols := extractTableTags(elemType)
if len(headers) == 0 {
return fmt.Errorf("no table tags found on type %s", elemType.Name())
}
// Collect all rows
rows := make([][]string, v.Len())
for i := 0; i < v.Len(); i++ {
rows[i] = extractRow(v.Index(i), cols)
}
// Calculate column widths
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = len(h)
}
for _, row := range rows {
for i, cell := range row {
if len(cell) > widths[i] {
widths[i] = len(cell)
}
}
}
// Print headers
sep := " "
renderRow(w, headers, widths, sep)
// Print separator line
separator := make([]string, len(headers))
for i, ww := range widths {
separator[i] = strings.Repeat("-", ww)
}
renderRow(w, separator, widths, sep)
// Print data rows
for _, row := range rows {
renderRow(w, row, widths, sep)
}
return nil
}
func renderRow(w io.Writer, cells []string, widths []int, sep string) {
parts := make([]string, len(cells))
for i, cell := range cells {
parts[i] = fmt.Sprintf("%-*s", widths[i], cell)
}
fmt.Fprintln(w, strings.Join(parts, sep))
}
func printTableSingle(w io.Writer, v reflect.Value) error {
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
t := v.Type()
for i := 0; i < t.NumField(); i++ {
tag := t.Field(i).Tag.Get("table")
if tag == "" {
continue
}
val := fmt.Sprintf("%v", v.Field(i).Interface())
fmt.Fprintf(w, "%-10s %s\n", tag+":", val)
}
return nil
}
func extractTableTags(t reflect.Type) ([]string, []int) {
var headers []string
var cols []int
for i := 0; i < t.NumField(); i++ {
tag := t.Field(i).Tag.Get("table")
if tag == "" {
continue
}
headers = append(headers, strings.ToUpper(tag))
cols = append(cols, i)
}
return headers, cols
}
func extractRow(v reflect.Value, cols []int) []string {
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
row := make([]string, len(cols))
for i, idx := range cols {
row[i] = fmt.Sprintf("%v", v.Field(idx).Interface())
}
return row
}
+13
View File
@@ -0,0 +1,13 @@
package output
import (
"io"
"gopkg.in/yaml.v3"
)
func printYAML(w io.Writer, data any) error {
enc := yaml.NewEncoder(w)
enc.SetIndent(2)
return enc.Encode(data)
}
+59
View File
@@ -0,0 +1,59 @@
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)
}
+40
View File
@@ -0,0 +1,40 @@
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
}
+192
View File
@@ -0,0 +1,192 @@
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)
}
+56
View File
@@ -0,0 +1,56 @@
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)
}
}
+76
View File
@@ -0,0 +1,76 @@
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)
}
+20
View File
@@ -0,0 +1,20 @@
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"`
}
+51
View File
@@ -0,0 +1,51 @@
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 }
+106
View File
@@ -0,0 +1,106 @@
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,
}
}
+95
View File
@@ -0,0 +1,95 @@
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)
}
}
+59
View File
@@ -0,0 +1,59 @@
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)
}
+41
View File
@@ -0,0 +1,41 @@
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
}
+57
View File
@@ -0,0 +1,57 @@
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)
}
}
BIN
View File
Binary file not shown.