v0.329.0: each app reaches only its own appdata folder; empty allow-list; drive retirement asks the agent first; plain no-durable-id refusal; :latest only for a release
gates / gates (push) Successful in 45s

R-1142: the HDD_PATH root is appdata/<own folder>, with a compiled-in folder map (paperless-ngx ->
paperless). R-981: the allow-list is empty (homepage's socket served no Felhom feature). R-1063:
finalizeDecommissionWith asks the agent before it marks the drive or moves the default; the 403/409
come back typed and reach the household as a plain 409. R-1069: the agent's no-durable-id format
refusal is ErrNoDurableID and a plain sentence. R-1143: build.sh carries :latest only for x.y.z.
Each pinned by a red-proofed test. MinAgent 0.158.1.
This commit is contained in:
2026-10-17 23:11:56 +02:00
parent 5d67041d6d
commit 2fe634141f
20 changed files with 504 additions and 78 deletions
+35 -4
View File
@@ -429,6 +429,13 @@ var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup de
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
// ErrNoDurableID is the agent's anti-retarget refusal of a blank format (R-1069): the device has no durable id to
// bind the format to — a virtual disk passed on a virtio bus without a serial has no /dev/disk/by-id link. The agent's
// rule is a safety rule and stays; this only lets the dashboard say it in plain words. Matched on the agent's own
// refusal text (felhom-agent internal/localapi/disks.go „format refused: device has no durable id"); if that text
// ever changes, the refusal degrades to the raw message it was before. Pinned by TestFormatDisk_NoDurableIDIsTyped.
var ErrNoDurableID = fmt.Errorf("agentapi: format refused — the device has no durable id (path-only formats are not permitted)")
// SameDiskResult is the agent's answer to POST /disks/same-disk (v0.157.0). Act on Proven first:
// Proven+Different = a counted second copy; Proven+!Different = the same disk; !Proven = unknown
// (Reason vm|network|usb_bridge|weak_wwn|no_serial|unmapped).
@@ -779,6 +786,11 @@ func (c *Client) Decommission(ctx context.Context, where string) (DecommissionRe
return out, err
}
if err := refusalError("/disks/decommission", status, env); err != nil {
if status == http.StatusForbidden || status == http.StatusConflict {
// R-1063: the agent's two policy refusals (403 a protected role, 409 the whole-guest backup target) are
// typed, so the dashboard can say which in plain words. The text is refusalError's, unchanged.
return out, &DecommissionRefusedError{Status: status, Reason: refusalReason(env)}
}
return out, err
}
if err := json.Unmarshal(env.Data, &out); err != nil {
@@ -787,6 +799,17 @@ func (c *Client) Decommission(ctx context.Context, where string) (DecommissionRe
return out, nil
}
// DecommissionRefusedError is the agent saying no to a decommission: Status 403 (the mount is system/backup by its
// role) or 409 (the mount holds the whole-guest backup target). Nothing was done on the host.
type DecommissionRefusedError struct {
Status int
Reason string // the agent's own words, truncated (never a secret: the agent's refusal text)
}
func (e *DecommissionRefusedError) Error() string {
return fmt.Sprintf("agentapi: POST /disks/decommission: HTTP %d: %s", e.Status, e.Reason)
}
// refusalError converts a non-2xx status or an ok:false envelope into an error that CARRIES the
// agent's reason (truncated; never request bodies or secrets). nil on an accepted 200/202+ok=true.
func refusalError(path string, status int, env apiResponse) error {
@@ -794,16 +817,21 @@ func refusalError(path string, status int, env apiResponse) error {
if accepted && env.OK {
return nil
}
reason := truncateErr(env.Error, 300)
if reason == "" {
reason = "(no reason in agent response)"
}
reason := refusalReason(env)
if accepted { // 2xx but ok:false — business refusal without an HTTP error code
return fmt.Errorf("agentapi: POST %s: %s", path, reason)
}
return fmt.Errorf("agentapi: POST %s: HTTP %d: %s", path, status, reason)
}
// refusalReason is the agent's refusal text as refusalError carries it.
func refusalReason(env apiResponse) string {
if r := truncateErr(env.Error, 300); r != "" {
return r
}
return "(no reason in agent response)"
}
// truncateErr mirrors stacks.truncateStr for agent refusal reasons.
func truncateErr(s string, maxLen int) string {
if len(s) <= maxLen {
@@ -850,6 +878,9 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirme
// real failure (e.g. the agent's 502 on a mkfs error: "device is mounted"). Returning the zero-value
// result with a nil error here made a failed destructive format read as a silent SUCCESS in the web
// layer. Surface it as an error so the caller (and the dashboard) report the failure.
if status == http.StatusConflict && strings.Contains(env.Error, "has no durable id") {
return out, ErrNoDurableID
}
if status < 200 || status >= 300 || !env.OK {
msg := strings.TrimSpace(env.Error)
if msg == "" {
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
@@ -35,3 +36,52 @@ func TestR856_CrashGuardDecodesAndAnOlderAgentIs404(t *testing.T) {
t.Fatalf("an older agent must answer a typed 404, got %v", err)
}
}
// R-1063: the agent's two decommission refusals come back TYPED (403 a protected role, 409 the whole-guest backup
// target), with the same text as before; any other failure is not that type.
func TestDecommission_PolicyRefusalsAreTyped(t *testing.T) {
for _, c := range []struct {
code int
typed bool
}{{http.StatusForbidden, true}, {http.StatusConflict, true}, {http.StatusBadGateway, false}} {
mux := http.NewServeMux()
mux.HandleFunc("POST /disks/decommission", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(c.code)
_, _ = w.Write([]byte(`{"ok":false,"error":"refused for the test"}`))
})
s := httptest.NewTLSServer(mux)
_, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).Decommission(context.Background(), "/mnt/x")
s.Close()
var dr *DecommissionRefusedError
if got := errors.As(err, &dr); got != c.typed {
t.Fatalf("HTTP %d: typed=%v, want %v (err %v)", c.code, got, c.typed, err)
}
if c.typed && (dr.Status != c.code || !strings.Contains(err.Error(), "HTTP "+strconv.Itoa(c.code)+": refused for the test")) {
t.Fatalf("HTTP %d: %+v / %q", c.code, dr, err)
}
}
}
// R-1069: the agent's anti-retarget refusal of a blank format (its exact text, a 409) comes back as ErrNoDurableID;
// another 409 does not.
func TestFormatDisk_NoDurableIDIsTyped(t *testing.T) {
for _, c := range []struct {
body string
typed bool
}{
{`{"ok":false,"error":"format refused: device has no durable id to bind the format to (path-only formats are not permitted)","data":{"formatted":false,"data_bearing":false}}`, true},
{`{"ok":false,"error":"format refused (device may have changed since inspection): gone","data":{"formatted":false}}`, false},
} {
mux := http.NewServeMux()
mux.HandleFunc("POST /disks/format", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(c.body))
})
s := httptest.NewTLSServer(mux)
_, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).FormatDisk(context.Background(), "/dev/vdb", "ext4", false, "")
s.Close()
if got := errors.Is(err, ErrNoDurableID); got != c.typed || err == nil {
t.Fatalf("body %s: ErrNoDurableID=%v want %v (err %v)", c.body[:60], got, c.typed, err)
}
}
}