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:
Matthew Meszaros
2026-05-27 15:54:36 +00:00
parent fdf79a07a9
commit a829b41c88
3 changed files with 691 additions and 0 deletions
@@ -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)
}