diff --git a/README.md b/README.md index 2747bf1..7d4b409 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Flags: -l, --log-level string Level of logs that should printed, one of (panic, fatal, error, warning, info, debug, trace) (default "info") -n, --no-dependencies don't analyze dependencies -o, --output-file string jsonschema file path relative to each chart directory to which jsonschema will be written (default "values.schema.json") + -r, --use-refereces use references instead of embeding dependencies schema -f, --value-files strings filenames to check for chart values (default [values.yaml]) -v, --version version for helm-schema ``` diff --git a/cmd/helm-schema/cli.go b/cmd/helm-schema/cli.go index dd39ce8..9314987 100644 --- a/cmd/helm-schema/cli.go +++ b/cmd/helm-schema/cli.go @@ -32,12 +32,14 @@ func configureLogging() { log.SetLevel(logLevel) } -func newCommand(run func(cmd *cobra.Command, args []string)) (*cobra.Command, error) { +func newCommand(run func(cmd *cobra.Command, args []string) error) (*cobra.Command, error) { cmd := &cobra.Command{ - Use: "helm-schema", - Short: "helm-schema automatically generates a jsonschema file for helm charts from values files", - Version: version, - Run: run, + Use: "helm-schema", + Short: "helm-schema automatically generates a jsonschema file for helm charts from values files", + Version: version, + RunE: run, + SilenceUsage: true, + SilenceErrors: true, } logLevelUsage := fmt.Sprintf( @@ -48,6 +50,8 @@ func newCommand(run func(cmd *cobra.Command, args []string)) (*cobra.Command, er StringP("chart-search-root", "c", ".", "directory to search recursively within for charts") cmd.PersistentFlags(). BoolP("dry-run", "d", false, "don't actually create files just print to stdout passed") + cmd.PersistentFlags(). + BoolP("use-refereces", "r", false, "use references instead of embeding dependencies schema") cmd.PersistentFlags(). BoolP("keep-full-comment", "s", false, "If this flag is used, comment won't be cut off if two newlines are found.") cmd.PersistentFlags(). diff --git a/cmd/helm-schema/main.go b/cmd/helm-schema/main.go index 3c2e1b7..1d3fc5f 100644 --- a/cmd/helm-schema/main.go +++ b/cmd/helm-schema/main.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "sort" "sync" "github.com/dadav/helm-schema/pkg/chart" @@ -38,35 +39,50 @@ func searchFiles(startPath, fileName string, queue chan<- string, errs chan<- er } } +type Result struct { + ChartPath string + ValuesPath string + Chart *chart.ChartFile + Schema map[string]interface{} + Errors []error +} + func worker( - dryRun, skipDeps, keepFullComment bool, + dryRun, skipDeps, useRef, keepFullComment bool, valueFileNames []string, outFile string, queue <-chan string, - errs chan<- error, + results chan<- Result, ) { for chartPath := range queue { + result := Result{ChartPath: chartPath} + chartBasePath := filepath.Dir(chartPath) file, err := os.Open(chartPath) if err != nil { - errs <- err + result.Errors = append(result.Errors, err) + results <- result continue } + chart, err := chart.ReadChart(file) if err != nil { - errs <- err + result.Errors = append(result.Errors, err) + results <- result continue } + result.Chart = &chart var valuesPath string var valuesFound bool + errorsWeMaybeCanIgnore := []error{} for _, possibleValueFileName := range valueFileNames { valuesPath = filepath.Join(chartBasePath, possibleValueFileName) _, err := os.Stat(valuesPath) if err != nil { - if !errors.Is(os.ErrNotExist, err) { - errs <- err + if !os.IsNotExist(err) { + errorsWeMaybeCanIgnore = append(errorsWeMaybeCanIgnore, err) } continue } @@ -75,72 +91,73 @@ func worker( } if !valuesFound { + for _, err := range errorsWeMaybeCanIgnore { + result.Errors = append(result.Errors, err) + } + result.Errors = append(result.Errors, errors.New("No values file found.")) + results <- result continue } + result.ValuesPath = valuesPath valuesFile, err := os.Open(valuesPath) if err != nil { - errs <- err + result.Errors = append(result.Errors, err) + results <- result continue } content, err := util.ReadFileAndFixNewline(valuesFile) if err != nil { - errs <- err + result.Errors = append(result.Errors, err) + results <- result continue } var values yaml.Node err = yaml.Unmarshal(content, &values) if err != nil { - log.Fatal(err) + result.Errors = append(result.Errors, err) + results <- result + continue } - schema := schema.YamlToJsonSchema(&values, keepFullComment, nil) + mainSchema := schema.YamlToJsonSchema(&values, keepFullComment, nil) + result.Schema = mainSchema if !skipDeps { for _, dep := range chart.Dependencies { if depName, ok := dep["name"].(string); ok { - schema["properties"].(map[string]interface{})[depName] = map[string]string{ - "title": chart.Name, - "description": chart.Description, - "$ref": fmt.Sprintf("charts/%s/%s", depName, outFile), + if useRef { + mainSchema["properties"].(map[string]interface{})[depName] = map[string]string{ + "title": chart.Name, + "description": chart.Description, + "$ref": fmt.Sprintf("charts/%s/%s", depName, outFile), + } } } } } - jsonStr, err := json.MarshalIndent(schema, "", " ") - - if dryRun { - log.Infof("Printing jsonschema for %s chart", chart.Name) - fmt.Printf("%s\n", jsonStr) - } else { - if err := os.WriteFile(filepath.Join(chartBasePath, outFile), jsonStr, 0644); err != nil { - errs <- err - continue - } - } - + results <- result } } -func exec(_ *cobra.Command, _ []string) { +func exec(cmd *cobra.Command, _ []string) error { configureLogging() chartSearchRoot := viper.GetString("chart-search-root") dryRun := viper.GetBool("dry-run") + useRef := viper.GetBool("use-references") noDeps := viper.GetBool("no-dependencies") keepFullComment := viper.GetBool("keep-full-comment") outFile := viper.GetString("output-file") valueFileNames := viper.GetStringSlice("value-files") - - workersCount := 1 - if !dryRun { - workersCount = runtime.NumCPU() - } + workersCount := runtime.NumCPU() * 2 // 1. Start a producer that searches Chart.yaml and values.yaml files queue := make(chan string) + resultsChan := make(chan Result) + results := []Result{} errs := make(chan error) done := make(chan struct{}) @@ -158,7 +175,16 @@ func exec(_ *cobra.Command, _ []string) { go func() { defer wg.Done() - worker(dryRun, noDeps, keepFullComment, valueFileNames, outFile, queue, errs) + worker( + dryRun, + noDeps, + useRef, + keepFullComment, + valueFileNames, + outFile, + queue, + resultsChan, + ) }() } @@ -167,12 +193,102 @@ loop: select { case err := <-errs: log.Error(err) + case res := <-resultsChan: + results = append(results, res) case <-done: break loop } } + // Sort results if dependencies should be processed + // Need to resolve the dependencies from deepest level to highest + if !noDeps && !useRef { + sort.Slice(results, func(i, j int) bool { + first := results[i] + second := results[j] + + // No dependencies + if len(first.Chart.Dependencies) == 0 { + return true + } + // First is dependency of second + for _, dep := range second.Chart.Dependencies { + if name, ok := dep["name"]; ok { + if name == first.Chart.Name { + return true + } + } + } + + // first comes after second + return false + }) + } + + chartNameToResult := make(map[string]Result) + foundErrors := false + + // process results + for _, result := range results { + // Error handling + if len(result.Errors) > 0 { + foundErrors = true + if result.Chart != nil { + log.Errorf( + "Found %d errors while processing the chart %s (%s)", + len(result.Errors), + result.Chart.Name, + result.ChartPath, + ) + } else { + log.Errorf("Found %d errors while processing the chart %s", len(result.Errors), result.ChartPath) + } + for _, err := range result.Errors { + log.Error(err) + } + continue + } + + // Embed dependencies if needed + if !noDeps && !useRef { + for _, dep := range result.Chart.Dependencies { + if depName, ok := dep["name"].(string); ok { + if dependencyResult, ok := chartNameToResult[depName]; ok { + result.Schema["properties"].(map[string]interface{})[depName] = map[string]interface{}{ + "type": "object", + "title": depName, + "description": dependencyResult.Chart.Description, + "properties": dependencyResult.Schema["properties"], + } + } + } + } + chartNameToResult[result.Chart.Name] = result + } + + // Print to stdout or write to file + jsonStr, err := json.MarshalIndent(result.Schema, "", " ") + if err != nil { + log.Error(err) + continue + } + + if dryRun { + log.Infof("Printing jsonschema for %s chart (%s)", result.Chart.Name, result.ChartPath) + fmt.Printf("%s\n", jsonStr) + } else { + chartBasePath := filepath.Dir(result.ChartPath) + if err := os.WriteFile(filepath.Join(chartBasePath, outFile), jsonStr, 0644); err != nil { + errs <- err + continue + } + } + } + if foundErrors { + return errors.New("foo") + } + return nil } func main() { @@ -183,7 +299,6 @@ func main() { } if err := command.Execute(); err != nil { - log.Errorf("Failed to start the CLI: %s", err) os.Exit(1) } } diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index 780fc0a..69627a1 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -387,6 +387,16 @@ func YamlToJsonSchema( keepFullComment, &requiredProperties, ) + + if _, ok := schema["properties"].(map[string]interface{})["global"]; !ok { + // global key must be present, otherwise helm lint will fail + schema["properties"].(map[string]interface{})["global"] = map[string]interface{}{ + "type": "object", + "title": "global", + "description": "Global values are values that can be accessed from any chart or subchart by exactly the same name.", + } + } + if len(requiredProperties) > 0 { schema["required"] = requiredProperties }