package cli import ( "context" "encoding/json" "flag" "fmt" "io" "os" "path" "path/filepath" "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/core/diagnostics" "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 defaultOutputRoot = "./notarius-output" const usage = `Usage: notarius help notarius run --input path/to/source.json [--config path/to/config.yml] [--only lane-a,lane-b] [--reference slot=path] [--without-reference slot] 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") referenceFlags := stringListFlag{} withoutReferenceFlags := stringListFlag{} fs.Var(&referenceFlags, "reference", "reference binding, as slot=path or lane.slot=path") fs.Var(&withoutReferenceFlags, "without-reference", "unbind a reference, as slot or lane.slot") 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 } referenceRequests, err := parseReferenceFlags(referenceFlags) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 2 } referenceUnbindRequests, err := parseReferenceUnbindFlags(withoutReferenceFlags) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 2 } cfg, loadedConfigPath, 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 } startedAt := opts.Now().UTC() runDir, err := diagnostics.NewRunDirectory(cfg.Diagnostics.WorkDir, cfg.Diagnostics.Retention) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } invocation := diagnostics.InvocationMetadata{ Operation: "run", PipelineID: pipelineID, InputPath: strings.TrimSpace(*inputPath), ConfigPath: loadedConfigPath, ConfigSource: configSource(*configPath), OnlyLanes: append([]string(nil), only...), RunID: runDir.RunID(), StartedAt: startedAt, } if err := runDir.WriteInvocationMetadata(invocation); err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics invocation metadata: %w", err)) } catalog, err := effectiveCatalog(opts) if err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err) } referenceOverrides, referenceUnbinds, err := resolveCLIReferenceRequests(cfg, pipelineID, only, catalog, referenceRequests, referenceUnbindRequests) if err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err) } effective, err := cfg.Resolve(config.ResolveInput{ PipelineID: pipelineID, Only: only, Catalog: catalog, LLMProfileOverride: *llmProfile, ReferenceOverrides: referenceOverrides, ReferenceUnbinds: referenceUnbinds, }) if err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err) } workingDir, err := os.Getwd() if err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("resolve working directory: %w", err)) } materialized, referenceWarnings, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{ ConfigPath: loadedConfigPath, WorkingDir: workingDir, }) if err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err) } effective.ResolvedPipeline = materialized invocation.PipelineDigest = effective.ResolvedPipeline.Digest if err := runDir.WriteInvocationMetadata(invocation); err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics invocation metadata: %w", err)) } if err := runDir.WriteRedactedEffectiveConfig(effective); err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics effective config: %w", err)) } if err := runDir.WriteResolvedPipeline(effective.ResolvedPipeline); err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics resolved pipeline: %w", err)) } if err := runDir.WriteResolvedReferences(pipeline.ReferenceProvenance(effective.ResolvedPipeline)); err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics resolved references: %w", err)) } profileIDs := effectiveLLMProfileIDs(effective.ResolvedPipeline) if len(profileIDs) != 1 { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("pipeline %q uses %d distinct LLM profiles; current runs require exactly one: %s", pipelineID, len(profileIDs), strings.Join(profileIDs, ", "))) } rawInput, err := os.ReadFile(strings.TrimSpace(*inputPath)) if err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("read input %q: %w", strings.TrimSpace(*inputPath), err)) } registries, err := effectiveRegistries(opts) if err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err) } ctx := context.Background() llmClient, llmProfiles, err := opts.LLMClientFactory(ctx, effective.Config, profileIDs[0]) if err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("create LLM client for profile %q: %w", profileIDs[0], err)) } output, err := pipeline.New(registries).Run(ctx, pipeline.RunInput{ Pipeline: effective.ResolvedPipeline, Path: strings.TrimSpace(*inputPath), RawInput: rawInput, LLMClient: llmClient, RunID: runDir.RunID(), StartedAt: startedAt, LLMProfiles: llmProfiles, Metadata: runMetadata(*outputDir, *diagnosticsDir), Warnings: referenceWarnings, }) if err != nil { if output.Manifest.PipelineID != "" { _ = runDir.WriteRunManifest(output.Manifest) } return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("run pipeline %q: %w", pipelineID, err)) } runOutputDir := filepath.Join(outputRoot(*outputDir), runDir.RunID()) if err := runDir.WriteRunManifest(output.Manifest); err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics run manifest: %w", err)) } if err := runDir.WriteWarnings(output.Warnings); err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics warnings: %w", err)) } if err := runDir.WriteRunReport(runReport{ RunID: runDir.RunID(), PipelineID: effective.PipelineID, OutputPath: runOutputDir, DiagnosticsPath: runDir.Path(), ApprovedCount: len(output.Approved), RejectedCount: len(output.Rejected), WarningCount: len(output.Warnings), ValidationStatus: output.Manifest.ValidationStatus, }); err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics run report: %w", err)) } if err := writeOutputFiles(runOutputDir, output.OutputFiles); err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err) } if err := runDir.ApplyRetention(diagnostics.RetentionDecisionInput{ RetentionMode: cfg.Diagnostics.Retention, RunSucceeded: true, HasWarnings: len(output.Warnings) > 0, }); err != nil { return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("apply diagnostics retention: %w", err)) } fmt.Fprintf(stdout, "pipeline %q complete: approved=%d rejected=%d output=%s\n", effective.PipelineID, len(output.Approved), len(output.Rejected), runOutputDir) if len(output.Warnings) > 0 { fmt.Fprintf(stderr, "notarius: run completed with %d warning(s)\n", len(output.Warnings)) } return 0 } type runReport struct { RunID string `json:"run_id"` PipelineID string `json:"pipeline_id"` OutputPath string `json:"output_path"` DiagnosticsPath string `json:"diagnostics_path,omitempty"` ApprovedCount int `json:"approved_count"` RejectedCount int `json:"rejected_count"` WarningCount int `json:"warning_count"` ValidationStatus string `json:"validation_status,omitempty"` } func failPipelineCommand(stderr io.Writer, runDir *diagnostics.RunDirectory, retention diagnostics.RetentionMode, err error) int { fmt.Fprintf(stderr, "notarius: %v\n", err) if runDir != nil { if logErr := runDir.WriteErrorLog(err.Error()); logErr != nil { fmt.Fprintf(stderr, "notarius: write diagnostics error log: %v\n", logErr) } if retentionErr := runDir.ApplyRetention(diagnostics.RetentionDecisionInput{ RetentionMode: retention, RunSucceeded: false, }); retentionErr != nil { fmt.Fprintf(stderr, "notarius: apply diagnostics retention: %v\n", retentionErr) } } return 1 } func configSource(configPath string) string { if strings.TrimSpace(configPath) != "" { return "flag" } return "discovered" } func outputRoot(outputDir string) string { if dir := strings.TrimSpace(outputDir); dir != "" { return dir } return defaultOutputRoot } func writeOutputFiles(runOutputDir string, files []contracts.OutputFile) error { type outputTarget struct { path string file contracts.OutputFile } targets := make([]outputTarget, 0, len(files)) for _, file := range files { targetPath, err := outputFilePath(runOutputDir, file.Name) if err != nil { return err } targets = append(targets, outputTarget{path: targetPath, file: file}) } if err := os.MkdirAll(runOutputDir, 0o755); err != nil { return fmt.Errorf("create output directory %q: %w", runOutputDir, err) } for _, target := range targets { if err := os.MkdirAll(filepath.Dir(target.path), 0o755); err != nil { return fmt.Errorf("create output directory %q: %w", filepath.Dir(target.path), err) } if err := writeFileAtomic(target.path, target.file.Bytes, 0o644); err != nil { return fmt.Errorf("write output file %q: %w", target.file.Name, err) } } return nil } func outputFilePath(runOutputDir, logicalName string) (string, error) { name := strings.TrimSpace(logicalName) if name == "" { return "", fmt.Errorf("output file name must not be empty") } if strings.Contains(name, `\`) { return "", fmt.Errorf("output file name %q must use slash-separated relative paths", name) } if path.IsAbs(name) || filepath.IsAbs(name) { return "", fmt.Errorf("output file name %q must be relative", name) } if strings.Contains(name, "..") { return "", fmt.Errorf("output file name %q must not contain ..", name) } cleaned := path.Clean(name) if cleaned == "." || cleaned != name { return "", fmt.Errorf("output file name %q must be clean", name) } root, err := filepath.Abs(runOutputDir) if err != nil { return "", fmt.Errorf("resolve output directory %q: %w", runOutputDir, err) } target, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(cleaned))) if err != nil { return "", fmt.Errorf("resolve output file %q: %w", name, err) } rel, err := filepath.Rel(root, target) if err != nil { return "", fmt.Errorf("resolve output file %q: %w", name, err) } if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { return "", fmt.Errorf("output file name %q resolves outside output directory", name) } return target, nil } func writeFileAtomic(path string, data []byte, perm os.FileMode) error { dir := filepath.Dir(path) temp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*") if err != nil { return err } tempPath := temp.Name() removeTemp := true defer func() { if removeTemp { _ = os.Remove(tempPath) } }() if _, err := temp.Write(data); err != nil { _ = temp.Close() return err } if err := temp.Chmod(perm); err != nil { _ = temp.Close() return err } if err := temp.Close(); err != nil { return err } if err := os.Rename(tempPath, path); err != nil { return err } removeTemp = false return nil } 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", "--reference", "--without-reference": 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 } type stringListFlag []string func (flag *stringListFlag) String() string { if flag == nil { return "" } return strings.Join(*flag, ",") } func (flag *stringListFlag) Set(value string) error { *flag = append(*flag, value) return nil } type cliReferenceRequest struct { LaneID string SlotName string Source string } type cliReferenceUnbindRequest struct { LaneID string SlotName string } func parseReferenceFlags(values []string) ([]cliReferenceRequest, error) { if len(values) == 0 { return nil, nil } requests := make([]cliReferenceRequest, 0, len(values)) for _, raw := range values { name, source, ok := strings.Cut(raw, "=") if !ok { return nil, fmt.Errorf("--reference must use slot=path or lane.slot=path") } if strings.TrimSpace(source) == "" { return nil, fmt.Errorf("--reference path must not be empty; use --without-reference to unbind") } laneID, slotName, err := parseReferenceSelector(name, "--reference") if err != nil { return nil, err } requests = append(requests, cliReferenceRequest{ LaneID: laneID, SlotName: slotName, Source: strings.TrimSpace(source), }) } return requests, nil } func parseReferenceUnbindFlags(values []string) ([]cliReferenceUnbindRequest, error) { if len(values) == 0 { return nil, nil } requests := make([]cliReferenceUnbindRequest, 0, len(values)) for _, raw := range values { if strings.Contains(raw, "=") { return nil, fmt.Errorf("--without-reference must use slot or lane.slot") } laneID, slotName, err := parseReferenceSelector(raw, "--without-reference") if err != nil { return nil, err } requests = append(requests, cliReferenceUnbindRequest{ LaneID: laneID, SlotName: slotName, }) } return requests, nil } func parseReferenceSelector(raw string, flagName string) (string, string, error) { selector := strings.TrimSpace(raw) if selector == "" { return "", "", fmt.Errorf("%s reference slot must not be empty", flagName) } if strings.Count(selector, ".") > 1 { return "", "", fmt.Errorf("%s must use slot or lane.slot", flagName) } laneID := "" slotName := selector if strings.Contains(selector, ".") { before, after, _ := strings.Cut(selector, ".") laneID = strings.TrimSpace(before) slotName = strings.TrimSpace(after) if laneID == "" || slotName == "" { return "", "", fmt.Errorf("%s must use non-empty lane.slot values", flagName) } } return laneID, slotName, nil } func resolveCLIReferenceRequests( cfg config.Config, pipelineID string, only []string, catalog pipeline.ModuleCatalog, referenceRequests []cliReferenceRequest, unbindRequests []cliReferenceUnbindRequest, ) ([]pipeline.ReferenceBinding, []pipeline.ReferenceUnbind, error) { if len(referenceRequests) == 0 && len(unbindRequests) == 0 { return nil, nil, nil } selected, err := selectedReferenceLanes(cfg, pipelineID, only, catalog) if err != nil { return nil, nil, err } overrides := make([]pipeline.ReferenceBinding, 0, len(referenceRequests)) for _, request := range referenceRequests { laneID, err := resolveCLIReferenceLane(selected, request.LaneID, request.SlotName) if err != nil { return nil, nil, err } overrides = append(overrides, pipeline.ReferenceBinding{ LaneID: laneID, SlotName: request.SlotName, Source: request.Source, BindingSource: contracts.ReferenceBindingSourceCLI, }) } unbinds := make([]pipeline.ReferenceUnbind, 0, len(unbindRequests)) for _, request := range unbindRequests { laneID, err := resolveCLIReferenceLane(selected, request.LaneID, request.SlotName) if err != nil { return nil, nil, err } unbinds = append(unbinds, pipeline.ReferenceUnbind{ LaneID: laneID, SlotName: request.SlotName, }) } return overrides, unbinds, nil } type selectedReferenceLane struct { id string slots map[string]struct{} } func selectedReferenceLanes(cfg config.Config, pipelineID string, only []string, catalog pipeline.ModuleCatalog) ([]selectedReferenceLane, error) { profile, ok := lookupCLIReferencePipeline(cfg.Pipelines, pipelineID) if !ok { return nil, fmt.Errorf("pipeline %q is not configured", strings.TrimSpace(pipelineID)) } lanesByID := make(map[string]pipeline.ArtifactLaneProfile, len(profile.Artifacts)) for rawLaneID, lane := range profile.Artifacts { laneID := strings.TrimSpace(rawLaneID) if laneID == "" { return nil, fmt.Errorf("pipeline %q artifact lane id must not be empty", strings.TrimSpace(pipelineID)) } if _, ok := lanesByID[laneID]; ok { return nil, fmt.Errorf("pipeline %q artifact lane %q is duplicated after trimming", strings.TrimSpace(pipelineID), laneID) } lanesByID[laneID] = lane } selectedIDs := make([]string, 0, len(lanesByID)) if len(only) == 0 { for laneID := range lanesByID { selectedIDs = append(selectedIDs, laneID) } } else { seen := make(map[string]struct{}, len(only)) for _, rawLaneID := range only { laneID := strings.TrimSpace(rawLaneID) if laneID == "" { return nil, fmt.Errorf("pipeline %q selected artifact lane id must not be empty", strings.TrimSpace(pipelineID)) } if _, ok := lanesByID[laneID]; !ok { return nil, fmt.Errorf("pipeline %q selected artifact lane %q is not declared", strings.TrimSpace(pipelineID), laneID) } if _, ok := seen[laneID]; !ok { selectedIDs = append(selectedIDs, laneID) seen[laneID] = struct{}{} } } } sort.Strings(selectedIDs) selected := make([]selectedReferenceLane, 0, len(selectedIDs)) for _, laneID := range selectedIDs { lane := lanesByID[laneID] extractModule := strings.TrimSpace(lane.Extract.Module) if extractModule == "" { return nil, fmt.Errorf("pipeline %q lane %q extract module must not be empty", strings.TrimSpace(pipelineID), laneID) } if catalog.Extractors == nil { return nil, fmt.Errorf("pipeline %q lane %q extract module %q: module %q is not registered", strings.TrimSpace(pipelineID), laneID, extractModule, extractModule) } spec, ok := catalog.Extractors.Spec(extractModule) if !ok { return nil, fmt.Errorf("pipeline %q lane %q extract module %q: module %q is not registered", strings.TrimSpace(pipelineID), laneID, extractModule, extractModule) } slotSet := make(map[string]struct{}, len(spec.ReferenceSlots)) for _, slot := range spec.ReferenceSlots { slotSet[slot.Name] = struct{}{} } selected = append(selected, selectedReferenceLane{id: laneID, slots: slotSet}) } return selected, nil } func lookupCLIReferencePipeline(profiles map[string]pipeline.PipelineProfile, pipelineID string) (pipeline.PipelineProfile, bool) { pipelineID = strings.TrimSpace(pipelineID) for rawID, profile := range profiles { if strings.TrimSpace(rawID) == pipelineID { return profile, true } } return pipeline.PipelineProfile{}, false } func resolveCLIReferenceLane(selected []selectedReferenceLane, requestedLaneID string, slotName string) (string, error) { slotName = strings.TrimSpace(slotName) requestedLaneID = strings.TrimSpace(requestedLaneID) if requestedLaneID != "" { for _, lane := range selected { if lane.id == requestedLaneID { if _, ok := lane.slots[slotName]; !ok { return "", fmt.Errorf("reference slot %q is not declared by selected lane %q", slotName, requestedLaneID) } return requestedLaneID, nil } } return "", fmt.Errorf("reference lane %q is not selected", requestedLaneID) } matches := make([]string, 0, 1) for _, lane := range selected { if _, ok := lane.slots[slotName]; ok { matches = append(matches, lane.id) } } switch len(matches) { case 0: return "", fmt.Errorf("reference slot %q is not declared by any selected lane", slotName) case 1: return matches[0], nil default: return "", fmt.Errorf("reference slot %q is declared by multiple selected lanes (%s); use lane.slot", slotName, strings.Join(matches, ", ")) } } 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 }