mirror of
https://github.com/hanzoai/s3-csi.git
synced 2026-09-30 23:52:32 +00:00
* fix(docker): clone the fork that exists, at a commit that exists
The s3-mount image has never built. A global rename turned the upstream
seaweedfs reference into `github.com/s3/s3`, which is not a repository, so the
build died on `git clone ... exit code 128` before compiling anything.
Three more things were wrong behind it, each of which would have failed the next
step in turn:
clone github.com/s3/s3 -> github.com/hanzoai/s3
path /go/src/github.com/s3/s3 -> /go/src/github.com/hanzoai/s3
dir cd weed -> cd s3 (the fork renamed it)
commit 19ce7c0b6fdb -> 6dda4e4d6bca
That commit is the real one. 19ce7c0b6fdb is an UPSTREAM seaweedfs hash and does
not exist in our fork — the API 422s on it — so the checkout could never have
succeeded even with the URL fixed. The ARG stays so a build can still pin.
SEAWEEDFS_COMMIT is renamed S3_COMMIT while we are here; nothing consumed it yet.
* chore: no seaweed, no weed — it is s3
369 occurrences across 33 files, plus nine files whose names carried it. All of
it gone: manifests, helm chart, nomad jobs, workflows, docs and code.
Two of these were not cosmetic.
The driver spawned a binary called "weed":
DefaultWeedBinary = "weed"
Our fork builds it as `s3` — the running filer pod has /usr/bin/s3 and no weed
at all — so every mount would have failed with an executable that is not there,
however well the image built.
And the crash-recovery integration test killed processes matching `pgrep -x
weed`. Nothing is named that any more, so it would have found no pids, killed
nothing, and passed while testing exactly nothing. It now matches `s3`. The test
already had a guard that fails when there is nothing to kill, which is the only
reason this would have been noticed at all.
The rest follows the same rename the Dockerfile got: SEAWEEDFS_COMMIT is
S3_COMMIT, and the release/sync workflows that patch that ARG were updated with
it or they would silently stop pinning anything.
No upstream import paths were touched — go.mod depends on github.com/hanzoai/s3,
not on anything upstream, so nothing here was load-bearing for the build.
Verified: go build clean, all three test packages pass.
---------
Co-authored-by: hanzo-dev <[email protected]>
Co-authored-by: Hanzo Dev <[email protected]>
396 lines
11 KiB
Go
396 lines
11 KiB
Go
package mountmanager
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/hanzoai/s3/s3/glog"
|
|
"k8s.io/mount-utils"
|
|
)
|
|
|
|
var kubeMounter = mount.New("")
|
|
|
|
// Manager owns s3 mount processes and exposes helpers to start and stop them.
|
|
type Manager struct {
|
|
s3Binary string
|
|
|
|
mu sync.Mutex
|
|
mounts map[string]*mountEntry
|
|
locks *keyMutex
|
|
}
|
|
|
|
// Config configures a Manager instance.
|
|
type Config struct {
|
|
S3Binary string
|
|
}
|
|
|
|
// NewManager returns a Manager ready to accept mount requests.
|
|
func NewManager(cfg Config) *Manager {
|
|
binary := cfg.S3Binary
|
|
if binary == "" {
|
|
binary = DefaultS3Binary
|
|
}
|
|
return &Manager{
|
|
s3Binary: binary,
|
|
mounts: make(map[string]*mountEntry),
|
|
locks: newKeyMutex(),
|
|
}
|
|
}
|
|
|
|
// Mount starts a s3 mount process using the provided request.
|
|
func (m *Manager) Mount(req *MountRequest) (*MountResponse, error) {
|
|
if req == nil {
|
|
return nil, errors.New("mount request is nil")
|
|
}
|
|
if err := validateMountRequest(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
lock := m.locks.get(req.VolumeID)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
if entry := m.getMount(req.VolumeID); entry != nil {
|
|
// If the previous s3 mount process has died, the entry is
|
|
// stale and the FUSE mount is dead. Tear down the stale entry
|
|
// so we can start a fresh process; otherwise we would falsely
|
|
// report "already mounted" and a CSI-driver recovery would
|
|
// silently bind-mount onto a dead path. See s3/s3-csi#261.
|
|
select {
|
|
case <-entry.process.exited:
|
|
glog.Warningf("volume %s previous s3 mount process has exited; replacing stale entry with a fresh mount", req.VolumeID)
|
|
// Wait for wait()'s post-exit cleanup (FUSE unmount) to finish
|
|
// so ensureTargetClean below sees a quiescent path.
|
|
<-entry.process.done
|
|
m.removeMount(req.VolumeID)
|
|
default:
|
|
if entry.targetPath == req.TargetPath {
|
|
glog.Infof("volume %s already mounted at %s", req.VolumeID, req.TargetPath)
|
|
return &MountResponse{LocalSocket: entry.localSocket}, nil
|
|
}
|
|
return nil, fmt.Errorf("volume %s already mounted at %s", req.VolumeID, entry.targetPath)
|
|
}
|
|
}
|
|
|
|
entry, err := m.startMount(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.mounts[req.VolumeID] = entry
|
|
m.mu.Unlock()
|
|
|
|
// Proactively clear the entry once the s3 mount process exits,
|
|
// even if Unmount is never called. Without this, a process that
|
|
// crashes on its own (e.g. backend errors) leaves a stale entry
|
|
// that fools later Mount calls into reporting success without
|
|
// starting a new process.
|
|
go m.watchProcessExit(req.VolumeID, entry)
|
|
|
|
glog.Infof("started s3 mount process for volume %s at %s", req.VolumeID, req.TargetPath)
|
|
return &MountResponse{LocalSocket: entry.localSocket}, nil
|
|
}
|
|
|
|
// watchProcessExit removes the entry for volumeID once its s3 mount
|
|
// process has finished exiting. It is safe to run concurrently with
|
|
// Unmount: if Unmount has already removed the entry (or replaced it
|
|
// with a fresh mount), the identity check ensures we leave the new
|
|
// entry alone.
|
|
func (m *Manager) watchProcessExit(volumeID string, entry *mountEntry) {
|
|
<-entry.process.done
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if existing, ok := m.mounts[volumeID]; ok && existing == entry {
|
|
delete(m.mounts, volumeID)
|
|
// See removeMount: do not delete the per-volume lock — a
|
|
// concurrent Mount/Unmount may still be holding it.
|
|
glog.Infof("removed mount entry for volume %s after s3 mount process exited (target: %s)", volumeID, entry.targetPath)
|
|
}
|
|
}
|
|
|
|
// Unmount terminates the s3 mount process associated with the provided request.
|
|
func (m *Manager) Unmount(req *UnmountRequest) (*UnmountResponse, error) {
|
|
if req == nil {
|
|
return nil, errors.New("unmount request is nil")
|
|
}
|
|
if req.VolumeID == "" {
|
|
return nil, errors.New("volumeId is required")
|
|
}
|
|
|
|
lock := m.locks.get(req.VolumeID)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
// Use getMount first to check if mounted, only remove from state after cleanup succeeds
|
|
entry := m.getMount(req.VolumeID)
|
|
if entry == nil {
|
|
glog.Infof("volume %s not mounted", req.VolumeID)
|
|
return &UnmountResponse{}, nil
|
|
}
|
|
|
|
// Note: We don't explicitly unmount here because s3MountProcess.wait()
|
|
// handles the unmount when the process terminates (either gracefully or forcefully).
|
|
// This centralizes unmount logic and avoids potential race conditions.
|
|
if err := entry.process.stop(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Remove cache dir only after process has been successfully stopped
|
|
if err := os.RemoveAll(entry.cacheDir); err != nil {
|
|
glog.Warningf("failed to remove cache dir %s for volume %s: %v", entry.cacheDir, req.VolumeID, err)
|
|
}
|
|
|
|
// Only remove from state after all cleanup operations succeeded
|
|
m.removeMount(req.VolumeID)
|
|
|
|
glog.Infof("stopped s3 mount process for volume %s at %s", req.VolumeID, entry.targetPath)
|
|
return &UnmountResponse{}, nil
|
|
}
|
|
|
|
func (m *Manager) getMount(volumeID string) *mountEntry {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.mounts[volumeID]
|
|
}
|
|
|
|
func (m *Manager) removeMount(volumeID string) *mountEntry {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
entry := m.mounts[volumeID]
|
|
delete(m.mounts, volumeID)
|
|
// Intentionally do NOT delete the per-volume lock here. If a caller
|
|
// is still holding the lock from m.locks.get(volumeID), deleting it
|
|
// would let a concurrent caller receive a brand-new lock from the
|
|
// next m.locks.get() and enter the critical section in parallel —
|
|
// splitting Mount/Unmount serialization for the same volume. The
|
|
// lock map grows by one entry per ever-mounted volume, which is
|
|
// negligible for the manager's lifetime.
|
|
return entry
|
|
}
|
|
|
|
func (m *Manager) startMount(req *MountRequest) (*mountEntry, error) {
|
|
targetPath := req.TargetPath
|
|
if err := ensureTargetClean(targetPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cacheDir := req.CacheDir
|
|
if cacheDir == "" {
|
|
return nil, errors.New("cacheDir is required")
|
|
}
|
|
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
|
return nil, fmt.Errorf("creating cache dir: %w", err)
|
|
}
|
|
|
|
localSocket := req.LocalSocket
|
|
if localSocket == "" {
|
|
return nil, errors.New("localSocket is required")
|
|
}
|
|
|
|
args := req.MountArgs
|
|
if len(args) == 0 {
|
|
return nil, errors.New("mountArgs is required")
|
|
}
|
|
|
|
process, err := startS3MountProcess(m.s3Binary, args, targetPath, req.VolumeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &mountEntry{
|
|
volumeID: req.VolumeID,
|
|
targetPath: targetPath,
|
|
cacheDir: cacheDir,
|
|
localSocket: localSocket,
|
|
process: process,
|
|
}, nil
|
|
}
|
|
|
|
func ensureTargetClean(targetPath string) error {
|
|
// Use IsLikelyNotMountPoint instead of deprecated IsMountPoint
|
|
notMnt, err := kubeMounter.IsLikelyNotMountPoint(targetPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
// Path does not exist, which is a clean state. Directory will be created below.
|
|
} else if mount.IsCorruptedMnt(err) {
|
|
glog.Warningf("Target path %s is a corrupted mount, attempting to unmount", targetPath)
|
|
if unmountErr := kubeMounter.Unmount(targetPath); unmountErr != nil {
|
|
return fmt.Errorf("failed to unmount corrupted mount %s: %w", targetPath, unmountErr)
|
|
}
|
|
} else {
|
|
return err
|
|
}
|
|
} else if !notMnt {
|
|
glog.Infof("Target path %s is an existing mount, attempting to unmount", targetPath)
|
|
if unmountErr := kubeMounter.Unmount(targetPath); unmountErr != nil {
|
|
return fmt.Errorf("failed to unmount existing mount %s: %w", targetPath, unmountErr)
|
|
}
|
|
}
|
|
|
|
// Ensure the path exists and is a directory.
|
|
return os.MkdirAll(targetPath, 0755)
|
|
}
|
|
|
|
func validateMountRequest(req *MountRequest) error {
|
|
if req.VolumeID == "" {
|
|
return errors.New("volumeId is required")
|
|
}
|
|
if req.TargetPath == "" {
|
|
return errors.New("targetPath is required")
|
|
}
|
|
if req.CacheDir == "" {
|
|
return errors.New("cacheDir is required")
|
|
}
|
|
if req.LocalSocket == "" {
|
|
return errors.New("localSocket is required")
|
|
}
|
|
if len(req.MountArgs) == 0 {
|
|
return errors.New("mountArgs is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type mountEntry struct {
|
|
volumeID string
|
|
targetPath string
|
|
cacheDir string
|
|
localSocket string
|
|
process *s3MountProcess
|
|
}
|
|
|
|
type s3MountProcess struct {
|
|
cmd *exec.Cmd
|
|
target string
|
|
// exited is closed as soon as cmd.Wait() returns, so callers can
|
|
// detect that the s3 mount process is gone without waiting for
|
|
// the post-exit FUSE unmount step.
|
|
exited chan struct{}
|
|
// done is closed after wait() finishes its full cleanup (including
|
|
// the kubeMounter.Unmount of the target).
|
|
done chan struct{}
|
|
}
|
|
|
|
func startS3MountProcess(command string, args []string, target string, volumeID string) (*s3MountProcess, error) {
|
|
cmd := exec.Command(command, args...)
|
|
|
|
// Capture stdout/stderr and log with volume ID prefix for better debugging
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating stdout pipe: %w", err)
|
|
}
|
|
stderrPipe, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating stderr pipe: %w", err)
|
|
}
|
|
|
|
glog.V(0).Infof("[%s] Starting s3 mount: %s %s", volumeID, command, strings.Join(args, " "))
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("starting s3 mount: %w", err)
|
|
}
|
|
|
|
// Forward stdout/stderr with volume ID prefix for better debugging
|
|
go forwardLogs(stdoutPipe, volumeID, "stdout")
|
|
go forwardLogs(stderrPipe, volumeID, "stderr")
|
|
|
|
process := &s3MountProcess{
|
|
cmd: cmd,
|
|
target: target,
|
|
exited: make(chan struct{}),
|
|
done: make(chan struct{}),
|
|
}
|
|
|
|
go process.wait()
|
|
|
|
if err := waitForMount(target, 10*time.Second); err != nil {
|
|
if stopErr := process.stop(); stopErr != nil {
|
|
glog.Warningf("[%s] failed to stop mount process after mount wait failure: %v", volumeID, stopErr)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return process, nil
|
|
}
|
|
|
|
func (p *s3MountProcess) wait() {
|
|
if err := p.cmd.Wait(); err != nil {
|
|
glog.Errorf("s3 mount exit (pid: %d, target: %s): %v", p.cmd.Process.Pid, p.target, err)
|
|
} else {
|
|
glog.Infof("s3 mount exit (pid: %d, target: %s)", p.cmd.Process.Pid, p.target)
|
|
}
|
|
|
|
// Signal exit immediately so Manager.Mount can detect a dead
|
|
// process without waiting for the post-exit unmount step.
|
|
close(p.exited)
|
|
|
|
// Brief delay to allow FUSE cleanup and pending I/O to complete before unmounting
|
|
time.Sleep(100 * time.Millisecond)
|
|
_ = kubeMounter.Unmount(p.target)
|
|
|
|
close(p.done)
|
|
}
|
|
|
|
func (p *s3MountProcess) stop() error {
|
|
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
|
glog.Warningf("sending SIGTERM to s3 mount failed: %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-p.done:
|
|
return nil
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
|
|
if err := p.cmd.Process.Kill(); err != nil {
|
|
glog.Warningf("killing s3 mount failed: %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-p.done:
|
|
return nil
|
|
case <-time.After(1 * time.Second):
|
|
return errors.New("timed out waiting for s3 mount to stop")
|
|
}
|
|
}
|
|
|
|
func waitForMount(path string, timeout time.Duration) error {
|
|
var elapsed time.Duration
|
|
interval := 10 * time.Millisecond
|
|
|
|
for {
|
|
notMount, err := kubeMounter.IsLikelyNotMountPoint(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !notMount {
|
|
return nil
|
|
}
|
|
|
|
time.Sleep(interval)
|
|
elapsed += interval
|
|
if elapsed >= timeout {
|
|
return errors.New("timeout waiting for mount")
|
|
}
|
|
}
|
|
}
|
|
|
|
// forwardLogs reads from a pipe and logs each line with a volume ID prefix.
|
|
func forwardLogs(pipe io.ReadCloser, volumeID string, stream string) {
|
|
scanner := bufio.NewScanner(pipe)
|
|
for scanner.Scan() {
|
|
glog.Infof("[%s] %s: %s", volumeID, stream, scanner.Text())
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
glog.Warningf("[%s] error reading %s: %v", volumeID, stream, err)
|
|
}
|
|
}
|