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:
Tronax 2026-08-24 14:19:28 +02:00
commit d56659e21a
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
34 changed files with 3260 additions and 0 deletions

344
internal/motd/apply.go Normal file
View 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
}