Unify application configuration loading

This commit is contained in:
2026-08-30 13:32:45 +00:00
parent f302488075
commit f86b17045d
9 changed files with 163 additions and 56 deletions

View File

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

View File

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

View File

@@ -497,7 +497,7 @@ configuration provenance within `internal/config`.
## Stage 8 — One Production Configuration Loading Path
**Status: Pending**
**Status: Completed**
### Goal

View File

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

View File

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

View 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)
}
}

View File

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

View File

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

View File

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