github.com/oam-dev/kubevela@v1.9.11/pkg/addon/init.go (about) 1 /* 2 Copyright 2022 The KubeVela Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package addon 18 19 import ( 20 "fmt" 21 "os" 22 "path" 23 "path/filepath" 24 "regexp" 25 "strings" 26 27 "cuelang.org/go/cue" 28 "cuelang.org/go/cue/cuecontext" 29 "cuelang.org/go/cue/format" 30 "cuelang.org/go/encoding/gocode/gocodec" 31 "github.com/fatih/color" 32 "k8s.io/klog/v2" 33 "sigs.k8s.io/yaml" 34 35 "github.com/oam-dev/kubevela/pkg/utils" 36 ) 37 38 const ( 39 // AddonNameRegex is the regex to validate addon names 40 AddonNameRegex = `^[a-z\d]+(-[a-z\d]+)*$` 41 // helmComponentDependency is the dependent addon of Helm Component 42 helmComponentDependency = "fluxcd" 43 ) 44 45 // InitCmd contains the options to initialize an addon scaffold 46 type InitCmd struct { 47 AddonName string 48 NoSamples bool 49 HelmRepoURL string 50 HelmChartName string 51 HelmChartVersion string 52 Path string 53 Overwrite bool 54 RefObjURLs []string 55 // We use string instead of v1beta1.Application is because 56 // the cue formatter is having some problems: it will keep 57 // TypeMeta (instead of inlined). 58 AppTmpl string 59 Metadata Meta 60 Readme string 61 Resources []ElementFile 62 Schemas []ElementFile 63 Views []ElementFile 64 Definitions []ElementFile 65 } 66 67 // CreateScaffold creates an addon scaffold 68 func (cmd *InitCmd) CreateScaffold() error { 69 var err error 70 71 if len(cmd.AddonName) == 0 || len(cmd.Path) == 0 { 72 return fmt.Errorf("addon name and path should not be empty") 73 } 74 75 err = CheckAddonName(cmd.AddonName) 76 if err != nil { 77 return err 78 } 79 80 err = cmd.createDirs() 81 if err != nil { 82 return fmt.Errorf("cannot create addon structure: %w", err) 83 } 84 // Delete created files if an error occurred afterwards. 85 defer func() { 86 if err != nil { 87 _ = os.RemoveAll(cmd.Path) 88 } 89 }() 90 91 cmd.createRequiredFiles() 92 93 if cmd.HelmChartName != "" && cmd.HelmChartVersion != "" && cmd.HelmRepoURL != "" { 94 klog.Info("Creating Helm component...") 95 err = cmd.createHelmComponent() 96 if err != nil { 97 return err 98 } 99 } 100 101 if len(cmd.RefObjURLs) > 0 { 102 klog.Info("Creating ref-objects URL component...") 103 err = cmd.createURLComponent() 104 if err != nil { 105 return err 106 } 107 } 108 109 if !cmd.NoSamples { 110 cmd.createSamples() 111 } 112 113 err = cmd.writeFiles() 114 if err != nil { 115 return err 116 } 117 118 // Print some instructions to get started. 119 fmt.Println("\nScaffold created in directory " + 120 color.New(color.Bold).Sprint(cmd.Path) + ". What to do next:\n" + 121 "- Check out our guide on how to build your own addon: " + 122 color.New(color.Bold, color.FgBlue).Sprint("https://kubevela.io/docs/platform-engineers/addon/intro") + "\n" + 123 "- Review and edit what we have generated in " + color.New(color.Bold).Sprint(cmd.Path) + "\n" + 124 "- To enable this addon, run: " + 125 color.New(color.FgGreen).Sprint("vela") + color.GreenString(" addon enable ") + color.New(color.Bold, color.FgGreen).Sprint(cmd.Path)) 126 127 return nil 128 } 129 130 // CheckAddonName checks if an addon name is valid 131 func CheckAddonName(addonName string) error { 132 if len(addonName) == 0 { 133 return fmt.Errorf("addon name should not be empty") 134 } 135 136 // Make sure addonName only contains lowercase letters, dashes, and numbers, e.g. some-addon 137 re := regexp.MustCompile(AddonNameRegex) 138 if !re.MatchString(addonName) { 139 return fmt.Errorf("addon name should only cocntain lowercase letters, dashes, and numbers, e.g. some-addon") 140 } 141 142 return nil 143 } 144 145 // createSamples creates sample files 146 func (cmd *InitCmd) createSamples() { 147 // Sample Definition mytrait.cue 148 cmd.Definitions = append(cmd.Definitions, ElementFile{ 149 Data: traitTemplate, 150 Name: "mytrait.cue", 151 }) 152 // Sample Resource 153 cmd.Resources = append(cmd.Resources, ElementFile{ 154 Data: resourceTemplate, 155 Name: "myresource.cue", 156 }) 157 // Sample schema 158 cmd.Schemas = append(cmd.Schemas, ElementFile{ 159 Data: schemaTemplate, 160 Name: "myschema.yaml", 161 }) 162 // Sample View 163 cmd.Views = append(cmd.Views, ElementFile{ 164 Data: strings.ReplaceAll(viewTemplate, "ADDON_NAME", cmd.AddonName), 165 Name: "my-view.cue", 166 }) 167 } 168 169 // createRequiredFiles creates README.md, template.yaml and metadata.yaml 170 func (cmd *InitCmd) createRequiredFiles() { 171 // README.md 172 cmd.Readme = strings.ReplaceAll(readmeTemplate, "ADDON_NAME", cmd.AddonName) 173 174 // template.cue 175 cmd.AppTmpl = appTemplate 176 177 // metadata.yaml 178 cmd.Metadata = Meta{ 179 Name: cmd.AddonName, 180 Version: "1.0.0", 181 Description: "An addon for KubeVela.", 182 Tags: []string{"my-tag"}, 183 Dependencies: []*Dependency{}, 184 DeployTo: nil, 185 } 186 } 187 188 // createHelmComponent creates a <addon-name-helm>.cue in /resources 189 func (cmd *InitCmd) createHelmComponent() error { 190 // Make fluxcd a dependency, since it uses a helm component 191 cmd.Metadata.addDependency(helmComponentDependency) 192 // Make addon version same as chart version 193 cmd.Metadata.Version = cmd.HelmChartVersion 194 195 // Create a <addon-name-helm>.cue in resources 196 tmpl := helmComponentTmpl{} 197 tmpl.Type = "helm" 198 tmpl.Properties.RepoType = "helm" 199 if strings.HasPrefix(cmd.HelmRepoURL, "oci") { 200 tmpl.Properties.RepoType = "oci" 201 } 202 tmpl.Properties.URL = cmd.HelmRepoURL 203 tmpl.Properties.Chart = cmd.HelmChartName 204 tmpl.Properties.Version = cmd.HelmChartVersion 205 tmpl.Name = "addon-" + cmd.AddonName 206 207 str, err := toCUEResourceString(tmpl) 208 if err != nil { 209 return err 210 } 211 212 cmd.Resources = append(cmd.Resources, ElementFile{ 213 Name: "helm.cue", 214 Data: str, 215 }) 216 217 return nil 218 } 219 220 // createURLComponent creates a ref-object component containing URLs 221 func (cmd *InitCmd) createURLComponent() error { 222 tmpl := refObjURLTmpl{Type: "ref-objects"} 223 224 for _, url := range cmd.RefObjURLs { 225 if !utils.IsValidURL(url) { 226 return fmt.Errorf("%s is not a valid url", url) 227 } 228 229 tmpl.Properties.URLs = append(tmpl.Properties.URLs, url) 230 } 231 232 str, err := toCUEResourceString(tmpl) 233 if err != nil { 234 return err 235 } 236 237 cmd.Resources = append(cmd.Resources, ElementFile{ 238 Data: str, 239 Name: "from-url.cue", 240 }) 241 242 return nil 243 } 244 245 // toCUEResourceString formats object to CUE string used in addons 246 // nolint:staticcheck 247 func toCUEResourceString(obj interface{}) (string, error) { 248 v, err := gocodec.New((*cue.Runtime)(cuecontext.New()), nil).Decode(obj) 249 if err != nil { 250 return "", err 251 } 252 253 bs, err := format.Node(v.Syntax()) 254 if err != nil { 255 return "", err 256 } 257 258 // Append "output: " to the beginning of the string, like "output: {}" 259 bs = append([]byte("output: "), bs...) 260 261 return string(bs), nil 262 } 263 264 // addDependency adds a dependency into metadata.yaml 265 func (m *Meta) addDependency(dep string) { 266 for _, d := range m.Dependencies { 267 if d.Name == dep { 268 return 269 } 270 } 271 272 m.Dependencies = append(m.Dependencies, &Dependency{Name: dep}) 273 } 274 275 // createDirs creates the directory structure for an addon 276 func (cmd *InitCmd) createDirs() error { 277 // Make sure addonDir is pointing to an empty directory, or does not exist at all 278 // so that we can create it later 279 _, err := os.Stat(cmd.Path) 280 if !os.IsNotExist(err) { 281 emptyDir, err := utils.IsEmptyDir(cmd.Path) 282 if err != nil { 283 return fmt.Errorf("we can't create directory %s. Make sure the name has not already been taken and you have the proper rights to write to it", cmd.Path) 284 } 285 286 if !emptyDir { 287 if !cmd.Overwrite { 288 return fmt.Errorf("directory %s is not empty. To avoid any data loss, please manually delete it first or use -f, then try again", cmd.Path) 289 } 290 klog.Warningf("Overwriting non-empty directory %s", cmd.Path) 291 } 292 293 // Now we are sure addonPath is en empty dir, (or the user want to overwrite), delete it 294 err = os.RemoveAll(cmd.Path) 295 if err != nil { 296 return err 297 } 298 } 299 300 // nolint:gosec 301 err = os.MkdirAll(cmd.Path, 0755) 302 if err != nil { 303 return err 304 } 305 306 dirs := []string{ 307 path.Join(cmd.Path, ResourcesDirName), 308 path.Join(cmd.Path, DefinitionsDirName), 309 path.Join(cmd.Path, DefSchemaName), 310 path.Join(cmd.Path, ViewDirName), 311 } 312 313 for _, dir := range dirs { 314 // nolint:gosec 315 err = os.MkdirAll(dir, 0755) 316 if err != nil { 317 return err 318 } 319 } 320 321 return nil 322 } 323 324 // writeFiles writes addon to disk 325 func (cmd *InitCmd) writeFiles() error { 326 var files []ElementFile 327 328 files = append(files, ElementFile{ 329 Name: ReadmeFileName, 330 Data: cmd.Readme, 331 }, ElementFile{ 332 Data: parameterTemplate, 333 Name: GlobalParameterFileName, 334 }) 335 336 for _, v := range cmd.Resources { 337 files = append(files, ElementFile{ 338 Data: v.Data, 339 Name: filepath.Join(ResourcesDirName, v.Name), 340 }) 341 } 342 for _, v := range cmd.Views { 343 files = append(files, ElementFile{ 344 Data: v.Data, 345 Name: filepath.Join(ViewDirName, v.Name), 346 }) 347 } 348 for _, v := range cmd.Definitions { 349 files = append(files, ElementFile{ 350 Data: v.Data, 351 Name: filepath.Join(DefinitionsDirName, v.Name), 352 }) 353 } 354 for _, v := range cmd.Schemas { 355 files = append(files, ElementFile{ 356 Data: v.Data, 357 Name: filepath.Join(DefSchemaName, v.Name), 358 }) 359 } 360 361 // Prepare template.cue 362 files = append(files, ElementFile{ 363 Data: cmd.AppTmpl, 364 Name: AppTemplateCueFileName, 365 }) 366 367 // Prepare metadata.yaml 368 metaBytes, err := yaml.Marshal(cmd.Metadata) 369 if err != nil { 370 return err 371 } 372 files = append(files, ElementFile{ 373 Data: string(metaBytes), 374 Name: MetadataFileName, 375 }) 376 377 // Write files 378 for _, f := range files { 379 err := os.WriteFile(filepath.Join(cmd.Path, f.Name), []byte(f.Data), 0600) 380 if err != nil { 381 return err 382 } 383 } 384 385 return nil 386 } 387 388 // helmComponentTmpl is a template for a helm component .cue in an addon 389 type helmComponentTmpl struct { 390 Name string `json:"name"` 391 Type string `json:"type"` 392 Properties struct { 393 RepoType string `json:"repoType"` 394 URL string `json:"url"` 395 Chart string `json:"chart"` 396 Version string `json:"version"` 397 } `json:"properties"` 398 } 399 400 // refObjURLTmpl is a template for ref-objects containing URLs in an addon 401 type refObjURLTmpl struct { 402 Type string `json:"type"` 403 Properties struct { 404 URLs []string `json:"urls"` 405 } `json:"properties"` 406 } 407 408 const ( 409 readmeTemplate = "# ADDON_NAME\n" + 410 "\n" + 411 "This is an addon template. Check how to build your own addon: https://kubevela.net/docs/platform-engineers/addon/intro\n" + 412 "" 413 viewTemplate = `// We put VelaQL views in views directory. 414 // 415 // VelaQL(Vela Query Language) is a resource query language for KubeVela, 416 // used to query status of any extended resources in application-level. 417 // Reference: https://kubevela.net/docs/platform-engineers/system-operation/velaql 418 // 419 // This VelaQL View queries the status of this addon. 420 // Use this view to query by: 421 // vela ql --query 'my-view{addonName:ADDON_NAME}.status' 422 // You should see 'running'. 423 424 import ( 425 "vela/ql" 426 ) 427 428 app: ql.#Read & { 429 value: { 430 kind: "Application" 431 apiVersion: "core.oam.dev/v1beta1" 432 metadata: { 433 name: "addon-" + parameter.addonName 434 namespace: "vela-system" 435 } 436 } 437 } 438 439 parameter: { 440 addonName: *"ADDON_NAME" | string 441 } 442 443 status: app.value.status.status 444 ` 445 traitTemplate = `// We put Definitions in definitions directory. 446 // References: 447 // - https://kubevela.net/docs/platform-engineers/cue/definition-edit 448 // - https://kubevela.net/docs/platform-engineers/addon/intro#definitions-directoryoptional 449 "mytrait": { 450 alias: "mt" 451 annotations: {} 452 attributes: { 453 appliesToWorkloads: [ 454 "deployments.apps", 455 "replicasets.apps", 456 "statefulsets.apps", 457 ] 458 conflictsWith: [] 459 podDisruptive: false 460 workloadRefPath: "" 461 } 462 description: "My trait description." 463 labels: {} 464 type: "trait" 465 } 466 template: { 467 parameter: {param: ""} 468 outputs: {sample: {}} 469 } 470 ` 471 resourceTemplate = `// We put Components in resources directory. 472 // References: 473 // - https://kubevela.net/docs/end-user/components/references 474 // - https://kubevela.net/docs/platform-engineers/addon/intro#resources-directoryoptional 475 output: { 476 type: "k8s-objects" 477 properties: { 478 objects: [ 479 { 480 // This creates a plain old Kubernetes namespace 481 apiVersion: "v1" 482 kind: "Namespace" 483 // We can use the parameter defined in parameter.cue like this. 484 metadata: name: parameter.myparam 485 }, 486 ] 487 } 488 } 489 ` 490 parameterTemplate = `// parameter.cue is used to store addon parameters. 491 // 492 // You can use these parameters in template.cue or in resources/ by 'parameter.myparam' 493 // 494 // For example, you can use parameters to allow the user to customize 495 // container images, ports, and etc. 496 parameter: { 497 // +usage=Custom parameter description 498 myparam: *"myns" | string 499 } 500 ` 501 schemaTemplate = `# We put UI Schemas that correspond to Definitions in schemas directory. 502 # References: 503 # - https://kubevela.net/docs/platform-engineers/addon/intro#schemas-directoryoptional 504 # - https://kubevela.net/docs/reference/ui-schema 505 - jsonKey: myparam 506 label: MyParam 507 validate: 508 required: true 509 ` 510 appTemplate = `package main 511 output: { 512 apiVersion: "core.oam.dev/v1beta1" 513 kind: "Application" 514 spec: { 515 components: [] 516 policies: [] 517 } 518 } 519 ` 520 )