Skip to content

Commit 0346c8e

Browse files
authored
Merge pull request #643 from stefanprodan/mod-vet-crd-validation
feat(engine): vet resources against their CRD schemas and CEL rules
2 parents 67050b6 + ba001e3 commit 0346c8e

31 files changed

Lines changed: 7812 additions & 117 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ The core of Timoni. Turns CUE into Kubernetes objects:
4747
- `ResourceSet` — the rendered set of objects.
4848
- `HealthCheck` — extracts the custom health checks a module declares under `timoni: healthChecks:` (the `#HealthCheck`/`#HealthCheckForCondition` CUE schemas) for custom resources that are not kstatus-compliant.
4949
- `Importer` (`importer.go`) — generates CUE definitions from Kubernetes **CRDs** by converting their OpenAPI v3 schemas (this is what `timoni mod vendor crd` runs, letting module authors use custom resources type-safely).
50+
- `CRDValidator` (`crd_validator.go`) — validates rendered custom resources with the kube-apiserver admission packages (OpenAPI schema, CEL rules, list uniqueness) against the original CRDs, which the `Importer` embeds in the generated `types_gen.cue` as a hidden `_crd` field and `timoni mod vet` collects from the module imports and output.
5051
- `fetcher/` — pulls module sources, either `local.go` (filesystem path) or `oci.go` (OCI registry).
5152

5253
### `internal/reconciler/` — server-side apply

cmd/timoni/mod_vendor_crd.go

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import (
3131
ssautil "github.com/fluxcd/pkg/ssa/utils"
3232
"github.com/hashicorp/go-cleanhttp"
3333
"github.com/spf13/cobra"
34-
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3534
"sigs.k8s.io/yaml"
3635

3736
"github.com/stefanprodan/timoni/internal/engine"
@@ -122,10 +121,7 @@ func runVendorCrdCmd(cmd *cobra.Command, args []string) error {
122121
return fmt.Errorf("parsing CRDs failed: %w", err)
123122
}
124123
for _, object := range objects {
125-
if object.GetKind() == "CustomResourceDefinition" {
126-
if err := removeCRDStatusSchema(object); err != nil {
127-
return err
128-
}
124+
if engine.IsCRD(object) {
129125
builder.WriteString("---\n")
130126
data, err := yaml.Marshal(object)
131127
if err != nil {
@@ -213,28 +209,3 @@ func readRemoteCRDManifest(ctx context.Context, client *http.Client, url string,
213209

214210
return data, nil
215211
}
216-
217-
// removeCRDStatusSchema removes the read-only status field from each version.
218-
func removeCRDStatusSchema(crd *unstructured.Unstructured) error {
219-
versions, found, err := unstructured.NestedSlice(crd.Object, "spec", "versions")
220-
if err != nil {
221-
return fmt.Errorf("reading CRD spec.versions failed: %w", err)
222-
}
223-
if !found {
224-
return nil
225-
}
226-
227-
for i := range versions {
228-
version, ok := versions[i].(map[string]any)
229-
if !ok {
230-
return fmt.Errorf("CRD spec.versions[%d] must be an object", i)
231-
}
232-
unstructured.RemoveNestedField(version, "schema", "openAPIV3Schema", "properties", "status")
233-
}
234-
235-
if err := unstructured.SetNestedSlice(crd.Object, versions, "spec", "versions"); err != nil {
236-
return fmt.Errorf("mutating versions in CRD failed: %w", err)
237-
}
238-
239-
return nil
240-
}

cmd/timoni/mod_vendor_crd_test.go

Lines changed: 0 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import (
3131
"github.com/mattn/go-shellwords"
3232
. "github.com/onsi/gomega"
3333
"github.com/onsi/gomega/types"
34-
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3534
)
3635

3736
func TestReadRemoteCRDManifestAcceptsBodyAtLimit(t *testing.T) {
@@ -131,72 +130,6 @@ func TestReadRemoteCRDManifestRejectsInsecureRedirect(t *testing.T) {
131130
g.Expect(err).To(MatchError(ContainSubstring("redirect to insecure HTTP")))
132131
}
133132

134-
func TestRemoveCRDStatusSchema(t *testing.T) {
135-
tests := []struct {
136-
name string
137-
versions any
138-
wantErr string
139-
}{
140-
{
141-
name: "non-object version",
142-
versions: []any{"v1"},
143-
wantErr: "spec.versions[0] must be an object",
144-
},
145-
{
146-
name: "non-list versions",
147-
versions: "v1",
148-
wantErr: "reading CRD spec.versions failed",
149-
},
150-
}
151-
152-
for _, tt := range tests {
153-
t.Run(tt.name, func(t *testing.T) {
154-
g := NewWithT(t)
155-
crd := &unstructured.Unstructured{Object: map[string]any{
156-
"spec": map[string]any{"versions": tt.versions},
157-
}}
158-
159-
err := removeCRDStatusSchema(crd)
160-
161-
g.Expect(err).To(MatchError(ContainSubstring(tt.wantErr)))
162-
})
163-
}
164-
}
165-
166-
func TestRemoveCRDStatusSchemaPreservesOtherFields(t *testing.T) {
167-
g := NewWithT(t)
168-
crd := &unstructured.Unstructured{Object: map[string]any{
169-
"spec": map[string]any{
170-
"versions": []any{
171-
map[string]any{
172-
"name": "v1",
173-
"schema": map[string]any{
174-
"openAPIV3Schema": map[string]any{
175-
"properties": map[string]any{
176-
"spec": map[string]any{"type": "object"},
177-
"status": map[string]any{"type": "object"},
178-
},
179-
},
180-
},
181-
},
182-
},
183-
},
184-
}}
185-
186-
err := removeCRDStatusSchema(crd)
187-
188-
g.Expect(err).ToNot(HaveOccurred())
189-
versions, found, err := unstructured.NestedSlice(crd.Object, "spec", "versions")
190-
g.Expect(err).ToNot(HaveOccurred())
191-
g.Expect(found).To(BeTrue())
192-
version := versions[0].(map[string]any)
193-
properties, found, err := unstructured.NestedMap(version, "schema", "openAPIV3Schema", "properties")
194-
g.Expect(err).ToNot(HaveOccurred())
195-
g.Expect(found).To(BeTrue())
196-
g.Expect(properties).To(HaveKey("spec"))
197-
g.Expect(properties).ToNot(HaveKey("status"))
198-
}
199-
200133
func TestVendorCrd(t *testing.T) {
201134
// To regenerate the golden files:
202135
// make install

cmd/timoni/mod_vet.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ var vetModCmd = &cobra.Command{
4242
Args: cobra.MaximumNArgs(1),
4343
Aliases: []string{"lint"},
4444
Short: "Validate a local module",
45-
Long: `The vet command builds the local module and validates the resulting Kubernetes objects.`,
45+
Long: `The vet command builds the local module and validates the resulting Kubernetes objects.
46+
Custom resources are validated against their CRD schemas and CEL rules,
47+
taken from the vendored CRD schemas and from the CRDs included in the module.`,
4648
Example: ` # validate module using default values
4749
timoni mod vet
4850
@@ -179,9 +181,41 @@ func runVetModCmd(cmd *cobra.Command, args []string) error {
179181
return fmt.Errorf("build failed, no objects to apply")
180182
}
181183

184+
// Register the CRD schemas vendored in the imported packages first,
185+
// so that the CRDs rendered by the module take precedence for the
186+
// same kind versions.
187+
imports, err := builder.GetImports()
188+
if err != nil {
189+
return fmt.Errorf("build failed: %w", err)
190+
}
191+
crdValidator := engine.NewCRDValidator()
192+
if err := crdValidator.AddPackages(imports); err != nil {
193+
return fmt.Errorf("validation failed: %w", err)
194+
}
195+
if err := crdValidator.AddCRDs(objects); err != nil {
196+
return fmt.Errorf("validation failed: %w", err)
197+
}
198+
199+
invalid := 0
182200
for _, object := range objects {
183-
log.Info(fmt.Sprintf("%s %s",
184-
logger.ColorizeSubject(ssautil.FmtUnstructured(object)), logger.ColorizeInfo("valid resource")))
201+
subject := logger.ColorizeSubject(ssautil.FmtUnstructured(object))
202+
if !crdValidator.HasSchema(object.GroupVersionKind()) {
203+
log.Info(fmt.Sprintf("%s %s", subject, logger.ColorizeInfo("valid resource")))
204+
continue
205+
}
206+
207+
if errs := crdValidator.Validate(cmd.Context(), object); len(errs) > 0 {
208+
invalid++
209+
for _, e := range errs {
210+
log.Error(nil, fmt.Sprintf("%s %s", subject, logger.ColorizeError(e)))
211+
}
212+
continue
213+
}
214+
log.Info(fmt.Sprintf("%s %s", subject, logger.ColorizeInfo("valid custom resource")))
215+
}
216+
217+
if invalid > 0 {
218+
return fmt.Errorf("validation failed, %d invalid custom resource(s)", invalid)
185219
}
186220

187221
images, err := builder.GetContainerImages(buildResult)

cmd/timoni/mod_vet_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,54 @@ func TestModVetSetNamespaceName(t *testing.T) {
159159
g.Expect(err.Error()).To(ContainSubstring("cannot find package"))
160160
})
161161
}
162+
163+
func TestModVetCELRules(t *testing.T) {
164+
// The module ships a Widget CRD with CEL rules and vendors the Gadget CRD
165+
// under cue.mod/gen. To regenerate the vendored files:
166+
// cd cmd/timoni/
167+
// timoni mod vendor crd testdata/module-cel -f testdata/module-cel/gadget-crd.yaml
168+
modPath := "testdata/module-cel"
169+
valuesPath := "testdata/module-cel-values"
170+
171+
t.Run("vets custom resources against the CEL rules", func(t *testing.T) {
172+
g := NewWithT(t)
173+
output, err := executeCommand(fmt.Sprintf(
174+
"mod vet %s -p main -n default",
175+
modPath,
176+
))
177+
g.Expect(err).ToNot(HaveOccurred())
178+
179+
g.Expect(output).To(ContainSubstring("CustomResourceDefinition/widgets.testing.timoni.sh valid resource v=0"))
180+
g.Expect(output).To(ContainSubstring("Widget/default/default valid custom resource"))
181+
g.Expect(output).To(ContainSubstring("Gadget/default/default valid custom resource"))
182+
g.Expect(output).To(ContainSubstring("timoni.sh/test-cel valid module"))
183+
})
184+
185+
t.Run("fails for rules of the CRD included in the module", func(t *testing.T) {
186+
g := NewWithT(t)
187+
output, err := executeCommand(fmt.Sprintf(
188+
"mod vet %s -p main -n default --values %s",
189+
modPath, valuesPath+"/widget-invalid.cue",
190+
))
191+
g.Expect(err).To(HaveOccurred())
192+
g.Expect(err.Error()).To(ContainSubstring("validation failed, 1 invalid custom resource(s)"))
193+
194+
g.Expect(output).To(ContainSubstring("Widget/default/default spec: minReplicas must not exceed replicas"))
195+
g.Expect(output).To(ContainSubstring("Gadget/default/default valid custom resource"))
196+
g.Expect(output).ToNot(ContainSubstring("valid module"))
197+
})
198+
199+
t.Run("fails for rules of the vendored CRD", func(t *testing.T) {
200+
g := NewWithT(t)
201+
output, err := executeCommand(fmt.Sprintf(
202+
"mod vet %s -p main -n default --values %s",
203+
modPath, valuesPath+"/gadget-invalid.cue",
204+
))
205+
g.Expect(err).To(HaveOccurred())
206+
g.Expect(err.Error()).To(ContainSubstring("validation failed, 1 invalid custom resource(s)"))
207+
208+
g.Expect(output).To(ContainSubstring("Gadget/default/default spec: size must be one of small or large"))
209+
g.Expect(output).ToNot(ContainSubstring("size is immutable"))
210+
g.Expect(output).To(ContainSubstring("Widget/default/default valid custom resource"))
211+
})
212+
}

0 commit comments

Comments
 (0)