diff --git a/cmd/inspect.go b/cmd/inspect.go index f3da430af5e..a3ca45b3212 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -38,6 +38,7 @@ const ( type inspectCommandParams struct { outputFormat *util.EnumFlag listAnnotations bool + dataPaths repeatedStringFlag v0Compatible bool v1Compatible bool } @@ -56,6 +57,7 @@ func newInspectCommandParams() inspectCommandParams { return inspectCommandParams{ outputFormat: formats.Flag(formats.Pretty, formats.JSON), listAnnotations: false, + dataPaths: newrepeatedStringFlag([]string{}), } } @@ -66,12 +68,12 @@ func initInspect(root *cobra.Command, brand string) { inspectCommand := &cobra.Command{ Use: "inspect [ [...]]", - Short: `Inspect ` + brand + ` bundle(s)`, - Long: `Inspect ` + brand + ` bundle(s). + Short: `Inspect ` + brand + ` bundle(s), Rego files, or data files`, + Long: `Inspect ` + brand + ` bundle(s), Rego files, or data files. -The 'inspect' command provides a summary of the contents in ` + brand + ` bundle(s) or a single Rego file. -Bundles are gzipped tarballs containing policies and data. The 'inspect' command reads bundle(s) and lists -the following: +The 'inspect' command provides a summary of the contents in ` + brand + ` bundle(s), a single Rego file, +or data files. Bundles are gzipped tarballs containing policies and data. The 'inspect' command reads +bundle(s) and lists the following: * packages that are contributed by .rego files * data locations defined by the data.json and data.yaml files @@ -80,16 +82,24 @@ the following: * information about the Wasm module files * package- and rule annotations -Example: +Examples: + # Inspect a bundle $ ls bundle.tar.gz $ ` + executable + ` inspect bundle.tar.gz + # Inspect data files + $ ` + executable + ` inspect --data data.json + $ ` + executable + ` inspect --data config.yaml + You can provide exactly one ` + brand + ` bundle, to a bundle directory, or direct path to a Rego file to the 'inspect' command on the command-line. If you provide a path referring to a directory, the 'inspect' command will load that path as -a bundle and summarize its structure and contents. If you provide a path referring to a Rego file, the 'inspect' command +a bundle and summarize its structure and contents. If you provide a path referring to a Rego file, the 'inspect' command will load that file and summarize its structure and contents. + +Alternatively, you can use the --data flag to inspect individual JSON or YAML data files. This flag can +be repeated to inspect multiple data files. `, PreRunE: func(cmd *cobra.Command, args []string) error { if err := validateInspectParams(¶ms, args); err != nil { @@ -101,7 +111,11 @@ will load that file and summarize its structure and contents. cmd.SilenceErrors = true cmd.SilenceUsage = true - if err := doInspect(params, args[0], os.Stdout); err != nil { + path := "" + if len(args) > 0 { + path = args[0] + } + if err := doInspect(params, path, os.Stdout); err != nil { fmt.Fprintln(os.Stderr, "error:", err) return err } @@ -111,13 +125,21 @@ will load that file and summarize its structure and contents. addOutputFormat(inspectCommand.Flags(), params.outputFormat) addListAnnotations(inspectCommand.Flags(), ¶ms.listAnnotations) + addDataFlag(inspectCommand.Flags(), ¶ms.dataPaths) addV0CompatibleFlag(inspectCommand.Flags(), ¶ms.v0Compatible, false) addV1CompatibleFlag(inspectCommand.Flags(), ¶ms.v1Compatible, false) root.AddCommand(inspectCommand) } func doInspect(params inspectCommandParams, path string, out io.Writer) error { - info, err := ib.FileForRegoVersion(params.regoVersion(), path, params.listAnnotations) + var info *ib.Info + var err error + + if params.dataPaths.isFlagSet() { + info, err = ib.DataFileInfo(params.dataPaths.v) + } else { + info, err = ib.FileForRegoVersion(params.regoVersion(), path, params.listAnnotations) + } if err != nil { return err } @@ -168,7 +190,11 @@ func hasManifest(info *ib.Info) bool { } func validateInspectParams(p *inspectCommandParams, args []string) error { - if len(args) != 1 { + if p.dataPaths.isFlagSet() { + if len(args) > 0 { + return errors.New("specify either a bundle/path argument or --data flag, not both") + } + } else if len(args) != 1 { return errors.New("specify exactly one OPA bundle or path") } diff --git a/cmd/inspect_test.go b/cmd/inspect_test.go index 5d2f482f904..fc9a5393057 100644 --- a/cmd/inspect_test.go +++ b/cmd/inspect_test.go @@ -2263,3 +2263,125 @@ NAMESPACES: } }) } + +func TestDoInspectDataFile(t *testing.T) { + files := map[string]string{ + "/data.json": `{"users": {"alice": {"role": "admin"}}}`, + } + + test.WithTempFS(files, func(rootDir string) { + fileName := filepath.Join(rootDir, "data.json") + ps := newInspectCommandParams() + ps.dataPaths = newrepeatedStringFlag([]string{}) + if err := ps.dataPaths.Set(fileName); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + err := doInspect(ps, "", &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + output := out.String() + if !strings.Contains(output, "NAMESPACES:") { + t.Fatalf("Expected NAMESPACES section in output, got:\n%v", output) + } + if !strings.Contains(output, "data") { + t.Fatalf("Expected 'data' namespace in output, got:\n%v", output) + } + if !strings.Contains(output, "data.json") { + t.Fatalf("Expected 'data.json' file in output, got:\n%v", output) + } + }) +} + +func TestDoInspectDataFileYAML(t *testing.T) { + files := map[string]string{ + "/config.yaml": "users:\n alice:\n role: admin\n", + } + + test.WithTempFS(files, func(rootDir string) { + fileName := filepath.Join(rootDir, "config.yaml") + ps := newInspectCommandParams() + ps.dataPaths = newrepeatedStringFlag([]string{}) + if err := ps.dataPaths.Set(fileName); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + err := doInspect(ps, "", &out) + if err != nil { + t.Fatalf("Unexpected error %v", err) + } + + output := out.String() + if !strings.Contains(output, "NAMESPACES:") { + t.Fatalf("Expected NAMESPACES section in output, got:\n%v", output) + } + if !strings.Contains(output, "config.yaml") { + t.Fatalf("Expected 'config.yaml' file in output, got:\n%v", output) + } + }) +} + +func TestDoInspectDataFileInvalidJSON(t *testing.T) { + files := map[string]string{ + "/bad.json": `{"users": invalid}`, + } + + test.WithTempFS(files, func(rootDir string) { + fileName := filepath.Join(rootDir, "bad.json") + ps := newInspectCommandParams() + ps.dataPaths = newrepeatedStringFlag([]string{}) + if err := ps.dataPaths.Set(fileName); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + err := doInspect(ps, "", &out) + if err == nil { + t.Fatal("Expected error for invalid JSON") + } + }) +} + +func TestDoInspectDataFileNonDataFile(t *testing.T) { + files := map[string]string{ + "/policy.rego": `package test`, + } + + test.WithTempFS(files, func(rootDir string) { + fileName := filepath.Join(rootDir, "policy.rego") + ps := newInspectCommandParams() + ps.dataPaths = newrepeatedStringFlag([]string{}) + if err := ps.dataPaths.Set(fileName); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + err := doInspect(ps, "", &out) + if err == nil { + t.Fatal("Expected error for non-data file") + } + if !strings.Contains(err.Error(), "not a JSON or YAML data file") { + t.Fatalf("Expected 'not a JSON or YAML' error, got: %v", err) + } + }) +} + +func TestValidateInspectParamsDataAndArgs(t *testing.T) { + ps := newInspectCommandParams() + ps.dataPaths = newrepeatedStringFlag([]string{}) + if err := ps.dataPaths.Set("data.json"); err != nil { + t.Fatal(err) + } + + err := validateInspectParams(&ps, []string{"bundle.tar.gz"}) + if err == nil { + t.Fatal("Expected error when both --data and positional args are given") + } + if !strings.Contains(err.Error(), "specify either") { + t.Fatalf("Unexpected error: %v", err) + } +} diff --git a/internal/bundle/inspect/inspect.go b/internal/bundle/inspect/inspect.go index 2b4baedeca5..667cbb7a244 100644 --- a/internal/bundle/inspect/inspect.go +++ b/internal/bundle/inspect/inspect.go @@ -18,6 +18,7 @@ import ( "github.com/open-policy-agent/opa/v1/bundle" "github.com/open-policy-agent/opa/v1/loader" "github.com/open-policy-agent/opa/v1/util" + "sigs.k8s.io/yaml" ) // Info represents information about a bundle. @@ -42,6 +43,54 @@ func FileForRegoVersion(regoVersion ast.RegoVersion, path string, includeAnnotat return bundleOrDirInfoForRegoVersion(regoVersion, path, includeAnnotations) } +// DataFileInfo returns an Info struct describing the given data files. +// It accepts JSON (.json) and YAML (.yaml, .yml) files. +func DataFileInfo(paths []string) (*Info, error) { + bi := &Info{ + Namespaces: make(map[string][]string), + } + + for _, path := range paths { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("error accessing path %s: %w", path, err) + } + + if info.IsDir() { + return nil, fmt.Errorf("path %s is a directory, use positional argument for directories", path) + } + + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".json" && ext != ".yaml" && ext != ".yml" { + return nil, fmt.Errorf("file %s is not a JSON or YAML data file", path) + } + + // Read the file to validate it can be parsed + bs, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("error reading file %s: %w", path, err) + } + + if ext == ".yaml" || ext == ".yml" { + if _, err := yaml.YAMLToJSON(bs); err != nil { + return nil, fmt.Errorf("error parsing YAML file %s: %w", path, err) + } + } else { + var x any + if err := util.UnmarshalJSON(bs, &x); err != nil { + return nil, fmt.Errorf("error parsing JSON file %s: %w", path, err) + } + } + + bi.Namespaces[ast.DefaultRootDocument.String()] = append( + bi.Namespaces[ast.DefaultRootDocument.String()], + filepath.Clean(path), + ) + } + + return bi, nil +} + func bundleOrDirInfoForRegoVersion(regoVersion ast.RegoVersion, path string, includeAnnotations bool) (*Info, error) { b, err := loader.NewFileLoader(). WithRegoVersion(regoVersion).