feat: add motd-assist TUI for managing dynamic MOTDs
Initial Go project built with Bubble Tea and Lip Gloss: - Live preview and management of /etc/update-motd.d scripts - Embedded presets (ubuntu-default, minimal, server-dashboard) - Bundled POSIX sh style scripts (brand header, sysinfo panel, footer) - Snapshot/restore safety around applying changes - CLI subcommands preview, apply, restore, list-presets - Unit tests for detect, sanitize, and apply logic
This commit is contained in:
commit
d56659e21a
34 changed files with 3260 additions and 0 deletions
344
internal/motd/apply.go
Normal file
344
internal/motd/apply.go
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"motd-assist/internal/assets"
|
||||
)
|
||||
|
||||
const (
|
||||
modeEnabled os.FileMode = 0o755
|
||||
modeDisabled os.FileMode = 0o644
|
||||
)
|
||||
|
||||
// Validate prüft einen Plan gegen das Dateisystem, bevor Root-Operationen
|
||||
// ausgeführt werden. Alle Namen müssen einfach bleiben und im
|
||||
// update-motd.d-Verzeichnis liegen; fremde Dateien sind tabu.
|
||||
func Validate(root string, plan Plan) error {
|
||||
dir := MotdDir(root)
|
||||
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
|
||||
return fmt.Errorf("%s existiert nicht", dir)
|
||||
}
|
||||
|
||||
existing := make(map[string]bool)
|
||||
listing, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range listing {
|
||||
existing[e.Name()] = true
|
||||
}
|
||||
|
||||
for _, op := range plan.Chmod {
|
||||
if !validName.MatchString(op.Name) {
|
||||
return fmt.Errorf("ungültiger Name %q", op.Name)
|
||||
}
|
||||
if !existing[op.Name] {
|
||||
return fmt.Errorf("chmod: %s existiert nicht", op.Name)
|
||||
}
|
||||
}
|
||||
for _, op := range plan.Removes {
|
||||
if !validName.MatchString(op.Name) {
|
||||
return fmt.Errorf("ungültiger Name %q", op.Name)
|
||||
}
|
||||
if !existing[op.Name] {
|
||||
return fmt.Errorf("remove: %s existiert nicht", op.Name)
|
||||
}
|
||||
if !isBundledFile(filepath.Join(dir, op.Name)) {
|
||||
return fmt.Errorf("remove: %s ist kein motd-assist-Script", op.Name)
|
||||
}
|
||||
}
|
||||
|
||||
renamedAway := make(map[string]bool, len(plan.Renames))
|
||||
for _, op := range plan.Renames {
|
||||
if !validName.MatchString(op.From) || !validName.MatchString(op.To) {
|
||||
return fmt.Errorf("ungültiger Name in Umbenennung %q → %q", op.From, op.To)
|
||||
}
|
||||
if !existing[op.From] {
|
||||
return fmt.Errorf("rename: %s existiert nicht", op.From)
|
||||
}
|
||||
renamedAway[op.From] = true
|
||||
}
|
||||
// Zielnamen dürfen nur frei werden, wenn die Datei dort wegrename'd oder
|
||||
// entfernt wird – sonst Kollision.
|
||||
for _, op := range plan.Renames {
|
||||
if existing[op.To] && !renamedAway[op.To] {
|
||||
isRemoved := false
|
||||
for _, r := range plan.Removes {
|
||||
if r.Name == op.To {
|
||||
isRemoved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isRemoved {
|
||||
return fmt.Errorf("rename: Ziel %s ist belegt", op.To)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, op := range plan.Installs {
|
||||
if !validName.MatchString(op.Name) {
|
||||
return fmt.Errorf("ungültiger Name %q", op.Name)
|
||||
}
|
||||
if _, ok := assets.ScriptContent(op.Source); !ok {
|
||||
return fmt.Errorf("install: unbekannte Vorlage %q", op.Source)
|
||||
}
|
||||
if existing[op.Name] && !isBundledFile(filepath.Join(dir, op.Name)) {
|
||||
return fmt.Errorf("install: %s wäre von einem fremden Script überschrieben", op.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isBundledFile meldet, ob die Datei unter path einer gebündelten Vorlage
|
||||
// entspricht (oder nicht lesbar ist, dann false).
|
||||
func isBundledFile(path string) bool {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := assets.MatchScriptBytes(b)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Snapshot sichert den Zustand vor einem Apply, damit Restore möglich ist.
|
||||
type Snapshot struct {
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
Scripts []SnapshotScript `json:"scripts"`
|
||||
}
|
||||
|
||||
// SnapshotScript hält Name, Modus und (falls gebündelt) die Vorlage fest.
|
||||
type SnapshotScript struct {
|
||||
Name string `json:"name"`
|
||||
Mode uint32 `json:"mode"`
|
||||
Bundled bool `json:"bundled"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// SnapshotPath liefert den Ablageort des letzten Snapshots.
|
||||
func SnapshotPath(root string) string {
|
||||
return filepath.Join(root, "var", "lib", "motd-assist", "last-snapshot.json")
|
||||
}
|
||||
|
||||
// WriteFile schreibt den Plan als temporäre JSON-Datei, z.B. für die
|
||||
// Übergabe an "motd-assist apply-state" unter sudo.
|
||||
func (p Plan) WriteFile() (string, error) {
|
||||
b, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
f, err := os.CreateTemp("", "motd-assist-plan-*.json")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Write(b); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
// ReadPlanFile liest eine mit WriteFile erzeugte Plandatei.
|
||||
func ReadPlanFile(path string) (Plan, error) {
|
||||
var plan Plan
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &plan); err != nil {
|
||||
return plan, fmt.Errorf("ungültige Plandatei: %w", err)
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func writeSnapshot(root string) error {
|
||||
scripts, err := Discover(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snap := Snapshot{SavedAt: time.Now(), Scripts: make([]SnapshotScript, 0, len(scripts))}
|
||||
for _, s := range scripts {
|
||||
ss := SnapshotScript{Name: s.Name, Bundled: s.Bundled}
|
||||
if fi, err := os.Stat(s.Path); err == nil {
|
||||
ss.Mode = uint32(fi.Mode().Perm())
|
||||
}
|
||||
if s.Bundled {
|
||||
if b, err := os.ReadFile(s.Path); err == nil {
|
||||
if src, ok := assets.MatchScript(string(b)); ok {
|
||||
ss.Source = src
|
||||
}
|
||||
}
|
||||
}
|
||||
snap.Scripts = append(snap.Scripts, ss)
|
||||
}
|
||||
path := SnapshotPath(root)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(snap, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(b, '\n'), 0o644)
|
||||
}
|
||||
|
||||
// Execute führt einen validierten Plan aus: Snapshot → Removes →
|
||||
// Umbenennungen (zweiphasig gegen Kollisionen) → Installationen → Modi.
|
||||
// Erfordert Root, wenn /etc/update-motd.d Root gehört.
|
||||
func Execute(root string, plan Plan) error {
|
||||
if err := Validate(root, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeSnapshot(root); err != nil {
|
||||
return fmt.Errorf("Snapshot fehlgeschlagen: %w", err)
|
||||
}
|
||||
dir := MotdDir(root)
|
||||
|
||||
// Reihenfolge: Removes → Chmods (alte Namen!) → Renames → Installs.
|
||||
for _, op := range plan.Removes {
|
||||
if err := os.Remove(filepath.Join(dir, op.Name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, op := range plan.Chmod {
|
||||
mode := modeDisabled
|
||||
if op.Enable {
|
||||
mode = modeEnabled
|
||||
}
|
||||
if err := os.Chmod(filepath.Join(dir, op.Name), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Zweiphasige Umbenennung: erst alle nach .tmp-N (Discover ignoriert
|
||||
// Punktnamen), dann auf die finalen Namen.
|
||||
if len(plan.Renames) > 0 {
|
||||
for i := range plan.Renames {
|
||||
tmp := fmt.Sprintf(".ma-tmp-%d", i)
|
||||
if err := os.Rename(filepath.Join(dir, plan.Renames[i].From), filepath.Join(dir, tmp)); err != nil {
|
||||
return err
|
||||
}
|
||||
plan.Renames[i].From = tmp
|
||||
}
|
||||
for _, op := range plan.Renames {
|
||||
if err := os.Rename(filepath.Join(dir, op.From), filepath.Join(dir, op.To)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, op := range plan.Installs {
|
||||
content, _ := assets.ScriptContent(op.Source)
|
||||
mode := modeDisabled
|
||||
if op.Enabled {
|
||||
mode = modeEnabled
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, op.Name), []byte(content), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore setzt den letzten Snapshot bestmöglich zurück (Modi, Namen,
|
||||
// gebündelte Installationen). Best-effort: Fehler werden gesammelt.
|
||||
func Restore(root string) error {
|
||||
b, err := os.ReadFile(SnapshotPath(root))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return errors.New("kein Snapshot vorhanden")
|
||||
}
|
||||
return err
|
||||
}
|
||||
var snap Snapshot
|
||||
if err := json.Unmarshal(b, &snap); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
current, err := Discover(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
curBySuffix := make(map[string]Script, len(current))
|
||||
for _, c := range current {
|
||||
curBySuffix[suffixOf(c.Name)] = c
|
||||
}
|
||||
snapBySuffix := make(map[string]SnapshotScript, len(snap.Scripts))
|
||||
for _, s := range snap.Scripts {
|
||||
snapBySuffix[suffixOf(s.Name)] = s
|
||||
}
|
||||
|
||||
var errs []error
|
||||
dir := MotdDir(root)
|
||||
|
||||
// Dateien entfernen, die es vor dem Apply nicht gab (nur unsere).
|
||||
for _, c := range current {
|
||||
if _, ok := snapBySuffix[suffixOf(c.Name)]; !ok && c.Bundled {
|
||||
if err := os.Remove(c.Path); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fehlende gebündelte Scripte aus der Vorlage nachinstallieren.
|
||||
var renames []RenameOp
|
||||
for _, s := range snap.Scripts {
|
||||
if _, ok := curBySuffix[suffixOf(s.Name)]; ok {
|
||||
continue
|
||||
}
|
||||
if s.Bundled && s.Source != "" {
|
||||
content, ok := assets.ScriptContent(s.Source)
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Errorf("Vorlage %s fehlt für %s", s.Source, s.Name))
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, s.Name), []byte(content), os.FileMode(s.Mode)); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Namen und Modi wiederherstellen.
|
||||
for _, c := range current {
|
||||
s, ok := snapBySuffix[suffixOf(c.Name)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if c.Name != s.Name {
|
||||
renames = append(renames, RenameOp{From: c.Name, To: s.Name})
|
||||
}
|
||||
if err := os.Chmod(c.Path, os.FileMode(s.Mode)); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(renames) > 0 {
|
||||
for i := range renames {
|
||||
tmp := fmt.Sprintf(".ma-tmp-%d", i)
|
||||
if err := os.Rename(filepath.Join(dir, renames[i].From), filepath.Join(dir, tmp)); err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
renames[i].From = tmp
|
||||
}
|
||||
for _, op := range renames {
|
||||
if op.From == op.To {
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(filepath.Join(dir, op.From), filepath.Join(dir, op.To)); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("Restore mit Fehlern: %w", errors.Join(errs...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
73
internal/motd/apply_test.go
Normal file
73
internal/motd/apply_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupDir legt ein update-motd.d-Verzeichnis unter einem Temp-Root an.
|
||||
func setupDir(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
dir := MotdDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnknownNames(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{"00-header": "#!/bin/sh\necho hi\n"})
|
||||
|
||||
err := Validate(root, Plan{Chmod: []ChmodOp{{Name: "../evil", Enable: true}}})
|
||||
if err == nil {
|
||||
t.Error("Pfad-Traversal sollte abgelehnt werden")
|
||||
}
|
||||
err = Validate(root, Plan{Chmod: []ChmodOp{{Name: "gibt-es-nicht", Enable: true}}})
|
||||
if err == nil {
|
||||
t.Error("chmod auf fehlende Datei sollte abgelehnt werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsForeignOverwrite(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{"00-header": "#!/bin/sh\necho eigenes\n"})
|
||||
|
||||
err := Validate(root, Plan{Installs: []InstallOp{{Name: "00-header", Source: "footer", Enabled: true}}})
|
||||
if err == nil {
|
||||
t.Error("Install über fremdes Script sollte abgelehnt werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsForeignRemove(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{"10-sysinfo": "#!/bin/sh\necho eigenes\n"})
|
||||
|
||||
err := Validate(root, Plan{Removes: []RemoveOp{{Name: "10-sysinfo"}}})
|
||||
if err == nil {
|
||||
t.Error("Remove eines fremden Scripts sollte abgelehnt werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenameCollision(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{
|
||||
"00-a": "#!/bin/sh\n",
|
||||
"10-b": "#!/bin/sh\n",
|
||||
})
|
||||
|
||||
// b wird nicht wegbewegt → Ziel 10-b ist belegt.
|
||||
err := Validate(root, Plan{Renames: []RenameOp{{From: "00-a", To: "10-b"}}})
|
||||
if err == nil {
|
||||
t.Error("Kollision sollte abgelehnt werden")
|
||||
}
|
||||
|
||||
// Beide tauschen → zweiphasige Ausführung macht das frei.
|
||||
err = Validate(root, Plan{Renames: []RenameOp{{From: "00-a", To: "10-b"}, {From: "10-b", To: "00-a"}}})
|
||||
if err != nil {
|
||||
t.Errorf("Tausch sollte erlaubt sein: %v", err)
|
||||
}
|
||||
}
|
||||
118
internal/motd/apply_unix_test.go
Normal file
118
internal/motd/apply_unix_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
//go:build unix
|
||||
|
||||
package motd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"motd-assist/internal/assets"
|
||||
)
|
||||
|
||||
func TestExecuteAppliesPlanAndRestore(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{
|
||||
"00-header": "#!/bin/sh\necho header\n",
|
||||
"10-sysinfo": "#!/bin/sh\necho sysinfo\n",
|
||||
})
|
||||
dir := MotdDir(root)
|
||||
if err := os.Chmod(filepath.Join(dir, "10-sysinfo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
current, err := Discover(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Ziel: sysinfo vor header, sysinfo aktiv, header aus.
|
||||
desired := []DesiredEntry{
|
||||
{Name: "10-sysinfo", Enabled: true},
|
||||
{Name: "00-header", Enabled: false},
|
||||
}
|
||||
plan := Diff(current, desired)
|
||||
if plan.Empty() {
|
||||
t.Fatal("Plan sollte Änderungen enthalten")
|
||||
}
|
||||
if err := Execute(root, plan); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
after, err := Discover(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after) != 2 {
|
||||
t.Fatalf("nach Execute: %v", after)
|
||||
}
|
||||
if after[0].Name != "00-sysinfo" || !after[0].Enabled {
|
||||
t.Errorf("after[0] = %+v, want 00-sysinfo aktiv", after[0])
|
||||
}
|
||||
if after[1].Name != "10-header" || after[1].Enabled {
|
||||
t.Errorf("after[1] = %+v, want 10-header inaktiv", after[1])
|
||||
}
|
||||
if _, err := os.Stat(SnapshotPath(root)); err != nil {
|
||||
t.Errorf("Snapshot fehlt: %v", err)
|
||||
}
|
||||
|
||||
if err := Restore(root); err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
restored, err := Discover(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored[0].Name != "00-header" || !restored[0].Enabled {
|
||||
t.Errorf("restored[0] = %+v, want 00-header aktiv", restored[0])
|
||||
}
|
||||
if restored[1].Name != "10-sysinfo" || restored[1].Enabled {
|
||||
t.Errorf("restored[1] = %+v, want 10-sysinfo inaktiv", restored[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInstallAndRemove(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{
|
||||
"00-header": "#!/bin/sh\necho header\n",
|
||||
"95-footer": "#!/bin/sh\ntrue\n", // wird unten als gebündelt ersetzt
|
||||
})
|
||||
dir := MotdDir(root)
|
||||
// echtes gebündeltes Script einsetzen, damit Remove validiert
|
||||
content, ok := assets.ScriptContent("footer")
|
||||
if !ok {
|
||||
t.Fatal("footer-Vorlage nicht gefunden")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "95-footer"), []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
current, err := Discover(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
desired := []DesiredEntry{
|
||||
{Name: "10-brand-header", Enabled: true, Install: "brand-header"},
|
||||
{Name: "00-header", Enabled: true},
|
||||
{Name: "95-footer", Remove: true},
|
||||
}
|
||||
if err := Execute(root, Diff(current, desired)); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
after, err := Discover(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range after {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
want := []string{"00-brand-header", "10-header"}
|
||||
if len(names) != 2 || names[0] != want[0] || names[1] != want[1] {
|
||||
t.Errorf("names = %v, want %v", names, want)
|
||||
}
|
||||
if !after[0].Bundled {
|
||||
t.Error("brand-header sollte als gebündelt erkannt werden")
|
||||
}
|
||||
if !after[0].Enabled || !after[1].Enabled {
|
||||
t.Errorf("Modi falsch: %+v", after)
|
||||
}
|
||||
}
|
||||
74
internal/motd/detect.go
Normal file
74
internal/motd/detect.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HostInfo beschreibt den erkannten MOTD-Zustand des Systems.
|
||||
type HostInfo struct {
|
||||
Hostname string
|
||||
Distro string // PRETTY_NAME aus /etc/os-release
|
||||
PAM bool // pam_motd konfiguriert (dynamischer MOTD aktiv)
|
||||
PAMWhere []string // Dateien in /etc/pam.d mit pam_motd-Eintrag
|
||||
DynamicPath string // vorhandene Cache-Datei der dynamischen MOTD ("" = keine)
|
||||
StaticMOTD bool // /etc/motd vorhanden und nicht leer
|
||||
}
|
||||
|
||||
// DetectHost sammelt Host- und MOTD-Informationen unter root.
|
||||
// Fehler einzelner Erkennungen führen nicht zum Abbruch.
|
||||
func DetectHost(root string) HostInfo {
|
||||
info := HostInfo{Hostname: "unbekannt", Distro: "unbekannt"}
|
||||
|
||||
if h, err := os.Hostname(); err == nil && h != "" {
|
||||
info.Hostname = strings.SplitN(h, ".", 2)[0]
|
||||
}
|
||||
if pretty, ok := osReleaseValue(filepath.Join(root, "etc", "os-release"), "PRETTY_NAME"); ok {
|
||||
info.Distro = pretty
|
||||
}
|
||||
|
||||
for _, svc := range []string{"sshd", "login", "remote"} {
|
||||
b, err := os.ReadFile(filepath.Join(root, "etc", "pam.d", svc))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "pam_motd") {
|
||||
info.PAM = true
|
||||
info.PAMWhere = append(info.PAMWhere, svc)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, cand := range []string{"run/motd.dynamic", "run/motd"} {
|
||||
p := filepath.Join(root, cand)
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
info.DynamicPath = p
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if fi, err := os.Stat(filepath.Join(root, "etc", "motd")); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
info.StaticMOTD = true
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func osReleaseValue(path, key string) (string, bool) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(strings.TrimSpace(line), key+"="); ok {
|
||||
return strings.Trim(v, `"'`), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
40
internal/motd/discover_test.go
Normal file
40
internal/motd/discover_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiscoverListsValidNamesSorted(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := MotdDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"10-sysinfo", "00-header", "notes.txt", ".hidden", "README"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
scripts, err := Discover(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range scripts {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
// README ist ein valider run-parts-Name und bleibt dabei.
|
||||
if !reflect.DeepEqual(names, []string{"00-header", "10-sysinfo", "README"}) {
|
||||
t.Errorf("names = %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverMissingDir(t *testing.T) {
|
||||
if _, err := Discover(t.TempDir()); err == nil {
|
||||
t.Error("Fehler erwartet, wenn update-motd.d fehlt")
|
||||
}
|
||||
}
|
||||
107
internal/motd/preview.go
Normal file
107
internal/motd/preview.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"motd-assist/internal/assets"
|
||||
)
|
||||
|
||||
// scriptTimeout begrenzt die Laufzeit eines einzelnen Preview-Scripts
|
||||
// (langsame Netzwerk-Scripte wie 50-motd-news blockieren sonst die TUI).
|
||||
const scriptTimeout = 2 * time.Second
|
||||
|
||||
// PreviewScript ist ein Preview-Kandidat: entweder eine vorhandene Datei
|
||||
// (Path) oder eine noch nicht installierte gebündelte Vorlage (Source).
|
||||
type PreviewScript struct {
|
||||
Name string
|
||||
Path string
|
||||
Source string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Render führt die aktivierten Scripte in Reihenfolge aus und liefert den
|
||||
// bereinigten MOTD-Output. Fehler einzelner Scripte werden als dezente
|
||||
// Hinweiszeile eingebaut statt die ganze Preview abbrechen.
|
||||
func Render(scripts []PreviewScript, width int) string {
|
||||
var parts []string
|
||||
for _, s := range scripts {
|
||||
if !s.Enabled {
|
||||
continue
|
||||
}
|
||||
path := s.Path
|
||||
if path == "" {
|
||||
if s.Source == "" {
|
||||
continue
|
||||
}
|
||||
staged, cleanup, err := stagePreview(s.Source, s.Name)
|
||||
if err != nil {
|
||||
parts = append(parts, dimLine(fmt.Sprintf("%s: %v", s.Name, err)))
|
||||
continue
|
||||
}
|
||||
path = staged
|
||||
defer cleanup()
|
||||
}
|
||||
addPart(&parts, runScript(path, width), width)
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
func addPart(parts *[]string, raw string, width int) {
|
||||
if text := strings.TrimSpace(Sanitize(raw, width)); text != "" {
|
||||
*parts = append(*parts, text)
|
||||
}
|
||||
}
|
||||
|
||||
func runScript(path string, width int) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), scriptTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, path)
|
||||
cmd.Env = append(os.Environ(),
|
||||
fmt.Sprintf("COLUMNS=%d", width),
|
||||
"TERM=xterm-256color",
|
||||
)
|
||||
cmd.Dir = "/"
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return dimLine(fmt.Sprintf("%s: Timeout", filepath.Base(path)))
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
return dimLine(fmt.Sprintf("%s: nicht ausführbar (%v)", filepath.Base(path), err))
|
||||
}
|
||||
// Exit-Status != 0 ist bei MOTD-Scripten üblich; Output behalten.
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// stagePreview kopiert eine gebündelte Vorlage in ein Temporärverzeichnis,
|
||||
// damit die Live-Vorschau sie vor der Installation zeigen kann.
|
||||
func stagePreview(source, name string) (string, func(), error) {
|
||||
content, ok := assets.ScriptContent(source)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("unbekannte Vorlage %q", source)
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "motd-assist-")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", nil, err
|
||||
}
|
||||
return path, func() { os.RemoveAll(dir) }, nil
|
||||
}
|
||||
|
||||
func dimLine(s string) string { return "\x1b[2m" + s + "\x1b[0m" }
|
||||
6
internal/motd/root_other.go
Normal file
6
internal/motd/root_other.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
//go:build !unix
|
||||
|
||||
package motd
|
||||
|
||||
// IsRoot ist auf Nicht-Unix-Plattformen immer false (Zielsystem ist Linux).
|
||||
func IsRoot() bool { return false }
|
||||
8
internal/motd/root_unix.go
Normal file
8
internal/motd/root_unix.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
//go:build unix
|
||||
|
||||
package motd
|
||||
|
||||
import "os"
|
||||
|
||||
// IsRoot meldet, ob der Prozess mit EUID 0 läuft.
|
||||
func IsRoot() bool { return os.Geteuid() == 0 }
|
||||
120
internal/motd/sanitize.go
Normal file
120
internal/motd/sanitize.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
type escapeKind int
|
||||
|
||||
const (
|
||||
escNone escapeKind = iota
|
||||
escSGR
|
||||
)
|
||||
|
||||
// parseEscape klassifiziert eine Escape-Sequenz ab runes[0] == ESC und
|
||||
// liefert die konsumierte Rune-Anzahl. Nur SGR (Farben) ist relevant.
|
||||
func parseEscape(r []rune) (string, int, escapeKind) {
|
||||
if len(r) < 2 {
|
||||
return "", len(r), escNone
|
||||
}
|
||||
switch r[1] {
|
||||
case '[': // CSI: Parameter (0x30–0x3F), Zwischenzeichen (0x20–0x2F), Final
|
||||
j := 2
|
||||
for j < len(r) && r[j] >= 0x30 && r[j] <= 0x3f {
|
||||
j++
|
||||
}
|
||||
for j < len(r) && r[j] >= 0x20 && r[j] <= 0x2f {
|
||||
j++
|
||||
}
|
||||
if j >= len(r) {
|
||||
return "", len(r), escNone // unvollständig
|
||||
}
|
||||
if r[j] == 'm' {
|
||||
return string(r[:j+1]), j + 1, escSGR
|
||||
}
|
||||
return "", j + 1, escNone
|
||||
case ']', 'P', 'X', '^', '_': // OSC/DCS/SOS/PM/APC: bis BEL oder ST
|
||||
for j := 2; j < len(r); j++ {
|
||||
if r[j] == 0x07 {
|
||||
return "", j + 1, escNone
|
||||
}
|
||||
if r[j] == '\x1b' && j+1 < len(r) && r[j+1] == '\\' {
|
||||
return "", j + 2, escNone
|
||||
}
|
||||
}
|
||||
return "", len(r), escNone
|
||||
case '(', ')', '*', '+', '#', '%': // Charset-/Zeichensatz-Wechsel
|
||||
if len(r) >= 3 {
|
||||
return "", 3, escNone
|
||||
}
|
||||
return "", len(r), escNone
|
||||
default:
|
||||
return "", 2, escNone
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize bereinigt Script-Output für die Anzeige in der TUI: behalten
|
||||
// werden SGR-Farbsequenzen und Zeilenumbrüche; Cursor-Steuerung, OSC,
|
||||
// Charset-Switches und andere Steuerzeichen werden entfernt. Jede Zeile
|
||||
// wird auf width Spellen begrenzt und bei Farbnutzung mit Reset
|
||||
// abgeschlossen, damit die Pane-Umrandung nicht verfärbt wird.
|
||||
func Sanitize(s string, width int) string {
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
lineW, hasSGR := 0, false
|
||||
|
||||
runes := []rune(s)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
switch {
|
||||
case r == '\x1b':
|
||||
seq, n, kind := parseEscape(runes[i:])
|
||||
i += n - 1
|
||||
if kind == escSGR {
|
||||
b.WriteString(seq)
|
||||
hasSGR = true
|
||||
}
|
||||
case r == '\n':
|
||||
if hasSGR && !endsWithReset(&b) {
|
||||
b.WriteString("\x1b[0m")
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
lineW, hasSGR = 0, false
|
||||
case r == '\r', r == '\a', r == '\b', r == '\v', r == '\f':
|
||||
// ignorieren
|
||||
case r == '\t':
|
||||
for k := 0; k < 4 && lineW < width; k++ {
|
||||
b.WriteByte(' ')
|
||||
lineW++
|
||||
}
|
||||
case r < 0x20 || r == 0x7f:
|
||||
// übrige Steuerzeichen entfernen
|
||||
default:
|
||||
if lineW >= width {
|
||||
continue // Zeile abgeschnitten
|
||||
}
|
||||
w := runewidth.RuneWidth(r)
|
||||
if lineW+w > width {
|
||||
lineW = width // breites Zeichen passt nicht mehr → Schnittstelle
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
lineW += w
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if hasSGR && !strings.HasSuffix(out, "\x1b[0m") {
|
||||
out += "\x1b[0m" // letzte Zeile ohne Newline: offene Farbe schließen
|
||||
}
|
||||
return strings.TrimRight(out, "\n")
|
||||
}
|
||||
|
||||
// endsWithReset prüft, ob der Builder bereits auf Farbe zurückgesetzt ist.
|
||||
func endsWithReset(b *strings.Builder) bool {
|
||||
return strings.HasSuffix(b.String(), "\x1b[0m")
|
||||
}
|
||||
34
internal/motd/sanitize_test.go
Normal file
34
internal/motd/sanitize_test.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package motd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeKeepsSGRStripsRest(t *testing.T) {
|
||||
in := "\x1b[1;32mgrün\x1b[0m\x1b[2J\x1b[?25lnormal\r\nnext\x1b]0;title\x07"
|
||||
want := "\x1b[1;32mgrün\x1b[0mnormal\x1b[0m\nnext"
|
||||
if got := Sanitize(in, 40); got != want {
|
||||
t.Errorf("Sanitize() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeTruncatesToWidth(t *testing.T) {
|
||||
if got := Sanitize("abcdefghij", 4); got != "abcd" {
|
||||
t.Errorf("plain: got %q, want %q", got, "abcd")
|
||||
}
|
||||
got := Sanitize("\x1b[31mabcdefghij\x1b[0m", 4)
|
||||
want := "\x1b[31mabcd\x1b[0m"
|
||||
if got != want {
|
||||
t.Errorf("colored: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeExpandsTabsAndStripsCharset(t *testing.T) {
|
||||
if got := Sanitize("\x1b(Ba\tb", 20); got != "a b" {
|
||||
t.Errorf("got %q, want %q", got, "a b")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeDropsOtherControlChars(t *testing.T) {
|
||||
if got := Sanitize("a\x00\x07\x08\x0bb", 20); got != "ab" {
|
||||
t.Errorf("got %q, want %q", got, "ab")
|
||||
}
|
||||
}
|
||||
74
internal/motd/script.go
Normal file
74
internal/motd/script.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Package motd enthält die Domain-Logik: Discovery der update-motd.d-Scripte,
|
||||
// Diff/Plan von Soll-Zuständen, das Ausführen des Plans und die Preview.
|
||||
package motd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
|
||||
"motd-assist/internal/assets"
|
||||
)
|
||||
|
||||
// Script ist ein Eintrag in /etc/update-motd.d.
|
||||
type Script struct {
|
||||
Name string // Dateiname inkl. Nummern-Präfix, z.B. "00-header"
|
||||
Path string // Absoluter Pfad
|
||||
Enabled bool // Executable-Bit gesetzt (run-parts führt nur diese aus)
|
||||
Bundled bool // von motd-assist installiert (Inhalt entspricht einer Vorlage)
|
||||
}
|
||||
|
||||
// MotdDir liefert den Pfad des update-motd.d-Verzeichnisses unter root.
|
||||
func MotdDir(root string) string {
|
||||
return filepath.Join(root, "etc", "update-motd.d")
|
||||
}
|
||||
|
||||
// run-parts im pam_motd-Kontext ignoriert Dateien mit Punkten; wir halten
|
||||
// uns an dieselbe Namenskonvention ([A-Za-z0-9_-]).
|
||||
var validName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// Discover listet alle Scripte im update-motd.d-Verzeichnis in
|
||||
// run-parts-Reihenfolge (lexikalische Sortierung). Gebündelte Scripte
|
||||
// werden per Inhaltsvergleich erkannt.
|
||||
func Discover(root string) ([]Script, error) {
|
||||
dir := MotdDir(root)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update-motd.d nicht lesbar: %w", err)
|
||||
}
|
||||
scripts := make([]Script, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !validName.MatchString(e.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
s := Script{
|
||||
Name: e.Name(),
|
||||
Path: path,
|
||||
Enabled: info.Mode()&0o111 != 0,
|
||||
}
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
_, s.Bundled = assets.MatchScript(string(b))
|
||||
}
|
||||
scripts = append(scripts, s)
|
||||
}
|
||||
sort.Slice(scripts, func(i, j int) bool { return scripts[i].Name < scripts[j].Name })
|
||||
return scripts, nil
|
||||
}
|
||||
|
||||
// EnabledCount zählt die aktiven Scripte.
|
||||
func EnabledCount(scripts []Script) int {
|
||||
n := 0
|
||||
for _, s := range scripts {
|
||||
if s.Enabled {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
207
internal/motd/state.go
Normal file
207
internal/motd/state.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DesiredEntry beschreibt den Soll-Zustand eines Scripts.
|
||||
// Remove markiert ein vorhandenes (gebündeltes) Script zur Deinstallation.
|
||||
type DesiredEntry struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Install string
|
||||
Remove bool
|
||||
}
|
||||
|
||||
// Plan-Ops: Es werden nur Modi, Namen und gebündelte Installationen
|
||||
// geändert — niemals Inhalte fremder Scripte.
|
||||
type (
|
||||
ChmodOp struct {
|
||||
Name string `json:"name"`
|
||||
Enable bool `json:"enable"`
|
||||
}
|
||||
RenameOp struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
InstallOp struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
RemoveOp struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
)
|
||||
|
||||
// Plan ist der validier- und ausführbare Unterschied zwischen Ist und Soll.
|
||||
type Plan struct {
|
||||
Chmod []ChmodOp `json:"chmod,omitempty"`
|
||||
Renames []RenameOp `json:"renames,omitempty"`
|
||||
Installs []InstallOp `json:"installs,omitempty"`
|
||||
Removes []RemoveOp `json:"removes,omitempty"`
|
||||
}
|
||||
|
||||
// Empty meldet, ob der Plan nichts tut.
|
||||
func (p Plan) Empty() bool { return p.NumOps() == 0 }
|
||||
|
||||
// NumOps zählt alle Operationen.
|
||||
func (p Plan) NumOps() int {
|
||||
return len(p.Chmod) + len(p.Renames) + len(p.Installs) + len(p.Removes)
|
||||
}
|
||||
|
||||
// Summary liefert eine Kurzbeschreibung für Statusmeldungen.
|
||||
func (p Plan) Summary() string {
|
||||
var parts []string
|
||||
if n := len(p.Removes); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d entfernt", n))
|
||||
}
|
||||
if n := len(p.Installs); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d installiert", n))
|
||||
}
|
||||
if n := len(p.Renames); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d umbenannt", n))
|
||||
}
|
||||
if n := len(p.Chmod); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d an/ausgeschaltet", n))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "keine Änderungen"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
var prefixRe = regexp.MustCompile(`^([0-9]+)-`)
|
||||
|
||||
// suffixOf entfernt den führenden Nummern-Präfix ("00-header" → "header").
|
||||
func suffixOf(name string) string {
|
||||
if loc := prefixRe.FindStringIndex(name); loc != nil {
|
||||
return name[loc[1]:]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func prefixNum(name string) (float64, bool) {
|
||||
m := prefixRe.FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// targetName baut den kanonischen Namen für Position i (Schrittweite 10).
|
||||
func targetName(i int, name string) string {
|
||||
return fmt.Sprintf("%02d-%s", i*10, suffixOf(name))
|
||||
}
|
||||
|
||||
// DesiredFromPreset baut die vollständige Soll-Liste aus einem Preset:
|
||||
// Gelistete Scripte kommen in Preset-Reihenfolge, ungelistete System-Scripte
|
||||
// behalten Zustand und Position (interleaved über ihren Nummern-Präfix).
|
||||
// Ungelistete gebündelte Scripte werden zur Deinstallation markiert.
|
||||
func DesiredFromPreset(current []Script, entries []DesiredEntry) []DesiredEntry {
|
||||
curByName := make(map[string]Script, len(current))
|
||||
for _, c := range current {
|
||||
curByName[c.Name] = c
|
||||
}
|
||||
|
||||
type slot struct {
|
||||
key float64
|
||||
entry DesiredEntry
|
||||
}
|
||||
var slots []slot
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for i, e := range entries {
|
||||
if _, exists := curByName[e.Name]; e.Install == "" && !exists {
|
||||
continue // Script existiert auf diesem System nicht
|
||||
}
|
||||
if seen[e.Name] || e.Remove {
|
||||
continue
|
||||
}
|
||||
seen[e.Name] = true
|
||||
slots = append(slots, slot{float64(i)*10 + 0.5, e})
|
||||
}
|
||||
|
||||
for i, c := range current {
|
||||
if seen[c.Name] {
|
||||
continue
|
||||
}
|
||||
key := float64(i) * 10
|
||||
if n, ok := prefixNum(c.Name); ok {
|
||||
key = n
|
||||
}
|
||||
e := DesiredEntry{Name: c.Name, Enabled: c.Enabled}
|
||||
if c.Bundled {
|
||||
e.Remove = true // gebündelte Scripte verwaltet ausschließlich das Preset
|
||||
}
|
||||
slots = append(slots, slot{key, e})
|
||||
}
|
||||
|
||||
sort.SliceStable(slots, func(i, j int) bool { return slots[i].key < slots[j].key })
|
||||
|
||||
out := make([]DesiredEntry, len(slots))
|
||||
for i, s := range slots {
|
||||
out[i] = s.entry
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Diff berechnet den Plan von current nach desired.
|
||||
func Diff(current []Script, desired []DesiredEntry) Plan {
|
||||
var plan Plan
|
||||
curByName := make(map[string]Script, len(current))
|
||||
for _, c := range current {
|
||||
curByName[c.Name] = c
|
||||
}
|
||||
|
||||
var final []string // Reihenfolge der Dateien nach dem Apply
|
||||
installIdx := make(map[string]int) // gewünschter Name → Index in plan.Installs
|
||||
for _, e := range desired {
|
||||
if e.Remove {
|
||||
if _, exists := curByName[e.Name]; exists {
|
||||
plan.Removes = append(plan.Removes, RemoveOp{Name: e.Name})
|
||||
}
|
||||
continue
|
||||
}
|
||||
final = append(final, e.Name)
|
||||
|
||||
cur, exists := curByName[e.Name]
|
||||
if !exists {
|
||||
if e.Install != "" {
|
||||
installIdx[e.Name] = len(plan.Installs)
|
||||
plan.Installs = append(plan.Installs, InstallOp{Name: e.Name, Source: e.Install, Enabled: e.Enabled})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur.Enabled != e.Enabled {
|
||||
plan.Chmod = append(plan.Chmod, ChmodOp{Name: e.Name, Enable: e.Enabled})
|
||||
}
|
||||
}
|
||||
|
||||
// Die Wunsch-Reihenfolge muss der lexikalischen Sortierung der Dateinamen
|
||||
// entsprechen (run-parts-Reihenfolge). Tut sie das nicht, werden alle
|
||||
// Positionen neu nummeriert – vorhandene Dateien umbenannt und
|
||||
// Installationen direkt unter dem Zielnamen angelegt.
|
||||
if !slices.IsSorted(final) {
|
||||
for i, name := range final {
|
||||
target := targetName(i, name)
|
||||
if cur, exists := curByName[name]; exists {
|
||||
if target != cur.Name {
|
||||
plan.Renames = append(plan.Renames, RenameOp{From: cur.Name, To: target})
|
||||
}
|
||||
} else if idx, ok := installIdx[name]; ok {
|
||||
plan.Installs[idx].Name = target
|
||||
}
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
120
internal/motd/state_test.go
Normal file
120
internal/motd/state_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiffToggleOnly(t *testing.T) {
|
||||
current := []Script{{Name: "00-header", Enabled: true}, {Name: "10-sysinfo", Enabled: false}}
|
||||
desired := []DesiredEntry{{Name: "00-header", Enabled: false}, {Name: "10-sysinfo", Enabled: false}}
|
||||
|
||||
plan := Diff(current, desired)
|
||||
if len(plan.Chmod) != 1 || plan.Chmod[0].Name != "00-header" || plan.Chmod[0].Enable {
|
||||
t.Errorf("Chmod = %+v, want 00-header disable", plan.Chmod)
|
||||
}
|
||||
if plan.Empty() {
|
||||
t.Error("Plan sollte eine Operation haben, ist aber leer")
|
||||
}
|
||||
if len(plan.Renames) != 0 {
|
||||
t.Errorf("Renames = %+v, want none", plan.Renames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffReorderRenumbers(t *testing.T) {
|
||||
current := []Script{
|
||||
{Name: "00-a", Enabled: true},
|
||||
{Name: "10-b", Enabled: true},
|
||||
{Name: "20-c", Enabled: true},
|
||||
}
|
||||
desired := []DesiredEntry{
|
||||
{Name: "10-b", Enabled: true},
|
||||
{Name: "00-a", Enabled: true},
|
||||
{Name: "20-c", Enabled: true},
|
||||
}
|
||||
|
||||
plan := Diff(current, desired)
|
||||
want := []RenameOp{{From: "10-b", To: "00-b"}, {From: "00-a", To: "10-a"}}
|
||||
if !reflect.DeepEqual(plan.Renames, want) {
|
||||
t.Errorf("Renames = %+v, want %+v", plan.Renames, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffKeepsPrefixesWhenOrderUnchanged(t *testing.T) {
|
||||
current := []Script{{Name: "05-a", Enabled: true}, {Name: "07-b", Enabled: true}}
|
||||
desired := []DesiredEntry{{Name: "05-a", Enabled: true}, {Name: "07-b", Enabled: true}}
|
||||
|
||||
if plan := Diff(current, desired); !plan.Empty() {
|
||||
t.Errorf("Plan = %+v, want leer", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffInstallAndRemove(t *testing.T) {
|
||||
current := []Script{
|
||||
{Name: "00-header", Enabled: true},
|
||||
{Name: "95-footer", Enabled: true, Bundled: true},
|
||||
}
|
||||
desired := []DesiredEntry{
|
||||
{Name: "00-header", Enabled: true},
|
||||
{Name: "10-brand-header", Enabled: true, Install: "brand-header"},
|
||||
{Name: "95-footer", Remove: true},
|
||||
}
|
||||
|
||||
plan := Diff(current, desired)
|
||||
if len(plan.Installs) != 1 || plan.Installs[0].Source != "brand-header" {
|
||||
t.Errorf("Installs = %+v", plan.Installs)
|
||||
}
|
||||
if len(plan.Removes) != 1 || plan.Removes[0].Name != "95-footer" {
|
||||
t.Errorf("Removes = %+v", plan.Removes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredFromPresetInterleavesUnlisted(t *testing.T) {
|
||||
current := []Script{
|
||||
{Name: "00-header", Enabled: true},
|
||||
{Name: "50-motd-news", Enabled: true},
|
||||
{Name: "90-updates-available", Enabled: false},
|
||||
{Name: "95-footer", Enabled: true, Bundled: true},
|
||||
}
|
||||
entries := []DesiredEntry{
|
||||
{Name: "10-brand-header", Enabled: true, Install: "brand-header"},
|
||||
{Name: "00-header", Enabled: false},
|
||||
}
|
||||
|
||||
got := DesiredFromPreset(current, entries)
|
||||
var names []string
|
||||
var removes []string
|
||||
for _, e := range got {
|
||||
if e.Remove {
|
||||
removes = append(removes, e.Name)
|
||||
} else {
|
||||
names = append(names, e.Name)
|
||||
}
|
||||
}
|
||||
wantNames := []string{"10-brand-header", "00-header", "50-motd-news", "90-updates-available"}
|
||||
if !reflect.DeepEqual(names, wantNames) {
|
||||
t.Errorf("Reihenfolge = %v, want %v", names, wantNames)
|
||||
}
|
||||
if !reflect.DeepEqual(removes, []string{"95-footer"}) {
|
||||
t.Errorf("Removes = %v, want [95-footer]", removes)
|
||||
}
|
||||
// Ungelistete behalten ihren Zustand.
|
||||
for _, e := range got {
|
||||
if e.Name == "50-motd-news" && !e.Enabled {
|
||||
t.Error("50-motd-news sollte aktiviert bleiben")
|
||||
}
|
||||
if e.Name == "00-header" && e.Enabled {
|
||||
t.Error("00-header sollte laut Preset deaktiviert werden")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredFromPresetSkipsMissing(t *testing.T) {
|
||||
current := []Script{{Name: "00-header", Enabled: true}}
|
||||
entries := []DesiredEntry{{Name: "10-gibt-es-nicht", Enabled: true}}
|
||||
|
||||
got := DesiredFromPreset(current, entries)
|
||||
if len(got) != 1 || got[0].Name != "00-header" {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue