This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -611,6 +613,54 @@ func (a *App) handleDeleteSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "snapshot destroyed", "name": name})
|
||||
}
|
||||
|
||||
func (a *App) handleRestoreSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract snapshot name from path like /api/v1/snapshots/pool@snapshot/restore
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/snapshots/")
|
||||
path = strings.TrimSuffix(path, "/restore")
|
||||
|
||||
// URL decode the snapshot name
|
||||
snapshotName, err := url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
writeError(w, errors.ErrBadRequest("invalid snapshot name"))
|
||||
return
|
||||
}
|
||||
|
||||
if snapshotName == "" {
|
||||
writeError(w, errors.ErrBadRequest("snapshot name required"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Force bool `json:"force,omitempty"` // Force rollback (recursive for child datasets)
|
||||
}
|
||||
|
||||
if r.Body != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// If body is empty or invalid, use defaults
|
||||
req.Force = false
|
||||
}
|
||||
}
|
||||
|
||||
// Validate snapshot exists
|
||||
_, err = a.zfs.GetSnapshot(snapshotName)
|
||||
if err != nil {
|
||||
writeError(w, errors.ErrNotFound(fmt.Sprintf("snapshot '%s' not found", snapshotName)))
|
||||
return
|
||||
}
|
||||
|
||||
// Restore snapshot
|
||||
if err := a.zfs.RestoreSnapshot(snapshotName, req.Force); err != nil {
|
||||
log.Printf("restore snapshot error: %v", err)
|
||||
writeError(w, errors.ErrInternal("failed to restore snapshot").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "snapshot restored successfully",
|
||||
"name": snapshotName,
|
||||
})
|
||||
}
|
||||
|
||||
// Snapshot Policy Handlers
|
||||
func (a *App) handleListSnapshotPolicies(w http.ResponseWriter, r *http.Request) {
|
||||
dataset := r.URL.Query().Get("dataset")
|
||||
@@ -875,6 +925,13 @@ func (a *App) handleDeleteSMBShare(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// NFS Export Handlers
|
||||
func (a *App) handleListNFSExports(w http.ResponseWriter, r *http.Request) {
|
||||
// Sync exports from OS (ZFS sharenfs) to store
|
||||
// This ensures exports created before service restart are visible
|
||||
if err := a.syncNFSExportsFromOS(); err != nil {
|
||||
log.Printf("warning: failed to sync NFS exports from OS: %v", err)
|
||||
// Continue anyway - return what's in store
|
||||
}
|
||||
|
||||
exports := a.nfsStore.List()
|
||||
writeJSON(w, http.StatusOK, exports)
|
||||
}
|
||||
@@ -1638,3 +1695,112 @@ func (a *App) handleListAuditLogs(w http.ResponseWriter, r *http.Request) {
|
||||
logs := a.auditStore.List(actor, action, resource, limit)
|
||||
writeJSON(w, http.StatusOK, logs)
|
||||
}
|
||||
|
||||
// syncNFSExportsFromOS syncs NFS exports from ZFS sharenfs properties to the store
|
||||
func (a *App) syncNFSExportsFromOS() error {
|
||||
// Get all datasets
|
||||
datasets, err := a.zfs.ListDatasets("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("list datasets: %w", err)
|
||||
}
|
||||
|
||||
// Find datasets with sharenfs property set
|
||||
for _, ds := range datasets {
|
||||
// Get sharenfs property
|
||||
zfsPath := "zfs"
|
||||
if path, err := exec.LookPath("zfs"); err == nil {
|
||||
zfsPath = path
|
||||
}
|
||||
|
||||
cmd := exec.Command("sudo", "-n", zfsPath, "get", "-H", "-o", "value", "sharenfs", ds.Name)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
continue // Skip if can't get property
|
||||
}
|
||||
|
||||
sharenfsValue := strings.TrimSpace(string(output))
|
||||
if sharenfsValue == "off" || sharenfsValue == "-" || sharenfsValue == "" {
|
||||
continue // Not shared
|
||||
}
|
||||
|
||||
// Check if export already exists in store
|
||||
existingExports := a.nfsStore.List()
|
||||
exists := false
|
||||
for _, exp := range existingExports {
|
||||
if exp.Dataset == ds.Name || exp.Path == ds.Mountpoint {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if exists {
|
||||
continue // Already in store
|
||||
}
|
||||
|
||||
// Parse sharenfs value to extract configuration
|
||||
// Format examples:
|
||||
// - "rw=*,no_root_squash"
|
||||
// - "ro=*,root_squash"
|
||||
// - "rw=10.0.0.0/8,ro=192.168.1.0/24,root_squash"
|
||||
readOnly := false
|
||||
rootSquash := true // Default
|
||||
clients := []string{"*"} // Default to all
|
||||
|
||||
// Parse sharenfs value
|
||||
parts := strings.Split(sharenfsValue, ",")
|
||||
clientSpecs := []string{}
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "root_squash" {
|
||||
rootSquash = true
|
||||
} else if part == "no_root_squash" {
|
||||
rootSquash = false
|
||||
} else if strings.HasPrefix(part, "ro=") {
|
||||
client := strings.TrimPrefix(part, "ro=")
|
||||
if client == "*" {
|
||||
readOnly = true
|
||||
clients = []string{"*"}
|
||||
} else {
|
||||
clientSpecs = append(clientSpecs, client)
|
||||
readOnly = true // At least one client is read-only
|
||||
}
|
||||
} else if strings.HasPrefix(part, "rw=") {
|
||||
client := strings.TrimPrefix(part, "rw=")
|
||||
if client == "*" {
|
||||
readOnly = false
|
||||
clients = []string{"*"}
|
||||
} else {
|
||||
clientSpecs = append(clientSpecs, client)
|
||||
}
|
||||
} else if part == "ro" {
|
||||
readOnly = true
|
||||
clients = []string{"*"}
|
||||
} else if part == "rw" {
|
||||
readOnly = false
|
||||
clients = []string{"*"}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found specific clients, use them
|
||||
if len(clientSpecs) > 0 {
|
||||
clients = clientSpecs
|
||||
}
|
||||
|
||||
// Use mountpoint as path, or default path
|
||||
path := ds.Mountpoint
|
||||
if path == "" || path == "none" {
|
||||
// Generate default path
|
||||
parts := strings.Split(ds.Name, "/")
|
||||
datasetName := parts[len(parts)-1]
|
||||
path = "/storage/datasets/" + datasetName
|
||||
}
|
||||
|
||||
// Create export in store (ignore error if already exists)
|
||||
_, err = a.nfsStore.Create(path, ds.Name, clients, readOnly, rootSquash)
|
||||
if err != nil && err != storage.ErrNFSExportExists {
|
||||
log.Printf("warning: failed to sync export for dataset %s: %v", ds.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -137,6 +137,16 @@ func (a *App) handleZVOLOps(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleSnapshotOps routes snapshot operations by method
|
||||
func (a *App) handleSnapshotOps(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if it's a restore operation
|
||||
if strings.HasSuffix(r.URL.Path, "/restore") {
|
||||
if r.Method == http.MethodPost {
|
||||
a.handleRestoreSnapshot(w, r)
|
||||
} else {
|
||||
writeError(w, errors.ErrBadRequest("method not allowed"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetSnapshot(w, r) },
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleCreateSnapshot(w, r) },
|
||||
|
||||
Reference in New Issue
Block a user