512feaffd7
This is a backward-compatible MINOR bump (0.4.0 → 0.5.0).
BREAKING NOTES (for users upgrading from pre-0.5.0):
- The nfs_exports.clients column schema changed from []string to
[]NFSClient (per-host options). A migration (0005) transforms existing
string arrays into object arrays, taking export-level options as
defaults for each host.
- ValidateNFSClient now only accepts IPv4 (192.168.1.1) or IPv4/CIDR
(192.168.1.0/24). Hostnames, wildcards, netgroups are rejected.
- If you use NASCTL_IMPORT_ON_BOOT, re-import your /etc/exports to pick
up per-host options.
What changed:
- NFSClient type: {host, read_only, async, root_squash, subtree_check, advanced}
- NFSExport.Clients is now []NFSClient (was []string)
- export-level flags (ro/async/root_squash/subtree_check/advanced) are
preserved as template defaults for newly added hosts in the UI.
- buildExportLine generates: path host1(ro,sync,...) host2(rw,async,...) fsid=N
- ValidateNFSClient: strict IPv4/CIDR only (0-255 octets, /0-32 prefix)
- parseExportLine now parses per-host options from /etc/exports (previously
only the first host's options were kept, others were discarded)
- UI: per-host rows with toggles (ro/async/root_squash/subtree_check) and
advanced options (all_squash, secure, wdelay, hide, crossmnt)
221 lines
4.8 KiB
Go
221 lines
4.8 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"sort"
|
|
"strings"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationsFS embed.FS
|
|
|
|
type DB struct {
|
|
conn *sql.DB
|
|
}
|
|
|
|
func Open(path string) (*DB, error) {
|
|
conn, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
}
|
|
if _, err := conn.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
|
_ = conn.Close()
|
|
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
|
}
|
|
return &DB{conn: conn}, nil
|
|
}
|
|
|
|
func (d *DB) Close() error {
|
|
return d.conn.Close()
|
|
}
|
|
|
|
func (d *DB) Conn() *sql.DB {
|
|
return d.conn
|
|
}
|
|
|
|
func (d *DB) Migrate() error {
|
|
if err := d.createMigrationsTable(); err != nil {
|
|
return fmt.Errorf("create migrations table: %w", err)
|
|
}
|
|
|
|
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
|
if err != nil {
|
|
return fmt.Errorf("read migrations: %w", err)
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return entries[i].Name() < entries[j].Name()
|
|
})
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
|
|
applied, err := d.isMigrationApplied(name)
|
|
if err != nil {
|
|
return fmt.Errorf("check migration %s: %w", name, err)
|
|
}
|
|
if applied {
|
|
continue
|
|
}
|
|
|
|
content, err := migrationsFS.ReadFile("migrations/" + name)
|
|
if err != nil {
|
|
return fmt.Errorf("read migration %s: %w", name, err)
|
|
}
|
|
|
|
if err := d.runMigration(name, string(content)); err != nil {
|
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
|
}
|
|
|
|
if name == "0002_nfs_structured.sql" {
|
|
if err := d.PopulateLegacyFSIDs(); err != nil {
|
|
return fmt.Errorf("populate legacy fsids: %w", err)
|
|
}
|
|
}
|
|
if name == "0005_nfs_per_host_options.sql" {
|
|
if err := d.MigrateNFSClients(); err != nil {
|
|
return fmt.Errorf("migrate nfs clients: %w", err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *DB) createMigrationsTable() error {
|
|
_, err := d.conn.Exec(`
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
name TEXT PRIMARY KEY,
|
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func (d *DB) isMigrationApplied(name string) (bool, error) {
|
|
var count int
|
|
err := d.conn.QueryRow(
|
|
"SELECT COUNT(*) FROM schema_migrations WHERE name = ?",
|
|
name,
|
|
).Scan(&count)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func (d *DB) runMigration(name, content string) error {
|
|
if _, err := d.conn.Exec(content); err != nil {
|
|
if isIgnorableMigrationError(err) {
|
|
// Schema is already in target state (e.g. columns already added
|
|
// by a previous partial run). Continue to record the migration.
|
|
} else {
|
|
return fmt.Errorf("execute: %w", err)
|
|
}
|
|
}
|
|
|
|
if _, err := d.conn.Exec(
|
|
"INSERT INTO schema_migrations (name) VALUES (?)",
|
|
name,
|
|
); err != nil {
|
|
return fmt.Errorf("record migration: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isIgnorableMigrationError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
return strings.Contains(msg, "duplicate column name") ||
|
|
strings.Contains(msg, "already exists")
|
|
}
|
|
|
|
func (d *DB) MigrateNFSClients() error {
|
|
rows, err := d.conn.Query(`
|
|
SELECT id, clients, read_only, async_, root_squash, subtree_check, fsid, advanced
|
|
FROM nfs_exports`)
|
|
if err != nil {
|
|
return fmt.Errorf("query nfs exports: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type rowData struct {
|
|
ID int64
|
|
Clients string
|
|
ReadOnly bool
|
|
Async bool
|
|
RootSquash bool
|
|
SubtreeCheck bool
|
|
FSID int64
|
|
Advanced string
|
|
}
|
|
|
|
var toUpdate []rowData
|
|
for rows.Next() {
|
|
var r rowData
|
|
if err := rows.Scan(&r.ID, &r.Clients, &r.ReadOnly, &r.Async, &r.RootSquash, &r.SubtreeCheck, &r.FSID, &r.Advanced); err != nil {
|
|
return fmt.Errorf("scan row: %w", err)
|
|
}
|
|
|
|
var raw []any
|
|
if err := json.Unmarshal([]byte(r.Clients), &raw); err != nil {
|
|
continue
|
|
}
|
|
if len(raw) == 0 {
|
|
continue
|
|
}
|
|
|
|
if _, ok := raw[0].(string); !ok {
|
|
continue
|
|
}
|
|
|
|
var adv NFSAdvanced
|
|
if r.Advanced != "" && r.Advanced != "{}" {
|
|
_ = json.Unmarshal([]byte(r.Advanced), &adv)
|
|
}
|
|
|
|
clients := make([]NFSClient, len(raw))
|
|
for i, elem := range raw {
|
|
host, ok := elem.(string)
|
|
if !ok {
|
|
host = ""
|
|
}
|
|
clients[i] = NFSClient{
|
|
Host: host,
|
|
ReadOnly: r.ReadOnly,
|
|
Async: r.Async,
|
|
RootSquash: r.RootSquash,
|
|
SubtreeCheck: r.SubtreeCheck,
|
|
Advanced: adv,
|
|
}
|
|
}
|
|
|
|
clientsJSON, err := json.Marshal(clients)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal clients for id %d: %w", r.ID, err)
|
|
}
|
|
|
|
r.Clients = string(clientsJSON)
|
|
toUpdate = append(toUpdate, r)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("rows iteration: %w", err)
|
|
}
|
|
|
|
for _, r := range toUpdate {
|
|
if _, err := d.conn.Exec(`UPDATE nfs_exports SET clients = ? WHERE id = ?`, r.Clients, r.ID); err != nil {
|
|
return fmt.Errorf("update nfs export id %d: %w", r.ID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|