github.com/joelanford/operator-sdk@v0.8.2/internal/pkg/scaffold/olm-catalog/csv.go (about)

     1  // Copyright 2018 The Operator-SDK Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package catalog
    16  
    17  import (
    18  	"bytes"
    19  	"encoding/json"
    20  	"errors"
    21  	"fmt"
    22  	"os"
    23  	"path/filepath"
    24  	"strings"
    25  	"sync"
    26  	"unicode"
    27  
    28  	"github.com/operator-framework/operator-sdk/internal/pkg/scaffold"
    29  	"github.com/operator-framework/operator-sdk/internal/pkg/scaffold/input"
    30  	"github.com/operator-framework/operator-sdk/internal/util/k8sutil"
    31  	"github.com/operator-framework/operator-sdk/internal/util/yamlutil"
    32  
    33  	"github.com/coreos/go-semver/semver"
    34  	"github.com/ghodss/yaml"
    35  	olmapiv1alpha1 "github.com/operator-framework/operator-lifecycle-manager/pkg/api/apis/operators/v1alpha1"
    36  	log "github.com/sirupsen/logrus"
    37  	"github.com/spf13/afero"
    38  )
    39  
    40  const (
    41  	CSVYamlFileExt    = ".clusterserviceversion.yaml"
    42  	CSVConfigYamlFile = "csv-config.yaml"
    43  )
    44  
    45  var ErrNoCSVVersion = errors.New("no CSV version supplied")
    46  
    47  type CSV struct {
    48  	input.Input
    49  
    50  	// ConfigFilePath is the location of a configuration file path for this
    51  	// projects' CSV file.
    52  	ConfigFilePath string
    53  	// CSVVersion is the CSV current version.
    54  	CSVVersion string
    55  	// FromVersion is the CSV version from which to build a new CSV. A CSV
    56  	// manifest with this version should exist at:
    57  	// deploy/olm-catalog/{from_version}/operator-name.v{from_version}.{CSVYamlFileExt}
    58  	FromVersion string
    59  
    60  	once       sync.Once
    61  	fs         afero.Fs // For testing, ex. afero.NewMemMapFs()
    62  	pathPrefix string   // For testing, ex. testdata/deploy/olm-catalog
    63  }
    64  
    65  func (s *CSV) initFS(fs afero.Fs) {
    66  	s.once.Do(func() {
    67  		s.fs = fs
    68  	})
    69  }
    70  
    71  func (s *CSV) getFS() afero.Fs {
    72  	s.initFS(afero.NewOsFs())
    73  	return s.fs
    74  }
    75  
    76  func (s *CSV) GetInput() (input.Input, error) {
    77  	// A CSV version is required.
    78  	if s.CSVVersion == "" {
    79  		return input.Input{}, ErrNoCSVVersion
    80  	}
    81  	if s.Path == "" {
    82  		lowerProjName := strings.ToLower(s.ProjectName)
    83  		// Path is what the operator-registry expects:
    84  		// {manifests -> olm-catalog}/{operator_name}/{semver}/{operator_name}.v{semver}.clusterserviceversion.yaml
    85  		s.Path = filepath.Join(s.pathPrefix,
    86  			scaffold.OLMCatalogDir,
    87  			lowerProjName,
    88  			s.CSVVersion,
    89  			getCSVFileName(lowerProjName, s.CSVVersion),
    90  		)
    91  	}
    92  	if s.ConfigFilePath == "" {
    93  		s.ConfigFilePath = filepath.Join(s.pathPrefix, scaffold.OLMCatalogDir, CSVConfigYamlFile)
    94  	}
    95  	return s.Input, nil
    96  }
    97  
    98  func (s *CSV) SetFS(fs afero.Fs) { s.initFS(fs) }
    99  
   100  // CustomRender allows a CSV to be written by marshalling
   101  // olmapiv1alpha1.ClusterServiceVersion instead of writing to a template.
   102  func (s *CSV) CustomRender() ([]byte, error) {
   103  	s.initFS(afero.NewOsFs())
   104  
   105  	// Get current CSV to update.
   106  	csv, exists, err := s.getBaseCSVIfExists()
   107  	if err != nil {
   108  		return nil, err
   109  	}
   110  	if !exists {
   111  		csv = &olmapiv1alpha1.ClusterServiceVersion{}
   112  		s.initCSVFields(csv)
   113  	}
   114  
   115  	cfg, err := GetCSVConfig(s.ConfigFilePath)
   116  	if err != nil {
   117  		return nil, err
   118  	}
   119  
   120  	setCSVDefaultFields(csv)
   121  	if err = s.updateCSVVersions(csv); err != nil {
   122  		return nil, err
   123  	}
   124  	if err = s.updateCSVFromManifestFiles(cfg, csv); err != nil {
   125  		return nil, err
   126  	}
   127  
   128  	if fields := getEmptyRequiredCSVFields(csv); len(fields) != 0 {
   129  		if exists {
   130  			log.Warnf("Required csv fields not filled in file %s:%s\n", s.Path, joinFields(fields))
   131  		} else {
   132  			// A new csv won't have several required fields populated.
   133  			// Report required fields to user informationally.
   134  			log.Infof("Fill in the following required fields in file %s:%s\n", s.Path, joinFields(fields))
   135  		}
   136  	}
   137  
   138  	return k8sutil.GetObjectBytes(csv)
   139  }
   140  
   141  func (s *CSV) getBaseCSVIfExists() (*olmapiv1alpha1.ClusterServiceVersion, bool, error) {
   142  	verToGet := s.CSVVersion
   143  	if s.FromVersion != "" {
   144  		verToGet = s.FromVersion
   145  	}
   146  	csv, exists, err := getCSVFromFSIfExists(s.getFS(), s.getCSVPath(verToGet))
   147  	if err != nil {
   148  		return nil, false, err
   149  	}
   150  	if !exists && s.FromVersion != "" {
   151  		log.Warnf("FromVersion set (%s) but CSV does not exist", s.FromVersion)
   152  	}
   153  	return csv, exists, nil
   154  }
   155  
   156  func getCSVFromFSIfExists(fs afero.Fs, path string) (*olmapiv1alpha1.ClusterServiceVersion, bool, error) {
   157  	csvBytes, err := afero.ReadFile(fs, path)
   158  	if err != nil {
   159  		if os.IsNotExist(err) {
   160  			return nil, false, nil
   161  		}
   162  		return nil, false, err
   163  	}
   164  	if len(csvBytes) == 0 {
   165  		return nil, false, nil
   166  	}
   167  
   168  	csv := &olmapiv1alpha1.ClusterServiceVersion{}
   169  	if err := yaml.Unmarshal(csvBytes, csv); err != nil {
   170  		return nil, false, fmt.Errorf("%s: %v", path, err)
   171  	}
   172  
   173  	return csv, true, nil
   174  }
   175  
   176  func getCSVName(name, version string) string {
   177  	return name + ".v" + version
   178  }
   179  
   180  func getCSVFileName(name, version string) string {
   181  	return getCSVName(name, version) + CSVYamlFileExt
   182  }
   183  
   184  func (s *CSV) getCSVPath(ver string) string {
   185  	lowerProjName := strings.ToLower(s.ProjectName)
   186  	name := getCSVFileName(lowerProjName, ver)
   187  	return filepath.Join(s.pathPrefix, scaffold.OLMCatalogDir, lowerProjName, ver, name)
   188  }
   189  
   190  // getDisplayName turns a project dir name in any of {snake, chain, camel}
   191  // cases, hierarchical dot structure, or space-delimited into a
   192  // space-delimited, title'd display name.
   193  // Ex. "another-_AppOperator_againTwiceThrice More"
   194  // ->  "Another App Operator Again Twice Thrice More"
   195  func getDisplayName(name string) string {
   196  	for _, sep := range ".-_ " {
   197  		splitName := strings.Split(name, string(sep))
   198  		for i := 0; i < len(splitName); i++ {
   199  			if splitName[i] == "" {
   200  				splitName = append(splitName[:i], splitName[i+1:]...)
   201  				i--
   202  			} else {
   203  				splitName[i] = strings.TrimSpace(splitName[i])
   204  			}
   205  		}
   206  		name = strings.Join(splitName, " ")
   207  	}
   208  	splitName := strings.Split(name, " ")
   209  	for i, word := range splitName {
   210  		temp := word
   211  		o := 0
   212  		for j, r := range word {
   213  			if unicode.IsUpper(r) {
   214  				if j > 0 && !unicode.IsUpper(rune(word[j-1])) {
   215  					temp = temp[0:j+o] + " " + temp[j+o:len(temp)]
   216  					o++
   217  				}
   218  			}
   219  		}
   220  		splitName[i] = temp
   221  	}
   222  	return strings.TrimSpace(strings.Title(strings.Join(splitName, " ")))
   223  }
   224  
   225  // initCSVFields initializes all csv fields that should be populated by a user
   226  // with sane defaults. initCSVFields should only be called for new csv's.
   227  func (s *CSV) initCSVFields(csv *olmapiv1alpha1.ClusterServiceVersion) {
   228  	// Metadata
   229  	csv.TypeMeta.APIVersion = olmapiv1alpha1.ClusterServiceVersionAPIVersion
   230  	csv.TypeMeta.Kind = olmapiv1alpha1.ClusterServiceVersionKind
   231  	csv.SetName(getCSVName(strings.ToLower(s.ProjectName), s.CSVVersion))
   232  	csv.SetNamespace("placeholder")
   233  	csv.SetAnnotations(map[string]string{"capabilities": "Basic Install"})
   234  
   235  	// Spec fields
   236  	csv.Spec.Version = *semver.New(s.CSVVersion)
   237  	csv.Spec.DisplayName = getDisplayName(s.ProjectName)
   238  	csv.Spec.Description = "Placeholder description"
   239  	csv.Spec.Maturity = "alpha"
   240  	csv.Spec.Provider = olmapiv1alpha1.AppLink{}
   241  	csv.Spec.Maintainers = make([]olmapiv1alpha1.Maintainer, 0)
   242  	csv.Spec.Links = make([]olmapiv1alpha1.AppLink, 0)
   243  }
   244  
   245  // setCSVDefaultFields sets default fields on older CSV versions or newly
   246  // initialized CSV's.
   247  func setCSVDefaultFields(csv *olmapiv1alpha1.ClusterServiceVersion) {
   248  	if len(csv.Spec.InstallModes) == 0 {
   249  		csv.Spec.InstallModes = []olmapiv1alpha1.InstallMode{
   250  			{Type: olmapiv1alpha1.InstallModeTypeOwnNamespace, Supported: true},
   251  			{Type: olmapiv1alpha1.InstallModeTypeSingleNamespace, Supported: true},
   252  			{Type: olmapiv1alpha1.InstallModeTypeMultiNamespace, Supported: false},
   253  			{Type: olmapiv1alpha1.InstallModeTypeAllNamespaces, Supported: true},
   254  		}
   255  	}
   256  }
   257  
   258  // TODO: validate that all fields from files are populated as expected
   259  // ex. add `resources` to a CRD
   260  
   261  func getEmptyRequiredCSVFields(csv *olmapiv1alpha1.ClusterServiceVersion) (fields []string) {
   262  	// Metadata
   263  	if csv.TypeMeta.APIVersion != olmapiv1alpha1.ClusterServiceVersionAPIVersion {
   264  		fields = append(fields, "apiVersion")
   265  	}
   266  	if csv.TypeMeta.Kind != olmapiv1alpha1.ClusterServiceVersionKind {
   267  		fields = append(fields, "kind")
   268  	}
   269  	if csv.ObjectMeta.Name == "" {
   270  		fields = append(fields, "metadata.name")
   271  	}
   272  	// Spec fields
   273  	if csv.Spec.Version.String() == "" {
   274  		fields = append(fields, "spec.version")
   275  	}
   276  	if csv.Spec.DisplayName == "" {
   277  		fields = append(fields, "spec.displayName")
   278  	}
   279  	if csv.Spec.Description == "" {
   280  		fields = append(fields, "spec.description")
   281  	}
   282  	if len(csv.Spec.Keywords) == 0 {
   283  		fields = append(fields, "spec.keywords")
   284  	}
   285  	if len(csv.Spec.Maintainers) == 0 {
   286  		fields = append(fields, "spec.maintainers")
   287  	}
   288  	if csv.Spec.Provider == (olmapiv1alpha1.AppLink{}) {
   289  		fields = append(fields, "spec.provider")
   290  	}
   291  	if csv.Spec.Maturity == "" {
   292  		fields = append(fields, "spec.maturity")
   293  	}
   294  
   295  	return fields
   296  }
   297  
   298  func joinFields(fields []string) string {
   299  	sb := &strings.Builder{}
   300  	for _, f := range fields {
   301  		sb.WriteString("\n\t" + f)
   302  	}
   303  	return sb.String()
   304  }
   305  
   306  // updateCSVVersions updates csv's version and data involving the version,
   307  // ex. ObjectMeta.Name, and place the old version in the `replaces` object,
   308  // if there is an old version to replace.
   309  func (s *CSV) updateCSVVersions(csv *olmapiv1alpha1.ClusterServiceVersion) error {
   310  
   311  	// Old csv version to replace, and updated csv version.
   312  	oldVer, newVer := csv.Spec.Version.String(), s.CSVVersion
   313  	if oldVer == newVer {
   314  		return nil
   315  	}
   316  
   317  	// We do not want to update versions in most fields, as these versions are
   318  	// independent of global csv version and will be updated elsewhere.
   319  	fieldsToUpdate := []interface{}{
   320  		&csv.ObjectMeta,
   321  		&csv.Spec.Labels,
   322  		&csv.Spec.Selector,
   323  	}
   324  	for _, v := range fieldsToUpdate {
   325  		err := replaceAllBytes(v, []byte(oldVer), []byte(newVer))
   326  		if err != nil {
   327  			return err
   328  		}
   329  	}
   330  
   331  	// Now replace all references to the old operator name.
   332  	lowerProjName := strings.ToLower(s.ProjectName)
   333  	oldCSVName := getCSVName(lowerProjName, oldVer)
   334  	newCSVName := getCSVName(lowerProjName, newVer)
   335  	err := replaceAllBytes(csv, []byte(oldCSVName), []byte(newCSVName))
   336  	if err != nil {
   337  		return err
   338  	}
   339  
   340  	csv.Spec.Version = *semver.New(newVer)
   341  	csv.Spec.Replaces = oldCSVName
   342  	return nil
   343  }
   344  
   345  func replaceAllBytes(v interface{}, old, new []byte) error {
   346  	b, err := json.Marshal(v)
   347  	if err != nil {
   348  		return err
   349  	}
   350  	b = bytes.Replace(b, old, new, -1)
   351  	if err = json.Unmarshal(b, v); err != nil {
   352  		return err
   353  	}
   354  	return nil
   355  }
   356  
   357  // updateCSVFromManifestFiles gathers relevant data from generated and
   358  // user-defined manifests and updates csv.
   359  func (s *CSV) updateCSVFromManifestFiles(cfg *CSVConfig, csv *olmapiv1alpha1.ClusterServiceVersion) error {
   360  	store := NewUpdaterStore()
   361  	otherSpecs := make(map[string][][]byte)
   362  	for _, f := range append(cfg.CRDCRPaths, cfg.OperatorPath, cfg.RolePath) {
   363  		yamlData, err := afero.ReadFile(s.getFS(), f)
   364  		if err != nil {
   365  			return err
   366  		}
   367  
   368  		scanner := yamlutil.NewYAMLScanner(yamlData)
   369  		for scanner.Scan() {
   370  			yamlSpec := scanner.Bytes()
   371  			kind, err := getKindfromYAML(yamlSpec)
   372  			if err != nil {
   373  				return fmt.Errorf("%s: %v", f, err)
   374  			}
   375  			found, err := store.AddToUpdater(yamlSpec, kind)
   376  			if err != nil {
   377  				return fmt.Errorf("%s: %v", f, err)
   378  			}
   379  			if !found {
   380  				if _, ok := otherSpecs[kind]; !ok {
   381  					otherSpecs[kind] = make([][]byte, 0)
   382  				}
   383  				otherSpecs[kind] = append(otherSpecs[kind], yamlSpec)
   384  			}
   385  		}
   386  		if err = scanner.Err(); err != nil {
   387  			return err
   388  		}
   389  	}
   390  
   391  	for k := range store.crds.crKinds {
   392  		if crSpecs, ok := otherSpecs[k]; ok {
   393  			for _, spec := range crSpecs {
   394  				if err := store.AddCR(spec); err != nil {
   395  					return err
   396  				}
   397  			}
   398  		}
   399  	}
   400  
   401  	return store.Apply(csv)
   402  }