Add configuration source reporting
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
@@ -21,12 +22,15 @@ type inspectionFlags struct {
|
||||
type inspectionConfig struct {
|
||||
PipelinePath string
|
||||
Pipeline *config.PipelineConfig
|
||||
CampaignPath string
|
||||
Campaign *config.CampaignConfig
|
||||
Party config.ResolvedParty
|
||||
}
|
||||
|
||||
// Config dispatches read-only pipeline configuration commands.
|
||||
func Config(ctx context.Context, args []string, out io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("config: expected subcommand: validate|show")
|
||||
return fmt.Errorf("config: expected subcommand: validate|show|sources")
|
||||
}
|
||||
if args[0] == "--help" || args[0] == "-h" {
|
||||
writeConfigCommandUsage(out)
|
||||
@@ -37,6 +41,8 @@ func Config(ctx context.Context, args []string, out io.Writer) error {
|
||||
return ConfigValidate(ctx, args[1:], out)
|
||||
case "show":
|
||||
return ConfigShow(ctx, args[1:], out)
|
||||
case "sources":
|
||||
return ConfigSources(ctx, args[1:], out)
|
||||
default:
|
||||
return fmt.Errorf("config: unknown subcommand %q", args[0])
|
||||
}
|
||||
@@ -83,6 +89,37 @@ func ConfigShow(_ context.Context, args []string, out io.Writer) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ConfigSources validates and reports deterministic effective configuration
|
||||
// ownership without loading session state or runtime collaborators.
|
||||
func ConfigSources(_ context.Context, args []string, out io.Writer) error {
|
||||
flags, err := parseInspectionFlags("config sources", args, out)
|
||||
if err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
resolved, err := resolveInspectionConfig(flags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config sources: %w", err)
|
||||
}
|
||||
records, err := config.EffectivePipelineSources(resolved.Pipeline, resolved.Party)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config sources: %w", err)
|
||||
}
|
||||
records = append(records, config.EffectiveCampaignSources(resolved.CampaignPath, resolved.Campaign, resolved.Party)...)
|
||||
sortInspectionSourceRecords(records)
|
||||
if err := writeInspectionSourceHeader(out, resolved); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
if _, err := fmt.Fprintf(out, "%s\t%s\t%s\n", record.Path, record.Role, record.Source); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseInspectionFlags(command string, args []string, out io.Writer) (inspectionFlags, error) {
|
||||
fs := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||
fs.SetOutput(out)
|
||||
@@ -155,6 +192,9 @@ func resolveInspectionConfig(flags inspectionFlags) (*inspectionConfig, error) {
|
||||
return &inspectionConfig{
|
||||
PipelinePath: loaded.PipelinePath,
|
||||
Pipeline: loaded.Pipeline,
|
||||
CampaignPath: loaded.CampaignPath,
|
||||
Campaign: loaded.Campaign,
|
||||
Party: loaded.Party,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -194,7 +234,63 @@ func inspectionProfile(pipeline *config.PipelineConfig) string {
|
||||
}
|
||||
|
||||
func writeConfigCommandUsage(out io.Writer) {
|
||||
fmt.Fprintln(out, "Usage: narratio config <validate|show>")
|
||||
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources>")
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Use config validate to check an effective pipeline or config show to print normalized effective YAML.")
|
||||
fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, or config sources to report ownership.")
|
||||
}
|
||||
|
||||
func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error {
|
||||
if _, err := fmt.Fprintf(out, "root: %s\n", inspectionRootPath(resolved)); err != nil {
|
||||
return err
|
||||
}
|
||||
imports := config.EffectivePipelineImports(resolved.Pipeline)
|
||||
if len(imports) == 0 {
|
||||
if _, err := fmt.Fprintln(out, "imports: none"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, imported := range imports {
|
||||
if _, err := fmt.Fprintf(out, "import: %s\n", config.NormalizedConfigurationPath(imported)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if profile, ok := config.SelectedPipelineProfile(resolved.Pipeline); ok {
|
||||
if _, err := fmt.Fprintf(out, "profile: name=%s selection=%s overlay=%s\n", profile.Name, profile.Source, config.NormalizedConfigurationPath(profile.OverlayPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := fmt.Fprintln(out, "profile: none"); err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved.Campaign == nil {
|
||||
if _, err := fmt.Fprintln(out, "campaign: none"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out, "party: none"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := fmt.Fprintf(out, "campaign: id=%s source=%s\n", config.CampaignID(resolved.Campaign), config.NormalizedConfigurationPath(resolved.CampaignPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "party: mode=%s source=%s\n", resolved.Party.Mode, config.NormalizedConfigurationPath(resolved.Party.Source.Path)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "digest: %s\n", config.EffectivePipelineDigest(resolved.Pipeline)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(out, "path\trole\tsource")
|
||||
return err
|
||||
}
|
||||
|
||||
func sortInspectionSourceRecords(records []config.EffectivePipelineSourceRecord) {
|
||||
sort.Slice(records, func(left, right int) bool {
|
||||
if records[left].Path != records[right].Path {
|
||||
return records[left].Path < records[right].Path
|
||||
}
|
||||
if records[left].Role != records[right].Role {
|
||||
return records[left].Role < records[right].Role
|
||||
}
|
||||
return records[left].Source < records[right].Source
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,11 +21,12 @@ func TestConfigCommandHelpAndDispatch(t *testing.T) {
|
||||
want string
|
||||
code int
|
||||
}{
|
||||
{name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config <validate|show>"},
|
||||
{name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config <validate|show|sources>"},
|
||||
{name: "validate help", args: []string{"config", "validate", "--help"}, want: "Usage: narratio config validate"},
|
||||
{name: "show help", args: []string{"config", "show", "--help"}, want: "Usage: narratio config show"},
|
||||
{name: "unknown", args: []string{"config", "sources"}, want: `config: unknown subcommand "sources"`, code: 1},
|
||||
{name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show", code: 1},
|
||||
{name: "sources help", args: []string{"config", "sources", "--help"}, want: "Usage: narratio config sources"},
|
||||
{name: "unknown", args: []string{"config", "diff"}, want: `config: unknown subcommand "diff"`, code: 1},
|
||||
{name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show|sources", code: 1},
|
||||
{name: "session flag", args: []string{"config", "validate", "--session", "session.yml"}, want: "flag provided but not defined", code: 1},
|
||||
{name: "stage flag", args: []string{"config", "show", "--from", "prepare"}, want: "flag provided but not defined", code: 1},
|
||||
{name: "force flag", args: []string{"config", "show", "--force"}, want: "flag provided but not defined", code: 1},
|
||||
@@ -86,6 +87,13 @@ whisperx:
|
||||
if strings.Contains(show, secret) {
|
||||
t.Fatalf("show output leaked a raw secret: %q", show)
|
||||
}
|
||||
var sourcesOut bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath}, &sourcesOut); err != nil {
|
||||
t.Fatalf("ConfigSources() error = %v", err)
|
||||
}
|
||||
if strings.Contains(sourcesOut.String(), secret) {
|
||||
t.Fatalf("sources output leaked a raw secret: %q", sourcesOut.String())
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("inspection invoked configured external endpoint %d time(s)", calls)
|
||||
}
|
||||
@@ -189,6 +197,133 @@ whisperx:
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSourcesReportsStableOwnership(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
enableCommandTestProfiles(t, pipelinePath)
|
||||
|
||||
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"}
|
||||
var first, second bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), args, &first); err != nil {
|
||||
t.Fatalf("first ConfigSources() error = %v", err)
|
||||
}
|
||||
if err := ConfigSources(context.Background(), args, &second); err != nil {
|
||||
t.Fatalf("second ConfigSources() error = %v", err)
|
||||
}
|
||||
if first.String() != second.String() {
|
||||
t.Fatalf("sources output is not deterministic:\nfirst=%q\nsecond=%q", first.String(), second.String())
|
||||
}
|
||||
output := first.String()
|
||||
for _, want := range []string{
|
||||
"root: ",
|
||||
"imports: none",
|
||||
"profile: name=testing selection=cli overlay=",
|
||||
"campaign: id=sample-campaign source=",
|
||||
"party: mode=legacy source=",
|
||||
"digest: ",
|
||||
"path\trole\tsource\n",
|
||||
"workspace.root\troot\t",
|
||||
"whisperx.language\tprofile\t",
|
||||
"campaign.inputs.players_file\tlegacy_player\t",
|
||||
"trim.enabled\tdefault\tdefault",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("sources output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
var out bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &out); err != nil {
|
||||
t.Fatalf("ConfigSources() error = %v", err)
|
||||
}
|
||||
output := out.String()
|
||||
partyPath := filepath.Join(filepath.Dir(campaignPath), "party.yml")
|
||||
for _, want := range []string{
|
||||
"party: mode=canonical source=" + partyPath,
|
||||
"derived.players\tparty\t" + partyPath,
|
||||
"scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + pipelinePath,
|
||||
"scriptorium.artifacts.character_note_arannis.enabled\tparty\t" + partyPath,
|
||||
"scriptorium.artifacts.character_note_arannis.depends_on\tfamily\t" + pipelinePath,
|
||||
"scriptorium.artifacts.character_note_arannis.inputs.prior.source\tparty\t" + partyPath,
|
||||
"publish.outputs[",
|
||||
"\tfamily\t" + pipelinePath,
|
||||
"\tparty\t" + partyPath,
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("sources output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
data, err := os.ReadFile(pipelinePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profilePath := filepath.Join(filepath.Dir(pipelinePath), "testing.yml")
|
||||
composition := "composition:\n default_profile: testing\n profiles:\n testing:\n overlay: testing.yml\n"
|
||||
if err := os.WriteFile(pipelinePath, append([]byte(composition), data...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, profilePath, `scriptorium:
|
||||
artifact_families:
|
||||
character_note:
|
||||
enabled: true
|
||||
prompt_id: dnd.character_note
|
||||
`)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &out); err != nil {
|
||||
t.Fatalf("ConfigSources() error = %v", err)
|
||||
}
|
||||
want := "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + profilePath
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("sources output missing profile-generated ownership %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSourcesReportsRootImportProfileAndDefaultOwnership(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := filepath.Join(dir, "pipeline.yml")
|
||||
importPath := filepath.Join(dir, "base.yml")
|
||||
profilePath := filepath.Join(dir, "testing.yml")
|
||||
mustWriteTestFile(t, rootPath, `composition:
|
||||
imports: [base.yml]
|
||||
default_profile: testing
|
||||
profiles:
|
||||
testing:
|
||||
overlay: testing.yml
|
||||
workspace:
|
||||
root: /srv/narratio
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`)
|
||||
mustWriteTestFile(t, importPath, "storage:\n backend: local\n")
|
||||
mustWriteTestFile(t, profilePath, "whisperx:\n language: fr\n")
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), []string{"--config", rootPath}, &out); err != nil {
|
||||
t.Fatalf("ConfigSources() error = %v", err)
|
||||
}
|
||||
output := out.String()
|
||||
for _, want := range []string{
|
||||
"import: " + importPath,
|
||||
"profile: name=testing selection=default overlay=" + profilePath,
|
||||
"workspace.root\troot\t" + rootPath,
|
||||
"storage.backend\timport\t" + importPath,
|
||||
"whisperx.language\tprofile\t" + profilePath,
|
||||
"trim.enabled\tdefault\tdefault",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("sources output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeInspectionFamilyConfig(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -204,10 +339,25 @@ whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
scriptorium:
|
||||
artifact_families:
|
||||
character_meta:
|
||||
enabled: false
|
||||
for_each: party.characters
|
||||
output_path_pattern: artifacts/characters/{character_id}/meta.md
|
||||
character_note:
|
||||
enabled: false
|
||||
for_each: party.characters
|
||||
output_path_pattern: artifacts/characters/{character_id}/note.md
|
||||
member_dependencies: [character_meta]
|
||||
inputs:
|
||||
prior: {source: narratio.member_artifact.character_meta, required: true}
|
||||
member_vars:
|
||||
character_name: character.name
|
||||
publish:
|
||||
enabled: true
|
||||
required: true
|
||||
publish:
|
||||
enabled: true
|
||||
upload_run: false
|
||||
`)
|
||||
mustWriteTestFile(t, campaignPath, `campaign_id: sample-campaign
|
||||
inputs:
|
||||
|
||||
Reference in New Issue
Block a user