diff --git a/api/v1alpha1/environment.go b/api/v1alpha1/environment.go index 5bf5c5dc..779cb696 100644 --- a/api/v1alpha1/environment.go +++ b/api/v1alpha1/environment.go @@ -37,6 +37,9 @@ type Promotion struct { } type EnvironmentSpec struct { + // Organization is the org this environment belongs to. This feature helps us define org-local content. + Organization string `yaml:"organization,omitempty" json:"organization,omitempty"` + // Order controls catalog ordering (lower values first). // When two environments share the same Order, they are sorted alphabetically by Name. Order int `yaml:"order,omitempty" json:"order,omitempty"` @@ -94,7 +97,8 @@ func (e Environment) Validate(validChartRefs []string) error { errs = append(errs, fmt.Errorf("unknown ref: %s", ref)) } } - return xerr.MultiErrOrderedFrom("", + return xerr.MultiErrOrderedFrom( + "", internal.ValidateAgainstSchema(schemas.Environment, e.File.Tree), xerr.MultiErrOrderedFrom("validating chart references", errs...), ) diff --git a/api/v1alpha1/schemas.cue b/api/v1alpha1/schemas.cue index fd9c6016..727947d1 100644 --- a/api/v1alpha1/schemas.cue +++ b/api/v1alpha1/schemas.cue @@ -35,7 +35,8 @@ package v1alpha1 kind: "Environment" metadata!: #metadata spec: { - order?: number + organization?: string + order?: number promotion?: { allowAutoMerge?: bool fromPullRequests?: bool @@ -98,6 +99,6 @@ package v1alpha1 name!: string annotations?: [string]: string labels?: [string]: string - relativePath?: string + relativePath?: string absolutePath?: string } diff --git a/internal/release/cross/release.go b/internal/release/cross/release.go index 71dcff9a..492b7fc8 100644 --- a/internal/release/cross/release.go +++ b/internal/release/cross/release.go @@ -39,12 +39,14 @@ func (r *Release) ComputePromotedFile(sourceEnv, targetEnv *v1alpha1.Environment return nil } + sameOrg := sourceEnv.Spec.Organization == targetEnv.Spec.Organization + // Do we have an existing target release? var promotedFile *yml.File var err error if targetRelease != nil && targetRelease.File != nil { // Promote source release to existing target - mergedTree := yml.Merge(targetRelease.File.Tree, sourceRelease.File.Tree) + mergedTree := yml.Merge(targetRelease.File.Tree, sourceRelease.File.Tree, sameOrg) promotedFile, err = targetRelease.File.CopyWithNewTree(mergedTree) if err != nil { return fmt.Errorf("creating in-memory copy of target file using merged result: %w", err) @@ -56,7 +58,7 @@ func (r *Release) ComputePromotedFile(sourceEnv, targetEnv *v1alpha1.Environment } // Promote source release to empty target - promoted := yml.Merge(nil, sourceRelease.File.Tree) + promoted := yml.Merge(nil, sourceRelease.File.Tree, sameOrg) targetPath := filepath.Join(targetEnv.Dir, relativePath) promotedFile, err = yml.NewFileFromTree(targetPath, sourceRelease.File.Indent, promoted) diff --git a/internal/release/promote/test/promotion_test.go b/internal/release/promote/test/promotion_test.go index a1fe2b17..bbc7c63a 100644 --- a/internal/release/promote/test/promotion_test.go +++ b/internal/release/promote/test/promotion_test.go @@ -3,10 +3,13 @@ package promote_test import ( "fmt" "io" + "maps" + "slices" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" "github.com/nestoca/joy/api/v1alpha1" "github.com/nestoca/joy/internal/git/pr" @@ -268,6 +271,56 @@ func TestPromotion(t *testing.T) { pullRequestTemplate: simplePullRequestTemplate, expectedPromoted: true, }, + { + name: "org-local", + opts: newOpts(), + setup: func(args setupArgs) func(t *testing.T) { + args.opts.SourceEnv.Spec.Organization = "bar" + args.opts.TargetEnv.Spec.Organization = "foo" + + args.opts.Catalog.Releases.Items = args.opts.Catalog.Releases.Items[:1] + args.opts.Catalog.Releases.Items[0].Releases[sourceEnvIndex] = newRelease( + "release1", + `spec: { key: value, potato: !org-local mashed }`, + sourceEnvName, + ) + + args.opts.Catalog.Releases.Items[0].Releases[targetEnvIndex] = newRelease("release1", `spec: {}`, sourceEnvName) + + args.promptProvider.SelectReleasesFunc = func(list cross.ReleaseList, maxColumnWidth int) (cross.ReleaseList, error) { + return list, nil + } + args.promptProvider.SelectPromotionActionFunc = func() (string, error) { + return promote.CreatePR, nil + } + + args.yamlWriter.WriteFileFunc = func(file *yml.File) error { + return nil + } + + args.prProvider.CreateFunc = func(createParams pr.CreateParams) (string, error) { + return "https://github.com/owner/repo/pull/123", nil + } + + setupDefaultMockInfoProvider(args.infoProvider) + + return func(t *testing.T) { + calls := args.yamlWriter.WriteFileCalls() + require.Len(t, calls, 1) + + var resource struct { + Spec map[string]any `yaml:"spec"` + } + require.NoError(t, yaml.Unmarshal(calls[0].File.Yaml, &resource)) + require.Equal(t, []string{"key"}, slices.Collect(maps.Keys(resource.Spec))) + } + }, + commitTemplate: simpleCommitTemplate, + pullRequestTemplate: simplePullRequestTemplate, + pullRequestLinkTemplate: "", + expectedErrorMessage: "", + expectedPromoted: true, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -374,7 +427,7 @@ metadata: name, specYaml) file, err := yml.NewFile("/dummy/environments/"+envName+"/releases/release.yaml", []byte(yaml)) if err != nil { - panic(err) + panic(fmt.Errorf("%v: %s", err, yaml)) } return file } diff --git a/internal/yml/merge.go b/internal/yml/merge.go index 109c230a..e362369c 100644 --- a/internal/yml/merge.go +++ b/internal/yml/merge.go @@ -4,7 +4,7 @@ import ( "gopkg.in/yaml.v3" ) -func Merge(dst, src *yaml.Node) *yaml.Node { +func Merge(dst, src *yaml.Node, sameOrg bool) *yaml.Node { dst, src = Clone(dst), Clone(src) doc := func() *yaml.Node { @@ -23,6 +23,10 @@ func Merge(dst, src *yaml.Node) *yaml.Node { src = markLockedValuesAsTodo(src, false) src = purgeLocalContent(src) + if !sameOrg { + src = purgeOrgLocalContent(src) + } + result := merge(dst, src) if result == nil { result = &yaml.Node{Kind: yaml.MappingNode} @@ -162,6 +166,10 @@ func IsLocal(node *yaml.Node) bool { return node != nil && node.Tag == "!local" } +func IsOrgLocal(node *yaml.Node) bool { + return node != nil && node.Tag == "!org-local" +} + type KeyValuePair struct { Key *yaml.Node Value *yaml.Node @@ -216,7 +224,15 @@ func firstNonNil(nodes ...*yaml.Node) *yaml.Node { } func purgeLocalContent(node *yaml.Node) *yaml.Node { - if node == nil || IsLocal(node) { + return purgeNodes(node, IsLocal) +} + +func purgeOrgLocalContent(node *yaml.Node) *yaml.Node { + return purgeNodes(node, IsOrgLocal) +} + +func purgeNodes(node *yaml.Node, predicate func(*yaml.Node) bool) *yaml.Node { + if node == nil || predicate(node) { return nil } @@ -226,17 +242,17 @@ func purgeLocalContent(node *yaml.Node) *yaml.Node { switch node.Kind { case yaml.MappingNode: for i := 0; i < len(node.Content); i += 2 { - if IsLocal(node.Content[i+1]) { + if predicate(node.Content[i+1]) { continue } - copy.Content = append(copy.Content, node.Content[i], purgeLocalContent(node.Content[i+1])) + copy.Content = append(copy.Content, node.Content[i], purgeNodes(node.Content[i+1], predicate)) } case yaml.SequenceNode: for _, item := range node.Content { - if IsLocal(item) { + if predicate(item) { continue } - copy.Content = append(copy.Content, purgeLocalContent(item)) + copy.Content = append(copy.Content, purgeNodes(item, predicate)) } } diff --git a/internal/yml/merge_test.go b/internal/yml/merge_test.go index 36aa177f..884b9275 100644 --- a/internal/yml/merge_test.go +++ b/internal/yml/merge_test.go @@ -400,7 +400,7 @@ func testMerge(t *testing.T, testcase MergeCase) { } // Merge - result := Merge(dst, src) + result := Merge(dst, src, false) // Marshal the result with custom indentation var buf bytes.Buffer @@ -450,7 +450,7 @@ e: k: TODO ` // Merge YAML nodes - mergedNode := Merge(nil, &sourceNode) + mergedNode := Merge(nil, &sourceNode, false) // Marshal the merged result var buf bytes.Buffer @@ -525,7 +525,7 @@ func TestTodoMerge(t *testing.T) { var dst yaml.Node require.NoError(t, yaml.Unmarshal([]byte(tc.Dst), &dst)) - result, err := yaml.Marshal(Merge(&dst, &src).Content[0]) + result, err := yaml.Marshal(Merge(&dst, &src, false).Content[0]) require.NoError(t, err) result = bytes.TrimSpace(result) @@ -751,7 +751,7 @@ func TestYmlMerge(t *testing.T) { require.NoError(t, yaml.Unmarshal([]byte(tc.Src), &src)) require.NoError(t, yaml.Unmarshal([]byte(tc.Dst), &dst)) - actual, err := yaml.Marshal(Merge(&dst, &src)) + actual, err := yaml.Marshal(Merge(&dst, &src, false)) require.NoError(t, err) actual = bytes.TrimSpace(actual) @@ -761,7 +761,7 @@ func TestYmlMerge(t *testing.T) { var result yaml.Node require.NoError(t, yaml.Unmarshal(actual, &result)) - check, err := yaml.Marshal(Merge(&result, &src)) + check, err := yaml.Marshal(Merge(&result, &src, false)) require.NoError(t, err) check = bytes.TrimSpace(check) @@ -769,3 +769,20 @@ func TestYmlMerge(t *testing.T) { }) } } + +func TestOrgLocalMerge(t *testing.T) { + var src, dst yaml.Node + + require.NoError(t, yaml.Unmarshal([]byte(`{ + apple: bottom, + jeans: !org-local boots, + }`), &src)) + + require.NoError(t, yaml.Unmarshal([]byte(`{}`), &dst)) + + result := Merge(&dst, &src, true) + require.Equal(t, "boots", FindNodeValueOrDefault(result, "jeans", "not-found")) + + result = Merge(&dst, &src, false) + require.Equal(t, "not-found", FindNodeValueOrDefault(result, "jeans", "not-found")) +} diff --git a/internal/yml/tags.go b/internal/yml/tags.go index 30136b01..fc6f454d 100644 --- a/internal/yml/tags.go +++ b/internal/yml/tags.go @@ -12,6 +12,6 @@ var StandardTags = []string{ "!!merge", } -var CustomTags = []string{"!lock", "!local"} +var CustomTags = []string{"!lock", "!local", "!org-local"} var KnownTags = append(StandardTags, CustomTags...)