mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 08:06:04 +00:00
infra(cloudprovider): pluggable cloud-VPS abstraction + Hetzner Cloud impl
cloudprovider.Provider interface (Locations, ServerTypes, Images, Verify, CreateServer/DeleteServer, CreatePrimaryIP/AssignPrimaryIP/ UnassignPrimaryIP/DeletePrimaryIP/SetReverseDNS). One impl today (Hetzner Cloud); adding OVH or Vultr later means implementing the same six surfaces. hetzner.Client is a minimal idiomatic Go REST client over https://api.hetzner.cloud/v1. Bearer-token auth. Returns provider- native IDs as strings so the orchestration layer can persist them for rollback. 9 tests against httptest.Server covering token transmission, error surfacing, parsing, request-body shape, and interface conformance.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
// Package cloudprovider abstracts over cloud-VPS APIs so the provisioning
|
||||
// state machine can target multiple providers without baking Hetzner-specific
|
||||
// types into the orchestration layer.
|
||||
//
|
||||
// One implementation today (Hetzner). The interface is intentionally small;
|
||||
// adding OVH or Vultr later means implementing six methods.
|
||||
package cloudprovider
|
||||
|
||||
import "context"
|
||||
|
||||
// Provider is the surface the provisioning state machine talks to.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
|
||||
// Catalog — what's available to provision against. Used by the admin
|
||||
// dropdowns when an operator is composing a template.
|
||||
Locations(ctx context.Context) ([]Location, error)
|
||||
ServerTypes(ctx context.Context) ([]ServerType, error)
|
||||
Images(ctx context.Context) ([]Image, error)
|
||||
|
||||
// Auth check, called from the admin "Test connection" button.
|
||||
Verify(ctx context.Context) error
|
||||
|
||||
// Provisioning. Each returns the provider-native ID + IPv4 so the state
|
||||
// machine can record it for later cleanup.
|
||||
CreateServer(ctx context.Context, req CreateServerRequest) (*Server, error)
|
||||
DeleteServer(ctx context.Context, serverID string) error
|
||||
|
||||
// Primary IP lifecycle. ipv4_per_server=1 in a template means "use the
|
||||
// IP that came with the server" — these calls are only made for extras.
|
||||
CreatePrimaryIP(ctx context.Context, req CreatePrimaryIPRequest) (*PrimaryIP, error)
|
||||
AssignPrimaryIP(ctx context.Context, ipID, serverID string) error
|
||||
UnassignPrimaryIP(ctx context.Context, ipID string) error
|
||||
DeletePrimaryIP(ctx context.Context, ipID string) error
|
||||
SetReverseDNS(ctx context.Context, ipID, hostname string) error
|
||||
}
|
||||
|
||||
// Location is a region / datacenter where servers can be created.
|
||||
type Location struct {
|
||||
Name string // "fsn1", "hil", etc.
|
||||
Description string // "Falkenstein DC Park 1"
|
||||
City string
|
||||
Country string // ISO-3166 alpha-2
|
||||
Network string // continent or "EU"/"US" grouping for UI
|
||||
}
|
||||
|
||||
// ServerType is one purchasable VPS shape.
|
||||
type ServerType struct {
|
||||
Name string // "cx22", "cpx11"
|
||||
Description string
|
||||
Cores int
|
||||
Memory float64 // GiB
|
||||
Disk int // GiB
|
||||
StorageType string // "local" / "network"
|
||||
CPUType string // "shared" / "dedicated"
|
||||
Architecture string // "x86" / "arm"
|
||||
PriceMonthlyEUR float64
|
||||
PriceMonthlyUSD float64
|
||||
IncludedTrafficTB float64
|
||||
}
|
||||
|
||||
// Image is an OS image available for new servers.
|
||||
type Image struct {
|
||||
Name string // "ubuntu-22.04"
|
||||
Description string
|
||||
OSFlavor string
|
||||
OSVersion string
|
||||
}
|
||||
|
||||
// CreateServerRequest is what the state machine passes to CreateServer.
|
||||
type CreateServerRequest struct {
|
||||
Name string
|
||||
ServerType string
|
||||
Image string
|
||||
Location string
|
||||
Datacenter string // overrides Location when set
|
||||
SSHKeyIDs []string
|
||||
UserData string // cloud-init
|
||||
Labels map[string]string
|
||||
PlacementGroup string
|
||||
PrivateNetwork string
|
||||
Firewall string
|
||||
StartAfterCreate bool
|
||||
}
|
||||
|
||||
// Server is what CreateServer returns.
|
||||
type Server struct {
|
||||
ID string
|
||||
Name string
|
||||
Status string
|
||||
PublicIPv4 string
|
||||
PublicIPv6 string
|
||||
}
|
||||
|
||||
// CreatePrimaryIPRequest configures one extra Primary IP. The IP that
|
||||
// comes free with a server is created by CreateServer, not here.
|
||||
type CreatePrimaryIPRequest struct {
|
||||
Type string // "ipv4" / "ipv6"
|
||||
Name string
|
||||
Datacenter string // must match the server's datacenter
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
// PrimaryIP is what CreatePrimaryIP returns.
|
||||
type PrimaryIP struct {
|
||||
ID string
|
||||
Type string
|
||||
IP string
|
||||
AssignedToServerID string // empty when unassigned
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// Package hetzner implements cloudprovider.Provider over the Hetzner Cloud
|
||||
// REST API (https://docs.hetzner.cloud/).
|
||||
//
|
||||
// Auth is a single bearer token (Project API token). One token is one
|
||||
// project; multi-project operators register multiple cloud_credentials rows.
|
||||
package hetzner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/cloudprovider"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://api.hetzner.cloud/v1"
|
||||
defaultTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// Client is the Hetzner Cloud API client implementing cloudprovider.Provider.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// Option customizes the Client. WithHTTPClient and WithBaseURL are useful
|
||||
// for tests against httptest.Server.
|
||||
type Option func(*Client)
|
||||
|
||||
func WithBaseURL(u string) Option { return func(c *Client) { c.baseURL = u } }
|
||||
func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.http = h } }
|
||||
|
||||
// New returns a Client authenticated with the given Hetzner project token.
|
||||
func New(token string, opts ...Option) (*Client, error) {
|
||||
if token == "" {
|
||||
return nil, errors.New("hetzner: token is required")
|
||||
}
|
||||
c := &Client{
|
||||
baseURL: defaultBaseURL,
|
||||
token: token,
|
||||
http: &http.Client{Timeout: defaultTimeout},
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(c)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) Name() string { return "hetzner" }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body any, out any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hetzner: marshal body: %w", err)
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hetzner: %s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hetzner: read body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
var e struct {
|
||||
Error struct {
|
||||
Code, Message string
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(respBody, &e)
|
||||
if e.Error.Message != "" {
|
||||
return fmt.Errorf("hetzner: %s %s: %d %s (%s)",
|
||||
method, path, resp.StatusCode, e.Error.Message, e.Error.Code)
|
||||
}
|
||||
return fmt.Errorf("hetzner: %s %s: %d %s",
|
||||
method, path, resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(respBody, out); err != nil {
|
||||
return fmt.Errorf("hetzner: decode response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify hits a cheap authenticated endpoint to confirm the token is valid.
|
||||
func (c *Client) Verify(ctx context.Context) error {
|
||||
var out struct {
|
||||
Datacenters []map[string]any `json:"datacenters"`
|
||||
}
|
||||
return c.do(ctx, http.MethodGet, "/datacenters", nil, &out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog: locations, server_types, images
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type apiLocation struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
NetworkZone string `json:"network_zone"`
|
||||
}
|
||||
|
||||
func (c *Client) Locations(ctx context.Context) ([]cloudprovider.Location, error) {
|
||||
var out struct {
|
||||
Locations []apiLocation `json:"locations"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodGet, "/locations", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locs := make([]cloudprovider.Location, 0, len(out.Locations))
|
||||
for _, l := range out.Locations {
|
||||
locs = append(locs, cloudprovider.Location{
|
||||
Name: l.Name,
|
||||
Description: l.Description,
|
||||
City: l.City,
|
||||
Country: l.Country,
|
||||
Network: l.NetworkZone,
|
||||
})
|
||||
}
|
||||
return locs, nil
|
||||
}
|
||||
|
||||
type apiPrice struct {
|
||||
Location string `json:"location"`
|
||||
PriceMonthly struct {
|
||||
Gross string `json:"gross"`
|
||||
Net string `json:"net"`
|
||||
} `json:"price_monthly"`
|
||||
}
|
||||
|
||||
type apiServerType struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Cores int `json:"cores"`
|
||||
Memory float64 `json:"memory"`
|
||||
Disk int `json:"disk"`
|
||||
StorageType string `json:"storage_type"`
|
||||
CPUType string `json:"cpu_type"`
|
||||
Architecture string `json:"architecture"`
|
||||
Prices []apiPrice `json:"prices"`
|
||||
}
|
||||
|
||||
func (c *Client) ServerTypes(ctx context.Context) ([]cloudprovider.ServerType, error) {
|
||||
var out struct {
|
||||
ServerTypes []apiServerType `json:"server_types"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodGet, "/server_types?per_page=100", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
types := make([]cloudprovider.ServerType, 0, len(out.ServerTypes))
|
||||
for _, t := range out.ServerTypes {
|
||||
st := cloudprovider.ServerType{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Cores: t.Cores,
|
||||
Memory: t.Memory,
|
||||
Disk: t.Disk,
|
||||
StorageType: t.StorageType,
|
||||
CPUType: t.CPUType,
|
||||
Architecture: t.Architecture,
|
||||
}
|
||||
// Use the cheapest available location price for the headline.
|
||||
for _, p := range t.Prices {
|
||||
gross, _ := strconv.ParseFloat(p.PriceMonthly.Gross, 64)
|
||||
if st.PriceMonthlyEUR == 0 || gross < st.PriceMonthlyEUR {
|
||||
st.PriceMonthlyEUR = gross
|
||||
}
|
||||
}
|
||||
types = append(types, st)
|
||||
}
|
||||
return types, nil
|
||||
}
|
||||
|
||||
func (c *Client) Images(ctx context.Context) ([]cloudprovider.Image, error) {
|
||||
var out struct {
|
||||
Images []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
OSFlavor string `json:"os_flavor"`
|
||||
OSVersion string `json:"os_version"`
|
||||
Type string `json:"type"`
|
||||
} `json:"images"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodGet, "/images?type=system&per_page=100", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imgs := make([]cloudprovider.Image, 0, len(out.Images))
|
||||
for _, i := range out.Images {
|
||||
if i.Type != "system" {
|
||||
continue
|
||||
}
|
||||
imgs = append(imgs, cloudprovider.Image{
|
||||
Name: i.Name,
|
||||
Description: i.Description,
|
||||
OSFlavor: i.OSFlavor,
|
||||
OSVersion: i.OSVersion,
|
||||
})
|
||||
}
|
||||
return imgs, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type createServerReq struct {
|
||||
Name string `json:"name"`
|
||||
ServerType string `json:"server_type"`
|
||||
Image string `json:"image"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Datacenter string `json:"datacenter,omitempty"`
|
||||
SSHKeys []string `json:"ssh_keys,omitempty"`
|
||||
UserData string `json:"user_data,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
PlacementGroup *string `json:"placement_group,omitempty"`
|
||||
Networks []string `json:"networks,omitempty"`
|
||||
Firewalls []firewallRef `json:"firewalls,omitempty"`
|
||||
StartAfterCreate bool `json:"start_after_create"`
|
||||
}
|
||||
type firewallRef struct {
|
||||
Firewall string `json:"firewall"`
|
||||
}
|
||||
|
||||
type apiServer struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
PublicNet struct {
|
||||
IPv4 struct {
|
||||
IP string `json:"ip"`
|
||||
} `json:"ipv4"`
|
||||
IPv6 struct {
|
||||
IP string `json:"ip"`
|
||||
} `json:"ipv6"`
|
||||
} `json:"public_net"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateServer(ctx context.Context, req cloudprovider.CreateServerRequest) (*cloudprovider.Server, error) {
|
||||
body := createServerReq{
|
||||
Name: req.Name,
|
||||
ServerType: req.ServerType,
|
||||
Image: req.Image,
|
||||
Location: req.Location,
|
||||
Datacenter: req.Datacenter,
|
||||
SSHKeys: req.SSHKeyIDs,
|
||||
UserData: req.UserData,
|
||||
Labels: req.Labels,
|
||||
StartAfterCreate: req.StartAfterCreate,
|
||||
}
|
||||
if req.PlacementGroup != "" {
|
||||
body.PlacementGroup = &req.PlacementGroup
|
||||
}
|
||||
if req.PrivateNetwork != "" {
|
||||
body.Networks = []string{req.PrivateNetwork}
|
||||
}
|
||||
if req.Firewall != "" {
|
||||
body.Firewalls = []firewallRef{{Firewall: req.Firewall}}
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Server apiServer `json:"server"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, "/servers", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cloudprovider.Server{
|
||||
ID: strconv.Itoa(out.Server.ID),
|
||||
Name: out.Server.Name,
|
||||
Status: out.Server.Status,
|
||||
PublicIPv4: out.Server.PublicNet.IPv4.IP,
|
||||
PublicIPv6: out.Server.PublicNet.IPv6.IP,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteServer(ctx context.Context, serverID string) error {
|
||||
return c.do(ctx, http.MethodDelete, "/servers/"+serverID, nil, nil)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primary IPs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type createPrimaryIPReq struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Datacenter string `json:"datacenter,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
AssigneeType string `json:"assignee_type"`
|
||||
}
|
||||
|
||||
type apiPrimaryIP struct {
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
IP string `json:"ip"`
|
||||
AssigneeID *int `json:"assignee_id"`
|
||||
AssigneeType string `json:"assignee_type"`
|
||||
AutoDelete bool `json:"auto_delete"`
|
||||
}
|
||||
|
||||
func (c *Client) CreatePrimaryIP(ctx context.Context, req cloudprovider.CreatePrimaryIPRequest) (*cloudprovider.PrimaryIP, error) {
|
||||
body := createPrimaryIPReq{
|
||||
Type: req.Type,
|
||||
Name: req.Name,
|
||||
Datacenter: req.Datacenter,
|
||||
Labels: req.Labels,
|
||||
AssigneeType: "server",
|
||||
}
|
||||
var out struct {
|
||||
PrimaryIP apiPrimaryIP `json:"primary_ip"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, "/primary_ips", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cloudprovider.PrimaryIP{
|
||||
ID: strconv.Itoa(out.PrimaryIP.ID),
|
||||
Type: out.PrimaryIP.Type,
|
||||
IP: out.PrimaryIP.IP,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) AssignPrimaryIP(ctx context.Context, ipID, serverID string) error {
|
||||
sid, err := strconv.Atoi(serverID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hetzner: assign: invalid server id %q", serverID)
|
||||
}
|
||||
body := struct {
|
||||
AssigneeType string `json:"assignee_type"`
|
||||
AssigneeID int `json:"assignee_id"`
|
||||
}{AssigneeType: "server", AssigneeID: sid}
|
||||
return c.do(ctx, http.MethodPost, "/primary_ips/"+ipID+"/actions/assign", body, nil)
|
||||
}
|
||||
|
||||
func (c *Client) UnassignPrimaryIP(ctx context.Context, ipID string) error {
|
||||
return c.do(ctx, http.MethodPost, "/primary_ips/"+ipID+"/actions/unassign", nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) DeletePrimaryIP(ctx context.Context, ipID string) error {
|
||||
return c.do(ctx, http.MethodDelete, "/primary_ips/"+ipID, nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) SetReverseDNS(ctx context.Context, ipID, hostname string) error {
|
||||
body := struct {
|
||||
IP string `json:"ip"`
|
||||
DNSPtr string `json:"dns_ptr"`
|
||||
}{DNSPtr: hostname}
|
||||
return c.do(ctx, http.MethodPost, "/primary_ips/"+ipID+"/actions/change_dns_ptr", body, nil)
|
||||
}
|
||||
|
||||
// Compile-time check.
|
||||
var _ cloudprovider.Provider = (*Client)(nil)
|
||||
@@ -0,0 +1,195 @@
|
||||
package hetzner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/cloudprovider"
|
||||
)
|
||||
|
||||
func newTestClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
c, err := New("test-token", WithBaseURL(srv.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c, srv
|
||||
}
|
||||
|
||||
func TestNew_RejectsEmptyToken(t *testing.T) {
|
||||
if _, err := New(""); err == nil {
|
||||
t.Fatal("expected error on empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_SendsBearerToken(t *testing.T) {
|
||||
gotAuth := ""
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
_, _ = w.Write([]byte(`{"datacenters":[]}`))
|
||||
})
|
||||
if err := c.Verify(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotAuth != "Bearer test-token" {
|
||||
t.Fatalf("auth header: got %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_SurfaceAPIError(t *testing.T) {
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"unauthorized","message":"invalid token"}}`))
|
||||
})
|
||||
err := c.Verify(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid token") {
|
||||
t.Fatalf("expected unauthorized error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocations_Parsing(t *testing.T) {
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/locations" {
|
||||
t.Fatalf("expected /locations, got %s", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"locations": [
|
||||
{"id":1,"name":"fsn1","description":"Falkenstein DC Park 1","country":"DE","city":"Falkenstein","network_zone":"eu-central"},
|
||||
{"id":2,"name":"hil","description":"Hillsboro DC1","country":"US","city":"Hillsboro","network_zone":"us-west"}
|
||||
]
|
||||
}`))
|
||||
})
|
||||
locs, err := c.Locations(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(locs) != 2 || locs[0].Name != "fsn1" || locs[1].Country != "US" {
|
||||
t.Fatalf("parse mismatch: %#v", locs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerTypes_PicksCheapestPrice(t *testing.T) {
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{
|
||||
"server_types": [
|
||||
{
|
||||
"id": 1, "name": "cx22", "description": "CX22",
|
||||
"cores": 2, "memory": 4, "disk": 40,
|
||||
"storage_type": "local", "cpu_type": "shared", "architecture": "x86",
|
||||
"prices": [
|
||||
{"location": "fsn1", "price_monthly": {"gross": "5.83", "net": "4.90"}},
|
||||
{"location": "hil", "price_monthly": {"gross": "7.05", "net": "5.92"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`))
|
||||
})
|
||||
types, err := c.ServerTypes(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(types) != 1 {
|
||||
t.Fatalf("want 1 type, got %d", len(types))
|
||||
}
|
||||
if types[0].PriceMonthlyEUR != 5.83 {
|
||||
t.Fatalf("want cheapest price 5.83, got %v", types[0].PriceMonthlyEUR)
|
||||
}
|
||||
if types[0].Cores != 2 || types[0].Memory != 4 || types[0].Disk != 40 {
|
||||
t.Fatalf("specs mismatch: %#v", types[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateServer_PostsAndParsesResponse(t *testing.T) {
|
||||
var receivedBody createServerReq
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/servers" {
|
||||
t.Fatalf("expected POST /servers, got %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&receivedBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"server": {
|
||||
"id": 42, "name": "warmbly-fsn1-001", "status": "initializing",
|
||||
"public_net": {
|
||||
"ipv4": {"ip": "1.2.3.4"},
|
||||
"ipv6": {"ip": "2a01::1"}
|
||||
}
|
||||
}
|
||||
}`))
|
||||
})
|
||||
|
||||
req := cloudprovider.CreateServerRequest{
|
||||
Name: "warmbly-fsn1-001",
|
||||
ServerType: "cx22",
|
||||
Image: "ubuntu-22.04",
|
||||
Location: "fsn1",
|
||||
SSHKeyIDs: []string{"key-1"},
|
||||
UserData: "#cloud-config\nrunmd: []",
|
||||
Labels: map[string]string{"warmbly": "true"},
|
||||
StartAfterCreate: true,
|
||||
}
|
||||
s, err := c.CreateServer(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.ID != "42" {
|
||||
t.Fatalf("server id: got %q want %q", s.ID, "42")
|
||||
}
|
||||
if s.PublicIPv4 != "1.2.3.4" {
|
||||
t.Fatalf("server ipv4: got %q", s.PublicIPv4)
|
||||
}
|
||||
if receivedBody.ServerType != "cx22" {
|
||||
t.Fatalf("posted body mismatch: %#v", receivedBody)
|
||||
}
|
||||
if !receivedBody.StartAfterCreate {
|
||||
t.Fatal("StartAfterCreate not propagated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePrimaryIP_DefaultsAssigneeServer(t *testing.T) {
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
var body createPrimaryIPReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.AssigneeType != "server" {
|
||||
t.Fatalf("want assignee_type=server, got %q", body.AssigneeType)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"primary_ip":{"id":7,"type":"ipv4","ip":"5.6.7.8"}}`))
|
||||
})
|
||||
ip, err := c.CreatePrimaryIP(context.Background(), cloudprovider.CreatePrimaryIPRequest{
|
||||
Type: "ipv4", Name: "warmbly-ip-1", Datacenter: "fsn1-dc14",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ip.ID != "7" || ip.IP != "5.6.7.8" {
|
||||
t.Fatalf("parse mismatch: %#v", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetReverseDNS_PostsToAction(t *testing.T) {
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("want POST, got %s", r.Method)
|
||||
}
|
||||
if !strings.HasSuffix(r.URL.Path, "/actions/change_dns_ptr") {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"action":{"id":1,"status":"running"}}`))
|
||||
})
|
||||
if err := c.SetReverseDNS(context.Background(), "7", "w.example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderInterfaceConformance(t *testing.T) {
|
||||
var _ cloudprovider.Provider = (*Client)(nil)
|
||||
}
|
||||
Reference in New Issue
Block a user