2592 lines
59 KiB
Markdown
2592 lines
59 KiB
Markdown
# 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`。
|