From 581536a60f2397a89f26570783140f95c5eca6dd Mon Sep 17 00:00:00 2001 From: jamesread Date: Thu, 6 Nov 2025 23:44:56 +0000 Subject: [PATCH 1/3] fmt: GetActionLogs --- service/internal/api/api.go | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/service/internal/api/api.go b/service/internal/api/api.go index d467bcb..4e6c46f 100644 --- a/service/internal/api/api.go +++ b/service/internal/api/api.go @@ -481,16 +481,20 @@ func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv ret.StartOffset = page.start return connect.NewResponse(ret), nil } - // Newest-first slicing: compute reversed indices - startIdx := page.total - page.end - endIdx := page.total - page.start - if startIdx < 0 { startIdx = 0 } - if endIdx > int64(len(filtered)) { endIdx = int64(len(filtered)) } - for _, le := range filtered[startIdx:endIdx] { - ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user)) - } - // Entries older than the returned newest page - ret.CountRemaining = page.start + // Newest-first slicing: compute reversed indices + startIdx := page.total - page.end + endIdx := page.total - page.start + if startIdx < 0 { + startIdx = 0 + } + if endIdx > int64(len(filtered)) { + endIdx = int64(len(filtered)) + } + for _, le := range filtered[startIdx:endIdx] { + ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user)) + } + // Entries older than the returned newest page + ret.CountRemaining = page.start ret.PageSize = page.size ret.TotalCount = page.total ret.StartOffset = page.start From 7dc99b13988d29267c7e61ecd23c4f4989498999 Mon Sep 17 00:00:00 2001 From: jamesread Date: Thu, 6 Nov 2025 23:45:54 +0000 Subject: [PATCH 2/3] fix: Massively improve config loading --- service/internal/config/config.go | 294 ++++++++++----------- service/internal/config/config_reloader.go | 246 ++--------------- 2 files changed, 167 insertions(+), 373 deletions(-) diff --git a/service/internal/config/config.go b/service/internal/config/config.go index 0e1e800..de0efca 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -7,212 +7,212 @@ import ( // Action represents the core functionality of OliveTin - commands that show up // as buttons in the UI. type Action struct { - ID string - Title string - Icon string - Shell string - Exec []string - ShellAfterCompleted string - Timeout int - Acls []string - Entity string - Hidden bool - ExecOnStartup bool - ExecOnCron []string - ExecOnFileCreatedInDir []string - ExecOnFileChangedInDir []string - ExecOnCalendarFile string - Triggers []string - MaxConcurrent int - MaxRate []RateSpec - Arguments []ActionArgument - PopupOnStart string - SaveLogs SaveLogsConfig + ID string `koanf:"id"` + Title string `koanf:"title"` + Icon string `koanf:"icon"` + Shell string `koanf:"shell"` + Exec []string `koanf:"exec"` + ShellAfterCompleted string `koanf:"shellAfterCompleted"` + Timeout int `koanf:"timeout"` + Acls []string `koanf:"acls"` + Entity string `koanf:"entity"` + Hidden bool `koanf:"hidden"` + ExecOnStartup bool `koanf:"execOnStartup"` + ExecOnCron []string `koanf:"execOnCron"` + ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"` + ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"` + ExecOnCalendarFile string `koanf:"execOnCalendarFile"` + Triggers []string `koanf:"triggers"` + MaxConcurrent int `koanf:"maxConcurrent"` + MaxRate []RateSpec `koanf:"maxRate"` + Arguments []ActionArgument `koanf:"arguments"` + PopupOnStart string `koanf:"popupOnStart"` + SaveLogs SaveLogsConfig `koanf:"saveLogs"` } // ActionArgument objects appear on Actions. type ActionArgument struct { - Name string - Title string - Description string - Type string - Default string - Choices []ActionArgumentChoice - Entity string - RejectNull bool - Suggestions map[string]string + Name string `koanf:"name"` + Title string `koanf:"title"` + Description string `koanf:"description"` + Type string `koanf:"type"` + Default string `koanf:"default"` + Choices []ActionArgumentChoice `koanf:"choices"` + Entity string `koanf:"entity"` + RejectNull bool `koanf:"rejectNull"` + Suggestions map[string]string `koanf:"suggestions"` } // ActionArgumentChoice represents a predefined choice for an argument. type ActionArgumentChoice struct { - Value string - Title string + Value string `koanf:"value"` + Title string `koanf:"title"` } // RateSpec allows you to set a max frequency for an action. type RateSpec struct { - Limit int - Duration string + Limit int `koanf:"limit"` + Duration string `koanf:"duration"` } // Entity represents a "thing" that can have multiple actions associated with it. // for example, a media player with a start and stop action. type EntityFile struct { - File string - Name string - Icon string + File string `koanf:"file"` + Name string `koanf:"name"` + Icon string `koanf:"icon"` } // PermissionsList defines what users can do with an action. type PermissionsList struct { - View bool `mapstructure:"view"` - Exec bool `mapstructure:"exec"` - Logs bool `mapstructure:"logs"` - Kill bool `mapstructure:"kill"` + View bool `koanf:"view"` + Exec bool `koanf:"exec"` + Logs bool `koanf:"logs"` + Kill bool `koanf:"kill"` } // AccessControlList defines what permissions apply to a user or user group. type AccessControlList struct { - Name string - AddToEveryAction bool - MatchUsergroups []string - MatchUsernames []string - Permissions PermissionsList - Policy ConfigurationPolicy + Name string `koanf:"name"` + AddToEveryAction bool `koanf:"addToEveryAction"` + MatchUsergroups []string `koanf:"matchUsergroups"` + MatchUsernames []string `koanf:"matchUsernames"` + Permissions PermissionsList `koanf:"permissions"` + Policy ConfigurationPolicy `koanf:"policy"` } // ConfigurationPolicy defines global settings which are overridden with an ACL. type ConfigurationPolicy struct { - ShowDiagnostics bool `mapstructure:"showDiagnostics"` - ShowLogList bool `mapstructure:"showLogList"` + ShowDiagnostics bool `koanf:"showDiagnostics"` + ShowLogList bool `koanf:"showLogList"` } type PrometheusConfig struct { - Enabled bool `mapstructure:"enabled"` - DefaultGoMetrics bool `mapstructure:"defaultGoMetrics"` + Enabled bool `koanf:"enabled"` + DefaultGoMetrics bool `koanf:"defaultGoMetrics"` } // Config is the global config used through the whole app. type Config struct { - UseSingleHTTPFrontend bool `mapstructure:"useSingleHTTPFrontend"` - ThemeName string `mapstructure:"themeName"` - ThemeCacheDisabled bool `mapstructure:"themeCacheDisabled"` - ListenAddressSingleHTTPFrontend string `mapstructure:"listenAddressSingleHTTPFrontend"` - ListenAddressWebUI string `mapstructure:"listenAddressWebUI"` - ListenAddressRestActions string `mapstructure:"listenAddressRestActions"` - ListenAddressPrometheus string `mapstructure:"listenAddressPrometheus"` - ExternalRestAddress string `mapstructure:"externalRestAddress"` - LogLevel string `mapstructure:"logLevel"` - LogDebugOptions LogDebugOptions `mapstructure:"logDebugOptions"` - LogHistoryPageSize int64 `mapstructure:"logHistoryPageSize"` - Actions []*Action `mapstructure:"actions"` - Entities []*EntityFile `mapstructure:"entities"` - Dashboards []*DashboardComponent `mapstructure:"dashboards"` - CheckForUpdates bool `mapstructure:"checkForUpdates"` - PageTitle string `mapstructure:"pageTitle"` - ShowFooter bool `mapstructure:"showFooter"` - ShowNavigation bool `mapstructure:"showNavigation"` - ShowNewVersions bool `mapstructure:"showNewVersions"` - EnableCustomJs bool `mapstructure:"enableCustomJs"` - AuthJwtCookieName string `mapstructure:"authJwtCookieName"` - AuthJwtHeader string `mapstructure:"authJwtHeader"` - AuthJwtAud string `mapstructure:"authJwtAud"` - AuthJwtDomain string `mapstructure:"authJwtDomain"` - AuthJwtCertsURL string `mapstructure:"authJwtCertsUrl"` - AuthJwtHmacSecret string `mapstructure:"authJwtHmacSecret"` // mutually exclusive with pub key config fields - AuthJwtClaimUsername string `mapstructure:"authJwtClaimUsername"` - AuthJwtClaimUserGroup string `mapstructure:"authJwtClaimUserGroup"` - AuthJwtPubKeyPath string `mapstructure:"authJwtPubKeyPath"` // will read pub key from file on disk - AuthHttpHeaderUsername string `mapstructure:"authHttpHeaderUsername"` - AuthHttpHeaderUserGroup string `mapstructure:"authHttpHeaderUserGroup"` - AuthHttpHeaderUserGroupSep string `mapstructure:"authHttpHeaderUserGroupSep"` - AuthLocalUsers AuthLocalUsersConfig `mapstructure:"authLocalUsers"` - AuthLoginUrl string `mapstructure:"authLoginUrl"` - AuthRequireGuestsToLogin bool `mapstructure:"authRequireGuestsToLogin"` - AuthOAuth2RedirectURL string `mapstructure:"authOAuth2RedirectUrl"` - AuthOAuth2Providers map[string]*OAuth2Provider `mapstructure:"authOAuth2Providers"` - DefaultPermissions PermissionsList `mapstructure:"defaultPermissions"` - DefaultPolicy ConfigurationPolicy `mapstructure:"defaultPolicy"` - AccessControlLists []*AccessControlList `mapstructure:"accessControlLists"` - WebUIDir string `mapstructure:"webUIDir"` - CronSupportForSeconds bool `mapstructure:"cronSupportForSeconds"` - SectionNavigationStyle string `mapstructure:"sectionNavigationStyle"` - DefaultPopupOnStart string `mapstructure:"defaultPopupOnStart"` - InsecureAllowDumpOAuth2UserData bool `mapstructure:"insecureAllowDumpOAuth2UserData"` - InsecureAllowDumpVars bool `mapstructure:"insecureAllowDumpVars"` - InsecureAllowDumpSos bool `mapstructure:"insecureAllowDumpSos"` - InsecureAllowDumpActionMap bool `mapstructure:"insecureAllowDumpActionMap"` - InsecureAllowDumpJwtClaims bool `mapstructure:"insecureAllowDumpJwtClaims"` - Prometheus PrometheusConfig `mapstructure:"prometheus"` - SaveLogs SaveLogsConfig `mapstructure:"saveLogs"` - DefaultIconForActions string `mapstructure:"defaultIconForActions"` - DefaultIconForDirectories string `mapstructure:"defaultIconForDirectories"` - DefaultIconForBack string `mapstructure:"defaultIconForBack"` - AdditionalNavigationLinks []*NavigationLink `mapstructure:"additionalNavigationLinks"` - ServiceHostMode string `mapstructure:"serviceHostMode"` - StyleMods []string `mapstructure:"styleMods"` - BannerMessage string `mapstructure:"bannerMessage"` - BannerCSS string `mapstructure:"bannerCss"` - Include string `mapstructure:"include"` + UseSingleHTTPFrontend bool `koanf:"useSingleHTTPFrontend"` + ThemeName string `koanf:"themeName"` + ThemeCacheDisabled bool `koanf:"themeCacheDisabled"` + ListenAddressSingleHTTPFrontend string `koanf:"listenAddressSingleHTTPFrontend"` + ListenAddressWebUI string `koanf:"listenAddressWebUI"` + ListenAddressRestActions string `koanf:"listenAddressRestActions"` + ListenAddressPrometheus string `koanf:"listenAddressPrometheus"` + ExternalRestAddress string `koanf:"externalRestAddress"` + LogLevel string `koanf:"logLevel"` + LogDebugOptions LogDebugOptions `koanf:"logDebugOptions"` + LogHistoryPageSize int64 `koanf:"logHistoryPageSize"` + Actions []*Action `koanf:"actions"` + Entities []*EntityFile `koanf:"entities"` + Dashboards []*DashboardComponent `koanf:"dashboards"` + CheckForUpdates bool `koanf:"checkForUpdates"` + PageTitle string `koanf:"pageTitle"` + ShowFooter bool `koanf:"showFooter"` + ShowNavigation bool `koanf:"showNavigation"` + ShowNewVersions bool `koanf:"showNewVersions"` + EnableCustomJs bool `koanf:"enableCustomJs"` + AuthJwtCookieName string `koanf:"authJwtCookieName"` + AuthJwtHeader string `koanf:"authJwtHeader"` + AuthJwtAud string `koanf:"authJwtAud"` + AuthJwtDomain string `koanf:"authJwtDomain"` + AuthJwtCertsURL string `koanf:"authJwtCertsUrl"` + AuthJwtHmacSecret string `koanf:"authJwtHmacSecret"` // mutually exclusive with pub key config fields + AuthJwtClaimUsername string `koanf:"authJwtClaimUsername"` + AuthJwtClaimUserGroup string `koanf:"authJwtClaimUserGroup"` + AuthJwtPubKeyPath string `koanf:"authJwtPubKeyPath"` // will read pub key from file on disk + AuthHttpHeaderUsername string `koanf:"authHttpHeaderUsername"` + AuthHttpHeaderUserGroup string `koanf:"authHttpHeaderUserGroup"` + AuthHttpHeaderUserGroupSep string `koanf:"authHttpHeaderUserGroupSep"` + AuthLocalUsers AuthLocalUsersConfig `koanf:"authLocalUsers"` + AuthLoginUrl string `koanf:"authLoginUrl"` + AuthRequireGuestsToLogin bool `koanf:"authRequireGuestsToLogin"` + AuthOAuth2RedirectURL string `koanf:"authOAuth2RedirectUrl"` + AuthOAuth2Providers map[string]*OAuth2Provider `koanf:"authOAuth2Providers"` + DefaultPermissions PermissionsList `koanf:"defaultPermissions"` + DefaultPolicy ConfigurationPolicy `koanf:"defaultPolicy"` + AccessControlLists []*AccessControlList `koanf:"accessControlLists"` + WebUIDir string `koanf:"webUIDir"` + CronSupportForSeconds bool `koanf:"cronSupportForSeconds"` + SectionNavigationStyle string `koanf:"sectionNavigationStyle"` + DefaultPopupOnStart string `koanf:"defaultPopupOnStart"` + InsecureAllowDumpOAuth2UserData bool `koanf:"insecureAllowDumpOAuth2UserData"` + InsecureAllowDumpVars bool `koanf:"insecureAllowDumpVars"` + InsecureAllowDumpSos bool `koanf:"insecureAllowDumpSos"` + InsecureAllowDumpActionMap bool `koanf:"insecureAllowDumpActionMap"` + InsecureAllowDumpJwtClaims bool `koanf:"insecureAllowDumpJwtClaims"` + Prometheus PrometheusConfig `koanf:"prometheus"` + SaveLogs SaveLogsConfig `koanf:"saveLogs"` + DefaultIconForActions string `koanf:"defaultIconForActions"` + DefaultIconForDirectories string `koanf:"defaultIconForDirectories"` + DefaultIconForBack string `koanf:"defaultIconForBack"` + AdditionalNavigationLinks []*NavigationLink `koanf:"additionalNavigationLinks"` + ServiceHostMode string `koanf:"serviceHostMode"` + StyleMods []string `koanf:"styleMods"` + BannerMessage string `koanf:"bannerMessage"` + BannerCSS string `koanf:"bannerCss"` + Include string `koanf:"include"` sourceFiles []string } type AuthLocalUsersConfig struct { - Enabled bool `mapstructure:"enabled"` - Users []*LocalUser `mapstructure:"users"` + Enabled bool `koanf:"enabled"` + Users []*LocalUser `koanf:"users"` } type LocalUser struct { - Username string `mapstructure:"username"` - Usergroup string `mapstructure:"usergroup"` - Password string `mapstructure:"password"` + Username string `koanf:"username"` + Usergroup string `koanf:"usergroup"` + Password string `koanf:"password"` } type OAuth2Provider struct { - Name string - Title string - ClientID string - ClientSecret string - Icon string - Scopes []string - AuthUrl string - TokenUrl string - WhoamiUrl string - UsernameField string - UserGroupField string - InsecureSkipVerify bool - CallbackTimeout int - CertBundlePath string + Name string `koanf:"name"` + Title string `koanf:"title"` + ClientID string `koanf:"clientId"` + ClientSecret string `koanf:"clientSecret"` + Icon string `koanf:"icon"` + Scopes []string `koanf:"scopes"` + AuthUrl string `koanf:"authUrl"` + TokenUrl string `koanf:"tokenUrl"` + WhoamiUrl string `koanf:"whoamiUrl"` + UsernameField string `koanf:"usernameField"` + UserGroupField string `koanf:"userGroupField"` + InsecureSkipVerify bool `koanf:"insecureSkipVerify"` + CallbackTimeout int `koanf:"callbackTimeout"` + CertBundlePath string `koanf:"certBundlePath"` } type NavigationLink struct { - Title string `mapstructure:"title"` - Url string `mapstructure:"url"` - Target string `mapstructure:"target"` + Title string `koanf:"title"` + Url string `koanf:"url"` + Target string `koanf:"target"` } type SaveLogsConfig struct { - ResultsDirectory string `mapstructure:"resultsDirectory"` - OutputDirectory string `mapstructure:"outputDirectory"` + ResultsDirectory string `koanf:"resultsDirectory"` + OutputDirectory string `koanf:"outputDirectory"` } type LogDebugOptions struct { - SingleFrontendRequests bool - SingleFrontendRequestHeaders bool - AclCheckStarted bool - AclMatched bool - AclNotMatched bool - AclNoneMatched bool + SingleFrontendRequests bool `koanf:"singleFrontendRequests"` + SingleFrontendRequestHeaders bool `koanf:"singleFrontendRequestHeaders"` + AclCheckStarted bool `koanf:"aclCheckStarted"` + AclMatched bool `koanf:"aclMatched"` + AclNotMatched bool `koanf:"aclNotMatched"` + AclNoneMatched bool `koanf:"aclNoneMatched"` } type DashboardComponent struct { - Title string - Type string - Entity string - Icon string - CssClass string - Contents []*DashboardComponent + Title string `koanf:"title"` + Type string `koanf:"type"` + Entity string `koanf:"entity"` + Icon string `koanf:"icon"` + CssClass string `koanf:"cssClass"` + Contents []*DashboardComponent `koanf:"contents"` } func DefaultConfig() *Config { diff --git a/service/internal/config/config_reloader.go b/service/internal/config/config_reloader.go index ab8e5d7..78b0d2f 100644 --- a/service/internal/config/config_reloader.go +++ b/service/internal/config/config_reloader.go @@ -3,8 +3,6 @@ package config import ( "os" "path/filepath" - "reflect" - "regexp" "sort" "strings" @@ -34,17 +32,6 @@ func AddListener(l func()) { listeners = append(listeners, l) } -// AppendSourceWithIncludes loads base config and any included configs -func AppendSourceWithIncludes(cfg *Config, k *koanf.Koanf, configPath string) { - // Load base config first - AppendSource(cfg, k, configPath) - - // Load included configs if specified - if cfg.Include != "" { - LoadIncludedConfigs(cfg, k, configPath) - } -} - func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) { log.Infof("Appending cfg source: %s", configPath) @@ -52,72 +39,29 @@ func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) { return } - loadCollectionsFallbacks(k, cfg) - - applyConfigOverrides(k, cfg) - afterLoadFinalize(cfg, configPath) } +func AppendSourceWithIncludes(cfg *Config, k *koanf.Koanf, configPath string) { + AppendSource(cfg, k, configPath) + + if cfg.Include != "" { + LoadIncludedConfigs(cfg, k, configPath) + } +} + func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool { - if err := k.Unmarshal(".", cfg); err != nil { + err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{ + Tag: "koanf", + }) + + if err != nil { log.Errorf("Error unmarshalling config: %v", err) return false } return true } -func loadCollectionsFallbacks(k *koanf.Koanf, cfg *Config) { - maybeUnmarshalActions(k, cfg) - maybeUnmarshalDashboards(k, cfg) - maybeUnmarshalEntities(k, cfg) - maybeUnmarshalAuthLocalUsers(k, cfg) -} - -func maybeUnmarshalActions(k *koanf.Koanf, cfg *Config) { - if len(cfg.Actions) != 0 || !k.Exists("actions") { - return - } - var actions []*Action - if err := k.Unmarshal("actions", &actions); err == nil { - cfg.Actions = actions - log.Debugf("Manually loaded %d actions", len(actions)) - } -} - -func maybeUnmarshalDashboards(k *koanf.Koanf, cfg *Config) { - if len(cfg.Dashboards) != 0 || !k.Exists("dashboards") { - return - } - var dashboards []*DashboardComponent - if err := k.Unmarshal("dashboards", &dashboards); err == nil { - cfg.Dashboards = dashboards - log.Debugf("Manually loaded %d dashboards", len(dashboards)) - } -} - -func maybeUnmarshalEntities(k *koanf.Koanf, cfg *Config) { - if len(cfg.Entities) != 0 || !k.Exists("entities") { - return - } - var entities []*EntityFile - if err := k.Unmarshal("entities", &entities); err == nil { - cfg.Entities = entities - log.Debugf("Manually loaded %d entities", len(entities)) - } -} - -func maybeUnmarshalAuthLocalUsers(k *koanf.Koanf, cfg *Config) { - if len(cfg.AuthLocalUsers.Users) != 0 || !k.Exists("authLocalUsers") { - return - } - var authLocalUsers AuthLocalUsersConfig - if err := k.Unmarshal("authLocalUsers", &authLocalUsers); err == nil { - cfg.AuthLocalUsers = authLocalUsers - log.Debugf("Manually loaded local auth config") - } -} - func afterLoadFinalize(cfg *Config, configPath string) { metricConfigReloadedCount.Inc() metricConfigActionCount.Set(float64(len(cfg.Actions))) @@ -130,31 +74,6 @@ func afterLoadFinalize(cfg *Config, configPath string) { } } -func applyConfigOverrides(k *koanf.Koanf, cfg *Config) { - // Override fields that should be read from config - // mapstructure tags should make most of this unnecessary, but keep for safety - boolVal(k, "showFooter", &cfg.ShowFooter) - boolVal(k, "showNavigation", &cfg.ShowNavigation) - boolVal(k, "checkForUpdates", &cfg.CheckForUpdates) - boolVal(k, "useSingleHTTPFrontend", &cfg.UseSingleHTTPFrontend) - stringVal(k, "logLevel", &cfg.LogLevel) - stringVal(k, "pageTitle", &cfg.PageTitle) - boolVal(k, "authRequireGuestsToLogin", &cfg.AuthRequireGuestsToLogin) - stringVal(k, "include", &cfg.Include) - - // Handle nested defaultPolicy struct - if k.Exists("defaultPolicy") { - boolVal(k, "defaultPolicy.showDiagnostics", &cfg.DefaultPolicy.ShowDiagnostics) - boolVal(k, "defaultPolicy.showLogList", &cfg.DefaultPolicy.ShowLogList) - } - - // Handle nested prometheus struct - if k.Exists("prometheus") { - boolVal(k, "prometheus.enabled", &cfg.Prometheus.Enabled) - boolVal(k, "prometheus.defaultGoMetrics", &cfg.Prometheus.DefaultGoMetrics) - } -} - // LoadIncludedConfigs loads configuration files from an include directory and merges them func LoadIncludedConfigs(cfg *Config, k *koanf.Koanf, baseConfigPath string) { if cfg.Include == "" { @@ -171,7 +90,7 @@ func LoadIncludedConfigs(cfg *Config, k *koanf.Koanf, baseConfigPath string) { sort.Strings(yamlFiles) for _, filename := range yamlFiles { - loadAndMergeIncludedFile(cfg, includePath, filename) + loadAndMergeIncludedFile(k, cfg, includePath, filename) } log.Infof("Finished loading %d included config file(s)", len(yamlFiles)) @@ -209,152 +128,26 @@ func listYamlFiles(includePath string) ([]string, bool) { return yamlFiles, true } -func loadAndMergeIncludedFile(cfg *Config, includePath, filename string) { +func loadAndMergeIncludedFile(k *koanf.Koanf, cfg *Config, includePath, filename string) { filePath := filepath.Join(includePath, filename) log.Infof("Loading included config file: %s", filePath) - includeK := koanf.New(".") - if err := includeK.Load(file.Provider(filePath), yaml.Parser()); err != nil { + if err := k.Load(file.Provider(filePath), yaml.Parser()); err != nil { log.Errorf("Error loading included config file %s: %v", filePath, err) return } - tempCfg := &Config{} - if err := includeK.Unmarshal(".", tempCfg); err != nil { + if err := k.Unmarshal(".", cfg); err != nil { log.Errorf("Error unmarshalling included config file %s: %v", filePath, err) return } - loadCollectionsFallbacks(includeK, tempCfg) - - mergeConfig(cfg, tempCfg) - log.Infof("Successfully loaded and merged %s", filename) -} - -func mergeConfig(base *Config, overlay *Config) { - mergeSlices(base, overlay) - overrideSimple(base, overlay) - overrideNested(base, overlay) - overrideStrings(base, overlay) -} - -func mergeSlices(base *Config, overlay *Config) { - if len(overlay.Actions) > 0 { - base.Actions = append(base.Actions, overlay.Actions...) - } - if len(overlay.Dashboards) > 0 { - base.Dashboards = append(base.Dashboards, overlay.Dashboards...) - log.Debugf("Merged %d dashboards from include", len(overlay.Dashboards)) - } - if len(overlay.Entities) > 0 { - base.Entities = append(base.Entities, overlay.Entities...) - log.Debugf("Merged %d entities from include", len(overlay.Entities)) - } - if len(overlay.AccessControlLists) > 0 { - base.AccessControlLists = append(base.AccessControlLists, overlay.AccessControlLists...) - log.Debugf("Merged %d access control lists from include", len(overlay.AccessControlLists)) - } - if len(overlay.AuthLocalUsers.Users) > 0 { - base.AuthLocalUsers.Users = append(base.AuthLocalUsers.Users, overlay.AuthLocalUsers.Users...) - log.Debugf("Merged %d local users from include", len(overlay.AuthLocalUsers.Users)) - } - if len(overlay.StyleMods) > 0 { - base.StyleMods = append(base.StyleMods, overlay.StyleMods...) - } - if len(overlay.AdditionalNavigationLinks) > 0 { - base.AdditionalNavigationLinks = append(base.AdditionalNavigationLinks, overlay.AdditionalNavigationLinks...) - } -} - -func overrideSimple(base *Config, overlay *Config) { - if overlay.LogLevel != "" { - base.LogLevel = overlay.LogLevel - } - if overlay.PageTitle != "" { - base.PageTitle = overlay.PageTitle - } - if overlay.ShowFooter != base.ShowFooter { - base.ShowFooter = overlay.ShowFooter - } - if overlay.ShowNavigation != base.ShowNavigation { - base.ShowNavigation = overlay.ShowNavigation - } - if overlay.CheckForUpdates != base.CheckForUpdates { - base.CheckForUpdates = overlay.CheckForUpdates - } - if overlay.UseSingleHTTPFrontend != base.UseSingleHTTPFrontend { - base.UseSingleHTTPFrontend = overlay.UseSingleHTTPFrontend - } - if overlay.AuthRequireGuestsToLogin != base.AuthRequireGuestsToLogin { - base.AuthRequireGuestsToLogin = overlay.AuthRequireGuestsToLogin - } - if overlay.AuthLocalUsers.Enabled { - base.AuthLocalUsers.Enabled = overlay.AuthLocalUsers.Enabled - } -} - -func overrideNested(base *Config, overlay *Config) { - // Only apply overrides when overlay explicitly enables the option. - // This mirrors the presence-check pattern used elsewhere to avoid - // unintentionally disabling an already-enabled base setting with a default false. - if overlay.DefaultPolicy.ShowDiagnostics { - base.DefaultPolicy.ShowDiagnostics = true - } - if overlay.DefaultPolicy.ShowLogList { - base.DefaultPolicy.ShowLogList = true - } - if overlay.Prometheus.Enabled { - base.Prometheus.Enabled = true - } - if overlay.Prometheus.DefaultGoMetrics { - base.Prometheus.DefaultGoMetrics = true - } -} - -func overrideStrings(base *Config, overlay *Config) { - overrideString(&base.BannerMessage, overlay.BannerMessage) - overrideString(&base.BannerCSS, overlay.BannerCSS) - overrideString(&base.LogLevel, overlay.LogLevel) - overrideString(&base.PageTitle, overlay.PageTitle) - overrideString(&base.SectionNavigationStyle, overlay.SectionNavigationStyle) - overrideString(&base.DefaultPopupOnStart, overlay.DefaultPopupOnStart) -} - -func overrideString(base *string, overlay string) { - if overlay != "" { - *base = overlay - } -} - -func getActionTitles(actions []*Action) []string { - titles := make([]string, len(actions)) - for i, action := range actions { - titles[i] = action.Title - } - return titles + log.Infof("Successfully loaded %s", filename) } +/** var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`) -// Helper functions to reduce repetitive if/set chains -func stringVal(k *koanf.Koanf, key string, dest *string) { - if k.Exists(key) { - *dest = k.String(key) - } -} - -func boolVal(k *koanf.Koanf, key string, dest *bool) { - if k.Exists(key) { - *dest = k.Bool(key) - } -} - -func int64Val(k *koanf.Koanf, key string, dest *int64) { - if k.Exists(key) { - *dest = k.Int64(key) - } -} - func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) { log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data) if from.Kind() != reflect.String { @@ -375,3 +168,4 @@ func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error log.Debugf("Environment variable interpolation result: %q -> %q", input, output) return output, nil } +*/ From 1552c104e9c7624eeb411e7d974ce810d0a9c41b Mon Sep 17 00:00:00 2001 From: jamesread Date: Fri, 7 Nov 2025 00:31:33 +0000 Subject: [PATCH 3/3] fix: Propperly merge included configs (handling actions) --- service/internal/config/config_reloader.go | 74 ++++++++++++++-------- service/main.go | 23 ++++--- 2 files changed, 60 insertions(+), 37 deletions(-) diff --git a/service/internal/config/config_reloader.go b/service/internal/config/config_reloader.go index 78b0d2f..9909f02 100644 --- a/service/internal/config/config_reloader.go +++ b/service/internal/config/config_reloader.go @@ -3,6 +3,8 @@ package config import ( "os" "path/filepath" + "reflect" + "regexp" "sort" "strings" @@ -33,7 +35,11 @@ func AddListener(l func()) { } func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) { - log.Infof("Appending cfg source: %s", configPath) + log.WithFields(log.Fields{ + "configPath": configPath, + }).Info("Appending cfg source") + + loadIncludedConfigsFromDir(k, configPath) if !unmarshalRoot(k, cfg) { return @@ -42,14 +48,6 @@ func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) { afterLoadFinalize(cfg, configPath) } -func AppendSourceWithIncludes(cfg *Config, k *koanf.Koanf, configPath string) { - AppendSource(cfg, k, configPath) - - if cfg.Include != "" { - LoadIncludedConfigs(cfg, k, configPath) - } -} - func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool { err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{ Tag: "koanf", @@ -74,14 +72,19 @@ func afterLoadFinalize(cfg *Config, configPath string) { } } -// LoadIncludedConfigs loads configuration files from an include directory and merges them -func LoadIncludedConfigs(cfg *Config, k *koanf.Koanf, baseConfigPath string) { - if cfg.Include == "" { +// loadIncludedConfigsFromDir loads configuration files from an include directory and merges them +func loadIncludedConfigsFromDir(k *koanf.Koanf, baseConfigPath string) { + relativeIncludePath := k.String("include") + + if relativeIncludePath == "" { return } - includePath := filepath.Join(filepath.Dir(baseConfigPath), cfg.Include) - log.Infof("Loading included configs from: %s", includePath) + includePath := filepath.Join(filepath.Dir(baseConfigPath), relativeIncludePath) + + log.WithFields(log.Fields{ + "includePath": includePath, + }).Infof("Loading included configs from dir") yamlFiles, ok := listYamlFiles(includePath) if !ok || len(yamlFiles) == 0 { @@ -90,11 +93,10 @@ func LoadIncludedConfigs(cfg *Config, k *koanf.Koanf, baseConfigPath string) { sort.Strings(yamlFiles) for _, filename := range yamlFiles { - loadAndMergeIncludedFile(k, cfg, includePath, filename) + loadAndMergeIncludedFile(k, includePath, filename) } log.Infof("Finished loading %d included config file(s)", len(yamlFiles)) - cfg.Sanitize() } func listYamlFiles(includePath string) ([]string, bool) { @@ -128,24 +130,43 @@ func listYamlFiles(includePath string) ([]string, bool) { return yamlFiles, true } -func loadAndMergeIncludedFile(k *koanf.Koanf, cfg *Config, includePath, filename string) { +func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) { filePath := filepath.Join(includePath, filename) - log.Infof("Loading included config file: %s", filePath) - if err := k.Load(file.Provider(filePath), yaml.Parser()); err != nil { + if err := k.Load(file.Provider(filePath), yaml.Parser(), koanf.WithMergeFunc(mergeFunc)); err != nil { log.Errorf("Error loading included config file %s: %v", filePath, err) return } - if err := k.Unmarshal(".", cfg); err != nil { - log.Errorf("Error unmarshalling included config file %s: %v", filePath, err) - return - } - - log.Infof("Successfully loaded %s", filename) + log.WithFields(log.Fields{ + "filePath": filePath, + }).Info("Successfully loaded included config file") +} + +func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error { + // Handle actions merging - koanf provides []interface{} not []*Action + // Merge src (new) into dest (existing) by appending src's actions to dest's actions + if srcActions, ok := src["actions"]; ok { + if destActions, ok := dest["actions"]; ok { + // Both have actions - append src to dest + srcSlice, ok1 := srcActions.([]interface{}) + destSlice, ok2 := destActions.([]interface{}) + if ok1 && ok2 { + dest["actions"] = append(destSlice, srcSlice...) + } else { + // Fallback: if types don't match, just use src + dest["actions"] = srcActions + } + } else { + // dest doesn't have actions, so use src's actions + dest["actions"] = srcActions + } + } + // If src doesn't have actions, leave dest unchanged + + return nil } -/** var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`) func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) { @@ -168,4 +189,3 @@ func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error log.Debugf("Environment variable interpolation result: %q -> %q", input, output) return output, nil } -*/ diff --git a/service/main.go b/service/main.go index f1dd357..8e4d824 100644 --- a/service/main.go +++ b/service/main.go @@ -146,23 +146,28 @@ func initConfig(configDir string) { ) } - var firstConfigPath string + var baseConfigPath string for _, directory := range directories { configPath := getConfigPath(directory) - log.Debugf("Checking config path: %s", configPath) + log.WithFields(log.Fields{ + "configPath": configPath, + }).Debug("Checking base config file path") if _, err := os.Stat(configPath); err != nil { log.Debugf("Config file not found at %s: %v", configPath, err) continue } - if firstConfigPath == "" { - firstConfigPath = configPath + if baseConfigPath == "" { + baseConfigPath = configPath } - log.Infof("Loading config from %s", configPath) + log.WithFields(log.Fields{ + "configPath": configPath, + }).Info("Loading config from path") + f := file.Provider(configPath) if err := k.Load(f, yaml.Parser()); err != nil { @@ -176,15 +181,13 @@ func initConfig(configDir string) { k.Load(f, yaml.Parser()) config.AppendSource(cfg, k, configPath) }) + + break } cfg = config.DefaultConfigWithBasePort(getBasePort()) - if firstConfigPath != "" { - config.AppendSourceWithIncludes(cfg, k, firstConfigPath) - } else { - config.AppendSource(cfg, k, "base") - } + config.AppendSource(cfg, k, baseConfigPath) }