Compare commits

...
26 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
33 changed files with 1860 additions and 267 deletions
+1
View File
@@ -1,5 +1,6 @@
# Binaries
ry
/cli
*.exe
*.exe~
*.dll
+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 |
-80
View File
@@ -1,80 +0,0 @@
package cmd
import (
"fmt"
"os"
"git.yoresee.cc/nightstar/ry/internal/config"
"git.yoresee.cc/nightstar/ry/internal/output"
"git.yoresee.cc/nightstar/ry/internal/provider"
"github.com/spf13/cobra"
)
var (
cfg *config.Config
cfgPath string
prov provider.Provider
out *output.Printer
flagProvider string
flagOutput string
flagRaw bool
flagDebug bool
)
var rootCmd = &cobra.Command{
Use: "ry",
Short: "Multi-cloud CLI — manage cloud resources from your terminal",
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)
}
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,
})
if err != nil {
return err
}
outputFormat := cfg.Output
if flagOutput != "" {
outputFormat = flagOutput
}
if flagRaw {
outputFormat = "raw"
}
out = output.New(outputFormat, os.Stdout)
return nil
},
}
func Execute() error {
return rootCmd.Execute()
}
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().BoolVar(&flagDebug, "debug", false, "Enable debug output")
}
+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 -85
View File
@@ -2,8 +2,8 @@ package provider
import (
"context"
"fmt"
"time"
"git.yoresee.cc/nightstar/ry/internal/model"
)
type ProviderConfig struct {
@@ -11,6 +11,7 @@ type ProviderConfig struct {
AccessKeyID string
AccessKeySecret string
Endpoint string
Debug bool
}
type Provider interface {
@@ -25,8 +26,8 @@ type Provider interface {
}
type ServerService interface {
List(ctx context.Context) ([]Server, error)
Get(ctx context.Context, id string) (*Server, error)
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
@@ -37,96 +38,22 @@ type ServerService interface {
}
type DomainService interface {
List(ctx context.Context) ([]Domain, error)
DNSList(ctx context.Context, domain string) ([]DNSRecord, error)
DNSAdd(ctx context.Context, domain string, record DNSRecord) error
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) ([]StorageInstance, error)
BucketList(ctx context.Context, instanceID string) ([]Bucket, error)
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) ([]CDNInstance, error)
List(ctx context.Context) ([]model.CDNInstance, error)
}
type BillingService interface {
Orders(ctx context.Context) ([]Order, error)
}
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:"-"`
}
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"`
}
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"`
}
type CDNInstance struct {
ID string `json:"id" table:"ID"`
Domain string `json:"domain" table:"DOMAIN"`
Status string `json:"status" table:"STATUS"`
Raw any `json:"-"`
}
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:"-"`
}
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))
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
}
+83 -23
View File
@@ -7,6 +7,8 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
@@ -16,9 +18,10 @@ type client struct {
httpClient *http.Client
baseURL string
apiKey string
debug bool
}
func newClient(endpoint, apiKey string) *client {
func newClient(endpoint, apiKey string, debug bool) *client {
if endpoint == "" {
endpoint = defaultEndpoint
}
@@ -26,6 +29,7 @@ func newClient(endpoint, apiKey string) *client {
httpClient: &http.Client{Timeout: 30 * time.Second},
baseURL: endpoint,
apiKey: apiKey,
debug: debug,
}
}
@@ -36,34 +40,72 @@ type apiResponse struct {
}
func (c *client) get(ctx context.Context, path string, result any) error {
return c.do(ctx, "GET", path, nil, result)
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)
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)
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)
return c.do(ctx, "DELETE", path, nil, result, nil)
}
func (c *client) do(ctx context.Context, method, path string, body, result any) error {
url := c.baseURL + path
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)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
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)
}
@@ -71,6 +113,7 @@ func (c *client) do(ctx context.Context, method, path string, body, result any)
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)
@@ -82,33 +125,37 @@ func (c *client) do(ctx context.Context, method, path string, body, result any)
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 400 {
return &APIError{
StatusCode: resp.StatusCode,
Method: method,
URL: url,
Body: respBody,
}
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 {
return fmt.Errorf("unmarshal response: %w", err)
}
if apiResp.Code != 0 {
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: url,
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 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
if result != nil && apiResp.Data != nil {
if err := json.Unmarshal(apiResp.Data, result); err != nil {
return fmt.Errorf("unmarshal data: %w", err)
}
}
return nil
@@ -126,7 +173,20 @@ type APIError struct {
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)
}
+4 -7
View File
@@ -16,13 +16,10 @@ func TestClientAuthHeader(t *testing.T) {
}))
defer srv.Close()
c := newClient(srv.URL, "sk-test")
c := newClient(srv.URL, "sk-test", false)
var result struct {
Code int `json:"code"`
Data struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
if err := c.get(context.Background(), "/test", &result); err != nil {
t.Fatalf("get() error: %v", err)
@@ -30,8 +27,8 @@ func TestClientAuthHeader(t *testing.T) {
if gotKey != "sk-test" {
t.Errorf("X-Api-Key = %q, want %q", gotKey, "sk-test")
}
if result.Data.Name != "test" {
t.Errorf("Data.Name = %q, want %q", result.Data.Name, "test")
if result.Name != "test" {
t.Errorf("Name = %q, want %q", result.Name, "test")
}
}
@@ -43,7 +40,7 @@ func TestClientAPIError(t *testing.T) {
}))
defer srv.Close()
c := newClient(srv.URL, "sk-test")
c := newClient(srv.URL, "sk-test", false)
var result any
err := c.get(context.Background(), "/error", &result)
if err == nil {
+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"`
}
+10 -4
View File
@@ -14,13 +14,19 @@ func init() {
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)
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
}
@@ -39,7 +45,7 @@ func (r *Rainyun) Server() provider.ServerService {
}
return r.server
}
func (r *Rainyun) Domain() provider.DomainService { return nil }
func (r *Rainyun) Storage() provider.StorageService { return nil }
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 nil }
func (r *Rainyun) Billing() provider.BillingService { return r.billing }
+31 -45
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"time"
"git.yoresee.cc/nightstar/ry/internal/provider"
"git.yoresee.cc/nightstar/ry/internal/model"
)
// ServerService implements provider.ServerService for 雨云.
@@ -13,52 +13,36 @@ type ServerService struct {
client *client
}
// rainyunServer mirrors the Rainyun RCS API JSON fields.
// rainyunServer mirrors the Rainyun RCS API JSON fields (PascalCase).
type rainyunServer struct {
ID int `json:"id"`
Name string `json:"name"`
ID int `json:"ID"`
HostName string `json:"HostName"`
Status string `json:"status"`
IP string `json:"ip"`
VCPU int `json:"vcpu"`
MainIPv4 string `json:"MainIPv4"`
CPU int `json:"CPU"`
Memory int `json:"memory"`
Disk int `json:"disk"`
OS string `json:"os"`
Region string `json:"zone_name"`
ExpireDate string `json:"expire_date"`
OsName string `json:"OsName"`
Zone string `json:"Zone"`
ExpireDate int64 `json:"ExpDate"`
}
// Response wrappers — the client unmarshals the full API envelope (code + data).
type serverListResponse struct {
Code int `json:"code"`
Data []rainyunServer `json:"data"`
}
type serverGetResponse struct {
Code int `json:"code"`
Data rainyunServer `json:"data"`
}
type vncResponse struct {
Code int `json:"code"`
Data struct {
VNCURL string `json:"vnc_url"`
} `json:"data"`
}
func (s *ServerService) List(ctx context.Context) ([]provider.Server, error) {
var resp serverListResponse
if err := s.client.get(ctx, "/product/rcs/", &resp); err != nil {
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([]provider.Server, len(resp.Data))
for i, r := range resp.Data {
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) (*provider.Server, error) {
var resp serverGetResponse
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
@@ -89,11 +73,13 @@ func (s *ServerService) ResetPassword(ctx context.Context, id string) error {
}
func (s *ServerService) GetVNC(ctx context.Context, id string) (string, error) {
var resp vncResponse
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.Data.VNCURL, nil
return resp.VNCURL, nil
}
func (s *ServerService) Upgrade(ctx context.Context, id, plan string) error {
@@ -101,19 +87,19 @@ func (s *ServerService) Upgrade(ctx context.Context, id, plan string) error {
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/upgrade", id), body, nil)
}
// toServer maps a rainyunServer to the provider.Server model.
func toServer(r rainyunServer) provider.Server {
expire, _ := time.Parse("2006-01-02", r.ExpireDate)
return provider.Server{
// 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.Name,
Name: r.HostName,
Status: r.Status,
IP: r.IP,
CPU: r.VCPU,
IP: r.MainIPv4,
CPU: r.CPU,
Memory: r.Memory,
Disk: r.Disk,
OS: r.OS,
Region: r.Region,
OS: r.OsName,
Region: r.Zone,
ExpireAt: expire,
Raw: r,
}
+14 -10
View File
@@ -15,25 +15,29 @@ func TestServerList(t *testing.T) {
}
resp := map[string]any{
"code": 0,
"data": []map[string]any{
"data": map[string]any{
"Records": []map[string]any{
{
"id": 12001,
"name": "web-server",
"ID": 12001,
"HostName": "web-server",
"status": "running",
"ip": "1.2.3.4",
"vcpu": 2,
"MainIPv4": "1.2.3.4",
"CPU": 2,
"memory": 4096,
"disk": 50,
"os": "Ubuntu 22.04",
"expire_date": "2026-08-15",
"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")}
rain := &Rainyun{client: newClient(srv.URL, "sk-test", false)}
svc := rain.Server()
servers, err := svc.List(context.Background())
if err != nil {
@@ -63,7 +67,7 @@ func TestServerStart(t *testing.T) {
}))
defer srv.Close()
rain := &Rainyun{client: newClient(srv.URL, "sk-test")}
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)
@@ -80,7 +84,7 @@ func TestServerVNC(t *testing.T) {
}))
defer srv.Close()
rain := &Rainyun{client: newClient(srv.URL, "sk-test")}
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)
+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)
}