package nfs import ( "bytes" "context" "embed" "fmt" "os" "path/filepath" "strings" "text/template" "time" "github.com/darroyo/nasctl/internal/db" "github.com/darroyo/nasctl/internal/system" ) const ModuleName = "nfs" //go:embed exports.tmpl var exportsTemplate embed.FS type Config struct { ExportsPath string Reload bool } type Module struct { cfg Config } type templateExport struct { Path string ClientSpec string } type templateData struct { Exports []templateExport } func New(cfg Config) *Module { if cfg.ExportsPath == "" { cfg.ExportsPath = "/etc/exports" } return &Module{cfg: cfg} } func (m *Module) Name() string { return ModuleName } func (m *Module) IsDirty(ctx context.Context, database *db.DB) (bool, error) { return database.IsDirty(ModuleName) } func (m *Module) Apply(ctx context.Context, database *db.DB) error { if err := m.maybeBackup(database); err != nil { return err } exports, err := database.ListNFSExports() if err != nil { return err } content, err := m.renderConfig(exports) if err != nil { return err } if err := writeAtomic(m.cfg.ExportsPath, content); err != nil { return err } if m.cfg.Reload { stdout, stderr, err := system.Run(ctx, "exportfs", "-ra") if err != nil { return fmt.Errorf("exportfs -ra: %w (stdout=%q stderr=%q)", err, stdout, stderr) } } message := fmt.Sprintf("applied %d nfs export(s) to %s", len(exports), m.cfg.ExportsPath) if err := database.AppendApplyLog(ModuleName, message, true); err != nil { return err } return database.ClearDirty(ModuleName) } // clientSpec builds the "client(opts) client(opts)" segment of an exports line. // If no clients are configured, it defaults to "*(opts)". func clientSpec(clients []string, options string) string { opts := strings.TrimSpace(options) if opts == "" { opts = "ro" } if len(clients) == 0 { return fmt.Sprintf("*(%s)", opts) } specs := make([]string, 0, len(clients)) for _, client := range clients { client = strings.TrimSpace(client) if client == "" { continue } specs = append(specs, fmt.Sprintf("%s(%s)", client, opts)) } if len(specs) == 0 { return fmt.Sprintf("*(%s)", opts) } return strings.Join(specs, " ") } func (m *Module) renderConfig(exports []db.NFSExport) ([]byte, error) { tmplContent, err := exportsTemplate.ReadFile("exports.tmpl") if err != nil { return nil, fmt.Errorf("read exports template: %w", err) } tmpl, err := template.New("exports").Parse(string(tmplContent)) if err != nil { return nil, fmt.Errorf("parse exports template: %w", err) } data := templateData{Exports: make([]templateExport, 0, len(exports))} for _, export := range exports { data.Exports = append(data.Exports, templateExport{ Path: export.Path, ClientSpec: clientSpec(export.Clients, export.Options), }) } var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { return nil, fmt.Errorf("execute exports template: %w", err) } return buf.Bytes(), nil } func (m *Module) maybeBackup(database *db.DB) error { done, ok, err := database.GetSetting("backup.nfs.done") if err != nil { return err } if ok && done == "true" { return nil } if _, err := os.Stat(m.cfg.ExportsPath); os.IsNotExist(err) { return nil } else if err != nil { return nil } src, err := os.ReadFile(m.cfg.ExportsPath) if err != nil { return nil } backupPath := m.cfg.ExportsPath + ".nasctl.bak." + time.Now().Format("20060102T150405") if err := os.WriteFile(backupPath, src, 0o644); err != nil { return fmt.Errorf("backup exports: %w", err) } _ = database.SetSetting("backup.nfs.done", "true") return nil } func writeAtomic(path string, content []byte) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create config dir: %w", err) } tmp, err := os.CreateTemp(dir, ".exports.*") if err != nil { return fmt.Errorf("create temp file: %w", err) } tmpPath := tmp.Name() cleanup := func() { _ = tmp.Close() _ = os.Remove(tmpPath) } if _, err := tmp.Write(content); err != nil { cleanup() return fmt.Errorf("write temp config: %w", err) } if err := tmp.Chmod(0o644); err != nil { cleanup() return fmt.Errorf("chmod temp config: %w", err) } if err := tmp.Close(); err != nil { cleanup() return fmt.Errorf("close temp config: %w", err) } if err := os.Rename(tmpPath, path); err != nil { cleanup() return fmt.Errorf("rename temp config: %w", err) } return nil }