v0.44.0: role-aware drive management — protected lockout + customer type-to-confirm wipe + drive-list restyle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ import (
|
||||
// without a live agent). *agentapi.Client satisfies it.
|
||||
type diskAgent interface {
|
||||
Disks(ctx context.Context) (agentapi.DisksResponse, error)
|
||||
FormatDisk(ctx context.Context, device, fstype string) (agentapi.FormatResult, error)
|
||||
FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error)
|
||||
AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
|
||||
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
|
||||
}
|
||||
@@ -60,21 +60,33 @@ func fsUUIDForDevice(disks agentapi.DisksResponse, device string) string {
|
||||
type storageInitResult struct {
|
||||
Registered bool `json:"registered"`
|
||||
Where string `json:"where,omitempty"`
|
||||
// Refusal (data-bearing): the operator must sign offline. No bypass.
|
||||
// NeedsConfirmation (USER-DATA data-bearing): the customer must confirm the wipe (type-to-confirm),
|
||||
// then the wizard re-submits with confirmed=true. NOT an operator signature.
|
||||
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
DurableID string `json:"durable_id,omitempty"`
|
||||
// Refusal (system/backup data-bearing): the operator must sign offline. No bypass.
|
||||
Refused bool `json:"refused,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Opsign string `json:"opsign,omitempty"`
|
||||
}
|
||||
|
||||
// runStorageInit is the testable core of the init flow: format → (refuse?) → resolve new UUID →
|
||||
// assign → register. On a data-bearing refusal it returns a result with Refused+Opsign and performs
|
||||
// NO further (destructive or mount) action.
|
||||
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) {
|
||||
// runStorageInit is the testable core of the init flow: format → (confirm/refuse?) → resolve new
|
||||
// UUID → assign → register. A USER-DATA data-bearing device requires the customer's confirmation
|
||||
// (NeedsConfirmation); a SYSTEM/BACKUP device requires an operator signature (Refused+Opsign). In
|
||||
// either refusal it performs NO further (destructive or mount) action.
|
||||
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault, confirmed bool, durableID string) (storageInitResult, error) {
|
||||
if !validFSTypes[fstype] {
|
||||
return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", fstype)
|
||||
}
|
||||
// 1. Format — the AGENT inspects the device and decides. A data-bearing device is refused.
|
||||
fr, err := agent.FormatDisk(ctx, device, fstype)
|
||||
// 1. Format — the AGENT inspects the device and tiers it by role. A data-bearing user-data device
|
||||
// is allowed only with the customer's confirmation bound to its durable id; system/backup needs
|
||||
// an operator signature.
|
||||
fr, err := agent.FormatDisk(ctx, device, fstype, confirmed, durableID)
|
||||
if errors.Is(err, agentapi.ErrNeedsConfirmation) {
|
||||
// USER-DATA: surface the type-to-confirm requirement + the durable id to confirm against.
|
||||
return storageInitResult{NeedsConfirmation: true, Role: fr.Role, DurableID: fr.DurableID, Reason: fr.Reason}, nil
|
||||
}
|
||||
if errors.Is(err, agentapi.ErrFormatRefused) {
|
||||
res := storageInitResult{Refused: true, Reason: fr.Reason}
|
||||
if fr.PendingOp != nil {
|
||||
@@ -169,6 +181,10 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleStorageAttach(w, r)
|
||||
case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost:
|
||||
s.handleStorageEject(w, r)
|
||||
case r.URL.Path == "/api/storage/wipe" && r.Method == http.MethodPost:
|
||||
s.handleStorageWipe(w, r)
|
||||
case r.URL.Path == "/api/storage/impact" && r.Method == http.MethodGet:
|
||||
s.handleStorageImpact(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -180,6 +196,10 @@ type storageProvReq struct {
|
||||
MountName string `json:"mount_name"`
|
||||
Label string `json:"label"`
|
||||
SetDefault bool `json:"set_default"`
|
||||
// Confirmed + DurableID: the customer's type-to-confirm authorization for a USER-DATA data-bearing
|
||||
// wipe (the durable id the agent returned on the prior NeedsConfirmation response).
|
||||
Confirmed bool `json:"confirmed"`
|
||||
DurableID string `json:"durable_id"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -202,11 +222,15 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault)
|
||||
res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault, req.Confirmed, req.DurableID)
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if res.NeedsConfirmation {
|
||||
writeDiskJSON(w, http.StatusConflict, false, "ügyfél-megerősítés szükséges", res)
|
||||
return
|
||||
}
|
||||
if res.Refused {
|
||||
writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res)
|
||||
return
|
||||
@@ -214,6 +238,93 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
|
||||
writeDiskJSON(w, http.StatusOK, true, "", res)
|
||||
}
|
||||
|
||||
// storageImpactReq / handleStorageImpact return the deployed apps whose data lives on a given mount —
|
||||
// the "name the apps that break" requirement for the type-to-confirm wipe/eject UI.
|
||||
func (s *Server) handleStorageImpact(w http.ResponseWriter, r *http.Request) {
|
||||
where := path.Clean(strings.TrimSpace(r.URL.Query().Get("where")))
|
||||
if where == "" || where == "." || !strings.HasPrefix(where, "/mnt/") {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
|
||||
return
|
||||
}
|
||||
apps := s.appsUsingPath(where)
|
||||
if apps == nil {
|
||||
apps = []string{}
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"where": where, "apps": apps})
|
||||
}
|
||||
|
||||
// handleStorageWipe is the customer-confirmed wipe of a USER-DATA drive: it unmounts (eject —
|
||||
// deregisters + frees the device) then formats with the customer's confirmation bound to the device's
|
||||
// durable id. The agent re-classifies the role and re-resolves the durable id itself — a system/backup
|
||||
// device is refused by the agent regardless of what the controller sends. The mount name must be typed
|
||||
// to match (type-to-confirm) — enforced both client-side (disabled button) and here (server-side).
|
||||
func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Device string `json:"device"`
|
||||
Where string `json:"where"`
|
||||
MountName string `json:"mount_name"` // the typed confirmation (must equal the basename of Where)
|
||||
FSType string `json:"fstype"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
|
||||
return
|
||||
}
|
||||
if req.Device == "" {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
|
||||
return
|
||||
}
|
||||
req.Where = path.Clean(strings.TrimSpace(req.Where))
|
||||
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
|
||||
return
|
||||
}
|
||||
// Server-side type-to-confirm: the typed name must match the mount's basename exactly.
|
||||
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
|
||||
return
|
||||
}
|
||||
fstype := req.FSType
|
||||
if !validFSTypes[fstype] {
|
||||
fstype = "ext4"
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
// 1. Unmount (benign — frees the device so mkfs can run) + deregister the StoragePath.
|
||||
if _, eerr := agent.EjectDisk(r.Context(), req.Where); eerr != nil {
|
||||
s.logger.Printf("[WARN] [web] wipe: eject %s failed (continuing to format): %v", req.Where, eerr)
|
||||
} else if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil {
|
||||
s.logger.Printf("[WARN] [web] wipe: deregister %s failed: %v", req.Where, rerr)
|
||||
} else {
|
||||
go s.SyncFileBrowserMounts()
|
||||
}
|
||||
// 2. Two-step customer-confirmed format: learn the agent's durable id (NeedsConfirmation), then
|
||||
// re-submit confirmed:true bound to it. The agent re-resolves + matches the durable id and
|
||||
// re-classifies the role — a protected device is refused here even though we send confirmed:true.
|
||||
probe, perr := agent.FormatDisk(r.Context(), req.Device, fstype, false, "")
|
||||
if errors.Is(perr, agentapi.ErrFormatRefused) {
|
||||
writeDiskJSON(w, http.StatusConflict, false, "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással", probe)
|
||||
return
|
||||
}
|
||||
if !errors.Is(perr, agentapi.ErrNeedsConfirmation) {
|
||||
if perr != nil {
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+perr.Error(), nil)
|
||||
return
|
||||
}
|
||||
// Already blank (no confirmation needed) — the format the agent just ran is the wipe.
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": true})
|
||||
return
|
||||
}
|
||||
fr, ferr := agent.FormatDisk(r.Context(), req.Device, fstype, true, probe.DurableID)
|
||||
if ferr != nil {
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+ferr.Error(), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID})
|
||||
}
|
||||
|
||||
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
|
||||
var req storageProvReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user