Add CLI config validation commands
This commit is contained in:
@@ -1,14 +1,38 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"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.
|
// Run executes the command-line interface and returns a process exit code.
|
||||||
func Run(args []string, stdout, stderr io.Writer) int {
|
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 {
|
if len(args) == 0 {
|
||||||
writeUsage(stdout)
|
writeUsage(stdout)
|
||||||
return 0
|
return 0
|
||||||
@@ -18,6 +42,10 @@ func Run(args []string, stdout, stderr io.Writer) int {
|
|||||||
case "help", "--help", "-h":
|
case "help", "--help", "-h":
|
||||||
writeUsage(stdout)
|
writeUsage(stdout)
|
||||||
return 0
|
return 0
|
||||||
|
case "config":
|
||||||
|
return runConfig(args[1:], stdout, stderr, opts)
|
||||||
|
case "pipelines":
|
||||||
|
return runPipelines(args[1:], stdout, stderr, opts)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(stderr, "notarius: unknown command %q\n", args[0])
|
fmt.Fprintf(stderr, "notarius: unknown command %q\n", args[0])
|
||||||
writeUsage(stderr)
|
writeUsage(stderr)
|
||||||
@@ -28,3 +56,208 @@ func Run(args []string, stdout, stderr io.Writer) int {
|
|||||||
func writeUsage(w io.Writer) {
|
func writeUsage(w io.Writer) {
|
||||||
fmt.Fprint(w, usage)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunNoArgsWritesUsageToStdout(t *testing.T) {
|
func TestRunNoArgsWritesUsageToStdout(t *testing.T) {
|
||||||
@@ -46,6 +51,9 @@ func TestRunHelpArgsWriteUsageToStdout(t *testing.T) {
|
|||||||
if stdout.String() != usage {
|
if stdout.String() != usage {
|
||||||
t.Fatalf("stdout = %q, want %q", 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 {
|
if stderr.Len() != 0 {
|
||||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
}
|
}
|
||||||
@@ -73,3 +81,326 @@ func TestRunUnknownCommandWritesErrorAndUsageToStderr(t *testing.T) {
|
|||||||
t.Fatalf("stderr = %q, want usage", gotStderr)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ func (c *Config) ApplyEnvOverrides() error {
|
|||||||
return c.applyEnvOverridesWithLookup(os.LookupEnv)
|
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 {
|
func (c *Config) applyEnvOverridesWithLookup(lookup func(string) (string, bool)) error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("config must not be nil")
|
return fmt.Errorf("config must not be nil")
|
||||||
|
|||||||
@@ -180,6 +180,10 @@ func (c *Config) ApplyFileConfig(fileCfg FileConfig) error {
|
|||||||
return c.applyFileConfigWithLookup(fileCfg, os.LookupEnv)
|
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 {
|
func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(string) (string, bool)) error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("config must not be nil")
|
return fmt.Errorf("config must not be nil")
|
||||||
|
|||||||
Reference in New Issue
Block a user