diff --git a/.travis.yml b/.travis.yml index 461a8fb65f..5c0476bd72 100644 --- a/.travis.yml +++ b/.travis.yml @@ -103,7 +103,9 @@ jobs: # Build and test go - <<: *test name: Go on Kubernetes - script: make test-e2e-go + script: + - make test-e2e-go + - make test-integration # Build and test ansible and test ansible using molecule - <<: *test diff --git a/Makefile b/Makefile index b11865ef32..8b0fbb8b07 100644 --- a/Makefile +++ b/Makefile @@ -185,7 +185,7 @@ test-sanity test/sanity: tidy build/operator-sdk ./hack/tests/sanity-check.sh ./hack/tests/check-lint.sh ci -TEST_PKGS:=$(shell go list ./... | grep -v -P 'github.com/operator-framework/operator-sdk/(hack|test/e2e)') +TEST_PKGS:=$(shell go list ./... | grep -v -P 'github.com/operator-framework/operator-sdk/(hack/|test/)') test-unit test/unit: ## Run the unit tests $(Q)go test -coverprofile=coverage.out -covermode=count -count=1 -short ${TEST_PKGS} @@ -208,8 +208,8 @@ test-subcommand-scorecard: test-subcommand-olm-install: ./hack/tests/subcommand-olm-install.sh -# E2E tests. -.PHONY: test-e2e test-e2e-go test-e2e-ansible test-e2e-ansible-molecule test-e2e-helm +# E2E and integration tests. +.PHONY: test-e2e test-e2e-go test-e2e-ansible test-e2e-ansible-molecule test-e2e-helm test-integration test-e2e: test-e2e-go test-e2e-ansible test-e2e-ansible-molecule test-e2e-helm ## Run the e2e tests @@ -224,3 +224,6 @@ test-e2e-ansible-molecule: image-build-ansible test-e2e-helm: image-build-helm ./hack/tests/e2e-helm.sh + +test-integration: + ./hack/tests/integration.sh diff --git a/hack/tests/integration.sh b/hack/tests/integration.sh new file mode 100755 index 0000000000..abd2584b76 --- /dev/null +++ b/hack/tests/integration.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -eu + +source hack/lib/image_lib.sh + +export OSDK_INTEGRATION_IMAGE="quay.io/example/memcached-operator:v0.0.1" + +# Build the operator image. +pushd test/test-framework +operator-sdk build "$OSDK_INTEGRATION_IMAGE" +# If using a kind cluster, load the image into all nodes. +load_image_if_kind "$OSDK_INTEGRATION_IMAGE" +popd + +# Install OLM on the cluster if not installed. +olm_latest_exists=0 +if ! operator-sdk alpha olm status > /dev/null 2>&1; then + operator-sdk alpha olm install + olm_latest_exists=1 +fi + +# Integration tests will use default loading rules for the kubeconfig if +# KUBECONFIG is not set. +go test -v ./test/integration + +# Uninstall OLM if it was installed for test purposes. +if eval "(( $olm_latest_exists ))"; then + operator-sdk alpha olm uninstall +fi + +echo -e "\n=== Integration tests succeeded ===\n" diff --git a/internal/olm/operator/manager.go b/internal/olm/operator/manager.go new file mode 100644 index 0000000000..8374a700da --- /dev/null +++ b/internal/olm/operator/manager.go @@ -0,0 +1,387 @@ +// Copyright 2019 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package olm + +import ( + "context" + "errors" + "fmt" + "io/ioutil" + + olmresourceclient "github.com/operator-framework/operator-sdk/internal/olm/client" + opinternal "github.com/operator-framework/operator-sdk/internal/olm/operator/internal" + "github.com/operator-framework/operator-sdk/internal/util/k8sutil" + "github.com/operator-framework/operator-sdk/internal/util/yamlutil" + + manifests "github.com/operator-framework/api/pkg/manifests" + valerrors "github.com/operator-framework/api/pkg/validation/errors" + olmapiv1 "github.com/operator-framework/operator-lifecycle-manager/pkg/api/apis/operators/v1" + olmapiv1alpha1 "github.com/operator-framework/operator-lifecycle-manager/pkg/api/apis/operators/v1alpha1" + registry "github.com/operator-framework/operator-registry/pkg/registry" + log "github.com/sirupsen/logrus" + apiextinstall "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/install" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" +) + +const defaultNamespace = "default" + +func init() { + // OLM schemes must be added to the global Scheme so controller-runtime's + // client recognizes OLM objects. + apiextinstall.Install(scheme.Scheme) + if err := olmapiv1.AddToScheme(scheme.Scheme); err != nil { + log.Fatalf("Failed to add OLM operator API v1 types to scheme: %v", err) + } +} + +type operatorManager struct { + client *olmresourceclient.Client + version string + namespace string + forceRegistry bool + + installMode olmapiv1alpha1.InstallModeType + installModeNamespaces []string + olmObjects []runtime.Object + pkg registry.PackageManifest + bundles []*registry.Bundle +} + +func (c *OLMCmd) newManager() (*operatorManager, error) { + m := &operatorManager{ + version: c.OperatorVersion, + forceRegistry: c.ForceRegistry, + } + rc, ns, err := k8sutil.GetKubeconfigAndNamespace(c.KubeconfigPath) + if err != nil { + return nil, fmt.Errorf("failed to get namespace from kubeconfig %s: %w", c.KubeconfigPath, err) + } + if ns == "" { + ns = defaultNamespace + } + if m.namespace = c.OperatorNamespace; m.namespace == "" { + m.namespace = ns + } + if m.client == nil { + m.client, err = olmresourceclient.ClientForConfig(rc) + if err != nil { + return nil, fmt.Errorf("failed to create SDK OLM client: %w", err) + } + } + for _, path := range c.IncludePaths { + if path != "" { + objs, err := readObjectsFromFile(path) + if err != nil { + return nil, err + } + for _, obj := range objs { + m.olmObjects = append(m.olmObjects, obj) + } + } + } + // Since a Subscription refers to a CatalogSource, supplying one but + // not the other is an error. + hasSub, hasCatSrc := m.hasSubscription(), m.hasCatalogSource() + if hasSub || hasCatSrc && !(hasSub && hasCatSrc) { + return nil, errors.New("both a CatalogSource and Subscription must be supplied if one is supplied") + } + pkg, bundles, results := manifests.GetManifestsDir(c.ManifestsDir) + if len(results) != 0 { + badResults := []valerrors.ManifestResult{} + for _, result := range results { + if result.HasError() || result.HasWarn() { + badResults = append(badResults, result) + } + } + if len(badResults) != 0 { + return nil, fmt.Errorf("bundle dir had errors: %s", badResults) + } + } + m.pkg, m.bundles = pkg, bundles + if c.InstallMode == "" { + // Default to OwnNamespace. + m.installMode = olmapiv1alpha1.InstallModeTypeOwnNamespace + m.installModeNamespaces = []string{m.namespace} + } else { + m.installMode, m.installModeNamespaces, err = parseInstallModeKV(c.InstallMode) + if err != nil { + return nil, err + } + } + if err := m.installModeCompatible(m.installMode); err != nil { + return nil, err + } + return m, nil +} + +func (m *operatorManager) up(ctx context.Context) (err error) { + // Ensure OLM is installed. + olmVer, err := m.client.GetInstalledVersion(ctx) + if err != nil { + return fmt.Errorf("error getting installed OLM version: %w", err) + } + pkgName := m.pkg.PackageName + bundle, err := getBundleForVersion(m.bundles, m.version) + if err != nil { + return fmt.Errorf("error getting bundle for version %s: %w", m.version, err) + } + csv, err := bundle.ClusterServiceVersion() + if err != nil { + return fmt.Errorf("error getting CSV from bundle: %w", err) + } + // Only check CSV here, since other deployed operators/versions may be + // running with shared CRDs. + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(csv) + if err != nil { + return fmt.Errorf("error converting CSV to unstructured: %w", err) + } + u := unstructured.Unstructured{Object: obj} + status := m.status(ctx, &u) + if installed, err := status.HasInstalledResources(); installed { + return fmt.Errorf("an operator with name %q is already running\n%s", pkgName, status) + } else if err != nil { + return fmt.Errorf("an operator with name %q is present and has resource errors\n%s", pkgName, status) + } + + if err = m.registryUp(ctx, olmresourceclient.OLMNamespace); err != nil { + return fmt.Errorf("error creating registry resources: %w", err) + } + log.Info("Creating resources") + if !m.hasCatalogSource() { + registryGRPCAddr := opinternal.GetRegistryServiceAddr(pkgName, olmresourceclient.OLMNamespace) + catsrc := newCatalogSource(pkgName, m.namespace, withGRPC(registryGRPCAddr)) + m.olmObjects = append(m.olmObjects, catsrc) + } + if !m.hasSubscription() { + channel, err := getChannelForCSVName(m.pkg, csv.GetName()) + if err != nil { + return err + } + sub := newSubscription(csv.GetName(), m.namespace, + withPackageChannel(pkgName, channel), + withCatalogSource(getCatalogSourceName(pkgName), m.namespace)) + m.olmObjects = append(m.olmObjects, sub) + } + if !m.hasOperatorGroup() { + og := newSDKOperatorGroup(m.namespace, + withTargetNamespaces(m.installModeNamespaces...)) + m.olmObjects = append(m.olmObjects, og) + } + // Check for Namespace objects and create those first. + namespaces, objects := []runtime.Object{}, []runtime.Object{} + for _, obj := range m.olmObjects { + if obj.GetObjectKind().GroupVersionKind().Kind == "Namespace" { + namespaces = append(namespaces, obj) + } else { + objects = append(objects, obj) + } + } + if err = m.client.DoCreate(ctx, namespaces...); err != nil { + return fmt.Errorf("error creating operator resources: %w", err) + } + if err = m.client.DoCreate(ctx, objects...); err != nil { + return fmt.Errorf("error creating operator resources: %w", err) + } + // BUG(estroz): if m.namespace is not contained in m.installModeNamespaces, + // DoCSVWait will fail. + nn := types.NamespacedName{ + Name: csv.GetName(), + Namespace: m.namespace, + } + log.Printf("Waiting for ClusterServiceVersion %q to reach 'Succeeded' phase", nn) + if err = m.client.DoCSVWait(ctx, nn); err != nil { + return fmt.Errorf("error waiting for CSV to install: %w", err) + } + + status = m.status(ctx, bundle.Objects...) + if installed, err := status.HasInstalledResources(); !installed { + return fmt.Errorf("operator %s did not install successfully\n%s", pkgName, status) + } else if err != nil { + return fmt.Errorf("operator %qhas resource errors\n%s", pkgName, status) + } + log.Infof("Successfully installed %q on OLM version %q", csv.GetName(), olmVer) + fmt.Print(status) + + return nil +} + +func (m *operatorManager) down(ctx context.Context) (err error) { + // Ensure OLM is installed. + olmVer, err := m.client.GetInstalledVersion(ctx) + if err != nil { + return fmt.Errorf("error getting installed OLM version: %w", err) + } + pkgName := m.pkg.PackageName + bundle, err := getBundleForVersion(m.bundles, m.version) + if err != nil { + return fmt.Errorf("error getting bundle for version %s: %w", m.version, err) + } + csv, err := bundle.ClusterServiceVersion() + if err != nil { + return fmt.Errorf("error getting CSV from bundle: %w", err) + } + + if err = m.registryDown(ctx, olmresourceclient.OLMNamespace); err != nil { + return fmt.Errorf("error removing registry resources: %w", err) + } + log.Info("Deleting resources") + if !m.hasCatalogSource() { + m.olmObjects = append(m.olmObjects, newCatalogSource(pkgName, m.namespace)) + } + if !m.hasSubscription() { + m.olmObjects = append(m.olmObjects, newSubscription(csv.GetName(), m.namespace)) + } + if !m.hasOperatorGroup() { + m.olmObjects = append(m.olmObjects, newSDKOperatorGroup(m.namespace)) + } + toDelete := []runtime.Object{} + for _, obj := range m.olmObjects { + toDelete = append(toDelete, obj.DeepCopyObject()) + } + for _, obj := range bundle.Objects { + objc := obj.DeepCopy() + objc.SetNamespace(m.namespace) + toDelete = append(toDelete, objc) + } + if err = m.client.DoDelete(ctx, toDelete...); err != nil { + return fmt.Errorf("error deleting operator resources: %w", err) + } + + status := m.status(ctx, bundle.Objects...) + if installed, err := status.HasInstalledResources(); installed { + return fmt.Errorf("operator %q still exists", pkgName) + } else if err != nil { + return fmt.Errorf("operator %q still exists and has resource errors\n%s", pkgName, status) + } + log.Infof("Successfully uninstalled %q on OLM version %q", csv.GetName(), olmVer) + + return nil +} + +func (m operatorManager) registryUp(ctx context.Context, namespace string) error { + rr := opinternal.RegistryResources{ + Client: m.client, + Pkg: m.pkg, + Bundles: m.bundles, + } + registryStale, err := rr.IsManifestDataStale(ctx, namespace) + if err != nil { + if !apierrors.IsNotFound(err) { + return fmt.Errorf("error checking registry data: %w", err) + } + // ConfigMap doesn't exist yet, so create it as usual. + log.Info("Creating registry") + if err = rr.CreateRegistryManifests(ctx, namespace); err != nil { + return fmt.Errorf("error registering bundle: %w", err) + } + return nil + } + if !registryStale { + log.Printf("Registry data is current") + return nil + } + log.Printf("Registry data stale. Recreating registry") + if err = rr.DeleteRegistryManifests(ctx, namespace); err != nil { + return fmt.Errorf("error deleting registered bundle: %w", err) + } + if err = rr.CreateRegistryManifests(ctx, namespace); err != nil { + return fmt.Errorf("error registering bundle: %w", err) + } + return nil +} + +func (m *operatorManager) registryDown(ctx context.Context, namespace string) error { + rr := opinternal.RegistryResources{ + Client: m.client, + Pkg: m.pkg, + Bundles: m.bundles, + } + if m.forceRegistry { + log.Printf("Deleting registry") + if err := rr.DeleteRegistryManifests(ctx, namespace); err != nil { + return fmt.Errorf("error deleting registered bundle: %w", err) + } + } + return nil +} + +// TODO(estroz): check registry health on each "status" subcommand invokation +func (m *operatorManager) status(ctx context.Context, us ...*unstructured.Unstructured) olmresourceclient.Status { + objs := []runtime.Object{} + for _, u := range us { + uc := u.DeepCopy() + uc.SetNamespace(m.namespace) + objs = append(objs, uc) + } + return m.client.GetObjectsStatus(ctx, objs...) +} + +func (m operatorManager) hasCatalogSource() bool { + return m.hasKind(olmapiv1alpha1.CatalogSourceKind) +} + +func (m operatorManager) hasSubscription() bool { + return m.hasKind(olmapiv1alpha1.SubscriptionKind) +} + +func (m operatorManager) hasOperatorGroup() bool { + return m.hasKind(olmapiv1.OperatorGroupKind) +} + +func (m operatorManager) hasKind(kind string) bool { + for _, obj := range m.olmObjects { + if obj.GetObjectKind().GroupVersionKind().Kind == kind { + return true + } + } + return false +} + +func readObjectsFromFile(path string) (objs []*unstructured.Unstructured, err error) { + b, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + scanner := yamlutil.NewYAMLScanner(b) + for scanner.Scan() { + u := &unstructured.Unstructured{} + if err := u.UnmarshalJSON(scanner.Bytes()); err != nil { + return nil, fmt.Errorf("failed to decode object from manifest %s: %w", path, err) + } + objs = append(objs, u) + } + if scanner.Err() != nil { + return nil, fmt.Errorf("failed to scan manifest %s: %w", path, scanner.Err()) + } + if len(objs) == 0 { + return nil, fmt.Errorf("no objects found in manifest %s", path) + } + return objs, nil +} + +func getBundleForVersion(bundles []*registry.Bundle, version string) (*registry.Bundle, error) { + names := []string{} + for _, bundle := range bundles { + if bundle.Name == version { + return bundle, nil + } + names = append(names, bundle.Name) + } + return nil, fmt.Errorf("no bundle found for version %s; valid names: %+q", version, names) +} diff --git a/internal/olm/operator/olm.go b/internal/olm/operator/olm.go new file mode 100644 index 0000000000..9620c465df --- /dev/null +++ b/internal/olm/operator/olm.go @@ -0,0 +1,162 @@ +// Copyright 2019 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package olm + +import ( + "fmt" + + "github.com/operator-framework/operator-sdk/internal/util/k8sutil" + + olmapiv1 "github.com/operator-framework/operator-lifecycle-manager/pkg/api/apis/operators/v1" + olmapiv1alpha1 "github.com/operator-framework/operator-lifecycle-manager/pkg/api/apis/operators/v1alpha1" + "github.com/operator-framework/operator-registry/pkg/registry" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// General OperatorGroup for operators created with the SDK. +const sdkOperatorGroupName = "operator-sdk-og" + +func getSubscriptionName(csvName string) string { + name := k8sutil.FormatOperatorNameDNS1123(csvName) + return fmt.Sprintf("%s-sub", name) +} + +// getChannelForCSVName returns the channel for a given csvName. csvName +// has the format "{operator-name}.(v)?{X.Y.Z}". An error is returned if +// no channel with current CSV name csvName is found. +func getChannelForCSVName(pkg registry.PackageManifest, csvName string) (registry.PackageChannel, error) { + for _, c := range pkg.Channels { + if c.CurrentCSVName == csvName { + return c, nil + } + } + return registry.PackageChannel{}, fmt.Errorf("no channel in package manifest %s exists for CSV %s", pkg.PackageName, csvName) +} + +// withCatalogSource returns a function that sets the Subscription argument's +// target CatalogSource's name and namespace. +func withCatalogSource(csName, csNamespace string) func(*olmapiv1alpha1.Subscription) { + return func(sub *olmapiv1alpha1.Subscription) { + sub.Spec.CatalogSource = csName + sub.Spec.CatalogSourceNamespace = csNamespace + } +} + +// withPackageChannel returns a function that sets the Subscription argument's +// target package, channel, and starting CSV to those in channel. +func withPackageChannel(pkgName string, channel registry.PackageChannel) func(*olmapiv1alpha1.Subscription) { + return func(sub *olmapiv1alpha1.Subscription) { + if sub.Spec == nil { + sub.Spec = &olmapiv1alpha1.SubscriptionSpec{} + } + sub.Spec.Package = pkgName + sub.Spec.Channel = channel.Name + sub.Spec.StartingCSV = channel.CurrentCSVName + } +} + +// newSubscription creates a new Subscription for a CSV with a name derived +// from csvName, the CSV's objectmeta.name, in namespace. opts will be applied +// to the Subscription object. +func newSubscription(csvName, namespace string, opts ...func(*olmapiv1alpha1.Subscription)) *olmapiv1alpha1.Subscription { + sub := &olmapiv1alpha1.Subscription{ + TypeMeta: metav1.TypeMeta{ + APIVersion: olmapiv1alpha1.SchemeGroupVersion.String(), + Kind: olmapiv1alpha1.SubscriptionKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: getSubscriptionName(csvName), + Namespace: namespace, + }, + } + for _, opt := range opts { + opt(sub) + } + return sub +} + +func getCatalogSourceName(pkgName string) string { + name := k8sutil.FormatOperatorNameDNS1123(pkgName) + return fmt.Sprintf("%s-ocs", name) +} + +// withGRPC returns a function that sets the CatalogSource argument's +// server type to GRPC and address at addr. +func withGRPC(addr string) func(*olmapiv1alpha1.CatalogSource) { + return func(catsrc *olmapiv1alpha1.CatalogSource) { + catsrc.Spec.SourceType = olmapiv1alpha1.SourceTypeGrpc + catsrc.Spec.Address = addr + } +} + +// newCatalogSource creates a new CatalogSource with a name derived from +// pkgName, the package manifest's packageName, in namespace. opts will +// be applied to the CatalogSource object. +func newCatalogSource(pkgName, namespace string, opts ...func(*olmapiv1alpha1.CatalogSource)) *olmapiv1alpha1.CatalogSource { + cs := &olmapiv1alpha1.CatalogSource{ + TypeMeta: metav1.TypeMeta{ + APIVersion: olmapiv1alpha1.SchemeGroupVersion.String(), + Kind: olmapiv1alpha1.CatalogSourceKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: getCatalogSourceName(pkgName), + Namespace: namespace, + }, + Spec: olmapiv1alpha1.CatalogSourceSpec{ + DisplayName: pkgName, + Publisher: "operator-sdk", + }, + } + for _, opt := range opts { + opt(cs) + } + return cs +} + +// withGRPC returns a function that sets the OperatorGroup argument's +// targetNamespaces to namespaces. namespaces can be length 0..N; if +// namespaces length is 0, targetNamespaces is set to an empty string, +// indicating a global scope. +func withTargetNamespaces(namespaces ...string) func(*olmapiv1.OperatorGroup) { + return func(og *olmapiv1.OperatorGroup) { + if len(namespaces) == 0 { + // Supports all namespaces. + og.Spec.TargetNamespaces = []string{""} + } else { + og.Spec.TargetNamespaces = namespaces + } + } +} + +// newSDKOperatorGroup creates a new OperatorGroup with name +// sdkOperatorGroupName in namespace. opts will be applied to the +// OperatorGroup object. Note that the default OperatorGroup has a global +// scope. +func newSDKOperatorGroup(namespace string, opts ...func(*olmapiv1.OperatorGroup)) *olmapiv1.OperatorGroup { + og := &olmapiv1.OperatorGroup{ + TypeMeta: metav1.TypeMeta{ + APIVersion: olmapiv1.SchemeGroupVersion.String(), + Kind: olmapiv1.OperatorGroupKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: sdkOperatorGroupName, + Namespace: namespace, + }, + } + for _, opt := range opts { + opt(og) + } + return og +} diff --git a/internal/olm/operator/operator.go b/internal/olm/operator/operator.go new file mode 100644 index 0000000000..550f5419a8 --- /dev/null +++ b/internal/olm/operator/operator.go @@ -0,0 +1,146 @@ +// Copyright 2019 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package olm + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/spf13/pflag" +) + +// TODO(estroz): figure out a good way to deal with creating scorecard objects +// and injecting proxy container + +const ( + defaultTimeout = time.Minute * 2 +) + +// OLMCmd configures deployment and teardown of an operator via an OLM +// installation existing on a cluster. +type OLMCmd struct { // nolint:golint + // ManifestsDir is a directory containing a package manifest and N bundles + // of the operator's CSV and CRD's. OperatorVersion can be set to the + // version of the desired operator version's subdir and Up()/Down() will + // deploy the operator version in that subdir. + ManifestsDir string + // OperatorVersion is the version of the operator to deploy. It must be + // a semantic version, ex. 0.0.1. + OperatorVersion string + // IncludePaths are path to manifests of Kubernetes resources that either + // supplement or override defaults generated by methods of OLMCmd. These + // manifests can be but are not limited to: RBAC, Subscriptions, + // CatalogSources, OperatorGroups. + // + // Kinds that are overridden if supplied: + // - CatalogSource + // - Subscription + // - OperatorGroup + IncludePaths []string + // InstallMode specifies which supported installMode should be used to + // create an OperatorGroup. The format for this field is as follows: + // + // "InstallModeType=[ns1,ns2[, ...]]" + // + // The InstallModeType string passed must be marked as "supported" in the + // CSV being installed. The namespaces passed must exist or be created by + // passing a Namespace manifest to IncludePaths. An empty set of namespaces + // can be used for AllNamespaces. + // The default mode is OwnNamespace, which uses OperatorNamespace or the + // kubeconfig default. + InstallMode string + + // KubeconfigPath is the local path to a kubeconfig. This uses well-defined + // default loading rules to load the config if empty. + KubeconfigPath string + // OperatorNamespace is the cluster namespace in which operator resources + // are created. + // OperatorNamespace must already exist in the cluster or be defined in + // a manifest passed to IncludePaths. + OperatorNamespace string + // Timeout dictates how long to wait for a REST call to complete. A call + // exceeding Timeout will generate an error. + Timeout time.Duration + // ForceRegistry forces deletion of registry resources. + ForceRegistry bool + + once sync.Once +} + +var installModeFormat = "InstallModeType=[ns1,ns2[, ...]]" + +func (c *OLMCmd) AddToFlagSet(fs *pflag.FlagSet) { + fs.StringVar(&c.OperatorVersion, "operator-version", "", "Version of operator to deploy") + fs.StringVar(&c.InstallMode, "install-mode", "", "InstallMode to create OperatorGroup with. Format: "+installModeFormat) + fs.StringVar(&c.KubeconfigPath, "kubeconfig", "", "Path to kubeconfig") + fs.StringVar(&c.OperatorNamespace, "namespace", "", "Namespace in which to create resources") + fs.StringSliceVar(&c.IncludePaths, "include", nil, "Path to Kubernetes resource manifests, ex. Role, Subscription. These supplement or override defaults generated by up/down") + fs.DurationVar(&c.Timeout, "timeout", defaultTimeout, "Time to wait for the command to complete before failing") + fs.BoolVar(&c.ForceRegistry, "force-registry", false, "Force deletion of the in-cluster registry. This option is a no-op on 'up'.") +} + +func (c *OLMCmd) validate() error { + if c.ManifestsDir == "" { + return errors.New("manifests dir must be set") + } + if c.OperatorVersion == "" { + return errors.New("operator version must be set") + } + if c.InstallMode != "" { + if _, _, err := parseInstallModeKV(c.InstallMode); err != nil { + return err + } + } + return nil +} + +func (c *OLMCmd) initialize() { + c.once.Do(func() { + if c.Timeout <= 0 { + c.Timeout = defaultTimeout + } + }) +} + +func (c *OLMCmd) Up() error { + c.initialize() + if err := c.validate(); err != nil { + return fmt.Errorf("validation error: %w", err) + } + m, err := c.newManager() + if err != nil { + return fmt.Errorf("error initializing operator manager: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) + defer cancel() + return m.up(ctx) +} + +func (c *OLMCmd) Down() (err error) { + c.initialize() + if err := c.validate(); err != nil { + return fmt.Errorf("validation error: %w", err) + } + m, err := c.newManager() + if err != nil { + return fmt.Errorf("error initializing operator manager: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) + defer cancel() + return m.down(ctx) +} diff --git a/internal/olm/operator/tenancy.go b/internal/olm/operator/tenancy.go new file mode 100644 index 0000000000..80e7d0c6ca --- /dev/null +++ b/internal/olm/operator/tenancy.go @@ -0,0 +1,119 @@ +// Copyright 2019 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package olm + +import ( + "encoding/json" + "fmt" + "strings" + + olmapiv1alpha1 "github.com/operator-framework/operator-lifecycle-manager/pkg/api/apis/operators/v1alpha1" + "github.com/operator-framework/operator-registry/pkg/registry" +) + +// Mapping of installMode string values to types, for validation. +var installModeStrings = map[string]olmapiv1alpha1.InstallModeType{ + string(olmapiv1alpha1.InstallModeTypeOwnNamespace): olmapiv1alpha1.InstallModeTypeOwnNamespace, + string(olmapiv1alpha1.InstallModeTypeSingleNamespace): olmapiv1alpha1.InstallModeTypeSingleNamespace, + string(olmapiv1alpha1.InstallModeTypeMultiNamespace): olmapiv1alpha1.InstallModeTypeMultiNamespace, + string(olmapiv1alpha1.InstallModeTypeAllNamespaces): olmapiv1alpha1.InstallModeTypeAllNamespaces, +} + +// installModeCompatible ensures installMode is compatible with the namespaces +// and CSV's installModes being used. +func (m operatorManager) installModeCompatible(installMode olmapiv1alpha1.InstallModeType) error { + err := validateInstallModeForNamespaces(installMode, m.installModeNamespaces) + if err != nil { + return err + } + if installMode == olmapiv1alpha1.InstallModeTypeOwnNamespace { + if ns := m.installModeNamespaces[0]; ns != m.namespace { + return fmt.Errorf("installMode %s namespace %q must match namespace %q", installMode, ns, m.namespace) + } + } + // Ensure CSV supports installMode. + bundle, err := getBundleForVersion(m.bundles, m.version) + if err != nil { + return err + } + bcsv, err := bundle.ClusterServiceVersion() + if err != nil { + return err + } + csv, err := bundleCSVToCSV(bcsv) + if err != nil { + return err + } + for _, mode := range csv.Spec.InstallModes { + if mode.Type == installMode && !mode.Supported { + return fmt.Errorf("installMode %s not supported in CSV %q", installMode, csv.GetName()) + } + } + return nil +} + +// parseInstallModeKV parses an installMode string of the format +// installModeFormat. +func parseInstallModeKV(raw string) (olmapiv1alpha1.InstallModeType, []string, error) { + modeSplit := strings.Split(raw, "=") + if len(modeSplit) != 2 { + return "", nil, fmt.Errorf("installMode string %q is malformatted, must be: %s", raw, installModeFormat) + } + modeStr, namespaceList := modeSplit[0], modeSplit[1] + mode, ok := installModeStrings[modeStr] + if !ok { + return "", nil, fmt.Errorf("installMode type string %q is not a valid installMode type", modeStr) + } + namespaces := []string{} + for _, namespace := range strings.Split(strings.Trim(namespaceList, ","), ",") { + namespaces = append(namespaces, namespace) + } + return mode, namespaces, nil +} + +// validateInstallModeForNamespaces ensures namespaces are valid given mode. +func validateInstallModeForNamespaces(mode olmapiv1alpha1.InstallModeType, namespaces []string) error { + switch mode { + case olmapiv1alpha1.InstallModeTypeOwnNamespace, olmapiv1alpha1.InstallModeTypeSingleNamespace: + if len(namespaces) != 1 || namespaces[0] == "" { + return fmt.Errorf("installMode %s must be passed with exactly one non-empty namespace, have: %+q", mode, namespaces) + } + case olmapiv1alpha1.InstallModeTypeMultiNamespace: + if len(namespaces) < 2 { + return fmt.Errorf("installMode %s must be passed with more than one non-empty namespaces, have: %+q", mode, namespaces) + } + case olmapiv1alpha1.InstallModeTypeAllNamespaces: + if len(namespaces) != 1 || namespaces[0] != "" { + return fmt.Errorf("installMode %s must be passed with exactly one empty namespace, have: %+q", mode, namespaces) + } + default: + return fmt.Errorf("installMode %q is not a valid installMode type", mode) + } + return nil +} + +// bundleCSVToCSV converts a registry.ClusterServiceVersion bcsv to a +// v1alpha1.ClusterServiceVersion. The returned type will not have a status. +func bundleCSVToCSV(bcsv *registry.ClusterServiceVersion) (*olmapiv1alpha1.ClusterServiceVersion, error) { + spec := olmapiv1alpha1.ClusterServiceVersionSpec{} + if err := json.Unmarshal(bcsv.Spec, &spec); err != nil { + return nil, fmt.Errorf("error converting bundle CSV %q type: %w", bcsv.GetName(), err) + } + return &olmapiv1alpha1.ClusterServiceVersion{ + TypeMeta: bcsv.TypeMeta, + ObjectMeta: bcsv.ObjectMeta, + Spec: spec, + }, nil +} diff --git a/test/integration/operator_olm_test.go b/test/integration/operator_olm_test.go new file mode 100644 index 0000000000..5a087ab26f --- /dev/null +++ b/test/integration/operator_olm_test.go @@ -0,0 +1,121 @@ +// Copyright 2018 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package e2e + +import ( + "io/ioutil" + "os" + "testing" + "time" + + operator "github.com/operator-framework/operator-sdk/internal/olm/operator" + "github.com/operator-framework/operator-sdk/pkg/k8sutil" + + opv1alpha1 "github.com/operator-framework/operator-lifecycle-manager/pkg/api/apis/operators/v1alpha1" + "github.com/stretchr/testify/assert" + apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" +) + +const ( + defaultTimeout = 2 * time.Minute +) + +var ( + kubeconfigPath = os.Getenv(k8sutil.KubeConfigEnvVar) +) + +func TestOLMIntegration(t *testing.T) { + if image, ok := os.LookupEnv(imageEnvVar); ok && image != "" { + defaultTestImageTag = image + } + t.Run("Operator", func(t *testing.T) { + t.Run("Single", SingleOperator) + }) +} + +func SingleOperator(t *testing.T) { + csvConfig := CSVTemplateConfig{ + OperatorName: "memcached-operator", + OperatorVersion: "0.0.2", + TestImageTag: defaultTestImageTag, + Maturity: "alpha", + ReplacesCSVName: "", + CRDKeys: []DefinitionKey{ + { + Kind: "Memcached", + Name: "memcacheds.cache.example.com", + Group: "cache.example.com", + Versions: []apiextv1beta1.CustomResourceDefinitionVersion{ + {Name: "v1alpha1", Storage: true, Served: true}, + }, + }, + }, + InstallModes: []opv1alpha1.InstallMode{ + {Type: opv1alpha1.InstallModeTypeOwnNamespace, Supported: true}, + {Type: opv1alpha1.InstallModeTypeSingleNamespace, Supported: true}, + {Type: opv1alpha1.InstallModeTypeMultiNamespace, Supported: false}, + {Type: opv1alpha1.InstallModeTypeAllNamespaces, Supported: true}, + }, + } + tmp, err := ioutil.TempDir("", "sdk-integration.") + if err != nil { + t.Fatal(err) + } + defer func() { + if err := os.RemoveAll(tmp); err != nil { + t.Fatal(err) + } + }() + defaultChannel := "alpha" + operatorName := "memcached-operator" + operatorVersion := "0.0.2" + manifestsDir, err := writeOperatorManifests(tmp, operatorName, defaultChannel, csvConfig) + if err != nil { + os.RemoveAll(tmp) + t.Fatal(err) + } + opcmd := operator.OLMCmd{ + ManifestsDir: manifestsDir, + OperatorVersion: operatorVersion, + KubeconfigPath: kubeconfigPath, + Timeout: defaultTimeout, + } + // Cleanup. + defer func() { + opcmd.ForceRegistry = true + if err := opcmd.Down(); err != nil { + t.Fatal(err) + } + }() + + // "Remove operator before deploy" + assert.NoError(t, opcmd.Down()) + // "Remove operator before deploy (force delete registry)" + opcmd.ForceRegistry = true + assert.NoError(t, opcmd.Down()) + + // "Deploy operator" + assert.NoError(t, opcmd.Up()) + // "Fail to deploy operator after deploy" + assert.Error(t, opcmd.Up()) + + // "Remove operator after deploy" + assert.NoError(t, opcmd.Down()) + // "Remove operator after removal" + assert.NoError(t, opcmd.Down()) + // "Remove operator after removal (force delete registry)" + opcmd.ForceRegistry = true + assert.NoError(t, opcmd.Down()) +} diff --git a/test/integration/test_suite.go b/test/integration/test_suite.go new file mode 100644 index 0000000000..4c281fc009 --- /dev/null +++ b/test/integration/test_suite.go @@ -0,0 +1,283 @@ +// Copyright 2018 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package e2e + +import ( + "fmt" + "html/template" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/ghodss/yaml" + operatorsv1alpha1 "github.com/operator-framework/operator-lifecycle-manager/pkg/api/apis/operators/v1alpha1" + "github.com/operator-framework/operator-registry/pkg/registry" + apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + imageEnvVar = "OSDK_INTEGRATION_IMAGE" +) + +var ( + // Set with OSDK_INTEGRATION_IMAGE in CI. + defaultTestImageTag = "memcached-operator" +) + +type DefinitionKey struct { + Kind string + Name string + Group string + Versions []apiextv1beta1.CustomResourceDefinitionVersion +} + +type CSVTemplateConfig struct { + OperatorName string + OperatorVersion string + TestImageTag string + Maturity string + ReplacesCSVName string + CRDKeys []DefinitionKey + InstallModes []operatorsv1alpha1.InstallMode +} + +const csvTmpl = `apiVersion: operators.coreos.com/v1alpha1 +kind: ClusterServiceVersion +metadata: + annotations: + capabilities: Basic Install + name: {{ .OperatorName }}.v{{ .OperatorVersion }} + namespace: placeholder +spec: + apiservicedefinitions: {} + customresourcedefinitions: + owned: +{{- range $i, $crd := .CRDKeys }}{{- range $j, $version := $crd.Versions }} + - description: Represents a cluster of {{ $crd.Kind }} apps + displayName: {{ $crd.Kind }} App + kind: {{ $crd.Kind }} + name: {{ $crd.Name }} + resources: + - kind: Deployment + version: v1 + - kind: ReplicaSet + version: v1 + - kind: Pod + version: v1 + specDescriptors: + - description: The desired number of member Pods for the deployment. + displayName: Size + path: size + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:podCount + statusDescriptors: + - description: The current status of the application. + displayName: Status + path: phase + x-descriptors: + - urn:alm:descriptor:io.kubernetes.phase + - description: Explanation for the current status of the application. + displayName: Status Details + path: reason + x-descriptors: + - urn:alm:descriptor:io.kubernetes.phase:reason + version: {{ $version.Name }} +{{- end }}{{- end }} + description: Big ol' Operator. + displayName: {{ .OperatorName }} Application + install: + spec: + deployments: + - name: {{ .OperatorName }} + spec: + replicas: 1 + selector: + matchLabels: + name: {{ .OperatorName }} + strategy: {} + template: + metadata: + labels: + name: {{ .OperatorName }} + spec: + containers: + - command: + - {{ .OperatorName }} + env: + - name: WATCH_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.annotations['olm.targetNamespaces'] + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: OPERATOR_NAME + value: {{ .OperatorName }} + image: {{ .TestImageTag }} + imagePullPolicy: Never + name: {{ .OperatorName }} + resources: {} + serviceAccountName: {{ .OperatorName }} + permissions: + - rules: + - apiGroups: + - "" + resources: + - pods + - services + - endpoints + - persistentvolumeclaims + - events + - configmaps + - secrets + verbs: + - '*' + - apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - apiGroups: + - apps + resources: + - deployments + - daemonsets + - replicasets + - statefulsets + verbs: + - '*' + - apiGroups: + - monitoring.coreos.com + resources: + - servicemonitors + verbs: + - get + - create + - apiGroups: + - apps + resourceNames: + - {{ .OperatorName }} + resources: + - deployments/finalizers + verbs: + - update + serviceAccountName: {{ .OperatorName }} + strategy: deployment + installModes: +{{- range $i, $mode := .InstallModes }} + - supported: {{ $mode.Supported }} + type: {{ $mode.Type }} +{{- end }} + keywords: + - big + - ol + - operator + maintainers: + - email: corp@example.com + name: Some Corp + maturity: {{ .Maturity }} + provider: + name: Example + url: www.example.com +{{- if .ReplacesCSVName }} + replaces: {{ .ReplacesCSVName }} +{{- end }} + version: {{ .OperatorVersion }} +` + +func writeOperatorManifests(root, operatorName, defaultChannel string, csvConfigs ...CSVTemplateConfig) (manifestsDir string, err error) { + manifestsDir = filepath.Join(root, operatorName) + pkg := registry.PackageManifest{ + PackageName: operatorName, + DefaultChannelName: defaultChannel, + } + for _, csvConfig := range csvConfigs { + pkg.Channels = append(pkg.Channels, registry.PackageChannel{ + Name: csvConfig.Maturity, + CurrentCSVName: fmt.Sprintf("%s.v%s", csvConfig.OperatorName, csvConfig.OperatorVersion), + }) + bundleDir := filepath.Join(manifestsDir, csvConfig.OperatorVersion) + for _, key := range csvConfig.CRDKeys { + crd := apiextv1beta1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiextv1beta1.SchemeGroupVersion.String(), + Kind: "CustomResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{Name: key.Name}, + Spec: apiextv1beta1.CustomResourceDefinitionSpec{ + Names: apiextv1beta1.CustomResourceDefinitionNames{ + Kind: key.Kind, + ListKind: key.Kind + "List", + Singular: strings.ToLower(key.Kind), + Plural: strings.ToLower(key.Kind) + "s", + }, + Group: key.Group, + Scope: "Namespaced", + Versions: key.Versions, + }, + } + crdPath := filepath.Join(bundleDir, fmt.Sprintf("%s.crd.yaml", key.Name)) + if err = writeObjectManifest(crdPath, crd); err != nil { + return "", err + } + } + csvPath := filepath.Join(bundleDir, fmt.Sprintf("%s.v%s.csv.yaml", csvConfig.OperatorName, csvConfig.OperatorVersion)) + if err = execTemplateOnFile(csvPath, csvTmpl, csvConfig); err != nil { + return "", err + } + } + pkgPath := filepath.Join(manifestsDir, fmt.Sprintf("%s.package.yaml", operatorName)) + if err = writeObjectManifest(pkgPath, pkg); err != nil { + return "", err + } + return manifestsDir, nil +} + +func writeObjectManifest(path string, o interface{}) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + b, err := yaml.Marshal(o) + if err != nil { + return err + } + if err = ioutil.WriteFile(path, b, 0644); err != nil { + return err + } + return nil +} + +func execTemplateOnFile(path, tmpl string, o interface{}) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + w, err := os.Create(path) + if err != nil { + return err + } + defer w.Close() + csvTmpl, err := template.New(path).Parse(tmpl) + if err != nil { + return err + } + if err = csvTmpl.Execute(w, o); err != nil { + return err + } + return nil +} diff --git a/test/test-framework/build/Dockerfile b/test/test-framework/build/Dockerfile index 464c54c794..dfee3c4c36 100644 --- a/test/test-framework/build/Dockerfile +++ b/test/test-framework/build/Dockerfile @@ -2,7 +2,7 @@ FROM registry.access.redhat.com/ubi8/ubi-minimal:latest ENV OPERATOR=/usr/local/bin/memcached-operator \ USER_UID=1001 \ - USER_NAME=test-framework + USER_NAME=memcached-operator # install operator binary COPY build/_output/bin/test-framework ${OPERATOR}