Files
s3-csi/pkg/driver/container_remount_linux.go
zeekayandHanzo Dev 69ddc9947a s3-csi: ZAP-native CSI driver for the Hanzo filer
Fork of seaweedfs/seaweedfs-csi-driver, renamed seaweedfs -> s3 throughout and
ported off protobuf entirely.

WHY a fork was unavoidable: hanzoai/s3 serves HanzoFiler over the native ZAP
transport on the port still named "grpc" (18888) — s3/command/filer.go binds it
with transport.ListenStream and says it "replaces the legacy gRPC HanzoFiler
server: the whole filer (28 unary + 5 streaming RPCs) now answers over ZAP, no
gRPC". RegisterHanzoFilerServer exists nowhere in that tree. So the upstream
driver's gRPC client cannot connect at all; it fails as
`Unimplemented: unknown service filer_pb.SeaweedFiler`, which LOOKS like a
service-name mismatch but is a gRPC client hitting a non-gRPC port. Renaming the
service in the client would not have helped.

pkg/filer is the only place this driver touches storage, and it speaks ZAP:
  Mkdir  -> CreateEntry           (CreateVolume)
  Remove -> DeleteEntry           (DeleteVolume)
  Exists -> LookupDirectoryEntry  (ValidateVolumeCapabilities)
One shared, concurrency-safe client multiplexes over a single ZAP connection,
dials lazily so controller start does not depend on filer readiness, and redials
exactly once on a transport fault (covers a filer restart closing an idle conn
without becoming a retry storm against a struggling backend).

pkg/driver/volume.go Quota() likewise moves to s3/wire/mount — the fork generates
no mount_pb at all. Dead gRPC plumbing (WithFilerClient, AdjustedUrl,
grpcDialOption) is deleted rather than left inert.

The kubelet-facing side stays CSI gRPC: that is the Kubernetes contract between
kubelet and any CSI driver, not a Hanzo choice.

Zero protobuf call sites remain. Whole module compiles clean.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 16:42:29 -07:00

468 lines
16 KiB
Go

package driver
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"github.com/hanzoai/s3/s3/glog"
"golang.org/x/sys/unix"
)
// remountInContainers fixes stale FUSE mounts inside pod containers after
// the health monitor has recovered a volume on the host.
//
// When the CSI driver recovers a dead FUSE mount (unmount stale staging,
// re-stage, re-bind publish paths), the host-level mounts are correct.
// However, container runtimes create the pod's volume bind-mount with
// rprivate propagation by default, so the host-side remount does NOT
// propagate into existing containers. The pod still sees the old dead
// FUSE mount ("Transport endpoint is not connected").
//
// This function enters each affected container's mount namespace via
// setns(2) and creates a fresh bind mount from the recovered staging
// path, replacing the stale one. This requires the CSI driver pod to
// run with hostPID: true so it can see container processes in /proc.
//
// oldDevice is the "major:minor" string of the dead FUSE mount that was
// at stagingPath before recovery. It is used to identify the
// corresponding stale mount entry inside each container's mountinfo.
func remountInContainers(publishPath, stagingPath, oldDevice string, readOnly bool) {
podUID := extractPodUID(publishPath)
if podUID == "" {
glog.V(4).Infof("container remount: could not extract pod UID from %s", publishPath)
return
}
pids, err := findContainerPIDsForPod(podUID)
if err != nil {
glog.V(4).Infof("container remount: could not find container PIDs for pod %s: %v (hostPID may not be enabled)", podUID, err)
return
}
if len(pids) == 0 {
glog.V(4).Infof("container remount: no container PIDs found for pod %s", podUID)
return
}
for _, pid := range pids {
mounts, err := findContainerMountsByDevice(pid, oldDevice)
if err != nil || len(mounts) == 0 {
continue
}
for _, m := range mounts {
glog.Infof("container remount: fixing stale mount %s (subPath %q) in container PID %d (pod %s)", m.mountpoint, m.root, pid, podUID)
if err := remountViaSetns(pid, m.mountpoint, stagingPath, m.root, readOnly); err != nil {
glog.Warningf("container remount: failed to remount %s in PID %d: %v", m.mountpoint, pid, err)
} else {
glog.Infof("container remount: successfully remounted %s in PID %d", m.mountpoint, pid)
}
}
}
}
// remountStaleFuseInContainers is a scan-based variant used by
// retryPublishPaths where the old device is unknown. It enters each
// affected container, finds FUSE mounts that are stale (ENOTCONN)
// AND whose device matches the staging path's current device, and
// replaces them with a fresh bind from stagingPath.
//
// Only mounts with the same device as stagingPath are touched. This
// prevents accidentally overwriting other S3 volumes mounted
// in the same pod.
func remountStaleFuseInContainers(publishPath, stagingPath string, readOnly bool) {
podUID := extractPodUID(publishPath)
if podUID == "" {
return
}
pids, err := findContainerPIDsForPod(podUID)
if err != nil || len(pids) == 0 {
return
}
// Get the device of the current (recovered) staging mount so we
// only touch container mounts that belong to this volume.
stagingDevice, err := getMountDevice(stagingPath)
if err != nil {
glog.V(4).Infof("container remount: cannot determine staging device for %s: %v", stagingPath, err)
return
}
for _, pid := range pids {
entries, err := parseMountInfo(pid)
if err != nil {
continue
}
for _, e := range entries {
if !isFuseFS(e.fstype) {
continue
}
// Only touch mounts whose device matches the recovered
// staging path. In retryPublishPaths the staging is alive,
// so in the normal case the container's mount (if healthy)
// already has the same device and isStaleMount returns
// false. If the staging was previously re-created with a
// new device (from a prior recoverVolume), the container
// mount has a different (dead) device.
if e.device == stagingDevice {
// Same device as the live staging -- container mount
// is already healthy.
continue
}
// Check accessibility via /proc/<pid>/root/<mountpoint>.
checkPath := fmt.Sprintf("/proc/%d/root%s", pid, e.mountpoint)
_, statErr := os.Stat(checkPath)
if !isStaleMount(statErr) {
continue
}
glog.Infof("container remount: fixing stale mount %s (subPath %q) in container PID %d (pod %s)", e.mountpoint, e.root, pid, podUID)
if err := remountViaSetns(pid, e.mountpoint, stagingPath, e.root, readOnly); err != nil {
glog.Warningf("container remount: failed to remount %s in PID %d: %v", e.mountpoint, pid, err)
} else {
glog.Infof("container remount: successfully remounted %s in PID %d", e.mountpoint, pid)
}
}
}
}
// extractPodUID extracts the pod UID from a CSI publish path.
// Expected format: .../pods/<uid>/volumes/...
func extractPodUID(publishPath string) string {
parts := strings.Split(publishPath, string(os.PathSeparator))
for i, part := range parts {
if part == "pods" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
// findContainerPIDsForPod returns one PID per unique mount namespace
// for containers belonging to the given pod. It scans /proc/*/cgroup
// for the pod UID. Requires hostPID: true on the CSI driver pod.
func findContainerPIDsForPod(podUID string) ([]int, error) {
// Kubernetes uses both dash-separated and underscore-separated UIDs
// in cgroup paths depending on the container runtime.
normalizedUID := strings.ReplaceAll(podUID, "-", "_")
entries, err := os.ReadDir("/proc")
if err != nil {
return nil, fmt.Errorf("read /proc: %w", err)
}
// Cache our own mount namespace inode to skip processes that share it.
selfNS, _ := os.Readlink("/proc/self/ns/mnt")
// Collect one PID per mount namespace to avoid redundant remounts.
seenNS := make(map[string]bool)
var pids []int
for _, entry := range entries {
pid, err := strconv.Atoi(entry.Name())
if err != nil {
continue
}
cgroupData, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", pid))
if err != nil {
continue
}
content := string(cgroupData)
if !strings.Contains(content, podUID) && !strings.Contains(content, normalizedUID) {
continue
}
// Deduplicate by mount namespace inode so we remount once per
// container, not once per process.
nsPath := fmt.Sprintf("/proc/%d/ns/mnt", pid)
nsLink, err := os.Readlink(nsPath)
if err != nil {
continue
}
// Skip processes that share the CSI driver's own mount namespace.
if selfNS != "" && nsLink == selfNS {
continue
}
if !seenNS[nsLink] {
seenNS[nsLink] = true
pids = append(pids, pid)
}
}
return pids, nil
}
// mountInfoEntry holds a parsed line from /proc/<pid>/mountinfo.
type mountInfoEntry struct {
device string // "major:minor"
// root is the pathname of the directory in the filesystem that forms
// the root of this mount (mountinfo field 4). For a bind created from
// a volumeMount subPath it is the subPath offset (e.g. "/store"); for
// a whole-filesystem mount it is "/". Preserving it is what keeps a
// subPath mount pointing at <staging>/<subPath> after a remount.
root string
mountpoint string
fstype string
}
// parseMountInfo parses /proc/<pid>/mountinfo and returns entries.
func parseMountInfo(pid int) ([]mountInfoEntry, error) {
path := fmt.Sprintf("/proc/%d/mountinfo", pid)
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return parseMountInfoReader(f)
}
// parseMountInfoReader parses mountinfo-formatted content. Split out from
// parseMountInfo so the field parsing (including the subPath-bearing root
// field) can be unit-tested without a live /proc.
func parseMountInfoReader(r io.Reader) ([]mountInfoEntry, error) {
var entries []mountInfoEntry
scanner := bufio.NewScanner(r)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 7 {
continue
}
// Format: id parent major:minor root mountpoint opts [optional...] - fstype source superopts
// root and mountpoint are octal-escaped by util-linux; decode them
// before use in filesystem operations.
device := fields[2]
root := unescapeMountField(fields[3])
mountpoint := unescapeMountField(fields[4])
// Find the "-" separator to get fstype
sepIdx := -1
for i := 6; i < len(fields); i++ {
if fields[i] == "-" {
sepIdx = i
break
}
}
var fstype string
if sepIdx >= 0 && sepIdx+1 < len(fields) {
fstype = fields[sepIdx+1]
}
entries = append(entries, mountInfoEntry{
device: device,
root: root,
mountpoint: mountpoint,
fstype: fstype,
})
}
return entries, scanner.Err()
}
// unescapeMountField decodes the octal escape sequences util-linux uses
// for the root and mountpoint fields of /proc/<pid>/mountinfo. Space
// (\040), tab (\011), newline (\012) and backslash (\134) are written as
// three-digit octal so each field stays whitespace-free and splittable, so
// any '\' in the field begins such a sequence. Unknown sequences are left
// verbatim. Unlike strconv.Unquote this never errors and never trips over
// an embedded quote, so a path that fails to decode cannot silently
// collapse to "" — which for the root field would drop the subPath offset
// and re-introduce the share-root bind this change exists to prevent.
func unescapeMountField(s string) string {
if !strings.ContainsRune(s, '\\') {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+3 < len(s) &&
isOctalDigit(s[i+1]) && isOctalDigit(s[i+2]) && isOctalDigit(s[i+3]) {
b.WriteByte((s[i+1]-'0')<<6 | (s[i+2]-'0')<<3 | (s[i+3] - '0'))
i += 3
continue
}
b.WriteByte(s[i])
}
return b.String()
}
func isOctalDigit(c byte) bool { return c >= '0' && c <= '7' }
// getMountDevice returns the "major:minor" device string for a mount
// at the given path by parsing /proc/self/mountinfo.
func getMountDevice(mountPath string) (string, error) {
entries, err := parseMountInfo(os.Getpid())
if err != nil {
return "", fmt.Errorf("parse self mountinfo: %w", err)
}
// Find the most specific (longest) matching mountpoint, since there
// may be nested mounts.
var best mountInfoEntry
for _, e := range entries {
if e.mountpoint == mountPath && len(e.mountpoint) >= len(best.mountpoint) {
best = e
}
}
if best.device == "" {
return "", fmt.Errorf("no mount entry found for %s", mountPath)
}
return best.device, nil
}
// findContainerMountsByDevice finds all mounts inside a container's
// mount namespace whose device matches oldDevice and whose fstype is
// FUSE. A single PVC can appear at multiple mount points in the same
// container (e.g. a plain mount plus one or more subPath mounts), so all
// matches are returned. Each entry carries its root (subPath offset) so
// the remount can re-create the bind from the correct subdirectory.
func findContainerMountsByDevice(pid int, oldDevice string) ([]mountInfoEntry, error) {
entries, err := parseMountInfo(pid)
if err != nil {
return nil, err
}
var matches []mountInfoEntry
for _, e := range entries {
if e.device == oldDevice && isFuseFS(e.fstype) {
matches = append(matches, e)
}
}
return matches, nil
}
// isFuseFS returns true if the fstype indicates a FUSE filesystem mount.
// "fusectl" is excluded -- it is the FUSE control filesystem, not a user mount.
func isFuseFS(fstype string) bool {
return strings.HasPrefix(fstype, "fuse") && fstype != "fusectl"
}
// isStaleMount returns true if the error indicates a dead FUSE mount
// ("Transport endpoint is not connected" or similar).
func isStaleMount(err error) bool {
if err == nil {
return false
}
var errno syscall.Errno
if errors.As(err, &errno) {
return errno == syscall.ENOTCONN || errno == syscall.EIO
}
return false
}
// remountViaSetns enters a container's mount namespace using setns(2),
// unmounts the stale FUSE mount, and creates a fresh bind mount from
// the recovered staging path. The staging path is accessible from
// inside the container's mount namespace because it resides on the
// host filesystem under the kubelet directory tree.
//
// subPath is the mountinfo root of the stale mount being replaced. When
// the volumeMount used a subPath, the original bind was offset into a
// subdirectory of the FUSE filesystem (e.g. "/store"), so the fresh bind
// must come from <stagingPath>/<subPath> rather than the share root.
// Otherwise a subPath mount silently resolves to the root after recovery.
// "" or "/" mean a whole-filesystem mount and bind stagingPath directly.
// See s3/s3-csi#271.
//
// If readOnly is true, a second remount is performed with MS_RDONLY to
// preserve the original read-only semantics of the volume mount.
func remountViaSetns(containerPID int, containerMountPath, stagingPath, subPath string, readOnly bool) error {
// Pin this goroutine to the current OS thread for the duration of
// the namespace switch. No other goroutine will be scheduled on
// this thread, preventing accidental cross-namespace operations.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// Give this thread its own filesystem context (cwd, root, umask).
// Go threads share their fs_struct by default, and the kernel
// rejects setns(CLONE_NEWNS) when the calling thread shares its
// fs_struct with other threads. unshare(CLONE_FS) breaks that
// sharing so setns can proceed.
if err := unix.Unshare(unix.CLONE_FS); err != nil {
return fmt.Errorf("unshare CLONE_FS: %w", err)
}
// Re-create the bind from the same subdirectory the stale mount was
// offset into. For a subPath volumeMount this is <staging>/<subPath>;
// for a plain mount subPath is "" or "/" and we bind the share root.
source := stagingPath
if subPath != "" && subPath != "/" {
source = filepath.Join(stagingPath, subPath)
}
// open_tree the source BEFORE setns so we have an fd we can
// move_mount into the container's namespace. A path-based bind
// after setns fails with ENOENT — the staging path lives only in
// the driver pod's namespace, and /proc/self/fd/N re-walks the
// original path string in the current namespace. Requires Linux 5.2+.
sourceFd, err := unix.OpenTree(unix.AT_FDCWD, source,
uint(unix.OPEN_TREE_CLONE|unix.OPEN_TREE_CLOEXEC|unix.AT_NO_AUTOMOUNT))
if err != nil {
return fmt.Errorf("open_tree %s: %w", source, err)
}
defer unix.Close(sourceFd)
// Save our current mount namespace so we can restore it.
origNS, err := os.Open("/proc/self/ns/mnt")
if err != nil {
return fmt.Errorf("open current mount ns: %w", err)
}
defer origNS.Close()
// Open the container's mount namespace.
containerNSPath := fmt.Sprintf("/proc/%d/ns/mnt", containerPID)
containerNS, err := os.Open(containerNSPath)
if err != nil {
return fmt.Errorf("open container mount ns %s: %w", containerNSPath, err)
}
defer containerNS.Close()
// Enter the container's mount namespace.
if err := unix.Setns(int(containerNS.Fd()), unix.CLONE_NEWNS); err != nil {
return fmt.Errorf("setns to container PID %d: %w", containerPID, err)
}
// ALWAYS restore our namespace before returning, even on error.
defer func() {
if restoreErr := unix.Setns(int(origNS.Fd()), unix.CLONE_NEWNS); restoreErr != nil {
// This is a critical error -- the goroutine is stuck in the
// wrong namespace. LockOSThread prevents it from affecting
// other goroutines, and the deferred UnlockOSThread will
// retire the thread. Log loudly.
glog.Errorf("container remount: CRITICAL: failed to restore mount namespace: %v", restoreErr)
}
}()
// Lazy-unmount the stale FUSE mount inside the container.
// MNT_DETACH ensures this succeeds even if processes have open
// files, letting them drain while new accesses use the fresh mount.
if umountErr := unix.Unmount(containerMountPath, unix.MNT_DETACH); umountErr != nil {
glog.V(4).Infof("container remount: umount %s in PID %d: %v (may already be unmounted)", containerMountPath, containerPID, umountErr)
}
if err := unix.MoveMount(sourceFd, "", unix.AT_FDCWD, containerMountPath,
unix.MOVE_MOUNT_F_EMPTY_PATH); err != nil {
return fmt.Errorf("move_mount detached source -> %s in container PID %d (source path was %s): %w", containerMountPath, containerPID, source, err)
}
// A bind mount created with MS_BIND ignores MS_RDONLY in the same
// call. To make it read-only a second remount is required.
if readOnly {
if err := unix.Mount("", containerMountPath, "", unix.MS_BIND|unix.MS_REMOUNT|unix.MS_RDONLY, ""); err != nil {
glog.Warningf("container remount: failed to set read-only on %s in PID %d: %v", containerMountPath, containerPID, err)
}
}
return nil
}