feat: #428 Initial support for include directive in config files
This commit is contained in:
parent
fcd3ccc59a
commit
a551589840
|
|
@ -0,0 +1,6 @@
|
||||||
|
# This file should be loaded first
|
||||||
|
actions:
|
||||||
|
- title: First Included Action
|
||||||
|
shell: echo "first"
|
||||||
|
icon: ping
|
||||||
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
@ -153,6 +153,7 @@ type Config struct {
|
||||||
StyleMods []string `mapstructure:"styleMods"`
|
StyleMods []string `mapstructure:"styleMods"`
|
||||||
BannerMessage string `mapstructure:"bannerMessage"`
|
BannerMessage string `mapstructure:"bannerMessage"`
|
||||||
BannerCSS string `mapstructure:"bannerCss"`
|
BannerCSS string `mapstructure:"bannerCss"`
|
||||||
|
Include string `mapstructure:"include"`
|
||||||
|
|
||||||
sourceFiles []string
|
sourceFiles []string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/knadh/koanf/parsers/yaml"
|
||||||
|
"github.com/knadh/koanf/providers/file"
|
||||||
"github.com/knadh/koanf/v2"
|
"github.com/knadh/koanf/v2"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
|
@ -30,10 +34,21 @@ func AddListener(l func()) {
|
||||||
listeners = append(listeners, l)
|
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) {
|
func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
|
||||||
log.Infof("Appending cfg source: %s", configPath)
|
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)
|
err := k.Unmarshal(".", cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Error unmarshalling config: %v", err)
|
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()
|
metricConfigReloadedCount.Inc()
|
||||||
metricConfigActionCount.Set(float64(len(cfg.Actions)))
|
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+) *?}}`)
|
var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`)
|
||||||
|
|
||||||
// Helper functions to reduce repetitive if/set chains
|
// Helper functions to reduce repetitive if/set chains
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ func initConfig(configDir string) {
|
||||||
cfg = config.DefaultConfigWithBasePort(getBasePort())
|
cfg = config.DefaultConfigWithBasePort(getBasePort())
|
||||||
|
|
||||||
if firstConfigPath != "" {
|
if firstConfigPath != "" {
|
||||||
config.AppendSource(cfg, k, firstConfigPath)
|
config.AppendSourceWithIncludes(cfg, k, firstConfigPath)
|
||||||
} else {
|
} else {
|
||||||
config.AppendSource(cfg, k, "base")
|
config.AppendSource(cfg, k, "base")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue