agent v0.50.0: NAS network storage Part A1 (NFS/SMB automount foundation)
Host-side NFS/SMB automount of a bulk-media NAS share under /mnt/felhom-drives/<name> (propagates into the guest via the existing shared bind), the +100000 uid recipe, per-share liveness, and add/list/remove local-API endpoints. A NAS is a distinct class that bypasses the drive enroll/eject/decommission/SMART/watchdog machinery. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
This commit is contained in:
@@ -16,6 +16,13 @@ func parseFelhomMountUnit(content string) (MountSpec, bool) {
|
||||
if !strings.Contains(content, felhomUnitMarker) {
|
||||
return MountSpec{}, false
|
||||
}
|
||||
// GUARD (network-storage bypass, Scenario D): a NAS network mount carries the netUnitMarker, which
|
||||
// also contains felhomUnitMarker as a substring. It is NOT a drive — no durable-id, no device — and
|
||||
// MUST NOT be picked up by the host-reboot drive re-assert. Refuse it explicitly here so even a
|
||||
// (contrived) network unit shaped like a by-uuid drive unit can never be classified as a drive.
|
||||
if strings.Contains(content, netUnitMarker) {
|
||||
return MountSpec{}, false
|
||||
}
|
||||
var spec MountSpec
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Network storage (NAS) — the bulk-media class (Part A1). A NAS share is mounted HOST-SIDE under
|
||||
// /mnt/felhom-drives/<name> via a systemd .automount (on-demand + idle-unmount) + .mount pair; it
|
||||
// propagates into the guest for free through the existing shared mp8 bind. This is a DISTINCT class
|
||||
// from a physical drive: it carries NO durable-id, never enters the IntentStore, is never SMART-probed,
|
||||
// and bypasses the enroll/eject/decommission/wipe/migrate machinery entirely (SPIKE-nas-storage Q7).
|
||||
//
|
||||
// The whole file is the validated, locked recipe from SPIKE-nas-storage-2026-06-29.md — match it, do
|
||||
// not re-derive: NFS preferred (vers=4.1,soft,timeo=50,retrans=2,noatime), SMB fallback, the +100000
|
||||
// uid rule (container uid/gid N = host N+100000), automount idle-unmount for failure isolation.
|
||||
|
||||
const (
|
||||
// NetworkMountRoot is the ONLY place a network mount may live — under the existing shared mp8
|
||||
// bind parent, so it propagates into the guest with no new mountpoint and no restart. The role
|
||||
// gate (NetworkMountRole) confines every network mount to this namespace.
|
||||
NetworkMountRoot = "/mnt/felhom-drives"
|
||||
|
||||
// lxcUIDOffset is the unprivileged-LXC id map base: a container uid/gid N appears on the host as
|
||||
// N+100000 (verified against the live felhom-usb userdata). The NAS must present/own/squash files
|
||||
// as container_id+100000 so the guest sees its native id and reads+writes (SPIKE Q5). This is the
|
||||
// whole +100000 recipe; a naive +0 (anonuid=1000 / uid=1000) lands as nobody:nogroup → not writable.
|
||||
lxcUIDOffset = 100000
|
||||
|
||||
// netUnitMarker headers every network-storage unit. It is the explicit guard that keeps a network
|
||||
// mount OUT of the drive machinery: parseFelhomMountUnit refuses any unit carrying it, so the
|
||||
// host-reboot drive re-assert (ReassertEnrolledMounts) never touches a NAS mount (Scenario D).
|
||||
netUnitMarker = "Managed by felhom-agent (network storage)"
|
||||
|
||||
// defaultIdleTimeoutSec is the automount TimeoutIdleSec: with no app reading, the share auto-unmounts,
|
||||
// so an idle NAS reboot is a non-event and the stale-mount window is minimised (SPIKE recommendation).
|
||||
defaultIdleTimeoutSec = 60
|
||||
|
||||
// netReachTimeout bounds the per-share liveness endpoint dial so a black-holed NAS cannot wedge a
|
||||
// list call (the mount itself may be EIO/D-state; we probe the endpoint, never stat the mount).
|
||||
netReachTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// NetworkProtocol is the wire protocol of a network mount.
|
||||
type NetworkProtocol string
|
||||
|
||||
const (
|
||||
ProtocolNFS NetworkProtocol = "nfs"
|
||||
ProtocolSMB NetworkProtocol = "smb"
|
||||
)
|
||||
|
||||
// NetworkMountSpec describes one NAS share to mount host-side. It is NOT a drive: no durable-id, no
|
||||
// device, no role lifecycle. MappedUID/MappedGID are the CONTAINER ids (e.g. 1000); the agent applies
|
||||
// the +100000 offset where it matters (the SMB client mount). CredsRef points at the 0600 out-of-band
|
||||
// SMB credentials file (empty for NFS) — never the credentials themselves.
|
||||
type NetworkMountSpec struct {
|
||||
Name string // share name → mountpoint /mnt/felhom-drives/<Name>; a single safe path segment
|
||||
Protocol NetworkProtocol // nfs | smb
|
||||
Server string // NAS host or IP
|
||||
Export string // NFS export path (/srv/media) or SMB share name (media)
|
||||
MappedUID int // CONTAINER uid the media app runs as (host = +100000)
|
||||
MappedGID int // CONTAINER gid
|
||||
CredsRef string // SMB only: absolute path to the 0600 credentials file (out-of-band)
|
||||
IdleTimeoutSec int // automount idle-unmount window; 0 → defaultIdleTimeoutSec
|
||||
}
|
||||
|
||||
// NetworkMountStatus is the per-share liveness surface (SPIKE Q7 health model): is it configured,
|
||||
// currently mounted, and is the NAS endpoint reachable. Health degrades to "unreachable" for the
|
||||
// affected share ONLY — it never folds into the box's overall health.
|
||||
type NetworkMountStatus struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"`
|
||||
Server string `json:"server"`
|
||||
Export string `json:"export"`
|
||||
Where string `json:"where"`
|
||||
Configured bool `json:"configured"` // the automount unit is installed
|
||||
Mounted bool `json:"mounted"` // currently mounted (false when idle-unmounted — normal, not a fault)
|
||||
Reachable bool `json:"reachable"` // the NAS endpoint is TCP-reachable
|
||||
Health string `json:"health"` // ok | idle | unreachable
|
||||
}
|
||||
|
||||
// Network mount health vocabulary.
|
||||
const (
|
||||
NetHealthOK = "ok" // reachable + mounted: serving
|
||||
NetHealthIdle = "idle" // reachable + not mounted: automount idle-unmounted (benign)
|
||||
NetHealthUnreachable = "unreachable" // endpoint not reachable: the affected share is degraded
|
||||
)
|
||||
|
||||
var (
|
||||
// reShareName: a single safe path segment for the share name (becomes the mountpoint dir + unit name).
|
||||
reShareName = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
// reNetServer: a hostname or IPv4/IPv6 literal — no metacharacters, no '/', no ':' path tricks.
|
||||
reNetServer = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`)
|
||||
)
|
||||
|
||||
const maxShareNameLen = 64
|
||||
|
||||
// Where returns the mountpoint for this spec under the network-mount root.
|
||||
func (s NetworkMountSpec) Where() string {
|
||||
return NetworkMountRoot + "/" + s.Name
|
||||
}
|
||||
|
||||
// HostUID/HostGID apply the +100000 LXC offset — the host id the share must own/squash files as.
|
||||
func (s NetworkMountSpec) HostUID() int { return s.MappedUID + lxcUIDOffset }
|
||||
func (s NetworkMountSpec) HostGID() int { return s.MappedGID + lxcUIDOffset }
|
||||
|
||||
// idleTimeout returns the configured idle-unmount window or the default.
|
||||
func (s NetworkMountSpec) idleTimeout() int {
|
||||
if s.IdleTimeoutSec > 0 {
|
||||
return s.IdleTimeoutSec
|
||||
}
|
||||
return defaultIdleTimeoutSec
|
||||
}
|
||||
|
||||
// ValidateNetworkMountSpec is the security boundary for the network-mount surface — every value that
|
||||
// reaches a systemd unit (and thus a root-triggered mount) is checked here before any unit is rendered.
|
||||
// Mirrors validate.go's discipline: strict charset/length, no metacharacters, no traversal.
|
||||
func ValidateNetworkMountSpec(s NetworkMountSpec) error {
|
||||
if s.Name == "" || len(s.Name) > maxShareNameLen || !reShareName.MatchString(s.Name) || s.Name == "." || s.Name == ".." {
|
||||
return fmt.Errorf("netmount: invalid share name %q (want a single safe segment)", s.Name)
|
||||
}
|
||||
switch s.Protocol {
|
||||
case ProtocolNFS, ProtocolSMB:
|
||||
default:
|
||||
return fmt.Errorf("netmount: unsupported protocol %q (want nfs|smb)", s.Protocol)
|
||||
}
|
||||
if s.Server == "" || len(s.Server) > 255 || !reNetServer.MatchString(s.Server) {
|
||||
return fmt.Errorf("netmount: invalid server %q", s.Server)
|
||||
}
|
||||
if err := validateExport(s.Protocol, s.Export); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.MappedUID < 0 || s.MappedUID > 60000 || s.MappedGID < 0 || s.MappedGID > 60000 {
|
||||
return fmt.Errorf("netmount: mapped uid/gid out of range (uid=%d gid=%d; want 0..60000)", s.MappedUID, s.MappedGID)
|
||||
}
|
||||
if s.Protocol == ProtocolSMB {
|
||||
if s.CredsRef == "" {
|
||||
return fmt.Errorf("netmount: smb requires a credentials file reference")
|
||||
}
|
||||
if err := ValidateMountPath(s.CredsRef); err != nil {
|
||||
return fmt.Errorf("netmount: invalid credentials path: %w", err)
|
||||
}
|
||||
}
|
||||
// The mountpoint must validate AND must land under the network-mount root (the role gate's namespace).
|
||||
if err := ValidateMountPath(s.Where()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateExport checks the NFS export path / SMB share name. NFS export must be an absolute, safe path;
|
||||
// the SMB share name a single safe segment.
|
||||
func validateExport(proto NetworkProtocol, export string) error {
|
||||
if export == "" {
|
||||
return fmt.Errorf("netmount: empty export/share")
|
||||
}
|
||||
switch proto {
|
||||
case ProtocolNFS:
|
||||
if export[0] != '/' {
|
||||
return fmt.Errorf("netmount: nfs export must be an absolute path, got %q", export)
|
||||
}
|
||||
if err := ValidateMountPath(export); err != nil {
|
||||
return fmt.Errorf("netmount: invalid nfs export: %w", err)
|
||||
}
|
||||
case ProtocolSMB:
|
||||
if len(export) > maxShareNameLen || !reShareName.MatchString(export) {
|
||||
return fmt.Errorf("netmount: invalid smb share name %q", export)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetworkMountRole gates network storage to the bulk-userdata namespace (SPIKE Q7: a NAS is selectable
|
||||
// as a media app's data path but DISQUALIFIED as a system/backup/DB target). A network mount is
|
||||
// user-data ONLY when it lands under NetworkMountRoot (the controller's bind blast radius); any other
|
||||
// target is the most-protected role (system) → the caller refuses it. Pure → unit-tested.
|
||||
func NetworkMountRole(where string) DeviceRole {
|
||||
clean := cleanMountPath(where)
|
||||
if clean == NetworkMountRoot || strings.HasPrefix(clean, NetworkMountRoot+"/") {
|
||||
return RoleUserData
|
||||
}
|
||||
return RoleSystem
|
||||
}
|
||||
|
||||
// mountSource builds the systemd What= for the spec: server:/export (NFS) or //server/share (SMB).
|
||||
func (s NetworkMountSpec) mountSource() string {
|
||||
if s.Protocol == ProtocolSMB {
|
||||
return "//" + s.Server + "/" + s.Export
|
||||
}
|
||||
return s.Server + ":" + s.Export
|
||||
}
|
||||
|
||||
// fsType maps the protocol to the kernel filesystem type for the .mount unit.
|
||||
func (s NetworkMountSpec) fsType() string {
|
||||
if s.Protocol == ProtocolSMB {
|
||||
return "cifs"
|
||||
}
|
||||
return "nfs4" // vers=4.1 → nfs4 (avoids the rpcbind/lock-manager surface of v3)
|
||||
}
|
||||
|
||||
// mountOptions returns the exact, validated option set for the protocol (SPIKE Q2/Q5):
|
||||
// - NFS: vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev — soft is the failure-isolation knob
|
||||
// (clean EIO, never a wedge); the +100000 squash is the EXPORT's job (anonuid=101000), not the
|
||||
// client mount, so no uid appears here.
|
||||
// - SMB: vers=3.0,credentials=<file>,uid=<+100000>,gid=<+100000>,forceuid,forcegid,file_mode=0664,
|
||||
// dir_mode=0775,_netdev — modes are PLAIN octal (not setgid 2775); the client forces the
|
||||
// guest-visible owner to the mapped id so the container reads+writes.
|
||||
//
|
||||
// Every interpolated value is pre-validated by ValidateNetworkMountSpec, so the string carries no
|
||||
// newline / no extra directive. NEVER a default `hard` NFS mount (it wedges) — soft is mandatory.
|
||||
func (s NetworkMountSpec) mountOptions() string {
|
||||
if s.Protocol == ProtocolSMB {
|
||||
return strings.Join([]string{
|
||||
"vers=3.0",
|
||||
"credentials=" + s.CredsRef,
|
||||
"uid=" + strconv.Itoa(s.HostUID()),
|
||||
"gid=" + strconv.Itoa(s.HostGID()),
|
||||
"forceuid",
|
||||
"forcegid",
|
||||
"file_mode=0664",
|
||||
"dir_mode=0775",
|
||||
"_netdev",
|
||||
}, ",")
|
||||
}
|
||||
return "vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev"
|
||||
}
|
||||
|
||||
// renderNetworkMountUnit builds the .mount unit (triggered by the .automount; deliberately NO [Install]
|
||||
// — we enable the .automount, not this). Marked with netUnitMarker so the drive machinery skips it.
|
||||
func renderNetworkMountUnit(s NetworkMountSpec) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# " + netUnitMarker + " — do not edit by hand.\n")
|
||||
b.WriteString("[Unit]\n")
|
||||
fmt.Fprintf(&b, "Description=Felhom network storage %s (%s)\n", sanitizeDesc(s.Name), s.Protocol)
|
||||
b.WriteString("After=network-online.target\n")
|
||||
b.WriteString("Wants=network-online.target\n")
|
||||
b.WriteString("\n[Mount]\n")
|
||||
fmt.Fprintf(&b, "What=%s\n", s.mountSource())
|
||||
fmt.Fprintf(&b, "Where=%s\n", s.Where())
|
||||
fmt.Fprintf(&b, "Type=%s\n", s.fsType())
|
||||
fmt.Fprintf(&b, "Options=%s\n", s.mountOptions())
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderNetworkAutomountUnit builds the .automount unit (on-demand + idle-unmount). Enabling THIS is
|
||||
// what realises the on-demand mount; the idle timeout makes an idle NAS reboot a non-event.
|
||||
func renderNetworkAutomountUnit(s NetworkMountSpec) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# " + netUnitMarker + " — do not edit by hand.\n")
|
||||
b.WriteString("[Unit]\n")
|
||||
fmt.Fprintf(&b, "Description=Felhom network storage automount %s (%s)\n", sanitizeDesc(s.Name), s.Protocol)
|
||||
b.WriteString("After=network-online.target\n")
|
||||
b.WriteString("Wants=network-online.target\n")
|
||||
b.WriteString("\n[Automount]\n")
|
||||
fmt.Fprintf(&b, "Where=%s\n", s.Where())
|
||||
fmt.Fprintf(&b, "TimeoutIdleSec=%d\n", s.idleTimeout())
|
||||
b.WriteString("\n[Install]\n")
|
||||
b.WriteString("WantedBy=multi-user.target\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseNetworkMountUnit is the inverse of renderNetworkMountUnit for the fields the liveness list needs.
|
||||
// ok=false unless the content is a felhom network-storage .mount unit (the netUnitMarker + a parseable
|
||||
// network What=). Pure → unit-tested.
|
||||
func parseNetworkMountUnit(content string) (proto, server, export, where string, ok bool) {
|
||||
if !strings.Contains(content, netUnitMarker) {
|
||||
return "", "", "", "", false
|
||||
}
|
||||
var what, fstype string
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "What="):
|
||||
what = strings.TrimPrefix(line, "What=")
|
||||
case strings.HasPrefix(line, "Where="):
|
||||
where = strings.TrimPrefix(line, "Where=")
|
||||
case strings.HasPrefix(line, "Type="):
|
||||
fstype = strings.TrimPrefix(line, "Type=")
|
||||
}
|
||||
}
|
||||
if where == "" || what == "" {
|
||||
return "", "", "", "", false
|
||||
}
|
||||
switch fstype {
|
||||
case "cifs":
|
||||
proto = string(ProtocolSMB)
|
||||
// //server/share
|
||||
rest := strings.TrimPrefix(what, "//")
|
||||
if i := strings.IndexByte(rest, '/'); i > 0 {
|
||||
server, export = rest[:i], rest[i+1:]
|
||||
}
|
||||
case "nfs4", "nfs":
|
||||
proto = string(ProtocolNFS)
|
||||
if i := strings.IndexByte(what, ':'); i > 0 {
|
||||
server, export = what[:i], what[i+1:]
|
||||
}
|
||||
default:
|
||||
return "", "", "", "", false
|
||||
}
|
||||
if server == "" {
|
||||
return "", "", "", "", false
|
||||
}
|
||||
return proto, server, export, where, true
|
||||
}
|
||||
|
||||
// networkHealth derives the per-share health string from reachability + mount state (SPIKE Q7).
|
||||
func networkHealth(reachable, mounted bool) string {
|
||||
switch {
|
||||
case !reachable:
|
||||
return NetHealthUnreachable
|
||||
case mounted:
|
||||
return NetHealthOK
|
||||
default:
|
||||
return NetHealthIdle // reachable but idle-unmounted — normal automount steady state
|
||||
}
|
||||
}
|
||||
|
||||
// netEndpoint returns the host:port the liveness probe dials for a protocol (NFS 2049, SMB 445).
|
||||
func netEndpoint(proto, server string) string {
|
||||
port := "2049"
|
||||
if proto == string(ProtocolSMB) {
|
||||
port = "445"
|
||||
}
|
||||
return netJoin(server, port)
|
||||
}
|
||||
|
||||
// ---- SudoHostOps: the privileged network-mount surface --------------------------------------------
|
||||
|
||||
// EnsureNetworkMount stages + installs the .mount and .automount units and enables the AUTOMOUNT (not
|
||||
// the mount): the share then mounts on first access and idle-unmounts when quiet. Idempotent. It does
|
||||
// NOT register a durable-id, NOT enter the IntentStore, NOT SMART-probe — a NAS is not a drive.
|
||||
func (h *SudoHostOps) EnsureNetworkMount(ctx context.Context, spec NetworkMountSpec) error {
|
||||
if err := ValidateNetworkMountSpec(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
// Defense in depth: never realise a network mount outside the user-data namespace.
|
||||
if NetworkMountRole(spec.Where()) != RoleUserData {
|
||||
return fmt.Errorf("netmount: refusing to mount outside the user-data namespace: %s", spec.Where())
|
||||
}
|
||||
mountUnit, err := UnitNameForMount(spec.Where())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
automountUnit := strings.TrimSuffix(mountUnit, ".mount") + ".automount"
|
||||
|
||||
// Ensure the mountpoint exists (systemd automount also creates it, but be explicit — the parent is
|
||||
// the shared bind root). Reuse the intermediary mkdir grant.
|
||||
if err := h.run(ctx, "/usr/bin/mkdir", "-p", spec.Where()); err != nil {
|
||||
return fmt.Errorf("netmount: mkdir %s: %w", spec.Where(), err)
|
||||
}
|
||||
|
||||
if err := h.installUnit(ctx, mountUnit, renderNetworkMountUnit(spec)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.installUnit(ctx, automountUnit, renderNetworkAutomountUnit(spec)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil {
|
||||
return fmt.Errorf("netmount: daemon-reload: %w", err)
|
||||
}
|
||||
// Enable + start the AUTOMOUNT (creates the autofs trigger; idempotent).
|
||||
if err := h.run(ctx, h.bins.Systemctl, "enable", "--now", "--", automountUnit); err != nil {
|
||||
return fmt.Errorf("netmount: enabling automount %s: %w", automountUnit, err)
|
||||
}
|
||||
h.logger.Info("netmount: ensured network mount", "name", spec.Name, "proto", spec.Protocol,
|
||||
"server", spec.Server, "export", spec.Export, "where", spec.Where(), "host_uid", spec.HostUID())
|
||||
return nil
|
||||
}
|
||||
|
||||
// installUnit stages an agent-owned unit file then root-installs it into the unit dir (atomic, fixed
|
||||
// mode/owner) — the same staging pattern as EnsureMount.
|
||||
func (h *SudoHostOps) installUnit(ctx context.Context, unitName, content string) error {
|
||||
if err := os.MkdirAll(h.stageDir, 0o700); err != nil {
|
||||
return fmt.Errorf("netmount: staging dir: %w", err)
|
||||
}
|
||||
stagePath := filepath.Join(h.stageDir, unitName)
|
||||
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("netmount: staging unit %s: %w", unitName, err)
|
||||
}
|
||||
dest := filepath.Join(h.unitDir, unitName)
|
||||
if err := h.run(ctx, h.bins.Install, "-o", "root", "-g", "root", "-m", "0644", "--", stagePath, dest); err != nil {
|
||||
return fmt.Errorf("netmount: installing unit %s: %w", unitName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveNetworkMount stops + disables the automount, stops the mount, and removes both unit files (and
|
||||
// the staged copies). It NEVER routes through the drive eject/decommission path — a NAS has no device
|
||||
// lifecycle. Best-effort on the per-step stop/disable (a not-loaded unit is fine); the file removal is
|
||||
// the authoritative "gone" signal.
|
||||
func (h *SudoHostOps) RemoveNetworkMount(ctx context.Context, name string) error {
|
||||
if name == "" || len(name) > maxShareNameLen || !reShareName.MatchString(name) || name == "." || name == ".." {
|
||||
return fmt.Errorf("netmount: invalid share name %q", name)
|
||||
}
|
||||
where := NetworkMountRoot + "/" + name
|
||||
mountUnit, err := UnitNameForMount(where)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
automountUnit := strings.TrimSuffix(mountUnit, ".mount") + ".automount"
|
||||
|
||||
// Stop the automount first (so it can't re-trigger the mount), then the mount. Tolerate "not loaded".
|
||||
_ = h.run(ctx, h.bins.Systemctl, "stop", "--", automountUnit)
|
||||
_ = h.run(ctx, h.bins.Systemctl, "disable", "--", automountUnit)
|
||||
_ = h.run(ctx, h.bins.Systemctl, "stop", "--", mountUnit)
|
||||
|
||||
destAuto := filepath.Join(h.unitDir, automountUnit)
|
||||
destMount := filepath.Join(h.unitDir, mountUnit)
|
||||
if err := h.run(ctx, "/usr/bin/rm", "-f", destAuto); err != nil {
|
||||
return fmt.Errorf("netmount: removing %s: %w", automountUnit, err)
|
||||
}
|
||||
if err := h.run(ctx, "/usr/bin/rm", "-f", destMount); err != nil {
|
||||
return fmt.Errorf("netmount: removing %s: %w", mountUnit, err)
|
||||
}
|
||||
_ = os.Remove(filepath.Join(h.stageDir, automountUnit))
|
||||
_ = os.Remove(filepath.Join(h.stageDir, mountUnit))
|
||||
if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil {
|
||||
return fmt.Errorf("netmount: daemon-reload: %w", err)
|
||||
}
|
||||
h.logger.Info("netmount: removed network mount", "name", name, "where", where)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListNetworkMounts enumerates the installed network-storage units and reports per-share liveness. It
|
||||
// reads the (world-readable) unit dir + /proc/mounts and TCP-probes each NAS endpoint with a short
|
||||
// timeout — it NEVER stat()s the (possibly EIO/D-state) mountpoint, so a black-holed NAS cannot wedge
|
||||
// this call. Best-effort: an unreadable unit dir yields an empty list.
|
||||
func (h *SudoHostOps) ListNetworkMounts(ctx context.Context) ([]NetworkMountStatus, error) {
|
||||
entries, err := os.ReadDir(h.unitDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("netmount: reading unit dir: %w", err)
|
||||
}
|
||||
mounts := h.mountedFSTypes()
|
||||
var out []NetworkMountStatus
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".mount") {
|
||||
continue // the .mount unit carries the What/Type; the .automount mirrors Where only
|
||||
}
|
||||
data, rerr := os.ReadFile(filepath.Join(h.unitDir, e.Name()))
|
||||
if rerr != nil {
|
||||
continue
|
||||
}
|
||||
proto, server, export, where, ok := parseNetworkMountUnit(string(data))
|
||||
if !ok {
|
||||
continue // not one of ours (or a drive by-uuid mount)
|
||||
}
|
||||
st := NetworkMountStatus{
|
||||
Name: strings.TrimPrefix(where, NetworkMountRoot+"/"),
|
||||
Protocol: proto,
|
||||
Server: server,
|
||||
Export: export,
|
||||
Where: where,
|
||||
Configured: true,
|
||||
Mounted: isNetworkMounted(mounts[where]),
|
||||
Reachable: endpointReachable(netEndpoint(proto, server)),
|
||||
}
|
||||
st.Health = networkHealth(st.Reachable, st.Mounted)
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mountedFSTypes maps each active mountpoint to its filesystem type (from /proc/mounts: field 2 = where,
|
||||
// field 3 = fstype). Best-effort.
|
||||
func (h *SudoHostOps) mountedFSTypes() map[string]string {
|
||||
out := map[string]string{}
|
||||
data, err := os.ReadFile("/proc/mounts")
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 3 {
|
||||
out[f[1]] = f[2]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isNetworkMounted reports whether the recorded fstype at a mountpoint is a real network mount (not the
|
||||
// autofs trigger). An idle automount shows fstype "autofs" (or nothing) → not mounted; an active mount
|
||||
// shows nfs4/nfs/cifs.
|
||||
func isNetworkMounted(fstype string) bool {
|
||||
switch fstype {
|
||||
case "nfs", "nfs4", "cifs":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// endpointReachable TCP-dials a NAS endpoint with a short timeout (the liveness probe that never touches
|
||||
// the mount). "" endpoint → not reachable.
|
||||
func endpointReachable(endpoint string) bool {
|
||||
if endpoint == "" {
|
||||
return false
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", endpoint, netReachTimeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- Unit rendering: the exact, locked SPIKE option sets + the +100000 recipe -----------------------
|
||||
|
||||
func TestNetworkMount_NFSUnitRendering(t *testing.T) {
|
||||
spec := NetworkMountSpec{
|
||||
Name: "media", Protocol: ProtocolNFS, Server: "192.168.0.180", Export: "/srv/nas-sim/media",
|
||||
MappedUID: 1000, MappedGID: 1000,
|
||||
}
|
||||
if err := ValidateNetworkMountSpec(spec); err != nil {
|
||||
t.Fatalf("valid NFS spec rejected: %v", err)
|
||||
}
|
||||
mu := renderNetworkMountUnit(spec)
|
||||
|
||||
wantOpts := "Options=vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev"
|
||||
for _, want := range []string{
|
||||
netUnitMarker,
|
||||
"What=192.168.0.180:/srv/nas-sim/media",
|
||||
"Where=/mnt/felhom-drives/media",
|
||||
"Type=nfs4",
|
||||
wantOpts,
|
||||
} {
|
||||
if !strings.Contains(mu, want) {
|
||||
t.Errorf("NFS .mount missing %q:\n%s", want, mu)
|
||||
}
|
||||
}
|
||||
// soft is the failure-isolation knob; a default hard mount wedges — it must NEVER appear.
|
||||
if strings.Contains(mu, "hard") {
|
||||
t.Errorf("NFS mount must not be hard:\n%s", mu)
|
||||
}
|
||||
// NFS uid mapping is the EXPORT's job (anonuid=101000) — the client mount carries no uid/gid.
|
||||
if strings.Contains(mu, "uid=") || strings.Contains(mu, "anonuid") {
|
||||
t.Errorf("NFS client mount must not carry uid options (server-side squash):\n%s", mu)
|
||||
}
|
||||
|
||||
au := renderNetworkAutomountUnit(spec)
|
||||
for _, want := range []string{
|
||||
netUnitMarker,
|
||||
"Where=/mnt/felhom-drives/media",
|
||||
"[Automount]",
|
||||
"TimeoutIdleSec=60",
|
||||
"WantedBy=multi-user.target",
|
||||
} {
|
||||
if !strings.Contains(au, want) {
|
||||
t.Errorf("NFS .automount missing %q:\n%s", want, au)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNetworkMount_SMBUnitRendering_Plus100000 is the headline +100000 companion: a container uid/gid of
|
||||
// 1000 MUST render the SMB client mount with uid=101000/gid=101000 (host = container+100000). A naive
|
||||
// +0 implementation (uid=1000) would FAIL here — and lands as nobody:nogroup in the guest (not writable).
|
||||
func TestNetworkMount_SMBUnitRendering_Plus100000(t *testing.T) {
|
||||
spec := NetworkMountSpec{
|
||||
Name: "media", Protocol: ProtocolSMB, Server: "nas.local", Export: "media",
|
||||
MappedUID: 1000, MappedGID: 1000, CredsRef: "/var/lib/felhom-agent/smb-creds/media.cred",
|
||||
}
|
||||
if err := ValidateNetworkMountSpec(spec); err != nil {
|
||||
t.Fatalf("valid SMB spec rejected: %v", err)
|
||||
}
|
||||
mu := renderNetworkMountUnit(spec)
|
||||
|
||||
wantOpts := "Options=vers=3.0,credentials=/var/lib/felhom-agent/smb-creds/media.cred,uid=101000,gid=101000,forceuid,forcegid,file_mode=0664,dir_mode=0775,_netdev"
|
||||
for _, want := range []string{
|
||||
netUnitMarker,
|
||||
"What=//nas.local/media",
|
||||
"Where=/mnt/felhom-drives/media",
|
||||
"Type=cifs",
|
||||
wantOpts,
|
||||
} {
|
||||
if !strings.Contains(mu, want) {
|
||||
t.Errorf("SMB .mount missing %q:\n%s", want, mu)
|
||||
}
|
||||
}
|
||||
// THE companion red-proof: the +100000 offset must be applied; a +0 impl emits uid=1000.
|
||||
if strings.Contains(mu, "uid=1000,") || strings.Contains(mu, "gid=1000,") {
|
||||
t.Errorf("SMB mount used the raw container id, not +100000 (the documented non-writable trap):\n%s", mu)
|
||||
}
|
||||
// Modes must be PLAIN octal (0664/0775), never the setgid 2775 the drive userdata uses.
|
||||
if strings.Contains(mu, "2775") {
|
||||
t.Errorf("SMB dir_mode must be plain octal 0775, not setgid 2775:\n%s", mu)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkMount_HostOffset(t *testing.T) {
|
||||
s := NetworkMountSpec{MappedUID: 1000, MappedGID: 1000}
|
||||
if s.HostUID() != 101000 || s.HostGID() != 101000 {
|
||||
t.Fatalf("HostUID/HostGID = %d/%d, want 101000/101000", s.HostUID(), s.HostGID())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validation -------------------------------------------------------------------------------------
|
||||
|
||||
func TestValidateNetworkMountSpec(t *testing.T) {
|
||||
base := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000}
|
||||
good := func(mut func(*NetworkMountSpec)) NetworkMountSpec { s := base; mut(&s); return s }
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
spec NetworkMountSpec
|
||||
}{
|
||||
{"empty name", good(func(s *NetworkMountSpec) { s.Name = "" })},
|
||||
{"name traversal", good(func(s *NetworkMountSpec) { s.Name = ".." })},
|
||||
{"name with slash", good(func(s *NetworkMountSpec) { s.Name = "a/b" })},
|
||||
{"name with space", good(func(s *NetworkMountSpec) { s.Name = "a b" })},
|
||||
{"bad protocol", good(func(s *NetworkMountSpec) { s.Protocol = "afp" })},
|
||||
{"server metachar", good(func(s *NetworkMountSpec) { s.Server = "a;rm -rf" })},
|
||||
{"empty server", good(func(s *NetworkMountSpec) { s.Server = "" })},
|
||||
{"nfs relative export", good(func(s *NetworkMountSpec) { s.Export = "srv/media" })},
|
||||
{"nfs export traversal", good(func(s *NetworkMountSpec) { s.Export = "/srv/../etc" })},
|
||||
{"uid out of range", good(func(s *NetworkMountSpec) { s.MappedUID = 70000 })},
|
||||
{"negative gid", good(func(s *NetworkMountSpec) { s.MappedGID = -1 })},
|
||||
{"smb without creds", good(func(s *NetworkMountSpec) { s.Protocol = ProtocolSMB; s.Export = "media"; s.CredsRef = "" })},
|
||||
{"smb bad share name", good(func(s *NetworkMountSpec) { s.Protocol = ProtocolSMB; s.Export = "a/b"; s.CredsRef = "/x/y.cred" })},
|
||||
}
|
||||
for _, c := range bad {
|
||||
if err := ValidateNetworkMountSpec(c.spec); err == nil {
|
||||
t.Errorf("%s: expected rejection, got nil", c.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Good specs.
|
||||
if err := ValidateNetworkMountSpec(base); err != nil {
|
||||
t.Errorf("valid NFS spec rejected: %v", err)
|
||||
}
|
||||
smb := good(func(s *NetworkMountSpec) { s.Protocol = ProtocolSMB; s.Export = "media"; s.CredsRef = "/var/lib/felhom-agent/smb-creds/media.cred" })
|
||||
if err := ValidateNetworkMountSpec(smb); err != nil {
|
||||
t.Errorf("valid SMB spec rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Role gate: bulk-userdata namespace only --------------------------------------------------------
|
||||
|
||||
func TestNetworkMountRole(t *testing.T) {
|
||||
userdata := []string{"/mnt/felhom-drives/media", "/mnt/felhom-drives/photos", NetworkMountRoot}
|
||||
for _, p := range userdata {
|
||||
if NetworkMountRole(p) != RoleUserData {
|
||||
t.Errorf("%s should be user-data", p)
|
||||
}
|
||||
}
|
||||
system := []string{"/etc/passwd", "/srv/system/x", "/mnt/felhom-drivesX/y", "/var/lib/felhom-agent"}
|
||||
for _, p := range system {
|
||||
if NetworkMountRole(p) != RoleSystem {
|
||||
t.Errorf("%s should be system (refused)", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Liveness parse + health ------------------------------------------------------------------------
|
||||
|
||||
func TestParseNetworkMountUnit_RoundTrip(t *testing.T) {
|
||||
nfs := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000}
|
||||
proto, server, export, where, ok := parseNetworkMountUnit(renderNetworkMountUnit(nfs))
|
||||
if !ok || proto != "nfs" || server != "10.0.0.5" || export != "/srv/media" || where != "/mnt/felhom-drives/media" {
|
||||
t.Errorf("NFS parse = %q %q %q %q ok=%v", proto, server, export, where, ok)
|
||||
}
|
||||
smb := NetworkMountSpec{Name: "vids", Protocol: ProtocolSMB, Server: "nas", Export: "vids", MappedUID: 1000, MappedGID: 1000, CredsRef: "/x/y.cred"}
|
||||
proto, server, export, where, ok = parseNetworkMountUnit(renderNetworkMountUnit(smb))
|
||||
if !ok || proto != "smb" || server != "nas" || export != "vids" || where != "/mnt/felhom-drives/vids" {
|
||||
t.Errorf("SMB parse = %q %q %q %q ok=%v", proto, server, export, where, ok)
|
||||
}
|
||||
// A non-network unit (a drive by-uuid .mount) must NOT parse as a network mount.
|
||||
drive := renderMountUnit(MountSpec{Name: "usb", UUID: "0fc63daf-8483-4772-8e79-3d69d8477de4", Where: "/mnt/felhom-usb", FSType: "ext4"})
|
||||
if _, _, _, _, ok := parseNetworkMountUnit(drive); ok {
|
||||
t.Errorf("a drive by-uuid unit must not parse as a network mount")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkHealth(t *testing.T) {
|
||||
cases := []struct {
|
||||
reachable, mounted bool
|
||||
want string
|
||||
}{
|
||||
{true, true, NetHealthOK},
|
||||
{true, false, NetHealthIdle},
|
||||
{false, true, NetHealthUnreachable},
|
||||
{false, false, NetHealthUnreachable},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := networkHealth(c.reachable, c.mounted); got != c.want {
|
||||
t.Errorf("networkHealth(%v,%v)=%q want %q", c.reachable, c.mounted, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNetworkMounted(t *testing.T) {
|
||||
for _, fs := range []string{"nfs", "nfs4", "cifs"} {
|
||||
if !isNetworkMounted(fs) {
|
||||
t.Errorf("%s should count as mounted", fs)
|
||||
}
|
||||
}
|
||||
for _, fs := range []string{"autofs", "", "ext4", "tmpfs"} {
|
||||
if isNetworkMounted(fs) {
|
||||
t.Errorf("%s must NOT count as a real network mount (idle automount = autofs)", fs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scenario D: the drive machinery ignores a NAS mount (guard + companion red-proof) --------------
|
||||
|
||||
// TestNetMount_DriveMachineryGuard proves parseFelhomMountUnit (the host-reboot drive re-assert's
|
||||
// classifier) REFUSES a network unit, so a NAS mount never enters the drive lifecycle. The companion
|
||||
// red-proof: the SAME unit content WITHOUT the network marker but WITH a by-uuid What parses as a drive
|
||||
// — i.e. it is the netUnitMarker guard (not luck) that keeps the NAS out of the drive machinery.
|
||||
func TestNetMount_DriveMachineryGuard(t *testing.T) {
|
||||
// A real network .mount unit: parseFelhomMountUnit must reject it.
|
||||
netUnit := renderNetworkMountUnit(NetworkMountSpec{
|
||||
Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000,
|
||||
})
|
||||
if _, ok := parseFelhomMountUnit(netUnit); ok {
|
||||
t.Fatalf("a NAS network unit must NOT be classified as a drive by parseFelhomMountUnit:\n%s", netUnit)
|
||||
}
|
||||
|
||||
// Contrived worst case: a unit carrying the network marker AND a by-uuid What (the shape that WOULD
|
||||
// otherwise parse as a drive). The guard must still refuse it.
|
||||
contrived := "# " + netUnitMarker + "\n[Unit]\nDescription=Felhom storage mount x\n[Mount]\n" +
|
||||
"What=" + byUUIDDir + "/0fc63daf-8483-4772-8e79-3d69d8477de4\nWhere=/mnt/felhom-drives/x\nType=nfs4\n"
|
||||
if _, ok := parseFelhomMountUnit(contrived); ok {
|
||||
t.Fatalf("the netUnitMarker guard must refuse a by-uuid-shaped network unit")
|
||||
}
|
||||
|
||||
// COMPANION RED-PROOF: identical content but with the drive marker instead of the network marker
|
||||
// DOES parse as a drive — confirming the guard is the discriminator, not an accident of shape.
|
||||
driveShaped := strings.Replace(contrived, "# "+netUnitMarker, "# "+felhomUnitMarker, 1)
|
||||
if spec, ok := parseFelhomMountUnit(driveShaped); !ok || spec.UUID == "" {
|
||||
t.Fatalf("control: a by-uuid unit with ONLY the drive marker should parse as a drive (ok=%v uuid=%q)", ok, spec.UUID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- SudoHostOps command sequence (Linux only — the unit filename embeds an escaped '-' = backslash) -
|
||||
|
||||
func TestEnsureNetworkMount_Commands(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("systemd-escaped unit filename contains a backslash; staging is exercised on the Linux build server")
|
||||
}
|
||||
ctx := context.Background()
|
||||
stage, unitDir := t.TempDir(), t.TempDir()
|
||||
rr := &recordingRunner{}
|
||||
ops := NewSudoHostOps(SudoHostOpsConfig{
|
||||
Runner: rr, Bins: Binaries{}.withDefaults(), UnitDir: unitDir, StageDir: stage, Logger: quietLogger(),
|
||||
})
|
||||
spec := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000}
|
||||
if err := ops.EnsureNetworkMount(ctx, spec); err != nil {
|
||||
t.Fatalf("EnsureNetworkMount: %v", err)
|
||||
}
|
||||
// Expect: mkdir, install(.mount), install(.automount), daemon-reload, enable --now <automount>.
|
||||
var sawMkdir, sawEnableAutomount, sawReload bool
|
||||
installs := 0
|
||||
for _, c := range rr.calls {
|
||||
joined := strings.Join(c, " ")
|
||||
switch {
|
||||
case strings.Contains(joined, "mkdir") && strings.Contains(joined, "/mnt/felhom-drives/media"):
|
||||
sawMkdir = true
|
||||
case strings.Contains(joined, "install"):
|
||||
installs++
|
||||
case strings.Contains(joined, "daemon-reload"):
|
||||
sawReload = true
|
||||
case strings.Contains(joined, "enable") && strings.Contains(joined, "--now") && strings.Contains(joined, ".automount"):
|
||||
sawEnableAutomount = true
|
||||
}
|
||||
}
|
||||
if !sawMkdir || installs != 2 || !sawReload || !sawEnableAutomount {
|
||||
t.Fatalf("unexpected command sequence (mkdir=%v installs=%d reload=%v enableAutomount=%v): %v",
|
||||
sawMkdir, installs, sawReload, sawEnableAutomount, rr.calls)
|
||||
}
|
||||
// The .automount is enabled; the .mount is NOT (automount triggers it).
|
||||
for _, c := range rr.calls {
|
||||
joined := strings.Join(c, " ")
|
||||
if strings.Contains(joined, "enable") && strings.Contains(joined, ".mount") && !strings.Contains(joined, ".automount") {
|
||||
t.Errorf("the .mount unit must NOT be enabled (automount drives it): %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Staged units carry the right bodies.
|
||||
autoName := "mnt-felhom\\x2ddrives-media.automount"
|
||||
body, err := os.ReadFile(filepath.Join(stage, autoName))
|
||||
if err != nil {
|
||||
t.Fatalf("staged automount not written: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), "[Automount]") {
|
||||
t.Errorf("staged automount missing [Automount]:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNetworkMount_RejectsBadSpec(t *testing.T) {
|
||||
ops := NewSudoHostOps(SudoHostOpsConfig{Runner: &recordingRunner{}, Bins: Binaries{}.withDefaults(), UnitDir: t.TempDir(), StageDir: t.TempDir(), Logger: quietLogger()})
|
||||
rr := ops.runner.(*recordingRunner)
|
||||
if err := ops.EnsureNetworkMount(context.Background(), NetworkMountSpec{Name: "..", Protocol: ProtocolNFS, Server: "x", Export: "/y"}); err == nil {
|
||||
t.Fatal("a bad spec must be refused")
|
||||
}
|
||||
if len(rr.calls) != 0 {
|
||||
t.Fatalf("a refused spec must construct ZERO commands, got: %v", rr.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveNetworkMount_Commands(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("systemd-escaped unit filename contains a backslash; exercised on the Linux build server")
|
||||
}
|
||||
ctx := context.Background()
|
||||
rr := &recordingRunner{}
|
||||
ops := NewSudoHostOps(SudoHostOpsConfig{Runner: rr, Bins: Binaries{}.withDefaults(), UnitDir: t.TempDir(), StageDir: t.TempDir(), Logger: quietLogger()})
|
||||
if err := ops.RemoveNetworkMount(ctx, "media"); err != nil {
|
||||
t.Fatalf("RemoveNetworkMount: %v", err)
|
||||
}
|
||||
var stopAuto, disableAuto, rmCount, reload bool
|
||||
rms := 0
|
||||
for _, c := range rr.calls {
|
||||
j := strings.Join(c, " ")
|
||||
switch {
|
||||
case strings.Contains(j, "stop") && strings.Contains(j, ".automount"):
|
||||
stopAuto = true
|
||||
case strings.Contains(j, "disable") && strings.Contains(j, ".automount"):
|
||||
disableAuto = true
|
||||
case strings.Contains(j, "rm"):
|
||||
rms++
|
||||
case strings.Contains(j, "daemon-reload"):
|
||||
reload = true
|
||||
}
|
||||
}
|
||||
rmCount = rms == 2
|
||||
if !stopAuto || !disableAuto || !rmCount || !reload {
|
||||
t.Fatalf("unexpected remove sequence (stopAuto=%v disableAuto=%v rm=%d reload=%v): %v", stopAuto, disableAuto, rms, reload, rr.calls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user