Compare commits
7
Commits
master
...
d6e6ded06d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6e6ded06d | ||
|
|
04f2942d77 | ||
|
|
11dd8bf0fc | ||
|
|
984fdfdb79 | ||
|
|
03f089c9c3 | ||
|
|
5a75b3e7cd | ||
|
|
c6d945cfcc |
+19
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user