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:
@@ -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{})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user