diff --git a/internal/cli/run.go b/internal/cli/run.go index a931c07..1a3113b 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -1,14 +1,38 @@ package cli import ( + "encoding/json" + "flag" "fmt" "io" + "os" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/core/config" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) -const usage = "Usage:\n notarius help\n" +const defaultConfigPath = "/usr/local/etc/notarius/config.yml" + +const usage = `Usage: + notarius help + notarius config validate --config path/to/config.yml [--pipeline pipeline-id] [--only lane-a,lane-b] + notarius pipelines list --config path/to/config.yml [--json] +` + +type Options struct { + Catalog pipeline.ModuleCatalog + LookupEnv func(string) (string, bool) +} // Run executes the command-line interface and returns a process exit code. func Run(args []string, stdout, stderr io.Writer) int { + return RunWithOptions(args, stdout, stderr, Options{}) +} + +func RunWithOptions(args []string, stdout, stderr io.Writer, opts Options) int { + opts = normalizeOptions(opts) if len(args) == 0 { writeUsage(stdout) return 0 @@ -18,6 +42,10 @@ func Run(args []string, stdout, stderr io.Writer) int { case "help", "--help", "-h": writeUsage(stdout) return 0 + case "config": + return runConfig(args[1:], stdout, stderr, opts) + case "pipelines": + return runPipelines(args[1:], stdout, stderr, opts) default: fmt.Fprintf(stderr, "notarius: unknown command %q\n", args[0]) writeUsage(stderr) @@ -28,3 +56,208 @@ func Run(args []string, stdout, stderr io.Writer) int { func writeUsage(w io.Writer) { fmt.Fprint(w, usage) } + +func normalizeOptions(opts Options) Options { + if opts.LookupEnv == nil { + opts.LookupEnv = os.LookupEnv + } + return opts +} + +func runConfig(args []string, stdout, stderr io.Writer, opts Options) int { + if len(args) == 0 { + fmt.Fprintln(stderr, "notarius: config requires a subcommand") + writeUsage(stderr) + return 2 + } + switch args[0] { + case "validate": + return runConfigValidate(args[1:], stdout, stderr, opts) + default: + fmt.Fprintf(stderr, "notarius: unknown config subcommand %q\n", args[0]) + writeUsage(stderr) + return 2 + } +} + +func runConfigValidate(args []string, stdout, stderr io.Writer, opts Options) int { + fs := flag.NewFlagSet("config validate", flag.ContinueOnError) + fs.SetOutput(io.Discard) + configPath := fs.String("config", "", "config file path") + pipelineID := fs.String("pipeline", "", "pipeline ID") + onlyRaw := fs.String("only", "", "comma-separated artifact lanes") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(stderr, "notarius: %v\n", err) + return 2 + } + if fs.NArg() != 0 { + fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(0)) + return 2 + } + if strings.TrimSpace(*onlyRaw) != "" && strings.TrimSpace(*pipelineID) == "" { + fmt.Fprintln(stderr, "notarius: --only requires --pipeline") + return 2 + } + + cfg, path, err := loadConfig(*configPath, opts) + if err != nil { + fmt.Fprintf(stderr, "notarius: %v\n", err) + return 1 + } + + if strings.TrimSpace(*pipelineID) != "" { + if _, err := cfg.Resolve(config.ResolveInput{ + PipelineID: *pipelineID, + Only: parseOnly(*onlyRaw), + Catalog: opts.Catalog, + }); err != nil { + fmt.Fprintf(stderr, "notarius: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "config %q is valid for pipeline %q\n", path, strings.TrimSpace(*pipelineID)) + return 0 + } + + if err := cfg.Validate(); err != nil { + fmt.Fprintf(stderr, "notarius: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "config %q is valid\n", path) + return 0 +} + +func runPipelines(args []string, stdout, stderr io.Writer, opts Options) int { + if len(args) == 0 { + fmt.Fprintln(stderr, "notarius: pipelines requires a subcommand") + writeUsage(stderr) + return 2 + } + switch args[0] { + case "list": + return runPipelinesList(args[1:], stdout, stderr, opts) + default: + fmt.Fprintf(stderr, "notarius: unknown pipelines subcommand %q\n", args[0]) + writeUsage(stderr) + return 2 + } +} + +func runPipelinesList(args []string, stdout, stderr io.Writer, opts Options) int { + fs := flag.NewFlagSet("pipelines list", flag.ContinueOnError) + fs.SetOutput(io.Discard) + configPath := fs.String("config", "", "config file path") + jsonOutput := fs.Bool("json", false, "write JSON output") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(stderr, "notarius: %v\n", err) + return 2 + } + if fs.NArg() != 0 { + fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(0)) + return 2 + } + + cfg, _, err := loadConfig(*configPath, opts) + if err != nil { + fmt.Fprintf(stderr, "notarius: %v\n", err) + return 1 + } + if err := cfg.Validate(); err != nil { + fmt.Fprintf(stderr, "notarius: %v\n", err) + return 1 + } + + ids := sortedPipelineIDs(cfg) + if *jsonOutput { + payload := struct { + Pipelines []string `json:"pipelines"` + }{Pipelines: ids} + encoded, err := json.Marshal(payload) + if err != nil { + fmt.Fprintf(stderr, "notarius: marshal pipeline list: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "%s\n", encoded) + return 0 + } + + for _, id := range ids { + fmt.Fprintln(stdout, id) + } + return 0 +} + +func loadConfig(configPath string, opts Options) (config.Config, string, error) { + path, err := discoverConfigPath(configPath, opts) + if err != nil { + return config.Config{}, "", err + } + + fileCfg, err := config.LoadFileConfig(path) + if err != nil { + return config.Config{}, "", err + } + cfg := config.Default() + if err := cfg.ApplyFileConfigWithLookup(fileCfg, opts.LookupEnv); err != nil { + return config.Config{}, "", err + } + if err := cfg.ApplyEnvOverridesWithLookup(opts.LookupEnv); err != nil { + return config.Config{}, "", err + } + return cfg, path, nil +} + +func discoverConfigPath(configPath string, opts Options) (string, error) { + if path := strings.TrimSpace(configPath); path != "" { + if err := requireConfigFile(path); err != nil { + return "", err + } + return path, nil + } + if path, ok := opts.LookupEnv("NOTARIUS_CONFIG"); ok && strings.TrimSpace(path) != "" { + path = strings.TrimSpace(path) + if err := requireConfigFile(path); err != nil { + return "", err + } + return path, nil + } + if _, err := os.Stat(defaultConfigPath); err == nil { + return defaultConfigPath, nil + } else if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("check default config %q: %w", defaultConfigPath, err) + } + return "", fmt.Errorf("config file not found; pass --config or set NOTARIUS_CONFIG") +} + +func requireConfigFile(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("config file %q is not available: %w", path, err) + } + if info.IsDir() { + return fmt.Errorf("config file %q is a directory", path) + } + return nil +} + +func parseOnly(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func sortedPipelineIDs(cfg config.Config) []string { + ids := make([]string, 0, len(cfg.Pipelines)) + for id := range cfg.Pipelines { + ids = append(ids, strings.TrimSpace(id)) + } + sort.Strings(ids) + return ids +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 3d200fe..c240a8a 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -2,8 +2,13 @@ package cli import ( "bytes" + "os" + "path/filepath" "strings" "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) func TestRunNoArgsWritesUsageToStdout(t *testing.T) { @@ -46,6 +51,9 @@ func TestRunHelpArgsWriteUsageToStdout(t *testing.T) { if stdout.String() != usage { t.Fatalf("stdout = %q, want %q", stdout.String(), usage) } + if !strings.Contains(stdout.String(), "config validate") || !strings.Contains(stdout.String(), "pipelines list") { + t.Fatalf("usage does not mention new commands: %q", stdout.String()) + } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } @@ -73,3 +81,326 @@ func TestRunUnknownCommandWritesErrorAndUsageToStderr(t *testing.T) { t.Fatalf("stderr = %q, want usage", gotStderr) } } + +func TestRunConfigValidateSuccessWithFakeCatalog(t *testing.T) { + configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes")) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example"}, &stdout, &stderr, Options{ + Catalog: fakeCatalog(t), + }) + + if code != 0 { + t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "is valid for pipeline") { + t.Fatalf("stdout = %q, want validation success", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRunConfigValidateReportsParseErrors(t *testing.T) { + configPath := writeFile(t, "config.yml", "version: 2\n") + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"config", "validate", "--config", configPath}, &stdout, &stderr, Options{}) + + if code != 1 { + t.Fatalf("RunWithOptions() code = %d, want 1", code) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "unsupported config version") { + t.Fatalf("stderr = %q, want parse error", stderr.String()) + } +} + +func TestRunConfigValidatePipelineOnlySuccessAndInvalidLane(t *testing.T) { + configPath := writeTestConfig(t, testConfigYAML("example", "events", "notes")) + + t.Run("success", func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", "notes"}, &stdout, &stderr, Options{ + Catalog: fakeCatalog(t), + }) + + if code != 0 { + t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) + } + }) + + t.Run("invalid lane", func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example", "--only", "missing"}, &stdout, &stderr, Options{ + Catalog: fakeCatalog(t), + }) + + if code != 1 { + t.Fatalf("RunWithOptions() code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "selected artifact lane") { + t.Fatalf("stderr = %q, want invalid lane error", stderr.String()) + } + }) +} + +func TestRunConfigValidateOnlyWithoutPipelineFails(t *testing.T) { + configPath := writeTestConfig(t, testConfigYAML("example", "events")) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--only", "events"}, &stdout, &stderr, Options{}) + + if code != 2 { + t.Fatalf("RunWithOptions() code = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "--only requires --pipeline") { + t.Fatalf("stderr = %q, want only/pipeline error", stderr.String()) + } +} + +func TestRunPipelinesListSortedTextOutput(t *testing.T) { + configPath := writeTestConfig(t, testConfigYAMLForPipelines(map[string][]string{ + "zeta": {"events"}, + "alpha": {"events"}, + })) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, Options{}) + + if code != 0 { + t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) + } + if got, want := stdout.String(), "alpha\nzeta\n"; got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + +func TestRunPipelinesListStableJSONOutput(t *testing.T) { + configPath := writeTestConfig(t, testConfigYAMLForPipelines(map[string][]string{ + "b": {"events"}, + "a": {"events"}, + })) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"pipelines", "list", "--config", configPath, "--json"}, &stdout, &stderr, Options{}) + + if code != 0 { + t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) + } + if got, want := stdout.String(), "{\"pipelines\":[\"a\",\"b\"]}\n"; got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + +func TestRunUsesNotariusConfigWhenConfigFlagAbsent(t *testing.T) { + configPath := writeTestConfig(t, testConfigYAML("example", "events")) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"pipelines", "list"}, &stdout, &stderr, Options{ + LookupEnv: mapLookup(map[string]string{"NOTARIUS_CONFIG": configPath}), + }) + + if code != 0 { + t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) + } + if got, want := stdout.String(), "example\n"; got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + +func TestRunConfigValidateResolvesAPIKeyEnvThroughOptions(t *testing.T) { + configPath := writeTestConfig(t, `version: 1 +llm_profiles: + default: + api_key_env: NOTARIUS_TEST_API_KEY +pipelines: + example: + input: fake/input + artifacts: + events: + extract: fake/extract +`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"config", "validate", "--config", configPath}, &stdout, &stderr, Options{ + LookupEnv: mapLookup(map[string]string{"NOTARIUS_TEST_API_KEY": "secret"}), + }) + + if code != 0 { + t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) + } +} + +func TestRunMissingConfigPathProducesActionableError(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"config", "validate", "--config", filepath.Join(t.TempDir(), "missing.yml")}, &stdout, &stderr, Options{}) + + if code != 1 { + t.Fatalf("RunWithOptions() code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "config file") || !strings.Contains(stderr.String(), "not available") { + t.Fatalf("stderr = %q, want actionable missing config error", stderr.String()) + } +} + +func TestRunRejectsAdHocStructuralFlags(t *testing.T) { + configPath := writeTestConfig(t, testConfigYAML("example", "events")) + flags := []string{"--extractor", "--chunker", "--input", "--merge", "--normalize"} + + for _, flagName := range flags { + t.Run(flagName, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"config", "validate", "--config", configPath, flagName, "value"}, &stdout, &stderr, Options{}) + + if code != 2 { + t.Fatalf("RunWithOptions() code = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "flag provided but not defined") { + t.Fatalf("stderr = %q, want invalid flag error", stderr.String()) + } + }) + } +} + +func TestRunInvalidFlagsExitTwo(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunWithOptions([]string{"pipelines", "list", "--bogus"}, &stdout, &stderr, Options{}) + + if code != 2 { + t.Fatalf("RunWithOptions() code = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "flag provided but not defined") { + t.Fatalf("stderr = %q, want invalid flag error", stderr.String()) + } +} + +func writeTestConfig(t *testing.T, content string) string { + t.Helper() + return writeFile(t, "config.yml", content) +} + +func writeFile(t *testing.T, name string, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + return path +} + +func testConfigYAML(pipelineID string, laneIDs ...string) string { + return testConfigYAMLForPipelines(map[string][]string{pipelineID: laneIDs}) +} + +func testConfigYAMLForPipelines(pipelines map[string][]string) string { + var b strings.Builder + b.WriteString("version: 1\n") + b.WriteString("pipelines:\n") + for pipelineID, laneIDs := range pipelines { + b.WriteString(" " + pipelineID + ":\n") + b.WriteString(" input: fake/input\n") + b.WriteString(" artifacts:\n") + for _, laneID := range laneIDs { + b.WriteString(" " + laneID + ":\n") + b.WriteString(" extract: fake/extract\n") + } + } + return b.String() +} + +func fakeCatalog(t *testing.T) pipeline.ModuleCatalog { + t.Helper() + inputs := pipeline.NewInputAdapterRegistry() + chunkers := pipeline.NewChunkerRegistry() + extractors := pipeline.NewExtractorRegistry() + mergers := pipeline.NewMergerRegistry() + normalizers := pipeline.NewNormalizerRegistry() + validators := pipeline.NewValidatorRegistry() + outputs := pipeline.NewOutputEncoderRegistry() + + mustRegisterInput(t, inputs, pipeline.ModuleSpec{Key: "fake/input", Stage: pipeline.StageInput, Provides: []string{"source"}}) + mustRegisterChunker(t, chunkers, pipeline.ModuleSpec{Key: "generic", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}}) + mustRegisterExtractor(t, extractors, pipeline.ModuleSpec{Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}}) + mustRegisterMerger(t, mergers, pipeline.ModuleSpec{Key: "appendorder", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}}) + mustRegisterNormalizer(t, normalizers, pipeline.ModuleSpec{Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}}) + mustRegisterOutput(t, outputs, pipeline.ModuleSpec{Key: "json", Stage: pipeline.StageOutput, Requires: []string{"normalized"}}) + + return pipeline.ModuleCatalog{ + Inputs: inputs, + Chunkers: chunkers, + Extractors: extractors, + Mergers: mergers, + Normalizers: normalizers, + Validators: validators, + Outputs: outputs, + } +} + +func mustRegisterInput(t *testing.T, registry *pipeline.InputAdapterRegistry, spec pipeline.ModuleSpec) { + t.Helper() + if err := registry.RegisterWithSpec(spec, func() (contracts.InputAdapter, error) { return nil, nil }); err != nil { + t.Fatalf("register input: %v", err) + } +} + +func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec pipeline.ModuleSpec) { + t.Helper() + if err := registry.RegisterWithSpec(spec, func() (contracts.Chunker, error) { return nil, nil }); err != nil { + t.Fatalf("register chunker: %v", err) + } +} + +func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) { + t.Helper() + if err := registry.RegisterWithSpec(spec, func() (contracts.Extractor, error) { return nil, nil }); err != nil { + t.Fatalf("register extractor: %v", err) + } +} + +func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) { + t.Helper() + if err := registry.RegisterWithSpec(spec, func() (contracts.Merger, error) { return nil, nil }); err != nil { + t.Fatalf("register merger: %v", err) + } +} + +func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) { + t.Helper() + if err := registry.RegisterWithSpec(spec, func() (contracts.Normalizer, error) { return nil, nil }); err != nil { + t.Fatalf("register normalizer: %v", err) + } +} + +func mustRegisterOutput(t *testing.T, registry *pipeline.OutputEncoderRegistry, spec pipeline.ModuleSpec) { + t.Helper() + if err := registry.RegisterWithSpec(spec, func() (contracts.OutputEncoder, error) { return nil, nil }); err != nil { + t.Fatalf("register output: %v", err) + } +} + +func mapLookup(values map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + value, ok := values[key] + return value, ok + } +} diff --git a/internal/core/config/env.go b/internal/core/config/env.go index c0de9ee..6000654 100644 --- a/internal/core/config/env.go +++ b/internal/core/config/env.go @@ -22,6 +22,10 @@ func (c *Config) ApplyEnvOverrides() error { return c.applyEnvOverridesWithLookup(os.LookupEnv) } +func (c *Config) ApplyEnvOverridesWithLookup(lookup func(string) (string, bool)) error { + return c.applyEnvOverridesWithLookup(lookup) +} + func (c *Config) applyEnvOverridesWithLookup(lookup func(string) (string, bool)) error { if c == nil { return fmt.Errorf("config must not be nil") diff --git a/internal/core/config/file_config.go b/internal/core/config/file_config.go index 469031d..5187587 100644 --- a/internal/core/config/file_config.go +++ b/internal/core/config/file_config.go @@ -180,6 +180,10 @@ func (c *Config) ApplyFileConfig(fileCfg FileConfig) error { return c.applyFileConfigWithLookup(fileCfg, os.LookupEnv) } +func (c *Config) ApplyFileConfigWithLookup(fileCfg FileConfig, lookup func(string) (string, bool)) error { + return c.applyFileConfigWithLookup(fileCfg, lookup) +} + func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(string) (string, bool)) error { if c == nil { return fmt.Errorf("config must not be nil")