Skip to content

Commit 38cd1d7

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 9f68bf4 commit 38cd1d7

4 files changed

Lines changed: 517 additions & 7 deletions

File tree

go.mod

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@ 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
2223
github.com/operator-framework/api v0.34.0
2324
github.com/operator-framework/helm-operator-plugins v0.8.0
2425
github.com/operator-framework/operator-registry v1.57.0
25-
github.com/prometheus/client_golang v1.23.2
26-
github.com/prometheus/common v0.66.1
26+
github.com/prometheus/client_golang v1.23.0
27+
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: 12 additions & 4 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=
@@ -399,13 +405,13 @@ github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY=
399405
github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
400406
github.com/proglottis/gpgme v0.1.4 h1:3nE7YNA70o2aLjcg63tXMOhPD7bplfE5CBdV+hLAm2M=
401407
github.com/proglottis/gpgme v0.1.4/go.mod h1:5LoXMgpE4bttgwwdv9bLs/vwqv3qV7F4glEEZ7mRKrM=
402-
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
403-
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
408+
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
409+
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
404410
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
405411
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
406412
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
407-
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
408-
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
413+
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
414+
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
409415
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
410416
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
411417
github.com/redis/go-redis/extra/rediscmd/v9 v9.10.0 h1:uTiEyEyfLhkw678n6EulHVto8AkcXVr8zUcBJNZ0ark=
@@ -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: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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 = "Namespace"
20+
notOwnNamespaceFormat = "NotOwnNamespace"
21+
)
22+
23+
var (
24+
// unsupportedInstallModes set of unsupported ClusterServiceVersion install modes
25+
unsupportedInstallModes = sets.New[v1alpha1.InstallModeType](v1alpha1.InstallModeTypeMultiNamespace)
26+
27+
// dnsFormat checks conformity to RFC1213 lowercase dns subdomain format by any field with format 'RFC-1123'
28+
dnsFormat = &schemavalidation.Format{
29+
Name: dns1123SubdomainFormat,
30+
Validate: func(v any) error {
31+
if v == nil {
32+
return nil
33+
}
34+
s, ok := v.(string)
35+
if !ok {
36+
return fmt.Errorf("invalid type %T, expected string", v)
37+
}
38+
errs := validation.IsDNS1123Subdomain(s)
39+
if len(errs) > 0 {
40+
return fmt.Errorf("%q is not a valid namespace name: %s", v, strings.Join(errs, ", "))
41+
}
42+
return nil
43+
},
44+
}
45+
)
46+
47+
// Config is a registry+v1 bundle configuration surface
48+
type Config struct {
49+
// WatchNamespace is supported for certain bundles to allow the user to configure installation in Single- or OwnNamespace modes
50+
// The validation behavior of this field is determined by the install modes supported by the bundle, e.g.:
51+
// - If a bundle only supports AllNamespaces mode (or only OwnNamespace mode): this field will be unknown
52+
// - If a bundle supports AllNamespaces and SingleNamespace install modes: this field is optional
53+
// - If a bundle supports AllNamespaces and OwnNamespace: this field is optional, but if set must be equal to the install namespace
54+
WatchNamespace string `json:"watchNamespace,omitempty"`
55+
}
56+
57+
// ValidatedBundleConfigFromRaw returns a validated Config struct from the values given in rawConfig.
58+
// The applied validation will be determined by the install modes supported by the bundle
59+
func ValidatedBundleConfigFromRaw(rv1 RegistryV1, installNamespace string, rawConfig map[string]interface{}) (*Config, error) {
60+
if len(rawConfig) == 0 {
61+
return nil, nil
62+
}
63+
64+
rawSchema := bundleConfigSchema(rv1, installNamespace)
65+
customFormats := []*schemavalidation.Format{
66+
dnsFormat,
67+
notOwnNamespaceFmt(installNamespace),
68+
}
69+
70+
if err := validateBundleConfig(rawSchema, customFormats, rawConfig); err != nil {
71+
return nil, fmt.Errorf("invalid configuration: %v", err)
72+
}
73+
74+
return toConfig(rawConfig)
75+
}
76+
77+
// bundleConfigSchema generates a jsonschema used to validate bundle configuration
78+
func bundleConfigSchema(rv1 RegistryV1, installNamespace string) []byte {
79+
// configure reflector
80+
r := new(jsonschema.Reflector)
81+
r.ExpandedStruct = true
82+
r.AllowAdditionalProperties = false
83+
84+
// generate base schema
85+
schema := r.Reflect(&Config{})
86+
87+
// apply bundle rawConfig based mutations for watchNamespace
88+
configureWatchNamespaceProperty(rv1, installNamespace, schema)
89+
90+
// return schema
91+
out, err := schema.MarshalJSON()
92+
if err != nil {
93+
panic(err)
94+
}
95+
return out
96+
}
97+
98+
// configureWatchNamespaceProperty modifies schema to configure the watchNamespace config property based on
99+
// the install modes supported by the bundle marking the field required or optional, or restricting the possible values
100+
// it can take
101+
func configureWatchNamespaceProperty(rv1 RegistryV1, installNamespace string, schema *jsonschema.Schema) {
102+
supportedInstallModes := sets.New[v1alpha1.InstallModeType]()
103+
for _, im := range rv1.CSV.Spec.InstallModes {
104+
if im.Supported && !unsupportedInstallModes.Has(im.Type) {
105+
supportedInstallModes.Insert(im.Type)
106+
}
107+
}
108+
109+
allSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeAllNamespaces)
110+
singleSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeSingleNamespace)
111+
ownSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeOwnNamespace)
112+
113+
if len(supportedInstallModes) == 0 {
114+
panic("bundle does not support any supported install modes")
115+
}
116+
117+
// no watchNamespace rawConfig parameter if bundle only supports AllNamespaces or OwnNamespace install modes
118+
if len(supportedInstallModes) == 1 && (allSupported || ownSupported) {
119+
schema.Properties.Delete("watchNamespace")
120+
return
121+
}
122+
123+
watchNamespaceProperty, ok := schema.Properties.Get("watchNamespace")
124+
if !ok {
125+
panic("watchNamespace not found in schema")
126+
}
127+
128+
watchNamespaceProperty.Format = dns1123SubdomainFormat
129+
130+
// required or optional
131+
if !allSupported && singleSupported {
132+
schema.Required = append(schema.Required, "watchNamespace")
133+
} else {
134+
// note: the library currently doesn't support jsonschema.Types
135+
// this is the current workaround for declaring optional/nullable fields
136+
// https://github.com/invopop/jsonschema/issues/115
137+
watchNamespaceProperty.Extras = map[string]any{
138+
"type": []string{"string", "null"},
139+
}
140+
if !ownSupported {
141+
// if own namespace is not supported validate that it is not being used
142+
watchNamespaceProperty.Format = notOwnNamespaceFormat
143+
}
144+
}
145+
146+
// must be the install namespace
147+
if allSupported && ownSupported && !singleSupported {
148+
watchNamespaceProperty.Enum = []any{
149+
installNamespace,
150+
nil,
151+
}
152+
}
153+
}
154+
155+
// validateBundleConfig validates the bundle rawConfig
156+
func validateBundleConfig(rawSchema []byte, customFormats []*schemavalidation.Format, rawConfig map[string]interface{}) error {
157+
schema, err := schemavalidation.UnmarshalJSON(strings.NewReader(string(rawSchema)))
158+
if err != nil {
159+
return err
160+
}
161+
162+
compiler := schemavalidation.NewCompiler()
163+
for _, format := range customFormats {
164+
compiler.RegisterFormat(format)
165+
}
166+
compiler.AssertFormat()
167+
if err := compiler.AddResource("schema.json", schema); err != nil {
168+
return err
169+
}
170+
compiledSchema, err := compiler.Compile("schema.json")
171+
if err != nil {
172+
return err
173+
}
174+
175+
return formatJSONSchemaValidationError(compiledSchema.Validate(rawConfig))
176+
}
177+
178+
// toConfig converts rawConfig into a Config struct
179+
func toConfig(rawConfig map[string]interface{}) (*Config, error) {
180+
cfg := Config{}
181+
dataBytes, err := json.Marshal(rawConfig)
182+
if err != nil {
183+
return nil, err
184+
}
185+
err = json.Unmarshal(dataBytes, &cfg)
186+
if err != nil {
187+
return nil, err
188+
}
189+
190+
return &cfg, nil
191+
}
192+
193+
// formatJSONSchemaValidationError extracts and formats the jsonschema validation errors given by the underlying library
194+
func formatJSONSchemaValidationError(err error) error {
195+
var validationErr *schemavalidation.ValidationError
196+
if !errors.As(err, &validationErr) {
197+
return err
198+
}
199+
var errs []error
200+
for _, cause := range validationErr.Causes {
201+
if cause == nil || cause.ErrorKind == nil {
202+
continue
203+
}
204+
205+
var errMsg string
206+
switch e := cause.ErrorKind.(type) {
207+
case *kind.Format:
208+
errMsg = e.Err.Error()
209+
default:
210+
errMsg = cause.Error()
211+
}
212+
213+
instanceLocation := "." + strings.Join(cause.InstanceLocation, ".")
214+
if instanceLocation == "." {
215+
errs = append(errs, fmt.Errorf("%v", errMsg))
216+
} else {
217+
errs = append(errs, fmt.Errorf("at path %q: %s", instanceLocation, errMsg))
218+
}
219+
}
220+
if len(errs) > 0 {
221+
return errors.Join(errs...)
222+
}
223+
return err
224+
}
225+
226+
// notOwnNamespaceFmt returns a dynamically generated format specifically for the case where
227+
// a bundle does not support own namespace installation but a watch namespace can be optionally given
228+
func notOwnNamespaceFmt(installNamespace string) *schemavalidation.Format {
229+
return &schemavalidation.Format{
230+
Name: notOwnNamespaceFormat,
231+
Validate: func(v any) error {
232+
if err := dnsFormat.Validate(v); err != nil {
233+
return err
234+
}
235+
if v == installNamespace {
236+
return fmt.Errorf("unsupported value %q, watchNamespace cannot be install namespace", v)
237+
}
238+
return nil
239+
},
240+
}
241+
}

0 commit comments

Comments
 (0)