From 794abd28b7f62fd02a48a6ae136ace486c9da4e9 Mon Sep 17 00:00:00 2001 From: Brahm Lower Date: Fri, 6 Feb 2026 22:56:00 -0800 Subject: [PATCH] modeline support --- README.md | 35 +++++++ cmd/helm-values/internal/config/modeline.go | 57 +++++++++++ cmd/helm-values/main.go | 31 ++++++ go.mod | 2 +- pkg/charts/chart.go | 15 ++- pkg/helm/cache.go | 69 +++++++++++++ pkg/helm/chart_details.go | 33 +++++++ pkg/helm/index.go | 100 +++++++++++++++++++ pkg/modeline/config.go | 10 ++ pkg/modeline/file_modeline_manager.go | 58 +++++++++++ pkg/modeline/modeline.go | 101 ++++++++++++++++++++ pkg/modeline/plan.go | 23 +++++ pkg/schema/modeline.go | 77 ++++++++------- pkg/schema/schema.go | 8 +- 14 files changed, 576 insertions(+), 43 deletions(-) create mode 100644 cmd/helm-values/internal/config/modeline.go create mode 100644 pkg/helm/cache.go create mode 100644 pkg/helm/chart_details.go create mode 100644 pkg/helm/index.go create mode 100644 pkg/modeline/config.go create mode 100644 pkg/modeline/file_modeline_manager.go create mode 100644 pkg/modeline/modeline.go create mode 100644 pkg/modeline/plan.go diff --git a/README.md b/README.md index 7b6bba3..42de8f8 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,40 @@ Flags: --use-default uses default template unless a custom template is present (default true) ``` +## Values Modeline + +The modeline subcommand is useful for setting a chart's schema in the yaml modeline of a values file. + +For instance, running the following: + +``` +helm values modeline brahmlower-kiwix/kiwix ./kiwix-values.yaml --version 0.1.1 +``` + +results in the following line being added to the top of the document: + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/brahmlower/helm-kiwix/refs/tags/kiwix-0.1.1/charts/kiwix/values.schema.json + +... +``` + +Options: + +``` +Add yaml-language-server modeline to values file + +Usage: + helm-values modeline [flags] chart_ref [values_file] + +Flags: + -f, --force replace existing modeline + -h, --help help for modeline + --log-level string log level (debug, info, warn, error, fatal, panic) (default "warn") + -p, --parents create parent directories if they don't exist + --version string chart version (for remote charts) +``` + ## Schema Comments This plugin simplifies schema markup in the values.yaml comments. @@ -419,6 +453,7 @@ and [helm-docs](https://github.com/norwoodj/helm-docs). - [x] Built-in plugin update mechanism - 0.3.0 - [x] Pre-Commit Hook support + - [x] Values modeline support - [ ] Schema Generation - [ ] Warn on ignored jsonschema property (in cases of $ref/$schema usage) - [ ] Json-Schema Draft 7 support? diff --git a/cmd/helm-values/internal/config/modeline.go b/cmd/helm-values/internal/config/modeline.go new file mode 100644 index 0000000..6bc3543 --- /dev/null +++ b/cmd/helm-values/internal/config/modeline.go @@ -0,0 +1,57 @@ +package config + +import ( + "helmvalues/pkg/modeline" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func NewModelineConfig() *ModelineConfig { + cfg := standardViper() + + return &ModelineConfig{cfg} +} + +type ModelineConfig struct { + *viper.Viper +} + +func (c *ModelineConfig) LogLevel() (logrus.Level, error) { + return logrus.ParseLevel(c.GetString("log-level")) +} + +func (c *ModelineConfig) UpdateLogger(logger *logrus.Logger) error { + level, err := c.LogLevel() + if err != nil { + return err + } + + logger.SetLevel(level) + return nil +} + +func (c *ModelineConfig) BindFlags(cmd *cobra.Command) { + cmd.Flags().BoolP("parents", "p", false, "create parent directories if they don't exist") + c.BindPFlag("parents", cmd.Flags().Lookup("parents")) + c.BindEnv("parents") + + cmd.Flags().String("version", "", "chart version (for remote charts)") + c.BindPFlag("version", cmd.Flags().Lookup("version")) + c.BindEnv("version") + + cmd.Flags().String("log-level", "warn", "log level (debug, info, warn, error, fatal, panic)") + c.BindPFlag("log-level", cmd.Flags().Lookup("log-level")) + c.BindEnv("log-level") +} + +func (c *ModelineConfig) ToPackageConfig(chartRef string, targetFile string) *modeline.Config { + return &modeline.Config{ + ChartRef: chartRef, + ChartVersion: c.GetString("version"), + TargetFile: targetFile, + CreateParents: c.GetBool("parents"), + PartialModeline: modeline.NewPartialModeline("yaml-language-server", "$schema"), + } +} diff --git a/cmd/helm-values/main.go b/cmd/helm-values/main.go index f936d95..1f9edba 100644 --- a/cmd/helm-values/main.go +++ b/cmd/helm-values/main.go @@ -8,6 +8,7 @@ import ( "helmvalues/cmd/helm-values/internal" "helmvalues/cmd/helm-values/internal/config" "helmvalues/pkg/docs" + "helmvalues/pkg/modeline" "helmvalues/pkg/schema" "github.com/sirupsen/logrus" @@ -46,6 +47,7 @@ func Program(logger *logrus.Logger) *cobra.Command { cmd.AddCommand(CommandSchema(logger, generationGroup)) cmd.AddCommand(CommandDocs(logger, generationGroup)) cmd.AddCommand(CommandPreCommit(logger)) + cmd.AddCommand(CommandModeline(logger, generationGroup)) cmd.AddCommand(CommandUpdate(logger)) cmd.AddCommand(CommandVersion(logger)) return cmd @@ -102,6 +104,35 @@ func CommandDocs(logger *logrus.Logger, group *cobra.Group) *cobra.Command { return cmd } +func CommandModeline(logger *logrus.Logger, group *cobra.Group) *cobra.Command { + cfg := config.NewModelineConfig() + + cmd := &cobra.Command{ + Use: "modeline [flags] chart_ref [values_file]", + Short: "Add yaml-language-server modeline to values file", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := cfg.UpdateLogger(logger); err != nil { + return err + } + + chartRef := args[0] + valuesFile := "" + if len(args) > 1 { + valuesFile = args[1] + } + + modelineCfg := cfg.ToPackageConfig(chartRef, valuesFile) + return modeline.WriteModeline(logger, modelineCfg) + }, + GroupID: group.ID, + } + + cfg.BindFlags(cmd) + + return cmd +} + func CommandUpdate(logger *logrus.Logger) *cobra.Command { cmd := &cobra.Command{ Use: "update", diff --git a/go.mod b/go.mod index b20ccb3..6d16448 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module helmvalues go 1.24.2 require ( + github.com/Masterminds/semver/v3 v3.4.0 github.com/Masterminds/sprig/v3 v3.3.0 github.com/elliotchance/orderedmap/v3 v3.1.0 github.com/samber/lo v1.52.0 @@ -17,7 +18,6 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect diff --git a/pkg/charts/chart.go b/pkg/charts/chart.go index 21a0097..f207156 100644 --- a/pkg/charts/chart.go +++ b/pkg/charts/chart.go @@ -66,6 +66,17 @@ func (p *Chart) ReadmeRstTemplateFilePath() string { } type ChartDetails struct { - Name string `yaml:"name"` - Description string `yaml:"description"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Version string `yaml:"version"` + Annotations map[string]string `yaml:"annotations"` +} + +func (d *ChartDetails) ValuesSchema() string { + schemaURL, ok := d.Annotations["values-schema"] + if !ok || schemaURL == "" { + return "" + } + + return schemaURL } diff --git a/pkg/helm/cache.go b/pkg/helm/cache.go new file mode 100644 index 0000000..6163f7e --- /dev/null +++ b/pkg/helm/cache.go @@ -0,0 +1,69 @@ +package helm + +import ( + "fmt" + "helmvalues/pkg/charts" + "os" + "path/filepath" + "strings" + + "github.com/sirupsen/logrus" +) + +func GetCacheHome() (string, error) { + cacheHome := os.Getenv("HELM_CACHE_HOME") + if cacheHome == "" { + return "", fmt.Errorf("HELM_CACHE_HOME environment variable is not set") + } + return cacheHome, nil +} + +func FindRepositoryIndex(logger *logrus.Logger, repoName string) (*Index, error) { + cacheHome, err := GetCacheHome() + if err != nil { + return nil, err + } + + indexPath := filepath.Join(cacheHome, "repository", repoName+"-index.yaml") + + if _, err := os.Stat(indexPath); err != nil { + return nil, err + } + + return LoadIndex(indexPath) +} + +// GetChartFromCache retrieves chart information from the Helm cache +func GetChartFromCache(logger *logrus.Logger, chartRef string, version string) (*charts.ChartDetails, error) { + // Parse chart reference + parts := strings.SplitN(chartRef, "/", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid chart reference: %s", chartRef) + } + repoName := parts[0] + chartName := parts[1] + + // Find the repository index + index, err := FindRepositoryIndex(logger, repoName) + if err != nil { + return nil, err + } + + // Get the specific version or latest + var chartVersion *charts.ChartDetails + if version != "" { + chartVersion, err = index.GetVersion(chartName, version) + if err != nil { + return nil, err + } + logger.Infof("Using chart %s version %s", chartName, version) + } else { + chartVersion, err = index.GetLatestVersion(chartName) + if err != nil { + return nil, err + } + logger.Infof("Using latest stable version of %s: %s", chartName, chartVersion.Version) + } + + return chartVersion, nil +} diff --git a/pkg/helm/chart_details.go b/pkg/helm/chart_details.go new file mode 100644 index 0000000..5ab8a84 --- /dev/null +++ b/pkg/helm/chart_details.go @@ -0,0 +1,33 @@ +package helm + +import ( + "fmt" + "helmvalues/pkg/charts" + "path/filepath" + "strings" + + "github.com/sirupsen/logrus" +) + +func ChartDetailsFromRef(chartRef string, version string) (*charts.ChartDetails, error) { + refParts := strings.Split(chartRef, "/") + if len(refParts) == 2 { + return GetChartFromCache(logrus.StandardLogger(), chartRef, version) + } + + return chartDetailsFromPath(chartRef) +} + +func chartDetailsFromPath(chartRef string) (*charts.ChartDetails, error) { + absPath, err := filepath.Abs(chartRef) + if err != nil { + return nil, fmt.Errorf("failed to resolve chart path: %w", err) + } + + chart, err := charts.NewChart(absPath) + if err != nil { + return nil, fmt.Errorf("failed to load chart: %w", err) + } + + return chart.Details, nil +} diff --git a/pkg/helm/index.go b/pkg/helm/index.go new file mode 100644 index 0000000..db6a062 --- /dev/null +++ b/pkg/helm/index.go @@ -0,0 +1,100 @@ +package helm + +import ( + "fmt" + "helmvalues/pkg/charts" + "os" + "sort" + + "github.com/Masterminds/semver/v3" + "go.yaml.in/yaml/v4" +) + +type Index struct { + Entries map[string][]*charts.ChartDetails `yaml:"entries"` +} + +// LoadIndex loads and parses a Helm index.yaml file +func LoadIndex(path string) (*Index, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read index file: %w", err) + } + + var index Index + if err := yaml.Unmarshal(data, &index); err != nil { + return nil, fmt.Errorf("failed to parse index file: %w", err) + } + + return &index, nil +} + +// FindChart finds chart versions by name in the index +func (i *Index) FindChart(chartName string) ([]*charts.ChartDetails, error) { + versions, ok := i.Entries[chartName] + if !ok { + return nil, fmt.Errorf("chart %q not found in index", chartName) + } + + if len(versions) == 0 { + return nil, fmt.Errorf("no versions found for chart %q", chartName) + } + + return versions, nil +} + +// GetVersion finds a specific version of a chart +func (i *Index) GetVersion(chartName, version string) (*charts.ChartDetails, error) { + versions, err := i.FindChart(chartName) + if err != nil { + return nil, err + } + + for _, v := range versions { + if v.Version == version { + return v, nil + } + } + + return nil, fmt.Errorf("version %q not found for chart %q", version, chartName) +} + +// GetLatestVersion finds the latest stable version of a chart +// Stable means non-prerelease versions (no -alpha, -beta, -rc suffixes) +func (i *Index) GetLatestVersion(chartName string) (*charts.ChartDetails, error) { + versions, err := i.FindChart(chartName) + if err != nil { + return nil, err + } + + // Parse and sort versions + semvers := []*semver.Version{} + versionMap := map[string]*charts.ChartDetails{} + + for _, v := range versions { + sv, err := semver.NewVersion(v.Version) + if err != nil { + // Skip invalid semver versions + continue + } + semvers = append(semvers, sv) + versionMap[v.Version] = v + } + + if len(semvers) == 0 { + return nil, fmt.Errorf("no valid semver versions found for chart %q", chartName) + } + + // Sort in descending order + sort.Sort(sort.Reverse(semver.Collection(semvers))) + + // Find the first stable (non-prerelease) version + for _, sv := range semvers { + if sv.Prerelease() == "" { + return versionMap[sv.Original()], nil + } + } + + // If no stable version found, return the latest version regardless + return versionMap[semvers[0].Original()], nil +} diff --git a/pkg/modeline/config.go b/pkg/modeline/config.go new file mode 100644 index 0000000..60dd79b --- /dev/null +++ b/pkg/modeline/config.go @@ -0,0 +1,10 @@ +package modeline + +// Config holds configuration for the modeline command +type Config struct { + ChartVersion string + ChartRef string + TargetFile string + CreateParents bool + PartialModeline PartialModeline +} diff --git a/pkg/modeline/file_modeline_manager.go b/pkg/modeline/file_modeline_manager.go new file mode 100644 index 0000000..7ade187 --- /dev/null +++ b/pkg/modeline/file_modeline_manager.go @@ -0,0 +1,58 @@ +package modeline + +import ( + "fmt" + "os" + "slices" + "strings" +) + +func NewFileModelineManager(filepath string) (*FileModelineManager, error) { + manager := &FileModelineManager{ + filepath: filepath, + } + + if _, err := os.Stat(filepath); err == nil { + manager.exists = true + + data, err := os.ReadFile(filepath) + if err != nil { + return nil, err + } + + manager.content = string(data) + } + + return manager, nil +} + +type FileModelineManager struct { + filepath string + exists bool + content string +} + +func (m *FileModelineManager) SetModeline(modeline *Modeline) { + yamlModelinePrefix := fmt.Sprintf("# %s", modeline.ProgramAndKey()) + yamlModeline := fmt.Sprintf("# %s", modeline.String()) + + found := false + content := strings.Split(m.content, "\n") + for i, line := range content { + if strings.HasPrefix(line, yamlModelinePrefix) { + content[i] = yamlModeline + found = true + break + } + } + + if !found { + content = slices.Insert(content, 0, yamlModeline) + } + + m.content = strings.Join(content, "\n") +} + +func (m *FileModelineManager) Write(createParents bool) error { + return os.WriteFile(m.filepath, []byte(m.content), 0644) +} diff --git a/pkg/modeline/modeline.go b/pkg/modeline/modeline.go new file mode 100644 index 0000000..88fc8a0 --- /dev/null +++ b/pkg/modeline/modeline.go @@ -0,0 +1,101 @@ +package modeline + +import ( + "fmt" + "helmvalues/pkg/helm" + + "github.com/sirupsen/logrus" +) + +func NewPartialModeline(program, key string) PartialModeline { + return PartialModeline{ + Program: program, + Key: key, + } +} + +type PartialModeline struct { + Program string + Key string +} + +func (m PartialModeline) ProgramAndKey() string { + return fmt.Sprintf("%s: %s=", m.Program, m.Key) +} + +func (m PartialModeline) ModelineWithValue(value string) *Modeline { + return &Modeline{ + PartialModeline: m, + Value: value, + } +} + +func (m PartialModeline) Matches(pm *PartialModeline) bool { + return m.Program == pm.Program && m.Key == pm.Key +} + +func ParseModeline(line string) (*Modeline, error) { + var program, key, value string + n, err := fmt.Sscanf(line, "%s: %s=%s", &program, &key, &value) + if err != nil { + return nil, fmt.Errorf("line does not match modeline format: %w", err) + } + if n != 3 { + return nil, fmt.Errorf("line does not match modeline format: expected 3 parts, got %d", n) + } + + return NewModeline(program, key, value), nil +} + +func NewModeline(program, key, value string) *Modeline { + return &Modeline{ + PartialModeline: PartialModeline{ + Program: program, + Key: key, + }, + Value: value, + } +} + +type Modeline struct { + PartialModeline + Value string +} + +func (m Modeline) String() string { + return fmt.Sprintf("%s%s", m.ProgramAndKey(), m.Value) +} + +func ValuesSchemaForChart(chartRef string, version string) (string, error) { + chartDetails, err := helm.ChartDetailsFromRef(chartRef, version) + if err != nil { + return "", err + } + + schemaURL := chartDetails.ValuesSchema() + if schemaURL == "" { + return "", fmt.Errorf("chart does not have annotations.values-schema defined") + } + + return schemaURL, nil +} + +func WriteModeline(logger *logrus.Logger, cfg *Config) error { + plan := NewPlan(cfg) + + schemaURL, err := plan.ValuesSchemaForChart() + if err != nil { + return fmt.Errorf("failed to get values schema for chart: %w", err) + } + + modeline := plan.Modeline(schemaURL) + + fm, err := plan.FileManager() + if err != nil { + return fmt.Errorf("failed to create file manager: %w", err) + } + + fm.SetModeline(modeline) + + return fm.Write(cfg.CreateParents) +} diff --git a/pkg/modeline/plan.go b/pkg/modeline/plan.go new file mode 100644 index 0000000..4912756 --- /dev/null +++ b/pkg/modeline/plan.go @@ -0,0 +1,23 @@ +package modeline + +func NewPlan(cfg *Config) *Plan { + return &Plan{ + cfg: cfg, + } +} + +type Plan struct { + cfg *Config +} + +func (p *Plan) ValuesSchemaForChart() (string, error) { + return ValuesSchemaForChart(p.cfg.ChartRef, p.cfg.ChartVersion) +} + +func (p *Plan) Modeline(schema string) *Modeline { + return p.cfg.PartialModeline.ModelineWithValue(schema) +} + +func (p *Plan) FileManager() (*FileModelineManager, error) { + return NewFileModelineManager(p.cfg.TargetFile) +} diff --git a/pkg/schema/modeline.go b/pkg/schema/modeline.go index abc2050..a393fbe 100644 --- a/pkg/schema/modeline.go +++ b/pkg/schema/modeline.go @@ -3,60 +3,59 @@ package schema import ( "fmt" "helmvalues/pkg/charts" + "helmvalues/pkg/modeline" "io" "os" - "path/filepath" - "strings" "github.com/sirupsen/logrus" ) -const YAML_MODELINE = "yaml-language-server" -const MODELINE_PARAM = "$schema=" - -func renderedModeline(schemaPath string) string { - return fmt.Sprintf( - "# %s: %s%s\n", - YAML_MODELINE, - MODELINE_PARAM, - filepath.Base(schemaPath), - ) -} - -func WriteSchemaModeline(logger *logrus.Logger, chart *charts.Chart, dryRun bool) error { - valuesFilePath := chart.ValuesFilePath() - - if dryRun { - logger.Infof("schema: %s: dry-run enabled, skipping modeline write to %s", chart.Details.Name, valuesFilePath) - return nil - } - - f, err := os.OpenFile(valuesFilePath, os.O_RDONLY, 0644) +func NewModelineWriter(filepath string) (*ModelineWriter, error) { + f, err := os.OpenFile(filepath, os.O_RDONLY, 0644) if err != nil { - return err + return nil, err } defer f.Close() - contentB, err := io.ReadAll(f) + content, err := io.ReadAll(f) if err != nil { - return err + return nil, err } - content := string(contentB) - var updatedContent string - - modelineStart := strings.Index(content, fmt.Sprintf("# %s:", YAML_MODELINE)) - if modelineStart == -1 { - // write an extra newline when inserting the modeline for the first time - updatedContent = renderedModeline(chart.SchemaFilePath()) + "\n" + content - } else { - eolIdx := strings.Index(content[modelineStart:], "\n") - updatedContent = content[:modelineStart] + renderedModeline(chart.SchemaFilePath()) + content[modelineStart+eolIdx+1:] + + ml := &ModelineWriter{ + filepath: filepath, + content: string(content), } - err = os.WriteFile(valuesFilePath, []byte(updatedContent), 0644) + return ml, nil +} + +type ModelineWriter struct { + filepath string + content string +} + +func (w *ModelineWriter) SetModeline(value *modeline.Modeline) { + w.content = value.String() + "\n" + w.content +} + +func (w *ModelineWriter) WriteToFile() error { + return os.WriteFile(w.filepath, []byte(w.content), 0644) +} + +func WriteSchemaModeline(logger *logrus.Logger, chart *charts.Chart, valuesPath string, dryRun bool) error { + mlWriter, err := NewModelineWriter(valuesPath) if err != nil { - return err + return fmt.Errorf("failed to read values file: %w", err) + } + + ml := modeline.NewModeline("yaml-language-server", "$schema", chart.SchemaFilePath()) + mlWriter.SetModeline(ml) + + if dryRun { + logger.Infof("schema: %s: dry-run enabled, skipping modeline write to %s", chart.Details.Name, valuesPath) + return nil } - return nil + return mlWriter.WriteToFile() } diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index de2c93c..61138f3 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -40,7 +40,13 @@ func GenerateSchema(logger *logrus.Logger, cfg *Config, chartDirs []string) erro if cfg.WriteModeline { logger.Debugf("schema: %s: writing modeline", plan.Chart().Details.Name) - if err := WriteSchemaModeline(logger, plan.Chart(), plan.DryRun()); err != nil { + err := WriteSchemaModeline( + logger, + plan.Chart(), + plan.Chart().ValuesFilePath(), + plan.DryRun(), + ) + if err != nil { logger.Error(err.Error()) return nil }