diff --git a/integration-tests/configs/include/config.d/00-first.yml b/integration-tests/configs/include/config.d/00-first.yml new file mode 100644 index 0000000..19cb232 --- /dev/null +++ b/integration-tests/configs/include/config.d/00-first.yml @@ -0,0 +1,6 @@ +# This file should be loaded first +actions: +- title: First Included Action + shell: echo "first" + icon: ping + diff --git a/integration-tests/configs/include/config.d/01-second.yml b/integration-tests/configs/include/config.d/01-second.yml new file mode 100644 index 0000000..b022d26 --- /dev/null +++ b/integration-tests/configs/include/config.d/01-second.yml @@ -0,0 +1,9 @@ +# This file should be loaded second +actions: +- title: Second Included Action + shell: echo "second" + icon: ping + +# Override base setting +logLevel: "INFO" + diff --git a/integration-tests/configs/include/config.yaml b/integration-tests/configs/include/config.yaml new file mode 100644 index 0000000..d672acf --- /dev/null +++ b/integration-tests/configs/include/config.yaml @@ -0,0 +1,14 @@ +# +# Integration Test Config: Include Directive +# + +logLevel: "DEBUG" +checkForUpdates: false + +include: config.d + +actions: +- title: Base Action + shell: echo "base" + icon: ping + diff --git a/integration-tests/test/include.mjs b/integration-tests/test/include.mjs new file mode 100644 index 0000000..86cfe1b --- /dev/null +++ b/integration-tests/test/include.mjs @@ -0,0 +1,53 @@ +import { describe, it, before, after } from 'mocha' +import { expect } from 'chai' +import { By, until } from 'selenium-webdriver' +import { + getRootAndWait, + getActionButtons, + takeScreenshotOnFailure, +} from '../lib/elements.js' + +describe('config: include', function () { + this.timeout(30000) + + before(async function () { + await runner.start('include') + }) + + after(async () => { + await runner.stop() + }) + + afterEach(function () { + takeScreenshotOnFailure(this.currentTest, webdriver); + }); + + it('Should load actions from base config and included files', async function () { + await getRootAndWait() + + // Wait for the page to be ready + await webdriver.wait(until.elementLocated(By.css('.action-button')), 10000) + + const buttons = await getActionButtons() + + // We should have: + // 1. Base Action from config.yaml + // 2. First Included Action from 00-first.yml + // 3. Second Included Action from 01-second.yml + expect(buttons.length).to.be.at.least(3, 'Should have at least 3 actions from base + includes') + + // Verify all actions are present + const buttonTexts = await Promise.all(buttons.map(btn => btn.getText())) + + console.log('Found actions:', buttonTexts) + + // Text includes newline, so check with includes + const allText = buttonTexts.join(' ') + expect(allText).to.include('Base Action') + expect(allText).to.include('First Included Action') + expect(allText).to.include('Second Included Action') + + console.log('✓ Include directive loaded actions from all files') + }) +}) + diff --git a/service/internal/config/config.go b/service/internal/config/config.go index fe8904f..0e1e800 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -153,6 +153,7 @@ type Config struct { StyleMods []string `mapstructure:"styleMods"` BannerMessage string `mapstructure:"bannerMessage"` BannerCSS string `mapstructure:"bannerCss"` + Include string `mapstructure:"include"` sourceFiles []string } diff --git a/service/internal/config/config_reloader.go b/service/internal/config/config_reloader.go index e0e2cd0..36b36ed 100644 --- a/service/internal/config/config_reloader.go +++ b/service/internal/config/config_reloader.go @@ -5,7 +5,11 @@ import ( "path/filepath" "reflect" "regexp" + "sort" + "strings" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -30,10 +34,21 @@ 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) - // Unmarshal the entire config with mapstructure tags + // Unmarshal config - koanf will handle mapstructure tags automatically err := k.Unmarshal(".", cfg) if err != nil { log.Errorf("Error unmarshalling config: %v", err) @@ -74,6 +89,10 @@ func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) { } } + // Map structure tags should handle these automatically, but we keep fallbacks + // for fields that might not unmarshal correctly + applyConfigOverrides(k, cfg) + metricConfigReloadedCount.Inc() metricConfigActionCount.Set(float64(len(cfg.Actions))) @@ -85,6 +104,224 @@ func AppendSource(cfg *Config, k *koanf.Koanf, 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 == "" { + return + } + + configDir := filepath.Dir(baseConfigPath) + includePath := filepath.Join(configDir, cfg.Include) + + log.Infof("Loading included configs from: %s", includePath) + + // Check if the include directory exists + dirInfo, err := os.Stat(includePath) + if err != nil { + log.Warnf("Include directory not found: %s", includePath) + return + } + + if !dirInfo.IsDir() { + log.Warnf("Include path is not a directory: %s", includePath) + return + } + + // Read all .yml files from the directory + entries, err := os.ReadDir(includePath) + if err != nil { + log.Errorf("Error reading include directory: %v", err) + return + } + + // Filter and sort .yml files + var yamlFiles []string + for _, entry := range entries { + if !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".yml") || strings.HasSuffix(entry.Name(), ".yaml")) { + yamlFiles = append(yamlFiles, entry.Name()) + } + } + + if len(yamlFiles) == 0 { + log.Infof("No YAML files found in include directory: %s", includePath) + return + } + + // Sort files to ensure deterministic load order + sort.Strings(yamlFiles) + + // Load each file and merge into config + for _, filename := range yamlFiles { + filePath := filepath.Join(includePath, filename) + log.Infof("Loading included config file: %s", filePath) + + includeK := koanf.New(".") + f := file.Provider(filePath) + + if err := includeK.Load(f, yaml.Parser()); err != nil { + log.Errorf("Error loading included config file %s: %v", filePath, err) + continue + } + + // Unmarshal into a temporary config to process properly + tempCfg := &Config{} + if err := includeK.Unmarshal(".", tempCfg); err != nil { + log.Errorf("Error unmarshalling included config file %s: %v", filePath, err) + continue + } + + // Apply the same manual loading workarounds as in AppendSource + if len(tempCfg.Actions) == 0 && includeK.Exists("actions") { + var actions []*Action + if err := includeK.Unmarshal("actions", &actions); err == nil { + tempCfg.Actions = actions + log.Debugf("Manually loaded %d actions from %s", len(actions), filename) + } + } + + // Merge the temp config into the main config + // Later files override earlier ones + mergeConfig(cfg, tempCfg) + + log.Infof("Successfully loaded and merged %s", filename) + } + + log.Infof("Finished loading %d included config file(s)", len(yamlFiles)) + + // Sanitize the merged config + cfg.Sanitize() +} + +func mergeConfig(base *Config, overlay *Config) { + // Merge Actions - overlay appends to base + if len(overlay.Actions) > 0 { + base.Actions = append(base.Actions, overlay.Actions...) + } + + // Merge Dashboards - overlay appends to base + if len(overlay.Dashboards) > 0 { + base.Dashboards = append(base.Dashboards, overlay.Dashboards...) + log.Debugf("Merged %d dashboards from include", len(overlay.Dashboards)) + } + + // Merge Entities - overlay appends to base + if len(overlay.Entities) > 0 { + base.Entities = append(base.Entities, overlay.Entities...) + log.Debugf("Merged %d entities from include", len(overlay.Entities)) + } + + // Merge AccessControlLists - overlay appends to base + if len(overlay.AccessControlLists) > 0 { + base.AccessControlLists = append(base.AccessControlLists, overlay.AccessControlLists...) + log.Debugf("Merged %d access control lists from include", len(overlay.AccessControlLists)) + } + + // Merge AuthLocalUsers.Users - overlay appends to base + 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)) + } + + // Merge slices by appending + if len(overlay.StyleMods) > 0 { + base.StyleMods = append(base.StyleMods, overlay.StyleMods...) + } + + if len(overlay.AdditionalNavigationLinks) > 0 { + base.AdditionalNavigationLinks = append(base.AdditionalNavigationLinks, overlay.AdditionalNavigationLinks...) + } + + // Override simple fields (later files win) + 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 + } + + // Override nested structs + if overlay.DefaultPolicy.ShowDiagnostics != base.DefaultPolicy.ShowDiagnostics { + base.DefaultPolicy.ShowDiagnostics = overlay.DefaultPolicy.ShowDiagnostics + } + if overlay.DefaultPolicy.ShowLogList != base.DefaultPolicy.ShowLogList { + base.DefaultPolicy.ShowLogList = overlay.DefaultPolicy.ShowLogList + } + + if overlay.Prometheus.Enabled != base.Prometheus.Enabled { + base.Prometheus.Enabled = overlay.Prometheus.Enabled + } + if overlay.Prometheus.DefaultGoMetrics != base.Prometheus.DefaultGoMetrics { + base.Prometheus.DefaultGoMetrics = overlay.Prometheus.DefaultGoMetrics + } + + // Override AuthLocalUsers.Enabled if set + if overlay.AuthLocalUsers.Enabled { + base.AuthLocalUsers.Enabled = overlay.AuthLocalUsers.Enabled + } + + // Override string fields if non-empty + 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 +} + var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`) // Helper functions to reduce repetitive if/set chains diff --git a/service/main.go b/service/main.go index 34aabd1..74e89ba 100644 --- a/service/main.go +++ b/service/main.go @@ -176,7 +176,7 @@ func initConfig(configDir string) { cfg = config.DefaultConfigWithBasePort(getBasePort()) if firstConfigPath != "" { - config.AppendSource(cfg, k, firstConfigPath) + config.AppendSourceWithIncludes(cfg, k, firstConfigPath) } else { config.AppendSource(cfg, k, "base") }