Compare commits
34
Commits
3662c10c24
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
998a47193d | ||
|
|
38d5b3f338 | ||
|
|
317dc9c4e5 | ||
|
|
e6e9ad01e1 | ||
|
|
468262305e | ||
|
|
9faf881efa | ||
|
|
b7085af9d3 | ||
|
|
86ced3e4bb | ||
|
|
72bbb4c8a0 | ||
|
|
93cea77bba | ||
|
|
bbba314127 | ||
|
|
ccc9f27130 | ||
|
|
2c13f4c5d9 | ||
|
|
0ba0430c63 | ||
|
|
71a842a327 | ||
|
|
9c3eba2327 | ||
|
|
7190d93edf | ||
|
|
284a503541 | ||
|
|
4e348031a6 | ||
|
|
51d1e82ac5 | ||
|
|
2c2d999bf2 | ||
|
|
146ad50295 | ||
|
|
096492dd82 | ||
|
|
b0fbc3e710 | ||
|
|
1bff97b434 | ||
|
|
b4bc96b44d | ||
|
|
abed5bf2a9 | ||
|
|
7a19c154d0 | ||
|
|
b817c56e9a | ||
|
|
fc48e5f4a3 | ||
|
|
8c901099c3 | ||
|
|
dd1c2ef3db | ||
|
|
d815ec1e9a | ||
|
|
fd36c8da23 |
+20
@@ -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
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
_ "git.yoresee.cc/nightstar/ry/internal/provider/rainyun"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+126
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,14 @@
|
||||
module git.yoresee.cc/nightstar/ry
|
||||
|
||||
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 (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
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=
|
||||
@@ -0,0 +1,67 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type ProviderConfig struct {
|
||||
APIKey string `toml:"api_key,omitempty"`
|
||||
AccessKeyID string `toml:"access_key_id,omitempty"`
|
||||
AccessKeySecret string `toml:"access_key_secret,omitempty"`
|
||||
Endpoint string `toml:"endpoint,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
CurrentProvider string `toml:"current_provider"`
|
||||
Output string `toml:"output"`
|
||||
Providers map[string]ProviderConfig `toml:"providers"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := &Config{
|
||||
Output: "table",
|
||||
Providers: make(map[string]ProviderConfig),
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := toml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = make(map[string]ProviderConfig)
|
||||
}
|
||||
if cfg.Output == "" {
|
||||
cfg.Output = "table"
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Save(path string, cfg *Config) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
enc := toml.NewEncoder(f)
|
||||
return enc.Encode(cfg)
|
||||
}
|
||||
|
||||
func DefaultPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "config.toml"
|
||||
}
|
||||
return filepath.Join(home, ".config", "ry", "config.toml")
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadTOML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
content := `
|
||||
current_provider = "rainyun"
|
||||
output = "table"
|
||||
|
||||
[providers.rainyun]
|
||||
api_key = "sk-test123"
|
||||
|
||||
[providers.aliyun]
|
||||
access_key_id = "ak-xxx"
|
||||
access_key_secret = "sk-xxx"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.CurrentProvider != "rainyun" {
|
||||
t.Errorf("CurrentProvider = %q, want %q", cfg.CurrentProvider, "rainyun")
|
||||
}
|
||||
if cfg.Output != "table" {
|
||||
t.Errorf("Output = %q, want %q", cfg.Output, "table")
|
||||
}
|
||||
if cfg.Providers["rainyun"].APIKey != "sk-test123" {
|
||||
t.Errorf("rainyun api_key = %q, want %q", cfg.Providers["rainyun"].APIKey, "sk-test123")
|
||||
}
|
||||
if cfg.Providers["aliyun"].AccessKeyID != "ak-xxx" {
|
||||
t.Errorf("aliyun access_key_id = %q, want %q", cfg.Providers["aliyun"].AccessKeyID, "ak-xxx")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "empty.toml")
|
||||
if err := os.WriteFile(path, []byte(""), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.Output != "table" {
|
||||
t.Errorf("default Output = %q, want %q", cfg.Output, "table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "out.toml")
|
||||
cfg := &Config{
|
||||
CurrentProvider: "test",
|
||||
Output: "json",
|
||||
Providers: map[string]ProviderConfig{
|
||||
"test": {APIKey: "secret"},
|
||||
},
|
||||
}
|
||||
if err := Save(path, cfg); err != nil {
|
||||
t.Fatalf("Save() error: %v", err)
|
||||
}
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("re-Load() error: %v", err)
|
||||
}
|
||||
if loaded.CurrentProvider != "test" {
|
||||
t.Errorf("roundtrip CurrentProvider = %q", loaded.CurrentProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPath(t *testing.T) {
|
||||
path := DefaultPath()
|
||||
if path == "" {
|
||||
t.Error("DefaultPath() returned empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDir(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "sub", "dir")
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
cfg := &Config{Output: "table"}
|
||||
if err := Save(path, cfg); err != nil {
|
||||
t.Fatalf("Save() to deep dir error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user