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

This commit is contained in:
jamesread
2025-10-27 15:32:30 +00:00
parent fcd3ccc59a
commit a551589840
7 changed files with 322 additions and 2 deletions

View File

@@ -0,0 +1,6 @@
# This file should be loaded first
actions:
- title: First Included Action
shell: echo "first"
icon: ping

View File

@@ -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"

View File

@@ -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

View File

@@ -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')
})
})

View File

@@ -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
}

View File

@@ -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

View File

@@ -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")
}