Configuration Hardening 2
What changed:
- Added visible runtime override reporting with masking for sensitive values.
- Expanded effective config for build cleanup, script compiler discovery/env, extract layout/cleanup/
HAK discovery, wiki deploy defaults, autogen cache policy, and release-source env names.
- Wired configured values into build, script compiler resolution, extract, autogen cache refresh/max
age, parts manifest release source, wiki generation/deploy, and TLK output naming.
- Added config sources override output and validation-rule hints in config explain.
- Added CONFIGURATION_HARDENING_AUDIT.md.
- Updated README.md with config commands, schema defaults, and JSON artifact boundaries.
- Added regression tests for override visibility/masking, compiler/extract/wiki/autogen config,
configured HAK discovery, and config sources output.
This commit is contained in:
+213
-65
@@ -3,6 +3,7 @@ package project
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -10,29 +11,38 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBuildPath = "build"
|
||||
DefaultCachePath = ".cache"
|
||||
DefaultToolsPath = "tools"
|
||||
DefaultModuleArchiveTemplate = "{module.resref}.mod"
|
||||
DefaultHAKManifest = "haks.json"
|
||||
DefaultHAKArchiveTemplate = "{hak.name}.hak"
|
||||
DefaultSourceJSONPattern = "{resref}.{extension}.json"
|
||||
DefaultScriptsSourceDir = "scripts"
|
||||
DefaultScriptsCache = "{paths.cache}/ncs"
|
||||
DefaultMusicStageRoot = "{paths.cache}/music"
|
||||
DefaultCreditsRoot = "{paths.cache}/credits"
|
||||
DefaultCreditsOverlayFile = "CREDITS.md"
|
||||
DefaultTopDataBuild = "{paths.build}/topdata"
|
||||
DefaultTopDataCompiled2DADir = "2da"
|
||||
DefaultTopDataCompiledTLK = "sow_tlk.tlk"
|
||||
DefaultTopDataWikiOutputRoot = "wiki"
|
||||
DefaultTopDataWikiPagesDir = "pages"
|
||||
DefaultTopDataWikiStateFile = "state.json"
|
||||
DefaultTopDataPackageHAK = "sow_top.hak"
|
||||
DefaultTopDataPackageTLK = "sow_tlk.tlk"
|
||||
DefaultAutogenCacheRoot = "{paths.cache}"
|
||||
DefaultAutogenCacheMaxAge = time.Hour
|
||||
DefaultAutogenRefreshEnv = "SOW_AUTOGEN_MANIFEST_REFRESH"
|
||||
DefaultBuildPath = "build"
|
||||
DefaultCachePath = ".cache"
|
||||
DefaultToolsPath = "tools"
|
||||
DefaultModuleArchiveTemplate = "{module.resref}.mod"
|
||||
DefaultHAKManifest = "haks.json"
|
||||
DefaultHAKArchiveTemplate = "{hak.name}.hak"
|
||||
DefaultSourceJSONPattern = "{resref}.{extension}.json"
|
||||
DefaultScriptsSourceDir = "scripts"
|
||||
DefaultScriptsCache = "{paths.cache}/ncs"
|
||||
DefaultScriptCompilerEnvPath = "SOW_NWN_SCRIPT_COMPILER"
|
||||
DefaultMusicStageRoot = "{paths.cache}/music"
|
||||
DefaultCreditsRoot = "{paths.cache}/credits"
|
||||
DefaultCreditsOverlayFile = "CREDITS.md"
|
||||
DefaultTopDataBuild = "{paths.build}/topdata"
|
||||
DefaultTopDataCompiled2DADir = "2da"
|
||||
DefaultTopDataCompiledTLK = "sow_tlk.tlk"
|
||||
DefaultTopDataWikiOutputRoot = "wiki"
|
||||
DefaultTopDataWikiPagesDir = "pages"
|
||||
DefaultTopDataWikiStateFile = "state.json"
|
||||
DefaultWikiDeployManifest = ".wiki_deploy_manifest.json"
|
||||
DefaultWikiDeployEditSummary = "Auto-generated from native builder"
|
||||
DefaultTopDataPackageHAK = "sow_top.hak"
|
||||
DefaultTopDataPackageTLK = "sow_tlk.tlk"
|
||||
DefaultAutogenCacheRoot = "{paths.cache}"
|
||||
DefaultAutogenCacheMaxAge = time.Hour
|
||||
DefaultAutogenRefreshEnv = "SOW_AUTOGEN_MANIFEST_REFRESH"
|
||||
DefaultPartsManifestRefreshEnv = "SOW_PARTS_MANIFEST_REFRESH"
|
||||
DefaultAutogenReleaseProvider = "gitea"
|
||||
DefaultAutogenServerURLEnv = "SOW_ASSETS_SERVER_URL"
|
||||
DefaultAutogenRepoEnv = "SOW_ASSETS_REPO"
|
||||
DefaultExtractLayout = "nwn_canonical_json"
|
||||
DefaultExtractHAKDiscovery = "build_glob"
|
||||
)
|
||||
|
||||
type ConfigProvenance map[string]ConfigValueProvenance
|
||||
@@ -48,6 +58,7 @@ type EffectiveConfig struct {
|
||||
Module ModuleConfig `json:"module" yaml:"module"`
|
||||
Paths EffectivePathConfig `json:"paths" yaml:"paths"`
|
||||
Outputs EffectiveOutputConfig `json:"outputs" yaml:"outputs"`
|
||||
Build BuildConfig `json:"build" yaml:"build"`
|
||||
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
||||
Scripts EffectiveScriptsConfig `json:"scripts" yaml:"scripts"`
|
||||
Music EffectiveMusicConfig `json:"music" yaml:"music"`
|
||||
@@ -56,6 +67,7 @@ type EffectiveConfig struct {
|
||||
Autogen EffectiveAutogenConfig `json:"autogen" yaml:"autogen"`
|
||||
HAKs []HAKConfig `json:"haks" yaml:"haks"`
|
||||
Provenance ConfigProvenance `json:"provenance" yaml:"provenance"`
|
||||
Overrides []ConfigOverride `json:"overrides,omitempty" yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
type EffectivePathConfig struct {
|
||||
@@ -73,8 +85,21 @@ type EffectiveOutputConfig struct {
|
||||
}
|
||||
|
||||
type EffectiveScriptsConfig struct {
|
||||
SourceDir string `json:"source_dir" yaml:"source_dir"`
|
||||
Cache string `json:"cache" yaml:"cache"`
|
||||
SourceDir string `json:"source_dir" yaml:"source_dir"`
|
||||
Cache string `json:"cache" yaml:"cache"`
|
||||
Compiler EffectiveScriptCompilerConfig `json:"compiler" yaml:"compiler"`
|
||||
}
|
||||
|
||||
type EffectiveScriptCompilerConfig struct {
|
||||
Path string `json:"path" yaml:"path"`
|
||||
Search []string `json:"search" yaml:"search"`
|
||||
Env EffectiveScriptCompilerEnvConfig `json:"env" yaml:"env"`
|
||||
}
|
||||
|
||||
type EffectiveScriptCompilerEnvConfig struct {
|
||||
Path string `json:"path" yaml:"path"`
|
||||
NWNRoot []string `json:"nwn_root" yaml:"nwn_root"`
|
||||
NWNUserDirectory []string `json:"nwn_user_directory" yaml:"nwn_user_directory"`
|
||||
}
|
||||
|
||||
type EffectiveMusicConfig struct {
|
||||
@@ -98,15 +123,29 @@ type EffectiveTopDataConfig struct {
|
||||
}
|
||||
|
||||
type EffectiveAutogenConfig struct {
|
||||
Producers []AutogenProducerConfig `json:"producers" yaml:"producers"`
|
||||
Consumers []AutogenConsumerConfig `json:"consumers" yaml:"consumers"`
|
||||
Cache EffectiveAutogenCacheConfig `json:"cache" yaml:"cache"`
|
||||
Producers []AutogenProducerConfig `json:"producers" yaml:"producers"`
|
||||
Consumers []AutogenConsumerConfig `json:"consumers" yaml:"consumers"`
|
||||
Cache EffectiveAutogenCacheConfig `json:"cache" yaml:"cache"`
|
||||
ReleaseSource EffectiveAutogenReleaseSourceConfig `json:"release_source" yaml:"release_source"`
|
||||
}
|
||||
|
||||
type EffectiveAutogenCacheConfig struct {
|
||||
Root string `json:"root" yaml:"root"`
|
||||
MaxAge string `json:"max_age" yaml:"max_age"`
|
||||
RefreshEnv string `json:"refresh_env" yaml:"refresh_env"`
|
||||
Root string `json:"root" yaml:"root"`
|
||||
MaxAge string `json:"max_age" yaml:"max_age"`
|
||||
RefreshEnv string `json:"refresh_env" yaml:"refresh_env"`
|
||||
PartsManifestRefreshEnv string `json:"parts_manifest_refresh_env" yaml:"parts_manifest_refresh_env"`
|
||||
}
|
||||
|
||||
type EffectiveAutogenReleaseSourceConfig struct {
|
||||
Provider string `json:"provider" yaml:"provider"`
|
||||
ServerURLEnv string `json:"server_url_env" yaml:"server_url_env"`
|
||||
RepoEnv string `json:"repo_env" yaml:"repo_env"`
|
||||
}
|
||||
|
||||
type ConfigOverride struct {
|
||||
Key string `json:"key" yaml:"key"`
|
||||
Value string `json:"value" yaml:"value"`
|
||||
Source string `json:"source" yaml:"source"`
|
||||
}
|
||||
|
||||
func (p *Project) EffectiveConfig() EffectiveConfig {
|
||||
@@ -133,6 +172,15 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
||||
scripts := EffectiveScriptsConfig{
|
||||
SourceDir: defaultString(p.Config.Scripts.SourceDir, DefaultScriptsSourceDir),
|
||||
Cache: expandPathTemplate(defaultString(p.Config.Scripts.Cache, DefaultScriptsCache), paths),
|
||||
Compiler: EffectiveScriptCompilerConfig{
|
||||
Path: strings.TrimSpace(p.Config.Scripts.Compiler.Path),
|
||||
Search: expandPathTemplates(defaultStringSlice(p.Config.Scripts.Compiler.Search, defaultScriptCompilerSearch()), paths),
|
||||
Env: EffectiveScriptCompilerEnvConfig{
|
||||
Path: defaultString(p.Config.Scripts.Compiler.Env.Path, DefaultScriptCompilerEnvPath),
|
||||
NWNRoot: defaultStringSlice(p.Config.Scripts.Compiler.Env.NWNRoot, []string{"SOW_NWN_ROOT", "NWN_ROOT"}),
|
||||
NWNUserDirectory: defaultStringSlice(p.Config.Scripts.Compiler.Env.NWNUserDirectory, []string{"SOW_NWN_USER_DIRECTORY", "NWN_HOME", "NWN_USER_DIRECTORY"}),
|
||||
},
|
||||
},
|
||||
}
|
||||
topBuild := expandPathTemplate(defaultString(p.Config.TopData.Build, DefaultTopDataBuild), paths)
|
||||
top := EffectiveTopDataConfig{
|
||||
@@ -145,17 +193,28 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
||||
PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK),
|
||||
PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK),
|
||||
Wiki: TopDataWikiConfig{
|
||||
OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot),
|
||||
PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir),
|
||||
StateFile: defaultString(p.Config.TopData.Wiki.StateFile, DefaultTopDataWikiStateFile),
|
||||
OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot),
|
||||
PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir),
|
||||
StateFile: defaultString(p.Config.TopData.Wiki.StateFile, DefaultTopDataWikiStateFile),
|
||||
ManagedNamespaces: defaultStringSlice(p.Config.TopData.Wiki.ManagedNamespaces, []string{"classes", "feat", "itemtypes", "races", "skills", "spells", "meta"}),
|
||||
DeployManifest: defaultString(p.Config.TopData.Wiki.DeployManifest, DefaultWikiDeployManifest),
|
||||
DeployEditSummary: defaultString(p.Config.TopData.Wiki.DeployEditSummary, DefaultWikiDeployEditSummary),
|
||||
},
|
||||
}
|
||||
extract := p.Config.Extract
|
||||
extract.Layout = defaultString(extract.Layout, DefaultExtractLayout)
|
||||
extract.HAKDiscovery = defaultString(extract.HAKDiscovery, DefaultExtractHAKDiscovery)
|
||||
if extract.CleanupStale == nil {
|
||||
defaultCleanup := true
|
||||
extract.CleanupStale = &defaultCleanup
|
||||
}
|
||||
|
||||
return EffectiveConfig{
|
||||
effective := EffectiveConfig{
|
||||
ConfigSource: p.ConfigSource,
|
||||
Module: p.Config.Module,
|
||||
Paths: paths,
|
||||
Outputs: outputs,
|
||||
Build: p.Config.Build,
|
||||
Inventory: inventory,
|
||||
Scripts: scripts,
|
||||
Music: EffectiveMusicConfig{
|
||||
@@ -166,19 +225,27 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
||||
MaxStemLength: 16,
|
||||
},
|
||||
TopData: top,
|
||||
Extract: p.Config.Extract,
|
||||
Extract: extract,
|
||||
Autogen: EffectiveAutogenConfig{
|
||||
Producers: slices.Clone(p.Config.Autogen.Producers),
|
||||
Consumers: slices.Clone(p.Config.Autogen.Consumers),
|
||||
Cache: EffectiveAutogenCacheConfig{
|
||||
Root: expandPathTemplate(defaultString(p.Config.Autogen.Cache.Root, DefaultAutogenCacheRoot), paths),
|
||||
MaxAge: DefaultAutogenCacheMaxAge.String(),
|
||||
RefreshEnv: DefaultAutogenRefreshEnv,
|
||||
Root: expandPathTemplate(defaultString(p.Config.Autogen.Cache.Root, DefaultAutogenCacheRoot), paths),
|
||||
MaxAge: defaultString(p.Config.Autogen.Cache.MaxAge, DefaultAutogenCacheMaxAge.String()),
|
||||
RefreshEnv: defaultString(p.Config.Autogen.Cache.RefreshEnv, DefaultAutogenRefreshEnv),
|
||||
PartsManifestRefreshEnv: DefaultPartsManifestRefreshEnv,
|
||||
},
|
||||
ReleaseSource: EffectiveAutogenReleaseSourceConfig{
|
||||
Provider: defaultString(p.Config.Autogen.ReleaseSource.Provider, DefaultAutogenReleaseProvider),
|
||||
ServerURLEnv: defaultString(p.Config.Autogen.ReleaseSource.ServerURLEnv, DefaultAutogenServerURLEnv),
|
||||
RepoEnv: defaultString(p.Config.Autogen.ReleaseSource.RepoEnv, DefaultAutogenRepoEnv),
|
||||
},
|
||||
},
|
||||
HAKs: slices.Clone(p.Config.HAKs),
|
||||
Provenance: provenance,
|
||||
}
|
||||
effective.Overrides = activeOverrides(effective)
|
||||
return effective
|
||||
}
|
||||
|
||||
func (p *Project) EffectiveConfigJSON() ([]byte, error) {
|
||||
@@ -223,32 +290,44 @@ func lookupEffectiveValue(effective EffectiveConfig, key string) (any, bool) {
|
||||
|
||||
func markMissingDefaults(provenance ConfigProvenance) {
|
||||
defaults := map[string]string{
|
||||
"paths.build": "build",
|
||||
"paths.cache": ".cache",
|
||||
"paths.tools": "tools",
|
||||
"outputs.module_archive": DefaultModuleArchiveTemplate,
|
||||
"outputs.hak_manifest": DefaultHAKManifest,
|
||||
"outputs.hak_archive": DefaultHAKArchiveTemplate,
|
||||
"inventory.source_extensions": "NWN source resource extensions",
|
||||
"inventory.asset_extensions": "NWN asset resource extensions",
|
||||
"inventory.source_json_pattern": DefaultSourceJSONPattern,
|
||||
"scripts.source_dir": DefaultScriptsSourceDir,
|
||||
"scripts.cache": DefaultScriptsCache,
|
||||
"music.stage_root": DefaultMusicStageRoot,
|
||||
"music.credits_root": DefaultCreditsRoot,
|
||||
"music.credits_overlay": DefaultCreditsOverlayFile,
|
||||
"music.max_stem_length": "16",
|
||||
"topdata.build": DefaultTopDataBuild,
|
||||
"topdata.compiled_2da_dir": DefaultTopDataCompiled2DADir,
|
||||
"topdata.compiled_tlk": DefaultTopDataCompiledTLK,
|
||||
"topdata.package_hak": DefaultTopDataPackageHAK,
|
||||
"topdata.package_tlk": DefaultTopDataPackageTLK,
|
||||
"topdata.wiki.output_root": DefaultTopDataWikiOutputRoot,
|
||||
"topdata.wiki.pages_dir": DefaultTopDataWikiPagesDir,
|
||||
"topdata.wiki.state_file": DefaultTopDataWikiStateFile,
|
||||
"autogen.cache.root": DefaultAutogenCacheRoot,
|
||||
"autogen.cache.max_age": DefaultAutogenCacheMaxAge.String(),
|
||||
"autogen.cache.refresh_env": DefaultAutogenRefreshEnv,
|
||||
"paths.build": "build",
|
||||
"paths.cache": ".cache",
|
||||
"paths.tools": "tools",
|
||||
"outputs.module_archive": DefaultModuleArchiveTemplate,
|
||||
"outputs.hak_manifest": DefaultHAKManifest,
|
||||
"outputs.hak_archive": DefaultHAKArchiveTemplate,
|
||||
"inventory.source_extensions": "NWN source resource extensions",
|
||||
"inventory.asset_extensions": "NWN asset resource extensions",
|
||||
"inventory.source_json_pattern": DefaultSourceJSONPattern,
|
||||
"scripts.source_dir": DefaultScriptsSourceDir,
|
||||
"scripts.cache": DefaultScriptsCache,
|
||||
"scripts.compiler.env.path": DefaultScriptCompilerEnvPath,
|
||||
"scripts.compiler.search": "repo tools and PATH:nwn_script_comp",
|
||||
"music.stage_root": DefaultMusicStageRoot,
|
||||
"music.credits_root": DefaultCreditsRoot,
|
||||
"music.credits_overlay": DefaultCreditsOverlayFile,
|
||||
"music.max_stem_length": "16",
|
||||
"topdata.build": DefaultTopDataBuild,
|
||||
"topdata.compiled_2da_dir": DefaultTopDataCompiled2DADir,
|
||||
"topdata.compiled_tlk": DefaultTopDataCompiledTLK,
|
||||
"topdata.package_hak": DefaultTopDataPackageHAK,
|
||||
"topdata.package_tlk": DefaultTopDataPackageTLK,
|
||||
"topdata.wiki.output_root": DefaultTopDataWikiOutputRoot,
|
||||
"topdata.wiki.pages_dir": DefaultTopDataWikiPagesDir,
|
||||
"topdata.wiki.state_file": DefaultTopDataWikiStateFile,
|
||||
"topdata.wiki.managed_namespaces": "NWN topdata wiki namespaces",
|
||||
"topdata.wiki.deploy_manifest": DefaultWikiDeployManifest,
|
||||
"topdata.wiki.deploy_edit_summary": DefaultWikiDeployEditSummary,
|
||||
"extract.layout": DefaultExtractLayout,
|
||||
"extract.hak_discovery": DefaultExtractHAKDiscovery,
|
||||
"extract.cleanup_stale": "true",
|
||||
"autogen.cache.root": DefaultAutogenCacheRoot,
|
||||
"autogen.cache.max_age": DefaultAutogenCacheMaxAge.String(),
|
||||
"autogen.cache.refresh_env": DefaultAutogenRefreshEnv,
|
||||
"autogen.cache.parts_manifest_refresh_env": DefaultPartsManifestRefreshEnv,
|
||||
"autogen.release_source.provider": DefaultAutogenReleaseProvider,
|
||||
"autogen.release_source.server_url_env": DefaultAutogenServerURLEnv,
|
||||
"autogen.release_source.repo_env": DefaultAutogenRepoEnv,
|
||||
}
|
||||
for key, detail := range defaults {
|
||||
if _, ok := provenance[key]; !ok {
|
||||
@@ -302,6 +381,75 @@ func expandPathTemplate(template string, paths EffectivePathConfig) string {
|
||||
return filepath.ToSlash(replacer.Replace(template))
|
||||
}
|
||||
|
||||
func expandPathTemplates(templates []string, paths EffectivePathConfig) []string {
|
||||
out := make([]string, 0, len(templates))
|
||||
for _, template := range templates {
|
||||
out = append(out, expandPathTemplate(template, paths))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func defaultScriptCompilerSearch() []string {
|
||||
return []string{
|
||||
"{paths.tools}/script-compiler/nwn_script_comp",
|
||||
"{paths.tools}/script-compiler/nwn_script_comp.exe",
|
||||
"{paths.tools}/nwn_script_comp",
|
||||
"{paths.tools}/nwn_script_comp.exe",
|
||||
"PATH:nwn_script_comp",
|
||||
"PATH:nwn_script_comp.exe",
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Project) ActiveOverrides() []ConfigOverride {
|
||||
return activeOverrides(p.EffectiveConfig())
|
||||
}
|
||||
|
||||
func activeOverrides(effective EffectiveConfig) []ConfigOverride {
|
||||
envs := map[string]string{
|
||||
"build.keep_existing_haks": effectiveBuildKeepExistingEnv,
|
||||
"scripts.compiler.path": effective.Scripts.Compiler.Env.Path,
|
||||
"autogen.cache.refresh": effective.Autogen.Cache.RefreshEnv,
|
||||
"autogen.cache.parts_manifest_refresh": effective.Autogen.Cache.PartsManifestRefreshEnv,
|
||||
"autogen.release_source.server_url": effective.Autogen.ReleaseSource.ServerURLEnv,
|
||||
"autogen.release_source.repo": effective.Autogen.ReleaseSource.RepoEnv,
|
||||
"music.ffmpeg": "SOW_FFMPEG",
|
||||
"music.ffprobe": "SOW_FFPROBE",
|
||||
"wiki.endpoint": "NODEBB_API_ENDPOINT",
|
||||
"wiki.token": "NODEBB_API_TOKEN",
|
||||
"wiki.categories": "NODEBB_WIKI_CATEGORIES",
|
||||
"release.version": "GITHUB_REF_NAME",
|
||||
}
|
||||
for _, key := range effective.Scripts.Compiler.Env.NWNRoot {
|
||||
envs["scripts.compiler.env.nwn_root."+key] = key
|
||||
}
|
||||
for _, key := range effective.Scripts.Compiler.Env.NWNUserDirectory {
|
||||
envs["scripts.compiler.env.nwn_user_directory."+key] = key
|
||||
}
|
||||
keys := make([]string, 0, len(envs))
|
||||
for key := range envs {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
overrides := make([]ConfigOverride, 0)
|
||||
for _, key := range keys {
|
||||
env := envs[key]
|
||||
if value := strings.TrimSpace(os.Getenv(env)); value != "" {
|
||||
if isSensitiveOverrideKey(key) || isSensitiveOverrideKey(env) {
|
||||
value = "<set>"
|
||||
}
|
||||
overrides = append(overrides, ConfigOverride{Key: key, Value: value, Source: "env:" + env})
|
||||
}
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
const effectiveBuildKeepExistingEnv = "SOW_BUILD_HAKS_KEEP_EXISTING"
|
||||
|
||||
func isSensitiveOverrideKey(key string) bool {
|
||||
lower := strings.ToLower(key)
|
||||
return strings.Contains(lower, "token") || strings.Contains(lower, "password")
|
||||
}
|
||||
|
||||
func (o EffectiveOutputConfig) ModuleArchiveName(module ModuleConfig) string {
|
||||
return strings.NewReplacer("{module.resref}", strings.TrimSpace(module.ResRef)).Replace(o.ModuleArchive)
|
||||
}
|
||||
|
||||
+159
-29
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -51,6 +52,7 @@ type Config struct {
|
||||
Module ModuleConfig `json:"module" yaml:"module"`
|
||||
Paths PathConfig `json:"paths" yaml:"paths"`
|
||||
Outputs OutputConfig `json:"outputs" yaml:"outputs"`
|
||||
Build BuildConfig `json:"build" yaml:"build"`
|
||||
HAKs []HAKConfig `json:"haks" yaml:"haks"`
|
||||
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
||||
Scripts ScriptsConfig `json:"scripts" yaml:"scripts"`
|
||||
@@ -81,6 +83,10 @@ type OutputConfig struct {
|
||||
HAKArchive string `json:"hak_archive" yaml:"hak_archive"`
|
||||
}
|
||||
|
||||
type BuildConfig struct {
|
||||
KeepExistingHAKs bool `json:"keep_existing_haks,omitempty" yaml:"keep_existing_haks,omitempty"`
|
||||
}
|
||||
|
||||
type InventoryConfig struct {
|
||||
SourceExtensions []string `json:"source_extensions" yaml:"source_extensions"`
|
||||
AssetExtensions []string `json:"asset_extensions" yaml:"asset_extensions"`
|
||||
@@ -88,8 +94,21 @@ type InventoryConfig struct {
|
||||
}
|
||||
|
||||
type ScriptsConfig struct {
|
||||
SourceDir string `json:"source_dir" yaml:"source_dir"`
|
||||
Cache string `json:"cache" yaml:"cache"`
|
||||
SourceDir string `json:"source_dir" yaml:"source_dir"`
|
||||
Cache string `json:"cache" yaml:"cache"`
|
||||
Compiler ScriptCompilerConfig `json:"compiler" yaml:"compiler"`
|
||||
}
|
||||
|
||||
type ScriptCompilerConfig struct {
|
||||
Path string `json:"path" yaml:"path"`
|
||||
Search []string `json:"search" yaml:"search"`
|
||||
Env ScriptCompilerEnvConfig `json:"env" yaml:"env"`
|
||||
}
|
||||
|
||||
type ScriptCompilerEnvConfig struct {
|
||||
Path string `json:"path" yaml:"path"`
|
||||
NWNRoot []string `json:"nwn_root" yaml:"nwn_root"`
|
||||
NWNUserDirectory []string `json:"nwn_user_directory" yaml:"nwn_user_directory"`
|
||||
}
|
||||
|
||||
type HAKConfig struct {
|
||||
@@ -118,24 +137,39 @@ type TopDataConfig struct {
|
||||
}
|
||||
|
||||
type TopDataWikiConfig struct {
|
||||
OutputRoot string `json:"output_root" yaml:"output_root"`
|
||||
PagesDir string `json:"pages_dir" yaml:"pages_dir"`
|
||||
StateFile string `json:"state_file" yaml:"state_file"`
|
||||
OutputRoot string `json:"output_root" yaml:"output_root"`
|
||||
PagesDir string `json:"pages_dir" yaml:"pages_dir"`
|
||||
StateFile string `json:"state_file" yaml:"state_file"`
|
||||
ManagedNamespaces []string `json:"managed_namespaces" yaml:"managed_namespaces"`
|
||||
DeployManifest string `json:"deploy_manifest" yaml:"deploy_manifest"`
|
||||
DeployEditSummary string `json:"deploy_edit_summary" yaml:"deploy_edit_summary"`
|
||||
}
|
||||
|
||||
type ExtractConfig struct {
|
||||
IgnoreExtensions []string `json:"ignore_extensions" yaml:"ignore_extensions"`
|
||||
Layout string `json:"layout" yaml:"layout"`
|
||||
HAKDiscovery string `json:"hak_discovery" yaml:"hak_discovery"`
|
||||
CleanupStale *bool `json:"cleanup_stale,omitempty" yaml:"cleanup_stale,omitempty"`
|
||||
DeleteModuleArchiveAfterSuccess bool `json:"delete_module_archive_after_success,omitempty" yaml:"delete_module_archive_after_success,omitempty"`
|
||||
}
|
||||
|
||||
type AutogenConfig struct {
|
||||
Producers []AutogenProducerConfig `json:"producers" yaml:"producers"`
|
||||
Consumers []AutogenConsumerConfig `json:"consumers" yaml:"consumers"`
|
||||
Cache AutogenCacheConfig `json:"cache" yaml:"cache"`
|
||||
Producers []AutogenProducerConfig `json:"producers" yaml:"producers"`
|
||||
Consumers []AutogenConsumerConfig `json:"consumers" yaml:"consumers"`
|
||||
Cache AutogenCacheConfig `json:"cache" yaml:"cache"`
|
||||
ReleaseSource AutogenReleaseSourceConfig `json:"release_source" yaml:"release_source"`
|
||||
}
|
||||
|
||||
type AutogenCacheConfig struct {
|
||||
Root string `json:"root" yaml:"root"`
|
||||
Root string `json:"root" yaml:"root"`
|
||||
MaxAge string `json:"max_age" yaml:"max_age"`
|
||||
RefreshEnv string `json:"refresh_env" yaml:"refresh_env"`
|
||||
}
|
||||
|
||||
type AutogenReleaseSourceConfig struct {
|
||||
Provider string `json:"provider" yaml:"provider"`
|
||||
ServerURLEnv string `json:"server_url_env" yaml:"server_url_env"`
|
||||
RepoEnv string `json:"repo_env" yaml:"repo_env"`
|
||||
}
|
||||
|
||||
type AutogenProducerConfig struct {
|
||||
@@ -329,26 +363,37 @@ func (p *Project) ValidateLayout() error {
|
||||
failures = append(failures, errors.New("at least one of paths.source, paths.assets, or topdata.source is required"))
|
||||
}
|
||||
for field, value := range map[string]string{
|
||||
"paths.source": p.Config.Paths.Source,
|
||||
"paths.assets": p.Config.Paths.Assets,
|
||||
"paths.build": p.Config.Paths.Build,
|
||||
"paths.cache": p.Config.Paths.Cache,
|
||||
"paths.tools": p.Config.Paths.Tools,
|
||||
"outputs.module_archive": p.Config.Outputs.ModuleArchive,
|
||||
"outputs.hak_manifest": p.Config.Outputs.HAKManifest,
|
||||
"outputs.hak_archive": p.Config.Outputs.HAKArchive,
|
||||
"scripts.source_dir": p.Config.Scripts.SourceDir,
|
||||
"scripts.cache": p.Config.Scripts.Cache,
|
||||
"topdata.source": p.Config.TopData.Source,
|
||||
"topdata.build": p.Config.TopData.Build,
|
||||
"topdata.reference_builder": p.Config.TopData.ReferenceBuilder,
|
||||
"topdata.assets": p.Config.TopData.Assets,
|
||||
"topdata.compiled_2da_dir": p.Config.TopData.Compiled2DADir,
|
||||
"topdata.compiled_tlk": p.Config.TopData.CompiledTLK,
|
||||
"topdata.wiki.output_root": p.Config.TopData.Wiki.OutputRoot,
|
||||
"topdata.wiki.pages_dir": p.Config.TopData.Wiki.PagesDir,
|
||||
"topdata.wiki.state_file": p.Config.TopData.Wiki.StateFile,
|
||||
"autogen.cache.root": p.Config.Autogen.Cache.Root,
|
||||
"paths.source": p.Config.Paths.Source,
|
||||
"paths.assets": p.Config.Paths.Assets,
|
||||
"paths.build": p.Config.Paths.Build,
|
||||
"paths.cache": p.Config.Paths.Cache,
|
||||
"paths.tools": p.Config.Paths.Tools,
|
||||
"outputs.module_archive": p.Config.Outputs.ModuleArchive,
|
||||
"outputs.hak_manifest": p.Config.Outputs.HAKManifest,
|
||||
"outputs.hak_archive": p.Config.Outputs.HAKArchive,
|
||||
"scripts.source_dir": p.Config.Scripts.SourceDir,
|
||||
"scripts.cache": p.Config.Scripts.Cache,
|
||||
"scripts.compiler.path": p.Config.Scripts.Compiler.Path,
|
||||
"scripts.compiler.env.path": p.Config.Scripts.Compiler.Env.Path,
|
||||
"topdata.source": p.Config.TopData.Source,
|
||||
"topdata.build": p.Config.TopData.Build,
|
||||
"topdata.reference_builder": p.Config.TopData.ReferenceBuilder,
|
||||
"topdata.assets": p.Config.TopData.Assets,
|
||||
"topdata.compiled_2da_dir": p.Config.TopData.Compiled2DADir,
|
||||
"topdata.compiled_tlk": p.Config.TopData.CompiledTLK,
|
||||
"topdata.wiki.output_root": p.Config.TopData.Wiki.OutputRoot,
|
||||
"topdata.wiki.pages_dir": p.Config.TopData.Wiki.PagesDir,
|
||||
"topdata.wiki.state_file": p.Config.TopData.Wiki.StateFile,
|
||||
"topdata.wiki.deploy_manifest": p.Config.TopData.Wiki.DeployManifest,
|
||||
"topdata.wiki.deploy_edit_summary": p.Config.TopData.Wiki.DeployEditSummary,
|
||||
"extract.layout": p.Config.Extract.Layout,
|
||||
"extract.hak_discovery": p.Config.Extract.HAKDiscovery,
|
||||
"autogen.cache.root": p.Config.Autogen.Cache.Root,
|
||||
"autogen.cache.max_age": p.Config.Autogen.Cache.MaxAge,
|
||||
"autogen.cache.refresh_env": p.Config.Autogen.Cache.RefreshEnv,
|
||||
"autogen.release_source.provider": p.Config.Autogen.ReleaseSource.Provider,
|
||||
"autogen.release_source.server_url_env": p.Config.Autogen.ReleaseSource.ServerURLEnv,
|
||||
"autogen.release_source.repo_env": p.Config.Autogen.ReleaseSource.RepoEnv,
|
||||
} {
|
||||
if strings.Contains(value, "\x00") {
|
||||
failures = append(failures, fmt.Errorf("%s must not contain NUL bytes", field))
|
||||
@@ -360,11 +405,13 @@ func (p *Project) ValidateLayout() error {
|
||||
failures = append(failures, validateRelativePath("outputs.hak_manifest", effective.Outputs.HAKManifest)...)
|
||||
failures = append(failures, validateRelativePath("outputs.hak_archive", effective.Outputs.HAKArchive)...)
|
||||
failures = append(failures, validateRelativePath("scripts.cache", effective.Scripts.Cache)...)
|
||||
failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...)
|
||||
failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...)
|
||||
failures = append(failures, validateRelativePath("topdata.compiled_tlk", effective.TopData.CompiledTLK)...)
|
||||
failures = append(failures, validateRelativePath("topdata.wiki.output_root", effective.TopData.Wiki.OutputRoot)...)
|
||||
failures = append(failures, validateRelativePath("topdata.wiki.pages_dir", effective.TopData.Wiki.PagesDir)...)
|
||||
failures = append(failures, validateRelativePath("topdata.wiki.state_file", effective.TopData.Wiki.StateFile)...)
|
||||
failures = append(failures, validateRelativePath("topdata.wiki.deploy_manifest", effective.TopData.Wiki.DeployManifest)...)
|
||||
failures = append(failures, validateRelativePath("autogen.cache.root", effective.Autogen.Cache.Root)...)
|
||||
if err := validateOutputFileName("outputs.module_archive", filepath.Base(effective.Outputs.ModuleArchive), ".mod"); err != nil {
|
||||
failures = append(failures, err)
|
||||
@@ -375,6 +422,25 @@ func (p *Project) ValidateLayout() error {
|
||||
if err := validateOutputFileName("outputs.hak_archive", filepath.Base(effective.Outputs.HAKArchive), ".hak"); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
if err := validateOutputFileName("topdata.wiki.deploy_manifest", filepath.Base(effective.TopData.Wiki.DeployManifest), ".json"); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
switch effective.Extract.Layout {
|
||||
case "nwn_canonical_json":
|
||||
default:
|
||||
failures = append(failures, fmt.Errorf("extract.layout %q is not supported", effective.Extract.Layout))
|
||||
}
|
||||
switch effective.Extract.HAKDiscovery {
|
||||
case "build_glob", "configured_haks":
|
||||
default:
|
||||
failures = append(failures, fmt.Errorf("extract.hak_discovery %q is not supported", effective.Extract.HAKDiscovery))
|
||||
}
|
||||
if _, err := time.ParseDuration(effective.Autogen.Cache.MaxAge); err != nil {
|
||||
failures = append(failures, fmt.Errorf("autogen.cache.max_age must be a duration: %w", err))
|
||||
}
|
||||
if effective.Autogen.ReleaseSource.Provider != "gitea" {
|
||||
failures = append(failures, fmt.Errorf("autogen.release_source.provider %q is not supported", effective.Autogen.ReleaseSource.Provider))
|
||||
}
|
||||
if p.HasTopData() {
|
||||
if err := validateOutputFileName("topdata.package_hak", p.TopDataPackageHAKName(), ".hak"); err != nil {
|
||||
failures = append(failures, err)
|
||||
@@ -685,6 +751,46 @@ func (p *Project) ScriptCacheDir() string {
|
||||
return p.rootPath(p.EffectiveConfig().Scripts.Cache)
|
||||
}
|
||||
|
||||
func (p *Project) ScriptCompilerPath() string {
|
||||
path := strings.TrimSpace(p.EffectiveConfig().Scripts.Compiler.Path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(p.Root, filepath.FromSlash(path))
|
||||
}
|
||||
|
||||
func (p *Project) ScriptCompilerSearchPaths() []string {
|
||||
search := p.EffectiveConfig().Scripts.Compiler.Search
|
||||
out := make([]string, 0, len(search))
|
||||
for _, candidate := range search {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(candidate, "PATH:") || filepath.IsAbs(candidate) {
|
||||
out = append(out, candidate)
|
||||
continue
|
||||
}
|
||||
out = append(out, filepath.Join(p.Root, filepath.FromSlash(candidate)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *Project) ScriptCompilerPathEnv() string {
|
||||
return p.EffectiveConfig().Scripts.Compiler.Env.Path
|
||||
}
|
||||
|
||||
func (p *Project) ScriptCompilerNWNRootEnvKeys() []string {
|
||||
return slices.Clone(p.EffectiveConfig().Scripts.Compiler.Env.NWNRoot)
|
||||
}
|
||||
|
||||
func (p *Project) ScriptCompilerNWNUserDirectoryEnvKeys() []string {
|
||||
return slices.Clone(p.EffectiveConfig().Scripts.Compiler.Env.NWNUserDirectory)
|
||||
}
|
||||
|
||||
func (p *Project) MusicStageDir() string {
|
||||
return p.rootPath(p.EffectiveConfig().Music.StageRoot)
|
||||
}
|
||||
@@ -701,6 +807,30 @@ func (p *Project) AutogenCachePath(name string) string {
|
||||
return filepath.Join(p.AutogenCacheDir(), filepath.FromSlash(strings.TrimSpace(name)))
|
||||
}
|
||||
|
||||
func (p *Project) AutogenCacheMaxAge() time.Duration {
|
||||
duration, err := time.ParseDuration(p.EffectiveConfig().Autogen.Cache.MaxAge)
|
||||
if err != nil {
|
||||
return DefaultAutogenCacheMaxAge
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
func (p *Project) AutogenRefreshEnv() string {
|
||||
return p.EffectiveConfig().Autogen.Cache.RefreshEnv
|
||||
}
|
||||
|
||||
func (p *Project) PartsManifestRefreshEnv() string {
|
||||
return p.EffectiveConfig().Autogen.Cache.PartsManifestRefreshEnv
|
||||
}
|
||||
|
||||
func (p *Project) AssetsServerURLEnv() string {
|
||||
return p.EffectiveConfig().Autogen.ReleaseSource.ServerURLEnv
|
||||
}
|
||||
|
||||
func (p *Project) AssetsRepoEnv() string {
|
||||
return p.EffectiveConfig().Autogen.ReleaseSource.RepoEnv
|
||||
}
|
||||
|
||||
func (p *Project) rootPath(path string) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return p.Root
|
||||
|
||||
@@ -136,6 +136,116 @@ autogen:
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigHonorsConfiguredCompilerExtractWikiAndAutogenPolicy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
||||
module:
|
||||
name: Test Module
|
||||
resref: testmod
|
||||
paths:
|
||||
source: src
|
||||
tools: custom-tools
|
||||
scripts:
|
||||
source_dir: module-scripts
|
||||
compiler:
|
||||
path: bin/compiler
|
||||
search:
|
||||
- "{paths.tools}/compiler"
|
||||
env:
|
||||
path: TEST_COMPILER
|
||||
nwn_root:
|
||||
- TEST_NWN_ROOT
|
||||
nwn_user_directory:
|
||||
- TEST_NWN_USER
|
||||
extract:
|
||||
layout: nwn_canonical_json
|
||||
hak_discovery: configured_haks
|
||||
cleanup_stale: false
|
||||
topdata:
|
||||
wiki:
|
||||
managed_namespaces:
|
||||
- skills
|
||||
deploy_manifest: wiki-manifest.json
|
||||
deploy_edit_summary: Custom summary
|
||||
autogen:
|
||||
cache:
|
||||
root: "{paths.cache}/released"
|
||||
max_age: 30m
|
||||
refresh_env: TEST_AUTOGEN_REFRESH
|
||||
release_source:
|
||||
provider: gitea
|
||||
server_url_env: TEST_ASSETS_SERVER
|
||||
repo_env: TEST_ASSETS_REPO
|
||||
`)
|
||||
|
||||
proj, err := Load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
effective := proj.EffectiveConfig()
|
||||
if got, want := proj.ScriptSourceDir(), filepath.Join(root, "src", "module-scripts"); got != want {
|
||||
t.Fatalf("expected script source dir %s, got %s", want, got)
|
||||
}
|
||||
if got, want := proj.ScriptCompilerPath(), filepath.Join(root, "bin", "compiler"); got != want {
|
||||
t.Fatalf("expected compiler path %s, got %s", want, got)
|
||||
}
|
||||
if got, want := effective.Scripts.Compiler.Search[0], "custom-tools/compiler"; got != want {
|
||||
t.Fatalf("expected compiler search %q, got %q", want, got)
|
||||
}
|
||||
if got, want := effective.Scripts.Compiler.Env.Path, "TEST_COMPILER"; got != want {
|
||||
t.Fatalf("expected compiler env %q, got %q", want, got)
|
||||
}
|
||||
if got, want := effective.Extract.HAKDiscovery, "configured_haks"; got != want {
|
||||
t.Fatalf("expected extract hak discovery %q, got %q", want, got)
|
||||
}
|
||||
if effective.Extract.CleanupStale == nil || *effective.Extract.CleanupStale {
|
||||
t.Fatalf("expected cleanup_stale false, got %#v", effective.Extract.CleanupStale)
|
||||
}
|
||||
if got, want := effective.TopData.Wiki.DeployManifest, "wiki-manifest.json"; got != want {
|
||||
t.Fatalf("expected wiki deploy manifest %q, got %q", want, got)
|
||||
}
|
||||
if got, want := effective.Autogen.Cache.MaxAge, "30m"; got != want {
|
||||
t.Fatalf("expected autogen cache max age %q, got %q", want, got)
|
||||
}
|
||||
if got, want := proj.AutogenRefreshEnv(), "TEST_AUTOGEN_REFRESH"; got != want {
|
||||
t.Fatalf("expected autogen refresh env %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveOverridesAreVisibleAndSensitiveValuesAreMasked(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
||||
module:
|
||||
name: Test Module
|
||||
resref: testmod
|
||||
paths:
|
||||
source: src
|
||||
`)
|
||||
t.Setenv("SOW_BUILD_HAKS_KEEP_EXISTING", "1")
|
||||
t.Setenv("NODEBB_API_TOKEN", "secret-token")
|
||||
|
||||
proj, err := Load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
overrides := proj.ActiveOverrides()
|
||||
var sawKeepExisting, sawMaskedToken bool
|
||||
for _, override := range overrides {
|
||||
if override.Key == "build.keep_existing_haks" && override.Value == "1" {
|
||||
sawKeepExisting = true
|
||||
}
|
||||
if override.Key == "wiki.token" && override.Value == "<set>" {
|
||||
sawMaskedToken = true
|
||||
}
|
||||
}
|
||||
if !sawKeepExisting {
|
||||
t.Fatalf("expected build keep existing override in %#v", overrides)
|
||||
}
|
||||
if !sawMaskedToken {
|
||||
t.Fatalf("expected masked wiki token override in %#v", overrides)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigJSONIsDeterministic(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
||||
|
||||
Reference in New Issue
Block a user