hub v0.51.0: DR-tier-by-default — per-customer dr_tier flag (default ON, legacy backfill from reality), cascade stages, WG-registration auto-provision hook, offsite-requires-DR guard (F-6 policy), host-page capability chips (inactive=neutral)
Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
This commit is contained in:
@@ -69,7 +69,7 @@
|
|||||||
| `(*Server).configFormData` (v0.49.0) | hub/internal/web/configs.go (~L430) | `(r, isNew, cfg, overrides, errMsg) configFormView` | The ONE view-model builder for the customer config form (standalone chrome + the customer page Edit tab) | `overrides=nil` → parses the STORED cfg.ConfigJSON; pass the SUBMITTED map on the update validation-error re-render or typed values reset (red-proofed). |
|
| `(*Server).configFormData` (v0.49.0) | hub/internal/web/configs.go (~L430) | `(r, isNew, cfg, overrides, errMsg) configFormView` | The ONE view-model builder for the customer config form (standalone chrome + the customer page Edit tab) | `overrides=nil` → parses the STORED cfg.ConfigJSON; pass the SUBMITTED map on the update validation-error re-render or typed values reset (red-proofed). |
|
||||||
| `config_form_body` sub-template (v0.49.0) | hub/internal/web/templates/config_form_body.html | `{{template "config_form_body" <configFormView>}}` | Rendering the config form on ANY surface (config_form.html chrome + customer Edit tab) | The floor/geo/danger cards on the Edit tab are SIBLINGS after `</form>` — never nest a form inside it (breaks the offsite/PBS formaction sub-buttons). Includes the F5 in-flight `<script>`. |
|
| `config_form_body` sub-template (v0.49.0) | hub/internal/web/templates/config_form_body.html | `{{template "config_form_body" <configFormView>}}` | Rendering the config form on ANY surface (config_form.html chrome + customer Edit tab) | The floor/geo/danger cards on the Edit tab are SIBLINGS after `</form>` — never nest a form inside it (breaks the offsite/PBS formaction sub-buttons). Includes the F5 in-flight `<script>`. |
|
||||||
| `(*Store).CountHostArtifacts` / `DeleteHost` | hub/internal/store/store.go (~L1640/~L1690) | `(hostID) (HostArtifacts, error)` / `(hostID, deleteEscrow bool) error` | Host-delete impact preview + the ONE-transaction cascade | ONLINE gate lives in the handler, escrow gate in the store (`ErrHostEscrowPresent`, tx never starts). log_bundles die by `scope_id == host_id` ONLY (customer-scoped bundles survive). The wg_peers delete is INSIDE the tx — never split it out. |
|
| `(*Store).CountHostArtifacts` / `DeleteHost` | hub/internal/store/store.go (~L1640/~L1690) | `(hostID) (HostArtifacts, error)` / `(hostID, deleteEscrow bool) error` | Host-delete impact preview + the ONE-transaction cascade | ONLINE gate lives in the handler, escrow gate in the store (`ErrHostEscrowPresent`, tx never starts). log_bundles die by `scope_id == host_id` ONLY (customer-scoped bundles survive). The wg_peers delete is INSIDE the tx — never split it out. |
|
||||||
| `(*Store).ListWGEndpoints` / `DeleteWGEndpoint` | hub/internal/store/wg.go (~L64/~L86) | `() ([]WGEndpoint, error)` / `(endpointID) error` | The /offsite endpoint-management surface | `GetWGEndpoint` (lowest id, LIMIT 1) stays THE allocation/sync endpoint — do not switch allocator/reconciler/desired-state to the list without the `wg_peers.endpoint_id` migration arc. Peers-in-subnet guards live in web/offsite.go. |
|
| `(*Store).ListWGEndpoints` / `DeleteWGEndpoint` | hub/internal/store/wg.go (~L64/~L86) | `() ([]WGEndpoint, error)` / `(endpointID) error` | The /offsite endpoint-management surface | `GetWGEndpoint` (lowest id, LIMIT 1) stays THE allocation/sync endpoint — do not switch allocator/reconciler/desired-state to the list without the `wg_peers.endpoint_id` migration arc. Peers-in-subnet guards live in hub/internal/web/offsite.go. |
|
||||||
|
|
||||||
### Artifact manifest / Day-0 trust root
|
### Artifact manifest / Day-0 trust root
|
||||||
|
|
||||||
@@ -82,13 +82,16 @@
|
|||||||
| `(*Handler).handleArtifactManifest` | hub/internal/api/handler.go (~L1550) | `GET /api/v1/artifacts/{id}` | Serving the vouched set to the bootstrap script | Auth mirrors handleConfigRetrieve exactly. Unset manifest = empty fields, not an error. |
|
| `(*Handler).handleArtifactManifest` | hub/internal/api/handler.go (~L1550) | `GET /api/v1/artifacts/{id}` | Serving the vouched set to the bootstrap script | Auth mirrors handleConfigRetrieve exactly. Unset manifest = empty fields, not an error. |
|
||||||
| `normalizeFloorInput` / `normalizeSHA256` | hub/internal/web/configs.go (~L27 / ~L627) | `(raw string) (string, bool)` | Validating operator-typed versions / shas | Empty string is VALID (means "clear"). Reuse for any new version/sha form field. |
|
| `normalizeFloorInput` / `normalizeSHA256` | hub/internal/web/configs.go (~L27 / ~L627) | `(raw string) (string, bool)` | Validating operator-typed versions / shas | Empty string is VALID (means "clear"). Reuse for any new version/sha form field. |
|
||||||
|
|
||||||
### PBS DR tier / tenantsync (hub/internal/tenantsync/, web/pbsdr.go, api/pbsdr.go)
|
### PBS DR tier / tenantsync (hub/internal/tenantsync/, hub/internal/web/pbsdr.go, hub/internal/api/pbsdr.go)
|
||||||
|
|
||||||
| Symbol | File | Short signature | Use for | Gotchas |
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `tenantsync.Client` (`Provision`/`Reissue`/`Fingerprint`) | hub/internal/tenantsync/client.go | `(ctx, customerID) (*Result, error)` | ep0 per-customer PBS tenancy over the pinned-SSH forced-command channel (the wgsync twin) | `Result.TokenSecret` is transient custody → `SaveHostPBSSecret` immediately, never log the struct. Error paths NEVER embed stdout (the secret channel) — do not "improve" diagnostics by quoting the response. `ErrTokenExists` is typed: provision refuses an existing token; re-issue is the explicit path. |
|
| `tenantsync.Client` (`Provision`/`Reissue`/`Fingerprint`) | hub/internal/tenantsync/client.go | `(ctx, customerID) (*Result, error)` | ep0 per-customer PBS tenancy over the pinned-SSH forced-command channel (the wgsync twin) | `Result.TokenSecret` is transient custody → `SaveHostPBSSecret` immediately, never log the struct. Error paths NEVER embed stdout (the secret channel) — do not "improve" diagnostics by quoting the response. `ErrTokenExists` is typed: provision refuses an existing token; re-issue is the explicit path. |
|
||||||
| `(*Store).SaveHostPBSSecret` / `ConsumeHostPBSSecret` | hub/internal/store/pbsdr.go | `(hostID, value)` / `(hostID) (string, error)` | HOST-scoped consume-once secret (the one_time_secrets host twin) | Same-tx mark-consumed; re-save resets consumption (re-issue supersedes). The agent consumes via `POST /api/v1/hosts/{id}/pbs/consume-token` (api/pbsdr.go). |
|
| `(*Store).SaveHostPBSSecret` / `ConsumeHostPBSSecret` | hub/internal/store/pbsdr.go | `(hostID, value)` / `(hostID) (string, error)` | HOST-scoped consume-once secret (the one_time_secrets host twin) | Same-tx mark-consumed; re-save resets consumption (re-issue supersedes). The agent consumes via `POST /api/v1/hosts/{id}/pbs/consume-token` (hub/internal/api/pbsdr.go). |
|
||||||
| `(*Server).applyPBSDR` + `mergePBSDR`/`readPBSDR` | hub/internal/web/pbsdr.go | `(ctx, r, cfg) error` | The config form's PBS DR section → HOST desired_json `pbs_dr` descriptor + generation bump | Descriptor lives in the host desired_json, NOT ConfigJSON (buildConfigJSON drops foreign keys on re-save). Fail-closed before SaveCustomerConfig; already-provisioned = success-no-op (red-proofed); disable keeps the ep0 tenancy. |
|
| `(*Server).applyPBSDR` + `mergePBSDR`/`readPBSDR` | hub/internal/web/pbsdr.go | `(ctx, r, cfg) error` | The config form's DR-tier section → HOST desired_json `pbs_dr` descriptor + generation bump | Descriptor lives in the host desired_json, NOT ConfigJSON (buildConfigJSON drops foreign keys on re-save). v0.51.0: driven by `cfg.DRTier` (set from the form BEFORE applyOffsite/applyPBSDR); UNMET preconditions are honest waiting stages (save succeeds), REAL failures stay fail-closed; already-provisioned = success-no-op (red-proofed); disable keeps the ep0 tenancy. |
|
||||||
|
| `(*Server).pbsdrProvisionAtom` + `PBSDRAutoProvision` | hub/internal/web/pbsdr.go | `(ctx, customerID, host, storageID) (blocked string, err error)` / `(ctx, customerID)` | The shared fresh-provision cascade atom; the WG-registration hook target (api `SetWGRegisteredHook`, wired in hub/cmd/hub/main.go when tenantsync is on) | `blocked != ""` = waiting stage (never an error); the hook runs in a detached goroutine and must never fail registration. Scenario-A e2e test: TestPBSDR_AutoProvisionOnWGRegistration. |
|
||||||
|
| `cfg.DRTier` + offsite coupling | hub/internal/store/store.go (CustomerConfig), hub/internal/web/configs.go (applyOffsite guard) | bool | Per-customer DR-tier flag: new-customer default ON (handleConfigNewForm); offsite REFUSED without it (exact F-6 message) | One-time migration backfill initializes legacy rows from descriptor reality — never re-runs (opt-outs survive re-open; store test pins it). Form field `dr_tier` (formBool helper). |
|
||||||
|
| `parseHostCapabilities` + `capabilityView` | hub/internal/web/hosts.go | `(reportJSON) []capabilityView` | Host-page capability chips (ok/degraded/inactive) | `inactive` (agent v0.86.0) = badge-neutral, NEVER warn/error — disabled ≠ degraded; unknown future statuses fall to the degraded styling (surface, don't hide). `capabilitiesNeedDRMigration` keys the pre-v1.15.0 migration hint on pbsdr-* + "binary not found". |
|
||||||
|
|
||||||
### Config generation & secrets hygiene (hub/internal/configgen/)
|
### Config generation & secrets hygiene (hub/internal/configgen/)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,43 @@
|
|||||||
# Felhom Hub — Changelog
|
# Felhom Hub — Changelog
|
||||||
|
|
||||||
|
## v0.51.0 — DR-tier-by-default: per-customer flag + hands-free cascade + offsite coupling + capability chips (2026-07-12)
|
||||||
|
|
||||||
|
Hub half of the DR-tier-by-default batch (DRILL-day0-vm-2026-07-12; operator decisions 1–5:
|
||||||
|
capability BAKED on every install, activation is THIS flag, DR defaults ON for new customers,
|
||||||
|
identity-only escrow PARKED by policy, WG is base infrastructure). Companion: installer v1.15.0
|
||||||
|
+ agent v0.86.0 (capability `inactive` state).
|
||||||
|
|
||||||
|
- **Per-customer `dr_tier` flag** (customer_configs column + form checkbox in the renamed
|
||||||
|
"DR tier (PBS, ep0)" section, replacing the old `pbsdr_enabled` form field). NEW customers
|
||||||
|
default ON; legacy rows were initialized FROM REALITY by a one-time migration backfill
|
||||||
|
(host carries an enabled pbs_dr descriptor → ON, else OFF — never auto-cascade a legacy box;
|
||||||
|
backfill runs only on the ALTER that adds the column, so later operator opt-outs survive).
|
||||||
|
- **Cascade semantics** (scenario D): an UNMET precondition (no host / no WG peer / no
|
||||||
|
tenantsync) is no longer a save-blocking error — the flag stores the intent and the edit form
|
||||||
|
shows per-stage status (host enrolled → WG peer → descriptor provisioned → escrow present),
|
||||||
|
reusing the fail-closed guard wording. REAL provisioning failures stay fail-closed (tenantsync
|
||||||
|
error, token-exists → Re-issue).
|
||||||
|
- **Hands-free auto-provision** (scenario A): a host's FIRST WG peer registration fires
|
||||||
|
`PBSDRAutoProvision` (api `SetWGRegisteredHook`, wired when tenantsync is enabled) — a DR-ON
|
||||||
|
customer's descriptor provisions with ZERO operator steps and applies on the agent's next
|
||||||
|
desired-state tick. Detached goroutine; never delays/fails the registration response.
|
||||||
|
- **Offsite requires the DR tier** (scenario C, drill F-6 CLOSED BY POLICY): `applyOffsite`
|
||||||
|
refuses without the flag — exact message "Offsite backup requires the DR tier — enable it
|
||||||
|
first (the escrow ceremony depends on the PBS key)". No more provisioning into the
|
||||||
|
EscrowState-pending-forever dead end.
|
||||||
|
- **Capability chips on the host page** (NEW render surface): the agent's privileged-capability
|
||||||
|
self-check is now visible — ok (blue), degraded (warn / error when critical), and the agent
|
||||||
|
v0.86.0 `inactive` state as a NEUTRAL chip (disabled ≠ degraded). The pre-v1.15.0 pbsdr
|
||||||
|
"binary not found" signature surfaces the migration one-liner (never silently pretend).
|
||||||
|
`.badge-ok` finally defined in style.css (was referenced, fell back to bare `.badge`).
|
||||||
|
- Setup-tab installer copy: `hostInstallVersion` 1.12.0 → **1.15.0** (drill F-1), now gated
|
||||||
|
against the installer's SCRIPT_VERSION by `scripts/hostinstall_gates.py`.
|
||||||
|
- Tests + red-proofs (all four mutations proven red): coupling gate (guard removed → refused
|
||||||
|
case fails), flag default (default flipped → form test fails), backfill
|
||||||
|
(enabled:false ignored → disabled case fails), auto-provision (hook unhooked → scenario A
|
||||||
|
test fails); plus cascade-wait, chips render (inactive-neutral / degraded-stays / migration
|
||||||
|
hint), and the one-time-backfill-survives-reopen case.
|
||||||
|
|
||||||
## v0.50.0 — customer-claim password arc: code engine + email + ACK/config delivery (2026-07-12)
|
## v0.50.0 — customer-claim password arc: code engine + email + ACK/config delivery (2026-07-12)
|
||||||
|
|
||||||
Hub half of the customer-claim password gate (closes DRILL-day0-vm F-4/F-5; needs controller
|
Hub half of the customer-claim password gate (closes DRILL-day0-vm F-4/F-5; needs controller
|
||||||
|
|||||||
+4
-1
@@ -393,7 +393,10 @@ func main() {
|
|||||||
logger.Printf("[ERROR] PBS DR tenantsync disabled: %v", err)
|
logger.Printf("[ERROR] PBS DR tenantsync disabled: %v", err)
|
||||||
} else {
|
} else {
|
||||||
webServer.SetTenantSync(tsClient)
|
webServer.SetTenantSync(tsClient)
|
||||||
logger.Printf("[INFO] PBS DR tenantsync enabled (endpoint %s, user %s)", wgAddr, wgUser)
|
// v0.51.0 DR-tier cascade (scenario A): a host's first WG registration may be
|
||||||
|
// the descriptor's last unmet precondition — auto-provision hands-free.
|
||||||
|
apiHandler.SetWGRegisteredHook(webServer.PBSDRAutoProvision)
|
||||||
|
logger.Printf("[INFO] PBS DR tenantsync enabled (endpoint %s, user %s; WG-registration auto-provision hook armed)", wgAddr, wgUser)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Printf("[INFO] PBS DR tenantsync disabled (key or endpoint not configured)")
|
logger.Printf("[INFO] PBS DR tenantsync disabled (key or endpoint not configured)")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -58,6 +59,13 @@ type Handler struct {
|
|||||||
// claimEngine is the customer-claim code engine (v0.50.0). nil = claim arc disabled: no codes
|
// claimEngine is the customer-claim code engine (v0.50.0). nil = claim arc disabled: no codes
|
||||||
// issued, no claim field in ACKs/configs — pre-arc behavior exactly.
|
// issued, no claim field in ACKs/configs — pre-arc behavior exactly.
|
||||||
claimEngine *claim.Engine
|
claimEngine *claim.Engine
|
||||||
|
|
||||||
|
// wgRegisteredHook (v0.51.0, DR-tier-by-default scenario A) fires after a host's FIRST WG
|
||||||
|
// peer registration — main.go wires it to the web server's PBSDRAutoProvision so a DR-ON
|
||||||
|
// customer's pbs_dr descriptor lands hands-free (WG registers → provision → the agent's next
|
||||||
|
// desired-state tick). nil = no cascade hook (pre-v0.51.0 behavior). Runs in a detached
|
||||||
|
// goroutine; must never delay or fail the registration response.
|
||||||
|
wgRegisteredHook func(ctx context.Context, customerID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetClaimEngine wires the customer-claim code engine (nil-safe everywhere it is used).
|
// SetClaimEngine wires the customer-claim code engine (nil-safe everywhere it is used).
|
||||||
@@ -65,6 +73,11 @@ func (h *Handler) SetClaimEngine(e *claim.Engine) {
|
|||||||
h.claimEngine = e
|
h.claimEngine = e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetWGRegisteredHook wires the post-WG-registration cascade hook (v0.51.0; nil-safe).
|
||||||
|
func (h *Handler) SetWGRegisteredHook(f func(ctx context.Context, customerID string)) {
|
||||||
|
h.wgRegisteredHook = f
|
||||||
|
}
|
||||||
|
|
||||||
// SetLatestVersionProvider wires the registry version checker so the controller report ACK can
|
// SetLatestVersionProvider wires the registry version checker so the controller report ACK can
|
||||||
// advertise the latest available version (Phase 2). nil-safe (no latest_version field emitted).
|
// advertise the latest available version (Phase 2). nil-safe (no latest_version field emitted).
|
||||||
func (h *Handler) SetLatestVersionProvider(p LatestVersionProvider) {
|
func (h *Handler) SetLatestVersionProvider(p LatestVersionProvider) {
|
||||||
|
|||||||
@@ -295,6 +295,13 @@ func (h *Handler) handleRegisterHostWG(w http.ResponseWriter, r *http.Request, p
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
syncStatus = h.syncAfterMutation(r.Context())
|
syncStatus = h.syncAfterMutation(r.Context())
|
||||||
|
// v0.51.0 DR-tier cascade (scenario A): a NEW tunnel peer may be the pbs_dr descriptor's
|
||||||
|
// last unmet precondition — fire the hook so a DR-ON customer provisions hands-free.
|
||||||
|
// Detached goroutine: registration must never wait on (or fail over) ep0 provisioning;
|
||||||
|
// the hook itself detaches+bounds its context and logs every outcome.
|
||||||
|
if h.wgRegisteredHook != nil && host.CustomerID != "" {
|
||||||
|
go h.wgRegisteredHook(context.WithoutCancel(r.Context()), host.CustomerID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
h.logger.Printf("[INFO] wg registered: host=%s pubkey=%s ip=%s/32 changed=%v gen=%d sync=%s",
|
h.logger.Printf("[INFO] wg registered: host=%s pubkey=%s ip=%s/32 changed=%v gen=%d sync=%s",
|
||||||
pathHostID, req.Pubkey, ip, changed, gen, syncStatus)
|
pathHostID, req.Pubkey, ip, changed, gen, syncStatus)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
// v0.51.0 dr_tier one-time legacy backfill — "initialize from reality": on the migration that
|
||||||
|
// ADDS the column, a customer whose host already carries an ENABLED pbs_dr descriptor flips ON;
|
||||||
|
// everyone else stays OFF (never auto-cascade a legacy box). Simulated against a genuine
|
||||||
|
// pre-v0.51.0 database file (tables without dr_tier), then opened through store.New so the REAL
|
||||||
|
// migrate() path runs. Red-proof partner: make the backfill ignore `"enabled":false` vs true
|
||||||
|
// (set every pbs_dr customer ON) → the disabled-descriptor case fails.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDRTierBackfill_InitializesFromReality(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "legacy.db")
|
||||||
|
|
||||||
|
// 1. Build a PRE-v0.51.0 database: customer_configs + hosts WITHOUT dr_tier.
|
||||||
|
raw, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustExec := func(q string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := raw.Exec(q, args...); err != nil {
|
||||||
|
t.Fatalf("legacy seed: %v (%s)", err, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mustExec(`CREATE TABLE customer_configs (
|
||||||
|
customer_id TEXT PRIMARY KEY,
|
||||||
|
customer_name TEXT NOT NULL DEFAULT '',
|
||||||
|
domain TEXT NOT NULL DEFAULT '',
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
retrieval_password TEXT NOT NULL,
|
||||||
|
api_key TEXT NOT NULL,
|
||||||
|
config_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)`)
|
||||||
|
mustExec(`CREATE TABLE hosts (
|
||||||
|
host_id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT NOT NULL,
|
||||||
|
api_key TEXT NOT NULL,
|
||||||
|
agent_version TEXT NOT NULL DEFAULT '',
|
||||||
|
last_report_at DATETIME,
|
||||||
|
desired_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
desired_generation INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dr_record_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)`)
|
||||||
|
seedCustomer := func(id string) {
|
||||||
|
mustExec(`INSERT INTO customer_configs (customer_id, retrieval_password, api_key) VALUES (?, ?, ?)`,
|
||||||
|
id, "pw", "key-"+id)
|
||||||
|
}
|
||||||
|
seedCustomer("applied") // host carries an ENABLED descriptor → must flip ON
|
||||||
|
seedCustomer("disabled") // descriptor present but enabled:false → must stay OFF
|
||||||
|
seedCustomer("plain") // no descriptor at all → must stay OFF
|
||||||
|
seedCustomer("hostless") // no host row → must stay OFF
|
||||||
|
mustExec(`INSERT INTO hosts (host_id, customer_id, api_key, desired_json) VALUES (?, ?, ?, ?)`,
|
||||||
|
"applied-01", "applied", "h1",
|
||||||
|
`{"pbs_dr":{"enabled":true,"storage_id":"felhom-pbs","namespace":"applied"}}`)
|
||||||
|
mustExec(`INSERT INTO hosts (host_id, customer_id, api_key, desired_json) VALUES (?, ?, ?, ?)`,
|
||||||
|
"disabled-01", "disabled", "h2",
|
||||||
|
`{"pbs_dr":{"enabled":false,"storage_id":"felhom-pbs","namespace":"disabled"}}`)
|
||||||
|
mustExec(`INSERT INTO hosts (host_id, customer_id, api_key, desired_json) VALUES (?, ?, ?, ?)`,
|
||||||
|
"plain-01", "plain", "h3", `{}`)
|
||||||
|
if err := raw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Open through the real store — migrate() adds dr_tier and runs the one-time backfill.
|
||||||
|
st, err := New(dbPath, log.New(io.Discard, "", 0))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.New over the legacy db: %v", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
want := map[string]bool{"applied": true, "disabled": false, "plain": false, "hostless": false}
|
||||||
|
for id, wantOn := range want {
|
||||||
|
cfg, err := st.GetCustomerConfig(id)
|
||||||
|
if err != nil || cfg == nil {
|
||||||
|
t.Fatalf("read %s: %v", id, err)
|
||||||
|
}
|
||||||
|
if cfg.DRTier != wantOn {
|
||||||
|
t.Errorf("customer %s: dr_tier=%v, want %v (initialize from reality)", id, cfg.DRTier, wantOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The backfill is ONE-TIME: a later flag change must survive a re-open (the ALTER now
|
||||||
|
// fails → no re-backfill stomping operator decisions).
|
||||||
|
cfg, _ := st.GetCustomerConfig("applied")
|
||||||
|
cfg.DRTier = false // operator opts the customer out
|
||||||
|
if err := st.SaveCustomerConfig(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st.Close()
|
||||||
|
st2, err := New(dbPath, log.New(io.Discard, "", 0))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st2.Close()
|
||||||
|
cfg2, _ := st2.GetCustomerConfig("applied")
|
||||||
|
if cfg2.DRTier {
|
||||||
|
t.Fatal("re-open re-ran the backfill and stomped the operator's opt-out")
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
-11
@@ -142,6 +142,15 @@ func (s *Store) migrate() error {
|
|||||||
// at 1, so an already-running box records that as its baseline on its next report without restarting.
|
// at 1, so an already-running box records that as its baseline on its next report without restarting.
|
||||||
s.db.Exec("ALTER TABLE customer_configs ADD COLUMN config_version INTEGER NOT NULL DEFAULT 1")
|
s.db.Exec("ALTER TABLE customer_configs ADD COLUMN config_version INTEGER NOT NULL DEFAULT 1")
|
||||||
|
|
||||||
|
// v0.51.0 (DR-tier-by-default): per-customer DR-tier flag. NEW customers default ON (the
|
||||||
|
// create handler sets it); the column default 0 is the LEGACY initialization — an existing
|
||||||
|
// customer is only flipped ON by the one-time backfill (initialize from reality: its host
|
||||||
|
// already carries an ENABLED pbs_dr descriptor; never auto-cascade a legacy box). The
|
||||||
|
// backfill runs exactly once — only when the ALTER actually added the column — and is
|
||||||
|
// DEFERRED to the end of migrate(): the hosts table it reads is created further down (a
|
||||||
|
// fresh DB reaches the ALTER before the CREATE).
|
||||||
|
_, drTierAlterErr := s.db.Exec("ALTER TABLE customer_configs ADD COLUMN dr_tier INTEGER NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
// v0.15.0: hub_settings — a tiny key/value table for operator-set globals that must survive
|
// v0.15.0: hub_settings — a tiny key/value table for operator-set globals that must survive
|
||||||
// restarts (currently only the global controller-version floor). The config/env DEFAULT_MIN_
|
// restarts (currently only the global controller-version floor). The config/env DEFAULT_MIN_
|
||||||
// CONTROLLER_VERSION is the FALLBACK; a row here (set via the operator UI) overrides it.
|
// CONTROLLER_VERSION is the FALLBACK; a row here (set via the operator UI) overrides it.
|
||||||
@@ -546,6 +555,14 @@ func (s *Store) migrate() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.51.0 dr_tier one-time legacy backfill — see the ALTER above; runs last so every table
|
||||||
|
// it touches (hosts, customer_configs) exists on a fresh DB too (where it finds nothing).
|
||||||
|
if drTierAlterErr == nil {
|
||||||
|
if err := s.backfillDRTierFromDescriptors(); err != nil {
|
||||||
|
return fmt.Errorf("dr_tier backfill: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,6 +909,43 @@ func (s *Store) Close() error {
|
|||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backfillDRTierFromDescriptors is the ONE-TIME legacy initialization for the v0.51.0 dr_tier
|
||||||
|
// column (called only when the ALTER just added it): a customer whose host already carries an
|
||||||
|
// ENABLED pbs_dr descriptor in desired_json gets dr_tier=1 — initialize from reality; everyone
|
||||||
|
// else stays 0 (never auto-cascade a legacy box). JSON is inspected in Go, not by LIKE, so field
|
||||||
|
// order/whitespace can't fool it.
|
||||||
|
func (s *Store) backfillDRTierFromDescriptors() error {
|
||||||
|
rows, err := s.db.Query(`SELECT customer_id, desired_json FROM hosts WHERE desired_json LIKE '%pbs_dr%'`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
enabled := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var customerID, desired string
|
||||||
|
if err := rows.Scan(&customerID, &desired); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
PBSDR *struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
} `json:"pbs_dr"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(desired), &doc) == nil && doc.PBSDR != nil && doc.PBSDR.Enabled {
|
||||||
|
enabled[customerID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for customerID := range enabled {
|
||||||
|
if _, err := s.db.Exec(`UPDATE customer_configs SET dr_tier = 1 WHERE customer_id = ?`, customerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CustomerConfig holds a pre-provisioned customer configuration.
|
// CustomerConfig holds a pre-provisioned customer configuration.
|
||||||
type CustomerConfig struct {
|
type CustomerConfig struct {
|
||||||
CustomerID string
|
CustomerID string
|
||||||
@@ -908,8 +962,14 @@ type CustomerConfig struct {
|
|||||||
// ConfigVersion is the monotonic config counter (bumped on every SaveCustomerConfig). The report
|
// ConfigVersion is the monotonic config counter (bumped on every SaveCustomerConfig). The report
|
||||||
// ACK advertises it; the controller re-pulls + self-restarts when it changes. Never a YAML hash.
|
// ACK advertises it; the controller re-pulls + self-restarts when it changes. Never a YAML hash.
|
||||||
ConfigVersion int
|
ConfigVersion int
|
||||||
CreatedAt time.Time
|
// DRTier (v0.51.0, DR-tier-by-default) is the per-customer DR-tier flag — the operator INTENT
|
||||||
UpdatedAt time.Time
|
// the pbsdr cascade converges toward. Default ON for NEW customers (create handler); legacy
|
||||||
|
// rows were initialized from reality by the one-time backfill (enabled descriptor → ON).
|
||||||
|
// OFF = zero Felhom-side cost (no ep0 namespace is provisioned) and offsite provisioning is
|
||||||
|
// refused (the escrow ceremony depends on the PBS key — drill F-6, closed by policy).
|
||||||
|
DRTier bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveCustomerConfig creates or updates a customer configuration. Every save BUMPS config_version
|
// SaveCustomerConfig creates or updates a customer configuration. Every save BUMPS config_version
|
||||||
@@ -921,8 +981,8 @@ type CustomerConfig struct {
|
|||||||
func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
|
func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec(`
|
||||||
INSERT INTO customer_configs (customer_id, customer_name, domain, email,
|
INSERT INTO customer_configs (customer_id, customer_name, domain, email,
|
||||||
retrieval_password, api_key, config_json, min_controller_version, config_version, updated_at)
|
retrieval_password, api_key, config_json, min_controller_version, dr_tier, config_version, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'))
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'))
|
||||||
ON CONFLICT(customer_id) DO UPDATE SET
|
ON CONFLICT(customer_id) DO UPDATE SET
|
||||||
customer_name = excluded.customer_name,
|
customer_name = excluded.customer_name,
|
||||||
domain = excluded.domain,
|
domain = excluded.domain,
|
||||||
@@ -931,10 +991,11 @@ func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
|
|||||||
api_key = excluded.api_key,
|
api_key = excluded.api_key,
|
||||||
config_json = excluded.config_json,
|
config_json = excluded.config_json,
|
||||||
min_controller_version = excluded.min_controller_version,
|
min_controller_version = excluded.min_controller_version,
|
||||||
|
dr_tier = excluded.dr_tier,
|
||||||
config_version = customer_configs.config_version + 1,
|
config_version = customer_configs.config_version + 1,
|
||||||
updated_at = datetime('now')`,
|
updated_at = datetime('now')`,
|
||||||
cfg.CustomerID, cfg.CustomerName, cfg.Domain, cfg.Email,
|
cfg.CustomerID, cfg.CustomerName, cfg.Domain, cfg.Email,
|
||||||
cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON, cfg.MinControllerVersion,
|
cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON, cfg.MinControllerVersion, cfg.DRTier,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -945,12 +1006,12 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) {
|
|||||||
var createdAt, updatedAt string
|
var createdAt, updatedAt string
|
||||||
err := s.db.QueryRow(`
|
err := s.db.QueryRow(`
|
||||||
SELECT customer_id, customer_name, domain, email,
|
SELECT customer_id, customer_name, domain, email,
|
||||||
retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at
|
retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, config_version, created_at, updated_at
|
||||||
FROM customer_configs WHERE customer_id = ?`,
|
FROM customer_configs WHERE customer_id = ?`,
|
||||||
customerID,
|
customerID,
|
||||||
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
|
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
|
||||||
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
|
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
|
||||||
&cfg.ConfigVersion, &createdAt, &updatedAt)
|
&cfg.DRTier, &cfg.ConfigVersion, &createdAt, &updatedAt)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -1000,7 +1061,7 @@ func (s *Store) ConsumeOneTimeSecret(customerID string) (string, error) {
|
|||||||
func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
|
func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT customer_id, customer_name, domain, email,
|
SELECT customer_id, customer_name, domain, email,
|
||||||
retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at
|
retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, config_version, created_at, updated_at
|
||||||
FROM customer_configs ORDER BY customer_id`)
|
FROM customer_configs ORDER BY customer_id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1013,7 +1074,7 @@ func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
|
|||||||
var createdAt, updatedAt string
|
var createdAt, updatedAt string
|
||||||
if err := rows.Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
|
if err := rows.Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
|
||||||
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
|
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
|
||||||
&cfg.ConfigVersion, &createdAt, &updatedAt); err != nil {
|
&cfg.DRTier, &cfg.ConfigVersion, &createdAt, &updatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cfg.CreatedAt = parseSQLiteTime(createdAt)
|
cfg.CreatedAt = parseSQLiteTime(createdAt)
|
||||||
@@ -1036,12 +1097,12 @@ func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error
|
|||||||
var createdAt, updatedAt string
|
var createdAt, updatedAt string
|
||||||
err := s.db.QueryRow(`
|
err := s.db.QueryRow(`
|
||||||
SELECT customer_id, customer_name, domain, email,
|
SELECT customer_id, customer_name, domain, email,
|
||||||
retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at
|
retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, config_version, created_at, updated_at
|
||||||
FROM customer_configs WHERE api_key = ?`,
|
FROM customer_configs WHERE api_key = ?`,
|
||||||
apiKey,
|
apiKey,
|
||||||
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
|
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
|
||||||
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
|
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
|
||||||
&cfg.ConfigVersion, &createdAt, &updatedAt)
|
&cfg.DRTier, &cfg.ConfigVersion, &createdAt, &updatedAt)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -473,13 +473,15 @@ func (s *Server) configFormData(r *http.Request, isNew bool, cfg *store.Customer
|
|||||||
ActiveNav: "configs",
|
ActiveNav: "configs",
|
||||||
Error: errMsg,
|
Error: errMsg,
|
||||||
CSRFField: s.csrfField(r),
|
CSRFField: s.csrfField(r),
|
||||||
PBSDR: s.pbsDRViewFor(cfg.CustomerID),
|
PBSDR: s.pbsDRViewFor(cfg.CustomerID, cfg.DRTier),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConfigNewForm shows the form to create a new customer config.
|
// handleConfigNewForm shows the form to create a new customer config. DRTier starts ON —
|
||||||
|
// DR-tier-by-default is the new-customer default (operator decision 2026-07-12 #2); opting out
|
||||||
|
// is the per-customer exception.
|
||||||
func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) {
|
||||||
s.renderConfigForm(w, r, true, &store.CustomerConfig{}, nil, "")
|
s.renderConfigForm(w, r, true, &store.CustomerConfig{DRTier: true}, nil, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConfigCreate processes the form submission to create a new config.
|
// handleConfigCreate processes the form submission to create a new config.
|
||||||
@@ -534,6 +536,9 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
RetrievalPassword: retrievalPassword,
|
RetrievalPassword: retrievalPassword,
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
ConfigJSON: configJSON,
|
ConfigJSON: configJSON,
|
||||||
|
// v0.51.0: the DR-tier flag (the form checkbox defaults ON for new customers). Set
|
||||||
|
// BEFORE applyOffsite — offsite provisioning is refused without the DR tier (F-6).
|
||||||
|
DRTier: formBool(r, "dr_tier"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offsite provisioning (fail-closed): a provisioning error must NOT save a half-enabled config.
|
// Offsite provisioning (fail-closed): a provisioning error must NOT save a half-enabled config.
|
||||||
@@ -588,6 +593,9 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
|
|||||||
cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name"))
|
cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name"))
|
||||||
cfg.Domain = strings.TrimSpace(r.FormValue("domain"))
|
cfg.Domain = strings.TrimSpace(r.FormValue("domain"))
|
||||||
cfg.Email = strings.TrimSpace(r.FormValue("email"))
|
cfg.Email = strings.TrimSpace(r.FormValue("email"))
|
||||||
|
// v0.51.0: the DR-tier flag — set BEFORE applyOffsite (offsite requires the tier) and
|
||||||
|
// applyPBSDR (which converges the descriptor toward it).
|
||||||
|
cfg.DRTier = formBool(r, "dr_tier")
|
||||||
|
|
||||||
// Server-side twin of the form's required attributes (v0.48.0 — B3). The error re-render is
|
// Server-side twin of the form's required attributes (v0.48.0 — B3). The error re-render is
|
||||||
// the STANDALONE page and carries the SUBMITTED overrides, so nothing the operator typed is
|
// the STANDALONE page and carries the SUBMITTED overrides, so nothing the operator typed is
|
||||||
@@ -1040,6 +1048,13 @@ func (s *Server) applyOffsite(ctx context.Context, r *http.Request, cfg *store.C
|
|||||||
if v := r.FormValue("offsite_enabled"); v != "on" && v != "true" {
|
if v := r.FormValue("offsite_enabled"); v != "on" && v != "true" {
|
||||||
return nil // not enabled → disabled by omission
|
return nil // not enabled → disabled by omission
|
||||||
}
|
}
|
||||||
|
// v0.51.0 (decision 3, drill F-6 closed by policy): offsite app backup REQUIRES the DR tier —
|
||||||
|
// fork-4 needs the escrow ceremony, and the ceremony hard-requires the PBS key K. Without the
|
||||||
|
// tier, provisioning would run straight into the F-6 dead end (EscrowState pending forever).
|
||||||
|
// The guard reads cfg.DRTier, which the handlers set from the form BEFORE calling here.
|
||||||
|
if !cfg.DRTier {
|
||||||
|
return fmt.Errorf("Offsite backup requires the DR tier — enable it first (the escrow ceremony depends on the PBS key)")
|
||||||
|
}
|
||||||
if s.offsite == nil {
|
if s.offsite == nil {
|
||||||
return fmt.Errorf("offsite provisioning is not configured on this hub (no Hetzner token)")
|
return fmt.Errorf("offsite provisioning is not configured on this hub (no Hetzner token)")
|
||||||
}
|
}
|
||||||
@@ -1110,6 +1125,13 @@ func buildConfigJSON(r *http.Request) string {
|
|||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formBool reads a checkbox-style form value ("on"/"true" → true; anything else, incl. absent,
|
||||||
|
// → false).
|
||||||
|
func formBool(r *http.Request, name string) bool {
|
||||||
|
v := r.FormValue(name)
|
||||||
|
return v == "on" || v == "true"
|
||||||
|
}
|
||||||
|
|
||||||
// handleGeoDisable removes all [felhom-geo] WAF rules from Cloudflare for a customer. The Cloudflare
|
// handleGeoDisable removes all [felhom-geo] WAF rules from Cloudflare for a customer. The Cloudflare
|
||||||
// WAF rules ARE the geo enforcement, so removing them disables geo-restriction. This is a hub→Cloudflare
|
// WAF rules ARE the geo enforcement, so removing them disables geo-restriction. This is a hub→Cloudflare
|
||||||
// call (NOT into the box) and stays. The old secondary inbound notify to the controller
|
// call (NOT into the box) and stays. The old secondary inbound notify to the controller
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func TestConfigUpdate_DebugSurvivesRebuild_OffsiteUntouched(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1) First save WITH offsite enabled → provisions + merges the descriptor. No debug yet.
|
// 1) First save WITH offsite enabled → provisions + merges the descriptor. No debug yet.
|
||||||
const offsiteForm = "customer_name=Kov%C3%A1cs&domain=kovacs.felhom.eu&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50"
|
const offsiteForm = "customer_name=Kov%C3%A1cs&domain=kovacs.felhom.eu&dr_tier=on&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50"
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
s.handleConfigUpdate(w, postForm("/configs/"+id+"/edit", offsiteForm), id)
|
s.handleConfigUpdate(w, postForm("/configs/"+id+"/edit", offsiteForm), id)
|
||||||
if w.Code != http.StatusSeeOther {
|
if w.Code != http.StatusSeeOther {
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ func TestApplyOffsite_ClientDisconnectMidProvision(t *testing.T) {
|
|||||||
r := httptest.NewRequest(http.MethodPost,
|
r := httptest.NewRequest(http.MethodPost,
|
||||||
"/configs/cust-web/edit?offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50", nil)
|
"/configs/cust-web/edit?offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50", nil)
|
||||||
r = r.WithContext(reqCtx)
|
r = r.WithContext(reqCtx)
|
||||||
cfg := &store.CustomerConfig{CustomerID: "cust-web", ConfigJSON: "{}"}
|
// DRTier: offsite requires the DR tier since v0.51.0 (the F-6 coupling) — this test is about
|
||||||
|
// the disconnect atom, so the gate is satisfied.
|
||||||
|
cfg := &store.CustomerConfig{CustomerID: "cust-web", ConfigJSON: "{}", DRTier: true}
|
||||||
|
|
||||||
err := s.applyOffsite(r.Context(), r, cfg)
|
err := s.applyOffsite(r.Context(), r, cfg)
|
||||||
|
|
||||||
|
|||||||
@@ -225,10 +225,11 @@ func TestCustomerActions_RedirectAnchors(t *testing.T) {
|
|||||||
cfg := &store.CustomerConfig{
|
cfg := &store.CustomerConfig{
|
||||||
CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu",
|
CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu",
|
||||||
RetrievalPassword: "pw", APIKey: "k", Status: "active", ConfigJSON: "{}",
|
RetrievalPassword: "pw", APIKey: "k", Status: "active", ConfigJSON: "{}",
|
||||||
|
DRTier: true, // offsite requires the DR tier since v0.51.0 (the F-6 coupling)
|
||||||
}
|
}
|
||||||
// Provision the shared offsite tier through the real applyOffsite leg so reissue/freeze
|
// Provision the shared offsite tier through the real applyOffsite leg so reissue/freeze
|
||||||
// find exactly one labelled sub-account.
|
// find exactly one labelled sub-account.
|
||||||
r := postForm("/configs/c1/edit", "offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50")
|
r := postForm("/configs/c1/edit", "dr_tier=on&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50")
|
||||||
if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
|
if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
|
||||||
t.Fatalf("provision: %v", err)
|
t.Fatalf("provision: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
// DR-tier-by-default (v0.51.0) — the three §10 contracts of the batch spec:
|
||||||
|
// 1. coupling gate: offsite provisioning REQUIRES the DR tier (drill F-6 closed by policy) —
|
||||||
|
// exact refusal message; DR ON proceeds to the existing path.
|
||||||
|
// Red-proof partner: remove the guard in applyOffsite → the refused-case test FAILS.
|
||||||
|
// 2. flag default: the NEW-customer form renders the DR checkbox CHECKED (default ON);
|
||||||
|
// a legacy customer without an applied descriptor stays OFF (backfill covered store-side).
|
||||||
|
// Red-proof partner: flip handleConfigNewForm's DRTier default → the form test FAILS.
|
||||||
|
// 3. capability chips: the host page renders the agent's capability list; "inactive" gets the
|
||||||
|
// NEUTRAL badge (disabled ≠ degraded), degraded stays warn/error, and the pre-v1.15.0
|
||||||
|
// "binary not found" pbsdr signature surfaces the migration hint.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const offsiteRequiresDRMsg = "Offsite backup requires the DR tier — enable it first (the escrow ceremony depends on the PBS key)"
|
||||||
|
|
||||||
|
// Scenario C — the coupling. DR OFF → offsite refused (exact message, Hetzner NEVER touched);
|
||||||
|
// DR ON → proceeds into the existing provisioning path.
|
||||||
|
func TestOffsiteRequiresDRTier(t *testing.T) {
|
||||||
|
newOffsiteServer := func(t *testing.T) (*Server, *store.Store, *hetznerapi.Fake) {
|
||||||
|
s, st := newTestServer(t)
|
||||||
|
fakeAPI := hetznerapi.NewFake()
|
||||||
|
s.SetOffsiteProvisioner(&offsite.Provisioner{
|
||||||
|
API: fakeAPI, Store: st, Scanner: webTestScanner{},
|
||||||
|
PoolBoxID: 611714, Location: "fsn1", Logger: log.New(io.Discard, "", 0),
|
||||||
|
})
|
||||||
|
if err := st.SaveCustomerConfig(&store.CustomerConfig{
|
||||||
|
CustomerID: "cpl", CustomerName: "Coupling", Domain: "cpl.hu",
|
||||||
|
RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s, st, fakeAPI
|
||||||
|
}
|
||||||
|
post := func(s *Server, form url.Values) *httptest.ResponseRecorder {
|
||||||
|
form.Set("customer_name", "Coupling")
|
||||||
|
form.Set("domain", "cpl.hu")
|
||||||
|
req := httptest.NewRequest("POST", "/configs/cpl", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleConfigUpdate(rr, req, "cpl")
|
||||||
|
return rr
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("DR OFF refuses with the exact message", func(t *testing.T) {
|
||||||
|
s, st, _ := newOffsiteServer(t)
|
||||||
|
rr := post(s, url.Values{"offsite_enabled": {"on"}, "offsite_type": {"shared"}, "offsite_quota_gb": {"50"}})
|
||||||
|
if rr.Code != 502 {
|
||||||
|
t.Fatalf("offsite with DR OFF = %d (%s), want 502 refusal", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), offsiteRequiresDRMsg) {
|
||||||
|
t.Fatalf("refusal message = %q, want the exact spec wording %q", rr.Body.String(), offsiteRequiresDRMsg)
|
||||||
|
}
|
||||||
|
// The F-6 dead end never starts: no descriptor, no one-time secret, nothing saved.
|
||||||
|
cfg, _ := st.GetCustomerConfig("cpl")
|
||||||
|
if strings.Contains(cfg.ConfigJSON, "offsite") {
|
||||||
|
t.Errorf("offsite descriptor written despite the refusal: %s", cfg.ConfigJSON)
|
||||||
|
}
|
||||||
|
if _, err := st.ConsumeOneTimeSecret("cpl"); err == nil {
|
||||||
|
t.Error("a one-time offsite secret exists despite the refusal")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DR ON proceeds", func(t *testing.T) {
|
||||||
|
s, st, _ := newOffsiteServer(t)
|
||||||
|
rr := post(s, url.Values{"dr_tier": {"on"}, "offsite_enabled": {"on"}, "offsite_type": {"shared"}, "offsite_quota_gb": {"50"}})
|
||||||
|
if rr.Code != 303 {
|
||||||
|
t.Fatalf("offsite with DR ON = %d (%s), want 303", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
cfg, _ := st.GetCustomerConfig("cpl")
|
||||||
|
if !strings.Contains(cfg.ConfigJSON, `"offsite"`) {
|
||||||
|
t.Fatalf("offsite descriptor missing after a DR-ON provision: %s", cfg.ConfigJSON)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag default: the NEW-customer form starts with the DR-tier checkbox CHECKED (decision 2 —
|
||||||
|
// DR is the default; opting out is the exception). The stored default for a customer saved
|
||||||
|
// WITHOUT the checkbox stays false (legacy shape — asserted store-side with the backfill).
|
||||||
|
func TestDRTier_NewCustomerFormDefaultsOn(t *testing.T) {
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
req := httptest.NewRequest("GET", "/configs/new", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleConfigNewForm(rr, req)
|
||||||
|
out := rr.Body.String()
|
||||||
|
if !strings.Contains(out, `name="dr_tier" checked`) {
|
||||||
|
t.Fatal("the new-customer form does not default the DR tier ON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The host page's capability section (v0.51.0): inactive = neutral badge, degraded = warn/error,
|
||||||
|
// and the pre-v1.15.0 pbsdr "binary not found" signature surfaces the migration hint. Also the
|
||||||
|
// honest-reporting edge (§8): degraded is NEVER re-labeled by the hub.
|
||||||
|
func TestHostDetail_CapabilityChips(t *testing.T) {
|
||||||
|
reportFor := func(status, reason string) string {
|
||||||
|
return `{"host":{"cpu_percent":1},"capabilities":[
|
||||||
|
{"name":"pbsdr-create","feature":"PBS DR storage-entry create","critical":false,"status":"` + status + `","reason":"` + reason + `"},
|
||||||
|
{"name":"drive-bind","feature":"drive attach","critical":true,"status":"ok"}
|
||||||
|
]}`
|
||||||
|
}
|
||||||
|
seed := func(t *testing.T, report string) (*Server, *store.Host) {
|
||||||
|
s, st := newTestServer(t)
|
||||||
|
if err := st.SaveCustomerConfig(&store.CustomerConfig{
|
||||||
|
CustomerID: "capc", RetrievalPassword: "pw", APIKey: "k",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertHost(&store.Host{HostID: "capc-01", CustomerID: "capc", APIKey: "h"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SaveHostReport("capc-01", "capc", []byte(report), store.HostReportDenorm{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
host, err := st.GetHost("capc-01")
|
||||||
|
if err != nil || host == nil {
|
||||||
|
t.Fatal("seed host read")
|
||||||
|
}
|
||||||
|
return s, host
|
||||||
|
}
|
||||||
|
render := func(t *testing.T, s *Server, host *store.Host) string {
|
||||||
|
data := s.hostDetailData(host, httptest.NewRequest("GET", "/hosts/capc-01", nil))
|
||||||
|
var sb strings.Builder
|
||||||
|
if err := s.templates.ExecuteTemplate(&sb, "host_detail_body", data); err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("inactive renders the neutral chip, not red", func(t *testing.T) {
|
||||||
|
s, host := seed(t, reportFor("inactive", "disabled by configuration"))
|
||||||
|
out := render(t, s, host)
|
||||||
|
if !strings.Contains(out, `badge badge-neutral">inactive`) {
|
||||||
|
t.Fatalf("inactive chip not neutral:\n%s", section(out, "Capabilities"))
|
||||||
|
}
|
||||||
|
if strings.Contains(out, `badge-error">inactive`) || strings.Contains(out, `badge-warn">inactive`) {
|
||||||
|
t.Error("inactive rendered as an alarm state — disabled must not look broken")
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "predates the uniform DR plumbing") {
|
||||||
|
t.Error("migration hint shown for a healthy inactive box")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("degraded binary-not-found stays degraded + migration hint", func(t *testing.T) {
|
||||||
|
s, host := seed(t, reportFor("degraded", "binary not found"))
|
||||||
|
out := render(t, s, host)
|
||||||
|
if !strings.Contains(out, `badge badge-warn">degraded`) {
|
||||||
|
t.Fatalf("pbsdr degraded chip missing/re-labeled:\n%s", section(out, "Capabilities"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "predates the uniform DR plumbing") {
|
||||||
|
t.Error("migration hint missing for the pre-v1.15.0 signature (never silently pretend)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ok renders ok; critical is marked", func(t *testing.T) {
|
||||||
|
s, host := seed(t, reportFor("ok", ""))
|
||||||
|
out := render(t, s, host)
|
||||||
|
if !strings.Contains(out, `badge badge-ok">ok`) {
|
||||||
|
t.Fatalf("ok chip missing:\n%s", section(out, "Capabilities"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `badge badge-neutral">critical`) {
|
||||||
|
t.Error("critical marker missing on drive-bind")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// section extracts a readable slice of rendered HTML around a heading for failure messages.
|
||||||
|
func section(html, heading string) string {
|
||||||
|
i := strings.Index(html, heading)
|
||||||
|
if i < 0 {
|
||||||
|
return "(heading not found)"
|
||||||
|
}
|
||||||
|
end := i + 1500
|
||||||
|
if end > len(html) {
|
||||||
|
end = len(html)
|
||||||
|
}
|
||||||
|
return html[i:end]
|
||||||
|
}
|
||||||
@@ -107,6 +107,70 @@ func parseHostVitals(reportJSON string) hostVitals {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// capabilityView is one privileged-capability chip on the host detail page (v0.51.0 — the agent
|
||||||
|
// has reported these since v0.44.0; the hub now renders them). Class maps the agent's status to
|
||||||
|
// a badge: ok → badge-ok, degraded → badge-fail (critical) / badge-warn, inactive → badge-neutral
|
||||||
|
// (disabled ≠ degraded — the DR-tier-by-default rule; agent v0.86.0 emits "inactive").
|
||||||
|
type capabilityView struct {
|
||||||
|
Name string
|
||||||
|
Feature string
|
||||||
|
Status string
|
||||||
|
Reason string
|
||||||
|
Critical bool
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHostCapabilities extracts the capabilities array from a host-report body. Missing or
|
||||||
|
// malformed → nil (the "waiting for first report" path — the section hides).
|
||||||
|
func parseHostCapabilities(reportJSON string) []capabilityView {
|
||||||
|
if reportJSON == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Capabilities []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Feature string `json:"feature"`
|
||||||
|
Critical bool `json:"critical"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
} `json:"capabilities"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(reportJSON), &body); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]capabilityView, 0, len(body.Capabilities))
|
||||||
|
for _, c := range body.Capabilities {
|
||||||
|
v := capabilityView{Name: c.Name, Feature: c.Feature, Status: c.Status, Reason: c.Reason, Critical: c.Critical}
|
||||||
|
switch c.Status {
|
||||||
|
case "ok":
|
||||||
|
v.Class = "badge-ok"
|
||||||
|
case "inactive":
|
||||||
|
v.Class = "badge-neutral"
|
||||||
|
default: // degraded (or an unknown future status — surface it, never hide it)
|
||||||
|
if c.Critical {
|
||||||
|
v.Class = "badge-error"
|
||||||
|
} else {
|
||||||
|
v.Class = "badge-warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// capabilitiesNeedDRMigration reports whether any pbsdr-* capability is degraded with the
|
||||||
|
// pre-v1.15.0 signature ("binary not found") — the box predates the uniform DR plumbing. The
|
||||||
|
// host page then surfaces the migration one-liner instead of silently pretending (§8 of the
|
||||||
|
// DR-by-default spec).
|
||||||
|
func capabilitiesNeedDRMigration(caps []capabilityView) bool {
|
||||||
|
for _, c := range caps {
|
||||||
|
if strings.HasPrefix(c.Name, "pbsdr-") && c.Status == "degraded" && c.Reason == "binary not found" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// storageTargetView is the rich per-drive row the host-detail Storage Targets table renders:
|
// storageTargetView is the rich per-drive row the host-detail Storage Targets table renders:
|
||||||
// fill %, role/state, thin-pool, and SMART health/temp/wear. Parsed from the latest report's
|
// fill %, role/state, thin-pool, and SMART health/temp/wear. Parsed from the latest report's
|
||||||
// storage_targets[] (the full hostStorageTarget wire shape lives in the api package; this view
|
// storage_targets[] (the full hostStorageTarget wire shape lives in the api package; this view
|
||||||
@@ -296,6 +360,24 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
|
|||||||
vitals := parseHostVitals(reportJSON)
|
vitals := parseHostVitals(reportJSON)
|
||||||
storageTargets := parseHostStorageTargets(reportJSON)
|
storageTargets := parseHostStorageTargets(reportJSON)
|
||||||
sort.Slice(storageTargets, func(i, j int) bool { return storageTargets[i].Name < storageTargets[j].Name })
|
sort.Slice(storageTargets, func(i, j int) bool { return storageTargets[i].Name < storageTargets[j].Name })
|
||||||
|
// v0.51.0: capability chips — non-ok first (what the operator needs to see), then by name.
|
||||||
|
capabilities := parseHostCapabilities(reportJSON)
|
||||||
|
sort.SliceStable(capabilities, func(i, j int) bool {
|
||||||
|
rank := func(s string) int {
|
||||||
|
switch s {
|
||||||
|
case "degraded":
|
||||||
|
return 0
|
||||||
|
case "inactive":
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a, b := rank(capabilities[i].Status), rank(capabilities[j].Status); a != b {
|
||||||
|
return a < b
|
||||||
|
}
|
||||||
|
return capabilities[i].Name < capabilities[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
// DR / backup presence — booleans only, never the opaque blobs.
|
// DR / backup presence — booleans only, never the opaque blobs.
|
||||||
drBundle, _ := s.store.GetHostDRBundle(host.HostID)
|
drBundle, _ := s.store.GetHostDRBundle(host.HostID)
|
||||||
@@ -320,6 +402,8 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
|
|||||||
"GuestRunning": guestRunning,
|
"GuestRunning": guestRunning,
|
||||||
"GuestTotal": len(guests),
|
"GuestTotal": len(guests),
|
||||||
"StorageTargets": storageTargets,
|
"StorageTargets": storageTargets,
|
||||||
|
"Capabilities": capabilities,
|
||||||
|
"NeedsDRMigration": capabilitiesNeedDRMigration(capabilities),
|
||||||
"DRPresent": drBundle != nil,
|
"DRPresent": drBundle != nil,
|
||||||
"EscrowPresent": escrow != nil,
|
"EscrowPresent": escrow != nil,
|
||||||
// v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL).
|
// v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL).
|
||||||
|
|||||||
+103
-33
@@ -91,11 +91,18 @@ func mergePBSDR(desiredJSON string, d *pbsDRDescriptor) (string, error) {
|
|||||||
return string(out), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyPBSDR handles the config form's PBS DR section on create/update. Called BEFORE
|
// applyPBSDR handles the config form's DR-tier section on create/update. Called BEFORE
|
||||||
// SaveCustomerConfig (fail-closed: an error must abort the whole save). The descriptor lives in
|
// SaveCustomerConfig (fail-closed on REAL provisioning failures: those abort the whole save).
|
||||||
// the HOST's desired_json — ConfigJSON never carries pbs_dr (single source of truth).
|
// The descriptor lives in the HOST's desired_json — ConfigJSON never carries pbs_dr.
|
||||||
|
//
|
||||||
|
// v0.51.0 (DR-tier-by-default): the switch is cfg.DRTier (the stored per-customer flag, set from
|
||||||
|
// the form by the handler) — and an UNMET PRECONDITION is no longer an error. New customers
|
||||||
|
// default the flag ON before any host exists; the cascade converges later: host enrolls → WG
|
||||||
|
// peer registers (the api hook auto-provisions, scenario A) → descriptor applies on the agent's
|
||||||
|
// next tick. The form save stores the intent and reports the honest waiting stage; only an
|
||||||
|
// actual provisioning FAILURE (tenantsync error, token mismatch…) still fails the save.
|
||||||
func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.CustomerConfig) error {
|
func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.CustomerConfig) error {
|
||||||
enabled := r.FormValue("pbsdr_enabled") == "on" || r.FormValue("pbsdr_enabled") == "true"
|
enabled := cfg.DRTier
|
||||||
storageID := strings.TrimSpace(r.FormValue("pbsdr_storage_id"))
|
storageID := strings.TrimSpace(r.FormValue("pbsdr_storage_id"))
|
||||||
if storageID == "" {
|
if storageID == "" {
|
||||||
storageID = defaultPBSStorageID
|
storageID = defaultPBSStorageID
|
||||||
@@ -106,10 +113,10 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus
|
|||||||
return fmt.Errorf("pbsdr: host lookup: %w", err)
|
return fmt.Errorf("pbsdr: host lookup: %w", err)
|
||||||
}
|
}
|
||||||
if host == nil {
|
if host == nil {
|
||||||
if !enabled {
|
if enabled {
|
||||||
return nil // nothing enrolled, nothing enabled — nothing to do
|
s.logger.Printf("[INFO] pbsdr: DR tier ON for %s, no host enrolled yet — the descriptor applies once the cascade is ready (host → WG peer → apply)", cfg.CustomerID)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no host enrolled for customer %s yet — the PBS DR tier needs the enrolled host", cfg.CustomerID)
|
return nil // the flag is stored; nothing host-side to converge yet
|
||||||
}
|
}
|
||||||
cur := readPBSDR(host.DesiredJSON)
|
cur := readPBSDR(host.DesiredJSON)
|
||||||
|
|
||||||
@@ -156,20 +163,38 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh provision. Fail-closed preconditions first.
|
// Fresh provision — the shared atom (also fired by the WG-registration hook, scenario A).
|
||||||
|
// An unmet precondition is an honest waiting stage, never a save-blocking error.
|
||||||
|
blocked, err := s.pbsdrProvisionAtom(ctx, cfg.CustomerID, host, storageID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if blocked != "" {
|
||||||
|
s.logger.Printf("[INFO] pbsdr: DR tier ON for %s — waiting: %s", cfg.CustomerID, blocked)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pbsdrProvisionAtom runs the fresh-provision cascade step for a host with NO provisioned
|
||||||
|
// descriptor yet: preconditions → tenantsync provision → consume-once secret → descriptor merge
|
||||||
|
// + generation bump. Returns (blockedReason, err): a non-empty blockedReason is an HONEST
|
||||||
|
// waiting stage (tunnel peer absent, endpoint unset, tenantsync unconfigured — the cascade
|
||||||
|
// retries at its next opportunity); err is a REAL provisioning failure and must stay loud
|
||||||
|
// (fail-closed on the form path, error-logged on the hook path).
|
||||||
|
func (s *Server) pbsdrProvisionAtom(ctx context.Context, customerID string, host *store.Host, storageID string) (string, error) {
|
||||||
if s.tenantsync == nil {
|
if s.tenantsync == nil {
|
||||||
return fmt.Errorf("PBS DR provisioning is not configured on this hub (no tenantsync key)")
|
return "PBS DR provisioning is not configured on this hub (no tenantsync key)", nil
|
||||||
}
|
}
|
||||||
if _, err := s.store.GetWGPeerForHost(host.HostID); err == sql.ErrNoRows {
|
if _, err := s.store.GetWGPeerForHost(host.HostID); err == sql.ErrNoRows {
|
||||||
return fmt.Errorf("host %s has not reported a WG key yet — the tunnel peer must exist before the PBS DR tier", host.HostID)
|
return fmt.Sprintf("host %s has not reported a WG key yet — the tunnel peer must exist before the PBS DR tier", host.HostID), nil
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return fmt.Errorf("pbsdr: wg peer lookup: %w", err)
|
return "", fmt.Errorf("pbsdr: wg peer lookup: %w", err)
|
||||||
}
|
}
|
||||||
ep, err := s.store.GetWGEndpoint()
|
ep, err := s.store.GetWGEndpoint()
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return fmt.Errorf("wg endpoint not configured — register ep0 before the PBS DR tier")
|
return "wg endpoint not configured — register ep0 before the PBS DR tier", nil
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return fmt.Errorf("pbsdr: wg endpoint read: %w", err)
|
return "", fmt.Errorf("pbsdr: wg endpoint read: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detach from the request context (the applyOffsite F1 precedent): once provisioning starts,
|
// Detach from the request context (the applyOffsite F1 precedent): once provisioning starts,
|
||||||
@@ -178,19 +203,19 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus
|
|||||||
// manual re-issue). The absolute timeout still bounds a hung SSH exec.
|
// manual re-issue). The absolute timeout still bounds a hung SSH exec.
|
||||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
res, err := s.tenantsync.Provision(ctx, cfg.CustomerID)
|
res, err := s.tenantsync.Provision(ctx, customerID)
|
||||||
if errors.Is(err, tenantsync.ErrTokenExists) {
|
if errors.Is(err, tenantsync.ErrTokenExists) {
|
||||||
// ep0 has a token but the hub has no descriptor — state mismatch (lost hub state or a
|
// ep0 has a token but the hub has no descriptor — state mismatch (lost hub state or a
|
||||||
// half-torn earlier attempt). Never silently re-key: the operator decides via Re-issue.
|
// half-torn earlier attempt). Never silently re-key: the operator decides via Re-issue.
|
||||||
return fmt.Errorf("the endpoint already holds a PBS token for %s but the hub has no descriptor — use the explicit \"Re-issue PBS credentials\" action", cfg.CustomerID)
|
return "", fmt.Errorf("the endpoint already holds a PBS token for %s but the hub has no descriptor — use the explicit \"Re-issue PBS credentials\" action", customerID)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// The atom: secret first (consume-once custody), then descriptor+bump (the agent's signal).
|
// The atom: secret first (consume-once custody), then descriptor+bump (the agent's signal).
|
||||||
if err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret); err != nil {
|
if err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret); err != nil {
|
||||||
return fmt.Errorf("pbsdr: store one-time token secret: %w", err)
|
return "", fmt.Errorf("pbsdr: store one-time token secret: %w", err)
|
||||||
}
|
}
|
||||||
desc := &pbsDRDescriptor{
|
desc := &pbsDRDescriptor{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@@ -203,15 +228,48 @@ func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.Cus
|
|||||||
}
|
}
|
||||||
merged, err := mergePBSDR(host.DesiredJSON, desc)
|
merged, err := mergePBSDR(host.DesiredJSON, desc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
gen, err := s.store.SetHostDesired(host.HostID, []byte(merged))
|
gen, err := s.store.SetHostDesired(host.HostID, []byte(merged))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("pbsdr: desired-state write: %w", err)
|
return "", fmt.Errorf("pbsdr: desired-state write: %w", err)
|
||||||
}
|
}
|
||||||
s.logger.Printf("[INFO] pbsdr provisioned for %s (host %s, ns %s, token_id %s, gen %d; secret stored consume-once, withheld from logs)",
|
s.logger.Printf("[INFO] pbsdr provisioned for %s (host %s, ns %s, token_id %s, gen %d; secret stored consume-once, withheld from logs)",
|
||||||
cfg.CustomerID, host.HostID, res.Namespace, res.TokenID, gen)
|
customerID, host.HostID, res.Namespace, res.TokenID, gen)
|
||||||
return nil
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PBSDRAutoProvision is the WG-registration hook (v0.51.0, scenario A): the api handler calls it
|
||||||
|
// (via the main.go closure) right after a host's first WG peer registration. If the customer's
|
||||||
|
// DR-tier flag is ON and no descriptor is provisioned yet, it runs the provisioning atom — the
|
||||||
|
// descriptor then applies on the agent's next desired-state tick with ZERO operator steps.
|
||||||
|
// Never fails the caller: outcomes are logged (an error here is retried by the next config save).
|
||||||
|
func (s *Server) PBSDRAutoProvision(ctx context.Context, customerID string) {
|
||||||
|
cfg, err := s.store.GetCustomerConfig(customerID)
|
||||||
|
if err != nil || cfg == nil || !cfg.DRTier {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host, err := s.store.GetHostByCustomer(customerID)
|
||||||
|
if err != nil || host == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cur := readPBSDR(host.DesiredJSON); cur != nil && cur.Namespace != "" {
|
||||||
|
return // already provisioned — nothing to converge here
|
||||||
|
}
|
||||||
|
storageID := defaultPBSStorageID
|
||||||
|
if cur := readPBSDR(host.DesiredJSON); cur != nil && cur.StorageID != "" {
|
||||||
|
storageID = cur.StorageID
|
||||||
|
}
|
||||||
|
blocked, err := s.pbsdrProvisionAtom(ctx, customerID, host, storageID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("[ERROR] pbsdr auto-provision for %s (WG-registration hook): %v — save the customer config to retry", customerID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if blocked != "" {
|
||||||
|
s.logger.Printf("[INFO] pbsdr auto-provision for %s still waiting: %s", customerID, blocked)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Printf("[INFO] pbsdr auto-provisioned for %s on WG registration (hands-free cascade)", customerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePBSDRReissue explicitly re-keys the customer's ep0 PBS token (the offsite F4 precedent):
|
// handlePBSDRReissue explicitly re-keys the customer's ep0 PBS token (the offsite F4 precedent):
|
||||||
@@ -269,22 +327,28 @@ func (s *Server) handlePBSDRReissue(w http.ResponseWriter, r *http.Request, cust
|
|||||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=pbsdr_reissued#tab=edit", http.StatusSeeOther)
|
http.Redirect(w, r, "/customers/"+customerID+"?flash=pbsdr_reissued#tab=edit", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pbsDRView is the config form's render model for the PBS DR section.
|
// pbsDRView is the config form's render model for the DR-tier section, including the v0.51.0
|
||||||
|
// cascade stages (host → WG peer → descriptor → escrow) — one flag, ordered rollout, honest
|
||||||
|
// intermediate states (scenario D).
|
||||||
type pbsDRView struct {
|
type pbsDRView struct {
|
||||||
Supported bool // tenantsync configured on this hub
|
Supported bool // tenantsync configured on this hub
|
||||||
NoHost bool // no enrolled host for the customer (enable would fail-closed)
|
NoHost bool // no enrolled host for the customer (cascade stage 1 waiting)
|
||||||
HostID string
|
HostID string
|
||||||
Enabled bool
|
DRTier bool // the stored per-customer flag (operator INTENT)
|
||||||
StorageID string // current or the default
|
Enabled bool // descriptor enabled (host-side reality)
|
||||||
Provisioned bool
|
StorageID string // current or the default
|
||||||
Namespace string
|
Provisioned bool
|
||||||
TokenID string
|
Namespace string
|
||||||
|
TokenID string
|
||||||
|
WGPeer bool // cascade stage 2: the host has registered its tunnel peer
|
||||||
|
EscrowPresent bool // cascade stage 4: the ceremony ran (blob in custody)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pbsDRViewFor loads the section state for the form. Read-only; every error degrades to a
|
// pbsDRViewFor loads the section state for the form. Read-only; every error degrades to a
|
||||||
// zero-ish view (the section still renders).
|
// zero-ish view (the section still renders). drTier is the customer's stored flag (the caller
|
||||||
func (s *Server) pbsDRViewFor(customerID string) pbsDRView {
|
// has the config; "" customerID = the create form).
|
||||||
v := pbsDRView{Supported: s.tenantsync != nil, StorageID: defaultPBSStorageID}
|
func (s *Server) pbsDRViewFor(customerID string, drTier bool) pbsDRView {
|
||||||
|
v := pbsDRView{Supported: s.tenantsync != nil, StorageID: defaultPBSStorageID, DRTier: drTier}
|
||||||
if customerID == "" {
|
if customerID == "" {
|
||||||
v.NoHost = true
|
v.NoHost = true
|
||||||
return v
|
return v
|
||||||
@@ -295,6 +359,12 @@ func (s *Server) pbsDRViewFor(customerID string) pbsDRView {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
v.HostID = host.HostID
|
v.HostID = host.HostID
|
||||||
|
if _, err := s.store.GetWGPeerForHost(host.HostID); err == nil {
|
||||||
|
v.WGPeer = true
|
||||||
|
}
|
||||||
|
if escrow, err := s.store.GetHostEscrow(host.HostID); err == nil && escrow != nil {
|
||||||
|
v.EscrowPresent = true
|
||||||
|
}
|
||||||
if d := readPBSDR(host.DesiredJSON); d != nil {
|
if d := readPBSDR(host.DesiredJSON); d != nil {
|
||||||
v.Enabled = d.Enabled
|
v.Enabled = d.Enabled
|
||||||
if d.StorageID != "" {
|
if d.StorageID != "" {
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ func TestPBSDR_ProvisionHappyPath(t *testing.T) {
|
|||||||
fake := &fakeTenancy{secret: "SUPER-SECRET-TOKEN"}
|
fake := &fakeTenancy{secret: "SUPER-SECRET-TOKEN"}
|
||||||
s, st, logBuf := newPBSDRServer(t, fake)
|
s, st, logBuf := newPBSDRServer(t, fake)
|
||||||
|
|
||||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}, "pbsdr_storage_id": {"felhom-pbs"}})
|
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}, "pbsdr_storage_id": {"felhom-pbs"}})
|
||||||
if rr.Code != 303 {
|
if rr.Code != 303 {
|
||||||
t.Fatalf("save = %d (%s), want 303", rr.Code, rr.Body.String())
|
t.Fatalf("save = %d (%s), want 303", rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ func TestPBSDR_ResaveIsNoOp(t *testing.T) {
|
|||||||
fake := &fakeTenancy{secret: "SUPER-SECRET-TOKEN"}
|
fake := &fakeTenancy{secret: "SUPER-SECRET-TOKEN"}
|
||||||
s, st, _ := newPBSDRServer(t, fake)
|
s, st, _ := newPBSDRServer(t, fake)
|
||||||
|
|
||||||
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||||
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != nil {
|
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != nil {
|
||||||
t.Fatalf("first secret consume: %v", err)
|
t.Fatalf("first secret consume: %v", err)
|
||||||
}
|
}
|
||||||
@@ -169,7 +169,7 @@ func TestPBSDR_ResaveIsNoOp(t *testing.T) {
|
|||||||
// Idempotent re-save: NO re-key, NO second secret row, NO spurious generation bump.
|
// Idempotent re-save: NO re-key, NO second secret row, NO spurious generation bump.
|
||||||
// (Red-proof: dropping the already-provisioned short-circuit in applyPBSDR re-runs
|
// (Red-proof: dropping the already-provisioned short-circuit in applyPBSDR re-runs
|
||||||
// Provision → calls=2 + a fresh consumable secret → FAIL.)
|
// Provision → calls=2 + a fresh consumable secret → FAIL.)
|
||||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||||
if rr.Code != 303 {
|
if rr.Code != 303 {
|
||||||
t.Fatalf("re-save = %d, want 303 (already-provisioned is success-no-op)", rr.Code)
|
t.Fatalf("re-save = %d, want 303 (already-provisioned is success-no-op)", rr.Code)
|
||||||
}
|
}
|
||||||
@@ -184,31 +184,43 @@ func TestPBSDR_ResaveIsNoOp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.51.0 (DR-tier-by-default): an UNMET PRECONDITION is an honest waiting stage, not a
|
||||||
|
// save-blocking error — the flag is stored and the cascade converges later. A REAL provisioning
|
||||||
|
// failure stays fail-closed. Red-proof partner for the coupling: drop the WG-peer stage check in
|
||||||
|
// pbsdrProvisionAtom → "no WG peer waits" fails (provision would be reached).
|
||||||
func TestPBSDR_FailClosed(t *testing.T) {
|
func TestPBSDR_FailClosed(t *testing.T) {
|
||||||
t.Run("no WG peer", func(t *testing.T) {
|
t.Run("no WG peer waits (stage, not error)", func(t *testing.T) {
|
||||||
fake := &fakeTenancy{secret: "S"}
|
fake := &fakeTenancy{secret: "S"}
|
||||||
s, st, _ := newPBSDRServer(t, fake)
|
s, st, logBuf := newPBSDRServer(t, fake)
|
||||||
// Kill the precondition: the host has never registered a WG key.
|
// Kill the precondition: the host has never registered a WG key.
|
||||||
if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil {
|
if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil {
|
||||||
t.Fatalf("remove seed peer: %v", err)
|
t.Fatalf("remove seed peer: %v", err)
|
||||||
}
|
}
|
||||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||||
if rr.Code != 502 {
|
if rr.Code != 303 {
|
||||||
t.Fatalf("save without WG peer = %d, want 502", rr.Code)
|
t.Fatalf("save without WG peer = %d (%s), want 303 — the flag stores, the cascade waits", rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
if fake.provisionCalls != 0 {
|
if fake.provisionCalls != 0 {
|
||||||
t.Errorf("provision reached despite missing peer (%d calls)", fake.provisionCalls)
|
t.Errorf("provision reached despite missing peer (%d calls)", fake.provisionCalls)
|
||||||
}
|
}
|
||||||
h, _ := st.GetHost("peti-01")
|
h, _ := st.GetHost("peti-01")
|
||||||
if readPBSDR(h.DesiredJSON) != nil || h.DesiredGeneration != 0 {
|
if readPBSDR(h.DesiredJSON) != nil || h.DesiredGeneration != 0 {
|
||||||
t.Errorf("fail-closed violated: desc=%v gen=%d", readPBSDR(h.DesiredJSON), h.DesiredGeneration)
|
t.Errorf("descriptor written while waiting: desc=%v gen=%d", readPBSDR(h.DesiredJSON), h.DesiredGeneration)
|
||||||
|
}
|
||||||
|
// The flag itself IS saved (the intent survives the wait).
|
||||||
|
cfg, _ := st.GetCustomerConfig("peti")
|
||||||
|
if !cfg.DRTier {
|
||||||
|
t.Error("DR flag not stored while the cascade waits")
|
||||||
|
}
|
||||||
|
if !strings.Contains(logBuf.String(), "has not reported a WG key yet") {
|
||||||
|
t.Error("the waiting stage (guard wording) was not logged")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("tenantsync error", func(t *testing.T) {
|
t.Run("tenantsync error", func(t *testing.T) {
|
||||||
fake := &fakeTenancy{err: errors.New("ssh boom")}
|
fake := &fakeTenancy{err: errors.New("ssh boom")}
|
||||||
s, st, _ := newPBSDRServer(t, fake)
|
s, st, _ := newPBSDRServer(t, fake)
|
||||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||||
if rr.Code != 502 {
|
if rr.Code != 502 {
|
||||||
t.Fatalf("save with tenantsync error = %d, want 502", rr.Code)
|
t.Fatalf("save with tenantsync error = %d, want 502", rr.Code)
|
||||||
}
|
}
|
||||||
@@ -229,7 +241,7 @@ func TestPBSDR_FailClosed(t *testing.T) {
|
|||||||
t.Run("token_exists points at re-issue", func(t *testing.T) {
|
t.Run("token_exists points at re-issue", func(t *testing.T) {
|
||||||
fake := &fakeTenancy{err: tenantsync.ErrTokenExists}
|
fake := &fakeTenancy{err: tenantsync.ErrTokenExists}
|
||||||
s, st, _ := newPBSDRServer(t, fake)
|
s, st, _ := newPBSDRServer(t, fake)
|
||||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||||
if rr.Code != 502 || !strings.Contains(rr.Body.String(), "Re-issue") {
|
if rr.Code != 502 || !strings.Contains(rr.Body.String(), "Re-issue") {
|
||||||
t.Fatalf("token_exists = %d (%s), want 502 mentioning Re-issue", rr.Code, rr.Body.String())
|
t.Fatalf("token_exists = %d (%s), want 502 mentioning Re-issue", rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
@@ -239,11 +251,11 @@ func TestPBSDR_FailClosed(t *testing.T) {
|
|||||||
_ = st
|
_ = st
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("not configured", func(t *testing.T) {
|
t.Run("not configured waits (stage, not error)", func(t *testing.T) {
|
||||||
s, st, _ := newPBSDRServer(t, nil) // no tenantsync
|
s, st, _ := newPBSDRServer(t, nil) // no tenantsync
|
||||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||||
if rr.Code != 502 {
|
if rr.Code != 303 {
|
||||||
t.Fatalf("save without tenantsync = %d, want 502", rr.Code)
|
t.Fatalf("save without tenantsync = %d, want 303 — the flag stores, provisioning waits", rr.Code)
|
||||||
}
|
}
|
||||||
if desc, _, gen := hostState(t, st); desc != nil || gen != 0 {
|
if desc, _, gen := hostState(t, st); desc != nil || gen != 0 {
|
||||||
t.Errorf("state written without a provisioner: desc=%+v gen=%d", desc, gen)
|
t.Errorf("state written without a provisioner: desc=%+v gen=%d", desc, gen)
|
||||||
@@ -251,10 +263,67 @@ func TestPBSDR_FailClosed(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scenario A (hands-free cascade): flag ON while the WG peer is missing → the save waits; the
|
||||||
|
// host's WG registration then fires PBSDRAutoProvision and the descriptor lands with ZERO
|
||||||
|
// operator steps. Red-proof partner: unhook the atom from PBSDRAutoProvision (early return) →
|
||||||
|
// this fails at "descriptor after WG registration".
|
||||||
|
func TestPBSDR_AutoProvisionOnWGRegistration(t *testing.T) {
|
||||||
|
fake := &fakeTenancy{secret: "AUTO-SECRET"}
|
||||||
|
s, st, logBuf := newPBSDRServer(t, fake)
|
||||||
|
if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil {
|
||||||
|
t.Fatalf("remove seed peer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Flag ON, peer missing → stored intent, no descriptor.
|
||||||
|
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||||
|
if desc, _, _ := hostState(t, st); desc != nil {
|
||||||
|
t.Fatalf("descriptor exists before the WG peer: %+v", desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The agent registers its WG key (what the api handler does) → the hook fires.
|
||||||
|
if _, _, err := st.RegisterWGPeerForHost("peti-01", "PETIPUBKEY"); err != nil {
|
||||||
|
t.Fatalf("register peer: %v", err)
|
||||||
|
}
|
||||||
|
s.PBSDRAutoProvision(context.Background(), "peti")
|
||||||
|
|
||||||
|
desc, desiredJSON, _ := hostState(t, st)
|
||||||
|
if desc == nil || !desc.Enabled || desc.Namespace != "peti" {
|
||||||
|
t.Fatalf("descriptor after WG registration = %+v (json %s), want provisioned", desc, desiredJSON)
|
||||||
|
}
|
||||||
|
if fake.provisionCalls != 1 {
|
||||||
|
t.Errorf("provision calls = %d, want 1", fake.provisionCalls)
|
||||||
|
}
|
||||||
|
if got, err := st.ConsumeHostPBSSecret("peti-01"); err != nil || got != "AUTO-SECRET" {
|
||||||
|
t.Fatalf("consume-once secret after auto-provision = (%q, %v)", got, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(logBuf.String(), "AUTO-SECRET") {
|
||||||
|
t.Error("secret leaked into the hub log")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Idempotent: a second hook firing (re-registration) must not re-key.
|
||||||
|
s.PBSDRAutoProvision(context.Background(), "peti")
|
||||||
|
if fake.provisionCalls != 1 {
|
||||||
|
t.Errorf("second hook firing re-provisioned (%d calls)", fake.provisionCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Flag OFF → the hook never provisions (scenario B: off is off).
|
||||||
|
fake2 := &fakeTenancy{secret: "S2"}
|
||||||
|
s2, st2, _ := newPBSDRServer(t, fake2)
|
||||||
|
cfg, _ := st2.GetCustomerConfig("peti")
|
||||||
|
cfg.DRTier = false
|
||||||
|
if err := st2.SaveCustomerConfig(cfg); err != nil {
|
||||||
|
t.Fatalf("save flag-off config: %v", err)
|
||||||
|
}
|
||||||
|
s2.PBSDRAutoProvision(context.Background(), "peti")
|
||||||
|
if fake2.provisionCalls != 0 {
|
||||||
|
t.Errorf("hook provisioned with the DR flag OFF (%d calls)", fake2.provisionCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPBSDR_DisableKeepsTenancy(t *testing.T) {
|
func TestPBSDR_DisableKeepsTenancy(t *testing.T) {
|
||||||
fake := &fakeTenancy{secret: "S"}
|
fake := &fakeTenancy{secret: "S"}
|
||||||
s, st, _ := newPBSDRServer(t, fake)
|
s, st, _ := newPBSDRServer(t, fake)
|
||||||
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||||
|
|
||||||
// Unchecked box → descriptor enabled:false, coords kept, ONE bump; no endpoint mutation.
|
// Unchecked box → descriptor enabled:false, coords kept, ONE bump; no endpoint mutation.
|
||||||
rr := postUpdate(t, s, url.Values{})
|
rr := postUpdate(t, s, url.Values{})
|
||||||
@@ -281,10 +350,10 @@ func TestPBSDR_DisableKeepsTenancy(t *testing.T) {
|
|||||||
func TestPBSDR_StorageIDChangeUpdatesDescriptorOnly(t *testing.T) {
|
func TestPBSDR_StorageIDChangeUpdatesDescriptorOnly(t *testing.T) {
|
||||||
fake := &fakeTenancy{secret: "S"}
|
fake := &fakeTenancy{secret: "S"}
|
||||||
s, st, _ := newPBSDRServer(t, fake)
|
s, st, _ := newPBSDRServer(t, fake)
|
||||||
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||||
st.ConsumeHostPBSSecret("peti-01") // spend the provision secret
|
st.ConsumeHostPBSSecret("peti-01") // spend the provision secret
|
||||||
|
|
||||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}, "pbsdr_storage_id": {"felhom-offsite"}})
|
rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}, "pbsdr_storage_id": {"felhom-offsite"}})
|
||||||
if rr.Code != 303 {
|
if rr.Code != 303 {
|
||||||
t.Fatalf("storage-id change = %d, want 303", rr.Code)
|
t.Fatalf("storage-id change = %d, want 303", rr.Code)
|
||||||
}
|
}
|
||||||
@@ -306,7 +375,7 @@ func TestPBSDR_StorageIDChangeUpdatesDescriptorOnly(t *testing.T) {
|
|||||||
func TestPBSDR_Reissue(t *testing.T) {
|
func TestPBSDR_Reissue(t *testing.T) {
|
||||||
fake := &fakeTenancy{secret: "OLD-SECRET"}
|
fake := &fakeTenancy{secret: "OLD-SECRET"}
|
||||||
s, st, logBuf := newPBSDRServer(t, fake)
|
s, st, logBuf := newPBSDRServer(t, fake)
|
||||||
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||||
st.ConsumeHostPBSSecret("peti-01") // agent already consumed; the dead-end scenario
|
st.ConsumeHostPBSSecret("peti-01") // agent already consumed; the dead-end scenario
|
||||||
|
|
||||||
fake.secret = "FRESH-SECRET"
|
fake.secret = "FRESH-SECRET"
|
||||||
@@ -353,7 +422,7 @@ func TestPBSDR_ReissueRequiresProvisionedState(t *testing.T) {
|
|||||||
func TestPBSDR_FormRendersState(t *testing.T) {
|
func TestPBSDR_FormRendersState(t *testing.T) {
|
||||||
fake := &fakeTenancy{secret: "S"}
|
fake := &fakeTenancy{secret: "S"}
|
||||||
s, _, _ := newPBSDRServer(t, fake)
|
s, _, _ := newPBSDRServer(t, fake)
|
||||||
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
postUpdate(t, s, url.Values{"dr_tier": {"on"}})
|
||||||
|
|
||||||
// v0.48.0 edit-a: the standalone GET edit page is a redirect now — the form renders embedded
|
// v0.48.0 edit-a: the standalone GET edit page is a redirect now — the form renders embedded
|
||||||
// in the customer page's Edit tab (the same config_form_body sub-template), so assert there.
|
// in the customer page's Edit tab (the same config_form_body sub-template), so assert there.
|
||||||
@@ -361,11 +430,16 @@ func TestPBSDR_FormRendersState(t *testing.T) {
|
|||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
s.handleCustomerUnified(rr, req, "peti")
|
s.handleCustomerUnified(rr, req, "peti")
|
||||||
out := rr.Body.String()
|
out := rr.Body.String()
|
||||||
if !strings.Contains(out, `name="pbsdr_enabled" checked`) {
|
if !strings.Contains(out, `name="dr_tier" checked`) {
|
||||||
t.Error("enabled checkbox not checked after provisioning")
|
t.Error("DR-tier checkbox not checked after provisioning")
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "Provisioned: namespace peti, token felhom@pbs!peti") {
|
if !strings.Contains(out, "descriptor provisioned (namespace peti, token felhom@pbs!peti)") {
|
||||||
t.Error("provisioned-state line missing")
|
t.Error("provisioned cascade stage line missing")
|
||||||
|
}
|
||||||
|
// The cascade (v0.51.0 scenario D): all four stages render as done on a fully-applied box.
|
||||||
|
if strings.Count(out, `class="badge badge-neutral">waiting</span>`) > 1 {
|
||||||
|
// escrow may still be waiting in this harness; every other stage must be done
|
||||||
|
t.Errorf("more than the escrow stage still waiting:\n%s", out[strings.Index(out, "DR tier (PBS, ep0)"):][:1200])
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "pbsdr-reissue") {
|
if !strings.Contains(out, "pbsdr-reissue") {
|
||||||
t.Error("re-issue button missing")
|
t.Error("re-issue button missing")
|
||||||
|
|||||||
@@ -140,16 +140,15 @@
|
|||||||
{{end}}{{end}}{{end}}
|
{{end}}{{end}}{{end}}
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="card" {{if .PBSDR.Enabled}}open{{end}}>
|
<details class="card" {{if .PBSDR.DRTier}}open{{end}}>
|
||||||
<summary><h2 style="display:inline">PBS DR tier (ep0)</h2></summary>
|
<summary><h2 style="display:inline">DR tier (PBS, ep0)</h2></summary>
|
||||||
<div class="form-grid" style="margin-top: 1rem;">
|
<div class="form-grid" style="margin-top: 1rem;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label><input type="checkbox" name="pbsdr_enabled" {{if .PBSDR.Enabled}}checked{{end}}>
|
<label><input type="checkbox" name="dr_tier" {{if .PBSDR.DRTier}}checked{{end}}>
|
||||||
Enable PBS DR (provisions the ep0 namespace + token on save; the host agent applies the storage entry)</label>
|
DR tier — offsite disaster-recovery via PBS (the DEFAULT for new customers; disabling is the exception and removes the Felhom-side cost entirely)</label>
|
||||||
|
<small class="form-hint">One flag, ordered rollout: the ep0 namespace + token provision automatically once the host's WG tunnel peer registers; the host agent applies the storage entry on its next tick. Offsite app backup requires this tier (the escrow ceremony depends on the PBS key).</small>
|
||||||
{{if not .PBSDR.Supported}}
|
{{if not .PBSDR.Supported}}
|
||||||
<small class="form-hint">Not configured on this hub (no tenantsync key) — enabling will fail until it is.</small>
|
<small class="form-hint">Not configured on this hub (no tenantsync key) — the flag is stored, but provisioning waits until it is.</small>
|
||||||
{{else if .PBSDR.NoHost}}
|
|
||||||
<small class="form-hint">No host enrolled for this customer yet — enabling fails until the agent enrolls and reports its WG key.</small>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -159,8 +158,21 @@
|
|||||||
<small class="form-hint">The storage-entry id the agent creates on the box. Default felhom-pbs; the demo host's adopted manual entry is felhom-offsite.</small>
|
<small class="form-hint">The storage-entry id the agent creates on the box. Default felhom-pbs; the demo host's adopted manual entry is felhom-offsite.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .PBSDR.DRTier}}
|
||||||
|
<!-- v0.51.0 cascade status (scenario D): per-stage reality vs the stored intent,
|
||||||
|
reusing the fail-closed guard wording. Neutral states, never red — waiting is
|
||||||
|
honest, not broken. -->
|
||||||
|
<ul class="form-hint" style="margin-top:.5rem; list-style:none; padding-left:0; display:grid; gap:.25rem">
|
||||||
|
<li>{{if not .PBSDR.NoHost}}<span class="badge badge-ok">done</span> host enrolled ({{.PBSDR.HostID}}){{else}}<span class="badge badge-neutral">waiting</span> no host enrolled yet — the Day-0 install enrolls it{{end}}</li>
|
||||||
|
<li>{{if .PBSDR.WGPeer}}<span class="badge badge-ok">done</span> WG tunnel peer registered{{else}}<span class="badge badge-neutral">waiting</span> the host has not reported a WG key yet — the tunnel peer must exist before the PBS DR tier (registration is hands-free once the agent starts){{end}}</li>
|
||||||
|
<li>{{if .PBSDR.Provisioned}}<span class="badge badge-ok">done</span> descriptor provisioned (namespace {{.PBSDR.Namespace}}, token {{.PBSDR.TokenID}}){{else}}<span class="badge badge-neutral">waiting</span> provisions automatically when the WG peer registers{{end}}</li>
|
||||||
|
<li>{{if .PBSDR.EscrowPresent}}<span class="badge badge-ok">done</span> key escrow present (ceremony done){{else}}<span class="badge badge-neutral">waiting</span> ceremony possible once the descriptor is applied on the box (run it with the customer — R-moment){{end}}</li>
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
{{if and .PBSDR.Provisioned (not .PBSDR.DRTier)}}
|
||||||
|
<p class="form-hint" style="margin-top:.5rem">Tier OFF with a provisioned namespace: runs stop and the box reports the DR capabilities inactive; the ep0 namespace + data are KEPT (teardown of an ever-on tier is a deliberate manual operator action).</p>
|
||||||
|
{{end}}
|
||||||
{{if .PBSDR.Provisioned}}
|
{{if .PBSDR.Provisioned}}
|
||||||
<p class="form-hint" style="margin-top:.5rem">Provisioned: namespace {{.PBSDR.Namespace}}, token {{.PBSDR.TokenID}} (host {{.PBSDR.HostID}}) — the token secret is delivered to the host agent once (never shown here).</p>
|
|
||||||
<!-- The offsite F4 precedent: explicit operator re-key for a consumed-secret dead-end.
|
<!-- The offsite F4 precedent: explicit operator re-key for a consumed-secret dead-end.
|
||||||
Rides the parent form via formaction; _csrf submits with it. -->
|
Rides the parent form via formaction; _csrf submits with it. -->
|
||||||
<button type="submit" class="btn btn-outline" style="margin-top:.5rem"
|
<button type="submit" class="btn btn-outline" style="margin-top:.5rem"
|
||||||
|
|||||||
@@ -153,6 +153,46 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Capabilities (v0.51.0): the agent's privileged-capability self-check, rendered.
|
||||||
|
inactive = disabled by configuration (neutral, DR tier off) — NOT degraded. -->
|
||||||
|
<section class="card" style="padding: 0; overflow: hidden;">
|
||||||
|
<h2 style="padding: 1.25rem 1.25rem 0.5rem;">Capabilities</h2>
|
||||||
|
{{if .NeedsDRMigration}}
|
||||||
|
<p class="hint" style="padding: 0 1.25rem; color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
This box predates the uniform DR plumbing (host-install < v1.15.0) — the pbsdr
|
||||||
|
wrapper is missing. Migration one-liner set (as root on the host): fetch
|
||||||
|
<code>configs/felhom-pbs-apply</code> from the agent repo to
|
||||||
|
<code>/usr/local/sbin/felhom-pbs-apply</code> (0755), <code>apt-get install -y age</code>,
|
||||||
|
set <code>wg_tunnel.enabled: true</code> in agent.json, restart felhom-agent —
|
||||||
|
see documentation/runbooks/day0-install.md (retrofit).
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
{{if .Capabilities}}
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Capability</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Feature / reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Capabilities}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Name}}{{if .Critical}} <span class="badge badge-neutral">critical</span>{{end}}</td>
|
||||||
|
<td><span class="badge {{.Class}}">{{.Status}}</span></td>
|
||||||
|
<td>{{.Feature}}{{if .Reason}} — {{.Reason}}{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state" style="border: none;">
|
||||||
|
<p>No capability report yet (waiting for the first host report).</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Diagnostics: component log bundles (v0.46.0) -->
|
<!-- Diagnostics: component log bundles (v0.46.0) -->
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Diagnostics — Log Bundles</h2>
|
<h2>Diagnostics — Log Bundles</h2>
|
||||||
|
|||||||
@@ -763,6 +763,12 @@ code {
|
|||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
/* v0.51.0: badge-ok was referenced by the DR-recipe rows but never defined (fell back to the
|
||||||
|
bare .badge). Blue = healthy, per the status-badge-ok convention. */
|
||||||
|
.badge-ok {
|
||||||
|
background: rgba(0, 131, 216, 0.15);
|
||||||
|
color: var(--blue-bright);
|
||||||
|
}
|
||||||
|
|
||||||
/* Summary cards row */
|
/* Summary cards row */
|
||||||
.summary-cards {
|
.summary-cards {
|
||||||
|
|||||||
Reference in New Issue
Block a user