mirror of
https://github.com/OliveTin/OliveTin
synced 2025-10-29 20:37:02 +00:00
feat: #428 Initial support for include directive in config files
This commit is contained in:
6
integration-tests/configs/include/config.d/00-first.yml
Normal file
6
integration-tests/configs/include/config.d/00-first.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
# This file should be loaded first
|
||||
actions:
|
||||
- title: First Included Action
|
||||
shell: echo "first"
|
||||
icon: ping
|
||||
|
||||
9
integration-tests/configs/include/config.d/01-second.yml
Normal file
9
integration-tests/configs/include/config.d/01-second.yml
Normal 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"
|
||||
|
||||
14
integration-tests/configs/include/config.yaml
Normal file
14
integration-tests/configs/include/config.yaml
Normal 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
|
||||
|
||||
53
integration-tests/test/include.mjs
Normal file
53
integration-tests/test/include.mjs
Normal 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user