Shares of other services and RepoISO integration
build / build (push) Successful in 1m43s

- include SMB drop-ins from /etc/samba/smb.conf.d into the generated smb.conf
- keep smbd/nfs-server running while other services (/etc/exports.d, drop-ins) share,
  instead of disabling them when nasmon has no shares of its own
- list those shares read-only on /shares
- settings: RepoISO URL; poll its /api/integration/status, dashboard card, menu link,
  alerts for portal down, stale worker, failed jobs, unmounted publications

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:06:17 +03:00
co-authored by Claude Opus 5.5
parent 661a862f8d
commit 30dbb00dd1
22 changed files with 656 additions and 19 deletions
+23 -8
View File
@@ -29,12 +29,17 @@ type Manager struct {
UID, GID string
SMBConf string
Exports string
Timeout time.Duration
// 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
}
+128
View File
@@ -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
}
+111
View File
@@ -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")
}
}
+9 -2
View File
@@ -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()
}
+1
View File
@@ -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
}