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:
2026-07-09 18:38:24 +02:00
parent 996d403248
commit 44ec06b50f
16 changed files with 1279 additions and 1 deletions
+46
View File
@@ -8,11 +8,13 @@ import (
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"
cfClient "gitea.dooplex.hu/admin/felhom-hub/internal/cloudflare"
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
@@ -456,6 +458,13 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
ConfigJSON: configJSON,
}
// Offsite provisioning (fail-closed): a provisioning error must NOT save a half-enabled config.
if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
s.logger.Printf("[ERROR] offsite provision for %s: %v", customerID, err)
http.Error(w, "Offsite provisioning failed: "+err.Error(), http.StatusBadGateway)
return
}
if err := s.store.SaveCustomerConfig(cfg); err != nil {
s.logger.Printf("[ERROR] Failed to save config for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
@@ -512,6 +521,12 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
cfg.Email = strings.TrimSpace(r.FormValue("email"))
cfg.ConfigJSON = buildConfigJSON(r)
if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
s.logger.Printf("[ERROR] offsite provision for %s: %v", customerID, err)
http.Error(w, "Offsite provisioning failed: "+err.Error(), http.StatusBadGateway)
return
}
if err := s.store.SaveCustomerConfig(cfg); err != nil {
s.logger.Printf("[ERROR] Failed to update config for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
@@ -791,6 +806,37 @@ func (s *Server) renderConfigForm(w http.ResponseWriter, r *http.Request, isNew
}
// buildConfigJSON builds the config_json from optional form fields.
// applyOffsite provisions the offsite tier (if enabled in the form) and merges the NON-SECRET descriptor
// into cfg.ConfigJSON. Fail-closed: on any provisioning error it returns the error and leaves cfg.ConfigJSON
// unchanged — the caller must NOT save. When offsite is unchecked, the offsite key is naturally absent from
// the freshly-built ConfigJSON (disabled by omission; the Hetzner resource is NOT deprovisioned this slice).
func (s *Server) applyOffsite(ctx context.Context, r *http.Request, cfg *store.CustomerConfig) error {
if v := r.FormValue("offsite_enabled"); v != "on" && v != "true" {
return nil // not enabled → disabled by omission
}
if s.offsite == nil {
return fmt.Errorf("offsite provisioning is not configured on this hub (no Hetzner token)")
}
in := offsite.Input{
Enabled: true,
Type: strings.TrimSpace(r.FormValue("offsite_type")),
BoxType: strings.TrimSpace(r.FormValue("offsite_box_type")),
}
if q := strings.TrimSpace(r.FormValue("offsite_quota_gb")); q != "" {
in.QuotaGB, _ = strconv.Atoi(q)
}
d, err := s.offsite.ProvisionOffsite(ctx, cfg.CustomerID, in)
if err != nil {
return err
}
merged, err := offsite.MergeDescriptor(cfg.ConfigJSON, d)
if err != nil {
return err
}
cfg.ConfigJSON = merged
return nil
}
func buildConfigJSON(r *http.Request) string {
overrides := make(map[string]interface{})