672 lines
20 KiB
Go
672 lines
20 KiB
Go
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 <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, 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)
|
|
}
|
|
effective, err := cfg.Resolve(config.ResolveInput{
|
|
PipelineID: pipelineID,
|
|
Only: only,
|
|
Catalog: catalog,
|
|
LLMProfileOverride: *llmProfile,
|
|
})
|
|
if err != nil {
|
|
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err)
|
|
}
|
|
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))
|
|
}
|
|
|
|
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),
|
|
})
|
|
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":
|
|
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
|
|
}
|