Unify application configuration loading
This commit is contained in:
@@ -36,6 +36,22 @@ This ordering preserves monolithic configuration behavior. Moving a field to
|
||||
an imported fragment changes its source ownership, not its path base, default,
|
||||
or schema semantics.
|
||||
|
||||
## Loaded Context Resolution
|
||||
|
||||
`LoadedPipelineCampaign` carries one already composed pipeline and its selected
|
||||
campaign into session resolution. `LoadSessionWithPipelineCampaignOptions`
|
||||
loads a local session against that context, while
|
||||
`ResolveLoadedPipelineCampaign` also accepts an already loaded remote session
|
||||
or no session while a caller retrieves one. Compatibility loaders route through
|
||||
these functions after their initial pipeline and campaign reads.
|
||||
|
||||
Application commands own pipeline and campaign discovery, campaign-file versus
|
||||
registry selection, and the corresponding mutual-exclusion rules. Once they
|
||||
have a `LoadedPipelineCampaign`, local session discovery and remote-session
|
||||
download retain that exact pipeline object and its private provenance. Removing
|
||||
a temporary downloaded session file therefore cannot invalidate the resolved
|
||||
pipeline or campaign context.
|
||||
|
||||
## Diagnostics And Runtime Metadata
|
||||
|
||||
Syntax, duplicate-key, composition, conflict, and schema failures include the
|
||||
@@ -66,5 +82,7 @@ the public schema. `pipeline_composition_test.go` exercises explicit imports,
|
||||
confinement, conflicts, strict decoding, metadata, and root-relative path
|
||||
behavior through `LoadPipeline`. `pipeline_profiles_test.go` covers selection,
|
||||
all-overlay validation, overlay behavior, provenance, option propagation, and
|
||||
effective-digest stability. Other configuration tests continue to protect
|
||||
defaults and validation after assembly.
|
||||
effective-digest stability. Application configuration-loader tests protect the
|
||||
single-read boundary by changing the pipeline file after its initial load and
|
||||
confirming local session resolution retains the original pipeline. Other
|
||||
configuration tests continue to protect defaults and validation after assembly.
|
||||
|
||||
@@ -47,9 +47,11 @@ Pipeline execution and `session plan` share the same inclusive contiguous-range
|
||||
model. Planning clones session state and applies selected-stage transitions and
|
||||
resume validation in memory; it does not create invocation state or initialize
|
||||
stage-execution adapters. Command configuration loading can still retrieve a
|
||||
missing session file through configured remote storage. Analyze planning
|
||||
additionally exposes the artifact closure's targets, prerequisite rebuilds,
|
||||
execution order, and current reuse.
|
||||
missing session file through configured remote storage. It retains the initially
|
||||
composed pipeline and selected campaign while resolving either a local or
|
||||
downloaded remote session, so one invocation cannot mix pipeline revisions.
|
||||
Analyze planning additionally exposes the artifact closure's targets,
|
||||
prerequisite rebuilds, execution order, and current reuse.
|
||||
|
||||
## Pipeline Stage Set
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ configuration provenance within `internal/config`.
|
||||
|
||||
## Stage 8 — One Production Configuration Loading Path
|
||||
|
||||
**Status: Pending**
|
||||
**Status: Completed**
|
||||
|
||||
### Goal
|
||||
|
||||
|
||||
@@ -88,11 +88,7 @@ func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writ
|
||||
strings.TrimSpace(flags.previousSessionID) != "" {
|
||||
return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
|
||||
}
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(flags.pipelinePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clean: %w", err)
|
||||
}
|
||||
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
|
||||
_, pipelineCfg, err := loadPipelineConfig(flags.pipelinePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clean: %w", err)
|
||||
}
|
||||
|
||||
@@ -14,14 +14,10 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
)
|
||||
|
||||
type pipelineCampaignConfig struct {
|
||||
PipelinePath string
|
||||
CampaignPath string
|
||||
Pipeline *config.PipelineConfig
|
||||
Campaign *config.CampaignConfig
|
||||
}
|
||||
type pipelineCampaignConfig = config.LoadedPipelineCampaign
|
||||
|
||||
var downloadObjectToTempFn = storage.DownloadObjectToTemp
|
||||
var loadPipelineConfigFn = config.LoadPipeline
|
||||
|
||||
type commandConfig struct {
|
||||
Config *config.Config
|
||||
@@ -58,7 +54,7 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
||||
}
|
||||
|
||||
if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" {
|
||||
cfg, err := config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, explicitSession, sessionOpts)
|
||||
cfg, err := config.LoadSessionWithPipelineCampaignOptions(*base, explicitSession, sessionOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -70,7 +66,7 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
||||
return nil, err
|
||||
}
|
||||
if discoveredSession.Path != "" {
|
||||
cfg, err := config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, discoveredSession.Path, sessionOpts)
|
||||
cfg, err := config.LoadSessionWithPipelineCampaignOptions(*base, discoveredSession.Path, sessionOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -88,11 +84,9 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
||||
}
|
||||
sessionPrefix := artifacts.S3SessionPrefix(rootPrefix, config.CampaignID(base.Campaign), sessionID)
|
||||
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
|
||||
partialCfg := &config.Config{
|
||||
Pipeline: base.Pipeline,
|
||||
Campaign: base.Campaign,
|
||||
PipelinePath: base.PipelinePath,
|
||||
CampaignPath: base.CampaignPath,
|
||||
partialCfg, err := config.ResolveLoadedPipelineCampaign(*base, "", nil, config.SessionSource{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := newCommandObjectStore(ctx, partialCfg, nil)
|
||||
if err != nil {
|
||||
@@ -122,11 +116,8 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg, err := config.Resolve(
|
||||
base.PipelinePath,
|
||||
base.Pipeline,
|
||||
base.CampaignPath,
|
||||
base.Campaign,
|
||||
cfg, err := config.ResolveLoadedPipelineCampaign(
|
||||
*base,
|
||||
sessionTempPath,
|
||||
sessionCfg,
|
||||
config.SessionSource{
|
||||
@@ -147,11 +138,7 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
||||
}
|
||||
|
||||
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) {
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
|
||||
loadedPipelinePath, pipelineCfg, err := loadPipelineConfig(pipelineFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -169,13 +156,25 @@ func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag str
|
||||
}
|
||||
}
|
||||
return &pipelineCampaignConfig{
|
||||
PipelinePath: resolvedPipelinePath,
|
||||
PipelinePath: loadedPipelinePath,
|
||||
CampaignPath: resolvedCampaignPath,
|
||||
Pipeline: pipelineCfg,
|
||||
Campaign: campaignCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadPipelineConfig(pipelineFlag string) (string, *config.PipelineConfig, error) {
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
pipelineCfg, err := loadPipelineConfigFn(resolvedPipelinePath)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return resolvedPipelinePath, pipelineCfg, nil
|
||||
}
|
||||
|
||||
func findRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, sessionPrefix, remoteKey string) (storage.ObjectInfo, error) {
|
||||
objects, err := store.List(ctx, sessionPrefix)
|
||||
if err != nil {
|
||||
|
||||
50
internal/app/config_loader_test.go
Normal file
50
internal/app/config_loader_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
func TestLoadCommandConfigRetainsInitiallyLoadedPipeline(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
originalLoader := loadPipelineConfigFn
|
||||
loadCalls := 0
|
||||
loadPipelineConfigFn = func(path string) (*config.PipelineConfig, error) {
|
||||
loaded, err := originalLoader(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loadCalls++
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changedRoot := workspaceRoot + "-changed"
|
||||
updated := strings.ReplaceAll(string(data), workspaceRoot, changedRoot)
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
t.Cleanup(func() { loadPipelineConfigFn = originalLoader })
|
||||
|
||||
loaded, err := loadCommandConfig(context.Background(), pipelinePath, "", campaignPath, sessionPath, config.SessionLoadOptions{SessionID: "2026-05-03"})
|
||||
if err != nil {
|
||||
t.Fatalf("loadCommandConfig() error = %v", err)
|
||||
}
|
||||
defer func() { _ = loaded.Close() }()
|
||||
|
||||
if loadCalls != 1 {
|
||||
t.Fatalf("pipeline load calls = %d, want 1", loadCalls)
|
||||
}
|
||||
if got := loaded.Config.Pipeline.Workspace.Root; got != workspaceRoot {
|
||||
t.Fatalf("resolved workspace root = %q, want originally loaded %q", got, workspaceRoot)
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("session init: %w", err)
|
||||
}
|
||||
cfg, err := config.Resolve(base.PipelinePath, base.Pipeline, base.CampaignPath, base.Campaign, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label})
|
||||
cfg, err := config.ResolveLoadedPipelineCampaign(*base, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label})
|
||||
if err != nil {
|
||||
return fmt.Errorf("session init: %w", err)
|
||||
}
|
||||
|
||||
@@ -166,6 +166,9 @@ inputs:
|
||||
if err := loaded.Close(); err != nil {
|
||||
t.Fatalf("second Close() error = %v", err)
|
||||
}
|
||||
if loaded.Config == nil || loaded.Config.Pipeline == nil || loaded.Config.Campaign == nil || loaded.Config.Session == nil {
|
||||
t.Fatal("closing remote session cleanup discarded retained configuration")
|
||||
}
|
||||
if _, err := os.Stat(downloadedPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("downloaded remote session path still exists or could not be inspected: %q, err=%v", downloadedPath, err)
|
||||
}
|
||||
|
||||
@@ -167,42 +167,81 @@ func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sess
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return LoadSessionWithPipelineCampaignOptions(LoadedPipelineCampaign{
|
||||
PipelinePath: pipelinePath,
|
||||
Pipeline: pipelineCfg,
|
||||
CampaignPath: campaignPath,
|
||||
Campaign: campaignCfg,
|
||||
}, sessionPath, sessionOpts)
|
||||
}
|
||||
|
||||
// LoadedPipelineCampaign retains one already loaded pipeline and campaign for
|
||||
// subsequent local or remote session resolution. It prevents a command from
|
||||
// reloading the root pipeline after campaign selection.
|
||||
type LoadedPipelineCampaign struct {
|
||||
PipelinePath string
|
||||
Pipeline *PipelineConfig
|
||||
CampaignPath string
|
||||
Campaign *CampaignConfig
|
||||
}
|
||||
|
||||
// LoadSessionWithPipelineCampaignOptions loads one local session and combines
|
||||
// it with an already loaded pipeline and campaign.
|
||||
func LoadSessionWithPipelineCampaignOptions(loaded LoadedPipelineCampaign, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
|
||||
sessionCfg, err := LoadSessionWithOptions(sessionPath, sessionOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Resolve(pipelinePath, pipelineCfg, campaignPath, campaignCfg, sessionPath, sessionCfg, SessionSource{
|
||||
return ResolveLoadedPipelineCampaign(loaded, sessionPath, sessionCfg, SessionSource{
|
||||
Source: "session_config",
|
||||
LocalPath: sessionPath,
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve builds final stage-facing configuration from already loaded
|
||||
// pipeline, campaign, and session documents.
|
||||
func Resolve(pipelinePath string, pipelineCfg *PipelineConfig, campaignPath string, campaignCfg *CampaignConfig, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) {
|
||||
stableInputs, err := mergeCampaignSession(campaignCfg, sessionCfg, campaignPath, sessionPath)
|
||||
// ResolveLoadedPipelineCampaign combines an already loaded pipeline and
|
||||
// campaign with optional already loaded session data. A nil session preserves
|
||||
// the resolved pipeline/campaign context for callers that need to locate or
|
||||
// retrieve a session without rereading the root pipeline.
|
||||
func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) {
|
||||
cfg := &Config{
|
||||
Pipeline: loaded.Pipeline,
|
||||
Campaign: loaded.Campaign,
|
||||
Session: sessionCfg,
|
||||
PipelinePath: loaded.PipelinePath,
|
||||
CampaignPath: loaded.CampaignPath,
|
||||
SessionPath: sessionPath,
|
||||
SessionSource: sessionSource,
|
||||
}
|
||||
if sessionCfg == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
stableInputs, err := mergeCampaignSession(loaded.Campaign, sessionCfg, loaded.CampaignPath, sessionPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(sessionSource.Source) == "" {
|
||||
sessionSource.Source = "session_config"
|
||||
if strings.TrimSpace(cfg.SessionSource.Source) == "" {
|
||||
cfg.SessionSource.Source = "session_config"
|
||||
}
|
||||
if strings.TrimSpace(sessionSource.LocalPath) == "" {
|
||||
sessionSource.LocalPath = sessionPath
|
||||
if strings.TrimSpace(cfg.SessionSource.LocalPath) == "" {
|
||||
cfg.SessionSource.LocalPath = sessionPath
|
||||
}
|
||||
cfg.StableInputs = stableInputs
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Pipeline: pipelineCfg,
|
||||
Campaign: campaignCfg,
|
||||
Session: sessionCfg,
|
||||
// Resolve builds final stage-facing configuration from already loaded
|
||||
// pipeline, campaign, and session documents.
|
||||
func Resolve(pipelinePath string, pipelineCfg *PipelineConfig, campaignPath string, campaignCfg *CampaignConfig, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) {
|
||||
if sessionCfg == nil {
|
||||
return nil, fmt.Errorf("session config is required")
|
||||
}
|
||||
return ResolveLoadedPipelineCampaign(LoadedPipelineCampaign{
|
||||
PipelinePath: pipelinePath,
|
||||
Pipeline: pipelineCfg,
|
||||
CampaignPath: campaignPath,
|
||||
SessionPath: sessionPath,
|
||||
StableInputs: stableInputs,
|
||||
SessionSource: sessionSource,
|
||||
}, nil
|
||||
Campaign: campaignCfg,
|
||||
}, sessionPath, sessionCfg, sessionSource)
|
||||
}
|
||||
|
||||
func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) {
|
||||
|
||||
Reference in New Issue
Block a user