motd-assist/internal/motd/apply.go
Tronax d56659e21a
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
2026-08-24 14:19:28 +02:00

344 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}