Add read-only configuration inspection commands
This commit is contained in:
25
docs/cli.md
25
docs/cli.md
@@ -20,6 +20,7 @@ Top-level commands:
|
||||
- `publish <session_id>`: force-run publish.
|
||||
- `clean <session_id>` or `clean --all`: remove local work/spool state.
|
||||
- `session <subcommand>`: session helper commands.
|
||||
- `config <subcommand>`: validate or display resolved pipeline configuration.
|
||||
|
||||
Session subcommands:
|
||||
|
||||
@@ -77,6 +78,30 @@ Commands with additional positionals keep their command-specific order:
|
||||
|
||||
## Command Reference
|
||||
|
||||
### `config validate` and `config show`
|
||||
|
||||
```bash
|
||||
narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>]
|
||||
narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>]
|
||||
```
|
||||
|
||||
These commands resolve the selected profile, defaults, ordinary paths, and—if
|
||||
a campaign is selected—the campaign-owned party. They neither discover or load
|
||||
a session nor create a workspace, manifest, run, lock, adapter, remote
|
||||
connection, or credential environment.
|
||||
|
||||
Campaign selection is optional for a pipeline without party-driven artifact
|
||||
families. A pipeline with `scriptorium.artifact_families` needs a selected or
|
||||
configured default campaign so Narratio can expand its concrete artifacts and
|
||||
publish rules. `--campaign` and `--campaign-file` remain mutually exclusive.
|
||||
Session, range, force, and artifact-execution flags are not accepted.
|
||||
|
||||
`config validate` writes a concise root-path, selected-profile (or `none`), and
|
||||
effective-digest summary after successful complete validation. `config show`
|
||||
writes one deterministic, secret-free YAML document containing defaulted and
|
||||
expanded concrete configuration. It omits composition declarations, artifact
|
||||
family declarations, and runtime provenance.
|
||||
|
||||
### `version`
|
||||
|
||||
```bash
|
||||
|
||||
@@ -42,6 +42,23 @@ The downloaded remote session file is command-scoped: Narratio removes it after
|
||||
the command finishes and records only the remote object provenance alongside
|
||||
the durable copied session input.
|
||||
|
||||
### Read-only effective pipeline inspection
|
||||
|
||||
`narratio config validate` and `narratio config show` use the same `--config`,
|
||||
`--campaign`, `--campaign-file`, and `--profile` selection rules as pipeline
|
||||
commands, but do not select, discover, or load a session. They do not read
|
||||
credential values or create runtime state.
|
||||
|
||||
Campaign selection is optional only when the resolved pipeline has no
|
||||
`scriptorium.artifact_families`. When families are declared, Narratio selects a
|
||||
campaign through an explicit flag or `pipeline.campaigns.default_campaign_id`,
|
||||
then parses the campaign-owned party and expands concrete artifacts and any
|
||||
family publish rules before validation. `config validate` prints the resulting
|
||||
root, profile, and effective digest. `config show` emits the normalized
|
||||
effective pipeline YAML, with defaults and concrete expansion included but
|
||||
composition and family declarations omitted. The [CLI reference](cli.md#config-validate-and-config-show)
|
||||
owns command syntax and output conventions.
|
||||
|
||||
### Identity segments
|
||||
|
||||
Campaign IDs (`campaign_id` and `default_campaign_id`), session IDs, previous
|
||||
|
||||
@@ -52,6 +52,22 @@ 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.
|
||||
|
||||
The application also has a separate read-only inspection resolver for `config
|
||||
validate` and `config show`. It uses the same production root/profile and
|
||||
campaign selection functions, but never routes through session discovery,
|
||||
remote-session download, secret loading, adapter composition, workspace
|
||||
initialization, manifest access, or cleanup. A pipeline with retained artifact
|
||||
family declarations must resolve its selected campaign before ordinary pipeline
|
||||
validation, which expands its canonical-party members and generated publish
|
||||
rules. A pipeline without those declarations may be validated by itself.
|
||||
|
||||
`MarshalEffectivePipeline` is the configuration-owned projection for `config
|
||||
show`. It serializes the typed, defaulted effective mapping through the
|
||||
deterministic composition renderer, then removes resolution-only artifact
|
||||
family declarations. The result contains no composition envelope or private
|
||||
provenance fields and has one trailing newline; commands do not marshal runtime
|
||||
objects directly.
|
||||
|
||||
Campaign context construction also reads and classifies the campaign-owned
|
||||
party source through `ParseParty`. A canonical party retains its raw bytes and
|
||||
normalized roster in runtime-only `ResolvedParty` provenance, while a legacy
|
||||
|
||||
@@ -952,7 +952,7 @@ rules before ordinary validation and publication.
|
||||
|
||||
## Stage 17 — Read-Only `config validate` And `config show`
|
||||
|
||||
**Status: Pending**
|
||||
**Status: Completed**
|
||||
|
||||
### Goal
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var supportedCommands = []string{"version", "run", "regenerate-artifacts", "run-stage", "analyze", "publish", "clean", "session"}
|
||||
var supportedCommands = []string{"version", "run", "regenerate-artifacts", "run-stage", "analyze", "publish", "clean", "session", "config"}
|
||||
|
||||
var runCommandFn = Run
|
||||
|
||||
@@ -40,6 +40,8 @@ func Execute(args []string, stdout, stderr io.Writer) int {
|
||||
err = Session(ctx, cmdArgs, stdout)
|
||||
case "clean":
|
||||
err = Clean(ctx, cmdArgs, stdout)
|
||||
case "config":
|
||||
err = Config(ctx, cmdArgs, stdout)
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown command: %q\n\n", cmd)
|
||||
printUsage(stderr)
|
||||
|
||||
200
internal/app/config_commands.go
Normal file
200
internal/app/config_commands.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
type inspectionFlags struct {
|
||||
pipelinePath string
|
||||
campaignPath string
|
||||
campaignFilePath string
|
||||
profile singletonStringFlag
|
||||
}
|
||||
|
||||
type inspectionConfig struct {
|
||||
PipelinePath string
|
||||
Pipeline *config.PipelineConfig
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
if args[0] == "--help" || args[0] == "-h" {
|
||||
writeConfigCommandUsage(out)
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "validate":
|
||||
return ConfigValidate(ctx, args[1:], out)
|
||||
case "show":
|
||||
return ConfigShow(ctx, args[1:], out)
|
||||
default:
|
||||
return fmt.Errorf("config: unknown subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigValidate validates one fully resolved effective pipeline without
|
||||
// loading session state or constructing runtime collaborators.
|
||||
func ConfigValidate(_ context.Context, args []string, out io.Writer) error {
|
||||
flags, err := parseInspectionFlags("config validate", 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 validate: %w", err)
|
||||
}
|
||||
_, err = fmt.Fprintf(out, "Configuration valid: root=%s; profile=%s; digest=%s\n",
|
||||
inspectionRootPath(resolved), inspectionProfile(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline))
|
||||
return err
|
||||
}
|
||||
|
||||
// ConfigShow validates and renders one fully resolved effective pipeline
|
||||
// without loading session state or constructing runtime collaborators.
|
||||
func ConfigShow(_ context.Context, args []string, out io.Writer) error {
|
||||
flags, err := parseInspectionFlags("config show", 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 show: %w", err)
|
||||
}
|
||||
data, err := config.MarshalEffectivePipeline(resolved.Pipeline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config show: %w", err)
|
||||
}
|
||||
_, err = out.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func parseInspectionFlags(command string, args []string, out io.Writer) (inspectionFlags, error) {
|
||||
fs := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||
fs.SetOutput(out)
|
||||
var flags inspectionFlags
|
||||
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID")
|
||||
fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||
flags.profile.name = "profile"
|
||||
fs.Var(&flags.profile, "profile", "named pipeline profile")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(out, "Usage: narratio %s [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>]\n\n", command)
|
||||
fmt.Fprintln(out, "Resolves configuration without a session, workspace, manifest, adapters, or external services.")
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Flags:")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return inspectionFlags{}, fmt.Errorf("%s: invalid flags: %w", command, err)
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return inspectionFlags{}, fmt.Errorf("%s: unexpected positional arguments", command)
|
||||
}
|
||||
if strings.TrimSpace(flags.campaignPath) != "" && strings.TrimSpace(flags.campaignFilePath) != "" {
|
||||
return inspectionFlags{}, fmt.Errorf("%s: --campaign and --campaign-file are mutually exclusive", command)
|
||||
}
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
func resolveInspectionConfig(flags inspectionFlags) (*inspectionConfig, error) {
|
||||
pipelinePath, pipeline, err := loadPipelineConfig(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
needsCampaign := pipelineHasArtifactFamilies(pipeline)
|
||||
hasCampaignSelection := strings.TrimSpace(flags.campaignPath) != "" || strings.TrimSpace(flags.campaignFilePath) != ""
|
||||
if !needsCampaign && !hasCampaignSelection {
|
||||
if err := validateInspectionPipeline(pipelinePath, pipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inspectionConfig{PipelinePath: pipelinePath, Pipeline: pipeline}, nil
|
||||
}
|
||||
|
||||
campaignPath, err := resolveCampaignConfigPath(pipeline, flags.campaignPath, flags.campaignFilePath)
|
||||
if err != nil {
|
||||
if needsCampaign {
|
||||
return nil, fmt.Errorf("pipeline.scriptorium.artifact_families requires a campaign: %w", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
campaign, err := config.LoadCampaign(campaignPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selectedID := strings.TrimSpace(flags.campaignPath); selectedID != "" && strings.TrimSpace(flags.campaignFilePath) == "" {
|
||||
if got := config.CampaignID(campaign); got != selectedID {
|
||||
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", campaignPath, got, selectedID)
|
||||
}
|
||||
}
|
||||
loaded, err := config.LoadPipelineCampaign(pipelinePath, pipeline, campaignPath, campaign)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateInspectionPipeline(loaded.PipelinePath, loaded.Pipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateInspectionCampaign(loaded.CampaignPath, loaded.Campaign); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inspectionConfig{
|
||||
PipelinePath: loaded.PipelinePath,
|
||||
Pipeline: loaded.Pipeline,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pipelineHasArtifactFamilies(pipeline *config.PipelineConfig) bool {
|
||||
return pipeline != nil && pipeline.Scriptorium != nil && len(pipeline.Scriptorium.ArtifactFamilies) != 0
|
||||
}
|
||||
|
||||
func validateInspectionPipeline(path string, pipeline *config.PipelineConfig) error {
|
||||
if err := config.ValidatePipelineConfig(pipeline); err != nil {
|
||||
return fmt.Errorf("pipeline config %q invalid: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInspectionCampaign(path string, campaign *config.CampaignConfig) error {
|
||||
if err := config.ValidateCampaignConfig(campaign); err != nil {
|
||||
return fmt.Errorf("campaign config %q invalid: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inspectionRootPath(resolved *inspectionConfig) string {
|
||||
if resolved == nil {
|
||||
return ""
|
||||
}
|
||||
if root := config.EffectivePipelineRootPath(resolved.Pipeline); root != "" {
|
||||
return root
|
||||
}
|
||||
return resolved.PipelinePath
|
||||
}
|
||||
|
||||
func inspectionProfile(pipeline *config.PipelineConfig) string {
|
||||
if profile, ok := config.SelectedPipelineProfile(pipeline); ok {
|
||||
return profile.Name
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
func writeConfigCommandUsage(out io.Writer) {
|
||||
fmt.Fprintln(out, "Usage: narratio config <validate|show>")
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Use config validate to check an effective pipeline or config show to print normalized effective YAML.")
|
||||
}
|
||||
226
internal/app/config_commands_test.go
Normal file
226
internal/app/config_commands_test.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
func TestConfigCommandHelpAndDispatch(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
code int
|
||||
}{
|
||||
{name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config <validate|show>"},
|
||||
{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: "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},
|
||||
{name: "artifact flag", args: []string{"config", "show", "--artifacts", "recap"}, want: "flag provided but not defined", code: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := Execute(test.args, &stdout, &stderr)
|
||||
if code != test.code {
|
||||
t.Fatalf("exit code = %d, want %d; stdout=%q stderr=%q", code, test.code, stdout.String(), stderr.String())
|
||||
}
|
||||
combined := stdout.String() + stderr.String()
|
||||
if !strings.Contains(combined, test.want) {
|
||||
t.Fatalf("output = %q, want %q", combined, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateAndShowPipelineOnlyAreSideEffectFree(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
workspaceRoot := filepath.Join(dir, "workspace-does-not-exist")
|
||||
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||
secretsDir := filepath.Join(dir, "secrets")
|
||||
const secret = "inspection-secret-must-not-escape"
|
||||
mustWriteTestFile(t, filepath.Join(secretsDir, "INSPECTION_SECRET"), secret+"\n")
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls++ }))
|
||||
defer server.Close()
|
||||
mustWriteTestFile(t, pipelinePath, `workspace:
|
||||
root: `+workspaceRoot+`
|
||||
secrets:
|
||||
env_dir: `+secretsDir+`
|
||||
whisperx:
|
||||
transcribe_url: `+server.URL+`
|
||||
`)
|
||||
t.Setenv("INSPECTION_SECRET", secret)
|
||||
|
||||
var validateOut bytes.Buffer
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &validateOut); err != nil {
|
||||
t.Fatalf("ConfigValidate() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(validateOut.String(), "Configuration valid: root=") || !strings.Contains(validateOut.String(), "; profile=none; digest=") {
|
||||
t.Fatalf("validate output = %q", validateOut.String())
|
||||
}
|
||||
|
||||
var showOut bytes.Buffer
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &showOut); err != nil {
|
||||
t.Fatalf("ConfigShow() error = %v", err)
|
||||
}
|
||||
show := showOut.String()
|
||||
if !strings.HasSuffix(show, "\n") || !strings.Contains(show, "workspace:\n") || !strings.Contains(show, "root: "+workspaceRoot) {
|
||||
t.Fatalf("show output = %q", show)
|
||||
}
|
||||
if strings.Contains(show, "artifact_families") || strings.Contains(show, "resolution") {
|
||||
t.Fatalf("show output leaked resolution-only fields: %q", show)
|
||||
}
|
||||
if strings.Contains(show, secret) {
|
||||
t.Fatalf("show output leaked a raw secret: %q", show)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("inspection invoked configured external endpoint %d time(s)", calls)
|
||||
}
|
||||
if _, err := os.Stat(workspaceRoot); !os.IsNotExist(err) {
|
||||
t.Fatalf("workspace root stat error = %v, want absent", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
enableCommandTestProfiles(t, pipelinePath)
|
||||
|
||||
var defaultOut bytes.Buffer
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &defaultOut); err != nil {
|
||||
t.Fatalf("default ConfigValidate() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(defaultOut.String(), "profile=production") {
|
||||
t.Fatalf("default output = %q", defaultOut.String())
|
||||
}
|
||||
|
||||
var explicitOut bytes.Buffer
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &explicitOut); err != nil {
|
||||
t.Fatalf("explicit ConfigShow() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(explicitOut.String(), "language: fr") {
|
||||
t.Fatalf("explicit show output = %q", explicitOut.String())
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"--config", pipelinePath, "--campaign", "sample-campaign"},
|
||||
{"--config", pipelinePath, "--campaign-file", campaignPath},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
if err := ConfigValidate(context.Background(), args, &out); err != nil {
|
||||
t.Fatalf("ConfigValidate(%q) error = %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCommandsExpandFamiliesAndRequireCampaign(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
|
||||
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow} {
|
||||
var out bytes.Buffer
|
||||
err := command(context.Background(), []string{"--config", pipelinePath}, &out)
|
||||
if err == nil || !strings.Contains(err.Error(), "artifact_families requires a campaign") {
|
||||
t.Fatalf("family command without campaign error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var show bytes.Buffer
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &show); err != nil {
|
||||
t.Fatalf("ConfigShow() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(show.String(), "character_note_arannis:") || strings.Contains(show.String(), "artifact_families") {
|
||||
t.Fatalf("expanded show output = %q", show.String())
|
||||
}
|
||||
|
||||
loadedPipeline, err := config.LoadPipeline(pipelinePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loadedCampaign, err := config.LoadCampaign(campaignPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.LoadPipelineCampaign(pipelinePath, loadedPipeline, campaignPath, loadedCampaign)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var validateOut bytes.Buffer
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validateOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(validateOut.String(), "digest="+config.EffectivePipelineDigest(loaded.Pipeline)) {
|
||||
t.Fatalf("inspection digest = %q, want %q", validateOut.String(), config.EffectivePipelineDigest(loaded.Pipeline))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateReportsCanonicalPartyAndImportErrors(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
mustWriteTestFile(t, filepath.Join(filepath.Dir(campaignPath), "party.yml"), "schema_version: narratio.party.v2\ncharacters: {}\n")
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, io.Discard); err == nil || !strings.Contains(err.Error(), "unsupported") {
|
||||
t.Fatalf("canonical party error = %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
rootPath := filepath.Join(dir, "pipeline.yml")
|
||||
mustWriteTestFile(t, rootPath, `composition:
|
||||
imports: [conf.yml]
|
||||
workspace:
|
||||
root: /one
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`)
|
||||
mustWriteTestFile(t, filepath.Join(dir, "conf.yml"), "workspace:\n root: /two\n")
|
||||
err := ConfigValidate(context.Background(), []string{"--config", rootPath}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "workspace.root") || !strings.Contains(err.Error(), "pipeline.yml") || !strings.Contains(err.Error(), "conf.yml") {
|
||||
t.Fatalf("import diagnostic = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeInspectionFamilyConfig(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
campaignRoot := filepath.Join(dir, "campaigns")
|
||||
campaignDir := filepath.Join(campaignRoot, "sample-campaign")
|
||||
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||
campaignPath := filepath.Join(campaignDir, "campaign.yml")
|
||||
mustWriteTestFile(t, pipelinePath, `workspace:
|
||||
root: `+filepath.Join(dir, "workspace")+`
|
||||
campaigns:
|
||||
root: `+campaignRoot+`
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
scriptorium:
|
||||
artifact_families:
|
||||
character_note:
|
||||
enabled: false
|
||||
for_each: party.characters
|
||||
output_path_pattern: artifacts/characters/{character_id}/note.md
|
||||
`)
|
||||
mustWriteTestFile(t, campaignPath, `campaign_id: sample-campaign
|
||||
inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
party_file: ./party.yml
|
||||
`)
|
||||
mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), `schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player: {name: Eric}
|
||||
character: {name: Arannis, classes: [{name: wizard}]}
|
||||
`)
|
||||
return pipelinePath, campaignPath
|
||||
}
|
||||
60
internal/config/effective_pipeline.go
Normal file
60
internal/config/effective_pipeline.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// EffectivePipelineRootPath reports the absolute root pipeline path retained
|
||||
// while resolving cfg. It is empty for a pipeline not loaded through the
|
||||
// production loader.
|
||||
func EffectivePipelineRootPath(cfg *PipelineConfig) string {
|
||||
if cfg == nil || cfg.resolution == nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.resolution.rootPath
|
||||
}
|
||||
|
||||
// MarshalEffectivePipeline renders the validated, normalized pipeline as one
|
||||
// deterministic YAML document. Composition declarations and runtime-only
|
||||
// resolution data are excluded. Artifact family declarations are also omitted
|
||||
// because a resolved pipeline exposes their concrete artifacts instead.
|
||||
func MarshalEffectivePipeline(cfg *PipelineConfig) ([]byte, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("pipeline config is required")
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serialize effective pipeline: %w", err)
|
||||
}
|
||||
document, err := parseCompositionBytes("effective pipeline", data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
removeResolutionOnlyPipelineFields(document)
|
||||
return document.canonicalYAML()
|
||||
}
|
||||
|
||||
func removeResolutionOnlyPipelineFields(document *compositionDocument) {
|
||||
if document == nil || document.root == nil {
|
||||
return
|
||||
}
|
||||
scriptoriumIndex := compositionFieldIndex(document.root.fields, "scriptorium")
|
||||
if scriptoriumIndex < 0 {
|
||||
return
|
||||
}
|
||||
scriptorium := document.root.fields[scriptoriumIndex].value
|
||||
if scriptorium == nil || scriptorium.kind != yaml.MappingNode {
|
||||
return
|
||||
}
|
||||
familyIndex := compositionFieldIndex(scriptorium.fields, "artifact_families")
|
||||
if familyIndex < 0 {
|
||||
return
|
||||
}
|
||||
scriptorium.fields = append(scriptorium.fields[:familyIndex], scriptorium.fields[familyIndex+1:]...)
|
||||
for index := range scriptorium.fields {
|
||||
scriptorium.fields[index].order = index
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,53 @@ inputs:
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalEffectivePipelineRendersStablePublicConfiguration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "pipeline.yml")
|
||||
if err := os.WriteFile(path, []byte(`scriptorium:
|
||||
artifacts:
|
||||
zeta:
|
||||
enabled: false
|
||||
output_path: artifacts/zeta.md
|
||||
alpha:
|
||||
enabled: false
|
||||
output_path: artifacts/alpha.md
|
||||
workspace:
|
||||
root: /srv/narratio
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first, err := LoadPipeline(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := LoadPipeline(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstYAML, err := MarshalEffectivePipeline(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondYAML, err := MarshalEffectivePipeline(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(firstYAML) != string(secondYAML) || !strings.HasSuffix(string(firstYAML), "\n") {
|
||||
t.Fatalf("effective YAML is not stable: first=%q second=%q", firstYAML, secondYAML)
|
||||
}
|
||||
output := string(firstYAML)
|
||||
if strings.Contains(output, "artifact_families") || strings.Contains(output, "resolution") {
|
||||
t.Fatalf("effective YAML leaked runtime fields: %q", output)
|
||||
}
|
||||
if strings.Index(output, "alpha:") > strings.Index(output, "zeta:") {
|
||||
t.Fatalf("configured artifacts are not sorted: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMissingAudioSource(t *testing.T) {
|
||||
cfg := loadedValidConfig(t)
|
||||
cfg.Session.Inputs.AudioDir = ""
|
||||
|
||||
@@ -31,10 +31,10 @@ func Validate(cfg *Config) error {
|
||||
return fmt.Errorf("session config is required")
|
||||
}
|
||||
|
||||
if err := validatePipeline(cfg.Pipeline); err != nil {
|
||||
if err := ValidatePipelineConfig(cfg.Pipeline); err != nil {
|
||||
return fmt.Errorf("pipeline config %q invalid: %w", shortName(cfg.PipelinePath, "pipeline.yml"), err)
|
||||
}
|
||||
if err := validateCampaign(cfg.Campaign); err != nil {
|
||||
if err := ValidateCampaignConfig(cfg.Campaign); err != nil {
|
||||
return fmt.Errorf("campaign config %q invalid: %w", shortName(cfg.CampaignPath, "campaign.yml"), err)
|
||||
}
|
||||
if err := validateSession(cfg.Session); err != nil {
|
||||
@@ -50,6 +50,23 @@ func Validate(cfg *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePipelineConfig validates a loaded, defaulted pipeline without
|
||||
// requiring session configuration. Callers that require party-driven artifact
|
||||
// expansion must resolve a campaign first through LoadPipelineCampaign.
|
||||
func ValidatePipelineConfig(cfg *PipelineConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("pipeline config is required")
|
||||
}
|
||||
return validatePipeline(cfg)
|
||||
}
|
||||
|
||||
// ValidateCampaignConfig validates a loaded campaign without requiring a
|
||||
// session. Party parsing and canonical-party checks remain owned by
|
||||
// LoadPipelineCampaign, which has the campaign source path available.
|
||||
func ValidateCampaignConfig(cfg *CampaignConfig) error {
|
||||
return validateCampaign(cfg)
|
||||
}
|
||||
|
||||
func validateCampaign(cfg *CampaignConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("campaign config is required")
|
||||
|
||||
Reference in New Issue
Block a user