Add semantic configuration profile comparison
This commit is contained in:
@@ -30,7 +30,7 @@ type inspectionConfig struct {
|
||||
// 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")
|
||||
return fmt.Errorf("config: expected subcommand: validate|show|sources|diff")
|
||||
}
|
||||
if args[0] == "--help" || args[0] == "-h" {
|
||||
writeConfigCommandUsage(out)
|
||||
@@ -43,6 +43,8 @@ func Config(ctx context.Context, args []string, out io.Writer) error {
|
||||
return ConfigShow(ctx, args[1:], out)
|
||||
case "sources":
|
||||
return ConfigSources(ctx, args[1:], out)
|
||||
case "diff":
|
||||
return ConfigDiff(ctx, args[1:], out)
|
||||
default:
|
||||
return fmt.Errorf("config: unknown subcommand %q", args[0])
|
||||
}
|
||||
@@ -234,9 +236,9 @@ func inspectionProfile(pipeline *config.PipelineConfig) string {
|
||||
}
|
||||
|
||||
func writeConfigCommandUsage(out io.Writer) {
|
||||
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources>")
|
||||
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources|diff>")
|
||||
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.")
|
||||
fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, or config diff to compare two profiles.")
|
||||
}
|
||||
|
||||
func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error {
|
||||
|
||||
@@ -21,12 +21,13 @@ func TestConfigCommandHelpAndDispatch(t *testing.T) {
|
||||
want string
|
||||
code int
|
||||
}{
|
||||
{name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config <validate|show|sources>"},
|
||||
{name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config <validate|show|sources|diff>"},
|
||||
{name: "validate help", args: []string{"config", "validate", "--help"}, want: "Usage: narratio config validate"},
|
||||
{name: "show help", args: []string{"config", "show", "--help"}, want: "Usage: narratio config show"},
|
||||
{name: "sources help", args: []string{"config", "sources", "--help"}, want: "Usage: narratio config sources"},
|
||||
{name: "unknown", args: []string{"config", "diff"}, want: `config: unknown subcommand "diff"`, code: 1},
|
||||
{name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show|sources", code: 1},
|
||||
{name: "diff help", args: []string{"config", "diff", "--help"}, want: "Usage: narratio config diff"},
|
||||
{name: "unknown", args: []string{"config", "unknown"}, want: `config: unknown subcommand "unknown"`, code: 1},
|
||||
{name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show|sources|diff", code: 1},
|
||||
{name: "session flag", args: []string{"config", "validate", "--session", "session.yml"}, want: "flag provided but not defined", code: 1},
|
||||
{name: "stage flag", args: []string{"config", "show", "--from", "prepare"}, want: "flag provided but not defined", code: 1},
|
||||
{name: "force flag", args: []string{"config", "show", "--force"}, want: "flag provided but not defined", code: 1},
|
||||
@@ -94,6 +95,14 @@ whisperx:
|
||||
if strings.Contains(sourcesOut.String(), secret) {
|
||||
t.Fatalf("sources output leaked a raw secret: %q", sourcesOut.String())
|
||||
}
|
||||
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n")
|
||||
var diffOut bytes.Buffer
|
||||
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diffOut); err != nil {
|
||||
t.Fatalf("ConfigDiff() error = %v", err)
|
||||
}
|
||||
if strings.Contains(diffOut.String(), secret) {
|
||||
t.Fatalf("diff output leaked a raw secret: %q", diffOut.String())
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("inspection invoked configured external endpoint %d time(s)", calls)
|
||||
}
|
||||
@@ -102,6 +111,152 @@ whisperx:
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffReportsSemanticProfileChanges(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
writeInspectionProfiles(t, pipelinePath, `workspace:
|
||||
cleanup_after_publish: true
|
||||
whisperx:
|
||||
language: en
|
||||
audita:
|
||||
modules: [glossary, grammar]
|
||||
scriptorium:
|
||||
artifacts:
|
||||
production_only:
|
||||
enabled: false
|
||||
output_path: artifacts/production.md
|
||||
`, `workspace:
|
||||
cleanup_after_publish: false
|
||||
whisperx:
|
||||
language: fr
|
||||
audita:
|
||||
modules: []
|
||||
scriptorium:
|
||||
artifacts:
|
||||
testing_only:
|
||||
enabled: false
|
||||
output_path: artifacts/testing.md
|
||||
`)
|
||||
|
||||
args := []string{"production", "testing", "--config", pipelinePath}
|
||||
var first, second bytes.Buffer
|
||||
if err := ConfigDiff(context.Background(), args, &first); err != nil {
|
||||
t.Fatalf("ConfigDiff() error = %v", err)
|
||||
}
|
||||
if err := ConfigDiff(context.Background(), args, &second); err != nil {
|
||||
t.Fatalf("second ConfigDiff() error = %v", err)
|
||||
}
|
||||
if first.String() != second.String() {
|
||||
t.Fatalf("diff output is not deterministic:\nfirst=%q\nsecond=%q", first.String(), second.String())
|
||||
}
|
||||
output := first.String()
|
||||
for _, want := range []string{
|
||||
"changed\taudita.modules\t[\"glossary\",\"grammar\"]\t[]\n",
|
||||
"changed\tworkspace.cleanup_after_publish\ttrue\tfalse\n",
|
||||
"changed\twhisperx.language\t\"en\"\t\"fr\"\n",
|
||||
"removed\tscriptorium.artifacts.production_only.enabled\tfalse\n",
|
||||
"added\tscriptorium.artifacts.testing_only.enabled\tfalse\n",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("diff output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
if strings.Index(output, "audita.modules") > strings.Index(output, "workspace.cleanup_after_publish") {
|
||||
t.Fatalf("diff records are not sorted by path:\n%s", output)
|
||||
}
|
||||
var reversed bytes.Buffer
|
||||
if err := ConfigDiff(context.Background(), []string{"testing", "production", "--config", pipelinePath}, &reversed); err != nil {
|
||||
t.Fatalf("reversed ConfigDiff() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(reversed.String(), "changed\twhisperx.language\t\"fr\"\t\"en\"\n") {
|
||||
t.Fatalf("reversed diff did not independently resolve profiles:\n%s", reversed.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
writeInspectionProfiles(t, pipelinePath, `scriptorium:
|
||||
artifact_families:
|
||||
character_note:
|
||||
enabled: false
|
||||
member_vars:
|
||||
character_name: character.name
|
||||
publish:
|
||||
enabled: true
|
||||
required: false
|
||||
`, `scriptorium:
|
||||
artifact_families:
|
||||
character_note:
|
||||
enabled: true
|
||||
prompt_id: dnd.character_note
|
||||
member_vars:
|
||||
character_name: character.class_summary
|
||||
publish:
|
||||
enabled: true
|
||||
required: true
|
||||
`)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := ConfigDiff(context.Background(), []string{
|
||||
"production", "testing", "--config", pipelinePath, "--campaign-file", campaignPath,
|
||||
}, &out); err != nil {
|
||||
t.Fatalf("ConfigDiff() error = %v", err)
|
||||
}
|
||||
output := out.String()
|
||||
for _, want := range []string{
|
||||
"changed\tscriptorium.artifacts.character_note_arannis.enabled\tfalse\ttrue\n",
|
||||
"changed\tscriptorium.artifacts.character_note_arannis.vars.character_name\t\"Arannis\"\t\"wizard\"\n",
|
||||
"changed\tpublish.outputs\t",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("family diff output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: en\n")
|
||||
|
||||
var equal bytes.Buffer
|
||||
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &equal); err != nil {
|
||||
t.Fatalf("ConfigDiff() equal profiles error = %v", err)
|
||||
}
|
||||
if got := equal.String(); got != "no differences\n" {
|
||||
t.Fatalf("equal diff output = %q", got)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"production", "testing", "extra", "--config", pipelinePath},
|
||||
{"production", "--config", pipelinePath},
|
||||
{"production", "production", "--config", pipelinePath},
|
||||
{"unknown", "testing", "--config", pipelinePath},
|
||||
{"production", "testing", "--config", pipelinePath, "--profile", "production"},
|
||||
{"production", "testing", "--config", pipelinePath, "--config", pipelinePath},
|
||||
{"production", "testing", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath},
|
||||
} {
|
||||
if err := ConfigDiff(context.Background(), args, io.Discard); err == nil {
|
||||
t.Fatalf("ConfigDiff(%q) succeeded, want error", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeInspectionProfiles(t *testing.T, pipelinePath, production, testing string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(pipelinePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
composition := "composition:\n default_profile: production\n profiles:\n production:\n overlay: production.yml\n testing:\n overlay: testing.yml\n"
|
||||
if err := os.WriteFile(pipelinePath, append([]byte(composition), data...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(pipelinePath)
|
||||
mustWriteTestFile(t, filepath.Join(dir, "production.yml"), production)
|
||||
mustWriteTestFile(t, filepath.Join(dir, "testing.yml"), testing)
|
||||
}
|
||||
|
||||
func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
302
internal/app/config_diff.go
Normal file
302
internal/app/config_diff.go
Normal file
@@ -0,0 +1,302 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user