303 lines
10 KiB
Go
303 lines
10 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
)
|
|
|
|
type configDiffFlags struct {
|
|
pipelinePath singletonStringFlag
|
|
campaignPath singletonStringFlag
|
|
campaignFilePath singletonStringFlag
|
|
}
|
|
|
|
type configDiffRequest struct {
|
|
leftProfile string
|
|
rightProfile string
|
|
flags configDiffFlags
|
|
}
|
|
|
|
type configDiffResolved struct {
|
|
left *inspectionConfig
|
|
right *inspectionConfig
|
|
}
|
|
|
|
type configDiffRecord struct {
|
|
Kind string
|
|
Path string
|
|
Left string
|
|
Right string
|
|
}
|
|
|
|
// ConfigDiff compares two explicitly selected profiles through the same
|
|
// read-only configuration resolution boundary used by config validate, show,
|
|
// and sources. Differences describe normalized effective values rather than
|
|
// the layout of root, import, or overlay files.
|
|
func ConfigDiff(_ context.Context, args []string, out io.Writer) error {
|
|
request, err := parseConfigDiffRequest(args, out)
|
|
if err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
resolved, err := resolveConfigDiff(request)
|
|
if err != nil {
|
|
return fmt.Errorf("config diff: %w", err)
|
|
}
|
|
leftValues, err := config.EffectivePipelineValues(resolved.left.Pipeline)
|
|
if err != nil {
|
|
return fmt.Errorf("config diff: project left effective configuration: %w", err)
|
|
}
|
|
rightValues, err := config.EffectivePipelineValues(resolved.right.Pipeline)
|
|
if err != nil {
|
|
return fmt.Errorf("config diff: project right effective configuration: %w", err)
|
|
}
|
|
records, err := diffEffectivePipelineValues(leftValues, rightValues)
|
|
if err != nil {
|
|
return fmt.Errorf("config diff: %w", err)
|
|
}
|
|
leftDigest := config.EffectivePipelineDigest(resolved.left.Pipeline)
|
|
rightDigest := config.EffectivePipelineDigest(resolved.right.Pipeline)
|
|
if len(records) == 0 {
|
|
if leftDigest != rightDigest {
|
|
return fmt.Errorf("internal error: effective pipeline digests differ without a semantic difference")
|
|
}
|
|
_, err := fmt.Fprintln(out, "no differences")
|
|
return err
|
|
}
|
|
for _, record := range records {
|
|
switch record.Kind {
|
|
case "added":
|
|
if _, err := fmt.Fprintf(out, "added\t%s\t%s\n", record.Path, record.Right); err != nil {
|
|
return err
|
|
}
|
|
case "removed":
|
|
if _, err := fmt.Fprintf(out, "removed\t%s\t%s\n", record.Path, record.Left); err != nil {
|
|
return err
|
|
}
|
|
case "changed":
|
|
if _, err := fmt.Fprintf(out, "changed\t%s\t%s\t%s\n", record.Path, record.Left, record.Right); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return fmt.Errorf("internal error: unsupported difference kind %q", record.Kind)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, error) {
|
|
profiles, flagArgs := splitConfigDiffArguments(args)
|
|
fs := flag.NewFlagSet("config diff", flag.ContinueOnError)
|
|
fs.SetOutput(out)
|
|
var request configDiffRequest
|
|
request.flags.pipelinePath.name = "config"
|
|
request.flags.campaignPath.name = "campaign"
|
|
request.flags.campaignFilePath.name = "campaign-file"
|
|
fs.Var(&request.flags.pipelinePath, "config", "path to pipeline.yml (optional; defaults searched)")
|
|
fs.Var(&request.flags.campaignPath, "campaign", "campaign ID")
|
|
fs.Var(&request.flags.campaignFilePath, "campaign-file", "path to campaign.yml")
|
|
fs.Usage = func() {
|
|
fmt.Fprintln(out, "Usage: narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>]")
|
|
fmt.Fprintln(out)
|
|
fmt.Fprintln(out, "Compares fully resolved profiles without a session, workspace, manifest, adapters, or external services.")
|
|
fmt.Fprintln(out)
|
|
fmt.Fprintln(out, "Flags:")
|
|
fs.PrintDefaults()
|
|
}
|
|
if err := fs.Parse(flagArgs); err != nil {
|
|
return configDiffRequest{}, fmt.Errorf("config diff: invalid flags: %w", err)
|
|
}
|
|
profiles = append(profiles, fs.Args()...)
|
|
if len(profiles) != 2 || strings.TrimSpace(profiles[0]) == "" || strings.TrimSpace(profiles[1]) == "" {
|
|
return configDiffRequest{}, fmt.Errorf("config diff: exactly two non-empty profile names are required")
|
|
}
|
|
if request.flags.campaignPath.set && request.flags.campaignFilePath.set {
|
|
return configDiffRequest{}, fmt.Errorf("config diff: --campaign and --campaign-file are mutually exclusive")
|
|
}
|
|
request.leftProfile = profiles[0]
|
|
request.rightProfile = profiles[1]
|
|
return request, nil
|
|
}
|
|
|
|
// splitConfigDiffArguments accepts the documented positional-first syntax and
|
|
// also permits flags before or between profile names. Values belonging to the
|
|
// supported string flags stay with their flag so they are not mistaken for
|
|
// profile names.
|
|
func splitConfigDiffArguments(args []string) (profiles, flagArgs []string) {
|
|
for index := 0; index < len(args); index++ {
|
|
argument := args[index]
|
|
if !strings.HasPrefix(argument, "-") || argument == "-" {
|
|
profiles = append(profiles, argument)
|
|
continue
|
|
}
|
|
flagArgs = append(flagArgs, argument)
|
|
name, hasValue := strings.CutPrefix(argument, "--")
|
|
if !hasValue {
|
|
continue
|
|
}
|
|
name, _, hasInlineValue := strings.Cut(name, "=")
|
|
if hasInlineValue || !configDiffStringFlag(name) || index+1 >= len(args) {
|
|
continue
|
|
}
|
|
index++
|
|
flagArgs = append(flagArgs, args[index])
|
|
}
|
|
return profiles, flagArgs
|
|
}
|
|
|
|
func configDiffStringFlag(name string) bool {
|
|
switch name {
|
|
case "config", "campaign", "campaign-file", "profile":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func resolveConfigDiff(request configDiffRequest) (*configDiffResolved, error) {
|
|
pipelinePath, err := resolvePipelineConfigPath(request.flags.pipelinePath.value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
leftPipeline, rightPipeline, err := config.LoadPipelineProfilePair(pipelinePath, request.leftProfile, request.rightProfile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
needsCampaign := pipelineHasArtifactFamilies(leftPipeline) || pipelineHasArtifactFamilies(rightPipeline)
|
|
hasCampaignSelection := request.flags.campaignPath.set || request.flags.campaignFilePath.set
|
|
if !needsCampaign && !hasCampaignSelection {
|
|
if err := validateInspectionPipeline(pipelinePath, leftPipeline); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateInspectionPipeline(pipelinePath, rightPipeline); err != nil {
|
|
return nil, err
|
|
}
|
|
return &configDiffResolved{
|
|
left: &inspectionConfig{PipelinePath: pipelinePath, Pipeline: leftPipeline},
|
|
right: &inspectionConfig{PipelinePath: pipelinePath, Pipeline: rightPipeline},
|
|
}, nil
|
|
}
|
|
|
|
campaignPath, err := resolveSharedDiffCampaignPath(leftPipeline, rightPipeline, request.flags)
|
|
if err != nil {
|
|
if needsCampaign {
|
|
return nil, fmt.Errorf("pipeline.scriptorium.artifact_families requires one shared campaign: %w", err)
|
|
}
|
|
return nil, err
|
|
}
|
|
campaign, err := config.LoadCampaign(campaignPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if request.flags.campaignPath.set && !request.flags.campaignFilePath.set {
|
|
if got := config.CampaignID(campaign); got != request.flags.campaignPath.value {
|
|
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", campaignPath, got, request.flags.campaignPath.value)
|
|
}
|
|
}
|
|
leftLoaded, err := config.LoadPipelineCampaign(pipelinePath, leftPipeline, campaignPath, campaign)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rightLoaded, err := config.LoadPipelineCampaignWithParty(pipelinePath, rightPipeline, campaignPath, campaign, leftLoaded.Party)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateInspectionPipeline(leftLoaded.PipelinePath, leftLoaded.Pipeline); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateInspectionPipeline(rightLoaded.PipelinePath, rightLoaded.Pipeline); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateInspectionCampaign(campaignPath, campaign); err != nil {
|
|
return nil, err
|
|
}
|
|
return &configDiffResolved{
|
|
left: &inspectionConfig{
|
|
PipelinePath: leftLoaded.PipelinePath,
|
|
Pipeline: leftLoaded.Pipeline,
|
|
CampaignPath: leftLoaded.CampaignPath,
|
|
Campaign: campaign,
|
|
Party: leftLoaded.Party,
|
|
},
|
|
right: &inspectionConfig{
|
|
PipelinePath: rightLoaded.PipelinePath,
|
|
Pipeline: rightLoaded.Pipeline,
|
|
CampaignPath: rightLoaded.CampaignPath,
|
|
Campaign: campaign,
|
|
Party: rightLoaded.Party,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func resolveSharedDiffCampaignPath(left, right *config.PipelineConfig, flags configDiffFlags) (string, error) {
|
|
leftPath, leftErr := resolveCampaignConfigPath(left, flags.campaignPath.value, flags.campaignFilePath.value)
|
|
if leftErr != nil {
|
|
return "", leftErr
|
|
}
|
|
rightPath, rightErr := resolveCampaignConfigPath(right, flags.campaignPath.value, flags.campaignFilePath.value)
|
|
if rightErr != nil {
|
|
return "", rightErr
|
|
}
|
|
if leftPath != rightPath {
|
|
return "", fmt.Errorf("profile selections resolve different campaign files; specify --campaign-file to compare one campaign")
|
|
}
|
|
return leftPath, nil
|
|
}
|
|
|
|
func diffEffectivePipelineValues(left, right []config.EffectivePipelineValueRecord) ([]configDiffRecord, error) {
|
|
leftByPath, err := effectivePipelineValueMap("left", left)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rightByPath, err := effectivePipelineValueMap("right", right)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
paths := make([]string, 0, len(leftByPath)+len(rightByPath))
|
|
seen := make(map[string]struct{}, len(leftByPath)+len(rightByPath))
|
|
for path := range leftByPath {
|
|
seen[path] = struct{}{}
|
|
paths = append(paths, path)
|
|
}
|
|
for path := range rightByPath {
|
|
if _, exists := seen[path]; !exists {
|
|
paths = append(paths, path)
|
|
}
|
|
}
|
|
sort.Strings(paths)
|
|
records := make([]configDiffRecord, 0)
|
|
for _, path := range paths {
|
|
leftValue, leftExists := leftByPath[path]
|
|
rightValue, rightExists := rightByPath[path]
|
|
switch {
|
|
case !leftExists:
|
|
records = append(records, configDiffRecord{Kind: "added", Path: path, Right: rightValue})
|
|
case !rightExists:
|
|
records = append(records, configDiffRecord{Kind: "removed", Path: path, Left: leftValue})
|
|
case leftValue != rightValue:
|
|
records = append(records, configDiffRecord{Kind: "changed", Path: path, Left: leftValue, Right: rightValue})
|
|
}
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
func effectivePipelineValueMap(side string, records []config.EffectivePipelineValueRecord) (map[string]string, error) {
|
|
values := make(map[string]string, len(records))
|
|
for _, record := range records {
|
|
if _, exists := values[record.Path]; exists {
|
|
return nil, fmt.Errorf("internal error: %s effective configuration has duplicate path %q", side, record.Path)
|
|
}
|
|
values[record.Path] = record.Value
|
|
}
|
|
return values, nil
|
|
}
|