297 lines
9.9 KiB
Go
297 lines
9.9 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
)
|
|
|
|
type inspectionFlags struct {
|
|
pipelinePath string
|
|
campaignPath string
|
|
campaignFilePath string
|
|
profile singletonStringFlag
|
|
}
|
|
|
|
type inspectionConfig struct {
|
|
PipelinePath string
|
|
Pipeline *config.PipelineConfig
|
|
CampaignPath string
|
|
Campaign *config.CampaignConfig
|
|
Party config.ResolvedParty
|
|
}
|
|
|
|
// Config dispatches read-only pipeline configuration commands.
|
|
func Config(ctx context.Context, args []string, out io.Writer) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("config: expected subcommand: validate|show|sources")
|
|
}
|
|
if args[0] == "--help" || args[0] == "-h" {
|
|
writeConfigCommandUsage(out)
|
|
return nil
|
|
}
|
|
switch args[0] {
|
|
case "validate":
|
|
return ConfigValidate(ctx, args[1:], out)
|
|
case "show":
|
|
return ConfigShow(ctx, args[1:], out)
|
|
case "sources":
|
|
return ConfigSources(ctx, args[1:], out)
|
|
default:
|
|
return fmt.Errorf("config: unknown subcommand %q", args[0])
|
|
}
|
|
}
|
|
|
|
// ConfigValidate validates one fully resolved effective pipeline without
|
|
// loading session state or constructing runtime collaborators.
|
|
func ConfigValidate(_ context.Context, args []string, out io.Writer) error {
|
|
flags, err := parseInspectionFlags("config validate", args, out)
|
|
if err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
resolved, err := resolveInspectionConfig(flags)
|
|
if err != nil {
|
|
return fmt.Errorf("config validate: %w", err)
|
|
}
|
|
_, err = fmt.Fprintf(out, "Configuration valid: root=%s; profile=%s; digest=%s\n",
|
|
inspectionRootPath(resolved), inspectionProfile(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline))
|
|
return err
|
|
}
|
|
|
|
// ConfigShow validates and renders one fully resolved effective pipeline
|
|
// without loading session state or constructing runtime collaborators.
|
|
func ConfigShow(_ context.Context, args []string, out io.Writer) error {
|
|
flags, err := parseInspectionFlags("config show", args, out)
|
|
if err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
resolved, err := resolveInspectionConfig(flags)
|
|
if err != nil {
|
|
return fmt.Errorf("config show: %w", err)
|
|
}
|
|
data, err := config.MarshalEffectivePipeline(resolved.Pipeline)
|
|
if err != nil {
|
|
return fmt.Errorf("config show: %w", err)
|
|
}
|
|
_, err = out.Write(data)
|
|
return err
|
|
}
|
|
|
|
// ConfigSources validates and reports deterministic effective configuration
|
|
// ownership without loading session state or runtime collaborators.
|
|
func ConfigSources(_ context.Context, args []string, out io.Writer) error {
|
|
flags, err := parseInspectionFlags("config sources", args, out)
|
|
if err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
resolved, err := resolveInspectionConfig(flags)
|
|
if err != nil {
|
|
return fmt.Errorf("config sources: %w", err)
|
|
}
|
|
records, err := config.EffectivePipelineSources(resolved.Pipeline, resolved.Party)
|
|
if err != nil {
|
|
return fmt.Errorf("config sources: %w", err)
|
|
}
|
|
records = append(records, config.EffectiveCampaignSources(resolved.CampaignPath, resolved.Campaign, resolved.Party)...)
|
|
sortInspectionSourceRecords(records)
|
|
if err := writeInspectionSourceHeader(out, resolved); err != nil {
|
|
return err
|
|
}
|
|
for _, record := range records {
|
|
if _, err := fmt.Fprintf(out, "%s\t%s\t%s\n", record.Path, record.Role, record.Source); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseInspectionFlags(command string, args []string, out io.Writer) (inspectionFlags, error) {
|
|
fs := flag.NewFlagSet(command, flag.ContinueOnError)
|
|
fs.SetOutput(out)
|
|
var flags inspectionFlags
|
|
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
|
fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID")
|
|
fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
|
flags.profile.name = "profile"
|
|
fs.Var(&flags.profile, "profile", "named pipeline profile")
|
|
fs.Usage = func() {
|
|
fmt.Fprintf(out, "Usage: narratio %s [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>]\n\n", command)
|
|
fmt.Fprintln(out, "Resolves configuration without a session, workspace, manifest, adapters, or external services.")
|
|
fmt.Fprintln(out)
|
|
fmt.Fprintln(out, "Flags:")
|
|
fs.PrintDefaults()
|
|
}
|
|
if err := fs.Parse(args); err != nil {
|
|
return inspectionFlags{}, fmt.Errorf("%s: invalid flags: %w", command, err)
|
|
}
|
|
if fs.NArg() != 0 {
|
|
return inspectionFlags{}, fmt.Errorf("%s: unexpected positional arguments", command)
|
|
}
|
|
if strings.TrimSpace(flags.campaignPath) != "" && strings.TrimSpace(flags.campaignFilePath) != "" {
|
|
return inspectionFlags{}, fmt.Errorf("%s: --campaign and --campaign-file are mutually exclusive", command)
|
|
}
|
|
return flags, nil
|
|
}
|
|
|
|
func resolveInspectionConfig(flags inspectionFlags) (*inspectionConfig, error) {
|
|
pipelinePath, pipeline, err := loadPipelineConfig(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
needsCampaign := pipelineHasArtifactFamilies(pipeline)
|
|
hasCampaignSelection := strings.TrimSpace(flags.campaignPath) != "" || strings.TrimSpace(flags.campaignFilePath) != ""
|
|
if !needsCampaign && !hasCampaignSelection {
|
|
if err := validateInspectionPipeline(pipelinePath, pipeline); err != nil {
|
|
return nil, err
|
|
}
|
|
return &inspectionConfig{PipelinePath: pipelinePath, Pipeline: pipeline}, nil
|
|
}
|
|
|
|
campaignPath, err := resolveCampaignConfigPath(pipeline, flags.campaignPath, flags.campaignFilePath)
|
|
if err != nil {
|
|
if needsCampaign {
|
|
return nil, fmt.Errorf("pipeline.scriptorium.artifact_families requires a campaign: %w", err)
|
|
}
|
|
return nil, err
|
|
}
|
|
campaign, err := config.LoadCampaign(campaignPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if selectedID := strings.TrimSpace(flags.campaignPath); selectedID != "" && strings.TrimSpace(flags.campaignFilePath) == "" {
|
|
if got := config.CampaignID(campaign); got != selectedID {
|
|
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", campaignPath, got, selectedID)
|
|
}
|
|
}
|
|
loaded, err := config.LoadPipelineCampaign(pipelinePath, pipeline, campaignPath, campaign)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateInspectionPipeline(loaded.PipelinePath, loaded.Pipeline); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateInspectionCampaign(loaded.CampaignPath, loaded.Campaign); err != nil {
|
|
return nil, err
|
|
}
|
|
return &inspectionConfig{
|
|
PipelinePath: loaded.PipelinePath,
|
|
Pipeline: loaded.Pipeline,
|
|
CampaignPath: loaded.CampaignPath,
|
|
Campaign: loaded.Campaign,
|
|
Party: loaded.Party,
|
|
}, nil
|
|
}
|
|
|
|
func pipelineHasArtifactFamilies(pipeline *config.PipelineConfig) bool {
|
|
return pipeline != nil && pipeline.Scriptorium != nil && len(pipeline.Scriptorium.ArtifactFamilies) != 0
|
|
}
|
|
|
|
func validateInspectionPipeline(path string, pipeline *config.PipelineConfig) error {
|
|
if err := config.ValidatePipelineConfig(pipeline); err != nil {
|
|
return fmt.Errorf("pipeline config %q invalid: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateInspectionCampaign(path string, campaign *config.CampaignConfig) error {
|
|
if err := config.ValidateCampaignConfig(campaign); err != nil {
|
|
return fmt.Errorf("campaign config %q invalid: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func inspectionRootPath(resolved *inspectionConfig) string {
|
|
if resolved == nil {
|
|
return ""
|
|
}
|
|
if root := config.EffectivePipelineRootPath(resolved.Pipeline); root != "" {
|
|
return root
|
|
}
|
|
return resolved.PipelinePath
|
|
}
|
|
|
|
func inspectionProfile(pipeline *config.PipelineConfig) string {
|
|
if profile, ok := config.SelectedPipelineProfile(pipeline); ok {
|
|
return profile.Name
|
|
}
|
|
return "none"
|
|
}
|
|
|
|
func writeConfigCommandUsage(out io.Writer) {
|
|
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources>")
|
|
fmt.Fprintln(out)
|
|
fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, or config sources to report ownership.")
|
|
}
|
|
|
|
func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error {
|
|
if _, err := fmt.Fprintf(out, "root: %s\n", inspectionRootPath(resolved)); err != nil {
|
|
return err
|
|
}
|
|
imports := config.EffectivePipelineImports(resolved.Pipeline)
|
|
if len(imports) == 0 {
|
|
if _, err := fmt.Fprintln(out, "imports: none"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, imported := range imports {
|
|
if _, err := fmt.Fprintf(out, "import: %s\n", config.NormalizedConfigurationPath(imported)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if profile, ok := config.SelectedPipelineProfile(resolved.Pipeline); ok {
|
|
if _, err := fmt.Fprintf(out, "profile: name=%s selection=%s overlay=%s\n", profile.Name, profile.Source, config.NormalizedConfigurationPath(profile.OverlayPath)); err != nil {
|
|
return err
|
|
}
|
|
} else if _, err := fmt.Fprintln(out, "profile: none"); err != nil {
|
|
return err
|
|
}
|
|
if resolved.Campaign == nil {
|
|
if _, err := fmt.Fprintln(out, "campaign: none"); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintln(out, "party: none"); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if _, err := fmt.Fprintf(out, "campaign: id=%s source=%s\n", config.CampaignID(resolved.Campaign), config.NormalizedConfigurationPath(resolved.CampaignPath)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(out, "party: mode=%s source=%s\n", resolved.Party.Mode, config.NormalizedConfigurationPath(resolved.Party.Source.Path)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := fmt.Fprintf(out, "digest: %s\n", config.EffectivePipelineDigest(resolved.Pipeline)); err != nil {
|
|
return err
|
|
}
|
|
_, err := fmt.Fprintln(out, "path\trole\tsource")
|
|
return err
|
|
}
|
|
|
|
func sortInspectionSourceRecords(records []config.EffectivePipelineSourceRecord) {
|
|
sort.Slice(records, func(left, right int) bool {
|
|
if records[left].Path != records[right].Path {
|
|
return records[left].Path < records[right].Path
|
|
}
|
|
if records[left].Role != records[right].Role {
|
|
return records[left].Role < records[right].Role
|
|
}
|
|
return records[left].Source < records[right].Source
|
|
})
|
|
}
|