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.
This commit is contained in:
parent
d56659e21a
commit
303c49b484
10 changed files with 375 additions and 48 deletions
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"motd-assist/internal/assets"
|
||||
|
|
@ -85,8 +86,8 @@ func Validate(root string, plan Plan) error {
|
|||
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 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)
|
||||
|
|
@ -95,6 +96,29 @@ func Validate(root string, plan Plan) error {
|
|||
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 {
|
||||
|
|
@ -112,12 +136,15 @@ type Snapshot struct {
|
|||
Scripts []SnapshotScript `json:"scripts"`
|
||||
}
|
||||
|
||||
// SnapshotScript hält Name, Modus und (falls gebündelt) die Vorlage fest.
|
||||
// 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.
|
||||
|
|
@ -170,9 +197,8 @@ func writeSnapshot(root string) error {
|
|||
}
|
||||
if s.Bundled {
|
||||
if b, err := os.ReadFile(s.Path); err == nil {
|
||||
if src, ok := assets.MatchScript(string(b)); ok {
|
||||
ss.Source = src
|
||||
}
|
||||
ss.Content = string(b)
|
||||
ss.Source, _ = assets.MatchScriptBytes(b)
|
||||
}
|
||||
}
|
||||
snap.Scripts = append(snap.Scripts, ss)
|
||||
|
|
@ -235,12 +261,11 @@ func Execute(root string, plan Plan) error {
|
|||
}
|
||||
|
||||
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 {
|
||||
if err := os.WriteFile(filepath.Join(dir, op.Name), []byte(op.Content), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -287,16 +312,20 @@ func Restore(root string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Fehlende gebündelte Scripte aus der Vorlage nachinstallieren.
|
||||
// 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 && 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))
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -38,12 +38,39 @@ func TestValidateRejectsUnknownNames(t *testing.T) {
|
|||
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}}})
|
||||
err := Validate(root, Plan{Installs: []InstallOp{{
|
||||
Name: "00-header", Source: "footer", Enabled: true,
|
||||
Content: "#!/bin/sh\necho neu\n",
|
||||
}}})
|
||||
if err == nil {
|
||||
t.Error("Install über fremdes Script sollte abgelehnt werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsEmptyInstallContent(t *testing.T) {
|
||||
root := setupDir(t, nil)
|
||||
|
||||
err := Validate(root, Plan{Installs: []InstallOp{{Name: "10-x", Source: "unbekannt", Enabled: true}}})
|
||||
if err == nil {
|
||||
t.Error("Install ohne aufgelösten Inhalt sollte abgelehnt werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveContentsEmbedsTemplates(t *testing.T) {
|
||||
plan := Plan{Installs: []InstallOp{{Name: "10-x", Source: "brand-header", Enabled: true}}}
|
||||
if err := plan.ResolveContents(); err != nil {
|
||||
t.Fatalf("ResolveContents: %v", err)
|
||||
}
|
||||
if plan.Installs[0].Content == "" {
|
||||
t.Error("Content wurde nicht befüllt")
|
||||
}
|
||||
|
||||
missing := Plan{Installs: []InstallOp{{Name: "10-y", Source: "gibt-es-nicht", Enabled: true}}}
|
||||
if err := missing.ResolveContents(); err == nil {
|
||||
t.Error("unbekannte Vorlage sollte einen Fehler liefern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsForeignRemove(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{"10-sysinfo": "#!/bin/sh\necho eigenes\n"})
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ func TestExecuteAppliesPlanAndRestore(t *testing.T) {
|
|||
if plan.Empty() {
|
||||
t.Fatal("Plan sollte Änderungen enthalten")
|
||||
}
|
||||
if err := plan.ResolveContents(); err != nil {
|
||||
t.Fatalf("ResolveContents: %v", err)
|
||||
}
|
||||
if err := Execute(root, plan); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
|
@ -93,7 +96,11 @@ func TestExecuteInstallAndRemove(t *testing.T) {
|
|||
{Name: "00-header", Enabled: true},
|
||||
{Name: "95-footer", Remove: true},
|
||||
}
|
||||
if err := Execute(root, Diff(current, desired)); err != nil {
|
||||
plan := Diff(current, desired)
|
||||
if err := plan.ResolveContents(); err != nil {
|
||||
t.Fatalf("ResolveContents: %v", err)
|
||||
}
|
||||
if err := Execute(root, plan); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ type (
|
|||
}
|
||||
InstallOp struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
RemoveOp struct {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue