Skip to content

Commit e2835d9

Browse files
committed
encoding/jsonschema: implement ExtractCRDs
This adds a CRD-specific entry-point that extracts all the CRDs from within a particular CRD spec and makes it easy for a caller to filter them at will. Signed-off-by: Roger Peppe <[email protected]> Change-Id: Ieb6bda596ed57b5e933e5d35b62127a59531a9cc Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1217735 TryBot-Result: CUEcueckoo <[email protected]> Unity-Result: CUE porcuepine <[email protected]> Reviewed-by: Daniel Martí <[email protected]>
1 parent 7bddda5 commit e2835d9

13 files changed

+599
-17
lines changed

encoding/jsonschema/crd.cue

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package jsonschema
2+
3+
// input holds the parsed YAML document, which may contain multiple
4+
// Kubernetes resources, in which case it will be an array.
5+
input!: _
6+
7+
// specs holds the resulting CRD specs: when there was only
8+
// a single resource in the input, we'll get an array with one
9+
// element.
10+
// TODO(rog) replace comparisons to bottom with whatever the
11+
// correct replacement will be.
12+
specs: {
13+
[...]
14+
if (input & [...]) != _|_ {
15+
// It's an array: include only elements that look like CRDs.
16+
[
17+
for doc in input
18+
if (doc & {#crdlike, ...}) != _|_ {
19+
// Note: don't check for unification with #CRDSpec above because
20+
// we want it to fail if it doesn't unify with the entirety of #CRDSpec,
21+
// not just exclude the document.
22+
doc
23+
#CRDSpec
24+
},
25+
]
26+
}
27+
if (input & [...]) == _|_ {
28+
// It's a single document. Include it if it looks like a CRD.
29+
if (input & {#crdlike, ...}) != _|_ {
30+
[{input, #CRDSpec}]
31+
}
32+
}
33+
}
34+
35+
#crdlike: {
36+
apiVersion!: "apiextensions.k8s.io/v1"
37+
kind!: "CustomResourceDefinition"
38+
} @go(crdLike)
39+
40+
// CRDSpec defines a subset of the CRD schema, suitable for filtering
41+
// CRDs based on common criteria like group and name.
42+
#CRDSpec: {
43+
#crdlike
44+
apiVersion!: "apiextensions.k8s.io/v1"
45+
kind!: "CustomResourceDefinition"
46+
spec!: {
47+
group!: string
48+
names!: {
49+
kind!: string
50+
plural!: string
51+
singular!: string
52+
}
53+
scope!: "Namespaced" | "Cluster"
54+
versions!: [... {
55+
name!: string
56+
schema!: {
57+
openAPIV3Schema!: _ @go(,type="cuelang.org/go/cue".Value)
58+
}
59+
}]
60+
}
61+
}

encoding/jsonschema/crd.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package jsonschema
2+
3+
import (
4+
_ "embed"
5+
"fmt"
6+
7+
"cuelang.org/go/cue"
8+
"cuelang.org/go/cue/ast"
9+
"cuelang.org/go/cue/token"
10+
)
11+
12+
//go:generate go run cuelang.org/go/cmd/cue exp gengotypes .
13+
14+
//go:embed crd.cue
15+
var crdCUE []byte
16+
17+
// CRDConfig holds configuration for [ExtractCRDs].
18+
// Although this empty currently, it allows configuration
19+
// to be added in the future without breaking the API.
20+
type CRDConfig struct{}
21+
22+
// ExtractedCRD holds an extracted Kubernetes CRD and the data it was derived from.
23+
type ExtractedCRD struct {
24+
// Versions holds the CUE schemas extracted from the CRD: one per
25+
// version.
26+
Versions map[string]*ast.File
27+
28+
// Data holds chosen fields extracted from the source CRD document.
29+
Data *CRDSpec
30+
31+
// Source holds the raw CRD document from which Data is derived.
32+
Source cue.Value
33+
}
34+
35+
// ExtractCRDs extracts Kubernetes custom resource definitions
36+
// (CRDs) from the given data. If data holds an array, each element
37+
// of the array might itself be a Kubernetes resource.
38+
//
39+
// While the data must hold Kubernetes resources, those resources
40+
// need not all be CRDs: resources with a kind that's not "CustomResourceDefinition"
41+
// will be ignored.
42+
//
43+
// If cfg is nil, it's equivalent to passing a pointer to the zero-valued [CRDConfig].
44+
func ExtractCRDs(data cue.Value, cfg *CRDConfig) ([]*ExtractedCRD, error) {
45+
crdInfos, crdValues, err := decodeCRDSpecs(data)
46+
if err != nil {
47+
return nil, fmt.Errorf("cannot decode CRD: %v", err)
48+
}
49+
crds := make([]*ExtractedCRD, len(crdInfos))
50+
for crdIndex, crd := range crdInfos {
51+
versions := make(map[string]*ast.File)
52+
for i, version := range crd.Spec.Versions {
53+
f, err := Extract(crdValues[crdIndex], &Config{
54+
PkgName: version.Name,
55+
// There are several kubernetes-related keywords that aren't implemented yet
56+
StrictFeatures: false,
57+
StrictKeywords: true,
58+
Root: fmt.Sprintf("#/spec/versions/%d/schema/openAPIV3Schema", i),
59+
SingleRoot: true,
60+
DefaultVersion: VersionKubernetesCRD,
61+
})
62+
if err != nil {
63+
return nil, err
64+
}
65+
namespaceConstraint := token.OPTION
66+
if crd.Spec.Scope == "Namespaced" {
67+
namespaceConstraint = token.NOT
68+
}
69+
// TODO provide a way to let this refer to a shared definition
70+
// in another package as the canonical definition for the schema.
71+
f.Decls = append(f.Decls,
72+
&ast.Field{
73+
Label: ast.NewIdent("apiVersion"),
74+
Value: ast.NewString(crd.Spec.Group + "/" + version.Name),
75+
},
76+
&ast.Field{
77+
Label: ast.NewIdent("kind"),
78+
Value: ast.NewString(crd.Spec.Names.Kind),
79+
},
80+
&ast.Field{
81+
Label: ast.NewIdent("metadata"),
82+
Constraint: token.NOT,
83+
Value: ast.NewStruct(
84+
"name", token.NOT, ast.NewIdent("string"),
85+
"namespace", namespaceConstraint, ast.NewIdent("string"),
86+
// TODO inline struct lit
87+
"labels", token.OPTION, ast.NewStruct(
88+
ast.NewList(ast.NewIdent("string")), ast.NewIdent("string"),
89+
),
90+
"annotations", token.OPTION, ast.NewStruct(
91+
ast.NewList(ast.NewIdent("string")), ast.NewIdent("string"),
92+
),
93+
// The above fields aren't exhaustive.
94+
// TODO it would be nicer to refer to the actual #ObjectMeta
95+
// definition instead (and for that to be the case for embedded
96+
// resources in general) but that needs a deeper fix inside
97+
// encoding/jsonschema.
98+
&ast.Ellipsis{},
99+
),
100+
},
101+
)
102+
versions[version.Name] = f
103+
}
104+
crds[crdIndex] = &ExtractedCRD{
105+
Versions: versions,
106+
Data: crdInfos[crdIndex],
107+
Source: crdValues[crdIndex],
108+
}
109+
}
110+
return crds, nil
111+
}
112+
113+
// decodeCRDSpecs decodes the CRD(s) held in the given value.
114+
// It returns both the (partially) decoded CRDs and the values
115+
// they were decoded from.
116+
func decodeCRDSpecs(v cue.Value) ([]*CRDSpec, []cue.Value, error) {
117+
ctx := v.Context()
118+
119+
// Check against the CUE version of the schema which can
120+
// do more detailed checks, including checking required fields,
121+
// before decoding into the Go struct.
122+
123+
// TODO it would be nice to avoid compiling crdCUE every time, but
124+
// that's not possible given the restrictions on combining cue.Values
125+
// derived from different contexts.
126+
crdSchema := ctx.CompileBytes(crdCUE)
127+
crdSchema = crdSchema.FillPath(cue.MakePath(cue.Str("input")), v)
128+
specsv := crdSchema.LookupPath(cue.MakePath(cue.Str("specs")))
129+
if err := specsv.Err(); err != nil {
130+
return nil, nil, err
131+
}
132+
var specs []*CRDSpec
133+
var specsValues []cue.Value
134+
if err := specsv.Decode(&specs); err != nil {
135+
return nil, nil, err
136+
}
137+
if err := specsv.Decode(&specsValues); err != nil {
138+
return nil, nil, err
139+
}
140+
return specs, specsValues, nil
141+
}

encoding/jsonschema/cue_types_gen.go

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

encoding/jsonschema/decode.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,8 @@ func (s0 *state) schemaState(n cue.Value, types cue.Kind, init func(*state)) (ex
904904
if s.schemaVersion == VersionKubernetesCRD && s.isRoot {
905905
// The root of a CRD is always a resource, so treat it as if it contained
906906
// the x-kubernetes-embedded-resource keyword
907+
// TODO remove this behavior now that we have an explicit
908+
// ExtractCRDs function which does a better job at doing this.
907909
c := constraintMap["x-kubernetes-embedded-resource"]
908910
if c.phase != pass {
909911
continue

encoding/jsonschema/decode_test.go

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import (
1818
"bytes"
1919
"fmt"
2020
"io/fs"
21+
"maps"
2122
"net/url"
2223
"path"
24+
"slices"
2325
"strings"
2426
"testing"
2527

@@ -71,6 +73,17 @@ func TestDecode(t *testing.T) {
7173
Matrix: cuetdtest.FullMatrix,
7274
}
7375
test.Run(t, func(t *cuetxtar.Test) {
76+
ctx := t.CueContext()
77+
78+
fsys, err := txtar.FS(t.Archive)
79+
if err != nil {
80+
t.Fatal(err)
81+
}
82+
v, err := readSchema(ctx, fsys)
83+
if err != nil {
84+
t.Fatal(err)
85+
}
86+
7487
cfg := &jsonschema.Config{}
7588

7689
if t.HasTag("brokenInV2") && t.M.Name() == "v2" {
@@ -96,6 +109,9 @@ func TestDecode(t *testing.T) {
96109
cfg.Root = "#/components/schemas/"
97110
cfg.StrictKeywords = true
98111
case "k8sCRD":
112+
if v.IncompleteKind() == cue.ListKind {
113+
t.Skip("regular Extract does not cope with extracting CRDs from arrays")
114+
}
99115
cfg.DefaultVersion = jsonschema.VersionKubernetesCRD
100116
// Default to the first version; can be overridden with #root.
101117
cfg.Root = "#/spec/versions/0/schema/openAPIV3Schema"
@@ -119,20 +135,6 @@ func TestDecode(t *testing.T) {
119135
}
120136
cfg.PkgName, _ = t.Value("pkgName")
121137

122-
ctx := t.CueContext()
123-
124-
fsys, err := txtar.FS(t.Archive)
125-
if err != nil {
126-
t.Fatal(err)
127-
}
128-
v, err := readSchema(ctx, fsys)
129-
if err != nil {
130-
t.Fatal(err)
131-
}
132-
if err := v.Err(); err != nil {
133-
t.Fatal(err)
134-
}
135-
136138
w := t.Writer("extract")
137139
expr, err := jsonschema.Extract(v, cfg)
138140
if err != nil {
@@ -234,9 +236,53 @@ func TestDecode(t *testing.T) {
234236
})
235237
}
236238

239+
func TestDecodeCRD(t *testing.T) {
240+
test := cuetxtar.TxTarTest{
241+
Root: "./testdata/txtar",
242+
Name: "decodeCRD",
243+
Matrix: cuetdtest.FullMatrix,
244+
}
245+
test.Run(t, func(t *cuetxtar.Test) {
246+
if versStr, ok := t.Value("version"); !ok || versStr != "k8sCRD" {
247+
t.Skip("test not relevant to CRDs")
248+
}
249+
250+
ctx := t.CueContext()
251+
252+
fsys, err := txtar.FS(t.Archive)
253+
if err != nil {
254+
t.Fatal(err)
255+
}
256+
v, err := readSchema(ctx, fsys)
257+
if err != nil {
258+
t.Fatal(err)
259+
}
260+
crds, err := jsonschema.ExtractCRDs(v, &jsonschema.CRDConfig{})
261+
if err != nil {
262+
w := t.Writer("extractCRD/error")
263+
fmt.Fprintf(w, "%v\n", err)
264+
return
265+
}
266+
for i, crd := range crds {
267+
for _, version := range slices.Sorted(maps.Keys(crd.Versions)) {
268+
w := t.Writer(fmt.Sprintf("extractCRD/%d/%s", i, version))
269+
f := crd.Versions[version]
270+
b, err := format.Node(f, format.Simplify())
271+
if err != nil {
272+
t.Fatal(errors.Details(err, nil))
273+
}
274+
b = append(bytes.TrimSpace(b), '\n')
275+
w.Write(b)
276+
// TODO test that schema actually works.
277+
}
278+
}
279+
})
280+
}
281+
237282
func readSchema(ctx *cue.Context, fsys fs.FS) (cue.Value, error) {
238283
jsonData, jsonErr := fs.ReadFile(fsys, "schema.json")
239284
yamlData, yamlErr := fs.ReadFile(fsys, "schema.yaml")
285+
var v cue.Value
240286
switch {
241287
case jsonErr == nil && yamlErr == nil:
242288
return cue.Value{}, fmt.Errorf("cannot define both schema.json and schema.yaml")
@@ -245,15 +291,20 @@ func readSchema(ctx *cue.Context, fsys fs.FS) (cue.Value, error) {
245291
if err != nil {
246292
return cue.Value{}, err
247293
}
248-
return ctx.BuildExpr(expr), nil
294+
v = ctx.BuildExpr(expr)
249295
case yamlErr == nil:
250296
file, err := yaml.Extract("schema.yaml", yamlData)
251297
if err != nil {
252298
return cue.Value{}, err
253299
}
254-
return ctx.BuildFile(file), nil
300+
v = ctx.BuildFile(file)
301+
default:
302+
return cue.Value{}, fmt.Errorf("no schema.yaml or schema.json file found for test")
303+
}
304+
if err := v.Err(); err != nil {
305+
return cue.Value{}, err
255306
}
256-
return cue.Value{}, fmt.Errorf("no schema.yaml or schema.json file found for test")
307+
return v, nil
257308
}
258309

259310
func TestMapURL(t *testing.T) {

0 commit comments

Comments
 (0)