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 }