Skip to content

Commit 86f8c3a

Browse files
author
Per Goncalves da Silva
committed
Add config support with jsonschema validation
Signed-off-by: Per Goncalves da Silva <pegoncal@redhat.com>
1 parent 5d6e431 commit 86f8c3a

2 files changed

Lines changed: 414 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package bundle
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/invopop/jsonschema"
10+
schemavalidation "github.com/santhosh-tekuri/jsonschema/v6"
11+
"k8s.io/apimachinery/pkg/util/sets"
12+
"k8s.io/apimachinery/pkg/util/validation"
13+
14+
"github.com/operator-framework/api/pkg/operators/v1alpha1"
15+
)
16+
17+
const (
18+
dns1123SubdomainFormat = "RFC-1123"
19+
)
20+
21+
var (
22+
// unsupportedInstallModes set of unsupported ClusterServiceVersion install modes
23+
unsupportedInstallModes = sets.New[v1alpha1.InstallModeType](v1alpha1.InstallModeTypeMultiNamespace)
24+
25+
// dnsFormat checks conformity to RFC1213 lowercase dns subdomain format by any field with format 'RFC-1123'
26+
dnsFormat = &schemavalidation.Format{
27+
Name: dns1123SubdomainFormat,
28+
Validate: func(v any) error {
29+
if v == nil {
30+
return nil
31+
}
32+
s, ok := v.(string)
33+
if !ok {
34+
return fmt.Errorf("invalid type %T, expected string", v)
35+
}
36+
errs := validation.IsDNS1123Subdomain(s)
37+
if len(errs) > 0 {
38+
return errors.New(strings.Join(errs, ", "))
39+
}
40+
return nil
41+
},
42+
}
43+
)
44+
45+
// Config is a registry+v1 bundle configuration surface
46+
type Config struct {
47+
// WatchNamespace is supported for certain bundles to allow the user to configure installation in Single- or OwnNamespace modes
48+
// The validation behavior of this field is determined by the install modes supported by the bundle, e.g.:
49+
// - If a bundle only supports AllNamespaces mode (or only OwnNamespace mode): this field will be unknown
50+
// - If a bundle supports AllNamespaces and SingleNamespace install modes: this field is optional
51+
// - If a bundle supports AllNamespaces and OwnNamespace: this field is optional, but if set must be equal to the install namespace
52+
WatchNamespace string `json:"watchNamespace,omitempty"`
53+
}
54+
55+
// ValidatedBundleConfigFromRaw returns a validated Config struct from the values given in rawConfig.
56+
// The applied validation will be determined by the install modes supported by the bundle
57+
func ValidatedBundleConfigFromRaw(rv1 RegistryV1, installNamespace string, rawConfig map[string]interface{}) (*Config, error) {
58+
if len(rawConfig) == 0 {
59+
return nil, nil
60+
}
61+
62+
rawSchema := bundleConfigSchema(rv1, installNamespace)
63+
if err := validateBundleConfig(rawSchema, rawConfig); err != nil {
64+
return nil, fmt.Errorf("invalid configuration: %v", err)
65+
}
66+
67+
return toConfig(rawConfig)
68+
}
69+
70+
// bundleConfigSchema generates a jsonschema used to validate bundle configuration
71+
func bundleConfigSchema(rv1 RegistryV1, installNamespace string) []byte {
72+
// configure reflector
73+
r := new(jsonschema.Reflector)
74+
r.ExpandedStruct = true
75+
r.AllowAdditionalProperties = false
76+
77+
// generate base schema
78+
schema := r.Reflect(&Config{})
79+
80+
// apply bundle rawConfig based mutations for watchNamespace
81+
configureWatchNamespaceProperty(rv1, installNamespace, schema)
82+
83+
// return schema
84+
out, err := schema.MarshalJSON()
85+
if err != nil {
86+
panic(err)
87+
}
88+
return out
89+
}
90+
91+
// configureWatchNamespaceProperty modifies schema to configure the watchNamespace config property based on
92+
// the install modes supported by the bundle marking the field required or optional, or restricting the possible values
93+
// it can take
94+
func configureWatchNamespaceProperty(rv1 RegistryV1, installNamespace string, schema *jsonschema.Schema) {
95+
supportedInstallModes := sets.New[v1alpha1.InstallModeType]()
96+
for _, im := range rv1.CSV.Spec.InstallModes {
97+
if im.Supported && !unsupportedInstallModes.Has(im.Type) {
98+
supportedInstallModes.Insert(im.Type)
99+
}
100+
}
101+
102+
allSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeAllNamespaces)
103+
singleSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeSingleNamespace)
104+
ownSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeOwnNamespace)
105+
106+
if len(supportedInstallModes) == 0 {
107+
panic("bundle does not support any supported install modes")
108+
}
109+
110+
// no watchNamespace rawConfig parameter if bundle only supports AllNamespaces or OwnNamespace install modes
111+
if len(supportedInstallModes) == 1 && (allSupported || ownSupported) {
112+
schema.Properties.Delete("watchNamespace")
113+
return
114+
}
115+
116+
watchNamespaceProperty, ok := schema.Properties.Get("watchNamespace")
117+
if !ok {
118+
panic("watchNamespace not found in schema")
119+
}
120+
121+
watchNamespaceProperty.Format = dns1123SubdomainFormat
122+
123+
// required or optional
124+
if !allSupported && singleSupported {
125+
schema.Required = append(schema.Required, "watchNamespace")
126+
} else {
127+
// note: the library currently doesn't support jsonschema.Types
128+
// this is the current workaround for declaring optional/nullable fields
129+
// https://github.com/invopop/jsonschema/issues/115
130+
watchNamespaceProperty.Extras = map[string]any{
131+
"type": []string{"string", "null"},
132+
}
133+
}
134+
135+
// must be the install namespace
136+
if allSupported && ownSupported && !singleSupported {
137+
watchNamespaceProperty.Enum = []any{
138+
installNamespace,
139+
nil,
140+
}
141+
}
142+
}
143+
144+
// validateBundleConfig validates the bundle rawConfig
145+
func validateBundleConfig(rawSchema []byte, rawConfig map[string]interface{}) error {
146+
schema, err := schemavalidation.UnmarshalJSON(strings.NewReader(string(rawSchema)))
147+
if err != nil {
148+
return err
149+
}
150+
151+
compiler := schemavalidation.NewCompiler()
152+
compiler.RegisterFormat(dnsFormat)
153+
compiler.AssertFormat()
154+
if err := compiler.AddResource("schema.json", schema); err != nil {
155+
return err
156+
}
157+
compiledSchema, err := compiler.Compile("schema.json")
158+
if err != nil {
159+
return err
160+
}
161+
162+
return formatJSONSchemaValidationError(compiledSchema.Validate(rawConfig))
163+
}
164+
165+
// toConfig converts rawConfig into a Config struct
166+
func toConfig(rawConfig map[string]interface{}) (*Config, error) {
167+
cfg := Config{}
168+
dataBytes, err := json.Marshal(rawConfig)
169+
if err != nil {
170+
return nil, err
171+
}
172+
err = json.Unmarshal(dataBytes, &cfg)
173+
if err != nil {
174+
return nil, err
175+
}
176+
177+
return &cfg, nil
178+
}
179+
180+
// formatJSONSchemaValidationError extracts and formats the jsonschema validation errors given by the underlying library
181+
func formatJSONSchemaValidationError(err error) error {
182+
var validationErr *schemavalidation.ValidationError
183+
if !errors.As(err, &validationErr) {
184+
return err
185+
}
186+
var errs []error
187+
for _, cause := range validationErr.Causes {
188+
if cause == nil || cause.BasicOutput() == nil {
189+
continue
190+
}
191+
192+
output := cause.BasicOutput()
193+
instanceLocation := strings.ReplaceAll(output.InstanceLocation, "/", ".")
194+
if instanceLocation == "" {
195+
errs = append(errs, fmt.Errorf("%v", output.Error))
196+
} else {
197+
errs = append(errs, fmt.Errorf("at path %q: %s", instanceLocation, output.Error))
198+
}
199+
}
200+
if len(errs) > 0 {
201+
return errors.Join(errs...)
202+
}
203+
return err
204+
}

0 commit comments

Comments
 (0)