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