Files
felhom.eu/hub/internal/hetznerapi/hetznerapi.go
T
admin 44ec06b50f hub v0.37.0: offsite provisioning SLICE 1 — Cloud-API client + provisioning core
Hetzner storage-box provisioning against api.hetzner.com/v1 (NOT .cloud).
internal/hetznerapi (typed client + CloudAPI interface + Fake + WaitAction);
internal/offsite (Provisioner.ProvisionOffsite — idempotent by label, shared
sub-account/dedicated box, transient password, non-secret Descriptor,
fail-closed); one_time_secrets store (single-use Save/Consume); POST
/offsite/consume-password/{id} (customer-key auth, once); config-form Offsite
section → applyOffsite (502+no-save on error) → descriptor in ConfigJSON →
version bump. Token/passwords never logged/committed/in ConfigJSON. Tested vs a
faked Cloud API + fail-closed red-proof. NOT yet live-provisioned (needs the
dedicated-project scoped token; current token can delete ep0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-09 18:38:24 +02:00

320 lines
12 KiB
Go

// Package hetznerapi is a small typed client for the Hetzner unified API storage-box surface, used by the
// hub to provision the offsite tier. SPIKE 996d403 (authoritative, measured live): the base URL is
// https://api.hetzner.com/v1 — NOT api.hetzner.cloud (the classic Cloud API 404s for every storage-box
// route). Async writes return an action object; poll it to status "success".
//
// Provisioning depends on the CloudAPI interface (not *Client) so tests inject a fake — NO live Hetzner
// calls in CI. The bearer token is supplied by an injected func (read from an out-of-band secret, never a
// committed file); it is never logged.
package hetznerapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// DefaultBaseURL is the storage-box API base (spike §2).
const DefaultBaseURL = "https://api.hetzner.com/v1"
// Action is the async-write result the API returns; poll to status "success".
type Action struct {
ID int64 `json:"id"`
Command string `json:"command"`
Status string `json:"status"` // "running" | "success" | "error"
Progress int `json:"progress"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// AccessSettings mirrors the storage-box/subaccount access_settings object.
type AccessSettings struct {
SSHEnabled bool `json:"ssh_enabled"`
ReachableExternally bool `json:"reachable_externally"`
SambaEnabled bool `json:"samba_enabled"`
WebDAVEnabled bool `json:"webdav_enabled"`
Readonly bool `json:"readonly"`
}
// Subaccount mirrors a storage-box subaccount (the non-secret fields; the API never returns a password).
type Subaccount struct {
ID int64 `json:"id"`
StorageBox int64 `json:"storage_box"`
Username string `json:"username"` // uXXXXXX-subN
Server string `json:"server"` // …-subN.your-storagebox.de
HomeDirectory string `json:"home_directory"`
AccessSettings AccessSettings `json:"access_settings"`
Labels map[string]string `json:"labels"`
}
// StorageBox mirrors a dedicated storage box (non-secret fields).
type StorageBox struct {
ID int64 `json:"id"`
Username string `json:"username"` // uXXXXXX (empty until active)
Name string `json:"name"`
Status string `json:"status"` // initializing | active | …
Server string `json:"server"` // uXXXXXX.your-storagebox.de
Labels map[string]string `json:"labels"`
}
// CreateSubaccountRequest — POST /storage_boxes/{box}/subaccounts. Password satisfies the 4-class policy.
type CreateSubaccountRequest struct {
HomeDirectory string `json:"home_directory"`
Password string `json:"password"`
AccessSettings AccessSettings `json:"access_settings"`
Labels map[string]string `json:"labels,omitempty"`
Description string `json:"description,omitempty"`
}
// CreateBoxRequest — POST /storage_boxes.
type CreateBoxRequest struct {
Name string `json:"name"`
StorageBoxType string `json:"storage_box_type"` // e.g. "bx11"
Location string `json:"location"` // e.g. "fsn1"
Password string `json:"password"`
AccessSettings AccessSettings `json:"access_settings"`
Labels map[string]string `json:"labels,omitempty"`
}
// CloudAPI is the storage-box provisioning surface the hub depends on. A fake implements it in tests.
type CloudAPI interface {
ListSubaccounts(ctx context.Context, boxID int64, labelSelector string) ([]Subaccount, error)
GetSubaccount(ctx context.Context, boxID, subID int64) (Subaccount, error)
CreateSubaccount(ctx context.Context, boxID int64, req CreateSubaccountRequest) (createdID int64, action Action, err error)
ResetSubaccountPassword(ctx context.Context, boxID, subID int64, password string) (Action, error)
UpdateSubaccountAccess(ctx context.Context, boxID, subID int64, as AccessSettings) (Action, error)
DeleteSubaccount(ctx context.Context, boxID, subID int64) (Action, error)
ListStorageBoxes(ctx context.Context, labelSelector string) ([]StorageBox, error)
GetStorageBox(ctx context.Context, boxID int64) (StorageBox, error)
CreateStorageBox(ctx context.Context, req CreateBoxRequest) (createdID int64, action Action, err error)
ChangeType(ctx context.Context, boxID int64, boxType string) (Action, error)
DeleteStorageBox(ctx context.Context, boxID int64) (Action, error)
// WaitAction polls the action to "success" (bounded); errors on "error" or timeout.
WaitAction(ctx context.Context, action Action) error
}
// Client implements CloudAPI against the live API.
type Client struct {
BaseURL string
Token func() string // injected; read from an out-of-band secret; never logged
HC *http.Client
PollEvery time.Duration
PollMax time.Duration
}
// NewClient builds a Client with sane defaults. token must return the bearer (from env/mounted secret).
func NewClient(token func() string) *Client {
return &Client{
BaseURL: DefaultBaseURL,
Token: token,
HC: &http.Client{Timeout: 30 * time.Second},
PollEvery: 3 * time.Second,
PollMax: 3 * time.Minute,
}
}
// apiError is the API's error envelope {error:{code,message,details}}.
type apiError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
} `json:"error"`
}
func (e *apiError) String() string { return fmt.Sprintf("%s: %s", e.Error.Code, e.Error.Message) }
// do performs a request and decodes the JSON body into out. On a non-2xx it returns a typed error carrying
// the API's error code/message. The token is set on the header only — never logged.
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 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.HC.Do(req)
if err != nil {
return fmt.Errorf("hetznerapi: %s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var ae apiError
if json.Unmarshal(raw, &ae) == nil && ae.Error.Code != "" {
return fmt.Errorf("hetznerapi: %s %s: HTTP %d: %s", method, path, resp.StatusCode, ae.String())
}
return fmt.Errorf("hetznerapi: %s %s: HTTP %d", method, path, resp.StatusCode)
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("hetznerapi: %s %s: decode: %w", method, path, err)
}
}
return nil
}
func labelQuery(labelSelector string) string {
if labelSelector == "" {
return ""
}
return "?label_selector=" + url.QueryEscape(labelSelector)
}
// --- subaccounts ---
func (c *Client) ListSubaccounts(ctx context.Context, boxID int64, labelSelector string) ([]Subaccount, error) {
var out struct {
Subaccounts []Subaccount `json:"subaccounts"`
}
err := c.do(ctx, http.MethodGet, fmt.Sprintf("/storage_boxes/%d/subaccounts%s", boxID, labelQuery(labelSelector)), nil, &out)
return out.Subaccounts, err
}
func (c *Client) GetSubaccount(ctx context.Context, boxID, subID int64) (Subaccount, error) {
var out struct {
Subaccount Subaccount `json:"subaccount"`
}
err := c.do(ctx, http.MethodGet, fmt.Sprintf("/storage_boxes/%d/subaccounts/%d", boxID, subID), nil, &out)
return out.Subaccount, err
}
func (c *Client) CreateSubaccount(ctx context.Context, boxID int64, req CreateSubaccountRequest) (int64, Action, error) {
var out struct {
Action Action `json:"action"`
Subaccount struct {
ID int64 `json:"id"`
} `json:"subaccount"`
}
err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/subaccounts", boxID), req, &out)
return out.Subaccount.ID, out.Action, err
}
func (c *Client) ResetSubaccountPassword(ctx context.Context, boxID, subID int64, password string) (Action, error) {
var out struct {
Action Action `json:"action"`
}
err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/subaccounts/%d/actions/reset_subaccount_password", boxID, subID), map[string]string{"password": password}, &out)
return out.Action, err
}
func (c *Client) UpdateSubaccountAccess(ctx context.Context, boxID, subID int64, as AccessSettings) (Action, error) {
var out struct {
Action Action `json:"action"`
}
err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/subaccounts/%d/actions/update_access_settings", boxID, subID), as, &out)
return out.Action, err
}
func (c *Client) DeleteSubaccount(ctx context.Context, boxID, subID int64) (Action, error) {
var out struct {
Action Action `json:"action"`
}
err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/storage_boxes/%d/subaccounts/%d", boxID, subID), nil, &out)
return out.Action, err
}
// --- storage boxes ---
func (c *Client) ListStorageBoxes(ctx context.Context, labelSelector string) ([]StorageBox, error) {
var out struct {
StorageBoxes []StorageBox `json:"storage_boxes"`
}
err := c.do(ctx, http.MethodGet, "/storage_boxes"+labelQuery(labelSelector), nil, &out)
return out.StorageBoxes, err
}
func (c *Client) GetStorageBox(ctx context.Context, boxID int64) (StorageBox, error) {
var out struct {
StorageBox StorageBox `json:"storage_box"`
}
err := c.do(ctx, http.MethodGet, fmt.Sprintf("/storage_boxes/%d", boxID), nil, &out)
return out.StorageBox, err
}
func (c *Client) CreateStorageBox(ctx context.Context, req CreateBoxRequest) (int64, Action, error) {
var out struct {
Action Action `json:"action"`
StorageBox struct {
ID int64 `json:"id"`
} `json:"storage_box"`
}
err := c.do(ctx, http.MethodPost, "/storage_boxes", req, &out)
return out.StorageBox.ID, out.Action, err
}
func (c *Client) ChangeType(ctx context.Context, boxID int64, boxType string) (Action, error) {
var out struct {
Action Action `json:"action"`
}
err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/actions/change_type", boxID), map[string]string{"storage_box_type": boxType}, &out)
return out.Action, err
}
func (c *Client) DeleteStorageBox(ctx context.Context, boxID int64) (Action, error) {
var out struct {
Action Action `json:"action"`
}
err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/storage_boxes/%d", boxID), nil, &out)
return out.Action, err
}
// WaitAction polls the action to "success" (bounded by PollMax). An already-terminal action returns
// immediately. It NEVER assumes create == ready.
func (c *Client) WaitAction(ctx context.Context, action Action) error {
if s := action.Status; s == "success" {
return nil
} else if s == "error" {
return actionErr(action)
}
deadline := time.Now().Add(c.PollMax)
for {
var out struct {
Action Action `json:"action"`
}
if err := c.do(ctx, http.MethodGet, fmt.Sprintf("/storage_boxes/actions/%d", action.ID), nil, &out); err != nil {
return err
}
switch out.Action.Status {
case "success":
return nil
case "error":
return actionErr(out.Action)
}
if time.Now().After(deadline) {
return fmt.Errorf("hetznerapi: action %d (%s) did not reach success within %s", action.ID, action.Command, c.PollMax)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(c.PollEvery):
}
}
}
func actionErr(a Action) error {
if a.Error != nil {
return fmt.Errorf("hetznerapi: action %d (%s) failed: %s: %s", a.ID, a.Command, a.Error.Code, a.Error.Message)
}
return fmt.Errorf("hetznerapi: action %d (%s) failed", a.ID, a.Command)
}