Skip to content

Commit 8676a36

Browse files
committed
feat: Add TestVetSkipGroup and TestVetFlagExclusion for nomos vet flag validation.
1 parent 89a937b commit 8676a36

File tree

12 files changed

+435
-0
lines changed

12 files changed

+435
-0
lines changed

cmd/nomos/flags/flags.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ const (
4040
// SkipAPIServerFlag is the flag name for SkipAPIServer below.
4141
SkipAPIServerFlag = "no-api-server-check"
4242

43+
// NoAPIServerCheckForGroupFlag is the flag name for NoAPIServerCheckForGroup below.
44+
NoAPIServerCheckForGroupFlag = "no-api-server-check-for-group"
45+
4346
// OutputYAML specifies exporting the output in YAML format.
4447
OutputYAML = "yaml"
4548

@@ -64,6 +67,9 @@ var (
6467
// SkipAPIServer directs whether to try to contact the API Server for checks.
6568
SkipAPIServer bool
6669

70+
// NoAPIServerCheckForGroup contains the list of API Groups to skip API server validation for.
71+
NoAPIServerCheckForGroup []string
72+
6773
// SourceFormat indicates the format of the Git repository.
6874
SourceFormat string
6975

@@ -101,6 +107,12 @@ func AddSkipAPIServerCheck(cmd *cobra.Command) {
101107
"If true, disables talking to the API Server for discovery.")
102108
}
103109

110+
// AddNoAPIServerCheckForGroup adds the --no-api-server-check-for-group flag.
111+
func AddNoAPIServerCheckForGroup(cmd *cobra.Command) {
112+
cmd.Flags().StringSliceVar(&NoAPIServerCheckForGroup, NoAPIServerCheckForGroupFlag, nil,
113+
"Accepts a comma-separated list of API Groups for which **all** resources will be skipped during processing (e.g., 'constraints.gatekeeper.sh'). This excludes objects in these groups from all validation and processing, not just API server validation.")
114+
}
115+
104116
// AllClusters returns true if all clusters should be processed.
105117
func AllClusters() bool {
106118
return Clusters == nil

cmd/nomos/vet/vet.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/GoogleContainerTools/config-sync/cmd/nomos/flags"
2222
"github.com/GoogleContainerTools/config-sync/pkg/api/configsync"
2323
"github.com/GoogleContainerTools/config-sync/pkg/importer/analyzer/validation/system"
24+
"github.com/GoogleContainerTools/config-sync/pkg/util/gvkutil"
2425
"github.com/spf13/cobra"
2526
)
2627

@@ -35,6 +36,7 @@ func init() {
3536
flags.AddClusters(Cmd)
3637
flags.AddPath(Cmd)
3738
flags.AddSkipAPIServerCheck(Cmd)
39+
flags.AddNoAPIServerCheckForGroup(Cmd)
3840
flags.AddSourceFormat(Cmd)
3941
flags.AddOutputFormat(Cmd)
4042
flags.AddAPIServerTimeout(Cmd)
@@ -75,15 +77,27 @@ returns a non-zero error code if any issues are found.
7577
nomos vet --path=my/directory
7678
nomos vet --path=/path/to/my/directory`,
7779
Args: cobra.ExactArgs(0),
80+
PreRunE: func(_ *cobra.Command, _ []string) error {
81+
if flags.SkipAPIServer && len(flags.NoAPIServerCheckForGroup) > 0 {
82+
return fmt.Errorf("cannot use --%s with --%s", flags.SkipAPIServerFlag, flags.NoAPIServerCheckForGroupFlag)
83+
}
84+
return nil
85+
},
7886
RunE: func(cmd *cobra.Command, _ []string) error {
7987
// Don't show usage on error, as argument validation passed.
8088
cmd.SilenceUsage = true
8189

90+
skippedGVKs, err := gvkutil.ParsePatterns(flags.NoAPIServerCheckForGroup)
91+
if err != nil {
92+
return err
93+
}
94+
8295
return runVet(cmd.Context(), cmd.OutOrStderr(), vetOptions{
8396
Namespace: namespaceValue,
8497
SourceFormat: configsync.SourceFormat(flags.SourceFormat),
8598
APIServerTimeout: flags.APIServerTimeout,
8699
MaxObjectCount: threshold,
100+
SkippedGVKs: skippedGVKs,
87101
})
88102
},
89103
}

cmd/nomos/vet/vet_impl.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ import (
3636
"github.com/GoogleContainerTools/config-sync/pkg/parse"
3737
"github.com/GoogleContainerTools/config-sync/pkg/reconcilermanager"
3838
"github.com/GoogleContainerTools/config-sync/pkg/status"
39+
"github.com/GoogleContainerTools/config-sync/pkg/util/gvkutil"
3940
)
4041

4142
type vetOptions struct {
4243
Namespace string
4344
SourceFormat configsync.SourceFormat
4445
APIServerTimeout time.Duration
4546
MaxObjectCount int
47+
SkippedGVKs []gvkutil.Pattern
4648
}
4749

4850
// vet runs nomos vet with the specified options.
@@ -102,6 +104,7 @@ func runVet(ctx context.Context, out io.Writer, opts vetOptions) error {
102104
}
103105
validateOpts.FieldManager = util.FieldManager
104106
validateOpts.MaxObjectCount = opts.MaxObjectCount
107+
validateOpts.SkippedGVKs = opts.SkippedGVKs
105108

106109
switch sourceFormat {
107110
case configsync.SourceFormatHierarchy:

cmd/nomos/vet/vet_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"fmt"
2121
"os"
2222
"path/filepath"
23+
"strings"
2324
"testing"
2425

2526
"github.com/GoogleContainerTools/config-sync/cmd/nomos/flags"
@@ -50,6 +51,7 @@ func resetFlags() {
5051
keepOutput = false
5152
outPath = flags.DefaultHydrationOutput
5253
flags.OutputFormat = flags.OutputYAML
54+
flags.NoAPIServerCheckForGroup = nil
5355
}
5456

5557
var examplesDir = cmpath.RelativeSlash("../../../examples")
@@ -180,6 +182,75 @@ func TestVet_MultiCluster(t *testing.T) {
180182
}
181183
}
182184

185+
func TestVet_FlagExclusivity(t *testing.T) {
186+
testCases := []struct {
187+
name string
188+
flags []string
189+
expectError bool
190+
expectedError string
191+
}{
192+
{
193+
name: "Both flags used",
194+
flags: []string{"--no-api-server-check", "--no-api-server-check-for-group=constraints.gatekeeper.sh"},
195+
expectError: true,
196+
expectedError: "cannot use --no-api-server-check with --no-api-server-check-for-group",
197+
},
198+
{
199+
name: "Only --no-api-server-check used",
200+
flags: []string{"--no-api-server-check"},
201+
expectError: false,
202+
},
203+
{
204+
name: "Only --no-api-server-check-for-group used",
205+
flags: []string{"--no-api-server-check-for-group=constraints.gatekeeper.sh"},
206+
expectError: false,
207+
},
208+
{
209+
name: "No flags used",
210+
flags: []string{},
211+
expectError: false,
212+
},
213+
}
214+
215+
for _, tc := range testCases {
216+
t.Run(tc.name, func(t *testing.T) {
217+
resetFlags()
218+
// We need a valid directory for the command to run, even though we're testing flag validation.
219+
tmpDir, err := os.MkdirTemp("", "nomos-vet-test")
220+
require.NoError(t, err)
221+
t.Cleanup(func() {
222+
err := os.RemoveAll(tmpDir)
223+
require.NoError(t, err)
224+
})
225+
226+
// Reset flags to a clean state for each test case.
227+
flags.SkipAPIServer = false
228+
flags.NoAPIServerCheckForGroup = nil
229+
230+
// Set the flags for the current test case.
231+
var groups []string
232+
for _, f := range tc.flags {
233+
if f == "--no-api-server-check" {
234+
flags.SkipAPIServer = true
235+
} else if strings.HasPrefix(f, "--no-api-server-check-for-group=") {
236+
parts := strings.SplitN(f, "=", 2)
237+
groups = append(groups, strings.Split(parts[1], ",")...)
238+
}
239+
}
240+
flags.NoAPIServerCheckForGroup = groups
241+
242+
err = Cmd.PreRunE(Cmd, nil)
243+
244+
if tc.expectError {
245+
require.Error(t, err)
246+
require.Contains(t, err.Error(), tc.expectedError)
247+
} else {
248+
require.NoError(t, err)
249+
}
250+
})
251+
}
252+
}
253+
183254
func TestVet_Threshold(t *testing.T) {
184255
Cmd.SilenceUsage = true
185256

e2e/testcases/cli_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,6 +1278,34 @@ func TestNomosStatusNameFilter(t *testing.T) {
12781278
}
12791279
}
12801280

1281+
func TestVetSkipGVK(t *testing.T) {
1282+
nt := nomostest.New(t, nomostesting.NomosCLI,
1283+
ntopts.SyncWithGitSource(nomostest.DefaultRootSyncID, ntopts.Unstructured),
1284+
)
1285+
1286+
rootRepo := nt.SyncSourceGitReadWriteRepository(nomostest.DefaultRootSyncID)
1287+
1288+
nt.Must(rootRepo.Copy("../testdata/gatekeeper-skip-gvk", "acme"))
1289+
if err := rootRepo.CommitAndPush("add gatekeeper constraint template and constraint"); err != nil {
1290+
nt.T.Fatal(err)
1291+
}
1292+
1293+
// First, run `nomos vet` without the flag and expect a failure.
1294+
out, err := nt.Shell.Command("nomos", "vet", "--path", rootRepo.Root, "--source-format=unstructured").CombinedOutput()
1295+
if err == nil {
1296+
t.Fatal("expected `nomos vet` to fail but it passed")
1297+
}
1298+
// Check for KNV1021 error.
1299+
if !strings.Contains(string(out), "KNV1021") {
1300+
t.Fatalf("expected KNV1021 error, but got: %v", string(out))
1301+
}
1302+
1303+
// Now, run `nomos vet` with the flag and expect it to pass.
1304+
output, err := nt.Shell.Command("nomos", "vet", "--path", rootRepo.Root, "--source-format=unstructured", "--no-api-server-check-for-group=templates.gatekeeper.sh,constraints.gatekeeper.sh").CombinedOutput()
1305+
nt.Must(output, err)
1306+
1307+
}
1308+
12811309
func TestApiResourceFormatting(t *testing.T) {
12821310
nt := nomostest.New(t, nomostesting.NomosCLI)
12831311

@@ -1296,6 +1324,18 @@ func TestApiResourceFormatting(t *testing.T) {
12961324
assert.Equal(t, columnName, header)
12971325
}
12981326

1327+
func TestVetFlagExclusion(t *testing.T) {
1328+
nt := nomostest.New(t, nomostesting.NomosCLI, ntopts.SkipConfigSyncInstall)
1329+
1330+
out, err := nt.Shell.Command("nomos", "vet", "--no-api-server-check", "--no-api-server-check-for-group=constraints.gatekeeper.sh").CombinedOutput()
1331+
if err == nil {
1332+
t.Fatal("expected `nomos vet` to fail but it passed")
1333+
}
1334+
if !strings.Contains(string(out), "cannot use --no-api-server-check with --no-api-server-check-for-group") {
1335+
t.Fatalf("expected error message, but got: %v", string(out))
1336+
}
1337+
}
1338+
12991339
func TestNomosMigrate(t *testing.T) {
13001340
nt := nomostest.New(t, nomostesting.NomosCLI, ntopts.SkipConfigSyncInstall)
13011341

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
apiVersion: templates.gatekeeper.sh/v1beta1
15+
kind: ConstraintTemplate
16+
metadata:
17+
name: k8srequiredlabels
18+
spec:
19+
crd:
20+
spec:
21+
names:
22+
kind: K8sRequiredLabels
23+
targets:
24+
- target: admission.k8s.gatekeeper.sh
25+
rego: |-
26+
package k8srequiredlabels
27+
violation[{"msg": msg, "details": {"missing_labels": missing}}] {
28+
provided := {label | input.review.object.metadata.labels[label]}
29+
required := {label | label := input.parameters.labels[_]}
30+
missing := required - provided
31+
count(missing) > 0
32+
msg := sprintf("you must provide labels: %v", [missing])
33+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
apiVersion: constraints.gatekeeper.sh/v1beta1
15+
kind: K8sRequiredLabels
16+
metadata:
17+
name: ns-must-have-gk-label
18+
spec:
19+
match:
20+
kinds:
21+
- apiGroups: [""]
22+
kinds: ["Namespace"]
23+
parameters:
24+
labels: ["gatekeeper"]

pkg/util/gvkutil/gvk.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package gvkutil
16+
17+
import (
18+
"k8s.io/apimachinery/pkg/runtime/schema"
19+
)
20+
21+
// Pattern represents a pattern to match against a GroupKind.
22+
type Pattern struct {
23+
Group string
24+
}
25+
26+
// ParsePatterns parses a slice of strings into a slice of Patterns.
27+
// Each string is expected to be a group.
28+
func ParsePatterns(rawPatterns []string) ([]Pattern, error) {
29+
if rawPatterns == nil {
30+
return []Pattern{}, nil
31+
}
32+
patterns := make([]Pattern, 0, len(rawPatterns))
33+
for _, p := range rawPatterns {
34+
patterns = append(patterns, Pattern{Group: p})
35+
}
36+
return patterns, nil
37+
}
38+
39+
// Matches checks if the given GroupKind matches any of the patterns.
40+
func Matches(gk schema.GroupKind, patterns []Pattern) bool {
41+
for _, p := range patterns {
42+
if gk.Group == p.Group {
43+
return true
44+
}
45+
}
46+
return false
47+
}

0 commit comments

Comments
 (0)