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,192 @@
|
||||
package hetznerapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Fake is an in-memory CloudAPI for tests (no live Hetzner calls). It records created resources by label
|
||||
// (so idempotency lookups work) and lets a test force a failure. Actions it returns are already-terminal
|
||||
// (success unless FailCreate/FailAction is set), so WaitAction resolves without a network poll.
|
||||
type Fake struct {
|
||||
mu sync.Mutex
|
||||
|
||||
Subaccounts map[int64]Subaccount // by id
|
||||
Boxes map[int64]StorageBox // by id
|
||||
nextID int64
|
||||
|
||||
// Records of calls (for assertions).
|
||||
CreatedSubaccounts int
|
||||
CreatedBoxes int
|
||||
ResetCalls int
|
||||
DeletedSubaccounts int
|
||||
DeletedBoxes int
|
||||
|
||||
// Failure injection.
|
||||
FailCreate error // if set, CreateSubaccount/CreateStorageBox return this error
|
||||
FailAction bool // if set, created actions come back status "error" (WaitAction fails)
|
||||
}
|
||||
|
||||
// NewFake returns an empty Fake.
|
||||
func NewFake() *Fake {
|
||||
return &Fake{Subaccounts: map[int64]Subaccount{}, Boxes: map[int64]StorageBox{}, nextID: 1000}
|
||||
}
|
||||
|
||||
func (f *Fake) newAction(cmd string) Action {
|
||||
if f.FailAction {
|
||||
a := Action{ID: f.nextID, Command: cmd, Status: "error"}
|
||||
a.Error = &struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}{Code: "action_failed", Message: "injected action failure"}
|
||||
f.nextID++
|
||||
return a
|
||||
}
|
||||
a := Action{ID: f.nextID, Command: cmd, Status: "success", Progress: 100}
|
||||
f.nextID++
|
||||
return a
|
||||
}
|
||||
|
||||
func labelMatch(labels map[string]string, selector string) bool {
|
||||
if selector == "" {
|
||||
return true
|
||||
}
|
||||
// minimal k=v matcher (the hub only uses single-key equality selectors)
|
||||
for i := 0; i < len(selector); i++ {
|
||||
if selector[i] == '=' {
|
||||
k, v := selector[:i], selector[i+1:]
|
||||
return labels[k] == v
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *Fake) ListSubaccounts(_ context.Context, boxID int64, sel string) ([]Subaccount, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []Subaccount
|
||||
for _, s := range f.Subaccounts {
|
||||
if s.StorageBox == boxID && labelMatch(s.Labels, sel) {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *Fake) GetSubaccount(_ context.Context, _ int64, subID int64) (Subaccount, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s, ok := f.Subaccounts[subID]
|
||||
if !ok {
|
||||
return Subaccount{}, fmt.Errorf("hetznerapi(fake): subaccount %d not found", subID)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (f *Fake) CreateSubaccount(_ context.Context, boxID int64, req CreateSubaccountRequest) (int64, Action, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.FailCreate != nil {
|
||||
return 0, Action{}, f.FailCreate
|
||||
}
|
||||
f.CreatedSubaccounts++
|
||||
id := f.nextID
|
||||
f.nextID++
|
||||
f.Subaccounts[id] = Subaccount{
|
||||
ID: id, StorageBox: boxID,
|
||||
Username: fmt.Sprintf("u629193-sub%d", id),
|
||||
Server: fmt.Sprintf("u629193-sub%d.your-storagebox.de", id),
|
||||
HomeDirectory: req.HomeDirectory,
|
||||
AccessSettings: req.AccessSettings,
|
||||
Labels: req.Labels,
|
||||
}
|
||||
return id, f.newAction("create_subaccount"), nil
|
||||
}
|
||||
|
||||
func (f *Fake) ResetSubaccountPassword(_ context.Context, _, _ int64, _ string) (Action, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.ResetCalls++
|
||||
return f.newAction("reset_subaccount_password"), nil
|
||||
}
|
||||
|
||||
func (f *Fake) UpdateSubaccountAccess(_ context.Context, _, subID int64, as AccessSettings) (Action, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if s, ok := f.Subaccounts[subID]; ok {
|
||||
s.AccessSettings = as
|
||||
f.Subaccounts[subID] = s
|
||||
}
|
||||
return f.newAction("update_access_settings"), nil
|
||||
}
|
||||
|
||||
func (f *Fake) DeleteSubaccount(_ context.Context, _, subID int64) (Action, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.Subaccounts, subID)
|
||||
f.DeletedSubaccounts++
|
||||
return f.newAction("delete_subaccount"), nil
|
||||
}
|
||||
|
||||
func (f *Fake) ListStorageBoxes(_ context.Context, sel string) ([]StorageBox, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []StorageBox
|
||||
for _, b := range f.Boxes {
|
||||
if labelMatch(b.Labels, sel) {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *Fake) GetStorageBox(_ context.Context, boxID int64) (StorageBox, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
b, ok := f.Boxes[boxID]
|
||||
if !ok {
|
||||
return StorageBox{}, fmt.Errorf("hetznerapi(fake): box %d not found", boxID)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (f *Fake) CreateStorageBox(_ context.Context, req CreateBoxRequest) (int64, Action, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.FailCreate != nil {
|
||||
return 0, Action{}, f.FailCreate
|
||||
}
|
||||
f.CreatedBoxes++
|
||||
id := f.nextID
|
||||
f.nextID++
|
||||
f.Boxes[id] = StorageBox{
|
||||
ID: id, Name: req.Name, Status: "active",
|
||||
Username: fmt.Sprintf("u6294%d", id),
|
||||
Server: fmt.Sprintf("u6294%d.your-storagebox.de", id),
|
||||
Labels: req.Labels,
|
||||
}
|
||||
return id, f.newAction("create_storage_box"), nil
|
||||
}
|
||||
|
||||
func (f *Fake) ChangeType(_ context.Context, _ int64, _ string) (Action, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.newAction("change_type"), nil
|
||||
}
|
||||
|
||||
func (f *Fake) DeleteStorageBox(_ context.Context, boxID int64) (Action, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.Boxes, boxID)
|
||||
f.DeletedBoxes++
|
||||
return f.newAction("delete_storage_box"), nil
|
||||
}
|
||||
|
||||
// WaitAction resolves based on the (already-terminal) fake action status — no polling.
|
||||
func (f *Fake) WaitAction(_ context.Context, a Action) error {
|
||||
if a.Status == "error" {
|
||||
return actionErr(a)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package hetznerapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// compile-time: both the real client and the fake satisfy CloudAPI.
|
||||
var _ CloudAPI = (*Client)(nil)
|
||||
var _ CloudAPI = (*Fake)(nil)
|
||||
|
||||
func newTestClient(t *testing.T, h http.Handler) (*Client, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
c := NewClient(func() string { return "TESTTOKEN" })
|
||||
c.BaseURL = srv.URL
|
||||
c.PollEvery = 5 * time.Millisecond
|
||||
c.PollMax = 2 * time.Second
|
||||
return c, srv
|
||||
}
|
||||
|
||||
// The create request carries the bearer + JSON, and the {action,subaccount} envelope decodes.
|
||||
func TestClient_CreateSubaccount(t *testing.T) {
|
||||
var gotAuth, gotBody string
|
||||
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
buf := make([]byte, r.ContentLength)
|
||||
r.Body.Read(buf)
|
||||
gotBody = string(buf)
|
||||
w.WriteHeader(201)
|
||||
w.Write([]byte(`{"action":{"id":42,"command":"create_subaccount","status":"success"},"subaccount":{"id":268917,"storage_box":611421}}`))
|
||||
}))
|
||||
id, act, err := c.CreateSubaccount(context.Background(), 611421, CreateSubaccountRequest{HomeDirectory: "spike-sub", Password: "Xx1%", AccessSettings: AccessSettings{SSHEnabled: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotAuth != "Bearer TESTTOKEN" {
|
||||
t.Errorf("bearer not set, got %q", gotAuth)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"home_directory":"spike-sub"`) {
|
||||
t.Errorf("request body missing home_directory: %s", gotBody)
|
||||
}
|
||||
if id != 268917 || act.ID != 42 || act.Status != "success" {
|
||||
t.Fatalf("got id=%d action=%+v", id, act)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-2xx surfaces the API's error code/message (the 422 password shape).
|
||||
func TestClient_ErrorEnvelope(t *testing.T) {
|
||||
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(422)
|
||||
w.Write([]byte(`{"error":{"code":"invalid_input","message":"invalid input in field password"}}`))
|
||||
}))
|
||||
_, _, err := c.CreateSubaccount(context.Background(), 1, CreateSubaccountRequest{})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid_input") {
|
||||
t.Fatalf("want invalid_input error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WaitAction: an already-success action returns immediately; running→success polls; error fails.
|
||||
func TestClient_WaitAction(t *testing.T) {
|
||||
if err := (&Client{}).WaitAction(context.Background(), Action{Status: "success"}); err != nil {
|
||||
t.Fatalf("terminal success must return nil, got %v", err)
|
||||
}
|
||||
if err := (&Client{}).WaitAction(context.Background(), Action{ID: 9, Command: "x", Status: "error"}); err == nil {
|
||||
t.Fatal("terminal error must return an error")
|
||||
}
|
||||
|
||||
calls := 0
|
||||
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
st := "running"
|
||||
if calls >= 3 {
|
||||
st = "success"
|
||||
}
|
||||
w.Write([]byte(`{"action":{"id":7,"command":"create","status":"` + st + `"}}`))
|
||||
}))
|
||||
if err := c.WaitAction(context.Background(), Action{ID: 7, Command: "create", Status: "running"}); err != nil {
|
||||
t.Fatalf("running→success must resolve, got %v", err)
|
||||
}
|
||||
if calls < 3 {
|
||||
t.Fatalf("expected polling, only %d calls", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_WaitActionTimeout(t *testing.T) {
|
||||
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"action":{"id":7,"status":"running"}}`))
|
||||
}))
|
||||
c.PollMax = 30 * time.Millisecond
|
||||
if err := c.WaitAction(context.Background(), Action{ID: 7, Status: "running"}); err == nil || !strings.Contains(err.Error(), "did not reach success") {
|
||||
t.Fatalf("want timeout error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user