Files
notarius/internal/cli/run.go

473 lines
13 KiB
Go

package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
const defaultConfigPath = "/usr/local/etc/notarius/config.yml"
const usage = `Usage:
notarius help
notarius run <pipeline-id> --input path/to/source.json [--config path/to/config.yml] [--only lane-a,lane-b]
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
Registries pipeline.Registries
LLMClientFactory LLMClientFactory
LookupEnv func(string) (string, bool)
Now func() time.Time
}
type LLMClientFactory func(ctx context.Context, cfg config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error)
// 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
}
switch args[0] {
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)
case "run":
return runPipelineCommand(args[1:], stdout, stderr, opts)
default:
fmt.Fprintf(stderr, "notarius: unknown command %q\n", args[0])
writeUsage(stderr)
return 2
}
}
func writeUsage(w io.Writer) {
fmt.Fprint(w, usage)
}
func normalizeOptions(opts Options) Options {
if opts.LookupEnv == nil {
opts.LookupEnv = os.LookupEnv
}
if opts.Now == nil {
opts.Now = time.Now
}
if opts.LLMClientFactory == nil {
opts.LLMClientFactory = productionLLMClientFactory
}
return opts
}
func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) int {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
configPath := fs.String("config", "", "config file path")
inputPath := fs.String("input", "", "source input file path")
onlyRaw := fs.String("only", "", "comma-separated artifact lanes")
outputDir := fs.String("output-dir", "", "output directory")
diagnosticsDir := fs.String("diagnostics-dir", "", "diagnostics directory")
llmProfile := fs.String("llm-profile", "", "LLM profile override")
if err := fs.Parse(reorderRunArgs(args)); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 2
}
if fs.NArg() == 0 {
fmt.Fprintln(stderr, "notarius: run requires a pipeline ID")
return 2
}
if fs.NArg() > 1 {
fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(1))
return 2
}
pipelineID := strings.TrimSpace(fs.Arg(0))
if pipelineID == "" {
fmt.Fprintln(stderr, "notarius: run requires a pipeline ID")
return 2
}
if strings.TrimSpace(*inputPath) == "" {
fmt.Fprintln(stderr, "notarius: run requires --input")
return 2
}
only, err := parseOnly(*onlyRaw)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 2
}
cfg, _, err := loadConfig(*configPath, opts)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
if dir := strings.TrimSpace(*diagnosticsDir); dir != "" {
cfg.Diagnostics.WorkDir = dir
}
catalog, err := effectiveCatalog(opts)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
effective, err := cfg.Resolve(config.ResolveInput{
PipelineID: pipelineID,
Only: only,
Catalog: catalog,
LLMProfileOverride: *llmProfile,
})
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
profileIDs := effectiveLLMProfileIDs(effective.ResolvedPipeline)
if len(profileIDs) != 1 {
fmt.Fprintf(stderr, "notarius: pipeline %q uses %d distinct LLM profiles; current runs require exactly one: %s\n", pipelineID, len(profileIDs), strings.Join(profileIDs, ", "))
return 1
}
rawInput, err := os.ReadFile(strings.TrimSpace(*inputPath))
if err != nil {
fmt.Fprintf(stderr, "notarius: read input %q: %v\n", strings.TrimSpace(*inputPath), err)
return 1
}
registries, err := effectiveRegistries(opts)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
ctx := context.Background()
llmClient, llmProfiles, err := opts.LLMClientFactory(ctx, effective.Config, profileIDs[0])
if err != nil {
fmt.Fprintf(stderr, "notarius: create LLM client for profile %q: %v\n", profileIDs[0], err)
return 1
}
output, err := pipeline.New(registries).Run(ctx, pipeline.RunInput{
Pipeline: effective.ResolvedPipeline,
Path: strings.TrimSpace(*inputPath),
RawInput: rawInput,
LLMClient: llmClient,
StartedAt: opts.Now().UTC(),
LLMProfiles: llmProfiles,
Metadata: runMetadata(*outputDir, *diagnosticsDir),
})
if err != nil {
fmt.Fprintf(stderr, "notarius: run pipeline %q: %v\n", pipelineID, err)
return 1
}
fmt.Fprintf(stdout, "pipeline %q complete: approved=%d rejected=%d\n", effective.PipelineID, len(output.Approved), len(output.Rejected))
if len(output.Warnings) > 0 {
fmt.Fprintf(stderr, "notarius: run completed with %d warning(s)\n", len(output.Warnings))
}
return 0
}
func reorderRunArgs(args []string) []string {
var flags []string
var positionals []string
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
positionals = append(positionals, args[i+1:]...)
break
}
if strings.HasPrefix(arg, "-") {
flags = append(flags, arg)
if runFlagTakesValue(arg) && !strings.Contains(arg, "=") && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
continue
}
positionals = append(positionals, arg)
}
return append(flags, positionals...)
}
func runFlagTakesValue(arg string) bool {
switch arg {
case "--config", "--input", "--only", "--output-dir", "--diagnostics-dir", "--llm-profile":
return true
default:
return false
}
}
func effectiveLLMProfileIDs(resolved pipeline.ResolvedPipeline) []string {
seen := make(map[string]struct{})
add := func(binding pipeline.ModuleBinding) {
id := strings.TrimSpace(binding.LLMProfile)
if id != "" {
seen[id] = struct{}{}
}
}
add(resolved.Input)
add(resolved.Chunk)
add(resolved.Output)
for _, lane := range resolved.ArtifactLanes {
add(lane.Extract)
add(lane.Merge)
add(lane.Normalize)
for _, validator := range lane.Validators {
add(validator)
}
}
ids := make([]string, 0, len(seen))
for id := range seen {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
func runMetadata(outputDir, diagnosticsDir string) map[string]any {
metadata := make(map[string]any)
if dir := strings.TrimSpace(outputDir); dir != "" {
metadata["output_dir"] = dir
}
if dir := strings.TrimSpace(diagnosticsDir); dir != "" {
metadata["diagnostics_dir"] = dir
}
if len(metadata) == 0 {
return nil
}
return metadata
}
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
}
only, err := parseOnly(*onlyRaw)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 2
}
cfg, path, err := loadConfig(*configPath, opts)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
if strings.TrimSpace(*pipelineID) != "" {
catalog, err := effectiveCatalog(opts)
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
if _, err := cfg.Resolve(config.ResolveInput{
PipelineID: *pipelineID,
Only: only,
Catalog: 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, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, fmt.Errorf("--only must contain comma-separated non-empty artifact lane IDs")
}
result = append(result, trimmed)
}
return result, nil
}
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
}