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{})
+7 -1
View File
@@ -18,6 +18,7 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"golang.org/x/crypto/bcrypt"
)
@@ -56,7 +57,8 @@ type Server struct {
versionChecker *VersionChecker
templateFetcher *TemplateFetcher
assetsMgr *assets.Manager
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
sessions map[string]*hubSession
sessionsMu sync.RWMutex
@@ -142,6 +144,10 @@ func (s *Server) SetAssetManager(am *assets.Manager) {
s.assetsMgr = am
}
// SetOffsiteProvisioner enables Hetzner offsite provisioning (optional). Without it, saving a config with
// offsite enabled returns an error (offsite not configured on this hub).
func (s *Server) SetOffsiteProvisioner(p *offsite.Provisioner) { s.offsite = p }
// SetGiteaClient enables the Day-0 artifact version dropdowns (optional). Without it the artifact form
// degrades to manual text entry.
func (s *Server) SetGiteaClient(c *gitea.Client) {
@@ -103,6 +103,40 @@
</div>
</details>
<details class="card" {{if index .Overrides "offsite"}}open{{end}}>
<summary><h2 style="display:inline">Offsite backup</h2></summary>
<div class="form-grid" style="margin-top: 1rem;">
<div class="form-group">
<label><input type="checkbox" name="offsite_enabled"
{{with .Overrides}}{{with index . "offsite"}}{{if index . "enabled"}}checked{{end}}{{end}}{{end}}>
Enable offsite (provisions a Hetzner Storage Box on save)</label>
</div>
<div class="form-group">
<label for="offsite_type">Type</label>
<select id="offsite_type" name="offsite_type">
<option value="shared" {{with .Overrides}}{{with index . "offsite"}}{{if eq (index . "type") "shared"}}selected{{end}}{{end}}{{end}}>Shared (sub-account on the pool box)</option>
<option value="dedicated" {{with .Overrides}}{{with index . "offsite"}}{{if eq (index . "type") "dedicated"}}selected{{end}}{{end}}{{end}}>Dedicated (own box)</option>
</select>
</div>
<div class="form-group">
<label for="offsite_quota_gb">Soft-quota GB (shared)</label>
<input type="number" id="offsite_quota_gb" name="offsite_quota_gb"
value="{{with .Overrides}}{{with index . "offsite"}}{{with index . "quota_gb"}}{{.}}{{end}}{{end}}{{end}}" placeholder="e.g. 50">
</div>
<div class="form-group">
<label for="offsite_box_type">Box type (dedicated)</label>
<select id="offsite_box_type" name="offsite_box_type">
<option value="bx11" {{with .Overrides}}{{with index . "offsite"}}{{if eq (index . "box_type") "bx11"}}selected{{end}}{{end}}{{end}}>bx11 (1 TB)</option>
<option value="bx21" {{with .Overrides}}{{with index . "offsite"}}{{if eq (index . "box_type") "bx21"}}selected{{end}}{{end}}{{end}}>bx21 (5 TB)</option>
<option value="bx31" {{with .Overrides}}{{with index . "offsite"}}{{if eq (index . "box_type") "bx31"}}selected{{end}}{{end}}{{end}}>bx31 (10 TB)</option>
</select>
</div>
</div>
{{with .Overrides}}{{with index . "offsite"}}{{if index . "host"}}
<p class="form-hint" style="margin-top:.5rem">Provisioned: {{index . "user"}}@{{index . "host"}}:{{index . "repo_path"}} — the transient password is delivered to the controller once (never shown here).</p>
{{end}}{{end}}{{end}}
</details>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button type="submit" class="btn">{{if .IsNew}}Create Configuration{{else}}Save Changes{{end}}</button>
<a href="{{if .IsNew}}/configs{{else}}/customers/{{.Config.CustomerID}}{{end}}" class="btn btn-outline">Cancel</a>