diff --git a/cmd/validate/input.go b/cmd/validate/input.go index 8bda6381b..f6b6df7f1 100644 --- a/cmd/validate/input.go +++ b/cmd/validate/input.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "runtime/trace" + "slices" "sort" "strings" @@ -58,24 +59,30 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { filterType: "include-exclude", // Default to include-exclude filter } cmd := &cobra.Command{ - Use: "input", + Use: "input [file ...] [flags]", Short: "Validate arbitrary JSON or yaml file input conformance with the provided policies", Long: hd.Doc(` Validate conformance of arbitrary JSON or yaml file input with the provided policies For each file, validation is performed to determine if the file conforms to rego policies defined in the EnterpriseContractPolicy. + + Input files can be specified as positional arguments or via the --file flag (deprecated). + If both are provided, the values are combined. `), Example: hd.Doc(` - Use an EnterpriseContractPolicy spec from a local YAML file to validate a single file - ec validate input --file /path/to/file.json --policy my-policy.yaml + Validate a single file using positional arguments + ec validate input /path/to/file.json --policy my-policy.yaml - Use an EnterpriseContractPolicy spec from a local YAML file to validate multiple files - The file flag can be repeated for multiple input files. - ec validate input --file /path/to/file.yaml --file /path/to/file2.yaml --policy my-policy.yaml + Validate multiple files using positional arguments + ec validate input /path/to/file.yaml /path/to/file2.yaml --policy my-policy.yaml + + Validate all YAML files in a directory using shell glob expansion + ec validate input *.yaml --policy my-policy.yaml - Use an EnterpriseContractPolicy spec from a local YAML file to validate multiple files - The file flag can take a comma separated series of files. + Use the deprecated --file flag (still functional, multiple forms supported) + ec validate input --file /path/to/file.json --policy my-policy.yaml + ec validate input --file /path/to/file.yaml --file /path/to/file2.yaml --policy my-policy.yaml ec validate input --file="/path/to/file.json,/path/to/file2.json" --policy my-policy.yaml Use a git url for the policy configuration. In the first example there should be a '.ec/policy.yaml' @@ -84,12 +91,36 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { of the git repo. For git repos not hosted on 'github.com' or 'gitlab.com', prefix the url with 'git::'. For the policy configuration files you can use json instead of yaml if you prefer. - ec validate input --file /path/to/file.json --policy github.com/user/repo//default?ref=main + ec validate input /path/to/file.json --policy github.com/user/repo//default?ref=main - ec validate input --file /path/to/file.yaml --policy github.com/user/repo + ec validate input /path/to/file.yaml --policy github.com/user/repo `), PreRunE: func(cmd *cobra.Command, args []string) (allErrors error) { + // Merge positional arguments into filePaths + data.filePaths = append(data.filePaths, args...) + + // Deduplicate file paths + seen := make(map[string]struct{}, len(data.filePaths)) + unique := make([]string, 0, len(data.filePaths)) + for _, f := range data.filePaths { + if _, ok := seen[f]; !ok { + seen[f] = struct{}{} + unique = append(unique, f) + } + } + data.filePaths = unique + + // Strip empty and whitespace-only entries + data.filePaths = slices.DeleteFunc(data.filePaths, func(s string) bool { + return strings.TrimSpace(s) == "" + }) + + if len(data.filePaths) == 0 { + allErrors = errors.Join(allErrors, fmt.Errorf("at least one input file must be specified as a positional argument or via the --file flag")) + return + } + ctx := cmd.Context() policyConfiguration, err := validate_utils.GetPolicyConfig(ctx, data.policyConfiguration) @@ -231,7 +262,7 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { }, } - cmd.Flags().StringSliceVarP(&data.filePaths, "file", "f", data.filePaths, "path to input YAML/JSON file (required)") + cmd.Flags().StringSliceVarP(&data.filePaths, "file", "f", data.filePaths, "DEPRECATED: use positional arguments instead. Path to input YAML/JSON file") cmd.Flags().StringVarP(&data.policyConfiguration, "policy", "p", data.policyConfiguration, hd.Doc(` Policy configuration as: @@ -275,7 +306,7 @@ func validateInputCmd(validate InputValidationFunc) *cobra.Command { cmd.Flags().BoolVar(&data.forceColor, "color", false, hd.Doc(` Enable color when using text output even when the current terminal does not support it`)) - if err := cmd.MarkFlagRequired("file"); err != nil { + if err := cmd.Flags().MarkHidden("file"); err != nil { panic(err) } diff --git a/cmd/validate/input_test.go b/cmd/validate/input_test.go index a7dcf6aff..7e79b69d0 100644 --- a/cmd/validate/input_test.go +++ b/cmd/validate/input_test.go @@ -269,14 +269,17 @@ func Test_ValidateInputCmd_NoPolicyProvided(t *testing.T) { } func Test_ValidateInputCmd_NoFileProvided(t *testing.T) { + // No SetTestRekorPublicKey call: this test verifies PreRunE returns early + // before policy initialization (which would require Rekor key setup). cmd, _ := setUpValidateInputCmd(nil, afero.NewMemMapFs()) cmd.SetArgs([]string{ "input", "--policy", `{"publicKey":"testkey"}`, }) + err := cmd.Execute() assert.Error(t, err) - assert.Contains(t, err.Error(), "required flag(s) \"file\" not set") + assert.Contains(t, err.Error(), "at least one input file must be specified") } func Test_ValidateInputCmd_PolicyParsingError(t *testing.T) { @@ -440,6 +443,184 @@ func Test_ValidateInputCmd_DefaultTextOutput(t *testing.T) { assert.NotContains(t, output, "Results:") } +func Test_ValidateInputCmd_PositionalArgsVariants(t *testing.T) { + outMock := &output.Output{ + PolicyCheck: []evaluator.Outcome{ + { + Successes: []evaluator.Result{ + {Message: "Pass"}, + }, + }, + }, + } + + cases := []struct { + name string + files map[string]string + args []string + expectedFiles []string + }{ + { + name: "single positional arg", + files: map[string]string{"/input.yaml": "some: data"}, + args: []string{ + "input", "/input.yaml", + "--policy", `{"publicKey": "testkey"}`, + "--output", "json", + }, + expectedFiles: []string{"/input.yaml"}, + }, + { + name: "multiple positional args", + files: map[string]string{ + "/input1.yaml": "some: data", + "/input2.yaml": "other: data", + }, + args: []string{ + "input", "/input1.yaml", "/input2.yaml", + "--policy", `{"publicKey": "testkey"}`, + "--output", "json", + }, + expectedFiles: []string{"/input1.yaml", "/input2.yaml"}, + }, + { + name: "mixed positional and flag", + files: map[string]string{ + "/flag-file.yaml": "some: data", + "/positional-file.yaml": "other: data", + }, + args: []string{ + "input", "/positional-file.yaml", + "--file", "/flag-file.yaml", + "--policy", `{"publicKey": "testkey"}`, + "--output", "json", + }, + expectedFiles: []string{"/flag-file.yaml", "/positional-file.yaml"}, + }, + { + name: "dedup when same file via both flag and positional", + files: map[string]string{"/shared.yaml": "some: data"}, + args: []string{ + "input", "/shared.yaml", + "--file", "/shared.yaml", + "--policy", `{"publicKey": "testkey"}`, + "--output", "json", + }, + expectedFiles: []string{"/shared.yaml"}, + }, + { + name: "flag-only (deprecated but functional, no warning)", + files: map[string]string{ + "/flag-only.yaml": "some: data", + }, + args: []string{ + "input", + "--file", "/flag-only.yaml", + "--policy", `{"publicKey": "testkey"}`, + "--output", "json", + }, + expectedFiles: []string{"/flag-only.yaml"}, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + for path, content := range c.files { + require.NoError(t, afero.WriteFile(fs, path, []byte(content), 0644)) + } + + cmd, buf := setUpValidateInputCmd(mockValidate(outMock, nil), fs) + cmd.SetArgs(c.args) + + utils.SetTestRekorPublicKey(t) + err := cmd.Execute() + require.NoError(t, err) + + var outJSON map[string]interface{} + err = json.Unmarshal(buf.Bytes(), &outJSON) + require.NoError(t, err) + require.True(t, outJSON["success"].(bool)) + + inputFiles, ok := outJSON["filepaths"].([]interface{}) + require.True(t, ok, "expected 'filepaths' key in output") + require.Len(t, inputFiles, len(c.expectedFiles)) + + filePaths := []string{} + for _, input := range inputFiles { + f, ok := input.(map[string]interface{})["filepath"].(string) + require.True(t, ok, "expected 'filepath' key in input object") + filePaths = append(filePaths, f) + } + for _, expected := range c.expectedFiles { + assert.Contains(t, filePaths, expected) + } + }) + } +} + +func Test_ValidateInputCmd_InvalidPositionalArgs(t *testing.T) { + cases := []struct { + name string + validate InputValidationFunc + args []string + needsRekor bool + expectedErr string + }{ + { + name: "flag-like positional arg rejected", + args: []string{ + "input", "--polcy", "foo.yaml", + "--policy", `{"publicKey": "testkey"}`, + }, + expectedErr: "unknown flag: --polcy", + }, + { + name: "single-dash flag-like positional arg rejected", + args: []string{ + "input", "-x", "foo.yaml", + "--policy", `{"publicKey": "testkey"}`, + }, + expectedErr: "unknown shorthand flag: 'x' in -x", + }, + { + name: "empty string positional arg", + args: []string{ + "input", "", + "--policy", `{"publicKey": "testkey"}`, + "--output", "json", + }, + expectedErr: "at least one input file must be specified", + }, + { + name: "non-existent file path", + validate: mockValidate(nil, errors.New("file not found")), + args: []string{ + "input", "/nonexistent/path.yaml", + "--policy", `{"publicKey": "testkey"}`, + "--output", "json", + }, + needsRekor: true, + expectedErr: "error validating file /nonexistent/path.yaml", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cmd, _ := setUpValidateInputCmd(c.validate, afero.NewMemMapFs()) + cmd.SetArgs(c.args) + + if c.needsRekor { + utils.SetTestRekorPublicKey(t) + } + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), c.expectedErr) + }) + } +} + func Test_ValidateInputCmd_TextOutputWithShowSuccesses(t *testing.T) { fs := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fs, "/input.yaml", []byte("some: data"), 0644)) diff --git a/docs/modules/ROOT/pages/ec_validate_input.adoc b/docs/modules/ROOT/pages/ec_validate_input.adoc index 8445f6041..01743401c 100644 --- a/docs/modules/ROOT/pages/ec_validate_input.adoc +++ b/docs/modules/ROOT/pages/ec_validate_input.adoc @@ -9,21 +9,27 @@ Validate conformance of arbitrary JSON or yaml file input with the provided poli For each file, validation is performed to determine if the file conforms to rego policies defined in the EnterpriseContractPolicy. +Input files can be specified as positional arguments or via the --file flag (deprecated). +If both are provided, the values are combined. + [source,shell] ---- -ec validate input [flags] +ec validate input [file ...] [flags] ---- == Examples -Use an EnterpriseContractPolicy spec from a local YAML file to validate a single file -ec validate input --file /path/to/file.json --policy my-policy.yaml +Validate a single file using positional arguments +ec validate input /path/to/file.json --policy my-policy.yaml -Use an EnterpriseContractPolicy spec from a local YAML file to validate multiple files -The file flag can be repeated for multiple input files. -ec validate input --file /path/to/file.yaml --file /path/to/file2.yaml --policy my-policy.yaml +Validate multiple files using positional arguments +ec validate input /path/to/file.yaml /path/to/file2.yaml --policy my-policy.yaml -Use an EnterpriseContractPolicy spec from a local YAML file to validate multiple files -The file flag can take a comma separated series of files. +Validate all YAML files in a directory using shell glob expansion +ec validate input *.yaml --policy my-policy.yaml + +Use the deprecated --file flag (still functional, multiple forms supported) +ec validate input --file /path/to/file.json --policy my-policy.yaml +ec validate input --file /path/to/file.yaml --file /path/to/file2.yaml --policy my-policy.yaml ec validate input --file="/path/to/file.json,/path/to/file2.json" --policy my-policy.yaml Use a git url for the policy configuration. In the first example there should be a '.ec/policy.yaml' @@ -32,9 +38,9 @@ example there should be a '.ec/policy.yaml' or a 'policy.yaml' file in the top l of the git repo. For git repos not hosted on 'github.com' or 'gitlab.com', prefix the url with 'git::'. For the policy configuration files you can use json instead of yaml if you prefer. - ec validate input --file /path/to/file.json --policy github.com/user/repo//default?ref=main + ec validate input /path/to/file.json --policy github.com/user/repo//default?ref=main - ec validate input --file /path/to/file.yaml --policy github.com/user/repo + ec validate input /path/to/file.yaml --policy github.com/user/repo == Options @@ -43,7 +49,7 @@ of the git repo. For git repos not hosted on 'github.com' or 'gitlab.com', prefi --effective-time:: Run policy checks with the provided time. Useful for testing rules with effective dates in the future. The value can be "now" (default) - for current time, or a RFC3339 formatted value, e.g. 2022-11-18T00:00:00Z. (Default: now) --f, --file:: path to input YAML/JSON file (required) (Default: []) +-f, --file:: DEPRECATED: use positional arguments instead. Path to input YAML/JSON file (Default: []) --filter-type:: Filter type to use for policy evaluation. Options: "include-exclude" (default) or "ec-policy". - "include-exclude": Uses traditional include/exclude filtering without pipeline intentions - "ec-policy": Uses Enterprise Contract policy filtering with pipeline intention support (Default: include-exclude) diff --git a/features/__snapshots__/validate_input.snap b/features/__snapshots__/validate_input.snap index 3e066077f..d8c9213d9 100644 --- a/features/__snapshots__/validate_input.snap +++ b/features/__snapshots__/validate_input.snap @@ -172,3 +172,34 @@ success: true [TestFeatures/valid policy URL with publicKey in policy config:stderr - 1] --- + +[TestFeatures/deprecated file flag backward compatibility:stdout - 1] +{ + "success": true, + "filepaths": [ + { + "filepath": "pipeline_definition.yaml", + "violations": [], + "warnings": [], + "successes": null, + "success": true, + "success-count": 1 + } + ], + "policy": { + "sources": [ + { + "policy": [ + "git::https://${GITHOST}/git/happy-day-policy.git" + ] + } + ] + }, + "ec-version": "${EC_VERSION}", + "effective-time": "${TIMESTAMP}" +} +--- + +[TestFeatures/deprecated file flag backward compatibility:stderr - 1] + +--- diff --git a/features/validate_input.feature b/features/validate_input.feature index c50762fd5..21394907c 100644 --- a/features/validate_input.feature +++ b/features/validate_input.feature @@ -23,7 +23,7 @@ Feature: validate input name: init version: "0.1" """ - When ec command is run with "validate input --file pipeline_definition.yaml --policy git::https://${GITHOST}/git/happy-day-config.git --output json" + When ec command is run with "validate input pipeline_definition.yaml --policy git::https://${GITHOST}/git/happy-day-config.git --output json" Then the exit status should be 0 Then the output should match the snapshot @@ -47,7 +47,7 @@ Feature: validate input name: init version: "0.1" """ - When ec command is run with "validate input --file pipeline_definition.yaml --policy git::https://${GITHOST}/git/happy-day-config-with-public-key.git --output json" + When ec command is run with "validate input pipeline_definition.yaml --policy git::https://${GITHOST}/git/happy-day-config-with-public-key.git --output json" Then the exit status should be 0 Then the output should match the snapshot @@ -70,7 +70,7 @@ Feature: validate input name: init version: "0.1" """ - When ec command is run with "validate input --file pipeline_definition.yaml --policy git::https://${GITHOST}/git/happy-day-config.git --output text --show-successes --color" + When ec command is run with "validate input pipeline_definition.yaml --policy git::https://${GITHOST}/git/happy-day-config.git --output text --show-successes --color" Then the exit status should be 0 Then the output should match the snapshot @@ -93,7 +93,7 @@ Feature: validate input name: init version: "0.1" """ - When ec command is run with "validate input --file pipeline_definition.yaml --policy git::https://${GITHOST}/git/sad-day-config.git --output json" + When ec command is run with "validate input pipeline_definition.yaml --policy git::https://${GITHOST}/git/sad-day-config.git --output json" Then the exit status should be 1 Then the output should match the snapshot @@ -110,7 +110,7 @@ Feature: validate input spam: false ham: rotten """ - When ec command is run with "validate input --file input.yaml --policy git::https://${GITHOST}/git/multiple-sources-config.git --output json" + When ec command is run with "validate input input.yaml --policy git::https://${GITHOST}/git/multiple-sources-config.git --output json" Then the exit status should be 1 Then the output should match the snapshot @@ -132,7 +132,7 @@ Feature: validate input """ {} """ - When ec command is run with "validate input --file ${TMPDIR}/input.json --policy ${TMPDIR}/policy.yaml -o yaml" + When ec command is run with "validate input ${TMPDIR}/input.json --policy ${TMPDIR}/policy.yaml -o yaml" Then the exit status should be 0 Then the output should match the snapshot @@ -153,6 +153,29 @@ Feature: validate input """ {} """ - When ec command is run with "validate input --file ${TMPDIR}/input.json --policy ${TMPDIR}/policy.yaml -o yaml" + When ec command is run with "validate input ${TMPDIR}/input.json --policy ${TMPDIR}/policy.yaml -o yaml" Then the exit status should be 1 Then the output should match the snapshot + + Scenario: deprecated file flag backward compatibility + Given a git repository named "happy-day-config" with + | policy.yaml | examples/happy_config.yaml | + Given a git repository named "happy-day-policy" with + | main.rego | examples/happy_day.rego | + Given a pipeline definition file named "pipeline_definition.yaml" containing + """ + --- + apiVersion: tekton.dev/v1 + kind: Pipeline + metadata: + name: basic-build + spec: + tasks: + - name: appstudio-init + taskRef: + name: init + version: "0.1" + """ + When ec command is run with "validate input --file pipeline_definition.yaml --policy git::https://${GITHOST}/git/happy-day-config.git --output json" + Then the exit status should be 0 + Then the output should match the snapshot