Compare commits

...
7 Commits
20 changed files with 1191 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Binaries
ry
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of go coverage
*.out
# IDE
.idea/
.vscode/
*.swp
*.swo
+15
View File
@@ -0,0 +1,15 @@
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)
}
}
+14
View File
@@ -0,0 +1,14 @@
module git.yoresee.cc/nightstar/ry
go 1.18
require (
github.com/BurntSushi/toml v1.6.0
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
+15
View File
@@ -0,0 +1,15 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+14
View File
@@ -0,0 +1,14 @@
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()
}
+67
View File
@@ -0,0 +1,67 @@
package config
import (
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
type ProviderConfig struct {
APIKey string `toml:"api_key,omitempty"`
AccessKeyID string `toml:"access_key_id,omitempty"`
AccessKeySecret string `toml:"access_key_secret,omitempty"`
Endpoint string `toml:"endpoint,omitempty"`
}
type Config struct {
CurrentProvider string `toml:"current_provider"`
Output string `toml:"output"`
Providers map[string]ProviderConfig `toml:"providers"`
}
func Load(path string) (*Config, error) {
cfg := &Config{
Output: "table",
Providers: make(map[string]ProviderConfig),
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, err
}
if err := toml.Unmarshal(data, cfg); err != nil {
return nil, err
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderConfig)
}
if cfg.Output == "" {
cfg.Output = "table"
}
return cfg, nil
}
func Save(path string, cfg *Config) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
enc := toml.NewEncoder(f)
return enc.Encode(cfg)
}
func DefaultPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "config.toml"
}
return filepath.Join(home, ".config", "ry", "config.toml")
}
+96
View File
@@ -0,0 +1,96 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadTOML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
content := `
current_provider = "rainyun"
output = "table"
[providers.rainyun]
api_key = "sk-test123"
[providers.aliyun]
access_key_id = "ak-xxx"
access_key_secret = "sk-xxx"
`
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.CurrentProvider != "rainyun" {
t.Errorf("CurrentProvider = %q, want %q", cfg.CurrentProvider, "rainyun")
}
if cfg.Output != "table" {
t.Errorf("Output = %q, want %q", cfg.Output, "table")
}
if cfg.Providers["rainyun"].APIKey != "sk-test123" {
t.Errorf("rainyun api_key = %q, want %q", cfg.Providers["rainyun"].APIKey, "sk-test123")
}
if cfg.Providers["aliyun"].AccessKeyID != "ak-xxx" {
t.Errorf("aliyun access_key_id = %q, want %q", cfg.Providers["aliyun"].AccessKeyID, "ak-xxx")
}
}
func TestLoadDefaults(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.toml")
if err := os.WriteFile(path, []byte(""), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Output != "table" {
t.Errorf("default Output = %q, want %q", cfg.Output, "table")
}
}
func TestSave(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "out.toml")
cfg := &Config{
CurrentProvider: "test",
Output: "json",
Providers: map[string]ProviderConfig{
"test": {APIKey: "secret"},
},
}
if err := Save(path, cfg); err != nil {
t.Fatalf("Save() error: %v", err)
}
loaded, err := Load(path)
if err != nil {
t.Fatalf("re-Load() error: %v", err)
}
if loaded.CurrentProvider != "test" {
t.Errorf("roundtrip CurrentProvider = %q", loaded.CurrentProvider)
}
}
func TestDefaultPath(t *testing.T) {
path := DefaultPath()
if path == "" {
t.Error("DefaultPath() returned empty")
}
}
func TestEnsureDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "sub", "dir")
path := filepath.Join(dir, "config.toml")
cfg := &Config{Output: "table"}
if err := Save(path, cfg); err != nil {
t.Fatalf("Save() to deep dir error: %v", err)
}
}
+12
View File
@@ -0,0 +1,12 @@
package output
import (
"encoding/json"
"io"
)
func printJSON(w io.Writer, data any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(data)
}
+33
View File
@@ -0,0 +1,33 @@
package output
import (
"fmt"
"io"
)
type Printer struct {
format string
writer io.Writer
}
func New(format string, w io.Writer) *Printer {
return &Printer{format: format, writer: w}
}
func (p *Printer) Print(data any) error {
switch p.format {
case "table":
return printTable(p.writer, data)
case "json":
return printJSON(p.writer, data)
case "yaml":
return printYAML(p.writer, data)
default:
return fmt.Errorf("unknown output format: %s", p.format)
}
}
func (p *Printer) PrintRaw(raw []byte) error {
_, err := fmt.Fprintln(p.writer, string(raw))
return err
}
+96
View File
@@ -0,0 +1,96 @@
package output
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
type testItem struct {
ID string `json:"id" table:"ID"`
Name string `json:"name" table:"NAME"`
Age int `json:"age" table:"AGE"`
}
func TestTableFormat(t *testing.T) {
items := []testItem{
{ID: "1", Name: "Alice", Age: 30},
{ID: "2", Name: "Bob", Age: 25},
}
var buf bytes.Buffer
printer := New("table", &buf)
if err := printer.Print(items); err != nil {
t.Fatalf("Print table error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "ID") || !strings.Contains(out, "Alice") {
t.Errorf("table output missing headers or data:\n%s", out)
}
}
func TestJSONFormat(t *testing.T) {
items := []testItem{
{ID: "1", Name: "Alice", Age: 30},
}
var buf bytes.Buffer
printer := New("json", &buf)
if err := printer.Print(items); err != nil {
t.Fatalf("Print json error: %v", err)
}
var parsed []testItem
if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
if len(parsed) != 1 || parsed[0].ID != "1" {
t.Errorf("JSON content mismatch: %+v", parsed)
}
}
func TestYAMLFormat(t *testing.T) {
items := []testItem{
{ID: "1", Name: "Alice", Age: 30},
}
var buf bytes.Buffer
printer := New("yaml", &buf)
if err := printer.Print(items); err != nil {
t.Fatalf("Print yaml error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "id:") || !strings.Contains(out, "Alice") {
t.Errorf("yaml output missing data:\n%s", out)
}
}
func TestRawFormat(t *testing.T) {
raw := []byte(`{"raw":"data"}`)
var buf bytes.Buffer
printer := New("raw", &buf)
if err := printer.PrintRaw(raw); err != nil {
t.Fatalf("PrintRaw error: %v", err)
}
if buf.String() != `{"raw":"data"}`+"\n" {
t.Errorf("raw output = %q", buf.String())
}
}
func TestSingleItem(t *testing.T) {
item := testItem{ID: "1", Name: "Solo", Age: 99}
var buf bytes.Buffer
printer := New("json", &buf)
if err := printer.Print(item); err != nil {
t.Fatalf("Print single item error: %v", err)
}
if !strings.Contains(buf.String(), "Solo") {
t.Errorf("single item output missing data:\n%s", buf.String())
}
}
func TestUnknownFormat(t *testing.T) {
var buf bytes.Buffer
printer := New("unknown", &buf)
err := printer.Print([]testItem{})
if err == nil {
t.Error("expected error for unknown format")
}
}
+120
View File
@@ -0,0 +1,120 @@
package output
import (
"fmt"
"io"
"reflect"
"strings"
)
func printTable(w io.Writer, data any) error {
v := reflect.ValueOf(data)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() == reflect.Slice {
return printTableSlice(w, v)
}
return printTableSingle(w, v)
}
func printTableSlice(w io.Writer, v reflect.Value) error {
if v.Len() == 0 {
fmt.Fprintln(w, "(empty)")
return nil
}
elemType := v.Index(0).Type()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
headers, cols := extractTableTags(elemType)
if len(headers) == 0 {
return fmt.Errorf("no table tags found on type %s", elemType.Name())
}
// Collect all rows
rows := make([][]string, v.Len())
for i := 0; i < v.Len(); i++ {
rows[i] = extractRow(v.Index(i), cols)
}
// Calculate column widths
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = len(h)
}
for _, row := range rows {
for i, cell := range row {
if len(cell) > widths[i] {
widths[i] = len(cell)
}
}
}
// Print headers
sep := " "
renderRow(w, headers, widths, sep)
// Print separator line
separator := make([]string, len(headers))
for i, ww := range widths {
separator[i] = strings.Repeat("-", ww)
}
renderRow(w, separator, widths, sep)
// Print data rows
for _, row := range rows {
renderRow(w, row, widths, sep)
}
return nil
}
func renderRow(w io.Writer, cells []string, widths []int, sep string) {
parts := make([]string, len(cells))
for i, cell := range cells {
parts[i] = fmt.Sprintf("%-*s", widths[i], cell)
}
fmt.Fprintln(w, strings.Join(parts, sep))
}
func printTableSingle(w io.Writer, v reflect.Value) error {
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
t := v.Type()
for i := 0; i < t.NumField(); i++ {
tag := t.Field(i).Tag.Get("table")
if tag == "" {
continue
}
val := fmt.Sprintf("%v", v.Field(i).Interface())
fmt.Fprintf(w, "%-10s %s\n", tag+":", val)
}
return nil
}
func extractTableTags(t reflect.Type) ([]string, []int) {
var headers []string
var cols []int
for i := 0; i < t.NumField(); i++ {
tag := t.Field(i).Tag.Get("table")
if tag == "" {
continue
}
headers = append(headers, strings.ToUpper(tag))
cols = append(cols, i)
}
return headers, cols
}
func extractRow(v reflect.Value, cols []int) []string {
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
row := make([]string, len(cols))
for i, idx := range cols {
row[i] = fmt.Sprintf("%v", v.Field(idx).Interface())
}
return row
}
+13
View File
@@ -0,0 +1,13 @@
package output
import (
"io"
"gopkg.in/yaml.v3"
)
func printYAML(w io.Writer, data any) error {
enc := yaml.NewEncoder(w)
enc.SetIndent(2)
return enc.Encode(data)
}
+132
View File
@@ -0,0 +1,132 @@
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))
}
+132
View File
@@ -0,0 +1,132 @@
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 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %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 msg
}
+59
View File
@@ -0,0 +1,59 @@
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, want %q", result.Data.Name, "test")
}
}
func TestClientAPIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":1002,"msg":"not found"}`))
}))
defer srv.Close()
c := newClient(srv.URL, "sk-test")
var result any
err := c.get(context.Background(), "/error", &result)
if err == nil {
t.Fatal("expected error for 404")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("expected *APIError, got %T", err)
}
if apiErr.StatusCode != 404 {
t.Errorf("error code = %d, want 404", apiErr.StatusCode)
}
}
+45
View File
@@ -0,0 +1,45 @@
package rainyun
import (
"context"
"git.yoresee.cc/nightstar/ry/internal/provider"
)
func init() {
provider.Register("rainyun", New)
}
// Rainyun provider — 雨云 cloud platform.
type Rainyun struct {
client *client
server *ServerService
}
// New creates a Rainyun provider from config.
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 "雨云" }
// ValidateAuth checks the API key by hitting the user info endpoint.
func (r *Rainyun) ValidateAuth(ctx context.Context) error {
var data any
return r.client.get(ctx, "/user/", &data)
}
func (r *Rainyun) Server() provider.ServerService {
if r.server == nil {
r.server = &ServerService{client: r.client}
}
return r.server
}
func (r *Rainyun) Domain() provider.DomainService { return 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 }
+120
View File
@@ -0,0 +1,120 @@
package rainyun
import (
"context"
"fmt"
"time"
"git.yoresee.cc/nightstar/ry/internal/provider"
)
// ServerService implements provider.ServerService for 雨云.
type ServerService struct {
client *client
}
// rainyunServer mirrors the Rainyun RCS API JSON fields.
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"`
}
// Response wrappers — the client unmarshals the full API envelope (code + data).
type serverListResponse struct {
Code int `json:"code"`
Data []rainyunServer `json:"data"`
}
type serverGetResponse struct {
Code int `json:"code"`
Data rainyunServer `json:"data"`
}
type vncResponse struct {
Code int `json:"code"`
Data struct {
VNCURL string `json:"vnc_url"`
} `json:"data"`
}
func (s *ServerService) List(ctx context.Context) ([]provider.Server, error) {
var resp serverListResponse
if err := s.client.get(ctx, "/product/rcs/", &resp); err != nil {
return nil, err
}
servers := make([]provider.Server, len(resp.Data))
for i, r := range resp.Data {
servers[i] = toServer(r)
}
return servers, nil
}
func (s *ServerService) Get(ctx context.Context, id string) (*provider.Server, error) {
var resp serverGetResponse
path := fmt.Sprintf("/product/rcs/%s/", id)
if err := s.client.get(ctx, path, &resp); err != nil {
return nil, err
}
sv := toServer(resp.Data)
return &sv, nil
}
func (s *ServerService) Start(ctx context.Context, id string) error {
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/start", id), nil, nil)
}
func (s *ServerService) Stop(ctx context.Context, id string) error {
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/stop", id), nil, nil)
}
func (s *ServerService) Reboot(ctx context.Context, id string) error {
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/reboot", id), nil, nil)
}
func (s *ServerService) Reinstall(ctx context.Context, id, osTemplate string) error {
body := map[string]string{"os": osTemplate}
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/changeos", id), body, nil)
}
func (s *ServerService) ResetPassword(ctx context.Context, id string) error {
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/reset-password", id), nil, nil)
}
func (s *ServerService) GetVNC(ctx context.Context, id string) (string, error) {
var resp vncResponse
if err := s.client.get(ctx, fmt.Sprintf("/product/rcs/%s/vnc", id), &resp); err != nil {
return "", err
}
return resp.Data.VNCURL, nil
}
func (s *ServerService) Upgrade(ctx context.Context, id, plan string) error {
body := map[string]string{"plan": plan}
return s.client.post(ctx, fmt.Sprintf("/product/rcs/%s/upgrade", id), body, nil)
}
// toServer maps a rainyunServer to the provider.Server model.
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,
}
}
+91
View File
@@ -0,0 +1,91 @@
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)
}
}
+41
View File
@@ -0,0 +1,41 @@
package provider
import (
"fmt"
"sort"
"sync"
)
type Factory func(cfg ProviderConfig) (Provider, error)
var (
registry = map[string]Factory{}
registryMu sync.RWMutex
)
func Register(name string, factory Factory) {
registryMu.Lock()
defer registryMu.Unlock()
registry[name] = factory
}
func New(name string, cfg ProviderConfig) (Provider, error) {
registryMu.RLock()
factory, ok := registry[name]
registryMu.RUnlock()
if !ok {
return nil, fmt.Errorf("unknown provider: %s (run 'ry provider add %s' first)", name, name)
}
return factory(cfg)
}
func List() []string {
registryMu.RLock()
defer registryMu.RUnlock()
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
sort.Strings(names)
return names
}
+57
View File
@@ -0,0 +1,57 @@
package provider
import (
"context"
"testing"
)
type mockProvider struct {
name string
}
func (m *mockProvider) Name() string { return m.name }
func (m *mockProvider) DisplayName() string { return "Mock" }
func (m *mockProvider) ValidateAuth(ctx context.Context) error { return nil }
func (m *mockProvider) Server() ServerService { return nil }
func (m *mockProvider) Domain() DomainService { return nil }
func (m *mockProvider) Storage() StorageService { return nil }
func (m *mockProvider) CDN() CDNService { return nil }
func (m *mockProvider) Billing() BillingService { return nil }
func TestRegistryRegisterAndNew(t *testing.T) {
Register("mock", func(cfg ProviderConfig) (Provider, error) {
return &mockProvider{name: "mock"}, nil
})
p, err := New("mock", ProviderConfig{})
if err != nil {
t.Fatalf("New() error: %v", err)
}
if p.Name() != "mock" {
t.Errorf("Name() = %q, want %q", p.Name(), "mock")
}
}
func TestRegistryUnknown(t *testing.T) {
_, err := New("unknown", ProviderConfig{})
if err == nil {
t.Error("expected error for unknown provider")
}
}
func TestRegistryList(t *testing.T) {
Register("list-test", func(cfg ProviderConfig) (Provider, error) {
return &mockProvider{name: "list-test"}, nil
})
names := List()
found := false
for _, n := range names {
if n == "list-test" {
found = true
break
}
}
if !found {
t.Errorf("List() should contain 'list-test', got %v", names)
}
}