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
344 lines
8.9 KiB
Go
344 lines
8.9 KiB
Go
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
|
||
}
|