فهرست منبع

feat: #428 Initial support for include directive in config files

jamesread 8 ماه پیش
والد
کامیت
a551589840

+ 6 - 0
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
+

+ 9 - 0
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"
+

+ 14 - 0
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
+

+ 53 - 0
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')
+  })
+})
+

+ 1 - 0
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
 }

+ 238 - 1
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

+ 1 - 1
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")
 	}