diff --git a/commands/pkg/pkgcmd.go b/commands/pkg/pkgcmd.go index 0430c33d65..6c3951e0f4 100644 --- a/commands/pkg/pkgcmd.go +++ b/commands/pkg/pkgcmd.go @@ -1,4 +1,4 @@ -// Copyright 2019 The kpt Authors +// Copyright 2019,2026 The kpt Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import ( initialization "github.com/kptdev/kpt/commands/pkg/init" "github.com/kptdev/kpt/commands/pkg/update" "github.com/kptdev/kpt/internal/docs/generated/pkgdocs" + "github.com/kptdev/kpt/thirdparty/cmdconfig/commands/cmdcat" "github.com/kptdev/kpt/thirdparty/cmdconfig/commands/cmdtree" "github.com/spf13/cobra" ) @@ -45,9 +46,12 @@ func GetCommand(ctx context.Context, name string) *cobra.Command { } pkg.AddCommand( - get.NewCommand(ctx, name), initialization.NewCommand(ctx, name), - update.NewCommand(ctx, name), diff.NewCommand(ctx, name), + get.NewCommand(ctx, name), + initialization.NewCommand(ctx, name), + update.NewCommand(ctx, name), + diff.NewCommand(ctx, name), cmdtree.NewCommand(ctx, name), + cmdcat.NewCommand(ctx, name), ) return pkg } diff --git a/internal/docs/generated/pkgdocs/docs.go b/internal/docs/generated/pkgdocs/docs.go index 62c6132e50..f41f43cea0 100644 --- a/internal/docs/generated/pkgdocs/docs.go +++ b/internal/docs/generated/pkgdocs/docs.go @@ -7,22 +7,27 @@ The ` + "`" + `pkg` + "`" + ` command group contains subcommands for fetching, u from git repositories. ` -var CatShort = `Print the resources in a file/directory` +var CatShort = `Print the contents of a package` var CatLong = ` kpt pkg cat [FILE | DIR] Args: FILE | DIR: - Path to a directory either a directory containing files with KRM resources, or - a file with KRM resource(s). Defaults to the current directory. + Path to a file or a directory containing a kpt package. Displays all + package files: KRM resources (YAML/JSON) are formatted by default, + and non-KRM text files (e.g., README.md) are shown as raw content. + Binary files are skipped. Defaults to the current directory. ` var CatExamples = ` - # Print resource from a file. + # Print all package contents from current directory. + $ kpt pkg cat + + # Print a single resource file. $ kpt pkg cat path/to/deployment.yaml - # Print resources from current directory. - $ kpt pkg cat + # Print a non-KRM file. + $ kpt pkg cat path/to/README.md ` var DiffShort = `Show differences between a local package and upstream.` diff --git a/thirdparty/cmdconfig/commands/cmdcat/cmdcat.go b/thirdparty/cmdconfig/commands/cmdcat/cmdcat.go new file mode 100644 index 0000000000..393774edfc --- /dev/null +++ b/thirdparty/cmdconfig/commands/cmdcat/cmdcat.go @@ -0,0 +1,244 @@ +// Copyright 2019,2026 The kpt Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmdcat + +import ( + "bytes" + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "unicode/utf8" + + "github.com/kptdev/kpt/internal/docs/generated/pkgdocs" + "github.com/kptdev/kpt/internal/util/argutil" + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "github.com/kptdev/kpt/thirdparty/cmdconfig/commands/runner" + "github.com/spf13/cobra" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/kio/filters" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +// krmMatch is the set of file globs that should be processed by this command, +// including the default KRM resource matches and Kptfile. +var krmMatch = append(append([]string{}, kio.DefaultMatch...), kptfilev1.KptFileName) + +// GetCatRunner returns a command CatRunner. +func GetCatRunner(ctx context.Context, _ string) *CatRunner { + r := &CatRunner{ + Ctx: ctx, + } + c := &cobra.Command{ + Use: "cat [FILE | DIR]", + Short: pkgdocs.CatShort, + Long: pkgdocs.CatLong, + Example: pkgdocs.CatExamples, + RunE: r.runE, + Args: cobra.MaximumNArgs(1), + } + c.Flags().BoolVar(&r.Format, "format", true, + "format resource config YAML before printing (reorders fields to canonical order).") + c.Flags().BoolVar(&r.KeepAnnotations, "annotate", false, + "annotate resources with their file origins.") + c.Flags().StringSliceVar(&r.Styles, "style", []string{}, + "yaml styles to apply. may be 'TaggedStyle', 'DoubleQuotedStyle', 'LiteralStyle', "+ + "'FoldedStyle', 'FlowStyle'.") + c.Flags().BoolVar(&r.StripComments, "strip-comments", false, + "remove comments from yaml.") + c.Flags().BoolVarP(&r.RecurseSubPackages, "recurse-subpackages", "R", true, + "print resources recursively in all the nested subpackages") + r.Command = c + return r +} + +func NewCommand(ctx context.Context, name string) *cobra.Command { + return GetCatRunner(ctx, name).Command +} + +// CatRunner contains the run function +type CatRunner struct { + Command *cobra.Command + Ctx context.Context + Format bool + KeepAnnotations bool + Styles []string + StripComments bool + RecurseSubPackages bool +} + +func (r *CatRunner) runE(c *cobra.Command, args []string) error { + var writer = c.OutOrStdout() + if len(args) == 0 { + args = append(args, ".") + } + + if err := r.Ctx.Err(); err != nil { + return runner.HandleError(r.Ctx, err) + } + + resolvedPath, err := argutil.ResolveSymlink(r.Ctx, args[0]) + if err != nil { + return runner.HandleError(r.Ctx, err) + } + + out := &bytes.Buffer{} + + // Single file: process directly without package traversal. + if info, err := os.Stat(resolvedPath); err == nil && !info.IsDir() { + if err := r.ExecuteCmd(out, resolvedPath); err != nil { + return runner.HandleError(r.Ctx, err) + } + } else { + e := runner.ExecuteCmdOnPkgs{ + Writer: out, + NeedOpenAPI: false, + RecurseSubPackages: r.RecurseSubPackages, + CmdRunner: r, + RootPkgPath: filepath.Clean(resolvedPath), + SkipPkgPathPrint: true, + } + if err := e.Execute(); err != nil { + return runner.HandleError(r.Ctx, err) + } + } + + res := strings.TrimSuffix(out.String(), "---\n") + res = strings.TrimSuffix(res, "---") + fmt.Fprintf(writer, "%s", res) + return nil +} + +// ExecuteCmd outputs the contents of a single package at pkgPath. +// It intentionally does NOT recurse into nested subpackages (directories +// containing a Kptfile). Subpackage recursion is handled by the caller +// (runner.ExecuteCmdOnPkgs) which invokes ExecuteCmd once per package +// when RecurseSubPackages is set. Callers invoking ExecuteCmd directly +// will only see the top-level package content; use ExecuteCmdOnPkgs for +// recursive traversal. +func (r *CatRunner) ExecuteCmd(w io.Writer, pkgPath string) error { + var parts []string + + err := filepath.WalkDir(pkgPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if r.Ctx.Err() != nil { + return r.Ctx.Err() + } + + // Skip symlinks to avoid reading outside the package. + if d.Type()&os.ModeSymlink != 0 { + return nil + } + if d.IsDir() { + if path != pkgPath { + // Skip nested subpackages; the caller is responsible for + // recursing into them via RecurseSubPackages if desired. + if _, statErr := os.Stat(filepath.Join(path, kptfilev1.KptFileName)); statErr == nil { + return filepath.SkipDir + } + } + return nil + } + + relPath, _ := filepath.Rel(pkgPath, path) + if relPath == "." { + relPath = filepath.Base(path) + } + ext := strings.ToLower(filepath.Ext(path)) + + if ext == ".yaml" || ext == ".yml" || ext == ".json" || filepath.Base(path) == kptfilev1.KptFileName { + // KRM file: read through pipeline with formatting. + buf := &bytes.Buffer{} + input := kio.LocalPackageReader{PackagePath: path, PackageFileName: "", MatchFilesGlob: krmMatch} + pErr := kio.Pipeline{ + Inputs: []kio.Reader{input}, + Filters: r.catFilters(), + Outputs: r.outputWriter(buf), + }.Execute() + if pErr != nil { + return fmt.Errorf("kpt pkg cat: %q: %w", relPath, pErr) + } + if s := buf.String(); s != "" { + parts = append(parts, s) + } + } else { + // Non-KRM file: display raw if it's valid UTF-8 text. + data, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + if !utf8.Valid(data) { + return nil // skip binary files + } + content := string(data) + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + parts = append(parts, content) + } + return nil + }) + if err != nil { + return err + } + + combined := strings.Join(parts, "---\n") + fmt.Fprint(w, combined) + if combined != "" { + fmt.Fprint(w, "---") + } + return nil +} + +func (r *CatRunner) catFilters() []kio.Filter { + var fltrs []kio.Filter + if r.Format { + fltrs = append(fltrs, filters.FormatFilter{}) + } + if r.StripComments { + fltrs = append(fltrs, filters.StripCommentsFilter{}) + } + return fltrs +} + +func (r *CatRunner) outputWriter(w io.Writer) []kio.Writer { + var outputs []kio.Writer + var functionConfig *yaml.RNode + + // remove these annotations explicitly; the ByteWriter won't clear them by + // default because they were set by the LocalPackageReader, not by it. + clear := []string{ + "config.kubernetes.io/path", + "internal.config.kubernetes.io/path", + } + if r.KeepAnnotations { + clear = nil + } + + outputs = append(outputs, kio.ByteWriter{ + Writer: w, + KeepReaderAnnotations: r.KeepAnnotations, + FunctionConfig: functionConfig, + Style: yaml.GetStyle(r.Styles...), + ClearAnnotations: clear, + }) + + return outputs +} diff --git a/thirdparty/cmdconfig/commands/cmdcat/cmdcat_test.go b/thirdparty/cmdconfig/commands/cmdcat/cmdcat_test.go new file mode 100644 index 0000000000..cd07124711 --- /dev/null +++ b/thirdparty/cmdconfig/commands/cmdcat/cmdcat_test.go @@ -0,0 +1,840 @@ +// Copyright 2019,2026 The kpt Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmdcat + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kptdev/kpt/pkg/printer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const f1Yaml = "f1.yaml" + +// writeFile is a small helper to keep the test bodies terse. +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +// runCat invokes `kpt pkg cat` with the given args and captures stdout. +func runCat(t *testing.T, args ...string) (string, error) { + t.Helper() + b := &bytes.Buffer{} + ctx := printer.WithContext(context.Background(), printer.New(nil, io.Discard)) + r := GetCatRunner(ctx, "") + r.Command.SetArgs(args) + r.Command.SetOut(b) + r.Command.SetErr(&bytes.Buffer{}) + err := r.Command.Execute() + return b.String(), err +} + +// TestCmd_DIR covers the basic directory case with two multi-doc YAML files. +func TestCmd_DIR(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, f1Yaml), ` +kind: Deployment +metadata: + labels: + app: nginx2 + name: foo + annotations: + app: nginx2 +spec: + replicas: 1 +--- +kind: Service +metadata: + name: foo + annotations: + app: nginx +spec: + selector: + app: nginx +`) + writeFile(t, filepath.Join(d, "f2.yaml"), ` +apiVersion: v1 +kind: Abstraction +metadata: + name: foo + configFn: + container: + image: gcr.io/example/reconciler:v1 + annotations: + config.kubernetes.io/local-config: "true" +spec: + replicas: 3 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: nginx + name: bar + annotations: + app: nginx +spec: + replicas: 3 +`) + + got, err := runCat(t, d) + require.NoError(t, err) + assert.Equal(t, `kind: Deployment +metadata: + labels: + app: nginx2 + name: foo + annotations: + app: nginx2 +spec: + replicas: 1 +--- +kind: Service +metadata: + name: foo + annotations: + app: nginx +spec: + selector: + app: nginx +--- +apiVersion: v1 +kind: Abstraction +metadata: + name: foo + annotations: + config.kubernetes.io/local-config: "true" + configFn: + container: + image: gcr.io/example/reconciler:v1 +spec: + replicas: 3 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bar + labels: + app: nginx + annotations: + app: nginx +spec: + replicas: 3 +`, got) +} + +// TestCmd_File covers the single-file-arg code path. +func TestCmd_File(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, f1Yaml), ` +kind: Deployment +metadata: + labels: + app: nginx2 + name: foo + annotations: + app: nginx2 +spec: + replicas: 1 +`) + + got, err := runCat(t, filepath.Join(d, f1Yaml)) + require.NoError(t, err) + assert.Equal(t, `kind: Deployment +metadata: + labels: + app: nginx2 + name: foo + annotations: + app: nginx2 +spec: + replicas: 1 +`, got) +} + +// TestCmd_Annotate verifies --annotate emits all four annotations +// (config.*/path, config.*/index, internal.*/path, internal.*/index). +func TestCmd_Annotate(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, f1Yaml), ` +kind: Deployment +metadata: + labels: + app: nginx2 + name: foo + annotations: + app: nginx2 +spec: + replicas: 1 +`) + + got, err := runCat(t, filepath.Join(d, f1Yaml), "--annotate") + require.NoError(t, err) + assert.Equal(t, `kind: Deployment +metadata: + labels: + app: nginx2 + name: foo + annotations: + app: nginx2 + config.kubernetes.io/index: '0' + config.kubernetes.io/path: 'f1.yaml' + internal.config.kubernetes.io/index: '0' + internal.config.kubernetes.io/path: 'f1.yaml' +spec: + replicas: 1 +`, got) +} + +// TestCmd_AnnotateDefaultOff verifies that in default mode (no --annotate), +// neither config.kubernetes.io/path nor internal.config.kubernetes.io/path leaks. +func TestCmd_AnnotateDefaultOff(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, f1Yaml), ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: a +`) + + got, err := runCat(t, filepath.Join(d, f1Yaml)) + require.NoError(t, err) + assert.NotContains(t, got, "config.kubernetes.io/path", + "config.kubernetes.io/path should be cleared by default") + assert.NotContains(t, got, "internal.config.kubernetes.io/path", + "internal.config.kubernetes.io/path should be cleared by default") +} + +// TestCmd_Subpkgs covers a directory with a regular sub-directory (no Kptfile). +// Resources in the sub-dir are emitted as part of the root package. +func TestCmd_Subpkgs(t *testing.T) { + d := t.TempDir() + if err := os.MkdirAll(filepath.Join(d, "subpkg"), 0700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(d, f1Yaml), ` +kind: Deployment +metadata: + labels: + app: nginx1 + name: foo + annotations: + app: nginx1 +spec: + replicas: 1 +`) + writeFile(t, filepath.Join(d, "subpkg", "f2.yaml"), ` +kind: Deployment +metadata: + labels: + app: nginx2 + name: foo + annotations: + app: nginx2 +spec: + replicas: 1 +`) + + got, err := runCat(t, d) + require.NoError(t, err) + assert.Equal(t, `kind: Deployment +metadata: + labels: + app: nginx1 + name: foo + annotations: + app: nginx1 +spec: + replicas: 1 +--- +kind: Deployment +metadata: + labels: + app: nginx2 + name: foo + annotations: + app: nginx2 +spec: + replicas: 1 +`, got) +} + +// TestCmd_NestedPackages exercises a true multi-pkg tree (Kptfile in root and +// in subdir) and asserts each pkg's resource is emitted exactly once. +// This guards against path-cleaning bugs and double-traversal of subpackages. +func TestCmd_NestedPackages(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +`) + writeFile(t, filepath.Join(d, "root.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: root-cm +`) + if err := os.MkdirAll(filepath.Join(d, "sub"), 0700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(d, "sub", "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: sub +`) + writeFile(t, filepath.Join(d, "sub", "sub.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: sub-cm +`) + + got, err := runCat(t, d) + require.NoError(t, err) + // Both packages' resources and Kptfiles should be emitted. + assert.Equal(t, 2, strings.Count(got, "\nkind: ConfigMap\n"), + "each pkg should be emitted exactly once") + assert.Contains(t, got, "name: root-cm") + assert.Contains(t, got, "name: sub-cm") + assert.Contains(t, got, "kind: Kptfile", "Kptfile should be included in output") +} + +// TestCmd_PathCleaning verifies that ./path and path/ behave identically to path. +func TestCmd_PathCleaning(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +`) + writeFile(t, filepath.Join(d, "cm.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm +`) + + for _, arg := range []string{d, d + "/", "./" + filepath.Base(d)} { + t.Run(arg, func(t *testing.T) { + // Run from d's parent so "./" resolves. + oldWd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(filepath.Dir(d)); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(oldWd); err != nil { + t.Fatal(err) + } + }() + + got, err := runCat(t, arg) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(got, "\nkind: ConfigMap\n"), + "arg %q should yield exactly one resource", arg) + }) + } +} + +// TestCmd_TrailingSeparator verifies that output does not end with a stray +// `---\n` separator. +func TestCmd_TrailingSeparator(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +`) + writeFile(t, filepath.Join(d, "dep.yaml"), `apiVersion: apps/v1 +kind: Deployment +metadata: + name: d +`) + if err := os.MkdirAll(filepath.Join(d, "sub-empty"), 0700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(d, "sub-empty", "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: sub-empty +`) + + got, err := runCat(t, d) + require.NoError(t, err) + assert.False(t, strings.HasSuffix(got, "---\n") || strings.HasSuffix(got, "---"), + "output must not end with a stray separator; got %q", got) +} + +// TestCmd_BrokenYAMLReturnsError verifies that a broken YAML file +// must cause a non-nil error; the command must not silently succeed. +func TestCmd_BrokenYAMLReturnsError(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +`) + writeFile(t, filepath.Join(d, "broken.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: x +data: + a: [unterminated +`) + + _, err := runCat(t, d) + assert.Error(t, err) + // The returned error must carry the pkg-path context ("kpt pkg cat: "":") + // as well as the underlying file that failed. The root cobra handler + // (main.handleErr) is responsible for printing; ExecuteCmd must not + // write to stderr itself, or the user would see the message twice. + assert.Contains(t, err.Error(), "kpt pkg cat:") + assert.Contains(t, err.Error(), d) + assert.Contains(t, err.Error(), "broken.yaml") + + // Sanity-check that ExecuteCmd itself does not write to the printer's + // ErrStream — otherwise the root handler would duplicate the output. + var errBuf bytes.Buffer + ctx := printer.WithContext(context.Background(), printer.New(nil, &errBuf)) + r := GetCatRunner(ctx, "") + r.Command.SetArgs([]string{d}) + r.Command.SetOut(&bytes.Buffer{}) + r.Command.SetErr(&bytes.Buffer{}) + _ = r.Command.Execute() + assert.Empty(t, errBuf.String(), + "ExecuteCmd must not print to ErrStream; the root handler prints the returned error") +} + +// TestCmd_NonExistent verifies a clean error on a missing path. +func TestCmd_NonExistent(t *testing.T) { + d := t.TempDir() + _, err := runCat(t, filepath.Join(d, "nope.yaml")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no such file or directory") +} + +// TestCmd_KptfileArgDisplayed: passing the Kptfile directly should display +// its content since cat now shows all package files. +func TestCmd_KptfileArgDisplayed(t *testing.T) { + d := t.TempDir() + kptContent := `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +` + writeFile(t, filepath.Join(d, "Kptfile"), kptContent) + + got, err := runCat(t, d) + require.NoError(t, err) + assert.Contains(t, got, "kind: Kptfile") + assert.Contains(t, got, "name: root") +} + +// TestCmd_NonYAMLFileDisplayed: non-YAML files in a package directory +// should be displayed as raw content. +func TestCmd_NonYAMLFileDisplayed(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +`) + writeFile(t, filepath.Join(d, "README.md"), "# Hello\nThis is a readme.\n") + + got, err := runCat(t, d) + require.NoError(t, err) + assert.Contains(t, got, "# Hello") + assert.Contains(t, got, "This is a readme.") +} + +// TestCmd_RecurseFalse ensures -R=false limits traversal to the root pkg. +func TestCmd_RecurseFalse(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +`) + writeFile(t, filepath.Join(d, "root.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: root-cm +`) + if err := os.MkdirAll(filepath.Join(d, "sub"), 0700); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(d, "sub", "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: sub +`) + writeFile(t, filepath.Join(d, "sub", "sub.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: sub-cm +`) + + got, err := runCat(t, d, "-R=false") + require.NoError(t, err) + assert.Contains(t, got, "name: root-cm") + assert.NotContains(t, got, "name: sub-cm", + "sub-pkg should not be traversed when -R=false") +} + +// TestCmd_StripComments verifies the --strip-comments flag removes comments. +func TestCmd_StripComments(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "f.yaml"), `# top comment +apiVersion: v1 +kind: ConfigMap +metadata: + name: c # inline comment +`) + + got, err := runCat(t, filepath.Join(d, "f.yaml"), "--strip-comments") + require.NoError(t, err) + assert.NotContains(t, got, "top comment") + assert.NotContains(t, got, "inline comment") +} + +// TestCmd_FormatTrue verifies that the default --format=true reorders fields +// to canonical Kubernetes order (apiVersion, kind, metadata, spec). +func TestCmd_FormatTrue(t *testing.T) { + d := t.TempDir() + // Deliberately non-canonical order: metadata before apiVersion/kind. + writeFile(t, filepath.Join(d, "f.yaml"), `metadata: + name: c +apiVersion: v1 +kind: ConfigMap +`) + + got, err := runCat(t, filepath.Join(d, "f.yaml")) + require.NoError(t, err) + apiIdx := strings.Index(got, "apiVersion:") + kindIdx := strings.Index(got, "kind:") + metaIdx := strings.Index(got, "metadata:") + assert.True(t, apiIdx >= 0 && kindIdx >= 0 && metaIdx >= 0, "all fields should be present") + assert.Less(t, apiIdx, kindIdx, + "with --format=true, apiVersion should come before kind") + assert.Less(t, kindIdx, metaIdx, + "with --format=true, kind should come before metadata") +} + +// TestCmd_FormatFalse verifies --format=false preserves the original field +// order, even when it differs from the canonical Kubernetes ordering. +func TestCmd_FormatFalse(t *testing.T) { + d := t.TempDir() + // Deliberately non-canonical order: metadata before apiVersion/kind. + writeFile(t, filepath.Join(d, "f.yaml"), `metadata: + name: c +apiVersion: v1 +kind: ConfigMap +`) + + got, err := runCat(t, filepath.Join(d, "f.yaml"), "--format=false") + require.NoError(t, err) + metaIdx := strings.Index(got, "metadata:") + apiIdx := strings.Index(got, "apiVersion:") + kindIdx := strings.Index(got, "kind:") + assert.True(t, metaIdx >= 0 && apiIdx >= 0 && kindIdx >= 0, "all fields should be present") + assert.Less(t, metaIdx, apiIdx, + "with --format=false, metadata should remain before apiVersion") + assert.Less(t, apiIdx, kindIdx, + "with --format=false, apiVersion should remain before kind") +} + +// TestCmd_SingleKptfileArg verifies that passing Kptfile as a direct file +// argument displays only the Kptfile content, not the entire package. +func TestCmd_SingleKptfileArg(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +`) + writeFile(t, filepath.Join(d, "cm.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm +`) + + got, err := runCat(t, filepath.Join(d, "Kptfile")) + require.NoError(t, err) + assert.Contains(t, got, "kind: Kptfile") + assert.NotContains(t, got, "kind: ConfigMap", + "only Kptfile should be displayed, not other package files") +} + +// TestCmd_SingleNonKRMFileArg verifies that passing a non-KRM file directly +// outputs its raw content. +func TestCmd_SingleNonKRMFileArg(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "README.md"), "# Title\nSome content.\n") + + got, err := runCat(t, filepath.Join(d, "README.md")) + require.NoError(t, err) + assert.Equal(t, "# Title\nSome content.\n", got) +} + +// TestCmd_EmptyDirectory verifies that an empty directory produces no output +// and no error. +func TestCmd_EmptyDirectory(t *testing.T) { + d := t.TempDir() + + got, err := runCat(t, d) + require.NoError(t, err) + assert.Empty(t, got) +} + +// TestCmd_StyleFlag verifies that --style flag is accepted without error. +func TestCmd_StyleFlag(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "f.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: value +`) + + got, err := runCat(t, filepath.Join(d, "f.yaml"), "--style=DoubleQuotedStyle") + require.NoError(t, err) + assert.Contains(t, got, "kind: ConfigMap") + assert.Contains(t, got, "name: test") +} + +// TestCmd_DirectoryOrder verifies that files are output in filesystem walk +// order, with KRM and non-KRM files interleaved correctly. +func TestCmd_DirectoryOrder(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +`) + writeFile(t, filepath.Join(d, "a.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: a-cm +`) + writeFile(t, filepath.Join(d, "b-readme.txt"), "hello\n") + writeFile(t, filepath.Join(d, "c.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: c-cm +`) + + got, err := runCat(t, d) + require.NoError(t, err) + // Verify all content is present. + assert.Contains(t, got, "kind: Kptfile") + assert.Contains(t, got, "name: a-cm") + assert.Contains(t, got, "hello") + assert.Contains(t, got, "name: c-cm") + // Verify order: Kptfile < a.yaml < b-readme.txt < c.yaml (alphabetical walk). + kptIdx := strings.Index(got, "kind: Kptfile") + aIdx := strings.Index(got, "name: a-cm") + bIdx := strings.Index(got, "hello") + cIdx := strings.Index(got, "name: c-cm") + assert.Less(t, kptIdx, aIdx, "Kptfile should appear before a.yaml") + assert.Less(t, aIdx, bIdx, "a.yaml should appear before b-readme.txt") + assert.Less(t, bIdx, cIdx, "b-readme.txt should appear before c.yaml") +} + +// TestCmd_SingleYAMLFileArg exercises the code path where LocalPackageReader +// is given a single file path (not a directory) with an empty PackageFileName. +// This guards against kyaml tightening validation on that usage. +func TestCmd_SingleYAMLFileArg(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "deploy.yaml"), `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx +spec: + replicas: 1 +`) + + got, err := runCat(t, filepath.Join(d, "deploy.yaml")) + require.NoError(t, err) + assert.Contains(t, got, "kind: Deployment") + assert.Contains(t, got, "name: nginx") + assert.Contains(t, got, "replicas:") +} + +// TestCmd_ContextCancellation verifies that a cancelled context aborts the walk. +func TestCmd_ContextCancellation(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +`) + writeFile(t, filepath.Join(d, "a.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm +`) + + ctx, cancel := context.WithCancel( + printer.WithContext(context.Background(), printer.New(nil, io.Discard)), + ) + cancel() + defer cancel() + + r := GetCatRunner(ctx, "") + r.Command.SilenceUsage = true + r.Command.SetArgs([]string{d}) + out := &bytes.Buffer{} + r.Command.SetOut(out) + r.Command.SetErr(&bytes.Buffer{}) + + err := r.Command.Execute() + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + assert.Empty(t, out.String(), "no output should be produced when ctx is cancelled before walk") +} + +// TestCmd_ContextCancellation_Deadline verifies DeadlineExceeded behaves the same. +func TestCmd_ContextCancellation_Deadline(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +`) + + ctx, cancel := context.WithTimeout( + printer.WithContext(context.Background(), printer.New(nil, io.Discard)), 0, + ) + defer cancel() + + r := GetCatRunner(ctx, "") + r.Command.SilenceUsage = true + r.Command.SetArgs([]string{d}) + r.Command.SetOut(&bytes.Buffer{}) + r.Command.SetErr(&bytes.Buffer{}) + err := r.Command.Execute() + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) +} + +// TestCmd_ContextCancellation_SingleFile verifies the single-file path honors cancellation. +func TestCmd_ContextCancellation_SingleFile(t *testing.T) { + d := t.TempDir() + f := filepath.Join(d, "a.yaml") + writeFile(t, f, `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm +`) + + ctx, cancel := context.WithCancel( + printer.WithContext(context.Background(), printer.New(nil, io.Discard)), + ) + cancel() + defer cancel() + + r := GetCatRunner(ctx, "") + r.Command.SilenceUsage = true + r.Command.SetArgs([]string{f}) + r.Command.SetOut(&bytes.Buffer{}) + r.Command.SetErr(&bytes.Buffer{}) + err := r.Command.Execute() + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) +} + +// TestCmd_ContextCancellation_MidWalk verifies that cancellation during a walk +// stops work before all files are processed. +func TestCmd_ContextCancellation_MidWalk(t *testing.T) { + d := t.TempDir() + writeFile(t, filepath.Join(d, "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +`) + for i := 0; i < 500; i++ { + writeFile(t, filepath.Join(d, fmt.Sprintf("f%03d.yaml", i)), + fmt.Sprintf("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm-%d\n", i)) + } + + ctx, cancel := context.WithCancel( + printer.WithContext(context.Background(), printer.New(nil, io.Discard)), + ) + go func() { + time.Sleep(2 * time.Millisecond) + cancel() + }() + defer cancel() + + r := GetCatRunner(ctx, "") + r.Command.SilenceUsage = true + out := &bytes.Buffer{} + r.Command.SetArgs([]string{d}) + r.Command.SetOut(out) + r.Command.SetErr(&bytes.Buffer{}) + err := r.Command.Execute() + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + assert.Empty(t, out.String(), "no output should leak on cancellation") +} + +// TestCmd_SymlinkArg verifies that a symlink argument is resolved and its +// target content is displayed, while symlinks inside the package are skipped. +func TestCmd_SymlinkArg(t *testing.T) { + d := t.TempDir() + real := filepath.Join(d, "real") + require.NoError(t, os.MkdirAll(real, 0o755)) + writeFile(t, filepath.Join(real, "Kptfile"), `apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: root +`) + writeFile(t, filepath.Join(real, "cm.yaml"), `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm +`) + // Create a file that a symlink inside the package points to. + writeFile(t, filepath.Join(d, "external.yaml"), `apiVersion: v1 +kind: Secret +metadata: + name: secret +`) + // Symlink inside the package — should be skipped. + require.NoError(t, os.Symlink(filepath.Join(d, "external.yaml"), filepath.Join(real, "link.yaml"))) + + // Symlink as the argument — should be resolved. + link := filepath.Join(d, "pkg-link") + require.NoError(t, os.Symlink(real, link)) + + got, err := runCat(t, link) + require.NoError(t, err) + assert.Contains(t, got, "name: cm", "target content should be displayed") + assert.Contains(t, got, "kind: Kptfile", "Kptfile should be displayed") + assert.NotContains(t, got, "kind: Secret", "symlinked file inside package should be skipped") +}