Compare commits
27
Commits
7a19c154d0
...
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 |
@@ -1,5 +1,6 @@
|
||||
# Binaries
|
||||
ry
|
||||
/cli
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
@@ -1,14 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "ry",
|
||||
Short: "Multi-cloud CLI, like gh for cloud providers",
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user