diff --git a/Makefile b/Makefile index 6c8b4b427f..687083e816 100644 --- a/Makefile +++ b/Makefile @@ -57,9 +57,10 @@ build/registry: build/olm: $(MAKE) $(PSM_CMD) $(OLM_CMDS) $(COLLECT_PROFILES_CMD) +# TODO: complete build command $(OPM): version_flags=-ldflags "-X '$(REGISTRY_PKG)/cmd/opm/version.gitCommit=$(GIT_COMMIT)' -X '$(REGISTRY_PKG)/cmd/opm/version.opmVersion=$(OPM_VERSION)' -X '$(REGISTRY_PKG)/cmd/opm/version.buildDate=$(BUILD_DATE)'" $(OPM): - go build $(version_flags) $(GO_BUILD_OPTS) $(GO_BUILD_TAGS) -o $@ $(REGISTRY_PKG)/cmd/$(notdir $@) + go build $(version_flags) $(GO_BUILD_OPTS) $(GO_BUILD_TAGS) -o $@ $(ROOT_PKG)/cmd/$(notdir $@) $(REGISTRY_CMDS): version_flags=-ldflags "-X '$(REGISTRY_PKG)/cmd/opm/version.gitCommit=$(GIT_COMMIT)' -X '$(REGISTRY_PKG)/cmd/opm/version.opmVersion=$(OPM_VERSION)' -X '$(REGISTRY_PKG)/cmd/opm/version.buildDate=$(BUILD_DATE)'" $(REGISTRY_CMDS): @@ -79,8 +80,8 @@ $(COLLECT_PROFILES_CMD): FORCE cross: version_flags=-X '$(REGISTRY_PKG)/cmd/opm/version.gitCommit=$(GIT_COMMIT)' -X '$(REGISTRY_PKG)/cmd/opm/version.opmVersion=$(OPM_VERSION)' -X '$(REGISTRY_PKG)/cmd/opm/version.buildDate=$(BUILD_DATE)' cross: ifeq ($(shell go env GOARCH),amd64) - GOOS=darwin CC=o64-clang CXX=o64-clang++ CGO_ENABLED=1 go build $(GO_BUILD_OPTS) $(GO_BUILD_TAGS) -o "bin/darwin-amd64-opm" --ldflags "-extld=o64-clang $(version_flags)" $(REGISTRY_PKG)/cmd/opm - GOOS=windows CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ CGO_ENABLED=1 go build $(GO_BUILD_OPTS) $(GO_BUILD_TAGS) -o "bin/windows-amd64-opm" --ldflags "-extld=x86_64-w64-mingw32-gcc $(version_flags)" -buildmode=exe $(REGISTRY_PKG)/cmd/opm + GOOS=darwin CC=o64-clang CXX=o64-clang++ CGO_ENABLED=1 go build $(GO_BUILD_OPTS) $(GO_BUILD_TAGS) -o "bin/darwin-amd64-opm" --ldflags "-extld=o64-clang $(version_flags)" $(ROOT_PKG)/cmd/opm + GOOS=windows CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ CGO_ENABLED=1 go build $(GO_BUILD_OPTS) $(GO_BUILD_TAGS) -o "bin/windows-amd64-opm" --ldflags "-extld=x86_64-w64-mingw32-gcc $(version_flags)" -buildmode=exe $(ROOT_PKG)/cmd/opm endif build/olm-container: @@ -105,6 +106,7 @@ unit/olm: bin/kubebuilder unit/registry: $(MAKE) unit WHAT=operator-registry + go test $(ROOT_DIR)/pkg/validate/... unit/api: $(MAKE) unit WHAT=api TARGET_NAME=test diff --git a/cmd/opm/main.go b/cmd/opm/main.go new file mode 100644 index 0000000000..ebeead1dcf --- /dev/null +++ b/cmd/opm/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + + "github.com/operator-framework/operator-registry/cmd/opm/root" + registrylib "github.com/operator-framework/operator-registry/pkg/registry" + + "github.com/openshift/operator-framework-olm/cmd/opm/validate" +) + +func main() { + override := map[string]*cobra.Command{"validate ": validate.NewCmd()} + cmd := root.NewCmd() + for _, c := range cmd.Commands() { + if newCmd, ok := override[c.Use]; ok { + cmd.RemoveCommand(c) + cmd.AddCommand(newCmd) + } + } + + if err := cmd.Execute(); err != nil { + agg, ok := err.(utilerrors.Aggregate) + if !ok { + os.Exit(1) + } + for _, e := range agg.Errors() { + if _, ok := e.(registrylib.BundleImageAlreadyAddedErr); ok { + os.Exit(2) + } + if _, ok := e.(registrylib.PackageVersionAlreadyAddedErr); ok { + os.Exit(3) + } + } + os.Exit(1) + } +} diff --git a/cmd/opm/validate/validate.go b/cmd/opm/validate/validate.go new file mode 100644 index 0000000000..8e1b002943 --- /dev/null +++ b/cmd/opm/validate/validate.go @@ -0,0 +1,39 @@ +package validate + +import ( + "fmt" + "os" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + dsvalidate "github.com/openshift/operator-framework-olm/pkg/validate" + "github.com/operator-framework/operator-registry/cmd/opm/validate" +) + +func NewCmd() *cobra.Command { + logger := logrus.New() + validateCmd := validate.NewCmd() + validateFn := validateCmd.RunE + validateCmd.RunE = func(c *cobra.Command, args []string) error { + if err := validateFn(c, args); err != nil { + logger.Fatal(err) + } + + directory := args[0] + s, err := os.Stat(directory) + if err != nil { + return err + } + if !s.IsDir() { + return fmt.Errorf("%q is not a directory", directory) + } + + if err := dsvalidate.Validate(os.DirFS(directory)); err != nil { + logger.Fatal(err) + } + return nil + } + + return validateCmd +} diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go new file mode 100644 index 0000000000..bba6ae7479 --- /dev/null +++ b/pkg/validate/validate.go @@ -0,0 +1,60 @@ +package validate + +import ( + "fmt" + "io/fs" + + "k8s.io/apimachinery/pkg/util/json" + + operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/model" + "github.com/operator-framework/operator-registry/pkg/api" +) + +func Validate(root fs.FS) error { + // Load config files and convert them to declcfg objects + cfg, err := declcfg.LoadFS(root) + if err != nil { + return err + } + // Validate the config using model validation: + // This will convert declcfg objects to intermediate model objects that are + // also used for serve and add commands. The conversion process will run + // validation for the model objects and ensure they are valid. + mdl, err := declcfg.ConvertToModel(*cfg) + if err != nil { + return err + } + + if err = validatePackageManifest(mdl); err != nil { + return err + } + return nil +} + +func validatePackageManifest(mdl model.Model) error { + for _, pkg := range mdl { + for _, channel := range pkg.Channels { + head, err := channel.Head() + if err != nil { + return err + } + + if len(head.CsvJSON) == 0 { + return fmt.Errorf("missing head CSV on package %s, channel %s head %s: ensure valid csv under 'olm.bundle.object' properties", pkg.Name, channel.Name, head.Name) + } + bundle, err := api.ConvertModelBundleToAPIBundle(*head) + if err != nil { + return err + } + + csv := operatorsv1alpha1.ClusterServiceVersion{} + err = json.Unmarshal([]byte(bundle.GetCsvJson()), &csv) + if err != nil { + return fmt.Errorf("invalid head CSV on package %s, channel %s head %s: failed to unmarshal any 'olm.bundle.object' property as CSV JSON: %v", pkg.Name, channel.Name, head.Name, err) + } + } + } + return nil +} diff --git a/pkg/validate/validate_test.go b/pkg/validate/validate_test.go new file mode 100644 index 0000000000..9b39bc2c66 --- /dev/null +++ b/pkg/validate/validate_test.go @@ -0,0 +1,255 @@ +package validate + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/require" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/model" + "github.com/operator-framework/operator-registry/alpha/property" +) + +func TestValidate(t *testing.T) { + requireMarshal := func(i interface{}) []byte { + out, err := json.Marshal(i) + require.NoError(t, err) + return out + } + + tests := []struct { + name string + config model.Model + wantErr error + }{ + { + name: "failWithoutHeadBundleCSV", + config: model.Model{ + "testpkg": { + Name: "testpkg", + DefaultChannel: &model.Channel{Name: "stable"}, + Channels: map[string]*model.Channel{ + "stable": { + Package: &model.Package{Name: "testpkg"}, + Name: "stable", + Bundles: map[string]*model.Bundle{ + "head": { + Package: &model.Package{Name: "testpkg"}, + Channel: &model.Channel{Name: "stable"}, + Name: "head", + Image: "head:image", + Properties: []property.Property{ + { + Type: property.TypePackage, + Value: requireMarshal(&property.Package{ + PackageName: "testpkg", + Version: "1.1.1", + }), + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: fmt.Errorf("missing head CSV on package testpkg, channel stable head head: ensure valid csv under 'olm.bundle.object' properties"), + }, + { + name: "passWithHeadBundleCSV", + config: model.Model{ + "testpkg": { + Name: "testpkg", + DefaultChannel: &model.Channel{Name: "stable"}, + Channels: map[string]*model.Channel{ + "stable": { + Package: &model.Package{Name: "testpkg"}, + Name: "stable", + Bundles: map[string]*model.Bundle{ + "head": { + Package: &model.Package{Name: "testpkg"}, + Channel: &model.Channel{Name: "stable"}, + Name: "head", + Image: "head:image", + Replaces: "non-head", + Properties: []property.Property{ + { + Type: property.TypePackage, + Value: requireMarshal(&property.Package{ + PackageName: "testpkg", + Version: "1.1.1", + }), + }, + { + Type: property.TypeBundleObject, + Value: json.RawMessage(fmt.Sprintf(`{"data":"%s"}`, base64.StdEncoding.EncodeToString([]byte(`{"kind":"ClusterServiceVersion"}`)))), + }, + }, + }, + "non-head": { + Package: &model.Package{Name: "testpkg"}, + Channel: &model.Channel{Name: "stable"}, + Name: "non-head", + Image: "non-head:image", + Properties: []property.Property{ + { + Type: property.TypePackage, + Value: requireMarshal(&property.Package{ + PackageName: "testpkg", + Version: "1.1.0", + }), + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := bytes.Buffer{} + require.NoError(t, declcfg.WriteJSON(declcfg.ConvertFromModel(tt.config), &b)) + testFs := fstest.MapFS{ + "catalog.json": { + Data: b.Bytes(), + Mode: 0755, + }, + } + + err := Validate(testFs) + + if tt.wantErr == nil { + require.NoError(t, err) + return + } + require.EqualError(t, err, tt.wantErr.Error()) + }) + + } + +} + +func TestValidatePackageManifest(t *testing.T) { + tests := []struct { + name string + model model.Model + wantErr error + }{ + { + name: "FailOnNoHeadCSV", + model: model.Model{ + "pkg": { + Name: "pkg", + Channels: map[string]*model.Channel{ + "alpha": { + Name: "alpha", + Bundles: map[string]*model.Bundle{ + "a": { + Name: "a", + }, + }, + }, + }, + }, + }, + wantErr: fmt.Errorf("missing head CSV on package pkg, channel alpha head a: ensure valid csv under 'olm.bundle.object' properties"), + }, + { + name: "FailOnInvalidHeadCSV", + model: model.Model{ + "pkg": { + Name: "pkg", + Channels: map[string]*model.Channel{ + "alpha": { + Name: "alpha", + Bundles: map[string]*model.Bundle{ + "a": { + Name: "a", + Package: &model.Package{}, + Channel: &model.Channel{}, + Properties: []property.Property{ + { + Type: "olm.package", + Value: json.RawMessage(`{"packageName":"","version":""}`), + }, + }, + CsvJSON: `invalid-csv`, + }, + }, + }, + }, + }, + }, + wantErr: fmt.Errorf("invalid head CSV on package pkg, channel alpha head a: failed to unmarshal any 'olm.bundle.object' property as CSV JSON: invalid character 'i' looking for beginning of value"), + }, + { + name: "PassWithHeadCSV", + model: model.Model{ + "pkg": { + Name: "pkg", + Channels: map[string]*model.Channel{ + "alpha": { + Name: "alpha", + Bundles: map[string]*model.Bundle{ + "a": { + Name: "a", + Replaces: "aa", + Package: &model.Package{}, + Channel: &model.Channel{}, + Properties: []property.Property{ + { + Type: "olm.package", + Value: json.RawMessage(`{"packageName":"","version":""}`), + }, + }, + CsvJSON: `{}`, + }, + "aa": { + Name: "aa", + }, + }, + }, + "beta": { + Name: "beta", + Bundles: map[string]*model.Bundle{ + "b": { + Name: "b", + Package: &model.Package{}, + Channel: &model.Channel{}, + Properties: []property.Property{ + { + Type: "olm.package", + Value: json.RawMessage(`{"packageName":"","version":""}`), + }, + }, + CsvJSON: `{}`, + }, + }, + }, + }, + }, + }, + wantErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePackageManifest(tt.model) + if tt.wantErr == nil { + require.NoError(t, err) + return + } + require.EqualError(t, err, tt.wantErr.Error()) + }) + } +}