Skip to content

Commit 97a2f86

Browse files
author
Per Goncalves da Silva
committed
Add config support with jsonschema validation
Signed-off-by: Per Goncalves da Silva <[email protected]>
1 parent 5d6e431 commit 97a2f86

File tree

4 files changed

+513
-1
lines changed

4 files changed

+513
-1
lines changed

go.mod

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
github.com/google/go-containerregistry v0.20.6
1717
github.com/google/renameio/v2 v2.0.0
1818
github.com/gorilla/handlers v1.5.2
19+
github.com/invopop/jsonschema v0.13.0
1920
github.com/klauspost/compress v1.18.0
2021
github.com/opencontainers/go-digest v1.0.0
2122
github.com/opencontainers/image-spec v1.1.1
@@ -24,6 +25,7 @@ require (
2425
github.com/operator-framework/operator-registry v1.57.0
2526
github.com/prometheus/client_golang v1.23.0
2627
github.com/prometheus/common v0.65.0
28+
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
2729
github.com/spf13/cobra v1.10.1
2830
github.com/stretchr/testify v1.11.1
2931
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
@@ -68,7 +70,9 @@ require (
6870
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
6971
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
7072
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
73+
github.com/bahlo/generic-list-go v0.2.0 // indirect
7174
github.com/beorn7/perks v1.0.1 // indirect
75+
github.com/buger/jsonparser v1.1.1 // indirect
7276
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
7377
github.com/cespare/xxhash/v2 v2.3.0 // indirect
7478
github.com/chai2010/gettext-go v1.0.2 // indirect
@@ -182,7 +186,6 @@ require (
182186
github.com/rivo/uniseg v0.4.7 // indirect
183187
github.com/rubenv/sql-migrate v1.8.0 // indirect
184188
github.com/russross/blackfriday/v2 v2.1.0 // indirect
185-
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
186189
github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect
187190
github.com/shopspring/decimal v1.4.0 // indirect
188191
github.com/sigstore/fulcio v1.7.1 // indirect
@@ -199,6 +202,7 @@ require (
199202
github.com/ulikunitz/xz v0.5.14 // indirect
200203
github.com/vbatts/tar-split v0.12.1 // indirect
201204
github.com/vbauerster/mpb/v8 v8.10.2 // indirect
205+
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
202206
github.com/x448/float16 v0.8.4 // indirect
203207
github.com/xlab/treeprint v1.2.0 // indirect
204208
go.etcd.io/bbolt v1.4.3 // indirect

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,16 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
3838
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
3939
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
4040
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
41+
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
42+
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
4143
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
4244
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
4345
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
4446
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
4547
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
4648
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
49+
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
50+
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
4751
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
4852
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
4953
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -271,6 +275,8 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI
271275
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
272276
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
273277
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
278+
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
279+
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
274280
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
275281
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
276282
github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs=
@@ -473,6 +479,8 @@ github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnn
473479
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
474480
github.com/vbauerster/mpb/v8 v8.10.2 h1:2uBykSHAYHekE11YvJhKxYmLATKHAGorZwFlyNw4hHM=
475481
github.com/vbauerster/mpb/v8 v8.10.2/go.mod h1:+Ja4P92E3/CorSZgfDtK46D7AVbDqmBQRTmyTqPElo0=
482+
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
483+
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
476484
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
477485
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
478486
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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+
"github.com/santhosh-tekuri/jsonschema/v6/kind"
12+
"k8s.io/apimachinery/pkg/util/sets"
13+
"k8s.io/apimachinery/pkg/util/validation"
14+
15+
"github.com/operator-framework/api/pkg/operators/v1alpha1"
16+
)
17+
18+
const (
19+
dns1123SubdomainFormat = "RFC-1123"
20+
21+
// the format name is injected into the error
22+
notOwnNamespaceFormat = "watchNamespace"
23+
)
24+
25+
var (
26+
// unsupportedInstallModes set of unsupported ClusterServiceVersion install modes
27+
unsupportedInstallModes = sets.New[v1alpha1.InstallModeType](v1alpha1.InstallModeTypeMultiNamespace)
28+
29+
// dnsFormat checks conformity to RFC1213 lowercase dns subdomain format by any field with format 'RFC-1123'
30+
dnsFormat = &schemavalidation.Format{
31+
Name: dns1123SubdomainFormat,
32+
Validate: func(v any) error {
33+
if v == nil {
34+
return nil
35+
}
36+
s, ok := v.(string)
37+
if !ok {
38+
return fmt.Errorf("invalid type %T, expected string", v)
39+
}
40+
errs := validation.IsDNS1123Subdomain(s)
41+
if len(errs) > 0 {
42+
return fmt.Errorf("%q is not a valid namespace name: %s", v, strings.Join(errs, ", "))
43+
}
44+
return nil
45+
},
46+
}
47+
)
48+
49+
// Config is a registry+v1 bundle configuration surface
50+
type Config struct {
51+
// WatchNamespace is supported for certain bundles to allow the user to configure installation in Single- or OwnNamespace modes
52+
// The validation behavior of this field is determined by the install modes supported by the bundle, e.g.:
53+
// - If a bundle only supports AllNamespaces mode (or only OwnNamespace mode): this field will be unknown
54+
// - If a bundle supports AllNamespaces and SingleNamespace install modes: this field is optional
55+
// - If a bundle supports AllNamespaces and OwnNamespace: this field is optional, but if set must be equal to the install namespace
56+
WatchNamespace string `json:"watchNamespace,omitempty"`
57+
}
58+
59+
// ValidatedBundleConfigFromRaw returns a validated Config struct from the values given in rawConfig.
60+
// The applied validation will be determined by the install modes supported by the bundle
61+
func ValidatedBundleConfigFromRaw(rv1 RegistryV1, installNamespace string, rawConfig map[string]interface{}) (*Config, error) {
62+
if len(rawConfig) == 0 {
63+
return nil, nil
64+
}
65+
66+
rawSchema := bundleConfigSchema(rv1, installNamespace)
67+
customFormats := []*schemavalidation.Format{
68+
dnsFormat,
69+
notOwnNamespaceFmt(installNamespace),
70+
}
71+
72+
if err := validateBundleConfig(rawSchema, customFormats, rawConfig); err != nil {
73+
return nil, fmt.Errorf("invalid configuration: %v", err)
74+
}
75+
76+
return toConfig(rawConfig)
77+
}
78+
79+
// bundleConfigSchema generates a jsonschema used to validate bundle configuration
80+
func bundleConfigSchema(rv1 RegistryV1, installNamespace string) []byte {
81+
// configure reflector
82+
r := new(jsonschema.Reflector)
83+
r.ExpandedStruct = true
84+
r.AllowAdditionalProperties = false
85+
86+
// generate base schema
87+
schema := r.Reflect(&Config{})
88+
89+
// apply bundle rawConfig based mutations for watchNamespace
90+
configureWatchNamespaceProperty(rv1, installNamespace, schema)
91+
92+
// return schema
93+
out, err := schema.MarshalJSON()
94+
if err != nil {
95+
panic(err)
96+
}
97+
return out
98+
}
99+
100+
// configureWatchNamespaceProperty modifies schema to configure the watchNamespace config property based on
101+
// the install modes supported by the bundle marking the field required or optional, or restricting the possible values
102+
// it can take
103+
func configureWatchNamespaceProperty(rv1 RegistryV1, installNamespace string, schema *jsonschema.Schema) {
104+
supportedInstallModes := sets.New[v1alpha1.InstallModeType]()
105+
for _, im := range rv1.CSV.Spec.InstallModes {
106+
if im.Supported && !unsupportedInstallModes.Has(im.Type) {
107+
supportedInstallModes.Insert(im.Type)
108+
}
109+
}
110+
111+
allSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeAllNamespaces)
112+
singleSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeSingleNamespace)
113+
ownSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeOwnNamespace)
114+
115+
if len(supportedInstallModes) == 0 {
116+
panic("bundle does not support any supported install modes")
117+
}
118+
119+
// no watchNamespace rawConfig parameter if bundle only supports AllNamespaces or OwnNamespace install modes
120+
if len(supportedInstallModes) == 1 && (allSupported || ownSupported) {
121+
schema.Properties.Delete("watchNamespace")
122+
return
123+
}
124+
125+
watchNamespaceProperty, ok := schema.Properties.Get("watchNamespace")
126+
if !ok {
127+
panic("watchNamespace not found in schema")
128+
}
129+
130+
watchNamespaceProperty.Format = dns1123SubdomainFormat
131+
132+
// required or optional
133+
if !allSupported && singleSupported {
134+
schema.Required = append(schema.Required, "watchNamespace")
135+
} else {
136+
// note: the library currently doesn't support jsonschema.Types
137+
// this is the current workaround for declaring optional/nullable fields
138+
// https://github.com/invopop/jsonschema/issues/115
139+
watchNamespaceProperty.Extras = map[string]any{
140+
"type": []string{"string", "null"},
141+
}
142+
if !ownSupported {
143+
// if own namespace is not supported validate that it is not being used
144+
watchNamespaceProperty.Format = notOwnNamespaceFormat
145+
}
146+
}
147+
148+
// must be the install namespace
149+
if allSupported && ownSupported && !singleSupported {
150+
watchNamespaceProperty.Enum = []any{
151+
installNamespace,
152+
nil,
153+
}
154+
}
155+
}
156+
157+
// validateBundleConfig validates the bundle rawConfig
158+
func validateBundleConfig(rawSchema []byte, customFormats []*schemavalidation.Format, rawConfig map[string]interface{}) error {
159+
schema, err := schemavalidation.UnmarshalJSON(strings.NewReader(string(rawSchema)))
160+
if err != nil {
161+
return err
162+
}
163+
164+
compiler := schemavalidation.NewCompiler()
165+
for _, format := range customFormats {
166+
compiler.RegisterFormat(format)
167+
}
168+
compiler.AssertFormat()
169+
if err := compiler.AddResource("schema.json", schema); err != nil {
170+
return err
171+
}
172+
compiledSchema, err := compiler.Compile("schema.json")
173+
if err != nil {
174+
return err
175+
}
176+
177+
return formatJSONSchemaValidationError(compiledSchema.Validate(rawConfig))
178+
}
179+
180+
// toConfig converts rawConfig into a Config struct
181+
func toConfig(rawConfig map[string]interface{}) (*Config, error) {
182+
cfg := Config{}
183+
dataBytes, err := json.Marshal(rawConfig)
184+
if err != nil {
185+
return nil, err
186+
}
187+
err = json.Unmarshal(dataBytes, &cfg)
188+
if err != nil {
189+
return nil, err
190+
}
191+
192+
return &cfg, nil
193+
}
194+
195+
// formatJSONSchemaValidationError extracts and formats the jsonschema validation errors given by the underlying library
196+
func formatJSONSchemaValidationError(err error) error {
197+
var validationErr *schemavalidation.ValidationError
198+
if !errors.As(err, &validationErr) {
199+
return err
200+
}
201+
var errs []error
202+
for _, cause := range validationErr.Causes {
203+
if cause == nil || cause.ErrorKind == nil {
204+
continue
205+
}
206+
207+
var errMsg string
208+
switch e := cause.ErrorKind.(type) {
209+
case *kind.Format:
210+
errMsg = e.Err.Error()
211+
default:
212+
errMsg = cause.Error()
213+
}
214+
215+
instanceLocation := "." + strings.Join(cause.InstanceLocation, ".")
216+
if instanceLocation == "." {
217+
errs = append(errs, fmt.Errorf("%v", errMsg))
218+
} else {
219+
errs = append(errs, fmt.Errorf("at path %q: %s", instanceLocation, errMsg))
220+
}
221+
}
222+
if len(errs) > 0 {
223+
return errors.Join(errs...)
224+
}
225+
return err
226+
}
227+
228+
// notOwnNamespaceFmt returns a dynamically generated format specifically for the case where
229+
// a bundle does not support own namespace installation but a watch namespace can be optionally given
230+
func notOwnNamespaceFmt(installNamespace string) *schemavalidation.Format {
231+
return &schemavalidation.Format{
232+
Name: notOwnNamespaceFormat,
233+
Validate: func(v any) error {
234+
if err := dnsFormat.Validate(v); err != nil {
235+
return err
236+
}
237+
if v == installNamespace {
238+
return fmt.Errorf("unsupported value %q, watchNamespace cannot be install namespace", v)
239+
}
240+
return nil
241+
},
242+
}
243+
}

0 commit comments

Comments
 (0)