Skip to content

Commit 4e6b0d3

Browse files
committed
cmd: add --data flag to opa inspect for data file inspection
Fixes #6879
1 parent 4c741cb commit 4e6b0d3

3 files changed

Lines changed: 207 additions & 10 deletions

File tree

cmd/inspect.go

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const (
3838
type inspectCommandParams struct {
3939
outputFormat *util.EnumFlag
4040
listAnnotations bool
41+
dataPaths repeatedStringFlag
4142
v0Compatible bool
4243
v1Compatible bool
4344
}
@@ -56,6 +57,7 @@ func newInspectCommandParams() inspectCommandParams {
5657
return inspectCommandParams{
5758
outputFormat: formats.Flag(formats.Pretty, formats.JSON),
5859
listAnnotations: false,
60+
dataPaths: newrepeatedStringFlag([]string{}),
5961
}
6062
}
6163

@@ -66,12 +68,12 @@ func initInspect(root *cobra.Command, brand string) {
6668

6769
inspectCommand := &cobra.Command{
6870
Use: "inspect <path> [<path> [...]]",
69-
Short: `Inspect ` + brand + ` bundle(s)`,
70-
Long: `Inspect ` + brand + ` bundle(s).
71+
Short: `Inspect ` + brand + ` bundle(s), Rego files, or data files`,
72+
Long: `Inspect ` + brand + ` bundle(s), Rego files, or data files.
7173
72-
The 'inspect' command provides a summary of the contents in ` + brand + ` bundle(s) or a single Rego file.
73-
Bundles are gzipped tarballs containing policies and data. The 'inspect' command reads bundle(s) and lists
74-
the following:
74+
The 'inspect' command provides a summary of the contents in ` + brand + ` bundle(s), a single Rego file,
75+
or data files. Bundles are gzipped tarballs containing policies and data. The 'inspect' command reads
76+
bundle(s) and lists the following:
7577
7678
* packages that are contributed by .rego files
7779
* data locations defined by the data.json and data.yaml files
@@ -80,16 +82,24 @@ the following:
8082
* information about the Wasm module files
8183
* package- and rule annotations
8284
83-
Example:
85+
Examples:
8486
87+
# Inspect a bundle
8588
$ ls
8689
bundle.tar.gz
8790
$ ` + executable + ` inspect bundle.tar.gz
8891
92+
# Inspect data files
93+
$ ` + executable + ` inspect --data data.json
94+
$ ` + executable + ` inspect --data config.yaml
95+
8996
You can provide exactly one ` + brand + ` bundle, to a bundle directory, or direct path to a Rego file to the 'inspect'
9097
command on the command-line. If you provide a path referring to a directory, the 'inspect' command will load that path as
91-
a bundle and summarize its structure and contents. If you provide a path referring to a Rego file, the 'inspect' command
98+
a bundle and summarize its structure and contents. If you provide a path referring to a Rego file, the 'inspect' command
9299
will load that file and summarize its structure and contents.
100+
101+
Alternatively, you can use the --data flag to inspect individual JSON or YAML data files. This flag can
102+
be repeated to inspect multiple data files.
93103
`,
94104
PreRunE: func(cmd *cobra.Command, args []string) error {
95105
if err := validateInspectParams(&params, args); err != nil {
@@ -101,7 +111,11 @@ will load that file and summarize its structure and contents.
101111
cmd.SilenceErrors = true
102112
cmd.SilenceUsage = true
103113

104-
if err := doInspect(params, args[0], os.Stdout); err != nil {
114+
path := ""
115+
if len(args) > 0 {
116+
path = args[0]
117+
}
118+
if err := doInspect(params, path, os.Stdout); err != nil {
105119
fmt.Fprintln(os.Stderr, "error:", err)
106120
return err
107121
}
@@ -111,13 +125,21 @@ will load that file and summarize its structure and contents.
111125

112126
addOutputFormat(inspectCommand.Flags(), params.outputFormat)
113127
addListAnnotations(inspectCommand.Flags(), &params.listAnnotations)
128+
addDataFlag(inspectCommand.Flags(), &params.dataPaths)
114129
addV0CompatibleFlag(inspectCommand.Flags(), &params.v0Compatible, false)
115130
addV1CompatibleFlag(inspectCommand.Flags(), &params.v1Compatible, false)
116131
root.AddCommand(inspectCommand)
117132
}
118133

119134
func doInspect(params inspectCommandParams, path string, out io.Writer) error {
120-
info, err := ib.FileForRegoVersion(params.regoVersion(), path, params.listAnnotations)
135+
var info *ib.Info
136+
var err error
137+
138+
if params.dataPaths.isFlagSet() {
139+
info, err = ib.DataFileInfo(params.dataPaths.v)
140+
} else {
141+
info, err = ib.FileForRegoVersion(params.regoVersion(), path, params.listAnnotations)
142+
}
121143
if err != nil {
122144
return err
123145
}
@@ -168,7 +190,11 @@ func hasManifest(info *ib.Info) bool {
168190
}
169191

170192
func validateInspectParams(p *inspectCommandParams, args []string) error {
171-
if len(args) != 1 {
193+
if p.dataPaths.isFlagSet() {
194+
if len(args) > 0 {
195+
return errors.New("specify either a bundle/path argument or --data flag, not both")
196+
}
197+
} else if len(args) != 1 {
172198
return errors.New("specify exactly one OPA bundle or path")
173199
}
174200

cmd/inspect_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2263,3 +2263,125 @@ NAMESPACES:
22632263
}
22642264
})
22652265
}
2266+
2267+
func TestDoInspectDataFile(t *testing.T) {
2268+
files := map[string]string{
2269+
"/data.json": `{"users": {"alice": {"role": "admin"}}}`,
2270+
}
2271+
2272+
test.WithTempFS(files, func(rootDir string) {
2273+
fileName := filepath.Join(rootDir, "data.json")
2274+
ps := newInspectCommandParams()
2275+
ps.dataPaths = newrepeatedStringFlag([]string{})
2276+
if err := ps.dataPaths.Set(fileName); err != nil {
2277+
t.Fatal(err)
2278+
}
2279+
2280+
var out bytes.Buffer
2281+
err := doInspect(ps, "", &out)
2282+
if err != nil {
2283+
t.Fatalf("Unexpected error %v", err)
2284+
}
2285+
2286+
output := out.String()
2287+
if !strings.Contains(output, "NAMESPACES:") {
2288+
t.Fatalf("Expected NAMESPACES section in output, got:\n%v", output)
2289+
}
2290+
if !strings.Contains(output, "data") {
2291+
t.Fatalf("Expected 'data' namespace in output, got:\n%v", output)
2292+
}
2293+
if !strings.Contains(output, "data.json") {
2294+
t.Fatalf("Expected 'data.json' file in output, got:\n%v", output)
2295+
}
2296+
})
2297+
}
2298+
2299+
func TestDoInspectDataFileYAML(t *testing.T) {
2300+
files := map[string]string{
2301+
"/config.yaml": "users:\n alice:\n role: admin\n",
2302+
}
2303+
2304+
test.WithTempFS(files, func(rootDir string) {
2305+
fileName := filepath.Join(rootDir, "config.yaml")
2306+
ps := newInspectCommandParams()
2307+
ps.dataPaths = newrepeatedStringFlag([]string{})
2308+
if err := ps.dataPaths.Set(fileName); err != nil {
2309+
t.Fatal(err)
2310+
}
2311+
2312+
var out bytes.Buffer
2313+
err := doInspect(ps, "", &out)
2314+
if err != nil {
2315+
t.Fatalf("Unexpected error %v", err)
2316+
}
2317+
2318+
output := out.String()
2319+
if !strings.Contains(output, "NAMESPACES:") {
2320+
t.Fatalf("Expected NAMESPACES section in output, got:\n%v", output)
2321+
}
2322+
if !strings.Contains(output, "config.yaml") {
2323+
t.Fatalf("Expected 'config.yaml' file in output, got:\n%v", output)
2324+
}
2325+
})
2326+
}
2327+
2328+
func TestDoInspectDataFileInvalidJSON(t *testing.T) {
2329+
files := map[string]string{
2330+
"/bad.json": `{"users": invalid}`,
2331+
}
2332+
2333+
test.WithTempFS(files, func(rootDir string) {
2334+
fileName := filepath.Join(rootDir, "bad.json")
2335+
ps := newInspectCommandParams()
2336+
ps.dataPaths = newrepeatedStringFlag([]string{})
2337+
if err := ps.dataPaths.Set(fileName); err != nil {
2338+
t.Fatal(err)
2339+
}
2340+
2341+
var out bytes.Buffer
2342+
err := doInspect(ps, "", &out)
2343+
if err == nil {
2344+
t.Fatal("Expected error for invalid JSON")
2345+
}
2346+
})
2347+
}
2348+
2349+
func TestDoInspectDataFileNonDataFile(t *testing.T) {
2350+
files := map[string]string{
2351+
"/policy.rego": `package test`,
2352+
}
2353+
2354+
test.WithTempFS(files, func(rootDir string) {
2355+
fileName := filepath.Join(rootDir, "policy.rego")
2356+
ps := newInspectCommandParams()
2357+
ps.dataPaths = newrepeatedStringFlag([]string{})
2358+
if err := ps.dataPaths.Set(fileName); err != nil {
2359+
t.Fatal(err)
2360+
}
2361+
2362+
var out bytes.Buffer
2363+
err := doInspect(ps, "", &out)
2364+
if err == nil {
2365+
t.Fatal("Expected error for non-data file")
2366+
}
2367+
if !strings.Contains(err.Error(), "not a JSON or YAML data file") {
2368+
t.Fatalf("Expected 'not a JSON or YAML' error, got: %v", err)
2369+
}
2370+
})
2371+
}
2372+
2373+
func TestValidateInspectParamsDataAndArgs(t *testing.T) {
2374+
ps := newInspectCommandParams()
2375+
ps.dataPaths = newrepeatedStringFlag([]string{})
2376+
if err := ps.dataPaths.Set("data.json"); err != nil {
2377+
t.Fatal(err)
2378+
}
2379+
2380+
err := validateInspectParams(&ps, []string{"bundle.tar.gz"})
2381+
if err == nil {
2382+
t.Fatal("Expected error when both --data and positional args are given")
2383+
}
2384+
if !strings.Contains(err.Error(), "specify either") {
2385+
t.Fatalf("Unexpected error: %v", err)
2386+
}
2387+
}

internal/bundle/inspect/inspect.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/open-policy-agent/opa/v1/bundle"
1919
"github.com/open-policy-agent/opa/v1/loader"
2020
"github.com/open-policy-agent/opa/v1/util"
21+
"sigs.k8s.io/yaml"
2122
)
2223

2324
// Info represents information about a bundle.
@@ -42,6 +43,54 @@ func FileForRegoVersion(regoVersion ast.RegoVersion, path string, includeAnnotat
4243
return bundleOrDirInfoForRegoVersion(regoVersion, path, includeAnnotations)
4344
}
4445

46+
// DataFileInfo returns an Info struct describing the given data files.
47+
// It accepts JSON (.json) and YAML (.yaml, .yml) files.
48+
func DataFileInfo(paths []string) (*Info, error) {
49+
bi := &Info{
50+
Namespaces: make(map[string][]string),
51+
}
52+
53+
for _, path := range paths {
54+
info, err := os.Stat(path)
55+
if err != nil {
56+
return nil, fmt.Errorf("error accessing path %s: %w", path, err)
57+
}
58+
59+
if info.IsDir() {
60+
return nil, fmt.Errorf("path %s is a directory, use positional argument for directories", path)
61+
}
62+
63+
ext := strings.ToLower(filepath.Ext(path))
64+
if ext != ".json" && ext != ".yaml" && ext != ".yml" {
65+
return nil, fmt.Errorf("file %s is not a JSON or YAML data file", path)
66+
}
67+
68+
// Read the file to validate it can be parsed
69+
bs, err := os.ReadFile(path)
70+
if err != nil {
71+
return nil, fmt.Errorf("error reading file %s: %w", path, err)
72+
}
73+
74+
if ext == ".yaml" || ext == ".yml" {
75+
if _, err := yaml.YAMLToJSON(bs); err != nil {
76+
return nil, fmt.Errorf("error parsing YAML file %s: %w", path, err)
77+
}
78+
} else {
79+
var x any
80+
if err := util.UnmarshalJSON(bs, &x); err != nil {
81+
return nil, fmt.Errorf("error parsing JSON file %s: %w", path, err)
82+
}
83+
}
84+
85+
bi.Namespaces[ast.DefaultRootDocument.String()] = append(
86+
bi.Namespaces[ast.DefaultRootDocument.String()],
87+
filepath.Clean(path),
88+
)
89+
}
90+
91+
return bi, nil
92+
}
93+
4594
func bundleOrDirInfoForRegoVersion(regoVersion ast.RegoVersion, path string, includeAnnotations bool) (*Info, error) {
4695
b, err := loader.NewFileLoader().
4796
WithRegoVersion(regoVersion).

0 commit comments

Comments
 (0)