From a829b41c88f45f32ad2f9db610bd8f87ea6694df Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 27 May 2026 15:54:36 +0000 Subject: [PATCH] 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. --- .../cloudprovider/cloudprovider.go | 110 +++++ .../cloudprovider/hetzner/hetzner.go | 386 ++++++++++++++++++ .../cloudprovider/hetzner/hetzner_test.go | 195 +++++++++ 3 files changed, 691 insertions(+) create mode 100644 internal/infrastructure/cloudprovider/cloudprovider.go create mode 100644 internal/infrastructure/cloudprovider/hetzner/hetzner.go create mode 100644 internal/infrastructure/cloudprovider/hetzner/hetzner_test.go diff --git a/internal/infrastructure/cloudprovider/cloudprovider.go b/internal/infrastructure/cloudprovider/cloudprovider.go new file mode 100644 index 00000000..270bba18 --- /dev/null +++ b/internal/infrastructure/cloudprovider/cloudprovider.go @@ -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 +} diff --git a/internal/infrastructure/cloudprovider/hetzner/hetzner.go b/internal/infrastructure/cloudprovider/hetzner/hetzner.go new file mode 100644 index 00000000..cb493d45 --- /dev/null +++ b/internal/infrastructure/cloudprovider/hetzner/hetzner.go @@ -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) diff --git a/internal/infrastructure/cloudprovider/hetzner/hetzner_test.go b/internal/infrastructure/cloudprovider/hetzner/hetzner_test.go new file mode 100644 index 00000000..18e351cf --- /dev/null +++ b/internal/infrastructure/cloudprovider/hetzner/hetzner_test.go @@ -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) +}