diff --git a/commands/pkg/update/cmdupdate_test.go b/commands/pkg/update/cmdupdate_test.go index d391521a60..e2274649eb 100644 --- a/commands/pkg/update/cmdupdate_test.go +++ b/commands/pkg/update/cmdupdate_test.go @@ -30,9 +30,11 @@ import ( internalgitutil "github.com/kptdev/kpt/internal/gitutil" "github.com/kptdev/kpt/internal/testutil" "github.com/kptdev/kpt/internal/testutil/pkgbuilder" + "github.com/kptdev/kpt/pkg/kptfile/kptfileutil" "github.com/kptdev/kpt/pkg/printer/fake" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "sigs.k8s.io/kustomize/kyaml/filesys" "sigs.k8s.io/kustomize/kyaml/yaml" ) @@ -132,6 +134,152 @@ func TestCmd_execute(t *testing.T) { } } +// TestCmd_subpkgVersions verifies that update is correctly invoked with an upstream 'mysql' package with multiple versions +func TestCmd_subpkgVersions(t *testing.T) { + // Setup version v1 of upstream package + g, w, clean := testutil.SetupRepoAndWorkspace(t, testutil.Content{ + Data: testutil.Dataset1, + Branch: "master", + }) + defer clean() + + commitDs1, err := g.GetCommit() + if !assert.NoError(t, err) { + return + } + err = g.Tag("dataset1") + if !assert.NoError(t, err) { + t.FailNow() + } + // update the master branch + if !assert.NoError(t, g.ReplaceData(testutil.Dataset2)) { + return + } + _, err = g.Commit("modify upstream package -- ds2") + if !assert.NoError(t, err) { + return + } + err = g.Tag("dataset2") + if !assert.NoError(t, err) { + t.FailNow() + } + + defer testutil.Chdir(t, w.WorkspaceDirectory)() + + dest := filepath.Join(w.WorkspaceDirectory, "mysql") + + // pkg get package version 'dataset1' + getCmd := get.NewRunner(fake.CtxWithDefaultPrinter(), "kpt") + getCmd.Command.SetArgs([]string{"file://" + g.RepoDirectory + ".git/mysql@dataset1", w.WorkspaceDirectory}) + err = getCmd.Command.Execute() + if !assert.NoError(t, err) { + return + } + if !g.AssertEqual(t, filepath.Join(g.DatasetDirectory, testutil.Dataset1, "mysql"), dest, true) { + return + } + + // Reference Kptfile for package version 'dataset1' + pkgDs1Kptfile, err := kptfileutil.ReadKptfile(filesys.FileSystemOrOnDisk{}, filepath.Join(g.DatasetDirectory, testutil.Dataset1, "mysql")) + if !assert.NoError(t, err) { + return + } + if !g.AssertKptfile(t, dest, kptfilev1.KptFile{ + ResourceMeta: yaml.ResourceMeta{ + ObjectMeta: yaml.ObjectMeta{ + NameMeta: yaml.NameMeta{ + Name: "mysql", + }, + }, + TypeMeta: yaml.TypeMeta{ + APIVersion: kptfilev1.TypeMeta.APIVersion, + Kind: kptfilev1.TypeMeta.Kind}, + }, + Upstream: &kptfilev1.Upstream{ + Type: kptfilev1.GitOrigin, + Git: &kptfilev1.Git{ + Repo: "file://" + g.RepoDirectory, + Ref: "dataset1", + Directory: "/mysql", + }, + UpdateStrategy: kptfilev1.ResourceMerge, // Defaulted + }, + UpstreamLock: &kptfilev1.Locator{ + Type: kptfilev1.GitOrigin, + Git: &kptfilev1.GitLock{ + Repo: "file://" + g.RepoDirectory, + Ref: "dataset1", + Directory: "/mysql", + Commit: commitDs1, + }, + }, + Info: &kptfilev1.PackageInfo{ + Description: pkgDs1Kptfile.Info.Description, + }, + Pipeline: pkgDs1Kptfile.Pipeline, + }) { + return + } + + // pkg update to version 'dataset2' + updateCmd := update.NewRunner(fake.CtxWithDefaultPrinter(), "kpt") + updateCmd.Command.SetArgs([]string{"mysql@dataset2", "--strategy", "fast-forward"}) + if !assert.NoError(t, updateCmd.Command.Execute()) { + return + } + if !g.AssertEqual(t, filepath.Join(g.DatasetDirectory, testutil.Dataset2, "mysql"), dest, true) { + return + } + + commitDs2, err := g.GetCommit() + if !assert.NoError(t, err) { + return + } + + // Reference Kptfile for package version 'dataset2' + pkgDs2Kptfile, err := kptfileutil.ReadKptfile(filesys.FileSystemOrOnDisk{}, filepath.Join(g.DatasetDirectory, testutil.Dataset2, "mysql")) + if !assert.NoError(t, err) { + return + } + + if !g.AssertKptfile(t, dest, kptfilev1.KptFile{ + ResourceMeta: yaml.ResourceMeta{ + ObjectMeta: yaml.ObjectMeta{ + NameMeta: yaml.NameMeta{ + Name: "mysql", + }, + }, + TypeMeta: yaml.TypeMeta{ + APIVersion: kptfilev1.TypeMeta.APIVersion, + Kind: kptfilev1.TypeMeta.Kind}, + }, + Upstream: &kptfilev1.Upstream{ + Type: kptfilev1.GitOrigin, + Git: &kptfilev1.Git{ + Repo: "file://" + g.RepoDirectory, + Ref: "dataset2", + Directory: "/mysql", + }, + UpdateStrategy: kptfilev1.FastForward, + }, + UpstreamLock: &kptfilev1.Locator{ + Type: kptfilev1.GitOrigin, + Git: &kptfilev1.GitLock{ + Repo: "file://" + g.RepoDirectory, + Ref: "dataset2", + Directory: "/mysql", + Commit: commitDs2, + }, + }, + Info: &kptfilev1.PackageInfo{ + Description: pkgDs2Kptfile.Info.Description, + }, + Pipeline: pkgDs2Kptfile.Pipeline, + }) { + return + } +} + func TestCmd_successUnCommitted(t *testing.T) { g, w, clean := testutil.SetupRepoAndWorkspace(t, testutil.Content{ Data: testutil.Dataset1, diff --git a/internal/testutil/testdata/dataset1/mysql/Kptfile b/internal/testutil/testdata/dataset1/mysql/Kptfile new file mode 100644 index 0000000000..9e3f844197 --- /dev/null +++ b/internal/testutil/testdata/dataset1/mysql/Kptfile @@ -0,0 +1,16 @@ +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: mysql +info: + description: kpt package for mysql +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/set-namespace:v0.4.1 + configMap: + namespace: example-ns + name: set namespace + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + color: orange + name: set color label diff --git a/internal/testutil/testdata/dataset2/mysql/Kptfile b/internal/testutil/testdata/dataset2/mysql/Kptfile new file mode 100644 index 0000000000..dab64aba7c --- /dev/null +++ b/internal/testutil/testdata/dataset2/mysql/Kptfile @@ -0,0 +1,20 @@ +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: mysql +info: + description: mysql package +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/set-namespace:v0.4.1 + configMap: + namespace: example-ns + name: set namespace + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + fruit: apple + name: set fruit label + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + color: orange + name: set color label diff --git a/internal/testutil/testdata/dataset3/mysql/Kptfile b/internal/testutil/testdata/dataset3/mysql/Kptfile new file mode 100644 index 0000000000..dab64aba7c --- /dev/null +++ b/internal/testutil/testdata/dataset3/mysql/Kptfile @@ -0,0 +1,20 @@ +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: mysql +info: + description: mysql package +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/set-namespace:v0.4.1 + configMap: + namespace: example-ns + name: set namespace + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + fruit: apple + name: set fruit label + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + color: orange + name: set color label diff --git a/internal/testutil/testdata/dataset4/mysql/Kptfile b/internal/testutil/testdata/dataset4/mysql/Kptfile new file mode 100644 index 0000000000..dab64aba7c --- /dev/null +++ b/internal/testutil/testdata/dataset4/mysql/Kptfile @@ -0,0 +1,20 @@ +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: mysql +info: + description: mysql package +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/set-namespace:v0.4.1 + configMap: + namespace: example-ns + name: set namespace + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + fruit: apple + name: set fruit label + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + color: orange + name: set color label diff --git a/internal/testutil/testdata/dataset5/mysql/Kptfile b/internal/testutil/testdata/dataset5/mysql/Kptfile new file mode 100644 index 0000000000..dab64aba7c --- /dev/null +++ b/internal/testutil/testdata/dataset5/mysql/Kptfile @@ -0,0 +1,20 @@ +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: mysql +info: + description: mysql package +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/set-namespace:v0.4.1 + configMap: + namespace: example-ns + name: set namespace + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + fruit: apple + name: set fruit label + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + color: orange + name: set color label diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index ff22b339cf..aef4aee036 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -15,7 +15,6 @@ package testutil import ( - "bytes" "fmt" "os" "os/exec" @@ -47,10 +46,10 @@ const TmpDirPrefix = "test-kpt" const ( Dataset1 = "dataset1" - Dataset2 = "dataset2" + Dataset2 = "dataset2" // Dataset2 is a replica of Dataset1 with workload spec changes (ports, replica count etc.) and a Kptfile mod Dataset3 = "dataset3" - Dataset4 = "dataset4" // Dataset4 is replica of Dataset2 with different setter values - Dataset5 = "dataset5" // Dataset5 is replica of Dataset2 with additional non KRM files + Dataset4 = "dataset4" // Dataset4 is a replica of Dataset2 with different setter values + Dataset5 = "dataset5" // Dataset5 is a replica of Dataset2 with additional non KRM files Dataset6 = "dataset6" // Dataset6 contains symlinks DatasetMerged = "datasetmerged" DiffOutput = "diff_output" @@ -98,10 +97,22 @@ func (g *TestGitRepo) AssertEqual(t *testing.T, sourceDir, destDir string, addMe if !assert.NoError(t, err) { return false } - diff = diff.Difference(KptfileSet) + diff = removeKptfiles(diff) return assert.Empty(t, diff.List()) } +// removeKptfiles removes all Kptfile paths (at any depth) from the diff set, +// since Kptfile content is validated separately by AssertKptfile. +func removeKptfiles(s sets.String) sets.String { + result := sets.String{} + for _, item := range s.List() { + if filepath.Base(item) != kptfilev1.KptFileName { + result.Insert(item) + } + } + return result +} + // KptfileAwarePkgEqual compares two packages (including any subpackages) // and has special handling of Kptfiles to handle fields that contain // values which cannot easily be specified in the golden package. @@ -273,12 +284,12 @@ func (g *TestGitRepo) AssertKptfile(t *testing.T, cloned string, kpkg kptfilev1. if !assert.NoError(t, err) { return false } - var res bytes.Buffer - d := yaml.NewEncoder(&res) - if !assert.NoError(t, d.Encode(kpkg)) { + // This mirrors 'WriteFile' in pkg/kptfile/kptfileutil/util.go + res, err := yaml.MarshalWithOptions(kpkg, &yaml.EncoderOptions{SeqIndent: yaml.WideSequenceStyle}) + if !assert.NoError(t, err) { return false } - return assert.Equal(t, res.String(), string(b)) + return assert.Equal(t, string(res), string(b)) } // CheckoutBranch checks out the git branch in the repo diff --git a/pkg/kptfile/kptfileutil/util.go b/pkg/kptfile/kptfileutil/util.go index 2810aeb627..5cfbac4d8d 100644 --- a/pkg/kptfile/kptfileutil/util.go +++ b/pkg/kptfile/kptfileutil/util.go @@ -170,6 +170,19 @@ func DefaultKptfile(name string) *kptfilev1.KptFile { } } +// readKptfileOrEmpty reads the Kptfile at the given path, returning an empty +// KptFile if the file does not exist. Any other error is returned as-is. +func readKptfileOrEmpty(path string) (*kptfilev1.KptFile, error) { + kf, err := ReadKptfile(filesys.FileSystemOrOnDisk{}, path) + if err != nil { + if !goerrors.Is(err, os.ErrNotExist) { + return nil, err + } + return &kptfilev1.KptFile{}, nil + } + return kf, nil +} + // UpdateKptfileWithoutOrigin updates the Kptfile in the package specified by // localPath with values from the package specified by updatedPath using a 3-way // merge strategy, but where origin does not have any values. @@ -177,24 +190,17 @@ func DefaultKptfile(name string) *kptfilev1.KptFile { // sections will also be copied into local. func UpdateKptfileWithoutOrigin(localPath, updatedPath string, updateUpstream bool) error { const op errors.Op = "kptfileutil.UpdateKptfileWithoutOrigin" - localKf, err := ReadKptfile(filesys.FileSystemOrOnDisk{}, localPath) + localKf, err := readKptfileOrEmpty(localPath) if err != nil { - if !goerrors.Is(err, os.ErrNotExist) { - return errors.E(op, kptfilev1.UniquePath(localPath), err) - } - localKf = &kptfilev1.KptFile{} + return errors.E(op, kptfilev1.UniquePath(localPath), err) } - updatedKf, err := ReadKptfile(filesys.FileSystemOrOnDisk{}, updatedPath) + updatedKf, err := readKptfileOrEmpty(updatedPath) if err != nil { - if !goerrors.Is(err, os.ErrNotExist) { - return errors.E(op, kptfilev1.UniquePath(updatedPath), err) - } - updatedKf = &kptfilev1.KptFile{} + return errors.E(op, kptfilev1.UniquePath(updatedPath), err) } - err = MergeKptfiles(localKf, updatedKf, &kptfilev1.KptFile{}) - if err != nil { + if err = MergeKptfiles(localKf, updatedKf, &kptfilev1.KptFile{}); err != nil { return err } @@ -202,8 +208,7 @@ func UpdateKptfileWithoutOrigin(localPath, updatedPath string, updateUpstream bo updateUpstreamAndUpstreamLock(localKf, updatedKf) } - err = WriteFile(localPath, localKf) - if err != nil { + if err = WriteFile(localPath, localKf); err != nil { return errors.E(op, kptfilev1.UniquePath(localPath), err) } return nil @@ -216,32 +221,22 @@ func UpdateKptfileWithoutOrigin(localPath, updatedPath string, updateUpstream bo // sections will also be copied into local. func UpdateKptfile(localPath, updatedPath, originPath string, updateUpstream bool) error { const op errors.Op = "kptfileutil.UpdateKptfile" - localKf, err := ReadKptfile(filesys.FileSystemOrOnDisk{}, localPath) + localKf, err := readKptfileOrEmpty(localPath) if err != nil { - if !goerrors.Is(err, os.ErrNotExist) { - return errors.E(op, kptfilev1.UniquePath(localPath), err) - } - localKf = &kptfilev1.KptFile{} + return errors.E(op, kptfilev1.UniquePath(localPath), err) } - updatedKf, err := ReadKptfile(filesys.FileSystemOrOnDisk{}, updatedPath) + updatedKf, err := readKptfileOrEmpty(updatedPath) if err != nil { - if !goerrors.Is(err, os.ErrNotExist) { - return errors.E(op, kptfilev1.UniquePath(localPath), err) - } - updatedKf = &kptfilev1.KptFile{} + return errors.E(op, kptfilev1.UniquePath(localPath), err) } - originKf, err := ReadKptfile(filesys.FileSystemOrOnDisk{}, originPath) + originKf, err := readKptfileOrEmpty(originPath) if err != nil { - if !goerrors.Is(err, os.ErrNotExist) { - return errors.E(op, kptfilev1.UniquePath(localPath), err) - } - originKf = &kptfilev1.KptFile{} + return errors.E(op, kptfilev1.UniquePath(localPath), err) } - err = MergeKptfiles(localKf, updatedKf, originKf) - if err != nil { + if err = MergeKptfiles(localKf, updatedKf, originKf); err != nil { return err } @@ -249,10 +244,51 @@ func UpdateKptfile(localPath, updatedPath, originPath string, updateUpstream boo updateUpstreamAndUpstreamLock(localKf, updatedKf) } - err = WriteFile(localPath, localKf) + if err = WriteFile(localPath, localKf); err != nil { + return errors.E(op, kptfilev1.UniquePath(localPath), err) + } + return nil +} + +// ReplaceKptfile replaces the Kptfile at localPath with content from the +// package at updatedPath. Unlike UpdateKptfile, it does not perform a 3-way +// merge — it takes all content fields directly from the updated Kptfile. +// This is appropriate for update strategies that discard local changes +// (fast-forward, force-delete-replace). +// The package name and namespace from local are preserved. The inventory +// field is preserved if the updated Kptfile does not define one (since +// inventory is typically set locally by kpt live init). +func ReplaceKptfile(localPath, updatedPath string) error { + const op errors.Op = "kptfileutil.ReplaceKptfile" + localKf, err := readKptfileOrEmpty(localPath) + if err != nil { + return errors.E(op, kptfilev1.UniquePath(localPath), err) + } + + updatedKf, err := readKptfileOrEmpty(updatedPath) if err != nil { return errors.E(op, kptfilev1.UniquePath(localPath), err) } + + // Take content fields directly from upstream. + localKf.Annotations = updatedKf.Annotations + localKf.Labels = updatedKf.Labels + localKf.Info = updatedKf.Info + localKf.Pipeline = updatedKf.Pipeline + localKf.Status = updatedKf.Status + + // Preserve local inventory if upstream doesn't define one, since + // inventory is typically set locally by kpt live init. + if updatedKf.Inventory != nil { + localKf.Inventory = updatedKf.Inventory + } + + // Always update upstream tracking fields. + updateUpstreamAndUpstreamLock(localKf, updatedKf) + + if err = WriteFile(localPath, localKf); err != nil { + return errors.E(op, kptfilev1.UniquePath(localPath), err) + } return nil } diff --git a/pkg/kptfile/kptfileutil/util_test.go b/pkg/kptfile/kptfileutil/util_test.go index b2961e6e9f..b5b40e3342 100644 --- a/pkg/kptfile/kptfileutil/util_test.go +++ b/pkg/kptfile/kptfileutil/util_test.go @@ -25,6 +25,33 @@ import ( "sigs.k8s.io/kustomize/kyaml/yaml" ) +// writeKptfileToTemp writes the given Kptfile content to a fresh temporary +// directory and returns that directory's path. +func writeKptfileToTemp(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, kptfilev1.KptFileName), []byte(content), 0600) + if !assert.NoError(t, err) { + t.FailNow() + } + return dir +} + +const ( + invalidKptfileContent = ` +apiVersion: kpt.dev/v1alpha1 +kind: Kptfile +metadata: + name: bad +` + validKptfileContent = ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: valid +` +) + // TestValidateInventory tests the ValidateInventory function. func TestValidateInventory(t *testing.T) { // nil inventory should not validate @@ -61,15 +88,6 @@ func TestValidateInventory(t *testing.T) { } func TestUpdateKptfile(t *testing.T) { - writeKptfileToTemp := func(tt *testing.T, content string) string { - dir := tt.TempDir() - err := os.WriteFile(filepath.Join(dir, kptfilev1.KptFileName), []byte(content), 0600) - if !assert.NoError(t, err) { - t.FailNow() - } - return dir - } - testCases := map[string]struct { origin string updated string @@ -1408,3 +1426,348 @@ pipeline: }) } } + +func TestReplaceKptfile(t *testing.T) { + testCases := map[string]struct { + updated string + local string + expected string + }{ + "pipeline is taken from updated, preserving order": { + local: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: mysql +upstream: + type: git + git: + repo: github.com/example/repo + directory: /mysql + ref: v1 +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/set-namespace:v0.4.1 + configMap: + namespace: example-ns + name: set namespace + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + color: orange + name: set color label +`, + updated: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: mysql +upstream: + type: git + git: + repo: github.com/example/repo + directory: /mysql + ref: v2 +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/set-namespace:v0.4.1 + configMap: + namespace: example-ns + name: set namespace + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + fruit: apple + name: set fruit label + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + color: orange + name: set color label +`, + expected: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: mysql +upstream: + type: git + git: + repo: github.com/example/repo + directory: /mysql + ref: v2 +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/set-namespace:v0.4.1 + configMap: + namespace: example-ns + name: set namespace + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + fruit: apple + name: set fruit label + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.2.0 + configMap: + color: orange + name: set color label +`, + }, + + "local inventory is preserved when updated has none": { + local: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: foo +inventory: + namespace: test-ns + name: test-name + inventoryID: test-id +pipeline: + mutators: + - image: foo:bar +`, + updated: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: foo +pipeline: + mutators: + - image: foo:baz +`, + expected: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: foo +pipeline: + mutators: + - image: foo:baz +inventory: + namespace: test-ns + name: test-name + inventoryID: test-id +`, + }, + + "inventory is replaced when updated defines one": { + local: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: foo +inventory: + namespace: old-ns + name: old-name + inventoryID: old-id +`, + updated: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: foo +inventory: + namespace: new-ns + name: new-name + inventoryID: new-id +`, + expected: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: foo +inventory: + namespace: new-ns + name: new-name + inventoryID: new-id +`, + }, + + "name and namespace are preserved from local": { + local: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: my-local-name + namespace: my-ns +`, + updated: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: upstream-name + namespace: upstream-ns +info: + description: from upstream +`, + expected: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: my-local-name + namespace: my-ns +info: + description: from upstream +`, + }, + + "upstream and upstreamLock are updated from updated": { + local: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: foo +upstream: + type: git + git: + repo: github.com/example/repo + directory: / + ref: v1 +upstreamLock: + type: git + git: + repo: github.com/example/repo + directory: / + ref: v1 + commit: aaa111 +`, + updated: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: foo +upstream: + type: git + git: + repo: github.com/example/repo + directory: / + ref: v2 +upstreamLock: + type: git + git: + repo: github.com/example/repo + directory: / + ref: v2 + commit: bbb222 +`, + expected: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: foo +upstream: + type: git + git: + repo: github.com/example/repo + directory: / + ref: v2 +upstreamLock: + type: git + git: + repo: github.com/example/repo + directory: / + ref: v2 + commit: bbb222 +`, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + localDir := writeKptfileToTemp(t, tc.local) + updatedDir := writeKptfileToTemp(t, tc.updated) + + err := ReplaceKptfile(localDir, updatedDir) + if !assert.NoError(t, err) { + t.FailNow() + } + + c, err := os.ReadFile(filepath.Join(localDir, kptfilev1.KptFileName)) + if !assert.NoError(t, err) { + t.FailNow() + } + + assert.Equal(t, strings.TrimSpace(tc.expected)+"\n", string(c)) + }) + } +} + +func TestReplaceKptfile_errorPaths(t *testing.T) { + t.Run("error reading local Kptfile", func(t *testing.T) { + localDir := writeKptfileToTemp(t, invalidKptfileContent) + updatedDir := writeKptfileToTemp(t, validKptfileContent) + + err := ReplaceKptfile(localDir, updatedDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "kptfileutil.ReplaceKptfile") + }) + + t.Run("error reading updated Kptfile", func(t *testing.T) { + localDir := writeKptfileToTemp(t, validKptfileContent) + updatedDir := writeKptfileToTemp(t, invalidKptfileContent) + + err := ReplaceKptfile(localDir, updatedDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "kptfileutil.ReplaceKptfile") + }) + + t.Run("error writing Kptfile", func(t *testing.T) { + localDir := writeKptfileToTemp(t, validKptfileContent) + updatedDir := writeKptfileToTemp(t, validKptfileContent) + + err := os.Chmod(filepath.Join(localDir, kptfilev1.KptFileName), 0444) + if !assert.NoError(t, err) { + t.FailNow() + } + err = os.Chmod(localDir, 0555) + if !assert.NoError(t, err) { + t.FailNow() + } + defer func() { + _ = os.Chmod(localDir, 0755) + _ = os.Chmod(filepath.Join(localDir, kptfilev1.KptFileName), 0644) + }() + + err = ReplaceKptfile(localDir, updatedDir) + assert.Error(t, err) + }) +} + +func TestUpdateKptfileWithoutOrigin_errorPaths(t *testing.T) { + t.Run("error reading local Kptfile", func(t *testing.T) { + localDir := writeKptfileToTemp(t, invalidKptfileContent) + updatedDir := writeKptfileToTemp(t, validKptfileContent) + + err := UpdateKptfileWithoutOrigin(localDir, updatedDir, true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "kptfileutil.UpdateKptfileWithoutOrigin") + }) + + t.Run("error reading updated Kptfile", func(t *testing.T) { + localDir := writeKptfileToTemp(t, validKptfileContent) + updatedDir := writeKptfileToTemp(t, invalidKptfileContent) + + err := UpdateKptfileWithoutOrigin(localDir, updatedDir, true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "kptfileutil.UpdateKptfileWithoutOrigin") + }) + + t.Run("error writing Kptfile", func(t *testing.T) { + localDir := writeKptfileToTemp(t, validKptfileContent) + updatedDir := writeKptfileToTemp(t, validKptfileContent) + + err := os.Chmod(filepath.Join(localDir, kptfilev1.KptFileName), 0444) + if !assert.NoError(t, err) { + t.FailNow() + } + err = os.Chmod(localDir, 0555) + if !assert.NoError(t, err) { + t.FailNow() + } + defer func() { + _ = os.Chmod(localDir, 0755) + _ = os.Chmod(filepath.Join(localDir, kptfilev1.KptFileName), 0644) + }() + + err = UpdateKptfileWithoutOrigin(localDir, updatedDir, false) + assert.Error(t, err) + }) +} diff --git a/pkg/lib/update/replace.go b/pkg/lib/update/replace.go index 047cb73430..dc90e2468f 100644 --- a/pkg/lib/update/replace.go +++ b/pkg/lib/update/replace.go @@ -36,8 +36,9 @@ var _ updatetypes.Updater = &ReplaceUpdater{} func (u ReplaceUpdater) Update(options updatetypes.Options) error { const op errors.Op = "update.Update" - // Update Kptfile for root package - if err := kptfileutil.UpdateKptfile(options.LocalPath, options.UpdatedPath, options.OriginPath, true); err != nil { + // Replace Kptfile wholesale — no 3-way merge needed since this strategy + // discards local changes. + if err := kptfileutil.ReplaceKptfile(options.LocalPath, options.UpdatedPath); err != nil { return errors.E(op, kptfilev1.UniquePath(options.LocalPath), err) }