Skip to content

Commit 630a47d

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 630a47d

2 files changed

Lines changed: 395 additions & 0 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package bundle
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
9+
jsonschemagen "github.com/invopop/jsonschema"
10+
"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 = &jsonschema.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 string `json:"watchNamespace,omitempty"`
45+
// OperatorConfig *v1alpha1.SubscriptionConfig `json:"operatorConfig,omitempty"`
46+
}
47+
48+
func ValidatedBundleConfigFromRaw(rv1 RegistryV1, installNamespace string, rawConfig map[string]interface{}) (*Config, error) {
49+
if len(rawConfig) == 0 {
50+
return nil, nil
51+
}
52+
53+
rawSchema := bundleConfigSchema(rv1, installNamespace)
54+
if err := validateBundleConfig(rawSchema, rawConfig); err != nil {
55+
return nil, fmt.Errorf("invalid configuration: %v", err)
56+
}
57+
58+
return toConfig(rawConfig)
59+
}
60+
61+
func bundleConfigSchema(rv1 RegistryV1, installNamespace string) []byte {
62+
// configure reflector
63+
r := new(jsonschemagen.Reflector)
64+
r.ExpandedStruct = true
65+
r.AllowAdditionalProperties = false
66+
67+
// generate base schema
68+
schema := r.Reflect(&Config{})
69+
70+
// apply bundle rawConfig based mutations for watchNamespace
71+
configureWatchNamespaceProperty(rv1, installNamespace, schema)
72+
73+
// return schema
74+
out, err := schema.MarshalJSON()
75+
if err != nil {
76+
panic(err)
77+
}
78+
return out
79+
}
80+
81+
func configureWatchNamespaceProperty(rv1 RegistryV1, installNamespace string, schema *jsonschemagen.Schema) {
82+
supportedInstallModes := sets.New[v1alpha1.InstallModeType]()
83+
for _, im := range rv1.CSV.Spec.InstallModes {
84+
if im.Supported && !unsupportedInstallModes.Has(im.Type) {
85+
supportedInstallModes.Insert(im.Type)
86+
}
87+
}
88+
89+
allSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeAllNamespaces)
90+
singleSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeSingleNamespace)
91+
ownSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeOwnNamespace)
92+
93+
if len(supportedInstallModes) == 0 {
94+
panic("bundle does not support any supported install modes")
95+
}
96+
97+
// no watchNamespace rawConfig parameter if bundle only supports AllNamespaces or OwnNamespace install modes
98+
if len(supportedInstallModes) == 1 && (allSupported || ownSupported) {
99+
schema.Properties.Delete("watchNamespace")
100+
return
101+
}
102+
103+
watchNamespaceProperty, ok := schema.Properties.Get("watchNamespace")
104+
if !ok {
105+
panic("watchNamespace not found in schema")
106+
}
107+
108+
watchNamespaceProperty.Format = dns1123SubdomainFormat
109+
110+
// required or optional
111+
if !allSupported && singleSupported {
112+
schema.Required = append(schema.Required, "watchNamespace")
113+
} else {
114+
watchNamespaceProperty.Extras = map[string]any{
115+
"type": []string{"string", "null"},
116+
}
117+
}
118+
119+
// must be the install namespace
120+
if allSupported && ownSupported && !singleSupported {
121+
watchNamespaceProperty.Enum = []any{
122+
installNamespace,
123+
nil,
124+
}
125+
}
126+
}
127+
128+
func validateBundleConfig(rawSchema []byte, config map[string]interface{}) error {
129+
schema, err := jsonschema.UnmarshalJSON(strings.NewReader(string(rawSchema)))
130+
if err != nil {
131+
return err
132+
}
133+
134+
compiler := jsonschema.NewCompiler()
135+
compiler.RegisterFormat(dnsFormat)
136+
compiler.AssertFormat()
137+
if err := compiler.AddResource("schema.json", schema); err != nil {
138+
return err
139+
}
140+
compiledSchema, err := compiler.Compile("schema.json")
141+
if err != nil {
142+
return err
143+
}
144+
145+
return formatJSONSchemaValidationError(compiledSchema.Validate(config))
146+
}
147+
148+
func toConfig(config map[string]interface{}) (*Config, error) {
149+
cfg := Config{}
150+
dataBytes, err := json.Marshal(config)
151+
if err != nil {
152+
return nil, err
153+
}
154+
err = json.Unmarshal(dataBytes, &cfg)
155+
if err != nil {
156+
return nil, err
157+
}
158+
159+
return &cfg, nil
160+
}
161+
162+
func formatJSONSchemaValidationError(err error) error {
163+
var validationErr *jsonschema.ValidationError
164+
if !errors.As(err, &validationErr) {
165+
return err
166+
}
167+
var errs []error
168+
for _, cause := range validationErr.Causes {
169+
if cause == nil || cause.BasicOutput() == nil {
170+
continue
171+
}
172+
173+
output := cause.BasicOutput()
174+
instanceLocation := strings.ReplaceAll(output.InstanceLocation, "/", ".")
175+
if instanceLocation == "" {
176+
errs = append(errs, fmt.Errorf("%v", output.Error))
177+
} else {
178+
errs = append(errs, fmt.Errorf("at path %q: %s", instanceLocation, output.Error))
179+
}
180+
}
181+
if len(errs) > 0 {
182+
return errors.Join(errs...)
183+
}
184+
return err
185+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package bundle_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
"github.com/operator-framework/api/pkg/operators/v1alpha1"
9+
10+
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle"
11+
. "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing"
12+
)
13+
14+
const (
15+
installNamespace = "install-namespace"
16+
)
17+
18+
var (
19+
requiredConfigSet = []v1alpha1.InstallModeType{
20+
v1alpha1.InstallModeTypeSingleNamespace,
21+
}
22+
23+
optionalConfigSet = []v1alpha1.InstallModeType{
24+
v1alpha1.InstallModeTypeAllNamespaces,
25+
v1alpha1.InstallModeTypeSingleNamespace,
26+
}
27+
28+
optionalRestrictedConfigSet = []v1alpha1.InstallModeType{
29+
v1alpha1.InstallModeTypeAllNamespaces,
30+
v1alpha1.InstallModeTypeOwnNamespace,
31+
}
32+
33+
notRequiredConfigSet = []v1alpha1.InstallModeType{
34+
v1alpha1.InstallModeTypeAllNamespaces,
35+
}
36+
)
37+
38+
func Test_ValidatedBundleConfigFromRaw_WatchNamespace_Configuration(t *testing.T) {
39+
// The behavior of watchNamespace is dynamic and depends on the install modes supported by
40+
// the bundle (declared in its ClusterServiceVersion). For instance, a bundle that only
41+
// supports AllNamespaces or only supports OwnNamespace mode does not need a watchNamespace configuration, or
42+
// a bundle that supports AllNamespaces and OwnNamespace install modes will have an optional watchNamespace
43+
// configuration, however when set, the value must be equal to the install namespace
44+
for _, tt := range []struct {
45+
name string
46+
supportedInstallModes []v1alpha1.InstallModeType
47+
rawConfig map[string]interface{}
48+
expectedErrMsgFragment string
49+
expectedConfig *bundle.Config
50+
}{
51+
{
52+
name: "watchNamespace is required and provided",
53+
supportedInstallModes: requiredConfigSet,
54+
rawConfig: map[string]interface{}{
55+
"watchNamespace": "some-namespace",
56+
},
57+
expectedConfig: &bundle.Config{
58+
WatchNamespace: "some-namespace",
59+
},
60+
},
61+
{
62+
name: "watchNamespace is required and provided but invalid",
63+
supportedInstallModes: requiredConfigSet,
64+
rawConfig: map[string]interface{}{
65+
"watchNamespace": "not a valid namespace name",
66+
},
67+
expectedErrMsgFragment: "'not a valid namespace name' is not valid RFC-1123",
68+
},
69+
{
70+
name: "watchNamespace is required and provided but empty",
71+
supportedInstallModes: requiredConfigSet,
72+
rawConfig: map[string]interface{}{
73+
"watchNamespace": "",
74+
},
75+
expectedErrMsgFragment: "'' is not valid RFC-1123",
76+
},
77+
{
78+
name: "watchNamespace is required and not provided (nil)",
79+
supportedInstallModes: requiredConfigSet,
80+
rawConfig: nil,
81+
expectedConfig: nil,
82+
},
83+
{
84+
name: "watchNamespace is required and not provided (empty config)",
85+
supportedInstallModes: requiredConfigSet,
86+
rawConfig: map[string]interface{}{},
87+
expectedConfig: nil,
88+
},
89+
{
90+
name: "watchNamespace is optional and provided",
91+
supportedInstallModes: optionalConfigSet,
92+
rawConfig: map[string]interface{}{
93+
"watchNamespace": "some-namespace",
94+
},
95+
expectedConfig: &bundle.Config{
96+
WatchNamespace: "some-namespace",
97+
},
98+
},
99+
{
100+
name: "watchNamespace is optional and provided but invalid",
101+
supportedInstallModes: optionalConfigSet,
102+
rawConfig: map[string]interface{}{
103+
"watchNamespace": "not a valid namespace name",
104+
},
105+
expectedErrMsgFragment: "'not a valid namespace name' is not valid RFC-1123",
106+
},
107+
{
108+
name: "watchNamespace is optional and not provided (nil config)",
109+
supportedInstallModes: optionalConfigSet,
110+
rawConfig: nil,
111+
expectedConfig: nil,
112+
},
113+
{
114+
name: "watchNamespace is optional and not provided (empty config)",
115+
supportedInstallModes: optionalConfigSet,
116+
rawConfig: map[string]interface{}{},
117+
expectedConfig: nil,
118+
},
119+
{
120+
name: "watchNamespace is optional and not provided (nil)",
121+
supportedInstallModes: optionalConfigSet,
122+
rawConfig: map[string]interface{}{
123+
"watchNamespace": nil,
124+
},
125+
expectedConfig: &bundle.Config{},
126+
},
127+
{
128+
name: "watchNamespace is optional and restricted to install-namespace and correctly provided",
129+
supportedInstallModes: optionalRestrictedConfigSet,
130+
rawConfig: map[string]interface{}{
131+
"watchNamespace": "install-namespace",
132+
},
133+
expectedConfig: &bundle.Config{
134+
WatchNamespace: "install-namespace",
135+
},
136+
},
137+
{
138+
name: "watchNamespace is optional and restricted to install-namespace and incorrectly provided",
139+
supportedInstallModes: optionalRestrictedConfigSet,
140+
rawConfig: map[string]interface{}{
141+
"watchNamespace": "not-install-namespace",
142+
},
143+
expectedErrMsgFragment: "value must be one of 'install-namespace', <nil>",
144+
},
145+
{
146+
name: "watchNamespace is optional and restricted to install-namespace and not provided (nil)",
147+
supportedInstallModes: optionalRestrictedConfigSet,
148+
rawConfig: map[string]interface{}{
149+
"watchNamespace": nil,
150+
},
151+
expectedConfig: &bundle.Config{},
152+
},
153+
{
154+
name: "watchNamespace is optional and restricted to install-namespace and not provided (nil config)",
155+
supportedInstallModes: optionalRestrictedConfigSet,
156+
rawConfig: nil,
157+
expectedConfig: nil,
158+
},
159+
{
160+
name: "watchNamespace is optional and restricted to install-namespace and not provided (empty config)",
161+
supportedInstallModes: optionalRestrictedConfigSet,
162+
rawConfig: map[string]interface{}{},
163+
expectedConfig: nil,
164+
},
165+
{
166+
name: "watchNamespace is not a config option and it is set",
167+
supportedInstallModes: notRequiredConfigSet,
168+
rawConfig: map[string]interface{}{
169+
"watchNamespace": "some-namespace",
170+
},
171+
expectedErrMsgFragment: "additional properties 'watchNamespace' not allowed",
172+
},
173+
{
174+
name: "watchNamespace is not a config option and it is not set (nil)",
175+
supportedInstallModes: notRequiredConfigSet,
176+
rawConfig: map[string]interface{}{
177+
"watchNamespace": nil,
178+
},
179+
expectedErrMsgFragment: "additional properties 'watchNamespace' not allowed",
180+
},
181+
{
182+
name: "watchNamespace is not a config option and it is not set (config empty)",
183+
supportedInstallModes: notRequiredConfigSet,
184+
rawConfig: map[string]interface{}{},
185+
expectedConfig: nil,
186+
},
187+
{
188+
name: "watchNamespace is not a config option and it is not set (config nil)",
189+
supportedInstallModes: notRequiredConfigSet,
190+
rawConfig: nil,
191+
expectedConfig: nil,
192+
},
193+
} {
194+
t.Run(tt.name, func(t *testing.T) {
195+
rv1 := bundle.RegistryV1{
196+
CSV: MakeCSV(WithInstallModeSupportFor(tt.supportedInstallModes...)),
197+
}
198+
199+
cfg, err := bundle.ValidatedBundleConfigFromRaw(rv1, installNamespace, tt.rawConfig)
200+
201+
if tt.expectedErrMsgFragment == "" {
202+
require.NoError(t, err)
203+
require.Equal(t, tt.expectedConfig, cfg)
204+
} else {
205+
require.Error(t, err)
206+
require.Contains(t, err.Error(), tt.expectedErrMsgFragment)
207+
}
208+
})
209+
}
210+
}

0 commit comments

Comments
 (0)