hub v0.137.0 source + burn-down round 2 in felhom.eu: R-277 R-581 R-600 R-544 R-855 R-134 R-92 R-292 R-599 R-725 R-728 (hub), R-819 R-857 R-555 R-364 R-587 (gates/tools), R-571 R-129 R-124-runbook (docs); 28 rows closed incl. catalog + agent v0.147.0 rows, R-350 merged into R-132, R-888 opened, R-887 mechanism (249 -> 222)
gates / gates (push) Successful in 2m3s
gates / gates (push) Successful in 2m3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
+28
-2
@@ -1,6 +1,32 @@
|
||||
## unreleased — comments, a pinned subset and the build without `:latest`; no image change (burn-down 2026-10-05: R-261, R-262, R-345)
|
||||
## v0.137.0 — small fixes from the burn-down: right numbers, right flashes, one create per press, the delete says when it opens (R-277, R-581, R-600, R-544, R-855, R-134, R-92, R-292, R-599, R-725, R-728, R-208; R-124 fixture) (2026-10-05)
|
||||
|
||||
The next hub release carries these lines into its own entry. Nothing here changes the running hub.
|
||||
**Operator action on deploy: none.** One new operator flash (`artifact_version_missing`) and one new customer i18n key
|
||||
(`bind.invalid.body_resend`, hu + en).
|
||||
|
||||
- **R-277:** an off-site repository under 1 GB no longer reads „0.0 GB" (`TestOffsiteRow_SmallRepoNotZeroGB`).
|
||||
- **R-581:** the customer list takes each customer's newest report by id — two reports in one second no longer show the
|
||||
customer twice (`TestGetCustomers_SameSecondReportsOneRowNewestWins`).
|
||||
- **R-600:** a customer delete asks the WireGuard peer-sync for an immediate push and its log line says whether it was
|
||||
pushed or left for the next cycle; wired in `main()` (`TestDeleteCascade_*`, `TestR600_MainWiresWGPeerSyncIntoWeb`).
|
||||
- **R-544:** the host-delete log says what happened to escrow custody („demoted to retained"), not a boolean name.
|
||||
- **R-855:** the start log prints the effective Docker-update nights (`DockerNightsEffective`; negative → 0, 0 → 2).
|
||||
- **R-134:** the Cloudflare zone is looked up from the most specific name up to two labels (`zoneCandidates`).
|
||||
- **R-92:** the PBS-DR panel shows exact bytes beside the GB figure, so a small change is visible.
|
||||
- **R-292:** a refused artifact save names its cause — version missing (new flash), registry unreachable, no sha
|
||||
listed, or a bad typed sha (`TestArtifactSave_FlashNamesTheCause`).
|
||||
- **R-599:** refusing to delete an online host now says when its last report arrived and when deletion opens
|
||||
(`deletionOpensAt`); `runbooks/target-selection.md` names the wait.
|
||||
- **R-725:** the expired bind-link page points at the resend button below (hu + en); the ISO console's ✔ glyph (rendered
|
||||
as a box) is gone too — that half ships with the next ISO.
|
||||
- **R-728:** one create per customer ID at a time — a double submit no longer mints two self-bind links
|
||||
(`TestConfigCreate_ConcurrentSubmitCreatesOnce`, also under `-race`).
|
||||
- **R-208 (hub half):** `ARG VERSION`/`BUILD_TIME` sit just above the `go build`, so a version bump no longer
|
||||
invalidates the module-download layer (`scripts/test_dockerfile_arg_order.py`).
|
||||
- **R-124:** the recipe fixture's root namespace is `""` (agent v0.147.0); the hub stores the recipe raw.
|
||||
|
||||
Red-proofs: `documentation/audits/burndown2-2026-10-05/felhom-eu-red-proofs.txt`.
|
||||
|
||||
Also in this release (from burn-down round 1, no behaviour change):
|
||||
|
||||
- **R-262:** `hostRestoreTest` is a deliberate SUBSET of the agent's `RestoreTest` — `mount_parity`, `mount_inventory`
|
||||
and `skipped` are not modelled (a skipped test reads as failed with its reason, by the agent's design).
|
||||
|
||||
+6
-3
@@ -1,8 +1,5 @@
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_TIME=unknown
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum* ./
|
||||
@@ -10,6 +7,12 @@ RUN go mod download || true
|
||||
|
||||
COPY . .
|
||||
|
||||
# R-208: the build args are declared HERE, below the module download. An ARG is part of every later RUN's
|
||||
# cache key, so declared above `go mod download` a fresh VERSION re-ran the download on every build
|
||||
# (~440 MB of dead cache each). Pinned by scripts/test_dockerfile_arg_order.py.
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_TIME=unknown
|
||||
|
||||
RUN go mod tidy && \
|
||||
CGO_ENABLED=0 go build \
|
||||
-ldflags "-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" \
|
||||
|
||||
+2
-1
@@ -447,7 +447,7 @@ func main() {
|
||||
logger.Printf("[WARN] osupdates: %d TEST approval(s) cancelled at start: %s", len(cancelled), strings.Join(cancelled, ", "))
|
||||
}
|
||||
logger.Printf("[INFO] osupdates: approval rule = every ring-0 box healthy for %s and %d night run(s)", osSvc.ApproveAfter, osSvc.NightsRequired)
|
||||
logger.Printf("[INFO] osupdates: the Docker engine set is approved only by the operator, after %d healthy ring-0 night(s)", osSvc.DockerNights)
|
||||
logger.Printf("[INFO] osupdates: the Docker engine set is approved only by the operator, after %d healthy ring-0 night(s)", osSvc.DockerNightsEffective())
|
||||
apiHandler.SetOSUpdateService(osSvc)
|
||||
webServer.SetOSUpdateAdmin(osSvc)
|
||||
// `11` §8.3: the four alarm thresholds are configuration (decided by CC unattended — operator may reverse).
|
||||
@@ -662,6 +662,7 @@ func main() {
|
||||
wgReconciler := wgsync.NewReconciler(dataStore, wgClient, logger)
|
||||
go wgReconciler.Run(ctx)
|
||||
apiHandler.SetWGSyncer(wgReconciler)
|
||||
webServer.SetWGPeerSync(wgReconciler.Trigger) // R-600: the customer delete pushes at once
|
||||
logger.Printf("[INFO] WG peer-sync enabled (endpoint %s, user %s)", wgAddr, wgUser)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-600 seam wiring: main() must hand the WG reconciler's Trigger to the web server, or the customer
|
||||
// delete cascade's immediate peer push is dead code and the deleted box's peer lingers on the endpoint
|
||||
// until the next 5-minute tick. RED-PROOF: delete the SetWGPeerSync call → this test fails.
|
||||
func TestR600_MainWiresWGPeerSyncIntoWeb(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wired := false
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
c, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if sel, ok := c.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SetWGPeerSync" && len(c.Args) == 1 {
|
||||
if arg, ok := c.Args[0].(*ast.SelectorExpr); ok && arg.Sel.Name == "Trigger" {
|
||||
wired = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !wired {
|
||||
t.Error("cmd/hub/main.go never calls webServer.SetWGPeerSync(<reconciler>.Trigger) — the delete cascade cannot push the peer removal")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-855: the start log line naming the Docker approval nights must print the EFFECTIVE count
|
||||
// (osSvc.DockerNightsEffective()), not the raw field, whose TEST "none" value is -1 and printed
|
||||
// "after -1 healthy ring-0 night(s)". RED-PROOF: pass osSvc.DockerNights → this test fails.
|
||||
func TestR855_StartLogPrintsEffectiveDockerNights(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, effective := false, false
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
c, ok := n.(*ast.CallExpr)
|
||||
if !ok || len(c.Args) < 2 {
|
||||
return true
|
||||
}
|
||||
lit, ok := c.Args[0].(*ast.BasicLit)
|
||||
if !ok || !strings.Contains(lit.Value, "healthy ring-0 night(s)") {
|
||||
return true
|
||||
}
|
||||
found = true
|
||||
if call, ok := c.Args[1].(*ast.CallExpr); ok {
|
||||
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "DockerNightsEffective" {
|
||||
effective = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatal("main.go no longer logs the Docker approval nights — update this test with the new line")
|
||||
}
|
||||
if !effective {
|
||||
t.Error("the Docker approval-nights start log must print osSvc.DockerNightsEffective(), not the raw field")
|
||||
}
|
||||
}
|
||||
@@ -113,11 +113,9 @@ type rule struct {
|
||||
}
|
||||
|
||||
func resolveZone(apiToken, domain string) (string, error) {
|
||||
// Try exact domain first, then parent domain
|
||||
for _, name := range []string{domain, parentDomain(domain)} {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// Try the exact domain first, then every parent down to two labels (R-134: the controller's
|
||||
// GetZoneID strips progressively; stripping ONE label missed the zone for a deeper name).
|
||||
for _, name := range zoneCandidates(domain) {
|
||||
resp, err := cfDo(apiToken, "GET", fmt.Sprintf("/zones?name=%s&status=active", name), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -133,12 +131,27 @@ func resolveZone(apiToken, domain string) (string, error) {
|
||||
return "", fmt.Errorf("no active zone found for %s", domain)
|
||||
}
|
||||
|
||||
func parentDomain(domain string) string {
|
||||
parts := strings.SplitN(domain, ".", 2)
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
// zoneCandidates lists the names a zone lookup tries, most specific first: the domain itself, then each
|
||||
// parent that still has at least two labels ("a.b.felhom.eu" → a.b.felhom.eu, b.felhom.eu, felhom.eu).
|
||||
// A bare TLD is never tried — no Cloudflare zone is named "eu". Pinned by TestZoneCandidates.
|
||||
func zoneCandidates(domain string) []string {
|
||||
domain = strings.Trim(strings.TrimSpace(domain), ".")
|
||||
if domain == "" {
|
||||
return nil
|
||||
}
|
||||
return parts[1]
|
||||
out := []string{domain}
|
||||
for name := domain; ; {
|
||||
i := strings.IndexByte(name, '.')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
name = name[i+1:]
|
||||
if !strings.Contains(name, ".") {
|
||||
break // a single label (the TLD) is not a zone
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findFirewallRuleset(apiToken, zoneID string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-134: the hub's zone lookup must reach the zone for a name more than one label below it, as the
|
||||
// controller's does. Stripping ONE label tried only [a.b.felhom.eu b.felhom.eu] and never felhom.eu,
|
||||
// so the geo-unblock no-op'd with "no active zone found".
|
||||
func TestZoneCandidates(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"a.b.felhom.eu", []string{"a.b.felhom.eu", "b.felhom.eu", "felhom.eu"}},
|
||||
{"demo.felhom.eu", []string{"demo.felhom.eu", "felhom.eu"}},
|
||||
{"felhom.eu", []string{"felhom.eu"}},
|
||||
{"x.y.z.example.co", []string{"x.y.z.example.co", "y.z.example.co", "z.example.co", "example.co"}},
|
||||
{"", nil},
|
||||
{"localhost", []string{"localhost"}},
|
||||
} {
|
||||
if got := zoneCandidates(tc.in); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("zoneCandidates(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package gitea
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -51,6 +52,24 @@ func New(baseURL, owner, user, token string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// StatusError is a registry answer other than 200. It keeps the status so a caller can tell "that
|
||||
// version does not exist" (404) from "the registry is failing" (5xx) — R-292. Error() keeps the old text.
|
||||
type StatusError struct {
|
||||
URL string
|
||||
Code int
|
||||
}
|
||||
|
||||
func (e *StatusError) Error() string { return fmt.Sprintf("gitea GET %s: HTTP %d", e.URL, e.Code) }
|
||||
|
||||
// IsNotFound reports whether err is the registry answering 404.
|
||||
func IsNotFound(err error) bool {
|
||||
var se *StatusError
|
||||
return errors.As(err, &se) && se.Code == http.StatusNotFound
|
||||
}
|
||||
|
||||
// ErrNoSHA marks a version whose metadata answered but carried no usable sha256 (R-292).
|
||||
var ErrNoSHA = errors.New("registry listed no sha256")
|
||||
|
||||
type pkgEntry struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
@@ -92,7 +111,7 @@ func (c *Client) FileSHA256(ctx context.Context, pkgName, version, preferredFile
|
||||
return "", err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return "", fmt.Errorf("no files listed for %s/%s", pkgName, version)
|
||||
return "", fmt.Errorf("no files listed for %s/%s: %w", pkgName, version, ErrNoSHA)
|
||||
}
|
||||
if preferredFile != "" {
|
||||
for _, f := range files {
|
||||
@@ -102,7 +121,7 @@ func (c *Client) FileSHA256(ctx context.Context, pkgName, version, preferredFile
|
||||
}
|
||||
}
|
||||
if files[0].SHA256 == "" {
|
||||
return "", fmt.Errorf("no sha256 for %s/%s file %q", pkgName, version, files[0].Name)
|
||||
return "", fmt.Errorf("no sha256 for %s/%s file %q: %w", pkgName, version, files[0].Name, ErrNoSHA)
|
||||
}
|
||||
return files[0].SHA256, nil
|
||||
}
|
||||
@@ -140,7 +159,7 @@ func (c *Client) getJSON(ctx context.Context, url string, out interface{}) error
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("gitea GET %s: HTTP %d", url, resp.StatusCode)
|
||||
return &StatusError{URL: url, Code: resp.StatusCode}
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"bind.locked.body": "There were too many failed attempts. For safety the link has locked — contact support to link the box.",
|
||||
"bind.invalid.lead": "This link is invalid or has expired.",
|
||||
"bind.invalid.body": "The link is valid for 7 days. If it has expired, ask support for a new one, or your operator can do the linking.",
|
||||
"bind.invalid.body_resend": "The link is valid for 7 days. If it has expired, ask for a new one with the button below, or your operator can do the linking.",
|
||||
"bind.htmllang": "en",
|
||||
"mail.test.subject": "[Felhom] Test notification",
|
||||
"mail.test.body": "Dear Customer,\n\nThis is a test notification from the Felhom monitoring system.\nNotifications are working correctly.\n\nBest regards,\nFelhom.eu monitoring",
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"bind.locked.body": "Túl sok sikertelen próbálkozás történt. Biztonsági okból a hivatkozás zárolódott — kérjük, vedd fel a kapcsolatot az ügyfélszolgálattal a doboz összekötéséhez.",
|
||||
"bind.invalid.lead": "Ez a hivatkozás érvénytelen vagy lejárt.",
|
||||
"bind.invalid.body": "A hivatkozás 7 napig érvényes. Ha lejárt, kérj újat az ügyfélszolgálattól, vagy az összekötést az üzemeltető is elvégezheti.",
|
||||
"bind.invalid.body_resend": "A hivatkozás 7 napig érvényes. Ha lejárt, kérj újat az alábbi gombbal, vagy az összekötést az üzemeltető is elvégezheti.",
|
||||
"bind.htmllang": "hu",
|
||||
"mail.test.subject": "[Felhom] Teszt értesítés",
|
||||
"mail.test.body": "Kedves Ügyfél!\n\nEz egy teszt értesítés a Felhom monitoring rendszerből.\nAz értesítések megfelelően működnek.\n\nÜdvözlettel,\nFelhom.eu monitoring",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package osupdates
|
||||
|
||||
import "testing"
|
||||
|
||||
// R-855: the start log and the approval rule read the same effective count. The TEST override
|
||||
// OS_DOCKER_APPROVE_NIGHTS=0 is stored as -1 ("none") and must print 0, never -1; the zero value is
|
||||
// the default 2.
|
||||
func TestDockerNightsEffective(t *testing.T) {
|
||||
for _, tc := range []struct{ raw, want int }{{-1, 0}, {0, 2}, {3, 3}, {2, 2}} {
|
||||
s := &Service{DockerNights: tc.raw}
|
||||
if got := s.DockerNightsEffective(); got != tc.want {
|
||||
t.Errorf("DockerNights=%d → effective %d, want %d", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,19 @@ type ReleaseBlock struct {
|
||||
Packages []Package `json:"packages"`
|
||||
}
|
||||
|
||||
// DockerNightsEffective is the number of healthy ring-0 nights the Docker engine set actually needs: 0 means
|
||||
// the default 2, negative means none (a TEST override only). The start log prints THIS, not the raw field —
|
||||
// the raw "none" value read "after -1 healthy ring-0 night(s)" (R-855). Pinned by TestDockerNightsEffective.
|
||||
func (s *Service) DockerNightsEffective() int {
|
||||
switch {
|
||||
case s.DockerNights == 0:
|
||||
return 2
|
||||
case s.DockerNights < 0:
|
||||
return 0
|
||||
}
|
||||
return s.DockerNights
|
||||
}
|
||||
|
||||
// Service ties the store, the events and the clock together.
|
||||
type Service struct {
|
||||
Store *store.Store
|
||||
@@ -559,13 +572,7 @@ func (s *Service) DockerStatus() (Status, error) {
|
||||
st.Approved, st.Waiting = rel.ID, "already approved"
|
||||
return st, nil
|
||||
}
|
||||
need := s.DockerNights // 0 = the default 2; negative = none (a TEST override only, logged at start)
|
||||
switch {
|
||||
case need == 0:
|
||||
need = 2
|
||||
case need < 0:
|
||||
need = 0
|
||||
}
|
||||
need := s.DockerNightsEffective()
|
||||
for _, h := range ring0 {
|
||||
reps, err := s.Store.OSReportsSince(h, LayerDocker, first)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
const drHostHalf = `{
|
||||
"recipe_version": 1,
|
||||
"guests": [ { "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 } ],
|
||||
"pbs": { "repo_id": "felhom-pbs", "namespace": "root", "namespace_state": "resolved", "latest_snapshot_id": "9201" },
|
||||
"pbs": { "repo_id": "felhom-pbs", "namespace": "", "namespace_state": "resolved", "latest_snapshot_id": "9201" },
|
||||
"drives": [ { "durable_id": "uuid:da9e7089-cf8e-4617-adcb-a377743fae00", "mount_path": "/mnt/felhom-usb", "intent": "enrolled", "total_bytes": 1000000000000 } ],
|
||||
"pve_storage": [ { "name": "local-lvm", "type": "lvmthin", "content": "rootdir,images" }, { "name": "felhom-usb", "type": "usb", "content": "backup" } ],
|
||||
"backup_target": { "state": "resolved", "storage_id": "felhom-usb", "mount_path": "/mnt/felhom-usb" }
|
||||
@@ -147,7 +147,7 @@ func TestAssembleDRRecipe_V1DriveShape(t *testing.T) {
|
||||
const v1Host = `{
|
||||
"recipe_version": 1,
|
||||
"guests": [ { "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 } ],
|
||||
"pbs": { "repo_id": "felhom-pbs", "namespace": "root", "latest_snapshot_id": "9201" },
|
||||
"pbs": { "repo_id": "felhom-pbs", "namespace": "", "latest_snapshot_id": "9201" },
|
||||
"drives": [ { "durable_id": "uuid:da9e7089", "mount_path": "/mnt/felhom-usb", "intent": "enrolled", "total_bytes": 1000000000000 } ],
|
||||
"pve_storage": [ { "name": "felhom-usb", "type": "usb", "content": "backup" } ]
|
||||
}`
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
// R-581: two reports for one customer inside the same second. received_at has second granularity, so
|
||||
// the dashboard's newest-report read must tie-break on insertion order — one row per customer, and it
|
||||
// is the SECOND report's health and version. The received_at values are forced equal so the tie is
|
||||
// certain rather than depending on the test's timing.
|
||||
func TestGetCustomers_SameSecondReportsOneRowNewestWins(t *testing.T) {
|
||||
s := langStore(t)
|
||||
if err := s.SaveReport("tie", []byte(`{"controller_version":"0.1.0","health":{"status":"fail"}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SaveReport("tie", []byte(`{"controller_version":"0.2.0","health":{"status":"ok"}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE reports SET received_at = '2026-10-05 12:00:00' WHERE customer_id = 'tie'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
custs, err := s.GetCustomers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []CustomerSummary
|
||||
for _, c := range custs {
|
||||
if c.CustomerID == "tie" {
|
||||
got = append(got, c)
|
||||
}
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("GetCustomers returned %d rows for one customer, want exactly 1", len(got))
|
||||
}
|
||||
if got[0].ControllerVersion != "0.2.0" || got[0].HealthStatus != "ok" {
|
||||
t.Fatalf("newest report lost the tie: version=%q health=%q, want 0.2.0/ok", got[0].ControllerVersion, got[0].HealthStatus)
|
||||
}
|
||||
|
||||
one, err := s.GetCustomer("tie")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if one.ControllerVersion != "0.2.0" {
|
||||
t.Fatalf("GetCustomer newest = %q, want 0.2.0", one.ControllerVersion)
|
||||
}
|
||||
}
|
||||
@@ -1317,7 +1317,10 @@ func (s *Store) LastEventAt(customerID, eventType string) (time.Time, error) {
|
||||
return parseSQLiteTime(createdAt), nil
|
||||
}
|
||||
|
||||
// GetCustomers returns the latest report summary for each customer.
|
||||
// GetCustomers returns the latest report summary for each customer. "Latest" is the highest
|
||||
// autoincrement id, NOT MAX(received_at): received_at has second granularity, and two reports in one
|
||||
// second joined on it returned BOTH rows (a duplicate customer) — R-581, pinned by
|
||||
// TestGetCustomers_SameSecondReportsOneRowNewestWins.
|
||||
func (s *Store) GetCustomers() ([]CustomerSummary, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT r.customer_id, r.received_at, r.report_json,
|
||||
@@ -1326,11 +1329,10 @@ func (s *Store) GetCustomers() ([]CustomerSummary, error) {
|
||||
r.backup_last_snapshot, r.controller_version, r.controller_url
|
||||
FROM reports r
|
||||
INNER JOIN (
|
||||
SELECT customer_id, MAX(received_at) as max_time
|
||||
SELECT customer_id, MAX(id) as max_id
|
||||
FROM reports
|
||||
GROUP BY customer_id
|
||||
) latest ON r.customer_id = latest.customer_id
|
||||
AND r.received_at = latest.max_time
|
||||
) latest ON r.id = latest.max_id
|
||||
ORDER BY r.customer_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1390,7 +1392,7 @@ func (s *Store) GetCustomer(customerID string) (*CustomerSummary, error) {
|
||||
backup_last_snapshot, controller_version, controller_url
|
||||
FROM reports
|
||||
WHERE customer_id = ?
|
||||
ORDER BY received_at DESC
|
||||
ORDER BY received_at DESC, id DESC
|
||||
LIMIT 1`, customerID)
|
||||
|
||||
var c CustomerSummary
|
||||
@@ -1443,7 +1445,7 @@ func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]Cu
|
||||
backup_last_snapshot, controller_version, controller_url
|
||||
FROM reports
|
||||
WHERE customer_id = ? AND received_at >= ?
|
||||
ORDER BY received_at DESC`, customerID, cutoff)
|
||||
ORDER BY received_at DESC, id DESC`, customerID, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"guests": [
|
||||
{ "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 }
|
||||
],
|
||||
"pbs": { "repo_id": "felhom-pbs", "namespace": "root", "namespace_state": "resolved", "latest_snapshot_id": "9201" },
|
||||
"pbs": { "repo_id": "felhom-pbs", "namespace": "", "namespace_state": "resolved", "latest_snapshot_id": "9201" },
|
||||
"drives": [
|
||||
{
|
||||
"durable_id": "uuid:da9e7089-cf8e-4617-adcb-a377743fae00",
|
||||
|
||||
+65
-19
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
cfClient "gitea.dooplex.hu/admin/felhom-hub/internal/cloudflare"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
@@ -701,6 +703,20 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// R-728: one create per customer ID at a time. Held from before the duplicate check until the
|
||||
// handler returns, so a double submit cannot pass the check twice and mint twice.
|
||||
if _, busy := s.creating.LoadOrStore(customerID, struct{}{}); busy {
|
||||
s.logger.Printf("[WARN] customer create %s refused: a create for this ID is already in progress (double submit)", customerID)
|
||||
s.renderConfigForm(w, r, true, &store.CustomerConfig{
|
||||
CustomerID: customerID,
|
||||
CustomerName: r.FormValue("customer_name"),
|
||||
Domain: r.FormValue("domain"),
|
||||
Email: r.FormValue("email"),
|
||||
}, nil, fmt.Sprintf("Customer ID %q is already being created — this second submit was ignored. Open the customer page in a moment.", customerID))
|
||||
return
|
||||
}
|
||||
defer s.creating.Delete(customerID)
|
||||
|
||||
// Check for duplicates
|
||||
existing, _ := s.store.GetCustomerConfig(customerID)
|
||||
if existing != nil {
|
||||
@@ -764,6 +780,9 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if s.beforeCreateSave != nil {
|
||||
s.beforeCreateSave(customerID)
|
||||
}
|
||||
if err := s.store.SaveCustomerConfig(cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to save config for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
@@ -1315,11 +1334,11 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
// file whose 404 broke Friday. Probing some other path that merely exists is how that failure
|
||||
// stayed invisible.
|
||||
//
|
||||
// IT RUNS BEFORE resolveArtifactSHA, and that ordering is load-bearing. The sha lookup fails
|
||||
// with `artifact_sha_invalid`, whose text reads "version missing / Gitea unreachable / bad sha"
|
||||
// — three different facts in one message. If it ran first, an unreachable registry would be
|
||||
// reported to the operator as a possibly-missing artifact. Probing first means the operator is
|
||||
// told which of those it actually is. (Found by scenario E failing against the first draft.)
|
||||
// IT RUNS BEFORE resolveArtifactSHA. Until R-292 the sha lookup failed with ONE flash,
|
||||
// `artifact_sha_invalid`, covering "version missing / Gitea unreachable / bad sha", so the order
|
||||
// was load-bearing (found by scenario E failing against the first draft). Since R-292 the sha
|
||||
// lookup names its own cause too (TestArtifactSave_FlashNamesTheCause); the order is kept so the
|
||||
// tag and package legs are still reported as such.
|
||||
//
|
||||
// UNDETERMINED IS ALSO A REFUSAL, and it says something different. A warning beside a success is
|
||||
// read as a success, and this project has the scars; so an unreachable registry refuses too,
|
||||
@@ -1369,10 +1388,12 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
agentSHA, okAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256"))
|
||||
goldenSHA, okGS := s.resolveArtifactSHA(r.Context(), pkgGolden, fileGolden, goldenVer, r.FormValue("golden_sha256"))
|
||||
if !okAS || !okGS {
|
||||
http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther)
|
||||
// R-292: each failure names its own cause — version missing, registry unreachable, no sha listed, or a
|
||||
// bad typed sha — instead of one flash that conflated all of them.
|
||||
agentSHA, flashAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256"))
|
||||
goldenSHA, flashGS := s.resolveArtifactSHA(r.Context(), pkgGolden, fileGolden, goldenVer, r.FormValue("golden_sha256"))
|
||||
if flash := firstNonEmpty(flashAS, flashGS); flash != "" {
|
||||
http.Redirect(w, r, "/configuration?flash="+flash, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// R-840: the vouched agent's CONFIG BUNDLE is resolved from the registry by exact name, like the binary. A version
|
||||
@@ -1450,23 +1471,48 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// resolveArtifactSHA determines the sha256 to store for a chosen artifact version. An empty version
|
||||
// clears the artifact (returns "",true). With a Gitea client it fetches the sha AUTHORITATIVELY from
|
||||
// Gitea (the submitted value is ignored — nothing hand-typed to trust); a fetch failure returns
|
||||
// (_,false) so the caller refuses the save rather than storing a version with a wrong/blank checksum.
|
||||
// Without a Gitea client it validates + uses the submitted sha (legacy manual path).
|
||||
func (s *Server) resolveArtifactSHA(ctx context.Context, pkg, file, version, submittedSHA string) (string, bool) {
|
||||
// clears the artifact (returns "",""). With a Gitea client it fetches the sha AUTHORITATIVELY from
|
||||
// Gitea (the submitted value is ignored — nothing hand-typed to trust); without one it validates + uses
|
||||
// the submitted sha (legacy manual path). The second value is "" on success, otherwise the flash that
|
||||
// names the cause (R-292), so the caller refuses the save with the right message:
|
||||
//
|
||||
// artifact_version_missing — the registry answered 404 for that version
|
||||
// artifact_sha_missing — the version exists but the registry listed no sha256
|
||||
// artifact_unverifiable — the registry did not answer usefully (network error, 5xx)
|
||||
// artifact_sha_invalid — a hand-typed sha is not 64 hex characters (manual path only)
|
||||
func (s *Server) resolveArtifactSHA(ctx context.Context, pkg, file, version, submittedSHA string) (string, string) {
|
||||
if version == "" {
|
||||
return "", true
|
||||
return "", ""
|
||||
}
|
||||
if s.gitea != nil {
|
||||
sha, err := s.gitea.FileSHA256(ctx, pkg, version, file)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] artifact sha resolve (%s/%s): %v", pkg, version, err)
|
||||
return "", false
|
||||
flash := "artifact_unverifiable"
|
||||
switch {
|
||||
case gitea.IsNotFound(err):
|
||||
flash = "artifact_version_missing"
|
||||
case errors.Is(err, gitea.ErrNoSHA):
|
||||
flash = "artifact_sha_missing"
|
||||
}
|
||||
s.logger.Printf("[WARN] artifact sha resolve (%s/%s) refused as %s: %v", pkg, version, flash, err)
|
||||
return "", flash
|
||||
}
|
||||
return sha, true
|
||||
return sha, ""
|
||||
}
|
||||
return normalizeSHA256(submittedSHA)
|
||||
if sha, ok := normalizeSHA256(submittedSHA); ok {
|
||||
return sha, ""
|
||||
}
|
||||
return "", "artifact_sha_invalid"
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-empty string, or "".
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor
|
||||
|
||||
@@ -229,7 +229,7 @@ func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request, cu
|
||||
for i := range hosts {
|
||||
if s.hostStatus(hosts[i].LastReportAt) == "ok" {
|
||||
s.logger.Printf("[WARN] delete %s REFUSED: host %s is ONLINE", customerID, hosts[i].HostID)
|
||||
http.Error(w, "Delete refused: host "+hosts[i].HostID+" is ONLINE. Decommission the box first — the cascade never deletes a live host.", http.StatusConflict)
|
||||
http.Error(w, "Delete refused: host "+hosts[i].HostID+" is ONLINE. Decommission the box first — the cascade never deletes a live host."+s.deletionOpensAt(hosts[i].LastReportAt, time.Now()), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -307,7 +307,11 @@ func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request, cu
|
||||
s.logger.Printf("[WARN] delete %s: save audit event: %v", customerID, eerr)
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] customer DELETE cascade COMPLETE for %s (journal #%d) — full teardown", customerID, journalID)
|
||||
// R-600: the WG peer was removed from the hub DB with the host rows (leg 1), but the endpoint only
|
||||
// drops it on the next peer-sync push. Ask for that push now, and say in the line that it is pending
|
||||
// — "full teardown" was logged while ep0 still held the peer for ~6 minutes.
|
||||
wgState := s.requestWGPeerPush()
|
||||
s.logger.Printf("[INFO] customer DELETE cascade COMPLETE for %s (journal #%d) — hub-side teardown done; %s", customerID, journalID, wgState)
|
||||
s.bumpIntent(customerID) // Direction-2: wake any still-holding wait so it completes promptly
|
||||
http.Redirect(w, r, "/configs?flash=deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -621,3 +622,46 @@ func TestR688_PreviewNamesCloudflareByHand(t *testing.T) {
|
||||
t.Fatal("the dialog does not render the manual-removal list")
|
||||
}
|
||||
}
|
||||
|
||||
// ── R-600: the cascade asks for the WG peer push at once, and its COMPLETE line says it is pending ───
|
||||
|
||||
func TestDeleteCascade_TriggersWGPeerPushAndSaysSo(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
seedDeletable(t, st, "acme")
|
||||
s.SetTenantSync(&orderTenancy{})
|
||||
var logBuf strings.Builder
|
||||
s.logger = log.New(&logBuf, "", 0)
|
||||
triggers := 0
|
||||
s.SetWGPeerSync(func() { triggers++ })
|
||||
|
||||
rr := postDelete(t, s, "acme", cascadeForm("acme", 1))
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if triggers != 1 {
|
||||
t.Fatalf("WG peer-sync triggers = %d, want exactly 1 (the endpoint must drop the peer now, not on the next tick)", triggers)
|
||||
}
|
||||
out := logBuf.String()
|
||||
if strings.Contains(out, "full teardown") {
|
||||
t.Errorf("COMPLETE line still claims a full teardown while the endpoint push is pending:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "WG peer removal push requested") {
|
||||
t.Errorf("COMPLETE line must name the pending peer push:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Without peer-sync configured, the line says the endpoint was NOT updated (never "full teardown").
|
||||
func TestDeleteCascade_NoWGPeerSyncSaysEndpointNotUpdated(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
seedDeletable(t, st, "acme")
|
||||
s.SetTenantSync(&orderTenancy{})
|
||||
var logBuf strings.Builder
|
||||
s.logger = log.New(&logBuf, "", 0)
|
||||
|
||||
if rr := postDelete(t, s, "acme", cascadeForm("acme", 1)); rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rr.Code)
|
||||
}
|
||||
if out := logBuf.String(); !strings.Contains(out, "endpoint's peer list was NOT updated") || strings.Contains(out, "full teardown") {
|
||||
t.Errorf("unwired peer-sync must be named in the COMPLETE line:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -895,7 +896,7 @@ func (s *Server) handleHostDelete(w http.ResponseWriter, r *http.Request, hostID
|
||||
}
|
||||
if status := s.hostStatus(host.LastReportAt); status == "ok" {
|
||||
s.logger.Printf("[WARN] host delete refused: %s is online", hostID)
|
||||
http.Error(w, "Host is ONLINE — deletion is refused (a live agent would receive 401s permanently).", http.StatusConflict)
|
||||
http.Error(w, "Host is ONLINE — deletion is refused (a live agent would receive 401s permanently)."+s.deletionOpensAt(host.LastReportAt, time.Now()), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if confirm := strings.TrimSpace(r.FormValue("confirm_host_id")); confirm != hostID {
|
||||
@@ -904,6 +905,8 @@ func (s *Server) handleHostDelete(w http.ResponseWriter, r *http.Request, hostID
|
||||
return
|
||||
}
|
||||
deleteEscrow := r.FormValue("delete_escrow") == "1"
|
||||
// R-544: read whether an escrow exists BEFORE the delete, so the log can say what happened to it.
|
||||
escrowBefore, escErr := s.store.GetHostEscrow(hostID)
|
||||
if err := s.store.DeleteHost(hostID, deleteEscrow); err != nil {
|
||||
if errors.Is(err, store.ErrHostEscrowPresent) {
|
||||
s.logger.Printf("[WARN] host delete refused: %s has key escrow (acknowledgement missing)", hostID)
|
||||
@@ -914,7 +917,7 @@ func (s *Server) handleHostDelete(w http.ResponseWriter, r *http.Request, hostID
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] host deleted: %s (escrow deleted: %v)", hostID, deleteEscrow)
|
||||
s.logger.Printf("[INFO] host deleted: %s (%s)", hostID, hostDeleteEscrowEffect(escrowBefore != nil, escErr))
|
||||
// R-509: the customer record stays and now waits for a box → send the connect link.
|
||||
if host.CustomerID != "" {
|
||||
s.autoMintSelfBindIfWaiting(host.CustomerID, "host delete")
|
||||
@@ -922,6 +925,38 @@ func (s *Server) handleHostDelete(w http.ResponseWriter, r *http.Request, hostID
|
||||
http.Redirect(w, r, "/hosts", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// hostDeleteEscrowEffect states what a host delete did to the key escrow, in operator words (R-544).
|
||||
// A host delete NEVER destroys escrow: the store demotes it to retained custody, and only the customer
|
||||
// delete purges it. The old line printed the form flag ("escrow deleted: true"), which read as a
|
||||
// household's last key being destroyed.
|
||||
func hostDeleteEscrowEffect(hadEscrow bool, lookupErr error) string {
|
||||
switch {
|
||||
case lookupErr != nil:
|
||||
return "escrow state unknown before the delete; any escrow is demoted to retained custody, never destroyed"
|
||||
case hadEscrow:
|
||||
return "escrow custody demoted to retained (host delete); the customer delete is the only purge"
|
||||
default:
|
||||
return "no key escrow held"
|
||||
}
|
||||
}
|
||||
|
||||
// deletionOpensAt is the tail of an ONLINE refusal (R-599): "online" is a report-staleness window, not a
|
||||
// liveness probe, so a box that no longer exists still reads online until its last report is older than
|
||||
// the CONFIGURED stale threshold. Say when that was and when the refusal ends, so a teardown waits
|
||||
// instead of concluding the delete is broken. Empty when there is no last report.
|
||||
func (s *Server) deletionOpensAt(lastReport *time.Time, now time.Time) string {
|
||||
if lastReport == nil {
|
||||
return ""
|
||||
}
|
||||
age := now.Sub(*lastReport)
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
opens := lastReport.Add(s.staleThreshold).UTC()
|
||||
return fmt.Sprintf(" Its last report arrived %d min ago (%s UTC); deletion opens at %s UTC, once the host has been silent for the stale threshold (%s).",
|
||||
int(age.Minutes()), lastReport.UTC().Format("15:04"), opens.Format("15:04"), s.staleThreshold)
|
||||
}
|
||||
|
||||
// handleHostDetail renders the read-only per-host detail page (audit F-M1). GET only.
|
||||
func (s *Server) handleHostDetail(w http.ResponseWriter, r *http.Request, hostID string) {
|
||||
host, err := s.store.GetHost(hostID)
|
||||
|
||||
@@ -177,7 +177,9 @@ func (s *Server) offsiteCustomerRows() []offsiteCustomerRow {
|
||||
}
|
||||
if hasReport[cfg.CustomerID] {
|
||||
row.UsageBytes = usage[cfg.CustomerID]
|
||||
row.UsageStr = fmtBytesGB(row.UsageBytes)
|
||||
// R-277: fmtBytesAuto, not fmtBytesGB — a 162 KB repo rendered "0.0 GB" and was read as an
|
||||
// absent off-site tier. Above 1 GB both helpers print the same "%.1f GB".
|
||||
row.UsageStr = fmtBytesAuto(row.UsageBytes)
|
||||
if d.Type == "shared" && d.QuotaGB > 0 {
|
||||
row.UsagePercent = float64(row.UsageBytes) * 100 / float64(int64(d.QuotaGB)<<30)
|
||||
row.UsageBand = pctBand(row.UsagePercent, 90, 95) // matches the per-customer OffsiteChecker bands
|
||||
|
||||
@@ -5,6 +5,7 @@ package web
|
||||
// snapshot carries its own state (ok / unavailable / degraded) so the UI renders each honestly.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
|
||||
@@ -20,11 +21,33 @@ type pbsdrBoxView struct {
|
||||
HasFill bool // numbers available (ok or degraded-with-last-known)
|
||||
CapacityStr string
|
||||
UsedStr string
|
||||
UsedExact string // R-92: exact bytes ("8 590 000 000 bytes") — UsedStr is 0.1 GB-granular
|
||||
FillPercent float64
|
||||
FillBand string
|
||||
FetchedAt time.Time
|
||||
}
|
||||
|
||||
// fmtBytesExact renders the exact byte count grouped by spaces ("8 590 000 000 bytes"), so a
|
||||
// 50 MB delta on the PBS DR datastore is visible next to the 0.1 GB-granular UsedStr (R-92).
|
||||
func fmtBytesExact(b int64) string {
|
||||
neg := b < 0
|
||||
if neg {
|
||||
b = -b
|
||||
}
|
||||
digits := fmt.Sprintf("%d", b)
|
||||
var out []byte
|
||||
for i := range digits {
|
||||
if i > 0 && (len(digits)-i)%3 == 0 {
|
||||
out = append(out, ' ')
|
||||
}
|
||||
out = append(out, digits[i])
|
||||
}
|
||||
if neg {
|
||||
return "-" + string(out) + " bytes"
|
||||
}
|
||||
return string(out) + " bytes"
|
||||
}
|
||||
|
||||
// pbsdrTile is the compact Dashboard PBS gauge: fill %, band-colored; "n/a" when unavailable.
|
||||
type pbsdrTile struct {
|
||||
Unavailable bool
|
||||
@@ -55,6 +78,7 @@ func (s *Server) pbsdrBoxData() pbsdrBoxView {
|
||||
view.HasFill = true
|
||||
view.CapacityStr = fmtBytesGB(snap.CapacityBytes)
|
||||
view.UsedStr = fmtBytesGB(snap.UsedBytes)
|
||||
view.UsedExact = fmtBytesExact(snap.UsedBytes)
|
||||
view.FillPercent = snap.FillPercent
|
||||
view.FillBand = snap.FillBand
|
||||
}
|
||||
@@ -62,6 +86,7 @@ func (s *Server) pbsdrBoxData() pbsdrBoxView {
|
||||
view.HasFill = true
|
||||
view.CapacityStr = fmtBytesGB(snap.CapacityBytes)
|
||||
view.UsedStr = fmtBytesGB(snap.UsedBytes)
|
||||
view.UsedExact = fmtBytesExact(snap.UsedBytes)
|
||||
view.FillPercent = snap.FillPercent
|
||||
view.FillBand = snap.FillBand
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
|
||||
)
|
||||
|
||||
// R-277(b): a small but non-empty off-site repo must not render as "0.0 GB" on the Offsite page — that
|
||||
// reading is how a healthy tier was reported to the operator as absent.
|
||||
func TestOffsiteRow_SmallRepoNotZeroGB(t *testing.T) {
|
||||
s, st := newRenderServer(t)
|
||||
s.SetOffsiteBox(func() (monitor.BoxSnapshot, bool) {
|
||||
return monitor.BoxSnapshot{CapacityBytes: 1 << 40, FillBand: "ok", OversubBand: "ok", FetchedAt: time.Now().UTC()}, true
|
||||
})
|
||||
saveCfg(t, st, "tiny", `{"offsite":{"enabled":true,"type":"shared","quota_gb":500}}`)
|
||||
if err := st.SaveReport("tiny", []byte(`{"customer_id":"tiny","offsite":{"enabled":true,"repo_size_bytes":165888}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := s.offsiteCustomerRows()
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].UsageStr == "0.0 GB" || rows[0].UsageStr != "162.0 KB" {
|
||||
t.Fatalf("162 KB repo rendered as %q, want \"162.0 KB\"", rows[0].UsageStr)
|
||||
}
|
||||
body := renderOffsite(t, s)
|
||||
if !strings.Contains(body, "162.0 KB") {
|
||||
t.Fatal("Offsite page must show the small repo's real size")
|
||||
}
|
||||
// Above 1 GB the unit stays GB (same as the quota column).
|
||||
if got := fmtBytesAuto(200 * gib); got != "200.0 GB" {
|
||||
t.Fatalf("200 GiB = %q, want 200.0 GB", got)
|
||||
}
|
||||
}
|
||||
|
||||
// R-92: two PBS DR polls 50 MB apart read identically at 0.1 GB granularity; the page must show a value
|
||||
// that differs between them, so a small delta (a prune, a GC) is verifiable from the hub.
|
||||
func TestPBSDRPanel_ExactBytesShowsSmallDelta(t *testing.T) {
|
||||
render := func(used int64) string {
|
||||
s, _ := newRenderServer(t)
|
||||
s.SetPBSDRBox(func() (monitor.PBSBoxSnapshot, bool) {
|
||||
return monitor.PBSBoxSnapshot{
|
||||
CapacityBytes: 40 * gib, UsedBytes: used, FillPercent: 20, FillBand: "ok",
|
||||
State: monitor.PBSStateOK, FetchedAt: time.Now().UTC(),
|
||||
}, true
|
||||
})
|
||||
return pbsSection(renderOffsite(t, s))
|
||||
}
|
||||
a := render(8 * gib)
|
||||
b := render(8*gib + 50_000_000)
|
||||
if !strings.Contains(a, "8 589 934 592 bytes") {
|
||||
t.Fatalf("PBS DR panel must show exact bytes:\n%s", a)
|
||||
}
|
||||
if !strings.Contains(b, "8 639 934 592 bytes") {
|
||||
t.Fatalf("PBS DR panel must show the +50 MB exact value:\n%s", b)
|
||||
}
|
||||
if fmtBytesExact(999) != "999 bytes" || fmtBytesExact(1000) != "1 000 bytes" {
|
||||
t.Fatalf("grouping wrong: %q %q", fmtBytesExact(999), fmtBytesExact(1000))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
|
||||
)
|
||||
|
||||
// R-292: the artifact save names the cause of a checksum failure. One flash used to cover "version
|
||||
// missing", "registry unreachable" and "bad typed sha" — and the operator acts differently on each.
|
||||
//
|
||||
// These drive resolveArtifactSHA through the real handler. The installability gate is passed by a
|
||||
// registry that serves the tag and the download; ONLY the sha metadata route is varied.
|
||||
func shaRegistry(t *testing.T, metaStatus int, metaBody string) *gitea.Client {
|
||||
t.Helper()
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if strings.HasPrefix(p, "api/v1/packages/") && strings.HasSuffix(p, "/files") {
|
||||
if metaStatus != http.StatusOK {
|
||||
w.WriteHeader(metaStatus)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(metaBody))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK) // tag probe + package download: installable
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return gitea.New(srv.URL, "admin", "", "")
|
||||
}
|
||||
|
||||
func TestArtifactSave_FlashNamesTheCause(t *testing.T) {
|
||||
sha := strings.Repeat("c", 64)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
metaStatus int
|
||||
metaBody string
|
||||
want string
|
||||
}{
|
||||
{"version not found", http.StatusNotFound, "", "artifact_version_missing"},
|
||||
{"registry failing", http.StatusBadGateway, "", "artifact_unverifiable"},
|
||||
{"no sha listed", http.StatusOK, `[{"name":"x","sha256":""}]`, "artifact_sha_missing"},
|
||||
{"healthy", http.StatusOK, `[{"name":"x","sha256":"` + sha + `"}]`, "artifacts_set"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
s.SetGiteaClient(shaRegistry(t, tc.metaStatus, tc.metaBody))
|
||||
if got := flashOf(saveArtifacts(t, s)); got != tc.want {
|
||||
t.Fatalf("flash = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The manual path (no Gitea client): a malformed typed sha is the ONE case artifact_sha_invalid means.
|
||||
func TestArtifactSave_BadTypedSHAIsShaInvalid(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
form := url.Values{"agent_version": {"0.128.0"}, "agent_sha256": {"not-a-sha"}}
|
||||
r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleSetArtifacts(w, r)
|
||||
if got := flashOf(w); got != "artifact_sha_invalid" {
|
||||
t.Fatalf("flash = %q, want artifact_sha_invalid", got)
|
||||
}
|
||||
assertUnchanged(t, st, "bad typed sha")
|
||||
}
|
||||
|
||||
// Every flash the save can redirect to renders its own message (seam: a flash with no template branch
|
||||
// shows the operator nothing). The sha_invalid text no longer claims a lookup failure.
|
||||
func TestArtifactFlashes_EachRendersItsOwnText(t *testing.T) {
|
||||
s, _ := newRenderServer(t)
|
||||
for flash, want := range map[string]string{
|
||||
"artifact_version_missing": "the registry has no such version",
|
||||
"artifact_sha_missing": "no checksum for a chosen version",
|
||||
"artifact_unverifiable": "could not verify",
|
||||
"artifact_sha_invalid": "a checksum you typed is not valid",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/configuration?flash="+flash, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleConfiguration(rr, req)
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("flash %s: page does not render %q", flash, want)
|
||||
}
|
||||
if flash == "artifact_sha_invalid" && strings.Contains(body, "Gitea unreachable") {
|
||||
t.Error("artifact_sha_invalid still conflates an unreachable registry with a bad sha")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// R-544: an acknowledged host delete with an escrow logs the DEMOTION, never "escrow deleted: true" —
|
||||
// the line an operator would read as a household's last key being destroyed.
|
||||
func TestHostDelete_LogSaysEscrowDemotedNotDeleted(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
var logBuf strings.Builder
|
||||
s.logger = log.New(&logBuf, "", 0)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "esc-host", CustomerID: "c2", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.SaveHostEscrow("esc-host", []byte("blob"), "fp", "p", "2026-07-01T00:00:00Z", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := postHostDelete(t, s, "esc-host", url.Values{"confirm_host_id": {"esc-host"}, "delete_escrow": {"1"}})
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("acked delete = %d, want 303: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
out := logBuf.String()
|
||||
if strings.Contains(out, "escrow deleted") {
|
||||
t.Errorf("log still says the escrow was deleted:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "escrow custody demoted to retained") {
|
||||
t.Errorf("log must state the demotion:\n%s", out)
|
||||
}
|
||||
// And it is true: the blob survives as retained custody (a delete would be the bug the log implied).
|
||||
if n, err := st.CountSupersededEscrow("esc-host"); err != nil || n == 0 {
|
||||
t.Errorf("retained escrow count = 0 after a host delete — custody was destroyed, not demoted")
|
||||
}
|
||||
}
|
||||
|
||||
// A host with no escrow says so, not "demoted".
|
||||
func TestHostDelete_LogNoEscrow(t *testing.T) {
|
||||
if got := hostDeleteEscrowEffect(false, nil); got != "no key escrow held" {
|
||||
t.Fatalf("no-escrow effect = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// R-599: an ONLINE refusal names how long ago the last report arrived and when deletion opens,
|
||||
// computed from the CONFIGURED stale threshold (45m here — not the 30m code default).
|
||||
func TestHostDelete_OnlineRefusalSaysWhenItOpens(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
s.staleThreshold = 45 * time.Minute
|
||||
if err := st.UpsertHost(&store.Host{HostID: "gone-vm", CustomerID: "c1", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveHostReport("gone-vm", "c1", []byte(`{}`), store.HostReportDenorm{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := st.GetHost("gone-vm")
|
||||
if err != nil || h == nil || h.LastReportAt == nil {
|
||||
t.Fatalf("host/last report: %v", err)
|
||||
}
|
||||
opens := h.LastReportAt.Add(45 * time.Minute).UTC().Format("15:04")
|
||||
|
||||
rr := postHostDelete(t, s, "gone-vm", url.Values{"confirm_host_id": {"gone-vm"}, "delete_escrow": {"1"}})
|
||||
if rr.Code != http.StatusConflict {
|
||||
t.Fatalf("online delete = %d, want 409", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"min ago", "deletion opens at " + opens + " UTC", "45m0s"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("host-delete 409 body missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
|
||||
// The customer delete cascade's ONLINE refusal says the same.
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", CustomerName: "c1", APIKey: "a", RetrievalPassword: "p"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr = postDelete(t, s, "c1", cascadeForm("c1", 1))
|
||||
if rr.Code != http.StatusConflict || !strings.Contains(rr.Body.String(), "deletion opens at "+opens+" UTC") {
|
||||
t.Errorf("cascade ONLINE refusal = %d %q, want 409 naming the opening time", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The arithmetic, with a fixed clock: last report 10 minutes ago at 17:12 UTC, threshold 45m.
|
||||
func TestDeletionOpensAt_Arithmetic(t *testing.T) {
|
||||
s := &Server{staleThreshold: 45 * time.Minute}
|
||||
last := time.Date(2026, 9, 20, 17, 12, 0, 0, time.UTC)
|
||||
got := s.deletionOpensAt(&last, last.Add(10*time.Minute))
|
||||
for _, want := range []string{"10 min ago (17:12 UTC)", "deletion opens at 17:57 UTC", "(45m0s)"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("deletionOpensAt = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
if s.deletionOpensAt(nil, last) != "" {
|
||||
t.Error("no last report → no timing sentence")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/i18n"
|
||||
)
|
||||
|
||||
// R-725: the expired bind page that SHOWS the „Új linket kérek" button must point the household at the
|
||||
// button, not send them to support („kérj újat az ügyfélszolgálattól") above it. The page without a
|
||||
// button (no token: rate-limited / not found) keeps the sentence that names another way.
|
||||
// ASCII fragment "szolg" is the support word; the no-button page is its positive control.
|
||||
func TestBindExpiredPage_PointsAtTheButton(t *testing.T) {
|
||||
b := i18n.Shared()
|
||||
s, st := newTestServer(t)
|
||||
seedForMint(t, st, "tester", "tester1@felhom.example")
|
||||
old := mintLink(t, st, "tester", -time.Hour)
|
||||
|
||||
withButton := bindGET(t, s, old).Body.String()
|
||||
if !strings.Contains(withButton, "/bind/"+old+"/resend") {
|
||||
t.Fatal("precondition: the expired page carries the resend button")
|
||||
}
|
||||
if !strings.Contains(withButton, b.Msg("hu", "bind.invalid.body_resend")) {
|
||||
t.Error("the expired page with a button does not carry the sentence that points at it")
|
||||
}
|
||||
if strings.Contains(withButton, "szolg") {
|
||||
t.Error("the expired page with a button still sends the household to support")
|
||||
}
|
||||
|
||||
// Positive control: the no-button page still carries the support sentence (the fragment is live).
|
||||
rr := httptest.NewRecorder()
|
||||
s.renderBind(rr, http.StatusNotFound, bindPageData{State: "expired", Lang: i18n.Default})
|
||||
noButton := rr.Body.String()
|
||||
if !strings.Contains(noButton, "szolg") || strings.Contains(noButton, "/resend") {
|
||||
t.Errorf("no-button page: want the support sentence and no resend form")
|
||||
}
|
||||
|
||||
// English twin: present, different, and pointing at the button.
|
||||
if en := b.Msg("en", "bind.invalid.body_resend"); !strings.Contains(en, "button below") {
|
||||
t.Errorf("English twin = %q, want it to point at the button", en)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func postCreate(s *Server, id string) *httptest.ResponseRecorder {
|
||||
form := url.Values{"customer_id": {id}, "customer_name": {"Tester"}, "email": {"t@felhom.example"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/configs/new", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleConfigCreate(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// R-728: a second submit for the same customer ID while the first is still between its duplicate check
|
||||
// and its save must NOT create (and mint) a second time. The first create is held at the save by the
|
||||
// test seam while the second runs to completion.
|
||||
func TestConfigCreate_ConcurrentSubmitCreatesOnce(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
var logBuf syncBuf
|
||||
s.logger = log.New(&logBuf, "", 0)
|
||||
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
var once sync.Once
|
||||
s.beforeCreateSave = func(string) {
|
||||
first := false
|
||||
once.Do(func() { first = true })
|
||||
if first {
|
||||
close(started)
|
||||
<-release
|
||||
}
|
||||
}
|
||||
|
||||
done := make(chan *httptest.ResponseRecorder)
|
||||
go func() { done <- postCreate(s, "tester-2") }()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the first create never reached the save")
|
||||
}
|
||||
second := postCreate(s, "tester-2")
|
||||
close(release)
|
||||
first := <-done
|
||||
|
||||
if first.Code != http.StatusSeeOther {
|
||||
t.Fatalf("first create = %d, want 303", first.Code)
|
||||
}
|
||||
if n := strings.Count(logBuf.String(), "Customer config created: tester-2"); n != 1 {
|
||||
t.Fatalf("customer created %d times for one press, want exactly 1:\n%s", n, logBuf.String())
|
||||
}
|
||||
if !strings.Contains(second.Body.String(), "already being created") {
|
||||
t.Errorf("the second submit must be told a create is in progress; got %d", second.Code)
|
||||
}
|
||||
|
||||
// The guard is released: a later create of the SAME id is refused by the ordinary duplicate check,
|
||||
// not stuck behind the in-flight guard.
|
||||
s.beforeCreateSave = nil
|
||||
if body := postCreate(s, "tester-2").Body.String(); !strings.Contains(body, "already exists") {
|
||||
t.Error("after the first create returned, a re-submit must meet the ordinary 'already exists' check")
|
||||
}
|
||||
}
|
||||
|
||||
// syncBuf is a goroutine-safe log sink.
|
||||
type syncBuf struct {
|
||||
mu sync.Mutex
|
||||
b strings.Builder
|
||||
}
|
||||
|
||||
func (w *syncBuf) Write(p []byte) (int, error) { w.mu.Lock(); defer w.mu.Unlock(); return w.b.Write(p) }
|
||||
func (w *syncBuf) String() string { w.mu.Lock(); defer w.mu.Unlock(); return w.b.String() }
|
||||
@@ -342,8 +342,9 @@ const bindPageHTML = `<!DOCTYPE html>
|
||||
<p>{{T "bind.locked.body"}}</p>
|
||||
{{else}}
|
||||
<p class="lead">{{T "bind.invalid.lead"}}</p>
|
||||
<p>{{T "bind.invalid.body"}}</p>
|
||||
{{if .Token}}<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>{{end}}
|
||||
{{if .Token}}<p>{{T "bind.invalid.body_resend"}}</p>
|
||||
<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>
|
||||
{{else}}<p>{{T "bind.invalid.body"}}</p>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<p class="foot">Felhom.eu</p>
|
||||
|
||||
@@ -111,6 +111,19 @@ type Server struct {
|
||||
// only — see the constant's comment for why this must never become a database row.
|
||||
artifactCache map[string]artifactChoiceCacheEntry
|
||||
artifactCacheMu sync.Mutex
|
||||
|
||||
// wgPeerSync asks the WG peer-sync reconciler for an immediate full-list push (R-600). nil when
|
||||
// peer-sync is not configured on this hub. Non-blocking: the push itself is asynchronous.
|
||||
wgPeerSync func()
|
||||
|
||||
// creating holds the customer IDs whose create is in flight (R-728): one press produced TWO creates
|
||||
// and two self-bind mints in the same second, and the first mail's link answered "expired". The
|
||||
// duplicate check reads the store, and the row is written only after the slow provisioning legs, so
|
||||
// a second submit inside that window passed it. Pinned by TestConfigCreate_ConcurrentSubmitCreatesOnce.
|
||||
creating sync.Map
|
||||
// beforeCreateSave is a TEST seam: called in handleConfigCreate after the duplicate check, before the
|
||||
// save. nil in production.
|
||||
beforeCreateSave func(customerID string)
|
||||
}
|
||||
|
||||
// New creates a new web server.
|
||||
@@ -247,6 +260,20 @@ func (s *Server) SetOffsiteBox(fn func() (monitor.BoxSnapshot, bool)) { s.offsit
|
||||
// carries its own state (ok/unavailable/degraded); the web layer never polls ep0.
|
||||
func (s *Server) SetPBSDRBox(fn func() (monitor.PBSBoxSnapshot, bool)) { s.pbsdrBox = fn }
|
||||
|
||||
// SetWGPeerSync wires the WG peer-sync reconciler's Trigger (R-600): the customer delete cascade asks
|
||||
// for an immediate push so the deleted box's peer leaves the endpoint now, not on the next 5-minute tick.
|
||||
func (s *Server) SetWGPeerSync(trigger func()) { s.wgPeerSync = trigger }
|
||||
|
||||
// requestWGPeerPush triggers the peer-sync push and returns the operator-facing phrase for the log:
|
||||
// the push is asynchronous, so the line says "requested", never "removed" (R-600).
|
||||
func (s *Server) requestWGPeerPush() string {
|
||||
if s.wgPeerSync == nil {
|
||||
return "WG peer-sync is not configured on this hub, so the endpoint's peer list was NOT updated"
|
||||
}
|
||||
s.wgPeerSync()
|
||||
return "WG peer removal push requested (asynchronous — the endpoint drops the peer on that push, check `wg show` before calling the box gone)"
|
||||
}
|
||||
|
||||
// SetClaimEngine wires the customer-claim code engine for the Setup-tab resend button (v0.50.0).
|
||||
func (s *Server) SetClaimEngine(e *claim.Engine) { s.claimEngine = e }
|
||||
|
||||
|
||||
@@ -52,7 +52,10 @@
|
||||
<div class="flash flash-error">Invalid artifact version — use X.Y.Z (or blank to clear).</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "artifact_sha_invalid"}}
|
||||
<div class="flash flash-error">Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.</div>
|
||||
<div class="flash flash-error"><strong>Refused: a checksum you typed is not valid.</strong> A sha256 is 64 characters, 0-9 and a-f. Manifest unchanged. Copy the checksum again and save.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "artifact_version_missing"}}
|
||||
<div class="flash flash-error"><strong>Refused: the registry has no such version.</strong> Gitea answered "not found" for that version, so there is no checksum to record. Manifest unchanged. Check the version number, or publish that version first, then vouch it here again.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "golden_behind_fleet"}}
|
||||
<div class="flash flash-error"><strong>Refused: that golden is older than the controller the fleet already runs.</strong> A fresh install would land on stale application code — which is R-120, where new boxes shipped a controller that told customers the wrong thing about a missing backup drive. Manifest unchanged. Re-bake the golden on the current controller, publish it, then vouch it here.</div>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<table class="detail-table">
|
||||
<tr><th style="width: 12rem;">Datastore</th><td><code>felhom-offsite</code> (ep0)</td></tr>
|
||||
<tr><th>Capacity</th><td>{{.PBSBox.CapacityStr}}</td></tr>
|
||||
<tr><th>Used</th><td>{{.PBSBox.UsedStr}} · {{formatFloat .PBSBox.FillPercent}}% full</td></tr>
|
||||
<tr><th>Used</th><td>{{.PBSBox.UsedStr}} · {{formatFloat .PBSBox.FillPercent}}% full <span class="text-muted">({{.PBSBox.UsedExact}})</span></td></tr>
|
||||
</table>
|
||||
<div class="bar" style="margin: 0.4rem 0 0.9rem;"><div class="bar-fill bar-{{.PBSBox.FillBand}}" style="width: {{formatFloat .PBSBox.FillPercent}}%;"></div></div>
|
||||
<table class="detail-table">
|
||||
|
||||
Reference in New Issue
Block a user