Add effective artifact catalog command
This commit is contained in:
16
docs/cli.md
16
docs/cli.md
@@ -20,7 +20,7 @@ Top-level commands:
|
|||||||
- `publish <session_id>`: force-run publish.
|
- `publish <session_id>`: force-run publish.
|
||||||
- `clean <session_id>` or `clean --all`: remove local work/spool state.
|
- `clean <session_id>` or `clean --all`: remove local work/spool state.
|
||||||
- `session <subcommand>`: session helper commands.
|
- `session <subcommand>`: session helper commands.
|
||||||
- `config <subcommand>`: validate, display, source-trace, or compare resolved pipeline configuration.
|
- `config <subcommand>`: validate, display, source-trace, catalog, or compare resolved pipeline configuration.
|
||||||
|
|
||||||
Session subcommands:
|
Session subcommands:
|
||||||
|
|
||||||
@@ -82,12 +82,13 @@ Commands with additional positionals keep their command-specific order:
|
|||||||
|
|
||||||
## Command Reference
|
## Command Reference
|
||||||
|
|
||||||
### `config validate`, `config show`, `config sources`, and `config diff`
|
### `config validate`, `config show`, `config sources`, `config artifacts`, and `config diff`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
||||||
narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
||||||
narratio config sources [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
narratio config sources [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
||||||
|
narratio config artifacts [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
||||||
narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <directory>]
|
narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <directory>]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -119,6 +120,17 @@ and generated family ownership. A generated party member has
|
|||||||
one family record and one party record at the same logical path. The output
|
one family record and one party record at the same logical path. The output
|
||||||
never reads or prints secret values.
|
never reads or prints secret values.
|
||||||
|
|
||||||
|
`config artifacts` prints the concrete catalog used by analysis, ordered by
|
||||||
|
artifact key. Its tab-separated header is `key`, `source`, `definitions`,
|
||||||
|
`enabled`, `output`, `dependencies`, `family`, `character`, `family_source`, and
|
||||||
|
`party_source`. Definitions and effective dependencies are sorted comma-separated
|
||||||
|
lists, and control characters in fields are escaped. Family-member rows identify
|
||||||
|
their family, character, defining family file, and party file. An empty catalog
|
||||||
|
prints only the header. Ordinary artifacts can be inspected without a campaign;
|
||||||
|
party-driven families retain the campaign requirement. See
|
||||||
|
[Scriptorium Artifact Entries](./config.md#scriptorium-artifact-entries) for the
|
||||||
|
configuration layout.
|
||||||
|
|
||||||
`config diff` resolves both supplied profile names independently and compares
|
`config diff` resolves both supplied profile names independently and compares
|
||||||
their fully resolved, secret-free effective mappings. It does
|
their fully resolved, secret-free effective mappings. It does
|
||||||
not accept `--profile`; the two positional names must be distinct, declared
|
not accept `--profile`; the two positional names must be distinct, declared
|
||||||
|
|||||||
@@ -467,6 +467,9 @@ effective dependency on that configured artifact. Narratio unions these inferred
|
|||||||
edges with explicit `depends_on` entries, then trims, deduplicates, and sorts the
|
edges with explicit `depends_on` entries, then trims, deduplicates, and sorts the
|
||||||
result during final configuration resolution. Authors use `depends_on` only for
|
result during final configuration resolution. Authors use `depends_on` only for
|
||||||
ordering constraints that are not already expressed by an artifact input.
|
ordering constraints that are not already expressed by an artifact input.
|
||||||
|
Use [`narratio config artifacts`](./cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
|
||||||
|
to inspect keys, definition sources, outputs, and effective dependencies without
|
||||||
|
loading a session or creating runtime state.
|
||||||
|
|
||||||
### Scriptorium Artifact Families
|
### Scriptorium Artifact Families
|
||||||
|
|
||||||
|
|||||||
@@ -395,7 +395,9 @@ internals with the implemented inference rule.
|
|||||||
|
|
||||||
## Stage 9 — Add the effective artifact catalog command
|
## Stage 9 — Add the effective artifact catalog command
|
||||||
|
|
||||||
Status: Pending
|
Status: Complete — Added the read-only `config artifacts` catalog over finalized
|
||||||
|
configuration, including effective dependencies and definition/family/party
|
||||||
|
provenance; documented it and verified focused, full, and race test suites.
|
||||||
|
|
||||||
Read: `internal/app/config_commands.go`, config diff/source rendering, the safe
|
Read: `internal/app/config_commands.go`, config diff/source rendering, the safe
|
||||||
projection and family-origin catalogs, and `docs/cli.md`.
|
projection and family-origin catalogs, and `docs/cli.md`.
|
||||||
|
|||||||
182
internal/app/config_artifacts_test.go
Normal file
182
internal/app/config_artifacts_test.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const artifactCatalogHeader = "key\tsource\tdefinitions\tenabled\toutput\tdependencies\tfamily\tcharacter\tfamily_source\tparty_source\n"
|
||||||
|
|
||||||
|
func TestConfigArtifactsReportsOrdinaryCatalogWithoutCampaign(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||||
|
mustWriteTestFile(t, pipelinePath, `workspace:
|
||||||
|
root: `+filepath.Join(dir, "workspace")+`
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: https://transcription.example.com/transcribe
|
||||||
|
scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
zeta:
|
||||||
|
enabled: false
|
||||||
|
output_path: artifacts/zeta.md
|
||||||
|
alpha:
|
||||||
|
enabled: false
|
||||||
|
output_path: artifacts/alpha.md
|
||||||
|
inputs:
|
||||||
|
upstream: {source: narratio.artifact.zeta, required: true}
|
||||||
|
`)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath}, &out); err != nil {
|
||||||
|
t.Fatalf("ConfigArtifacts() error = %v", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n")
|
||||||
|
if len(lines) != 3 || !strings.HasPrefix(lines[1], "alpha\t") || !strings.HasPrefix(lines[2], "zeta\t") {
|
||||||
|
t.Fatalf("catalog order/output = %q", out.String())
|
||||||
|
}
|
||||||
|
alpha := strings.Split(lines[1], "\t")
|
||||||
|
if len(alpha) != 10 {
|
||||||
|
t.Fatalf("alpha fields = %#v", alpha)
|
||||||
|
}
|
||||||
|
if alpha[1] != "narratio.artifact.alpha" || alpha[2] != pipelinePath || alpha[3] != "false" || alpha[4] != "artifacts/alpha.md" || alpha[5] != "zeta" {
|
||||||
|
t.Fatalf("alpha record = %#v", alpha)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigArtifactsEmptyCatalogWritesOnlyHeader(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||||
|
mustWriteTestFile(t, pipelinePath, "workspace:\n root: "+filepath.Join(dir, "workspace")+"\nwhisperx:\n transcribe_url: https://transcription.example.com/transcribe\n")
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath}, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.String() != artifactCatalogHeader {
|
||||||
|
t.Fatalf("empty catalog = %q, want header only", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigArtifactsReportsGeneratedFamilyProvenance(t *testing.T) {
|
||||||
|
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
records := catalogRecordsByKey(t, out.String())
|
||||||
|
meta := records["character_meta_arannis"]
|
||||||
|
note := records["character_note_arannis"]
|
||||||
|
partyPath := filepath.Join(filepath.Dir(campaignPath), "party.yml")
|
||||||
|
if len(meta) != 10 || meta[6] != "character_meta" || meta[7] != "arannis" || meta[8] != pipelinePath || meta[9] != partyPath {
|
||||||
|
t.Fatalf("generated meta record = %#v", meta)
|
||||||
|
}
|
||||||
|
if len(note) != 10 || note[5] != "character_meta_arannis" || note[6] != "character_note" {
|
||||||
|
t.Fatalf("generated note record = %#v", note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigArtifactsReportsBaseAndProfileContributors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||||
|
profilePath := filepath.Join(dir, "profiles", "testing.yml")
|
||||||
|
mustWriteTestFile(t, pipelinePath, `workspace:
|
||||||
|
root: /srv/narratio
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: https://transcription.example.com/transcribe
|
||||||
|
scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
notes:
|
||||||
|
enabled: false
|
||||||
|
output_path: artifacts/notes.md
|
||||||
|
`)
|
||||||
|
mustWriteTestFile(t, profilePath, `scriptorium:
|
||||||
|
artifacts:
|
||||||
|
notes:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: notes
|
||||||
|
profile_only:
|
||||||
|
enabled: false
|
||||||
|
output_path: artifacts/profile-only.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
records := catalogRecordsByKey(t, out.String())
|
||||||
|
if got := strings.Split(records["notes"][2], ","); !reflect.DeepEqual(got, []string{pipelinePath, profilePath}) {
|
||||||
|
t.Fatalf("notes definitions = %#v", got)
|
||||||
|
}
|
||||||
|
if got := records["profile_only"][2]; got != profilePath {
|
||||||
|
t.Fatalf("profile-only definition = %q, want %q", got, profilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigArtifactsIdentityDoesNotDependOnDefinitionFilename(t *testing.T) {
|
||||||
|
inlineDir := t.TempDir()
|
||||||
|
inlinePath := filepath.Join(inlineDir, "pipeline.yml")
|
||||||
|
mustWriteTestFile(t, inlinePath, artifactIdentityPipeline("", "recap"))
|
||||||
|
importDir := t.TempDir()
|
||||||
|
importPath := filepath.Join(importDir, "pipeline.yml")
|
||||||
|
mustWriteTestFile(t, importPath, artifactIdentityPipeline("artifacts.yml", ""))
|
||||||
|
mustWriteTestFile(t, filepath.Join(importDir, "artifacts.yml"), "scriptorium:\n artifacts:\n recap:\n enabled: false\n output_path: artifacts/recap.md\n")
|
||||||
|
|
||||||
|
for _, path := range []string{inlinePath, importPath} {
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := ConfigArtifacts(context.Background(), []string{"--config", path}, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := catalogRecordsByKey(t, out.String())["recap"][1]; got != "narratio.artifact.recap" {
|
||||||
|
t.Fatalf("source identity for %s = %q", path, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigArtifactsPropagatesWriterErrorsAndEscapesFields(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||||
|
mustWriteTestFile(t, pipelinePath, "workspace:\n root: /srv/narratio\nwhisperx:\n transcribe_url: https://transcription.example.com/transcribe\n")
|
||||||
|
want := errors.New("write rejected")
|
||||||
|
err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath}, rejectingCatalogWriter{err: want})
|
||||||
|
if !errors.Is(err, want) {
|
||||||
|
t.Fatalf("ConfigArtifacts() error = %v, want writer error", err)
|
||||||
|
}
|
||||||
|
if got := escapeCatalogField("a\\b\tc\nd\r"); got != `a\\b\tc\nd\r` {
|
||||||
|
t.Fatalf("escaped field = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogRecordsByKey(t *testing.T, output string) map[string][]string {
|
||||||
|
t.Helper()
|
||||||
|
lines := strings.Split(strings.TrimSuffix(output, "\n"), "\n")
|
||||||
|
if len(lines) == 0 || lines[0]+"\n" != artifactCatalogHeader {
|
||||||
|
t.Fatalf("catalog header = %q", output)
|
||||||
|
}
|
||||||
|
records := make(map[string][]string, len(lines)-1)
|
||||||
|
for _, line := range lines[1:] {
|
||||||
|
fields := strings.Split(line, "\t")
|
||||||
|
records[fields[0]] = fields
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
func artifactIdentityPipeline(importPath, inlineKey string) string {
|
||||||
|
document := "workspace:\n root: /srv/narratio\nwhisperx:\n transcribe_url: https://transcription.example.com/transcribe\nscriptorium:\n binary: scriptorium\n"
|
||||||
|
if importPath != "" {
|
||||||
|
document = "configkit:\n imports:\n - path: " + importPath + "\n" + document
|
||||||
|
}
|
||||||
|
if inlineKey != "" {
|
||||||
|
document += " artifacts:\n " + inlineKey + ":\n enabled: false\n output_path: artifacts/recap.md\n"
|
||||||
|
}
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
|
||||||
|
type rejectingCatalogWriter struct{ err error }
|
||||||
|
|
||||||
|
func (w rejectingCatalogWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
@@ -36,7 +37,7 @@ func Config(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
func configWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error {
|
func configWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error {
|
||||||
deps = normalizeCommandDependencies(deps)
|
deps = normalizeCommandDependencies(deps)
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return fmt.Errorf("config: expected subcommand: validate|show|sources|diff")
|
return fmt.Errorf("config: expected subcommand: validate|show|sources|artifacts|diff")
|
||||||
}
|
}
|
||||||
if args[0] == "--help" || args[0] == "-h" {
|
if args[0] == "--help" || args[0] == "-h" {
|
||||||
writeConfigCommandUsage(out)
|
writeConfigCommandUsage(out)
|
||||||
@@ -49,6 +50,8 @@ func configWithDependencies(ctx context.Context, args []string, out io.Writer, d
|
|||||||
return configShowWithDependencies(ctx, args[1:], out, deps)
|
return configShowWithDependencies(ctx, args[1:], out, deps)
|
||||||
case "sources":
|
case "sources":
|
||||||
return configSourcesWithDependencies(ctx, args[1:], out, deps)
|
return configSourcesWithDependencies(ctx, args[1:], out, deps)
|
||||||
|
case "artifacts":
|
||||||
|
return configArtifactsWithDependencies(ctx, args[1:], out, deps)
|
||||||
case "diff":
|
case "diff":
|
||||||
return configDiffWithDependencies(ctx, args[1:], out, deps)
|
return configDiffWithDependencies(ctx, args[1:], out, deps)
|
||||||
default:
|
default:
|
||||||
@@ -140,6 +143,70 @@ func configSourcesWithDependencies(_ context.Context, args []string, out io.Writ
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfigArtifacts validates and reports the effective concrete artifact catalog
|
||||||
|
// without loading session state or runtime collaborators.
|
||||||
|
func ConfigArtifacts(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
return configArtifactsWithDependencies(ctx, args, out, commandDependencies{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func configArtifactsWithDependencies(_ context.Context, args []string, out io.Writer, deps commandDependencies) error {
|
||||||
|
flags, err := parseInspectionFlags("config artifacts", args, out)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolved, err := resolveInspectionConfigWithDependencies(flags, deps)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config artifacts: %w", err)
|
||||||
|
}
|
||||||
|
records, err := config.EffectiveArtifactCatalog(resolved.Pipeline, resolved.Party)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config artifacts: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(out, "key\tsource\tdefinitions\tenabled\toutput\tdependencies\tfamily\tcharacter\tfamily_source\tparty_source"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, record := range records {
|
||||||
|
fields := []string{
|
||||||
|
record.Key,
|
||||||
|
record.SourceID,
|
||||||
|
strings.Join(normalizedCatalogPaths(record.DefinitionSources), ","),
|
||||||
|
strconv.FormatBool(record.Enabled),
|
||||||
|
record.OutputPath,
|
||||||
|
strings.Join(record.Dependencies, ","),
|
||||||
|
record.FamilyKey,
|
||||||
|
record.CharacterID,
|
||||||
|
config.NormalizedConfigurationPath(record.FamilySource),
|
||||||
|
config.NormalizedConfigurationPath(record.PartySource),
|
||||||
|
}
|
||||||
|
for index := range fields {
|
||||||
|
fields[index] = escapeCatalogField(fields[index])
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(out, strings.Join(fields, "\t")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedCatalogPaths(paths []string) []string {
|
||||||
|
result := make([]string, len(paths))
|
||||||
|
for index, path := range paths {
|
||||||
|
result[index] = config.NormalizedConfigurationPath(path)
|
||||||
|
}
|
||||||
|
sort.Strings(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeCatalogField(value string) string {
|
||||||
|
value = strings.ReplaceAll(value, "\\", "\\\\")
|
||||||
|
value = strings.ReplaceAll(value, "\t", "\\t")
|
||||||
|
value = strings.ReplaceAll(value, "\r", "\\r")
|
||||||
|
return strings.ReplaceAll(value, "\n", "\\n")
|
||||||
|
}
|
||||||
|
|
||||||
func parseInspectionFlags(command string, args []string, out io.Writer) (inspectionFlags, error) {
|
func parseInspectionFlags(command string, args []string, out io.Writer) (inspectionFlags, error) {
|
||||||
fs := flag.NewFlagSet(command, flag.ContinueOnError)
|
fs := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||||
fs.SetOutput(out)
|
fs.SetOutput(out)
|
||||||
@@ -256,9 +323,9 @@ func inspectionProfile(pipeline *config.PipelineConfig) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeConfigCommandUsage(out io.Writer) {
|
func writeConfigCommandUsage(out io.Writer) {
|
||||||
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources|diff>")
|
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources|artifacts|diff>")
|
||||||
fmt.Fprintln(out)
|
fmt.Fprintln(out)
|
||||||
fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, or config diff to compare two profiles.")
|
fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, config artifacts to list concrete artifacts, or config diff to compare two profiles.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error {
|
func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error {
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ func TestConfigCommandHelpAndDispatch(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
code int
|
code int
|
||||||
}{
|
}{
|
||||||
{name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config <validate|show|sources|diff>"},
|
{name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config <validate|show|sources|artifacts|diff>"},
|
||||||
{name: "validate help", args: []string{"config", "validate", "--help"}, want: "Usage: narratio config validate"},
|
{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: "show help", args: []string{"config", "show", "--help"}, want: "Usage: narratio config show"},
|
||||||
{name: "sources help", args: []string{"config", "sources", "--help"}, want: "Usage: narratio config sources"},
|
{name: "sources help", args: []string{"config", "sources", "--help"}, want: "Usage: narratio config sources"},
|
||||||
|
{name: "artifacts help", args: []string{"config", "artifacts", "--help"}, want: "Usage: narratio config artifacts"},
|
||||||
{name: "diff help", args: []string{"config", "diff", "--help"}, want: "Usage: narratio config diff"},
|
{name: "diff help", args: []string{"config", "diff", "--help"}, want: "Usage: narratio config diff"},
|
||||||
{name: "unknown", args: []string{"config", "unknown"}, want: `config: unknown subcommand "unknown"`, code: 1},
|
{name: "unknown", args: []string{"config", "unknown"}, want: `config: unknown subcommand "unknown"`, code: 1},
|
||||||
{name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show|sources|diff", code: 1},
|
{name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show|sources|artifacts|diff", code: 1},
|
||||||
{name: "session flag", args: []string{"config", "validate", "--session", "session.yml"}, want: "flag provided but not defined", 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: "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: "force flag", args: []string{"config", "show", "--force"}, want: "flag provided but not defined", code: 1},
|
||||||
@@ -53,7 +54,7 @@ func TestExecuteConfigUsesInvocationPipelineLoader(t *testing.T) {
|
|||||||
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||||
mustWriteTestFile(t, pipelinePath, "workspace:\n root: "+filepath.Join(dir, "workspace")+"\nwhisperx:\n transcribe_url: https://transcription.example.com/transcribe\n")
|
mustWriteTestFile(t, pipelinePath, "workspace:\n root: "+filepath.Join(dir, "workspace")+"\nwhisperx:\n transcribe_url: https://transcription.example.com/transcribe\n")
|
||||||
|
|
||||||
for _, subcommand := range []string{"validate", "show", "sources"} {
|
for _, subcommand := range []string{"validate", "show", "sources", "artifacts"} {
|
||||||
t.Run(subcommand, func(t *testing.T) {
|
t.Run(subcommand, func(t *testing.T) {
|
||||||
calls := 0
|
calls := 0
|
||||||
deps := commandDependencies{
|
deps := commandDependencies{
|
||||||
@@ -336,11 +337,15 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
|
|||||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &sources); err != nil {
|
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &sources); err != nil {
|
||||||
t.Fatalf("config sources: %v", err)
|
t.Fatalf("config sources: %v", err)
|
||||||
}
|
}
|
||||||
|
var catalog bytes.Buffer
|
||||||
|
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &catalog); err != nil {
|
||||||
|
t.Fatalf("config artifacts: %v", err)
|
||||||
|
}
|
||||||
var diff bytes.Buffer
|
var diff bytes.Buffer
|
||||||
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diff); err != nil {
|
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diff); err != nil {
|
||||||
t.Fatalf("config diff: %v", err)
|
t.Fatalf("config diff: %v", err)
|
||||||
}
|
}
|
||||||
for label, output := range map[string]string{"show": show.String(), "sources": sources.String(), "diff": diff.String()} {
|
for label, output := range map[string]string{"show": show.String(), "sources": sources.String(), "artifacts": catalog.String(), "diff": diff.String()} {
|
||||||
if strings.Contains(output, firstCanary) || strings.Contains(output, secondCanary) {
|
if strings.Contains(output, firstCanary) || strings.Contains(output, secondCanary) {
|
||||||
t.Fatalf("%s output exposed credential value: %q", label, output)
|
t.Fatalf("%s output exposed credential value: %q", label, output)
|
||||||
}
|
}
|
||||||
@@ -364,7 +369,7 @@ func TestConfigCommandsInspectMaintainedSplitBundleWithoutRuntimeState(t *testin
|
|||||||
t.Fatalf("example workspace stat = %v, want absent", err)
|
t.Fatalf("example workspace stat = %v, want absent", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow, ConfigSources} {
|
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow, ConfigSources, ConfigArtifacts} {
|
||||||
if err := command(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "production"}, io.Discard); err != nil {
|
if err := command(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "production"}, io.Discard); err != nil {
|
||||||
t.Fatalf("inspection command %T error = %v", command, err)
|
t.Fatalf("inspection command %T error = %v", command, err)
|
||||||
}
|
}
|
||||||
@@ -423,7 +428,7 @@ func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) {
|
|||||||
func TestConfigCommandsExpandFamiliesAndRequireCampaign(t *testing.T) {
|
func TestConfigCommandsExpandFamiliesAndRequireCampaign(t *testing.T) {
|
||||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||||
|
|
||||||
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow} {
|
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow, ConfigArtifacts} {
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := command(context.Background(), []string{"--config", pipelinePath}, &out)
|
err := command(context.Background(), []string{"--config", pipelinePath}, &out)
|
||||||
if err == nil || !strings.Contains(err.Error(), "artifact_families requires a campaign") {
|
if err == nil || !strings.Contains(err.Error(), "artifact_families requires a campaign") {
|
||||||
|
|||||||
132
internal/config/artifact_catalog.go
Normal file
132
internal/config/artifact_catalog.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EffectiveArtifactRecord describes one concrete artifact after configuration
|
||||||
|
// resolution and family expansion.
|
||||||
|
type EffectiveArtifactRecord struct {
|
||||||
|
Key string
|
||||||
|
SourceID string
|
||||||
|
DefinitionSources []string
|
||||||
|
Enabled bool
|
||||||
|
OutputPath string
|
||||||
|
Dependencies []string
|
||||||
|
FamilyKey string
|
||||||
|
CharacterID string
|
||||||
|
FamilySource string
|
||||||
|
PartySource string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveArtifactCatalog returns the deterministic concrete artifact catalog
|
||||||
|
// consumed by runtime planning.
|
||||||
|
func EffectiveArtifactCatalog(cfg *PipelineConfig, party ResolvedParty) ([]EffectiveArtifactRecord, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, fmt.Errorf("pipeline config is required")
|
||||||
|
}
|
||||||
|
if cfg.Scriptorium == nil || len(cfg.Scriptorium.Artifacts) == 0 {
|
||||||
|
return []EffectiveArtifactRecord{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(cfg.Scriptorium.Artifacts))
|
||||||
|
for key := range cfg.Scriptorium.Artifacts {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
records := make([]EffectiveArtifactRecord, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
artifact := cfg.Scriptorium.Artifacts[key]
|
||||||
|
record := EffectiveArtifactRecord{
|
||||||
|
Key: key,
|
||||||
|
SourceID: artifactpolicy.ConfiguredSourceID(key),
|
||||||
|
Enabled: artifact.Enabled,
|
||||||
|
OutputPath: artifact.OutputPath,
|
||||||
|
Dependencies: append([]string(nil), artifact.DependsOn...),
|
||||||
|
}
|
||||||
|
if member, ok := cfg.familyCatalog.Members[key]; ok {
|
||||||
|
record.DefinitionSources = nonEmptySortedStrings([]string{member.Source})
|
||||||
|
record.FamilyKey = member.Family
|
||||||
|
record.CharacterID = member.CharacterID
|
||||||
|
record.FamilySource = member.Source
|
||||||
|
record.PartySource = party.Source.Path
|
||||||
|
} else {
|
||||||
|
record.DefinitionSources = artifactDefinitionSources(cfg, key)
|
||||||
|
}
|
||||||
|
record.Dependencies = nonEmptySortedStrings(record.Dependencies)
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func artifactDefinitionSources(cfg *PipelineConfig, key string) []string {
|
||||||
|
if cfg.resolution == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pathsByID := make(map[string]string, len(cfg.resolution.configkitSources))
|
||||||
|
for _, source := range cfg.resolution.configkitSources {
|
||||||
|
path := source.Path
|
||||||
|
if path == "" {
|
||||||
|
path = source.Label
|
||||||
|
}
|
||||||
|
pathsByID[source.ID] = path
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
declaration := configkit.Keys("scriptorium", "artifacts", key)
|
||||||
|
for _, change := range cfg.resolution.configkitMetadata.History(declaration) {
|
||||||
|
if change.Origin.Layer != configkit.LayerBase && change.Origin.Layer != configkit.LayerProfile {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, location := range change.Origin.Locations {
|
||||||
|
source := pathsByID[location.SourceID]
|
||||||
|
if source == "" {
|
||||||
|
source = location.SourceID
|
||||||
|
}
|
||||||
|
seen[source] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seen) == 0 {
|
||||||
|
prefix := "scriptorium.artifacts." + key
|
||||||
|
for _, ownership := range cfg.resolution.ownership {
|
||||||
|
if ownership.path != prefix && !strings.HasPrefix(ownership.path, prefix+".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, source := range ownership.sources {
|
||||||
|
if source != pipelineDefaultOwnershipSource && source != "resolution" {
|
||||||
|
seen[source] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seen) == 0 && cfg.resolution.rootPath != "" {
|
||||||
|
seen[cfg.resolution.rootPath] = struct{}{}
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(seen))
|
||||||
|
for source := range seen {
|
||||||
|
result = append(result, source)
|
||||||
|
}
|
||||||
|
sort.Strings(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonEmptySortedStrings(values []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value != "" {
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(seen))
|
||||||
|
for value := range seen {
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
sort.Strings(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user