motd-assist/internal/motd/apply.go
Tronax 303c49b484
feat: drop-in support for custom templates
User templates can now be added without rebuilding: drop a script into
~/.config/motd-assist/templates/ and reference it from a preset via
"install": "<filename>". Same-name files override built-ins; new files
are picked up by the running TUI via 'r'. `motd-assist templates` lists
known templates and creates the drop-in directory.

Apply plans now embed the resolved template content (Plan.ResolveContents)
so the sudo/root process never needs to read user config; snapshots store
installed script content so restore works under sudo as well. Validation
rejects plans with unresolved content.
2026-08-31 10:31:58 +02:00

373 lines
9.9 KiB
Go
Raw Permalink 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"
"strings"
"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 strings.TrimSpace(op.Content) == "" {
return fmt.Errorf("install: Vorlage %q hat keinen Inhalt Plan ohne ResolveContents erstellt?", 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
}
// ResolveContents füllt die Vorlagen-Inhalte aller Installationen auf.
// Muss im unprivilegierten Kontext laufen (User-Vorlagen liegen im
// $HOME des aufrufenden Benutzers), bevor der Plan per sudo an Root
// übergeben wird der Root-Prozess kann die User-Konfiguration nicht lesen.
func (p *Plan) ResolveContents() error {
for i := range p.Installs {
op := &p.Installs[i]
if op.Content != "" {
continue
}
content, ok := assets.ScriptContent(op.Source)
if !ok {
dir, err := assets.TemplatesDir()
if err != nil || dir == "" {
dir = "~/.config/motd-assist/templates"
}
return fmt.Errorf("Vorlage %q nicht gefunden (eigene Vorlagen ablegen in: %s)", op.Source, dir)
}
op.Content = content
}
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) Vorlage und
// Inhalt fest, damit Restore ohne Zugriff auf die ursprüngliche
// Vorlage (auch User-Vorlagen unter sudo) auskommt.
type SnapshotScript struct {
Name string `json:"name"`
Mode uint32 `json:"mode"`
Bundled bool `json:"bundled"`
Source string `json:"source,omitempty"`
Content string `json:"content,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 {
ss.Content = string(b)
ss.Source, _ = assets.MatchScriptBytes(b)
}
}
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 {
mode := modeDisabled
if op.Enabled {
mode = modeEnabled
}
if err := os.WriteFile(filepath.Join(dir, op.Name), []byte(op.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 dem Snapshot-Inhalt nachinstallieren;
// als Fallback (alte Snapshots ohne Content) über den Vorlagennamen.
var renames []RenameOp
for _, s := range snap.Scripts {
if _, ok := curBySuffix[suffixOf(s.Name)]; ok {
continue
}
if s.Bundled {
content := s.Content
if content == "" && s.Source != "" {
content, _ = assets.ScriptContent(s.Source)
}
if content == "" {
errs = append(errs, fmt.Errorf("Inhalt für %s fehlt im Snapshot", 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
}