Skip to content

Commit 700ec6f

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 bd36e5a commit 700ec6f

2 files changed

Lines changed: 411 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)