Files
rainyun-cli/docs/2026-07-28-ry-cli-plan.md
T

2592 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ry CLI 实现计划
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 构建 `ry`——多提供商云服务 CLI 工具,首期实现雨云 Provider,支持服务器/域名/存储/计费管理。
**架构:** Go + cobra + Provider 接口 + 工厂/策略/适配器模式。命令层 → Provider 接口 → 雨云 HTTP 客户端 → API。
**技术栈:** Go 1.21+, cobra, viper, BurntSushi/toml, tablewriter, yaml.v3
---
## 文件结构总览
| 文件 | 职责 |
|------|------|
| `go.mod` / `go.sum` | Go 模块定义 |
| `cmd/ry/main.go` | 入口,初始化 cobra + 注册子命令 |
| `internal/config/config.go` | Config 结构体 + TOML 读写 |
| `internal/config/config_test.go` | 配置读写测试 |
| `internal/output/output.go` | Output 接口 + 调度 |
| `internal/output/table.go` | 表格渲染 |
| `internal/output/json.go` | JSON 输出 |
| `internal/output/yaml.go` | YAML 输出 |
| `internal/output/output_test.go` | 输出格式测试 |
| `internal/provider/provider.go` | Provider/ServerService 等接口 + Error 类型 + 通用模型 |
| `internal/provider/registry.go` | 工厂注册表 |
| `internal/provider/rainyun/client.go` | 雨云 HTTP 客户端(适配器) |
| `internal/provider/rainyun/rainyun.go` | 雨云 Provider 实现 |
| `internal/provider/rainyun/server.go` | 雨云 ServerService 实现 |
| `internal/provider/rainyun/domain.go` | 雨云 DomainService 实现 |
| `internal/provider/rainyun/storage.go` | 雨云 StorageService 实现 |
| `internal/provider/rainyun/billing.go` | 雨云 BillingService 实现 |
| `internal/provider/rainyun/server_test.go` | 雨云服务器操作测试 |
| `internal/cmd/root.go` | cobra 根命令 + 全局 flag |
| `internal/cmd/provider.go` | `ry provider` 子命令 |
| `internal/cmd/server.go` | `ry server` 子命令 |
| `internal/cmd/domain.go` | `ry domain` 子命令 |
| `internal/cmd/storage.go` | `ry storage` 子命令 |
| `internal/cmd/billing.go` | `ry billing` 子命令 |
| `internal/cmd/config_cmd.go` | `ry config` 子命令 |
| `internal/cmd/server_test.go` | server 命令集成测试 |
---
### 任务 1:项目脚手架
**文件:**
- 创建:`go.mod`
- [ ] **步骤 1:初始化 Go module**
```bash
cd /root/.openclaw/workspace/project/rainyun-cli
go mod init git.yoresee.cc/nightstar/ry
```
运行:`go mod init git.yoresee.cc/nightstar/ry`
预期:创建 `go.mod` 文件,module 路径为 `git.yoresee.cc/nightstar/ry`
- [ ] **步骤 2:创建目录结构**
```bash
mkdir -p cmd/ry \
internal/config \
internal/output \
internal/provider/rainyun \
internal/cmd
```
- [ ] **步骤 3:写入入口 main.go**
创建 `cmd/ry/main.go`:
```go
package main
import (
"fmt"
"os"
"git.yoresee.cc/nightstar/ry/internal/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
```
创建 `internal/cmd/root.go`(最小 stub):
```go
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()
}
```
- [ ] **步骤 4:拉依赖并验证编译**
```bash
cd /root/.openclaw/workspace/project/rainyun-cli
go mod tidy
go build ./cmd/ry/
./ry --help
```
预期:输出 cobra 默认 help,无报错
- [ ] **步骤 5:Commit**
```bash
git add -A
git commit -m "feat: project scaffold — go module, cobra root, dir layout"
```
---
### 任务 2:配置系统
**文件:**
- 创建:`internal/config/config.go`
- 创建:`internal/config/config_test.go`
- [ ] **步骤 1:编写配置测试(失败先行)**
创建 `internal/config/config_test.go`:
```go
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)
}
}
```
- [ ] **步骤 2:运行测试确认失败**
```bash
cd /root/.openclaw/workspace/project/rainyun-cli
go test ./internal/config/
```
预期:FAIL — 类型未定义
- [ ] **步骤 3:实现 config.go**
创建 `internal/config/config.go`:
```go
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")
}
```
- [ ] **步骤 4:运行测试验证通过**
```bash
go test ./internal/config/ -v
```
预期:4 个测试全部 PASS
- [ ] **步骤 5:拉依赖 + Commit**
```bash
go mod tidy
git add -A
git commit -m "feat: config system — TOML load/save with provider auth map"
```
---
### 任务 3:输出系统
**文件:**
- 创建:`internal/output/output.go`
- 创建:`internal/output/table.go`
- 创建:`internal/output/json.go`
- 创建:`internal/output/yaml.go`
- 创建:`internal/output/output_test.go`
- [ ] **步骤 1:编写输出测试(失败先行)**
创建 `internal/output/output_test.go`:
```go
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")
}
}
```
- [ ] **步骤 2:运行测试确认失败**
```bash
go test ./internal/output/ -v
```
预期:FAIL — 类型未定义
- [ ] **步骤 3:实现 Printer 接口 + table.go**
创建 `internal/output/output.go`:
```go
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
}
```
创建 `internal/output/table.go`:
```go
package output
import (
"fmt"
"io"
"reflect"
"strings"
"github.com/olekukoneko/tablewriter"
)
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)
table := tablewriter.NewWriter(w)
table.SetHeader(headers)
table.SetBorder(false)
table.SetColumnSeparator(" ")
for i := 0; i < v.Len(); i++ {
row := extractRow(v.Index(i), cols)
table.Append(row)
}
table.Render()
return nil
}
func printTableSingle(w io.Writer, v reflect.Value) error {
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
t := v.Type()
table := tablewriter.NewWriter(w)
table.SetBorder(false)
table.SetColumnSeparator(" ")
table.SetAutoFormatHeaders(false)
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())
table.Append([]string{tag, val})
}
table.Render()
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
}
```
创建 `internal/output/json.go`:
```go
package output
import (
"encoding/json"
"io"
)
func printJSON(w io.Writer, data any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(data)
}
```
创建 `internal/output/yaml.go`:
```go
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)
}
```
- [ ] **步骤 4:运行测试验证通过**
```bash
go test ./internal/output/ -v
```
预期:6 个测试全部 PASS
- [ ] **步骤 5:Commit**
```bash
go mod tidy
git add -A
git commit -m "feat: output system — table/json/yaml/raw printers with struct tag driven tables"
```
---
### 任务 4:Provider 接口 + Registry + 通用模型
**文件:**
- 创建:`internal/provider/provider.go`
- 创建:`internal/provider/registry.go`
- [ ] **步骤 1:编写 registry 测试(失败先行)**
创建 `internal/provider/registry_test.go`:
```go
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)
}
}
```
- [ ] **步骤 2:运行测试确认失败**
```bash
go test ./internal/provider/ -v
```
预期:FAIL — 类型未定义
- [ ] **步骤 3:实现 provider.go + registry.go**
创建 `internal/provider/provider.go`:
```go
package provider
import (
"context"
"fmt"
"time"
)
type ProviderConfig struct {
APIKey string
AccessKeyID string
AccessKeySecret string
Endpoint string
}
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) ([]Server, error)
Get(ctx context.Context, id string) (*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) ([]Domain, error)
DNSList(ctx context.Context, domain string) ([]DNSRecord, error)
DNSAdd(ctx context.Context, domain string, record 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)
BucketCreate(ctx context.Context, instanceID, name string) error
}
type CDNService interface {
List(ctx context.Context) ([]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))
}
```
创建 `internal/provider/registry.go`:
```go
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
}
```
- [ ] **步骤 4:运行测试验证通过**
```bash
go test ./internal/provider/ -v
```
预期:3 个测试 PASS。注意 mock 注册可能在 `TestRegistryList` 中有残留——没问题,Go 测试隔离运行。
- [ ] **步骤 5:Commit**
```bash
go mod tidy
git add -A
git commit -m "feat: provider interface + registry — factory pattern for multi-cloud"
```
---
### 任务 5:雨云 HTTP 客户端(适配器模式)
**文件:**
- 创建:`internal/provider/rainyun/client.go`
- 创建:`internal/provider/rainyun/client_test.go`
- [ ] **步骤 1:编写 client 测试(失败先行)**
创建 `internal/provider/rainyun/client_test.go`:
```go
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")
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)
}
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", result.Data.Name)
}
}
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")
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.Code != 404 {
t.Errorf("error code = %d, want 404", apiErr.Code)
}
}
```
- [ ] **步骤 2:运行测试确认失败**
```bash
go test ./internal/provider/rainyun/ -v
```
预期:FAIL
- [ ] **步骤 3:实现 client.go**
创建 `internal/provider/rainyun/client.go`:
```go
package rainyun
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const defaultEndpoint = "https://api.v2.rainyun.com"
type client struct {
httpClient *http.Client
baseURL string
apiKey string
}
func newClient(endpoint, apiKey string) *client {
if endpoint == "" {
endpoint = defaultEndpoint
}
return &client{
httpClient: &http.Client{Timeout: 30 * time.Second},
baseURL: endpoint,
apiKey: apiKey,
}
}
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)
}
func (c *client) post(ctx context.Context, path string, body, result any) error {
return c.do(ctx, "POST", path, body, result)
}
func (c *client) patch(ctx context.Context, path string, body, result any) error {
return c.do(ctx, "PATCH", path, body, result)
}
func (c *client) delete(ctx context.Context, path string, result any) error {
return c.do(ctx, "DELETE", path, nil, result)
}
func (c *client) do(ctx context.Context, method, path string, body, result any) error {
url := c.baseURL + path
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, url, 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")
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 resp.StatusCode >= 400 {
return &APIError{
StatusCode: resp.StatusCode,
Method: method,
URL: url,
Body: respBody,
}
}
var apiResp apiResponse
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
if apiResp.Code != 0 {
return &APIError{
StatusCode: resp.StatusCode,
Method: method,
URL: url,
Body: respBody,
APICode: apiResp.Code,
APIMsg: apiResp.Msg,
}
}
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 == "" {
msg = http.StatusText(e.StatusCode)
}
return fmt.Sprintf("%s", msg)
}
```
- [ ] **步骤 4:运行测试验证通过**
```bash
go test ./internal/provider/rainyun/ -v
```
预期:2 个测试 PASS
- [ ] **步骤 5:Commit**
```bash
go mod tidy
git add -A
git commit -m "feat: rainyun http client — adapter pattern with auth injection"
```
---
### 任务 6:雨云 ServerService 实现
**文件:**
- 创建:`internal/provider/rainyun/rainyun.go`
- 创建:`internal/provider/rainyun/server.go`
- 创建:`internal/provider/rainyun/server_test.go`
- [ ] **步骤 1:编写 server 测试(失败先行)**
创建 `internal/provider/rainyun/server_test.go`:
```go
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{
{
"id": 12001,
"name": "web-server",
"status": "running",
"ip": "1.2.3.4",
"vcpu": 2,
"memory": 4096,
"disk": 50,
"os": "Ubuntu 22.04",
"expire_date": "2026-08-15",
},
},
}
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
rain := &Rainyun{client: newClient(srv.URL, "sk-test")}
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")}
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")}
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)
}
}
```
- [ ] **步骤 2:运行测试确认失败**
```bash
go test ./internal/provider/rainyun/ -v
```
预期:FAIL — Rainyun 类型 / Server() 未定义
- [ ] **步骤 3:实现 rainyun.go**
创建 `internal/provider/rainyun/rainyun.go`:
```go
package rainyun
import (
"context"
"git.yoresee.cc/nightstar/ry/internal/provider"
)
func init() {
provider.Register("rainyun", New)
}
type Rainyun struct {
client *client
server *ServerService
}
func New(cfg provider.ProviderConfig) (provider.Provider, error) {
c := newClient(cfg.Endpoint, cfg.APIKey)
r := &Rainyun{client: c}
r.server = &ServerService{client: c}
return r, nil
}
func (r *Rainyun) Name() string { return "rainyun" }
func (r *Rainyun) DisplayName() string { return "雨云" }
func (r *Rainyun) ValidateAuth(ctx context.Context) error {
var data any
return r.client.get(ctx, "/user/", &data)
}
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) CDN() provider.CDNService { return nil }
func (r *Rainyun) Billing() provider.BillingService { return nil }
```
- [ ] **步骤 4:实现 server.go**
创建 `internal/provider/rainyun/server.go`:
```go
package rainyun
import (
"context"
"fmt"
"time"
"git.yoresee.cc/nightstar/ry/internal/provider"
)
type ServerService struct {
client *client
}
type rainyunServer struct {
ID int `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
IP string `json:"ip"`
VCPU int `json:"vcpu"`
Memory int `json:"memory"`
Disk int `json:"disk"`
OS string `json:"os"`
Region string `json:"zone_name"`
ExpireDate string `json:"expire_date"`
}
func (s *ServerService) List(ctx context.Context) ([]provider.Server, error) {
var raw []rainyunServer
if err := s.client.get(ctx, "/product/rcs/", &raw); err != nil {
return nil, err
}
servers := make([]provider.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 raw rainyunServer
path := fmt.Sprintf("/product/rcs/%s/", id)
if err := s.client.get(ctx, path, &raw); err != nil {
return nil, err
}
sv := toServer(raw)
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 result struct {
VNCURL string `json:"vnc_url"`
}
if err := s.client.get(ctx, fmt.Sprintf("/product/rcs/%s/vnc", id), &result); err != nil {
return "", err
}
return result.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)
}
func toServer(r rainyunServer) provider.Server {
expire, _ := time.Parse("2006-01-02", r.ExpireDate)
return provider.Server{
ID: fmt.Sprintf("%d", r.ID),
Name: r.Name,
Status: r.Status,
IP: r.IP,
CPU: r.VCPU,
Memory: r.Memory,
Disk: r.Disk,
OS: r.OS,
Region: r.Region,
ExpireAt: expire,
Raw: r,
}
}
```
- [ ] **步骤 5:运行测试验证通过**
```bash
go test ./internal/provider/rainyun/ -v
```
预期:5 个测试 PASS(client 2 + server 3)
- [ ] **步骤 6:Commit**
```bash
go mod tidy
git add -A
git commit -m "feat: rainyun server service — list/get/start/stop/reboot/vnc/upgrade"
```
---
### 任务 7:Cobra 命令层 — 根命令 + 全局 flag + 配置集成
**文件:**
- 修改:`internal/cmd/root.go`
- 创建:`internal/provider/provider.go` 中补充 ProviderConfig(已有)
- 修改:`internal/provider/registry.go`(已有)
- [ ] **步骤 1:重写 root.go(集成 viper + 配置 + 全局 flag)**
重写 `internal/cmd/root.go`:
```go
package cmd
import (
"fmt"
"os"
"git.yoresee.cc/nightstar/ry/internal/config"
"git.yoresee.cc/nightstar/ry/internal/output"
"git.yoresee.cc/nightstar/ry/internal/provider"
"github.com/spf13/cobra"
)
var (
cfg *config.Config
cfgPath string
prov provider.Provider
out *output.Printer
flagProvider string
flagOutput string
flagRaw bool
flagDebug bool
)
var rootCmd = &cobra.Command{
Use: "ry",
Short: "Multi-cloud CLI — manage cloud resources from your terminal",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
var err error
cfg, err = config.Load(cfgPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
providerName := cfg.CurrentProvider
if flagProvider != "" {
providerName = flagProvider
}
if env := os.Getenv("RY_PROVIDER"); env != "" {
providerName = env
}
if providerName == "" {
return fmt.Errorf("no provider configured — run 'ry provider add <name>' first")
}
pc := cfg.Providers[providerName]
prov, err = provider.New(providerName, provider.ProviderConfig{
APIKey: pc.APIKey,
AccessKeyID: pc.AccessKeyID,
AccessKeySecret: pc.AccessKeySecret,
Endpoint: pc.Endpoint,
})
if err != nil {
return err
}
outputFormat := cfg.Output
if flagOutput != "" {
outputFormat = flagOutput
}
if flagRaw {
outputFormat = "raw"
}
out = output.New(outputFormat, os.Stdout)
return nil
},
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
cfgPath = config.DefaultPath()
rootCmd.PersistentFlags().StringVarP(&flagProvider, "provider", "p", "", "Cloud provider to use")
rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "", "Output format: table, json, yaml, raw")
rootCmd.PersistentFlags().BoolVar(&flagRaw, "raw", false, "Output raw API response")
rootCmd.PersistentFlags().BoolVar(&flagDebug, "debug", false, "Enable debug output")
}
```
- [ ] **步骤 2:编译验证**
```bash
go build ./cmd/ry/ && ./ry --help
```
预期:输出 help 信息,包含全局 flag
- [ ] **步骤 3:Commit**
```bash
git add -A
git commit -m "feat: cobra root — config integration, global flags, provider resolution"
```
---
### 任务 8:Cobra 命令层 — provider 子命令
**文件:**
- 创建:`internal/cmd/provider.go`
- [ ] **步骤 1:实现 provider 命令**
创建 `internal/cmd/provider.go`:
```go
package cmd
import (
"fmt"
"git.yoresee.cc/nightstar/ry/internal/config"
"git.yoresee.cc/nightstar/ry/internal/provider"
"github.com/spf13/cobra"
)
var providerCmd = &cobra.Command{
Use: "provider",
Short: "Manage cloud providers",
}
var providerListCmd = &cobra.Command{
Use: "list",
Short: "List configured providers",
RunE: func(cmd *cobra.Command, args []string) error {
current := cfg.CurrentProvider
for _, name := range provider.List() {
marker := " "
if name == current {
marker = "*"
}
fmt.Printf("%s %s\n", marker, name)
}
return nil
},
}
var providerUseCmd = &cobra.Command{
Use: "use <name>",
Short: "Switch the current cloud provider",
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
},
}
var providerAddCmd = &cobra.Command{
Use: "add <name>",
Short: "Add a new cloud provider",
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
},
}
func init() {
providerCmd.AddCommand(providerListCmd)
providerCmd.AddCommand(providerUseCmd)
providerCmd.AddCommand(providerAddCmd)
rootCmd.AddCommand(providerCmd)
}
```
- [ ] **步骤 2:编译验证**
```bash
go build ./cmd/ry/ && ./ry provider --help && ./ry provider list
```
预期:子命令 help 正常输出
- [ ] **步骤 3:Commit**
```bash
git add -A
git commit -m "feat: provider cmd — list, use, add with interactive key input"
```
---
### 任务 9:Cobra 命令层 — server 子命令
**文件:**
- 创建:`internal/cmd/server.go`
- [ ] **步骤 1:实现 server 命令**
创建 `internal/cmd/server.go`:
```go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var serverCmd = &cobra.Command{
Use: "server",
Short: "Manage cloud servers",
}
var 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 flagRaw {
var raw []any
for _, s := range servers {
raw = append(raw, s.Raw)
}
return out.Print(raw)
}
return out.Print(servers)
},
}
var 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 flagRaw {
return out.Print(s.Raw)
}
return out.Print(s)
},
}
var 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
},
}
var 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
},
}
var 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
},
}
var 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
},
}
var 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
},
}
var 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
},
}
func init() {
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)
rootCmd.AddCommand(serverCmd)
}
```
- [ ] **步骤 2:编译验证**
```bash
go build ./cmd/ry/ && ./ry server --help
```
预期:server 子命令 help 正常
- [ ] **步骤 3:Commit**
```bash
git add -A
git commit -m "feat: server cmd — list/get/start/stop/reboot/reinstall/reset-password/vnc"
```
---
### 任务 10:剩余命令层 — domain / storage / billing / config
**文件:**
- 创建:`internal/cmd/domain.go`
- 创建:`internal/cmd/storage.go`
- 创建:`internal/cmd/billing.go`
- 创建:`internal/cmd/config_cmd.go`
- [ ] **步骤 1:实现 domain 命令**
创建 `internal/cmd/domain.go`:
```go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var domainCmd = &cobra.Command{
Use: "domain",
Short: "Manage domains",
}
var 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)
},
}
var dnsCmd = &cobra.Command{
Use: "dns <domain>",
Short: "Manage DNS records",
Args: cobra.ExactArgs(1),
}
var dnsListCmd = &cobra.Command{
Use: "list",
Short: "List DNS records",
RunE: func(cmd *cobra.Command, args []string) error {
domain := args[0]
svc := prov.Domain()
if svc == nil {
return fmt.Errorf("domain management not supported")
}
records, err := svc.DNSList(cmd.Context(), domain)
if err != nil {
return err
}
return out.Print(records)
},
}
var dnsAddCmd = &cobra.Command{
Use: "add",
Short: "Add a DNS record",
RunE: func(cmd *cobra.Command, args []string) error {
domain := args[0]
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(), domain,
DNSRecord{Type: recType, Name: name, Value: value, TTL: ttl})
},
}
var dnsDeleteCmd = &cobra.Command{
Use: "delete <record-id>",
Short: "Delete a DNS record",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
domain := args[0]
recordID := args[1]
svc := prov.Domain()
if svc == nil {
return fmt.Errorf("domain management not supported")
}
return svc.DNSDelete(cmd.Context(), domain, recordID)
},
}
func init() {
dnsAddCmd.Flags().String("type", "A", "Record type (A, CNAME, TXT, etc.)")
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)
rootCmd.AddCommand(domainCmd)
}
```
- [ ] **步骤 2:实现 storage 命令**
创建 `internal/cmd/storage.go`:
```go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var storageCmd = &cobra.Command{
Use: "storage",
Short: "Manage object storage",
}
var 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)
},
}
var bucketCmd = &cobra.Command{
Use: "bucket <instance-id>",
Short: "Manage buckets",
Args: cobra.ExactArgs(1),
}
var bucketListCmd = &cobra.Command{
Use: "list",
Short: "List buckets",
RunE: func(cmd *cobra.Command, args []string) error {
instanceID := args[0]
svc := prov.Storage()
if svc == nil {
return fmt.Errorf("storage management not supported")
}
buckets, err := svc.BucketList(cmd.Context(), instanceID)
if err != nil {
return err
}
return out.Print(buckets)
},
}
var bucketCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create a bucket",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
instanceID := args[0]
name := args[1]
svc := prov.Storage()
if svc == nil {
return fmt.Errorf("storage management not supported")
}
if err := svc.BucketCreate(cmd.Context(), instanceID, name); err != nil {
return err
}
fmt.Printf("Bucket %q created\n", name)
return nil
},
}
func init() {
bucketCmd.AddCommand(bucketListCmd)
bucketCmd.AddCommand(bucketCreateCmd)
storageCmd.AddCommand(storageListCmd)
storageCmd.AddCommand(bucketCmd)
rootCmd.AddCommand(storageCmd)
}
```
- [ ] **步骤 3:实现 billing 命令**
创建 `internal/cmd/billing.go`:
```go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var billingCmd = &cobra.Command{
Use: "billing",
Short: "Billing and orders",
}
var 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)
},
}
func init() {
billingCmd.AddCommand(ordersCmd)
rootCmd.AddCommand(billingCmd)
}
```
- [ ] **步骤 4:实现 config 命令**
创建 `internal/cmd/config_cmd.go`:
```go
package cmd
import (
"fmt"
"strings"
"git.yoresee.cc/nightstar/ry/internal/config"
"github.com/spf13/cobra"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage ry configuration",
}
var 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
},
}
var 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
},
}
var configPathCmd = &cobra.Command{
Use: "path",
Short: "Print config file path",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println(cfgPath)
return nil
},
}
func init() {
configCmd.AddCommand(configShowCmd)
configCmd.AddCommand(configSetCmd)
configCmd.AddCommand(configPathCmd)
rootCmd.AddCommand(configCmd)
}
```
- [ ] **步骤 5:注意 domain.go 中引用 DNSRecord 的方式**
`domain.go` 中的 `dnsAddCmd` 直接用了 `DNSRecord`,需要 import provider 包或定义类型别名。修复:
在 `internal/cmd/domain.go` 顶部 import 添加:
```go
import (
"fmt"
"git.yoresee.cc/nightstar/ry/internal/provider"
"github.com/spf13/cobra"
)
```
并将 `DNSRecord` 改为 `provider.DNSRecord`。
- [ ] **步骤 6:编译验证**
```bash
go build ./cmd/ry/ && ./ry --help && ./ry config --help && ./ry domain --help && ./ry storage --help && ./ry billing --help
```
预期:所有子命令 help 正常输出
- [ ] **步骤 7:Commit**
```bash
git add -A
git commit -m "feat: domain/storage/billing/config commands — full CLI surface"
```
---
### 任务 11:雨云剩余 Service 实现(domain/storage/billing)
**文件:**
- 创建:`internal/provider/rainyun/domain.go`
- 创建:`internal/provider/rainyun/storage.go`
- 创建:`internal/provider/rainyun/billing.go`
- 修改:`internal/provider/rainyun/rainyun.go`(挂载新 service)
- [ ] **步骤 1:实现 domain.go**
创建 `internal/provider/rainyun/domain.go`:
```go
package rainyun
import (
"context"
"fmt"
"git.yoresee.cc/nightstar/ry/internal/provider"
)
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) ([]provider.Domain, error) {
var raw []rainyunDomain
if err := d.client.get(ctx, "/product/domain/", &raw); err != nil {
return nil, err
}
domains := make([]provider.Domain, len(raw))
for i, r := range raw {
domains[i] = provider.Domain{
ID: fmt.Sprintf("%d", r.ID),
Name: r.DomainName,
}
}
return domains, nil
}
func (d *DomainService) DNSList(ctx context.Context, domain string) ([]provider.DNSRecord, error) {
var raw []struct {
ID int `json:"id"`
Type string `json:"record_type"`
Name string `json:"host"`
Value string `json:"value"`
TTL int `json:"ttl"`
}
path := fmt.Sprintf("/product/domain/%s/dns/", domain)
if err := d.client.get(ctx, path, &raw); err != nil {
return nil, err
}
records := make([]provider.DNSRecord, len(raw))
for i, r := range raw {
records[i] = provider.DNSRecord{
ID: fmt.Sprintf("%d", r.ID),
Type: r.Type,
Name: r.Name,
Value: r.Value,
TTL: r.TTL,
}
}
return records, nil
}
func (d *DomainService) DNSAdd(ctx context.Context, domain string, record provider.DNSRecord) error {
body := map[string]any{
"record_type": record.Type,
"host": record.Name,
"value": record.Value,
"ttl": record.TTL,
}
return d.client.post(ctx, fmt.Sprintf("/product/domain/%s/dns", domain), body, nil)
}
func (d *DomainService) DNSDelete(ctx context.Context, domain string, recordID string) error {
return d.client.delete(ctx, fmt.Sprintf("/product/domain/%s/dns/%s", domain, recordID), nil)
}
```
- [ ] **步骤 2:实现 storage.go**
创建 `internal/provider/rainyun/storage.go`:
```go
package rainyun
import (
"context"
"fmt"
"git.yoresee.cc/nightstar/ry/internal/provider"
)
type StorageService struct {
client *client
}
func (s *StorageService) List(ctx context.Context) ([]provider.StorageInstance, error) {
var raw []struct {
ID int `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
}
if err := s.client.get(ctx, "/product/ros/instance", &raw); err != nil {
return nil, err
}
instances := make([]provider.StorageInstance, len(raw))
for i, r := range raw {
instances[i] = provider.StorageInstance{
ID: fmt.Sprintf("%d", r.ID),
Name: r.Name,
Raw: r,
}
}
return instances, nil
}
func (s *StorageService) BucketList(ctx context.Context, instanceID string) ([]provider.Bucket, error) {
var raw []struct {
ID int `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
}
path := fmt.Sprintf("/product/ros/bucket/%s", instanceID)
if err := s.client.get(ctx, path, &raw); err != nil {
return nil, err
}
buckets := make([]provider.Bucket, len(raw))
for i, r := range raw {
buckets[i] = provider.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}
return s.client.post(ctx, fmt.Sprintf("/product/ros/bucket/%s", instanceID), body, nil)
}
```
- [ ] **步骤 3:实现 billing.go**
创建 `internal/provider/rainyun/billing.go`:
```go
package rainyun
import (
"context"
"fmt"
"time"
"git.yoresee.cc/nightstar/ry/internal/provider"
)
type BillingService struct {
client *client
}
func (b *BillingService) Orders(ctx context.Context) ([]provider.Order, error) {
var raw []struct {
ID int `json:"id"`
Product string `json:"product_name"`
Amount float64 `json:"amount"`
Status string `json:"status"`
CreatedAt string `json:"create_date"`
}
if err := b.client.get(ctx, "/expense/orders/list", &raw); err != nil {
return nil, err
}
orders := make([]provider.Order, len(raw))
for i, r := range raw {
t, _ := time.Parse("2006-01-02 15:04:05", r.CreatedAt)
orders[i] = provider.Order{
ID: fmt.Sprintf("%d", r.ID),
Product: r.Product,
Amount: r.Amount,
Status: r.Status,
Created: t,
Raw: r,
}
}
return orders, nil
}
```
- [ ] **步骤 4:更新 rainyun.go 挂载新 service**
修改 `internal/provider/rainyun/rainyun.go`:
```go
type Rainyun struct {
client *client
server *ServerService
domain *DomainService
storage *StorageService
billing *BillingService
}
func New(cfg provider.ProviderConfig) (provider.Provider, error) {
c := newClient(cfg.Endpoint, cfg.APIKey)
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) Domain() provider.DomainService { return r.domain }
func (r *Rainyun) Storage() provider.StorageService { return r.storage }
func (r *Rainyun) Billing() provider.BillingService { return r.billing }
```
- [ ] **步骤 5:编译 + 测试验证**
```bash
go build ./cmd/ry/ && go test ./internal/provider/rainyun/ -v && go vet ./...
```
预期:编译通过 + 已有测试 PASS
- [ ] **步骤 6:Commit**
```bash
git add -A
git commit -m "feat: rainyun domain/storage/billing services — full provider implementation"
```
---
### 任务 12:补全命令 + README
**文件:**
- 创建:`README.md`
- [ ] **步骤 1:添加 completion 命令**
在 `internal/cmd/root.go` 的 `init()` 中添加:
```go
rootCmd.AddCommand(&cobra.Command{
Use: "completion [bash|zsh|fish]",
Short: "Generate shell completion script",
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])
}
},
})
```
- [ ] **步骤 2:编写 README.md**
创建 `README.md`:
```markdown
# 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>
ry provider use aliyun # switch provider
```
## Commands
- `ry provider` — manage cloud providers
- `ry server` — manage cloud servers (list, get, start, stop, reboot, reinstall, vnc)
- `ry domain` — manage domains and DNS
- `ry storage` — manage object storage
- `ry billing` — view orders
- `ry config` — manage configuration
- `ry completion` — shell completion
## 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`
```
- [ ] **步骤 3:最终编译 + 全量测试**
```bash
cd /root/.openclaw/workspace/project/rainyun-cli
go build ./cmd/ry/ && go test ./... -v && go vet ./...
```
预期:全部编译/测试通过
- [ ] **步骤 4:Commit**
```bash
git add -A
git commit -m "feat: completion + readme — polish and docs"
```
---
## 自检
1. **规格覆盖度**:设计规格中所有章节均有对应任务——项目结构(任务1)、配置系统(任务2)、输出系统(任务3)、Provider接口(任务4)、雨云客户端(任务5)、ServerService(任务6)、命令层(任务7-10)、剩余Service(任务11)、补全+README(任务12)。错误处理在任务5的 APIError 中已覆盖。
2. **占位符扫描**:无 "TODO"、"待定"、"后续实现"。所有代码步骤均有实际代码块。
3. **类型一致性**:`provider.Server`、`provider.DNSRecord` 等在接口定义和实现中一致。`domain.go` 命令层明确引用 `provider.DNSRecord`。