Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30dbb00dd1 | ||
|
|
661a862f8d | ||
|
|
0eab036de7 | ||
|
|
4a03d58193 | ||
|
|
0cdee29359 |
@@ -11,6 +11,10 @@ share management from the UI.
|
||||
* Service: `systemctl status nasmon` / `journalctl -u nasmon -f`
|
||||
* JSON API: `/api/status`, `/api/history?hours=24`, `/api/disks/<serial>/history?days=30`
|
||||
|
||||
Integrates with RepoISO on the same host: shares of other services
|
||||
(`/etc/samba/smb.conf.d`, `/etc/exports.d`) are included and kept running,
|
||||
and RepoISO health is shown on the dashboard and alerted on.
|
||||
|
||||
No authentication — designed for a LAN-only NAS (the `/shares` page can
|
||||
create SMB/NFS users and exports, so keep it off the public internet).
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nasmon/internal/collect"
|
||||
)
|
||||
|
||||
func TestRepoISORules(t *testing.T) {
|
||||
base := Input{Now: time.Now()}
|
||||
if ev := Evaluate(base); len(ev) != 0 {
|
||||
t.Fatalf("integration off must not alert: %v", keys(ev))
|
||||
}
|
||||
|
||||
in := base
|
||||
in.RepoISO = &collect.RepoISO{URL: "http://127.0.0.1", Error: "connection refused"}
|
||||
ev := Evaluate(in)
|
||||
if !has(ev, "repoiso:api:down") || len(ev) != 1 || ev[0].Level != LevelCrit {
|
||||
t.Fatalf("down: %v", keys(ev))
|
||||
}
|
||||
|
||||
in.RepoISO = &collect.RepoISO{Reachable: true, WorkerAlive: false, JobsFailed24h: 2, PublicationsError: 1}
|
||||
ev = Evaluate(in)
|
||||
for _, k := range []string{"repoiso:worker:stale", "repoiso:jobs:failed", "repoiso:publications:error"} {
|
||||
if !has(ev, k) {
|
||||
t.Errorf("missing %s in %v", k, keys(ev))
|
||||
}
|
||||
}
|
||||
|
||||
in.RepoISO = &collect.RepoISO{Reachable: true, WorkerAlive: true}
|
||||
if ev = Evaluate(in); len(ev) != 0 {
|
||||
t.Fatalf("healthy RepoISO alerted: %v", keys(ev))
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ type Input struct {
|
||||
HasCPU bool
|
||||
RootFS collect.FSUsage
|
||||
RootOK bool
|
||||
// RepoISO is the polled RepoISO health (nil when the integration is off).
|
||||
RepoISO *collect.RepoISO
|
||||
// Frozen lists key prefixes whose open alerts must be left untouched
|
||||
// because no fresh data was available for them in this cycle.
|
||||
Frozen []string
|
||||
@@ -194,10 +196,36 @@ func Evaluate(in Input) []Event {
|
||||
}
|
||||
}
|
||||
|
||||
if r := in.RepoISO; r != nil {
|
||||
switch {
|
||||
case !r.Reachable:
|
||||
add("repoiso:api:down", LevelCrit, "RepoISO is not responding",
|
||||
fmt.Sprintf("%s/api/integration/status: %s. Check: systemctl status repoiso-api nginx", r.URL, r.Error))
|
||||
case !r.WorkerAlive:
|
||||
add("repoiso:worker:stale", LevelWarn, "RepoISO worker is not running",
|
||||
fmt.Sprintf("Last worker heartbeat: %s. Downloads, mounts and Proxmox exports are not processed. Check: systemctl status repoiso-worker", orNever(r.WorkerHeartbeat)))
|
||||
}
|
||||
if r.Reachable && r.JobsFailed24h > 0 {
|
||||
add("repoiso:jobs:failed", LevelWarn, fmt.Sprintf("RepoISO: %d failed job(s) in 24 h", r.JobsFailed24h),
|
||||
"See the Jobs page of RepoISO for the errors.")
|
||||
}
|
||||
if r.Reachable && r.PublicationsError > 0 {
|
||||
add("repoiso:publications:error", LevelWarn, fmt.Sprintf("RepoISO: %d publication(s) not mounted", r.PublicationsError),
|
||||
"See the Publications page of RepoISO; remount or unpublish the image.")
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(ev, func(i, j int) bool { return ev[i].Key < ev[j].Key })
|
||||
return ev
|
||||
}
|
||||
|
||||
func orNever(s string) string {
|
||||
if s == "" {
|
||||
return "never"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func humanBytes(b uint64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RepoISO is the health summary of a RepoISO portal running on the same host
|
||||
// (GET <url>/api/integration/status). Reachable=false means the API did not answer.
|
||||
type RepoISO struct {
|
||||
URL string `json:"url"`
|
||||
Reachable bool `json:"reachable"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
Version string `json:"version"`
|
||||
WorkerAlive bool `json:"worker_alive"`
|
||||
WorkerHeartbeat string `json:"worker_heartbeat"`
|
||||
Images int `json:"images"`
|
||||
ImagesBytes uint64 `json:"images_bytes"`
|
||||
ImagesPending int `json:"images_pending"`
|
||||
ImagesError int `json:"images_error"`
|
||||
Publications int `json:"publications"`
|
||||
PublicationsError int `json:"publications_error"`
|
||||
JobsActive int `json:"jobs_active"`
|
||||
JobsFailed24h int `json:"jobs_failed_24h"`
|
||||
Exports int `json:"exports"`
|
||||
}
|
||||
|
||||
var repoisoClient = &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
// FetchRepoISO queries the RepoISO status endpoint; errors are reported in the result.
|
||||
func FetchRepoISO(base string) RepoISO {
|
||||
base = strings.TrimRight(base, "/")
|
||||
r := RepoISO{URL: base, CheckedAt: time.Now()}
|
||||
resp, err := repoisoClient.Get(base + "/api/integration/status")
|
||||
if err != nil {
|
||||
r.Error = err.Error()
|
||||
return r
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
r.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
return r
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||
r.Error = "bad response: " + err.Error()
|
||||
return r
|
||||
}
|
||||
r.URL, r.Reachable, r.Error = base, true, ""
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchRepoISO(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/integration/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"version":"0.1.3","worker_alive":true,"images":5,"images_bytes":1024,"jobs_failed_24h":1}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
r := FetchRepoISO(srv.URL + "/")
|
||||
if !r.Reachable || r.Version != "0.1.3" || !r.WorkerAlive || r.Images != 5 || r.JobsFailed24h != 1 || r.URL != srv.URL {
|
||||
t.Fatalf("unexpected %+v", r)
|
||||
}
|
||||
if r := FetchRepoISO(srv.URL + "/missing"); r.Reachable || r.Error != "HTTP 404" {
|
||||
t.Fatalf("404 not reported: %+v", r)
|
||||
}
|
||||
srv.Close()
|
||||
if r := FetchRepoISO(srv.URL); r.Reachable || r.Error == "" {
|
||||
t.Fatalf("closed server reported reachable: %+v", r)
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -35,6 +35,8 @@ type Pool struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Vdevs []Vdev `json:"vdevs"`
|
||||
// DedupRatio is the pool-wide dedup ratio (1.00 = nothing deduplicated).
|
||||
DedupRatio float64 `json:"dedup_ratio"`
|
||||
}
|
||||
|
||||
type Dataset struct {
|
||||
@@ -43,11 +45,12 @@ type Dataset struct {
|
||||
Avail uint64 `json:"avail"`
|
||||
Refer uint64 `json:"refer"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Dedup string `json:"dedup"` // dedup property: off, on, verify, sha256, ...
|
||||
}
|
||||
|
||||
// ReadPools combines zpool list and zpool status for every pool.
|
||||
func ReadPools() ([]Pool, error) {
|
||||
out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag")
|
||||
out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag,dedup")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -63,7 +66,7 @@ func ReadPools() ([]Pool, error) {
|
||||
}
|
||||
|
||||
func ReadDatasets() ([]Dataset, error) {
|
||||
out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint")
|
||||
out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint,dedup")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -84,6 +87,9 @@ func parseZpoolList(b []byte) []Pool {
|
||||
p.Free, _ = strconv.ParseUint(f[3], 10, 64)
|
||||
p.CapPct = atoiPct(f[4])
|
||||
p.FragPct = atoiPct(f[6])
|
||||
if len(f) > 7 {
|
||||
p.DedupRatio, _ = strconv.ParseFloat(strings.TrimSuffix(strings.TrimSpace(f[7]), "x"), 64)
|
||||
}
|
||||
if p.CapPct == 0 && p.Size > 0 {
|
||||
p.CapPct = int(p.Alloc * 100 / p.Size)
|
||||
}
|
||||
@@ -220,6 +226,9 @@ func parseZfsList(b []byte) []Dataset {
|
||||
d.Used, _ = strconv.ParseUint(f[1], 10, 64)
|
||||
d.Avail, _ = strconv.ParseUint(f[2], 10, 64)
|
||||
d.Refer, _ = strconv.ParseUint(f[3], 10, 64)
|
||||
if len(f) > 5 {
|
||||
d.Dedup = f[5]
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -119,6 +119,15 @@ func TestParseZpoolList(t *testing.T) {
|
||||
if pools[1].CapPct != 90 || pools[1].FragPct != 0 {
|
||||
t.Errorf("pool1 = %+v", pools[1])
|
||||
}
|
||||
if pools[0].DedupRatio != 0 {
|
||||
t.Errorf("dedup ratio without the column = %v", pools[0].DedupRatio)
|
||||
}
|
||||
|
||||
// with the dedup column; -p prints "1.35", without -p it is "1.35x"
|
||||
pools = parseZpoolList([]byte("a\t1\t1\t0\t100\tONLINE\t0\t1.35\nb\t1\t1\t0\t100\tONLINE\t0\t2.00x\n"))
|
||||
if len(pools) != 2 || pools[0].DedupRatio != 1.35 || pools[1].DedupRatio != 2 {
|
||||
t.Errorf("dedup ratio = %+v", pools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseZfsList(t *testing.T) {
|
||||
@@ -127,4 +136,8 @@ func TestParseZfsList(t *testing.T) {
|
||||
if len(ds) != 2 || ds[1].Name != "nas_pool_1_8_gb/appdata" || ds[1].Avail != 200 {
|
||||
t.Errorf("datasets = %+v", ds)
|
||||
}
|
||||
ds = parseZfsList([]byte("tank\t1\t2\t3\t/tank\ton\n"))
|
||||
if len(ds) != 1 || ds[0].Dedup != "on" || ds[0].Mountpoint != "/tank" {
|
||||
t.Errorf("dedup column: %+v", ds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@ type SelfTests struct {
|
||||
Hour int `json:"hour"` // local hour of day when tests may be started
|
||||
}
|
||||
|
||||
// Integrations links nasmon with other services on the same host.
|
||||
type Integrations struct {
|
||||
// RepoISOURL is the RepoISO portal base URL nasmon polls for health
|
||||
// (e.g. http://127.0.0.1); empty disables the integration.
|
||||
RepoISOURL string `json:"repoiso_url"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
SMTP SMTP `json:"smtp"`
|
||||
AlertsEnabled bool `json:"alerts_enabled"`
|
||||
@@ -49,6 +56,7 @@ type Settings struct {
|
||||
SmartSkipStandby bool `json:"smart_skip_standby"`
|
||||
Thresholds Thresholds `json:"thresholds"`
|
||||
SelfTests SelfTests `json:"self_tests"`
|
||||
Integrations Integrations `json:"integrations"`
|
||||
}
|
||||
|
||||
func Defaults() Settings {
|
||||
@@ -102,6 +110,11 @@ func (s *Settings) Validate() error {
|
||||
if s.SelfTests.ShortDays < 0 || s.SelfTests.LongDays < 0 {
|
||||
return errors.New("self-test periods must be >= 0")
|
||||
}
|
||||
u := strings.TrimRight(strings.TrimSpace(s.Integrations.RepoISOURL), "/")
|
||||
if u != "" && !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||
return errors.New("RepoISO URL must start with http:// or https://")
|
||||
}
|
||||
s.Integrations.RepoISOURL = u
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ type Status struct {
|
||||
NextSmartCheck time.Time `json:"next_smart_check"`
|
||||
SmartRunning bool `json:"smart_running"`
|
||||
LastMailErr string `json:"last_mail_err,omitempty"`
|
||||
RepoISO *collect.RepoISO `json:"repoiso,omitempty"`
|
||||
}
|
||||
|
||||
type Scheduler struct {
|
||||
@@ -243,6 +244,11 @@ func (s *Scheduler) collectStorage() {
|
||||
root, _ := collect.ReadFS("/")
|
||||
alerts, _ := s.st.ActiveAlerts()
|
||||
services := collect.ReadShareServices()
|
||||
var repoiso *collect.RepoISO
|
||||
if url := s.cfg.Get().Integrations.RepoISOURL; url != "" {
|
||||
r := collect.FetchRepoISO(url)
|
||||
repoiso = &r
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -264,6 +270,7 @@ func (s *Scheduler) collectStorage() {
|
||||
s.status.RootFS = root
|
||||
s.status.Alerts = alerts
|
||||
s.status.Services = services
|
||||
s.status.RepoISO = repoiso
|
||||
}
|
||||
|
||||
// buildViews merges lsblk disks with cached SMART data; caller holds s.mu.
|
||||
@@ -420,6 +427,7 @@ func (s *Scheduler) evaluate() {
|
||||
CPUTemp: st.CPUTemp, HasCPU: st.HasCPUTemp,
|
||||
RootFS: st.RootFS, RootOK: st.RootFS.Total > 0,
|
||||
Prev: map[string]*collect.Smart{},
|
||||
RepoISO: st.RepoISO,
|
||||
}
|
||||
for _, v := range st.Disks {
|
||||
in.Disks = append(in.Disks, v.Disk)
|
||||
|
||||
@@ -29,12 +29,17 @@ type Manager struct {
|
||||
UID, GID string
|
||||
SMBConf string
|
||||
Exports string
|
||||
// IncludeDir holds Samba drop-ins of other services, included into smb.conf;
|
||||
// ExportsDir is scanned for other services' NFS exports (both: "" = none).
|
||||
IncludeDir string
|
||||
ExportsDir string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func New(run hostrun.Runner) *Manager {
|
||||
m := &Manager{Run: run, LookPath: exec.LookPath, UID: DefaultOwnerUID, GID: DefaultOwnerUID,
|
||||
SMBConf: DefaultSMBConf, Exports: DefaultExports, Timeout: 90 * time.Second}
|
||||
SMBConf: DefaultSMBConf, Exports: DefaultExports, IncludeDir: DefaultSMBIncludeDir,
|
||||
ExportsDir: DefaultExportsDir, Timeout: 90 * time.Second}
|
||||
m.Owner = m.UID
|
||||
if u, err := user.LookupId(m.UID); err == nil {
|
||||
m.Owner, m.GID = u.Username, u.Gid
|
||||
@@ -74,14 +79,19 @@ func (m *Manager) Apply(c Config) error {
|
||||
func (m *Manager) applySMB(c Config) error {
|
||||
ctx, cancel := m.ctx()
|
||||
defer cancel()
|
||||
if err := hostrun.WriteFile(ctx, m.Run, m.SMBConf, RenderSMB(c, m.Owner)); err != nil {
|
||||
own := c
|
||||
if !c.SMB.Enabled {
|
||||
own.SMB.Shares = nil // other services may keep smbd running; don't serve disabled shares
|
||||
}
|
||||
external := countKind(m.External(), "smb")
|
||||
if err := hostrun.WriteFile(ctx, m.Run, m.SMBConf, RenderSMB(own, m.Owner, IncludeFiles(m.IncludeDir)...)); err != nil {
|
||||
return fmt.Errorf("write %s: %w", m.SMBConf, err)
|
||||
}
|
||||
if out, err := m.Run(ctx, "", "testparm", "-s", "--suppress-prompt", m.SMBConf); err != nil {
|
||||
_ = hostrun.Restore(ctx, m.Run, m.SMBConf)
|
||||
return fmt.Errorf("testparm rejected the config, previous file restored: %s", firstLines(out, 4))
|
||||
}
|
||||
active := c.SMB.Enabled && len(c.SMB.Shares) > 0
|
||||
active := len(own.SMB.Shares) > 0 || external > 0
|
||||
if !active {
|
||||
_, err := m.Run(ctx, "", "systemctl", "disable", "--now", "smbd", "nmbd")
|
||||
if err != nil {
|
||||
@@ -96,17 +106,22 @@ func (m *Manager) applySMB(c Config) error {
|
||||
if out, err := m.Run(ctx, "", "smbcontrol", "all", "reload-config"); err != nil {
|
||||
return fmt.Errorf("reload smbd: %s", firstLines(out, 2))
|
||||
}
|
||||
log.Printf("shares: SMB config applied, %d shares", len(c.SMB.Shares))
|
||||
log.Printf("shares: SMB config applied, %d shares, %d from other services", len(own.SMB.Shares), external)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) applyNFS(c Config) error {
|
||||
ctx, cancel := m.ctx()
|
||||
defer cancel()
|
||||
if err := hostrun.WriteFile(ctx, m.Run, m.Exports, RenderExports(c, m.UID, m.GID)); err != nil {
|
||||
own := c
|
||||
if !c.NFS.Enabled {
|
||||
own.NFS.Exports = nil
|
||||
}
|
||||
external := countKind(m.External(), "nfs")
|
||||
if err := hostrun.WriteFile(ctx, m.Run, m.Exports, RenderExports(own, m.UID, m.GID)); err != nil {
|
||||
return fmt.Errorf("write %s: %w", m.Exports, err)
|
||||
}
|
||||
active := c.NFS.Enabled && len(c.NFS.Exports) > 0
|
||||
active := len(own.NFS.Exports) > 0 || external > 0
|
||||
if !active {
|
||||
if _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "nfs-server"); err != nil {
|
||||
return fmt.Errorf("stop nfs-server: %w", err)
|
||||
@@ -122,7 +137,7 @@ func (m *Manager) applyNFS(c Config) error {
|
||||
_, _ = m.Run(ctx, "", "exportfs", "-ra")
|
||||
return fmt.Errorf("exportfs rejected the exports, previous file restored: %s", firstLines(out, 4))
|
||||
}
|
||||
log.Printf("shares: NFS exports applied, %d exports", len(c.NFS.Exports))
|
||||
log.Printf("shares: NFS exports applied, %d exports, %d from other services", len(own.NFS.Exports), external)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package shares
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Other services on the host (e.g. RepoISO) publish their own shares through
|
||||
// drop-in files. nasmon includes the SMB drop-ins into the smb.conf it
|
||||
// generates and keeps smbd / nfs-server running while any of them define a
|
||||
// share, even when nasmon itself has none.
|
||||
const (
|
||||
DefaultSMBIncludeDir = "/etc/samba/smb.conf.d"
|
||||
DefaultExportsDir = "/etc/exports.d"
|
||||
)
|
||||
|
||||
// ExternalShare is a share defined outside nasmon (read-only in the UI).
|
||||
type ExternalShare struct {
|
||||
Kind string // smb | nfs
|
||||
Source string // drop-in file name without extension, e.g. "repoiso"
|
||||
Name string // SMB share name (empty for NFS)
|
||||
Path string
|
||||
Clients string // NFS clients with options, as written in the file
|
||||
}
|
||||
|
||||
// IncludeFiles lists the *.conf drop-ins in dir, sorted. A missing dir yields nil.
|
||||
func IncludeFiles(dir string) []string {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
m, _ := filepath.Glob(filepath.Join(dir, "*.conf"))
|
||||
sort.Strings(m)
|
||||
return m
|
||||
}
|
||||
|
||||
func sourceName(file string) string {
|
||||
return strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
|
||||
}
|
||||
|
||||
// ParseSMBDropIn returns the shares ([section] other than [global], [homes],
|
||||
// [printers]) defined in a Samba config fragment.
|
||||
func ParseSMBDropIn(text, source string) []ExternalShare {
|
||||
var out []ExternalShare
|
||||
var cur *ExternalShare
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || line[0] == '#' || line[0] == ';' {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
name := strings.TrimSpace(line[1 : len(line)-1])
|
||||
switch strings.ToLower(name) {
|
||||
case "global", "homes", "printers":
|
||||
cur = nil
|
||||
default:
|
||||
out = append(out, ExternalShare{Kind: "smb", Source: source, Name: name})
|
||||
cur = &out[len(out)-1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
if k, v, ok := strings.Cut(line, "="); ok && strings.EqualFold(strings.TrimSpace(k), "path") {
|
||||
cur.Path = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseExportsFile returns the exports in an exports(5) file.
|
||||
func ParseExportsFile(text, source string) []ExternalShare {
|
||||
var out []ExternalShare
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
var path, rest string
|
||||
if line[0] == '"' {
|
||||
if end := strings.Index(line[1:], `"`); end >= 0 {
|
||||
path, rest = line[1:end+1], line[end+2:]
|
||||
}
|
||||
} else {
|
||||
path, rest, _ = strings.Cut(line, " ")
|
||||
}
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, ExternalShare{Kind: "nfs", Source: source, Path: path, Clients: strings.TrimSpace(rest)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// External reads the SMB drop-ins and the exports.d files of other services.
|
||||
func (m *Manager) External() []ExternalShare {
|
||||
var out []ExternalShare
|
||||
for _, f := range IncludeFiles(m.IncludeDir) {
|
||||
if b, err := os.ReadFile(f); err == nil {
|
||||
out = append(out, ParseSMBDropIn(string(b), sourceName(f))...)
|
||||
}
|
||||
}
|
||||
if m.ExportsDir != "" {
|
||||
files, _ := filepath.Glob(filepath.Join(m.ExportsDir, "*.exports"))
|
||||
sort.Strings(files)
|
||||
for _, f := range files {
|
||||
if filepath.Clean(f) == filepath.Clean(m.Exports) {
|
||||
continue
|
||||
}
|
||||
if b, err := os.ReadFile(f); err == nil {
|
||||
out = append(out, ParseExportsFile(string(b), sourceName(f))...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countKind(ext []ExternalShare, kind string) int {
|
||||
n := 0
|
||||
for _, e := range ext {
|
||||
if e.Kind == kind {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package shares
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const repoisoSMB = `# Managed by repoiso — do not edit
|
||||
|
||||
[iso-pve]
|
||||
path = /tank/iso/export/pve
|
||||
read only = yes
|
||||
guest ok = yes
|
||||
|
||||
[global]
|
||||
; ignored
|
||||
`
|
||||
|
||||
func writeDropIns(t *testing.T) (smbDir, nfsDir string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
smbDir, nfsDir = filepath.Join(root, "smb.conf.d"), filepath.Join(root, "exports.d")
|
||||
for _, d := range []string{smbDir, nfsDir} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
must := func(p, s string) {
|
||||
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
must(filepath.Join(smbDir, "repoiso.conf"), repoisoSMB)
|
||||
must(filepath.Join(smbDir, "notes.txt"), "[ignored]\n")
|
||||
must(filepath.Join(nfsDir, "repoiso.exports"), "# Managed by repoiso\n\"/tank/iso/export/pve\" 10.10.0.0/24(ro,root_squash)\n")
|
||||
must(filepath.Join(nfsDir, "nasmon.exports"), "\"/data\" *(rw)\n")
|
||||
return
|
||||
}
|
||||
|
||||
func TestParseDropIns(t *testing.T) {
|
||||
smb := ParseSMBDropIn(repoisoSMB, "repoiso")
|
||||
if len(smb) != 1 || smb[0].Name != "iso-pve" || smb[0].Path != "/tank/iso/export/pve" || smb[0].Source != "repoiso" {
|
||||
t.Fatalf("smb: %+v", smb)
|
||||
}
|
||||
nfs := ParseExportsFile("# c\n/plain host(ro)\n\"/with space\" 10.0.0.0/8(ro)\n\n", "x")
|
||||
if len(nfs) != 2 || nfs[0].Path != "/plain" || nfs[1].Path != "/with space" || nfs[1].Clients != "10.0.0.0/8(ro)" {
|
||||
t.Fatalf("nfs: %+v", nfs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalSkipsOwnExports(t *testing.T) {
|
||||
smbDir, nfsDir := writeDropIns(t)
|
||||
m := newManager(newFake())
|
||||
m.IncludeDir, m.ExportsDir, m.Exports = smbDir, nfsDir, filepath.Join(nfsDir, "nasmon.exports")
|
||||
ext := m.External()
|
||||
if countKind(ext, "smb") != 1 || countKind(ext, "nfs") != 1 {
|
||||
t.Fatalf("external: %+v", ext)
|
||||
}
|
||||
if got := IncludeFiles(smbDir); len(got) != 1 || !strings.HasSuffix(got[0], "repoiso.conf") {
|
||||
t.Fatalf("includes: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSMBIncludes(t *testing.T) {
|
||||
out := RenderSMB(sample(), "ansible", "/etc/samba/smb.conf.d/repoiso.conf")
|
||||
if !strings.HasSuffix(strings.TrimSpace(out), "include = /etc/samba/smb.conf.d/repoiso.conf") {
|
||||
t.Fatalf("include not at the end:\n%s", out)
|
||||
}
|
||||
if strings.Contains(RenderSMB(sample(), "ansible"), "include =") {
|
||||
t.Fatal("include rendered without drop-ins")
|
||||
}
|
||||
}
|
||||
|
||||
// The bug this guards against: nasmon without own shares disabled smbd and
|
||||
// nfs-server and killed the shares RepoISO publishes for Proxmox.
|
||||
func TestApplyKeepsServicesForOtherServices(t *testing.T) {
|
||||
smbDir, nfsDir := writeDropIns(t)
|
||||
f := newFake()
|
||||
m := newManager(f)
|
||||
m.IncludeDir, m.ExportsDir, m.Exports = smbDir, nfsDir, filepath.Join(nfsDir, "nasmon.exports")
|
||||
if err := m.Apply(Defaults()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.has("systemctl disable") {
|
||||
t.Fatalf("services disabled although other services share: %v", f.calls)
|
||||
}
|
||||
if !f.has("systemctl enable --now smbd nmbd") || !f.has("systemctl enable --now nfs-server") || !f.has("exportfs -ra") {
|
||||
t.Fatalf("services not kept running: %v", f.calls)
|
||||
}
|
||||
wi := f.index("sh -c")
|
||||
if !strings.Contains(f.stdin[f.calls[wi]], "include = "+filepath.Join(smbDir, "repoiso.conf")) {
|
||||
t.Errorf("smb.conf lacks the drop-in include")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDisabledSMBHidesOwnShares(t *testing.T) {
|
||||
smbDir, _ := writeDropIns(t)
|
||||
f := newFake()
|
||||
m := newManager(f)
|
||||
m.IncludeDir = smbDir
|
||||
c := sample()
|
||||
c.SMB.Enabled = false
|
||||
if err := m.Apply(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(f.stdin[f.calls[f.index("sh -c")]], "[documents]") {
|
||||
t.Errorf("disabled nasmon shares still rendered while smbd stays up for others")
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,9 @@ func anyGuest(c Config) bool {
|
||||
|
||||
// RenderSMB produces the complete smb.conf. owner is the Unix account every
|
||||
// SMB session acts as (force user), so files end up with the same owner the
|
||||
// containers use.
|
||||
func RenderSMB(c Config, owner string) string {
|
||||
// containers use. includes are drop-in files of other services appended as
|
||||
// "include =" lines.
|
||||
func RenderSMB(c Config, owner string, includes ...string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s\n\n[global]\n", Header)
|
||||
// Bad User turns unknown accounts (e.g. the Windows login) into guests.
|
||||
@@ -98,6 +99,12 @@ func RenderSMB(c Config, owner string) string {
|
||||
fmt.Fprintf(&b, " %s\n", l)
|
||||
}
|
||||
}
|
||||
if len(includes) > 0 {
|
||||
b.WriteString("\n# Shares of other services (read-only in nasmon)\n")
|
||||
for _, f := range includes {
|
||||
fmt.Fprintf(&b, "include = %s\n", f)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ func newManager(f *fake) *Manager {
|
||||
m := New(f.run)
|
||||
m.LookPath = f.lookPath
|
||||
m.Owner, m.UID, m.GID = "ansible", "1000", "1000"
|
||||
m.IncludeDir, m.ExportsDir = "", "" // never read the host's drop-ins in tests
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,8 @@ type zfsVM struct {
|
||||
S sched.Status
|
||||
Th config.Thresholds
|
||||
Datasets []datasetRow
|
||||
// PoolDedup maps a pool to the dedup property of its root dataset.
|
||||
PoolDedup map[string]string
|
||||
Form createForm // last submitted values (re-rendered after an error)
|
||||
}
|
||||
|
||||
@@ -134,15 +136,18 @@ type datasetRow struct {
|
||||
}
|
||||
|
||||
type createForm struct {
|
||||
Pool, Name, Owner, Compression, Quota string
|
||||
Pool, Name, Owner, Compression, Dedup, Quota string
|
||||
}
|
||||
|
||||
func (s *Server) zfsView(st sched.Status, form createForm) zfsVM {
|
||||
vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form}
|
||||
vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form, PoolDedup: map[string]string{}}
|
||||
if vm.Form.Owner == "" {
|
||||
vm.Form.Owner = defaultOwner
|
||||
}
|
||||
for _, d := range st.Datasets {
|
||||
if !strings.Contains(d.Name, "/") {
|
||||
vm.PoolDedup[d.Name] = d.Dedup
|
||||
}
|
||||
row := datasetRow{Dataset: d, DomID: strings.NewReplacer("/", "-", ".", "_", ":", "_").Replace(d.Name)}
|
||||
row.CanDestroy = strings.Contains(d.Name, "/") && d.Refer < zfsadm.EmptyRefer
|
||||
for _, o := range st.Datasets {
|
||||
@@ -170,6 +175,7 @@ func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) {
|
||||
Name: strings.Trim(strings.TrimSpace(r.FormValue("name")), "/"),
|
||||
Owner: strings.TrimSpace(r.FormValue("owner")),
|
||||
Compression: r.FormValue("compression"),
|
||||
Dedup: r.FormValue("dedup"),
|
||||
Quota: strings.ToUpper(strings.TrimSpace(r.FormValue("quota"))),
|
||||
}
|
||||
st := s.sch.Status()
|
||||
@@ -191,20 +197,20 @@ func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateSize(f.Quota)} {
|
||||
for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateDedup(f.Dedup), zfsadm.ValidateSize(f.Quota)} {
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Quota: f.Quota, Owner: f.Owner})
|
||||
err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Dedup: f.Dedup, Quota: f.Quota, Owner: f.Owner})
|
||||
s.sch.RefreshStorage()
|
||||
if err != nil {
|
||||
log.Printf("zfs create %s: %v", full, err)
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("dataset %s created (owner %s, compression %q, quota %q)", full, f.Owner, f.Compression, f.Quota)
|
||||
log.Printf("dataset %s created (owner %s, compression %q, dedup %q, quota %q)", full, f.Owner, f.Compression, f.Dedup, f.Quota)
|
||||
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+full+" created")+"#"+url.PathEscape(f.Pool), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -243,6 +249,37 @@ func (s *Server) zfsDestroy(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+name+" destroyed"), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// zfsDedup turns dedup on/off on a pool's root dataset (inherited by children).
|
||||
func (s *Server) zfsDedup(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pool := strings.TrimSpace(r.FormValue("pool"))
|
||||
value := r.FormValue("dedup")
|
||||
st := s.sch.Status()
|
||||
fail := func(msg string) {
|
||||
s.render(w, r, "zfs", pageData{Title: "ZFS", Data: s.zfsView(st, createForm{}), Alerts: len(st.Alerts), Error: "Dedup not changed: " + msg})
|
||||
}
|
||||
if !hasPool(st, pool) {
|
||||
fail("unknown pool " + pool)
|
||||
return
|
||||
}
|
||||
if value != "on" && value != "off" {
|
||||
fail("dedup must be on or off")
|
||||
return
|
||||
}
|
||||
err := s.admin.SetDedup(pool, value)
|
||||
s.sch.RefreshStorage()
|
||||
if err != nil {
|
||||
log.Printf("zfs set dedup=%s %s: %v", value, pool, err)
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("dedup=%s set on pool %s", value, pool)
|
||||
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dedup "+value+" for "+pool)+"#"+url.PathEscape(pool), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func hasPool(st sched.Status, name string) bool {
|
||||
for _, p := range st.Pools {
|
||||
if p.Name == name {
|
||||
@@ -428,6 +465,7 @@ func formToSettings(r *http.Request, old config.Settings) config.Settings {
|
||||
c.SelfTests.ShortDays = num("st_short_days", old.SelfTests.ShortDays)
|
||||
c.SelfTests.LongDays = num("st_long_days", old.SelfTests.LongDays)
|
||||
c.SelfTests.Hour = num("st_hour", old.SelfTests.Hour)
|
||||
c.Integrations.RepoISOURL = strings.TrimSpace(r.FormValue("repoiso_url"))
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nasmon/internal/collect"
|
||||
"nasmon/internal/shares"
|
||||
)
|
||||
|
||||
func TestBrowserURL(t *testing.T) {
|
||||
cases := []struct{ cfg, host, want string }{
|
||||
{"", "nas:8090", ""},
|
||||
{"http://127.0.0.1", "10.10.0.129:8090", "http://10.10.0.129"},
|
||||
{"http://localhost:8080/", "nas.lan:8090", "http://nas.lan:8080/"},
|
||||
{"https://repo.example.com", "10.0.0.1:8090", "https://repo.example.com"},
|
||||
{"http://127.0.0.1", "[fe80::1]:8090", "http://[fe80::1]"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := browserURL(c.cfg, c.host); got != c.want {
|
||||
t.Errorf("browserURL(%q, %q) = %q, want %q", c.cfg, c.host, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoISOIntegrationUI(t *testing.T) {
|
||||
srv, sc, _ := newTestServer(t)
|
||||
// settings: save the URL, menu link appears
|
||||
rec := postForm(srv, "/settings", url.Values{"smtp_port": {"587"}, "smtp_security": {"starttls"},
|
||||
"smart_interval": {"30"}, "repoiso_url": {"http://127.0.0.1/"}})
|
||||
if rec.Code != 200 || srv.cfg.Get().Integrations.RepoISOURL != "http://127.0.0.1" {
|
||||
t.Fatalf("settings not saved: %d %q", rec.Code, srv.cfg.Get().Integrations.RepoISOURL)
|
||||
}
|
||||
rec = postForm(srv, "/settings", url.Values{"smtp_port": {"587"}, "smtp_security": {"starttls"},
|
||||
"smart_interval": {"30"}, "repoiso_url": {"ftp://x"}})
|
||||
if !strings.Contains(rec.Body.String(), "RepoISO URL must start") {
|
||||
t.Errorf("bad URL accepted")
|
||||
}
|
||||
|
||||
st := sc.Status()
|
||||
st.RepoISO = &collect.RepoISO{URL: "http://127.0.0.1", Reachable: true, WorkerAlive: true, Version: "0.1.3",
|
||||
Images: 61, ImagesBytes: 350 << 30, Publications: 2, JobsFailed24h: 1, Exports: 1}
|
||||
sc.SetStatus(st)
|
||||
rec = httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Host = "10.10.0.129:8090"
|
||||
srv.ServeHTTP(rec, req)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{`href="http://10.10.0.129"`, "RepoISO ↗", `id="repoiso"`, "v0.1.3", "1 failed in 24 h"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("dashboard lacks %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
st.RepoISO = &collect.RepoISO{URL: "http://127.0.0.1", Error: "connection refused"}
|
||||
sc.SetStatus(st)
|
||||
rec = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
||||
if !strings.Contains(rec.Body.String(), "not responding") {
|
||||
t.Errorf("unreachable RepoISO not shown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharesPageListsExternal(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
f := &shareRunner{}
|
||||
withShareRunner(srv, f)
|
||||
dir := t.TempDir()
|
||||
smbDir, nfsDir := filepath.Join(dir, "smb.conf.d"), filepath.Join(dir, "exports.d")
|
||||
_ = os.MkdirAll(smbDir, 0o755)
|
||||
_ = os.MkdirAll(nfsDir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(smbDir, "repoiso.conf"), []byte("[iso-pve]\n path = /tank/iso/export/pve\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(nfsDir, "repoiso.exports"), []byte("\"/tank/iso/export/pve\" 10.10.0.0/24(ro)\n"), 0o644)
|
||||
srv.shm.IncludeDir, srv.shm.ExportsDir, srv.shm.Exports = smbDir, nfsDir, filepath.Join(nfsDir, shares.DefaultExports)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/shares", nil))
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{`id="external"`, "iso-pve", "/tank/iso/export/pve", "10.10.0.0/24(ro)", "RepoISO"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("shares page lacks %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -73,6 +75,7 @@ func New(cfg *config.Store, st *store.Store, ring *history.Ring, sch *sched.Sche
|
||||
s.mux.HandleFunc("GET /zfs", s.zfs)
|
||||
s.mux.HandleFunc("POST /zfs/create", s.zfsCreate)
|
||||
s.mux.HandleFunc("POST /zfs/destroy", s.zfsDestroy)
|
||||
s.mux.HandleFunc("POST /zfs/dedup", s.zfsDedup)
|
||||
s.mux.HandleFunc("GET /alerts", s.alerts)
|
||||
s.mux.HandleFunc("POST /alerts/{id}/{action}", s.alertAction)
|
||||
s.mux.HandleFunc("GET /shares", s.sharesPage)
|
||||
@@ -104,6 +107,7 @@ type pageData struct {
|
||||
Data any
|
||||
Flash string
|
||||
Error string
|
||||
RepoISO string // browser URL of the RepoISO portal ("" = integration off)
|
||||
}
|
||||
|
||||
// render writes a full page, or only the "content" block when ?partial=1 is
|
||||
@@ -111,6 +115,7 @@ type pageData struct {
|
||||
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, pd pageData) {
|
||||
pd.Page = name
|
||||
pd.Partial = r.URL.Query().Get("partial") == "1"
|
||||
pd.RepoISO = browserURL(s.cfg.Get().Integrations.RepoISOURL, r.Host)
|
||||
if pd.Alerts == 0 {
|
||||
pd.Alerts = len(s.sch.Status().Alerts)
|
||||
}
|
||||
@@ -287,3 +292,33 @@ func sparkline(vals []float64, max float64, class string) template.HTML {
|
||||
`<svg class="spark %s" viewBox="0 0 %d %d" preserveAspectRatio="none"><polygon points="0,%d %s %d,%d"/><polyline points="%s"/></svg>`,
|
||||
class, int(w), int(h), int(h), p, int(w), int(h), p))
|
||||
}
|
||||
|
||||
// browserURL turns the configured RepoISO address into one the user's browser
|
||||
// can open: a loopback host is replaced by the host the browser used for nasmon.
|
||||
func browserURL(cfg, reqHost string) string {
|
||||
if cfg == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(cfg)
|
||||
if err != nil || u.Host == "" {
|
||||
return cfg
|
||||
}
|
||||
switch u.Hostname() {
|
||||
case "127.0.0.1", "localhost", "::1":
|
||||
host := reqHost
|
||||
if h, _, err := net.SplitHostPort(reqHost); err == nil {
|
||||
host = h
|
||||
}
|
||||
if host == "" {
|
||||
return cfg
|
||||
}
|
||||
if strings.Contains(host, ":") {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
if p := u.Port(); p != "" {
|
||||
host += ":" + p
|
||||
}
|
||||
u.Host = host
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ type sharesVM struct {
|
||||
NForm nfsForm
|
||||
ApplyErr string // last Apply failure: config saved but not live
|
||||
Squashes []string
|
||||
External []shares.ExternalShare // shares of other services (drop-ins), read-only
|
||||
}
|
||||
|
||||
func (s *Server) applyErr() string {
|
||||
@@ -96,6 +97,7 @@ func (s *Server) sharesView(st sched.Status, form smbForm, nform nfsForm) shares
|
||||
vm.NForm.Squash = "root"
|
||||
}
|
||||
}
|
||||
vm.External = s.shm.External()
|
||||
if u, err := s.shm.ListUsers(); err == nil {
|
||||
vm.Users = u
|
||||
} else {
|
||||
|
||||
@@ -59,6 +59,7 @@ func withShareRunner(srv *Server, f *shareRunner) {
|
||||
srv.shm = shares.New(f.run)
|
||||
srv.shm.LookPath = func(string) (string, error) { return "/usr/sbin/x", nil }
|
||||
srv.shm.Owner, srv.shm.UID, srv.shm.GID = "ansible", "1000", "1000"
|
||||
srv.shm.IncludeDir, srv.shm.ExportsDir = "", ""
|
||||
}
|
||||
|
||||
func TestSharesSMBFlow(t *testing.T) {
|
||||
|
||||
@@ -104,6 +104,26 @@
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
{{with .S.RepoISO}}
|
||||
<section class="card" id="repoiso">
|
||||
<h2>RepoISO {{if $.RepoISO}}<a class="muted small" href="{{$.RepoISO}}" target="_blank" rel="noopener">open ↗</a>{{end}}
|
||||
{{if not .Reachable}}<span class="badge crit">not responding</span>
|
||||
{{else if not .WorkerAlive}}<span class="badge warn">worker stopped</span>
|
||||
{{else}}<span class="badge ok">running</span>{{end}}
|
||||
{{if .Version}}<span class="muted small">v{{.Version}}</span>{{end}}</h2>
|
||||
{{if not .Reachable}}<div class="flash crit">{{.URL}}/api/integration/status: {{.Error}}</div>{{else}}
|
||||
<div class="tablewrap"><table class="compact">
|
||||
<tbody>
|
||||
<tr><td class="muted">Images</td><td class="num">{{.Images}}</td><td>{{bytes .ImagesBytes}}{{if .ImagesPending}} · <span class="muted">{{.ImagesPending}} being checked</span>{{end}}{{if .ImagesError}} · <span class="crit">{{.ImagesError}} with errors</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Publications</td><td class="num">{{.Publications}}</td><td>{{if .PublicationsError}}<span class="badge warn">{{.PublicationsError}} not mounted</span>{{else}}<span class="muted">HTTP repositories / file trees</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Jobs</td><td class="num">{{.JobsActive}}</td><td>{{if .JobsActive}}active{{else}}<span class="muted">idle</span>{{end}}{{if .JobsFailed24h}} · <span class="badge warn">{{.JobsFailed24h}} failed in 24 h</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Proxmox exports</td><td class="num">{{.Exports}}</td><td class="muted">NFS / SMB shares — listed on <a href="/shares#external">Shares</a></td></tr>
|
||||
<tr><td class="muted">Worker</td><td></td><td class="small">{{if .WorkerHeartbeat}}last heartbeat {{.WorkerHeartbeat}}{{else}}no heartbeat yet{{end}}</td></tr>
|
||||
</tbody>
|
||||
</table></div>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="card">
|
||||
<h2>Datasets</h2>
|
||||
<div class="tablewrap"><table>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="/shares" {{if eq .Page "shares"}}class="on"{{end}}>Shares</a>
|
||||
<a href="/alerts" {{if eq .Page "alerts"}}class="on"{{end}}>Alerts{{if .Alerts}} <span class="badge crit">{{.Alerts}}</span>{{end}}</a>
|
||||
<a href="/settings" {{if eq .Page "settings"}}class="on"{{end}}>Settings</a>
|
||||
{{if .RepoISO}}<a href="{{.RepoISO}}" target="_blank" rel="noopener" title="ISO library, repositories, Proxmox exports">RepoISO ↗</a>{{end}}
|
||||
</nav>
|
||||
<span class="refresh" id="refresh-state" title="auto-refresh"></span>
|
||||
</header>
|
||||
|
||||
@@ -67,6 +67,17 @@
|
||||
<p class="muted small">Tests start only for disks that are awake. A long test on a large HDD takes many hours; disk stays usable.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Integrations</h2>
|
||||
<div class="form-grid">
|
||||
<label class="wide">RepoISO URL <input name="repoiso_url" value="{{$c.Integrations.RepoISOURL}}" placeholder="http://127.0.0.1"></label>
|
||||
</div>
|
||||
<p class="muted small">RepoISO (ISO library, apt/dnf repositories, Proxmox ISO exports) on this host. nasmon polls
|
||||
<span class="mono"><url>/api/integration/status</span> every minute, shows a dashboard card, alerts when the portal
|
||||
or its worker is down, and adds a menu link. Use the local address (<span class="mono">http://127.0.0.1</span>) — the
|
||||
status endpoint only answers on loopback. Empty disables the integration.</p>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" name="action" value="save">Save</button>
|
||||
<button type="submit" name="action" value="test" class="secondary">Save & send test e-mail</button>
|
||||
|
||||
@@ -140,5 +140,26 @@
|
||||
</details>
|
||||
<p class="muted small">Linux: <span class="mono">sudo mount -t nfs {{.Host}}:/nas_pool_1_8_gb/documents /mnt/nas</span>; list exports with <span class="mono">showmount -e {{.Host}}</span>.</p>
|
||||
</section>
|
||||
|
||||
{{if .External}}
|
||||
<section class="card" id="external">
|
||||
<h2>Shares of other services <span class="muted small">read-only</span></h2>
|
||||
<p class="muted small">Defined by other software on this host in <span class="mono">/etc/samba/smb.conf.d/*.conf</span>
|
||||
(included into smb.conf) and <span class="mono">/etc/exports.d/*.exports</span>. Manage them in that software;
|
||||
nasmon keeps smbd / nfs-server running while any of them exist.</p>
|
||||
<div class="tablewrap"><table>
|
||||
<thead><tr><th>Type</th><th>Source</th><th>Name</th><th>Path</th><th>Clients</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .External}}<tr>
|
||||
<td><span class="badge">{{if eq .Kind "smb"}}SMB{{else}}NFS{{end}}</span></td>
|
||||
<td>{{if eq .Source "repoiso"}}{{if $.RepoISO}}<a href="{{$.RepoISO}}/proxmox" target="_blank" rel="noopener">RepoISO</a>{{else}}RepoISO{{end}}{{else}}{{.Source}}{{end}}</td>
|
||||
<td class="mono">{{if .Name}}\\{{$.Data.Host}}\{{.Name}}{{else}}—{{end}}</td>
|
||||
<td class="mono small">{{.Path}}</td>
|
||||
<td class="mono small">{{.Clients}}</td>
|
||||
</tr>{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<div><span class="muted">fragmentation</span> {{.FragPct}}%</div>
|
||||
<div><span class="muted">last scrub</span> {{if .LastScrub}}{{dtp .LastScrub}} ({{agop .LastScrub}}){{else}}never{{end}}</div>
|
||||
<div><span class="muted">errors</span> {{if .Errors}}{{.Errors}}{{else}}—{{end}}</div>
|
||||
<div><span class="muted">dedup</span> {{with index $.Data.PoolDedup .Name}}{{.}}{{else}}—{{end}}{{if .DedupRatio}} (ratio {{printf "%.2f" .DedupRatio}}x){{end}}</div>
|
||||
</div>
|
||||
<div class="bar"><span class="fill {{capClass .CapPct $.Data.Th.PoolCapacityPct 95}}" style="width:{{.CapPct}}%"></span></div>
|
||||
{{if .Scan}}<p class="small"><span class="muted">scan:</span> {{.Scan}}</p>{{end}}
|
||||
@@ -31,6 +32,21 @@
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{$dedup := index $.Data.PoolDedup .Name}}
|
||||
<details class="dedup dedup-{{.Name}}">
|
||||
<summary>Deduplication</summary>
|
||||
<form method="post" action="/zfs/dedup" class="inline">
|
||||
<input type="hidden" name="pool" value="{{.Name}}">
|
||||
<label>Dedup for {{.Name}}
|
||||
<select name="dedup">
|
||||
<option value="off" {{if or (eq $dedup "off") (eq $dedup "")}}selected{{end}}>off</option>
|
||||
<option value="on" {{if and (ne $dedup "off") (ne $dedup "")}}selected{{end}}>on</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Apply</button>
|
||||
</form>
|
||||
<p class="small muted">Sets dedup on the pool root dataset; child datasets inherit it unless they override it. Only newly written data is deduplicated, and turning it off keeps the existing dedup table. The dedup table needs a lot of RAM (up to ~5 GB per 1 TB of unique data) — enable it only for data with many duplicate blocks.</p>
|
||||
</details>
|
||||
<details class="newds newds-{{.Name}}" {{if eq $form.Pool .Name}}open{{end}}>
|
||||
<summary>New dataset in {{.Name}}</summary>
|
||||
<form method="post" action="/zfs/create" class="ds-form">
|
||||
@@ -46,6 +62,13 @@
|
||||
<option value="off" {{if eq $form.Compression "off"}}selected{{end}}>off</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Dedup
|
||||
<select name="dedup">
|
||||
<option value="" {{if eq $form.Dedup ""}}selected{{end}}>inherit</option>
|
||||
<option value="on" {{if eq $form.Dedup "on"}}selected{{end}}>on</option>
|
||||
<option value="off" {{if eq $form.Dedup "off"}}selected{{end}}>off</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Quota <input name="quota" placeholder="none, or 100G / 1.5T" value="{{if eq $form.Pool .Name}}{{$form.Quota}}{{end}}"></label>
|
||||
</div>
|
||||
<p class="small muted">Creates {{.Name}}/<name> (parents are created too), mounts it at /{{.Name}}/<name> and chowns the mountpoint to the owner.</p>
|
||||
|
||||
@@ -68,12 +68,12 @@ func newTestServer(t *testing.T) (*Server, *sched.Scheduler, *store.Store) {
|
||||
},
|
||||
Pools: []collect.Pool{
|
||||
{Name: "tank", Health: "DEGRADED", Size: 8 << 40, Alloc: 3 << 40, Free: 5 << 40, CapPct: 37, FragPct: 5, Scan: "scrub repaired 0B", LastScrub: &old,
|
||||
Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors",
|
||||
Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors", DedupRatio: 1.42,
|
||||
Vdevs: []collect.Vdev{{Name: "tank", State: "DEGRADED"}, {Name: "mirror-0", Depth: 1, State: "DEGRADED"}, {Name: "sda", Depth: 2, State: "ONLINE", Cksum: 2}, {Name: "123", Depth: 2, State: "UNAVAIL", Note: "was /dev/sdb1"}}},
|
||||
{Name: "tmp_pool", Health: "ONLINE", Size: 1 << 40, Alloc: 900 << 30, Free: 124 << 30, CapPct: 88, Scrubbing: true, Scan: "scrub in progress"},
|
||||
},
|
||||
Datasets: []collect.Dataset{
|
||||
{Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank"},
|
||||
{Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank", Dedup: "on"},
|
||||
{Name: "tank/empty", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/empty"},
|
||||
{Name: "tank/data", Used: 3 << 40, Avail: 5 << 40, Refer: 3 << 40, Mountpoint: "/tank/data"},
|
||||
{Name: "tank/parent", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent"},
|
||||
@@ -245,7 +245,7 @@ func TestZfsPageControls(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/zfs?msg=hello", nil))
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm"} {
|
||||
for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm", `action="/zfs/dedup"`, "Dedup for tmp_pool", "ratio 1.42x"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("zfs page missing %q", want)
|
||||
}
|
||||
@@ -274,6 +274,7 @@ func TestZfsCreate(t *testing.T) {
|
||||
{"pool": {"tank"}, "name": {"x"}, "quota": {"lots"}}, // bad quota
|
||||
{"pool": {"tank"}, "name": {"x"}, "owner": {"ansible"}}, // bad owner
|
||||
{"pool": {"tank"}, "name": {"x"}, "compression": {"gzip"}},
|
||||
{"pool": {"tank"}, "name": {"x"}, "dedup": {"verify"}},
|
||||
}
|
||||
for _, form := range bad {
|
||||
f.calls = nil
|
||||
@@ -289,11 +290,11 @@ func TestZfsCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "quota": {"100g"}})
|
||||
rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "dedup": {"on"}, "quota": {"100g"}})
|
||||
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "created") {
|
||||
t.Fatalf("status %d location %q body %s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
||||
}
|
||||
want := "zfs create -p -o compression=lz4 -o quota=100G tank/media/video"
|
||||
want := "zfs create -p -o compression=lz4 -o dedup=on -o quota=100G tank/media/video"
|
||||
if len(f.calls) != 3 || f.calls[0] != want || f.calls[2] != "chown 1000:1000 /tank/media/video" {
|
||||
t.Errorf("calls = %v", f.calls)
|
||||
}
|
||||
@@ -307,6 +308,44 @@ func TestZfsCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestZfsDedup(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
f := &fakeRunner{}
|
||||
srv.admin = zfsadm.New(f.run)
|
||||
|
||||
bad := []url.Values{
|
||||
{"pool": {"nope"}, "dedup": {"on"}}, // unknown pool
|
||||
{"pool": {"tank/data"}, "dedup": {"on"}}, // not a pool
|
||||
{"pool": {"tank"}, "dedup": {"verify"}}, // unsupported value
|
||||
{"pool": {"tank"}}, // missing value
|
||||
}
|
||||
for _, form := range bad {
|
||||
f.calls = nil
|
||||
rec := postForm(srv, "/zfs/dedup", form)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Dedup not changed") {
|
||||
t.Errorf("%v: status %d, no error shown", form, rec.Code)
|
||||
}
|
||||
if len(f.calls) != 0 {
|
||||
t.Errorf("%v: commands ran: %v", form, f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
rec := postForm(srv, "/zfs/dedup", url.Values{"pool": {"tmp_pool"}, "dedup": {"on"}})
|
||||
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "#tmp_pool") {
|
||||
t.Fatalf("status %d location %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if len(f.calls) != 1 || f.calls[0] != "zfs set dedup=on tmp_pool" {
|
||||
t.Errorf("calls = %v", f.calls)
|
||||
}
|
||||
|
||||
f = &fakeRunner{fail: "zfs set"}
|
||||
srv.admin = zfsadm.New(f.run)
|
||||
rec = postForm(srv, "/zfs/dedup", url.Values{"pool": {"tank"}, "dedup": {"off"}})
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "cannot do that") {
|
||||
t.Errorf("failure not reported: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZfsDestroy(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
f := &fakeRunner{}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package zfsadm performs the few mutating ZFS operations the UI offers
|
||||
// (create / destroy datasets). Commands go through a Runner so they can be
|
||||
// (create / destroy datasets, toggle pool dedup). Commands go through a Runner so they can be
|
||||
// executed on the host via systemd-run: nasmon runs in a sandboxed mount
|
||||
// namespace (ProtectSystem=strict), where a plain "zfs create" would mount the
|
||||
// new dataset only inside the service and leave the host without it.
|
||||
@@ -59,6 +59,7 @@ func (a *Admin) ctx() (context.Context, context.CancelFunc) {
|
||||
// CreateOpts are the user-visible knobs of "zfs create".
|
||||
type CreateOpts struct {
|
||||
Compression string // "", lz4, zstd, off ("" = inherit)
|
||||
Dedup string // "", on, off ("" = inherit)
|
||||
Quota string // "", or a size like 100G / 1.5T
|
||||
Owner string // uid:gid applied to the mountpoint; "" = leave root
|
||||
}
|
||||
@@ -71,6 +72,9 @@ func (a *Admin) Create(full string, o CreateOpts) error {
|
||||
if err := ValidateCompression(o.Compression); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateDedup(o.Dedup); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateSize(o.Quota); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -81,6 +85,9 @@ func (a *Admin) Create(full string, o CreateOpts) error {
|
||||
if o.Compression != "" {
|
||||
args = append(args, "-o", "compression="+o.Compression)
|
||||
}
|
||||
if o.Dedup != "" {
|
||||
args = append(args, "-o", "dedup="+o.Dedup)
|
||||
}
|
||||
if o.Quota != "" {
|
||||
args = append(args, "-o", "quota="+o.Quota)
|
||||
}
|
||||
@@ -106,6 +113,25 @@ func (a *Admin) Create(full string, o CreateOpts) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDedup sets the dedup property (on/off) on the root dataset of pool, so it
|
||||
// applies to every dataset that does not override it. Only newly written
|
||||
// blocks are affected either way.
|
||||
func (a *Admin) SetDedup(pool, value string) error {
|
||||
if err := ValidateFullName(pool); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(pool, "/") {
|
||||
return errors.New("dedup is set on the pool root dataset only")
|
||||
}
|
||||
if value != "on" && value != "off" {
|
||||
return errors.New("dedup must be on or off")
|
||||
}
|
||||
ctx, cancel := a.ctx()
|
||||
defer cancel()
|
||||
_, err := a.Run(ctx, "zfs", "set", "dedup="+value, pool)
|
||||
return err
|
||||
}
|
||||
|
||||
// EmptyRefer is the upper bound of "referenced" bytes for a dataset we still
|
||||
// consider empty (a fresh dataset refers ~96-128 KiB of metadata).
|
||||
const EmptyRefer = 1 << 20
|
||||
@@ -206,6 +232,14 @@ func ValidateCompression(s string) error {
|
||||
return errors.New("compression must be inherit, lz4, zstd or off")
|
||||
}
|
||||
|
||||
func ValidateDedup(s string) error {
|
||||
switch s {
|
||||
case "", "on", "off":
|
||||
return nil
|
||||
}
|
||||
return errors.New("dedup must be inherit, on or off")
|
||||
}
|
||||
|
||||
func ValidateOwner(s string) error {
|
||||
if s == "" || reOwner.MatchString(s) {
|
||||
return nil
|
||||
|
||||
@@ -31,6 +31,9 @@ func TestValidators(t *testing.T) {
|
||||
if ValidateCompression("gzip") == nil || ValidateCompression("lz4") != nil || ValidateCompression("") != nil {
|
||||
t.Error("compression validation")
|
||||
}
|
||||
if ValidateDedup("verify") == nil || ValidateDedup("on") != nil || ValidateDedup("") != nil {
|
||||
t.Error("dedup validation")
|
||||
}
|
||||
if ValidateOwner("ansible") == nil || ValidateOwner("1000:1000") != nil || ValidateOwner("") != nil {
|
||||
t.Error("owner validation")
|
||||
}
|
||||
@@ -74,6 +77,16 @@ func TestCreate(t *testing.T) {
|
||||
t.Errorf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
|
||||
// dedup goes between compression and quota
|
||||
f = &fake{}
|
||||
a = New(f.run)
|
||||
if err := a.Create("tank/d", CreateOpts{Compression: "zstd", Dedup: "on", Quota: "1T"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.calls) != 1 || f.calls[0] != "zfs create -p -o compression=zstd -o dedup=on -o quota=1T tank/d" {
|
||||
t.Errorf("calls = %v", f.calls)
|
||||
}
|
||||
|
||||
// no owner, inherit everything
|
||||
f = &fake{}
|
||||
a = New(f.run)
|
||||
@@ -115,6 +128,28 @@ func TestCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDedup(t *testing.T) {
|
||||
f := &fake{}
|
||||
a := New(f.run)
|
||||
for _, c := range [][2]string{{"tank/x", "on"}, {"tank", "verify"}, {"tank", ""}, {"-tank", "on"}, {"", "off"}} {
|
||||
if err := a.SetDedup(c[0], c[1]); err == nil {
|
||||
t.Errorf("%q=%q accepted", c[0], c[1])
|
||||
}
|
||||
}
|
||||
if len(f.calls) != 0 {
|
||||
t.Errorf("rejected input ran commands: %v", f.calls)
|
||||
}
|
||||
if err := a.SetDedup("tank", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.SetDedup("tank", "off"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(f.calls, "|") != "zfs set dedup=on tank|zfs set dedup=off tank" {
|
||||
t.Errorf("calls = %v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestroyEmpty(t *testing.T) {
|
||||
ok := map[string]string{"zfs list -Hp -r -t filesystem": "tank/x\t98304", "zfs list -H -t snapshot": ""}
|
||||
f := &fake{reply: ok}
|
||||
|
||||
+30
-1
@@ -14,8 +14,11 @@ periodic SMART/ZFS checks with e-mail alerts.
|
||||
|
||||
The ZFS page (http://<host>:8090/zfs) can create datasets in a pool — name (nested
|
||||
names like media/video are fine), owner uid:gid for the mountpoint, compression,
|
||||
optional quota — and destroy datasets that are empty (no data, no children, no
|
||||
dedup, optional quota — and destroy datasets that are empty (no data, no children, no
|
||||
snapshots; the check is repeated against the live system before "zfs destroy").
|
||||
Each pool card also shows the dedup ratio and can turn dedup on/off on the pool
|
||||
root dataset (`zfs set dedup=on|off <pool>`, inherited by child datasets; only new
|
||||
writes are affected, and the dedup table needs a lot of RAM).
|
||||
|
||||
nasmon runs sandboxed (ProtectSystem=strict → own mount namespace), so these
|
||||
commands are executed on the host through transient units:
|
||||
@@ -44,3 +47,29 @@ Alerts: the × on an open alert acknowledges it (hidden and not mailed until the
|
||||
problem changes or clears); the × on a resolved alert removes it from the log.
|
||||
Resolved alerts older than 7 days are hidden by default, older than 90 days
|
||||
deleted.
|
||||
|
||||
## Shares of other services
|
||||
|
||||
Other software on the host can publish shares without fighting over the
|
||||
config files nasmon owns:
|
||||
|
||||
* SMB: drop a fragment into /etc/samba/smb.conf.d/<name>.conf. nasmon appends
|
||||
"include = <file>" for every *.conf there to the smb.conf it generates.
|
||||
* NFS: write /etc/exports.d/<name>.exports.
|
||||
|
||||
nasmon lists these shares read-only on /shares and keeps smbd / nfs-server
|
||||
enabled while any of them exist, even with no nasmon shares (previously it
|
||||
disabled the daemons and took such shares down). When SMB is switched off in
|
||||
nasmon, only nasmon's own shares are removed.
|
||||
|
||||
## RepoISO integration
|
||||
|
||||
Settings → Integrations → RepoISO URL (e.g. http://127.0.0.1). nasmon then
|
||||
polls <url>/api/integration/status every minute and
|
||||
|
||||
* shows a RepoISO card on the dashboard (images, publications, jobs, Proxmox
|
||||
exports, worker heartbeat) and a "RepoISO" menu link;
|
||||
* alerts: crit when the portal does not respond, warn when its worker has no
|
||||
heartbeat, when jobs failed in the last 24 h or publications are not mounted.
|
||||
|
||||
The status endpoint answers only on loopback, so use the local address.
|
||||
|
||||
Reference in New Issue
Block a user