Skip to content

Commit 8285761

Browse files
authored
Merge pull request #42 from kevinrizza/validation
OperatorHub io validator
2 parents 9f0bd9c + 5072cd1 commit 8285761

13 files changed

+646
-36
lines changed
Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
package manifests
22

33
import (
4-
"fmt"
5-
"os"
6-
74
"github.com/operator-framework/api/pkg/manifests"
85
"github.com/operator-framework/api/pkg/validation"
96
"github.com/operator-framework/api/pkg/validation/errors"
@@ -13,33 +10,57 @@ import (
1310
)
1411

1512
func NewCmd() *cobra.Command {
16-
return &cobra.Command{
13+
rootCmd := &cobra.Command{
1714
Use: "manifests",
1815
Short: "Validates all manifests in a directory",
19-
Long: `'operator-verify manifests' validates all manifests in the supplied directory
16+
Long: `'operator-verify manifests' validates a bundle in the supplied directory
2017
and prints errors and warnings corresponding to each manifest found to be
2118
invalid. Manifests are only validated if a validator for that manifest
2219
type/kind, ex. CustomResourceDefinition, is implemented in the Operator
2320
validation library.`,
24-
Run: func(cmd *cobra.Command, args []string) {
25-
if len(args) != 1 {
26-
log.Fatalf("command %s requires exactly one argument", cmd.CommandPath())
27-
}
28-
bundle, err := manifests.GetBundleFromDir(args[0])
29-
if err != nil {
30-
log.Fatalf("Error generating bundle from directory %s", err.Error())
31-
}
32-
results := validation.AllValidators.Validate(bundle)
33-
nonEmptyResults := []errors.ManifestResult{}
34-
for _, result := range results {
35-
if result.HasError() || result.HasWarn() {
36-
nonEmptyResults = append(nonEmptyResults, result)
37-
}
38-
}
39-
if len(nonEmptyResults) != 0 {
40-
fmt.Println(nonEmptyResults)
41-
os.Exit(1)
42-
}
43-
},
21+
Run: manifestsFunc,
22+
}
23+
24+
rootCmd.Flags().Bool("operatorhub_validate", false, "enable optional UI validation for operatorhub.io")
25+
26+
return rootCmd
27+
}
28+
29+
func manifestsFunc(cmd *cobra.Command, args []string) {
30+
bundle, err := manifests.GetBundleFromDir(args[0])
31+
if err != nil {
32+
log.Fatalf("Error generating bundle from directory: %s", err.Error())
33+
}
34+
if bundle == nil {
35+
log.Fatalf("Error generating bundle from directory")
36+
}
37+
38+
operatorHubValidate, err := cmd.Flags().GetBool("operatorhub_validate")
39+
if err != nil {
40+
log.Fatalf("Unable to parse operatorhub_validate parameter")
41+
}
42+
43+
validators := validation.DefaultBundleValidators
44+
if operatorHubValidate {
45+
validators = validators.WithValidators(validation.OperatorHubValidator)
46+
}
47+
48+
results := validators.Validate(bundle.ObjectsToValidate()...)
49+
nonEmptyResults := []errors.ManifestResult{}
50+
for _, result := range results {
51+
if result.HasError() || result.HasWarn() {
52+
nonEmptyResults = append(nonEmptyResults, result)
53+
}
54+
}
55+
56+
for _, result := range nonEmptyResults {
57+
for _, err := range result.Errors {
58+
log.Errorf(err.Error())
59+
}
60+
for _, err := range result.Warnings {
61+
log.Warnf(err.Error())
62+
}
4463
}
64+
65+
return
4566
}

pkg/manifests/bundle.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,17 @@ type Bundle struct {
2020
V1CRDs []*apiextensionsv1.CustomResourceDefinition
2121
Dependencies []*Dependency
2222
}
23+
24+
func (b *Bundle) ObjectsToValidate() []interface{} {
25+
objs := []interface{}{}
26+
for _, crd := range b.V1CRDs {
27+
objs = append(objs, crd)
28+
}
29+
for _, crd := range b.V1beta1CRDs {
30+
objs = append(objs, crd)
31+
}
32+
objs = append(objs, b.CSV)
33+
objs = append(objs, b)
34+
35+
return objs
36+
}

pkg/manifests/bundleloader.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ import (
1818

1919
// bundleLoader loads a bundle directory from disk
2020
type bundleLoader struct {
21-
dir string
22-
bundle *Bundle
21+
dir string
22+
bundle *Bundle
23+
foundCSV bool
2324
}
2425

2526
func NewBundleLoader(dir string) bundleLoader {
@@ -34,6 +35,12 @@ func (b *bundleLoader) LoadBundle() error {
3435
errs = append(errs, err)
3536
}
3637

38+
if !b.foundCSV {
39+
errs = append(errs, fmt.Errorf("unable to find a csv in bundle directory %s", b.dir))
40+
} else if b.bundle == nil {
41+
errs = append(errs, fmt.Errorf("unable to load bundle from directory %s", b.dir))
42+
}
43+
3744
return utilerrors.NewAggregate(errs)
3845
}
3946

@@ -82,6 +89,8 @@ func (b *bundleLoader) LoadBundleWalkFunc(path string, f os.FileInfo, err error)
8289
return nil
8390
}
8491

92+
b.foundCSV = true
93+
8594
var errs []error
8695
bundle, err := loadBundle(csv.GetName(), filepath.Dir(path))
8796
if err != nil {

pkg/validation/internal/csv.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func validateCSV(csv *v1alpha1.ClusterServiceVersion) errors.ManifestResult {
4646
// validate installModes
4747
result.Add(validateInstallModes(csv)...)
4848
// check missing optional/mandatory fields.
49-
result.Add(checkFields(csv)...)
49+
result.Add(checkFields(*csv)...)
5050
return result
5151
}
5252

@@ -67,7 +67,7 @@ func parseCSVNameFormat(name string) (string, semver.Version, error) {
6767
}
6868

6969
// checkFields runs checkEmptyFields and returns its errors.
70-
func checkFields(csv *v1alpha1.ClusterServiceVersion) (errs []errors.Error) {
70+
func checkFields(csv v1alpha1.ClusterServiceVersion) (errs []errors.Error) {
7171
result := errors.ManifestResult{}
7272
checkEmptyFields(&result, reflect.ValueOf(csv), "")
7373
return append(result.Errors, result.Warnings...)
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package internal
2+
3+
import (
4+
"fmt"
5+
"net/mail"
6+
"net/url"
7+
"strings"
8+
9+
"github.com/operator-framework/api/pkg/manifests"
10+
"github.com/operator-framework/api/pkg/operators/v1alpha1"
11+
"github.com/operator-framework/api/pkg/validation/errors"
12+
interfaces "github.com/operator-framework/api/pkg/validation/interfaces"
13+
)
14+
15+
var OperatorHubValidator interfaces.Validator = interfaces.ValidatorFunc(validateOperatorHub)
16+
17+
var validCapabilities = map[string]struct{}{
18+
"Basic Install": struct{}{},
19+
"Seamless Upgrades": struct{}{},
20+
"Full Lifecycle": struct{}{},
21+
"Deep Insights": struct{}{},
22+
"Auto Pilot": struct{}{},
23+
}
24+
25+
var validMediatypes = map[string]struct{}{
26+
"image/gif": struct{}{},
27+
"image/jpeg": struct{}{},
28+
"image/png": struct{}{},
29+
"image/svg+xml": struct{}{},
30+
}
31+
32+
var validCategories = map[string]struct{}{
33+
"AI/Machine Learning": struct{}{},
34+
"Application Runtime": struct{}{},
35+
"Big Data": struct{}{},
36+
"Cloud Provider": struct{}{},
37+
"Developer Tools": struct{}{},
38+
"Database": struct{}{},
39+
"Integration & Delivery": struct{}{},
40+
"Logging & Tracing": struct{}{},
41+
"Monitoring": struct{}{},
42+
"Networking": struct{}{},
43+
"OpenShift Optional": struct{}{},
44+
"Security": struct{}{},
45+
"Storage": struct{}{},
46+
"Streaming & Messaging": struct{}{},
47+
}
48+
49+
func validateOperatorHub(objs ...interface{}) (results []errors.ManifestResult) {
50+
for _, obj := range objs {
51+
switch v := obj.(type) {
52+
case *manifests.Bundle:
53+
results = append(results, validateBundleOperatorHub(v))
54+
}
55+
}
56+
return results
57+
}
58+
59+
func validateBundleOperatorHub(bundle *manifests.Bundle) errors.ManifestResult {
60+
result := errors.ManifestResult{Name: bundle.Name}
61+
62+
if bundle == nil {
63+
result.Add(errors.ErrInvalidBundle("Bundle is nil", nil))
64+
return result
65+
}
66+
67+
if bundle.CSV == nil {
68+
result.Add(errors.ErrInvalidBundle("Bundle csv is nil", bundle.Name))
69+
return result
70+
}
71+
72+
errs := validateHubCSVSpec(*bundle.CSV)
73+
for _, err := range errs {
74+
result.Add(errors.ErrInvalidCSV(err.Error(), bundle.CSV.GetName()))
75+
}
76+
77+
return result
78+
}
79+
80+
func validateHubCSVSpec(csv v1alpha1.ClusterServiceVersion) []error {
81+
var errs []error
82+
83+
if csv.Spec.Provider.Name == "" {
84+
errs = append(errs, fmt.Errorf("csv.Spec.Provider.Name not specified"))
85+
}
86+
87+
for _, maintainer := range csv.Spec.Maintainers {
88+
if maintainer.Name == "" || maintainer.Email == "" {
89+
errs = append(errs, fmt.Errorf("csv.Spec.Maintainers elements should contain both name and email"))
90+
}
91+
if maintainer.Email != "" {
92+
_, err := mail.ParseAddress(maintainer.Email)
93+
if err != nil {
94+
errs = append(errs, fmt.Errorf("csv.Spec.Maintainers email %s is invalid: %v", maintainer.Email, err))
95+
}
96+
}
97+
}
98+
99+
for _, link := range csv.Spec.Links {
100+
if link.Name == "" || link.URL == "" {
101+
errs = append(errs, fmt.Errorf("csv.Spec.Links elements should contain both name and url"))
102+
}
103+
if link.URL != "" {
104+
_, err := url.ParseRequestURI(link.URL)
105+
if err != nil {
106+
errs = append(errs, fmt.Errorf("csv.Spec.Links url %s is invalid: %v", link.URL, err))
107+
}
108+
}
109+
}
110+
111+
if csv.GetAnnotations() == nil {
112+
csv.SetAnnotations(make(map[string]string))
113+
}
114+
115+
if capability, ok := csv.ObjectMeta.Annotations["capabilities"]; ok {
116+
if _, ok := validCapabilities[capability]; !ok {
117+
errs = append(errs, fmt.Errorf("csv.Metadata.Annotations.Capabilities %s is not a valid capabilities level", capability))
118+
}
119+
}
120+
121+
if csv.Spec.Icon != nil {
122+
// only one icon is allowed
123+
if len(csv.Spec.Icon) != 1 {
124+
errs = append(errs, fmt.Errorf("csv.Spec.Icon should only have one element"))
125+
}
126+
127+
icon := csv.Spec.Icon[0]
128+
if icon.MediaType == "" || icon.Data == "" {
129+
errs = append(errs, fmt.Errorf("csv.Spec.Icon elements should contain both data and mediatype"))
130+
}
131+
132+
if icon.MediaType != "" {
133+
if _, ok := validMediatypes[icon.MediaType]; !ok {
134+
errs = append(errs, fmt.Errorf("csv.Spec.Icon %s does not have a valid mediatype", icon.MediaType))
135+
}
136+
}
137+
} else {
138+
errs = append(errs, fmt.Errorf("csv.Spec.Icon not specified"))
139+
}
140+
141+
if categories, ok := csv.ObjectMeta.Annotations["categories"]; ok {
142+
categorySlice := strings.Split(categories, ",")
143+
144+
for _, category := range categorySlice {
145+
if _, ok := validCategories[category]; !ok {
146+
errs = append(errs, fmt.Errorf("csv.Metadata.Annotations.Categories %s is not a valid category", category))
147+
}
148+
}
149+
}
150+
151+
return errs
152+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package internal
2+
3+
import (
4+
"testing"
5+
6+
"github.com/operator-framework/api/pkg/manifests"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestValidateBundleOperatorHub(t *testing.T) {
12+
var table = []struct {
13+
description string
14+
directory string
15+
hasError bool
16+
errStrings []string
17+
}{
18+
{
19+
description: "registryv1 bundle/valid bundle",
20+
directory: "./testdata/valid_bundle",
21+
hasError: false,
22+
},
23+
{
24+
description: "registryv1 bundle/invald bundle operatorhubio",
25+
directory: "./testdata/invalid_bundle_operatorhub",
26+
hasError: true,
27+
errStrings: []string{
28+
`Error: Value : (etcdoperator.v0.9.4) csv.Spec.Provider.Name not specified`,
29+
`Error: Value : (etcdoperator.v0.9.4) csv.Spec.Maintainers elements should contain both name and email`,
30+
`Error: Value : (etcdoperator.v0.9.4) csv.Spec.Maintainers email invalidemail is invalid: mail: missing '@' or angle-addr`,
31+
`Error: Value : (etcdoperator.v0.9.4) csv.Spec.Links elements should contain both name and url`,
32+
`Error: Value : (etcdoperator.v0.9.4) csv.Spec.Links url https//coreos.com/operators/etcd/docs/latest/ is invalid: parse https//coreos.com/operators/etcd/docs/latest/: invalid URI for request`,
33+
`Error: Value : (etcdoperator.v0.9.4) csv.Metadata.Annotations.Capabilities Installs and stuff is not a valid capabilities level`,
34+
`Error: Value : (etcdoperator.v0.9.4) csv.Spec.Icon should only have one element`,
35+
`Error: Value : (etcdoperator.v0.9.4) csv.Metadata.Annotations.Categories Magic is not a valid category`,
36+
},
37+
},
38+
}
39+
40+
for _, tt := range table {
41+
// Validate the bundle object
42+
bundle, err := manifests.GetBundleFromDir(tt.directory)
43+
require.NoError(t, err)
44+
45+
results := OperatorHubValidator.Validate(bundle)
46+
47+
if len(results) > 0 {
48+
require.Equal(t, results[0].HasError(), tt.hasError)
49+
if results[0].HasError() {
50+
require.Equal(t, len(tt.errStrings), len(results[0].Errors))
51+
52+
for _, err := range results[0].Errors {
53+
errString := err.Error()
54+
require.Contains(t, tt.errStrings, errString)
55+
}
56+
}
57+
}
58+
}
59+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
apiVersion: apiextensions.k8s.io/v1beta1
2+
kind: CustomResourceDefinition
3+
metadata:
4+
name: etcdbackups.etcd.database.coreos.com
5+
spec:
6+
group: etcd.database.coreos.com
7+
names:
8+
kind: EtcdBackup
9+
listKind: EtcdBackupList
10+
plural: etcdbackups
11+
singular: etcdbackup
12+
scope: Namespaced
13+
version: v1beta2

0 commit comments

Comments
 (0)