fix: fstab write fails on bind-mounted /host-fstab (EBUSY)

rename() fails with EBUSY on Docker bind-mounted files. Add safeWriteFile()
helper that tries atomic rename first, falls back to direct write. Fixes
both init wizard and attach wizard fstab operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 21:19:30 +01:00
parent 98834dd7e8
commit 1d394e32ad
2 changed files with 24 additions and 39 deletions
+2 -19
View File
@@ -359,15 +359,7 @@ func appendBindFstabEntry(fstabPath, source, target string) error {
entry := fmt.Sprintf("\n# Bind mount (auto-generated by felhom-controller)\n%s\t%s\tnone\tbind,nofail\t0 0\n", source, target) entry := fmt.Sprintf("\n# Bind mount (auto-generated by felhom-controller)\n%s\t%s\tnone\tbind,nofail\t0 0\n", source, target)
newContent := append(existing, []byte(entry)...) newContent := append(existing, []byte(entry)...)
tmpPath := fstabPath + ".tmp" return safeWriteFile(fstabPath, newContent, 0644)
if err := os.WriteFile(tmpPath, newContent, 0644); err != nil {
return fmt.Errorf("cannot write fstab tmp file: %w", err)
}
if err := os.Rename(tmpPath, fstabPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("cannot rename fstab tmp file: %w", err)
}
return nil
} }
// removeBindFstabEntry removes the bind mount fstab entry for the given target mount path. // removeBindFstabEntry removes the bind mount fstab entry for the given target mount path.
@@ -395,14 +387,5 @@ func removeBindFstabEntry(fstabPath, targetMountPath string) error {
kept = append(kept, line) kept = append(kept, line)
} }
newContent := strings.Join(kept, "\n") return safeWriteFile(fstabPath, []byte(strings.Join(kept, "\n")), 0644)
tmpPath := fstabPath + ".tmp"
if err := os.WriteFile(tmpPath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("cannot write fstab tmp file: %w", err)
}
if err := os.Rename(tmpPath, fstabPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("cannot rename fstab tmp file: %w", err)
}
return nil
} }
+22 -20
View File
@@ -105,10 +105,10 @@ func BackupFstab(fstabPath string) error {
return os.WriteFile(backupPath, data, 0644) return os.WriteFile(backupPath, data, 0644)
} }
// AppendFstabEntry appends a UUID-based fstab entry atomically (write tmp + rename). // AppendFstabEntry appends a UUID-based fstab entry.
// H8: Direct write to /etc/fstab risks corruption on crash — use atomic write pattern. // Uses atomic rename when possible, falls back to direct overwrite for bind-mounted files
// (Docker mounts /etc/fstab as /host-fstab — rename fails with EBUSY on bind mounts).
func AppendFstabEntry(fstabPath, uuid, mountPoint, fsType, options string) error { func AppendFstabEntry(fstabPath, uuid, mountPoint, fsType, options string) error {
// Read existing content
existing, err := os.ReadFile(fstabPath) existing, err := os.ReadFile(fstabPath)
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("cannot read fstab: %w", err) return fmt.Errorf("cannot read fstab: %w", err)
@@ -117,19 +117,10 @@ func AppendFstabEntry(fstabPath, uuid, mountPoint, fsType, options string) error
entry := fmt.Sprintf("\nUUID=%s\t%s\t%s\t%s\t0 2\n", uuid, mountPoint, fsType, options) entry := fmt.Sprintf("\nUUID=%s\t%s\t%s\t%s\t0 2\n", uuid, mountPoint, fsType, options)
newContent := append(existing, []byte(entry)...) newContent := append(existing, []byte(entry)...)
// Write to .tmp then rename — atomic on same filesystem return safeWriteFile(fstabPath, newContent, 0644)
tmpPath := fstabPath + ".tmp"
if err := os.WriteFile(tmpPath, newContent, 0644); err != nil {
return fmt.Errorf("cannot write fstab tmp file: %w", err)
}
if err := os.Rename(tmpPath, fstabPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("cannot rename fstab tmp file: %w", err)
}
return nil
} }
// RemoveFstabEntry removes any line containing the given UUID from fstab, atomically. // RemoveFstabEntry removes any line containing the given UUID from fstab.
// H19: Called as rollback if mount fails after fstab was written. // H19: Called as rollback if mount fails after fstab was written.
func RemoveFstabEntry(fstabPath, uuid string) error { func RemoveFstabEntry(fstabPath, uuid string) error {
data, err := os.ReadFile(fstabPath) data, err := os.ReadFile(fstabPath)
@@ -145,13 +136,24 @@ func RemoveFstabEntry(fstabPath, uuid string) error {
} }
newContent := strings.Join(kept, "\n") newContent := strings.Join(kept, "\n")
tmpPath := fstabPath + ".tmp" return safeWriteFile(fstabPath, []byte(newContent), 0644)
if err := os.WriteFile(tmpPath, []byte(newContent), 0644); err != nil { }
return fmt.Errorf("cannot write fstab tmp file: %w", err)
// safeWriteFile writes content to a file. It tries atomic rename first (write to .tmp,
// then rename). If rename fails (e.g., bind-mounted files where rename returns EBUSY),
// it falls back to a direct truncate-and-write to the target file.
func safeWriteFile(path string, content []byte, perm os.FileMode) error {
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, content, perm); err != nil {
return fmt.Errorf("cannot write tmp file %s: %w", tmpPath, err)
} }
if err := os.Rename(tmpPath, fstabPath); err != nil { if err := os.Rename(tmpPath, path); err == nil {
os.Remove(tmpPath) return nil // atomic rename succeeded
return fmt.Errorf("cannot rename fstab tmp file: %w", err) }
// Rename failed (likely bind mount) — fall back to direct write
os.Remove(tmpPath)
if err := os.WriteFile(path, content, perm); err != nil {
return fmt.Errorf("cannot write %s: %w", path, err)
} }
return nil return nil
} }