From 7a19c154d01896aa8e30ae6821ecf0d50c840478 Mon Sep 17 00:00:00 2001 From: NightStar Date: Tue, 28 Jul 2026 16:02:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20rainyun=20server=20service=20=E2=80=94?= =?UTF-8?q?=20list/get/start/stop/reboot/vnc/upgrade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/rainyun/rainyun.go | 45 +++++++++ internal/provider/rainyun/server.go | 120 +++++++++++++++++++++++ internal/provider/rainyun/server_test.go | 91 +++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 internal/provider/rainyun/rainyun.go create mode 100644 internal/provider/rainyun/server.go create mode 100644 internal/provider/rainyun/server_test.go diff --git a/internal/provider/rainyun/rainyun.go b/internal/provider/rainyun/rainyun.go new file mode 100644 index 0000000..09424e9 --- /dev/null +++ b/internal/provider/rainyun/rainyun.go @@ -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 } diff --git a/internal/provider/rainyun/server.go b/internal/provider/rainyun/server.go new file mode 100644 index 0000000..12f675e --- /dev/null +++ b/internal/provider/rainyun/server.go @@ -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, + } +} diff --git a/internal/provider/rainyun/server_test.go b/internal/provider/rainyun/server_test.go new file mode 100644 index 0000000..2171fbd --- /dev/null +++ b/internal/provider/rainyun/server_test.go @@ -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) + } +}