فهرست منبع

fix: Move templating functionality to global, making it possible to replace templates across the config

jamesread 5 ماه پیش
والد
کامیت
b37f035ea6

+ 2 - 1
service/internal/api/api.go

@@ -27,6 +27,7 @@ import (
 	entities "github.com/OliveTin/OliveTin/internal/entities"
 	executor "github.com/OliveTin/OliveTin/internal/executor"
 	installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
+	"github.com/OliveTin/OliveTin/internal/tpl"
 	connectproto "go.akshayshah.org/connectproto"
 )
 
@@ -709,7 +710,7 @@ func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.Dum
 		return connect.NewResponse(res), nil
 	}
 
-	jsonstring, _ := json.MarshalIndent(entities.GetAll(), "", "  ")
+	jsonstring, _ := json.MarshalIndent(tpl.GetNewGeneralTemplateContext(), "", "  ")
 	fmt.Printf("%s", &jsonstring)
 
 	res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpVars = false again after you don't need it anymore"

+ 17 - 6
service/internal/api/apiActions.go

@@ -13,6 +13,7 @@ import (
 	config "github.com/OliveTin/OliveTin/internal/config"
 	entities "github.com/OliveTin/OliveTin/internal/entities"
 	executor "github.com/OliveTin/OliveTin/internal/executor"
+	"github.com/OliveTin/OliveTin/internal/tpl"
 )
 
 type DashboardRenderRequest struct {
@@ -66,7 +67,7 @@ func evaluateEnabledExpression(action *config.Action, entity *entities.Entity) b
 		return true
 	}
 
-	result := entities.ParseTemplateWith(action.EnabledExpression, entity)
+	result := tpl.ParseTemplateWith(action.EnabledExpression, entity)
 	result = strings.TrimSpace(result)
 
 	if result == "" {
@@ -105,6 +106,16 @@ func evaluateResultValue(result string) bool {
 	return false
 }
 
+func getDefaultValue(cfgArg config.ActionArgument, entity *entities.Entity) string {
+	defaultValue := cfgArg.Default
+
+	if defaultValue != "" {
+		defaultValue = tpl.ParseTemplateWith(defaultValue, entity)
+	}
+
+	return defaultValue
+}
+
 func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
 	action := actionBinding.Action
 
@@ -120,8 +131,8 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
 
 	btn := apiv1.Action{
 		BindingId:                actionBinding.ID,
-		Title:                    entities.ParseTemplateWith(action.Title, actionBinding.Entity),
-		Icon:                     entities.ParseTemplateWith(action.Icon, actionBinding.Entity),
+		Title:                    tpl.ParseTemplateWith(action.Title, actionBinding.Entity),
+		Icon:                     tpl.ParseTemplateWith(action.Icon, actionBinding.Entity),
 		CanExec:                  aclCanExec && enabledExprCanExec,
 		PopupOnStart:             action.PopupOnStart,
 		Order:                    int32(actionBinding.ConfigOrder),
@@ -135,7 +146,7 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
 			Title:                 cfgArg.Title,
 			Type:                  cfgArg.Type,
 			Description:           cfgArg.Description,
-			DefaultValue:          cfgArg.Default,
+			DefaultValue:          getDefaultValue(cfgArg, actionBinding.Entity),
 			Choices:               buildChoices(cfgArg),
 			Suggestions:           cfgArg.Suggestions,
 			SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,
@@ -162,8 +173,8 @@ func buildChoicesEntity(firstChoice config.ActionArgumentChoice, entityTitle str
 
 	for _, ent := range entList {
 		ret = append(ret, &apiv1.ActionArgumentChoice{
-			Value: entities.ParseTemplateWith(firstChoice.Value, ent),
-			Title: entities.ParseTemplateWith(firstChoice.Title, ent),
+			Value: tpl.ParseTemplateWith(firstChoice.Value, ent),
+			Title: tpl.ParseTemplateWith(firstChoice.Title, ent),
 		})
 	}
 

+ 3 - 3
service/internal/api/api_test.go

@@ -119,9 +119,9 @@ func TestGetEntities(t *testing.T) {
 }
 
 func setupTestEntities() {
-	entities.ClearEntities("server")
-	entities.ClearEntities("database")
-	entities.ClearEntities("application")
+	entities.ClearEntitiesOfType("server")
+	entities.ClearEntitiesOfType("database")
+	entities.ClearEntitiesOfType("application")
 
 	entities.AddEntity("server", "zebra", map[string]any{"title": "Server Zebra", "hostname": "zebra.example.com"})
 	entities.AddEntity("server", "alpha", map[string]any{"title": "Server Alpha", "hostname": "alpha.example.com"})

+ 10 - 9
service/internal/api/dashboard_entities.go

@@ -4,6 +4,7 @@ import (
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	entities "github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/tpl"
 	log "github.com/sirupsen/logrus"
 )
 
@@ -23,14 +24,14 @@ func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr
 	return ret
 }
 
-func buildEntityFieldset(tpl *config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
+func buildEntityFieldset(component *config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
 	return &apiv1.DashboardComponent{
-		Title:      entities.ParseTemplateWith(tpl.Title, ent),
+		Title:      tpl.ParseTemplateWith(component.Title, ent),
 		Type:       "fieldset",
-		Contents:   removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(tpl.Contents, ent, tpl.Entity, rr)),
-		CssClass:   entities.ParseTemplateWith(tpl.CssClass, ent),
-		Action:     rr.findAction(tpl.Title),
-		EntityType: tpl.Entity,
+		Contents:   removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(component.Contents, ent, component.Entity, rr)),
+		CssClass:   tpl.ParseTemplateWith(component.CssClass, ent),
+		Action:     rr.findAction(component.Title),
+		EntityType: component.Entity,
 		EntityKey:  ent.UniqueKey,
 	}
 }
@@ -68,7 +69,7 @@ func buildEntityFieldsetContents(contents []*config.DashboardComponent, ent *ent
 
 func cloneItem(subitem *config.DashboardComponent, ent *entities.Entity, entityType string, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
 	clone := &apiv1.DashboardComponent{}
-	clone.CssClass = entities.ParseTemplateWith(subitem.CssClass, ent)
+	clone.CssClass = tpl.ParseTemplateWith(subitem.CssClass, ent)
 
 	if isLinkType(subitem.Type) {
 		return cloneLinkItem(subitem, ent, clone, rr)
@@ -83,7 +84,7 @@ func isLinkType(itemType string) bool {
 
 func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
 	clone.Type = "link"
-	clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
+	clone.Title = tpl.ParseTemplateWith(subitem.Title, ent)
 	// Prefer an entity-specific action when available, but fall back to a
 	// non-entity-scoped action with the same title. This allows inline actions
 	// defined inside entity dashboards to work without requiring an explicit
@@ -98,7 +99,7 @@ func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clo
 }
 
 func cloneNonLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, entityType string, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
-	clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
+	clone.Title = tpl.ParseTemplateWith(subitem.Title, ent)
 	clone.Type = subitem.Type
 
 	if isDirectoryWithEntity(clone.Type, ent, entityType) {

+ 2 - 1
service/internal/api/dashboards.go

@@ -6,6 +6,7 @@ import (
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	entities "github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/tpl"
 	log "github.com/sirupsen/logrus"
 	"golang.org/x/exp/slices"
 )
@@ -236,7 +237,7 @@ func buildDashboardComponentSimpleWithEntity(subitem *config.DashboardComponent,
 
 	title := subitem.Title
 	if entity != nil {
-		title = entities.ParseTemplateWith(subitem.Title, entity)
+		title = tpl.ParseTemplateWith(subitem.Title, entity)
 	}
 
 	newitem := &apiv1.DashboardComponent{

+ 2 - 1
service/internal/config/config.go

@@ -170,7 +170,8 @@ type Config struct {
 	BannerCSS                       string                     `koanf:"bannerCss"`
 	Include                         string                     `koanf:"include"`
 
-	sourceFiles []string
+	sourceFiles            []string
+	passwordTemplateParser func(string, interface{}) string
 }
 
 type AuthLocalUsersConfig struct {

+ 21 - 0
service/internal/config/sanitize.go

@@ -13,6 +13,7 @@ func (cfg *Config) Sanitize() {
 	cfg.sanitizeLogLevel()
 	cfg.sanitizeAuthRequireGuestsToLogin()
 	cfg.sanitizeLogHistoryPageSize()
+	cfg.sanitizeLocalUserPasswords()
 
 	// log.Infof("cfg %p", cfg)
 
@@ -172,6 +173,26 @@ func (cfg *Config) sanitizeLogHistoryPageSize() {
 	}
 }
 
+// SetPasswordTemplateParser sets the function to use for parsing password templates.
+// This is called from main.go to avoid import cycles (config can't import entities).
+func (cfg *Config) SetPasswordTemplateParser(parser func(string, interface{}) string) {
+	cfg.passwordTemplateParser = parser
+}
+
+func (cfg *Config) sanitizeLocalUserPasswords() {
+	if cfg.passwordTemplateParser == nil {
+		return
+	}
+
+	for _, user := range cfg.AuthLocalUsers.Users {
+		if user.Password != "" {
+			// Parse password as template to support environment variables and other template values
+			// Note: .CurrentEntity is nil in this context as local users are not entity-bound
+			user.Password = cfg.passwordTemplateParser(user.Password, nil)
+		}
+	}
+}
+
 func getActionID(action *Action) string {
 	if action.ID == "" {
 		return uuid.NewString()

+ 1 - 1
service/internal/entities/entities.go

@@ -135,7 +135,7 @@ func loadEntityFileYaml(filename string, entityname string) {
 }
 
 func updateSvFromFile(entityname string, data []map[string]any) {
-	ClearEntities(entityname)
+	ClearEntitiesOfType(entityname)
 
 	for i, mapp := range data {
 		AddEntity(entityname, fmt.Sprintf("%d", i), mapp)

+ 17 - 51
service/internal/entities/storage.go

@@ -10,72 +10,31 @@ package entities
  */
 
 import (
-	"os"
 	"strings"
 	"sync"
-
-	"github.com/OliveTin/OliveTin/internal/installationinfo"
 )
 
 type entityInstancesByKey map[string]*Entity
 
-type entitiesByClass map[string]entityInstancesByKey
-
-type variableBase struct {
-	OliveTin installationInfo
-	Entities entitiesByClass
-
-	CurrentEntity interface{}
-	Arguments     map[string]string
-	Env           map[string]string
-}
-
-type installationInfo struct {
-	Build   *installationinfo.BuildInfo
-	Runtime *installationinfo.RuntimeInfo
-}
+type EntitiesByClass map[string]entityInstancesByKey
 
 var (
-	contents *variableBase
 	rwmutex  = sync.RWMutex{}
+	Entities EntitiesByClass
 )
 
 func init() {
 	rwmutex.Lock()
-
-	envMap := make(map[string]string)
-	for _, env := range os.Environ() {
-		parts := strings.SplitN(env, "=", 2)
-		if len(parts) == 2 {
-			envMap[parts[0]] = parts[1]
-		}
-	}
-
-	contents = &variableBase{
-		OliveTin: installationInfo{
-			Build:   installationinfo.Build,
-			Runtime: installationinfo.Runtime,
-		},
-		Entities: make(entitiesByClass, 0),
-		Env:      envMap,
-	}
-
+	Entities = make(EntitiesByClass, 0)
 	rwmutex.Unlock()
 }
 
-func GetAll() *variableBase {
-	rwmutex.RLock()
-	defer rwmutex.RUnlock()
-
-	return contents
-}
-
-func GetEntities() entitiesByClass {
+func GetEntities() EntitiesByClass {
 	rwmutex.RLock()
 
-	copiedEntities := make(entitiesByClass, len(contents.Entities))
+	copiedEntities := make(EntitiesByClass, len(Entities))
 
-	for entityName, entityInstances := range contents.Entities {
+	for entityName, entityInstances := range Entities {
 		copiedInstances := make(entityInstancesByKey, len(entityInstances))
 
 		for key, entity := range entityInstances {
@@ -93,7 +52,7 @@ func GetEntityInstances(entityName string) entityInstancesByKey {
 	rwmutex.RLock()
 	defer rwmutex.RUnlock()
 
-	if entities, ok := contents.Entities[entityName]; ok {
+	if entities, ok := Entities[entityName]; ok {
 		copiedInstances := make(entityInstancesByKey, len(entities))
 
 		for key, entity := range entities {
@@ -108,11 +67,11 @@ func GetEntityInstances(entityName string) entityInstancesByKey {
 func AddEntity(entityName string, entityKey string, data any) {
 	rwmutex.Lock()
 
-	if _, ok := contents.Entities[entityName]; !ok {
-		contents.Entities[entityName] = make(entityInstancesByKey, 0)
+	if _, ok := Entities[entityName]; !ok {
+		Entities[entityName] = make(entityInstancesByKey, 0)
 	}
 
-	contents.Entities[entityName][entityKey] = &Entity{
+	Entities[entityName][entityKey] = &Entity{
 		Data:      data,
 		UniqueKey: entityKey,
 		Title:     findEntityTitle(data),
@@ -144,3 +103,10 @@ func findEntityTitle(data any) string {
 
 	return "Untitled Entity"
 }
+
+func ClearEntitiesOfType(entityType string) {
+	rwmutex.Lock()
+	defer rwmutex.Unlock()
+
+	delete(Entities, entityType)
+}

+ 4 - 3
service/internal/executor/arguments.go

@@ -3,6 +3,7 @@ package executor
 import (
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/tpl"
 	log "github.com/sirupsen/logrus"
 
 	"fmt"
@@ -75,7 +76,7 @@ func parseSingleExec(a string, values map[string]string, entity *entities.Entity
 	if err != nil {
 		return "", err
 	}
-	return entities.ParseTemplateWithArgs(arg, entity, values), nil
+	return tpl.ParseTemplateWithArgs(arg, entity, values), nil
 }
 
 func validateArguments(values map[string]string, action *config.Action) error {
@@ -117,7 +118,7 @@ func parseActionArguments(values map[string]string, action *config.Action, entit
 		}).Debugf("Arg assigned")
 	}
 
-	parsedShellCommand := entities.ParseTemplateWithArgs(rawShellCommand, entity, values)
+	parsedShellCommand := tpl.ParseTemplateWithArgs(rawShellCommand, entity, values)
 	redactedShellCommand := redactShellCommand(parsedShellCommand, action.Arguments, values)
 
 	if err != nil {
@@ -256,7 +257,7 @@ func typecheckChoiceEntity(value string, arg *config.ActionArgument) error {
 	templateChoice := arg.Choices[0].Value
 
 	for _, ent := range entities.GetEntityInstances(arg.Entity) {
-		choice := entities.ParseTemplateWith(templateChoice, ent)
+		choice := tpl.ParseTemplateWith(templateChoice, ent)
 
 		if value == choice {
 			return nil

+ 2 - 1
service/internal/executor/executor.go

@@ -6,6 +6,7 @@ import (
 	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/tpl"
 	"github.com/google/uuid"
 	log "github.com/sirupsen/logrus"
 
@@ -728,7 +729,7 @@ func stepRequestAction(req *ExecutionRequest) bool {
 
 	req.logEntry.Binding = req.Binding
 	req.logEntry.ActionConfigTitle = req.Binding.Action.Title
-	req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity)
+	req.logEntry.ActionTitle = tpl.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity)
 	req.logEntry.ActionIcon = req.Binding.Action.Icon
 	req.logEntry.Tags = req.Tags
 

+ 64 - 14
service/internal/entities/templates.go → service/internal/tpl/templates.go

@@ -1,19 +1,75 @@
-package entities
+package tpl
 
 import (
 	"fmt"
+	"os"
 	"regexp"
 	"strings"
 	"text/template"
 
+	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/installationinfo"
 	log "github.com/sirupsen/logrus"
 )
 
 var tpl = template.New("tpl")
 
+type olivetinInfo struct {
+	Build   *installationinfo.BuildInfo
+	Runtime *installationinfo.RuntimeInfo
+}
+
 var legacyArgumentRegex = regexp.MustCompile(`{{ ([a-zA-Z0-9_]+) }}`)
 var legacyEntityPropertiesRegex = regexp.MustCompile(`{{ ([a-zA-Z0-9_]+)\.([a-zA-Z0-9_\.]+) }}`)
 
+type generalTemplateContext struct {
+	OliveTin olivetinInfo
+	Env      map[string]string
+}
+
+type actionTemplateContext struct {
+	CurrentEntity interface{}
+	Arguments     map[string]string
+
+	// These are deliberately repeated because embedding structs
+	// won't work in text/template.
+	OliveTin olivetinInfo
+	Env      map[string]string
+}
+
+var (
+	cachedOliveTinInfo olivetinInfo
+	cachedEnvMap       map[string]string
+)
+
+func init() {
+	cachedOliveTinInfo = olivetinInfo{
+		Build:   installationinfo.Build,
+		Runtime: installationinfo.Runtime,
+	}
+
+	cachedEnvMap = buildEnvMap()
+}
+
+func GetNewGeneralTemplateContext() *generalTemplateContext {
+	return &generalTemplateContext{
+		OliveTin: cachedOliveTinInfo,
+		Env:      cachedEnvMap,
+	}
+}
+
+func buildEnvMap() map[string]string {
+	envMap := make(map[string]string)
+	for _, env := range os.Environ() {
+		parts := strings.SplitN(env, "=", 2)
+		if len(parts) == 2 {
+			envMap[parts[0]] = parts[1]
+		}
+	}
+
+	return envMap
+}
+
 func migrateLegacyEntityProperties(rawShellCommand string) string {
 	foundArgumentNames := legacyEntityPropertiesRegex.FindAllStringSubmatch(rawShellCommand, -1)
 
@@ -68,7 +124,7 @@ func migrateLegacyArgumentNames(rawShellCommand string) string {
 	return rawShellCommand
 }
 
-func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) string {
+func ParseTemplateWithArgs(source string, ent *entities.Entity, args map[string]string) string {
 	source = migrateLegacyArgumentNames(source)
 	source = migrateLegacyEntityProperties(source)
 
@@ -90,11 +146,12 @@ func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) s
 		entdata = ent.Data
 	}
 
-	templateVariables := &variableBase{
-		OliveTin:      GetAll().OliveTin,
+	templateVariables := &actionTemplateContext{
+		OliveTin: cachedOliveTinInfo,
+		Env:      cachedEnvMap,
+
 		Arguments:     args,
 		CurrentEntity: entdata,
-		Env:           GetAll().Env,
 	}
 
 	var sb strings.Builder
@@ -114,21 +171,14 @@ func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) s
 	return ret
 }
 
-func ParseTemplateWith(source string, ent *Entity) string {
+func ParseTemplateWith(source string, ent *entities.Entity) string {
 	return ParseTemplateWithArgs(source, ent, nil)
 }
 
-func ParseTemplateBoolWith(source string, ent *Entity) bool {
+func ParseTemplateBoolWith(source string, ent *entities.Entity) bool {
 	source = strings.TrimSpace(source)
 
 	tplBool := ParseTemplateWith(source, ent)
 
 	return tplBool == "true"
 }
-
-func ClearEntities(entityType string) {
-	rwmutex.Lock()
-	defer rwmutex.Unlock()
-
-	delete(contents.Entities, entityType)
-}