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
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user