feat(create): add --chart-api-version flag (when HELM_EXPERIMENTAL_CHART_V3 env var is set) (#31592)

* feat(create): add hidden --chart-api-version flag

Add --chart-api-version flag to helm create command to allow selecting
chart API version (v2 or v3) when creating a new chart.

- Default is v2 (existing behavior unchanged)
- v3 uses internal/chart/v3 scaffold generator
- Invalid versions return clear error message
- Works with --starter flag

Signed-off-by: Evans Mungai <mbuevans@gmail.com>

* Add HELM_EXPERIMENTAL_CHART_V3 feature gate to create command

Signed-off-by: Evans Mungai <mbuevans@gmail.com>

* make chartv3 private and use loader to load the chart

Signed-off-by: Evans Mungai <mbuevans@gmail.com>

* Hide chart-api-version flag until chart v3 is officially released

Signed-off-by: Evans Mungai <mbuevans@gmail.com>

* Conditionally hide the --chart-api-version flag if chart v3 is not enabled

Signed-off-by: Evans Mungai <mbuevans@gmail.com>

* Add internal gates package for internal feature gates

Signed-off-by: Evans Mungai <mbuevans@gmail.com>

* Add doc for internal/gates package

Signed-off-by: Evans Mungai <mbuevans@gmail.com>

---------

Signed-off-by: Evans Mungai <mbuevans@gmail.com>
pull/31601/merge
Evans Mungai 7 days ago committed by GitHub
parent fbb8de54be
commit 5aac32077f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,19 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package gates contains internal feature gates that can be used to enable or disable experimental features.
// This is a separate internal package instead of using the pkg/gates package to avoid circular dependencies.
package gates

@ -0,0 +1,21 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gates
import "helm.sh/helm/v4/pkg/gates"
// ChartV3 is the feature gate for chart API version v3.
const ChartV3 gates.Gate = "HELM_EXPERIMENTAL_CHART_V3"

@ -23,6 +23,9 @@ import (
"github.com/spf13/cobra"
chartv3 "helm.sh/helm/v4/internal/chart/v3"
chartutilv3 "helm.sh/helm/v4/internal/chart/v3/util"
"helm.sh/helm/v4/internal/gates"
chart "helm.sh/helm/v4/pkg/chart/v2"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/cmd/require"
@ -51,9 +54,10 @@ will be overwritten, but other files will be left alone.
`
type createOptions struct {
starter string // --starter
name string
starterDir string
starter string // --starter
name string
starterDir string
chartAPIVersion string // --chart-api-version
}
func newCreateCmd(out io.Writer) *cobra.Command {
@ -81,12 +85,32 @@ func newCreateCmd(out io.Writer) *cobra.Command {
}
cmd.Flags().StringVarP(&o.starter, "starter", "p", "", "the name or absolute path to Helm starter scaffold")
cmd.Flags().StringVar(&o.chartAPIVersion, "chart-api-version", chart.APIVersionV2, "chart API version to use (v2 or v3)")
if !gates.ChartV3.IsEnabled() {
cmd.Flags().MarkHidden("chart-api-version")
}
return cmd
}
func (o *createOptions) run(out io.Writer) error {
fmt.Fprintf(out, "Creating %s\n", o.name)
switch o.chartAPIVersion {
case chart.APIVersionV2, "":
return o.createV2Chart(out)
case chartv3.APIVersionV3:
if !gates.ChartV3.IsEnabled() {
return gates.ChartV3.Error()
}
return o.createV3Chart(out)
default:
return fmt.Errorf("unsupported chart API version: %s (supported: v2, v3)", o.chartAPIVersion)
}
}
func (o *createOptions) createV2Chart(out io.Writer) error {
chartname := filepath.Base(o.name)
cfile := &chart.Metadata{
Name: chartname,
@ -111,3 +135,29 @@ func (o *createOptions) run(out io.Writer) error {
_, err := chartutil.Create(chartname, filepath.Dir(o.name))
return err
}
func (o *createOptions) createV3Chart(out io.Writer) error {
chartname := filepath.Base(o.name)
cfile := &chartv3.Metadata{
Name: chartname,
Description: "A Helm chart for Kubernetes",
Type: "application",
Version: "0.1.0",
AppVersion: "0.1.0",
APIVersion: chartv3.APIVersionV3,
}
if o.starter != "" {
// Create from the starter
lstarter := filepath.Join(o.starterDir, o.starter)
// If path is absolute, we don't want to prefix it with helm starters folder
if filepath.IsAbs(o.starter) {
lstarter = o.starter
}
return chartutilv3.CreateFrom(cfile, filepath.Dir(o.name), lstarter)
}
chartutilv3.Stderr = out
_, err := chartutilv3.Create(chartname, filepath.Dir(o.name))
return err
}

@ -22,9 +22,13 @@ import (
"path/filepath"
"testing"
chartv3 "helm.sh/helm/v4/internal/chart/v3"
chartutilv3 "helm.sh/helm/v4/internal/chart/v3/util"
"helm.sh/helm/v4/internal/gates"
"helm.sh/helm/v4/internal/test/ensure"
chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/chart/v2/loader"
chart "helm.sh/helm/v4/pkg/chart"
chartloader "helm.sh/helm/v4/pkg/chart/loader"
chartv2 "helm.sh/helm/v4/pkg/chart/v2"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/helmpath"
)
@ -46,41 +50,171 @@ func TestCreateCmd(t *testing.T) {
t.Fatalf("chart is not directory")
}
c, err := loader.LoadDir(cname)
c, err := chartloader.LoadDir(cname)
if err != nil {
t.Fatal(err)
}
if c.Name() != cname {
t.Errorf("Expected %q name, got %q", cname, c.Name())
acc, err := chart.NewAccessor(c)
if err != nil {
t.Fatal(err)
}
if acc.Name() != cname {
t.Errorf("Expected %q name, got %q", cname, acc.Name())
}
metadata := acc.MetadataAsMap()
apiVersion, ok := metadata["APIVersion"].(string)
if !ok {
t.Fatal("APIVersion not found in metadata")
}
if c.Metadata.APIVersion != chart.APIVersionV2 {
t.Errorf("Wrong API version: %q", c.Metadata.APIVersion)
if apiVersion != chartv2.APIVersionV2 {
t.Errorf("Wrong API version: %q", apiVersion)
}
}
func TestCreateStarterCmd(t *testing.T) {
tests := []struct {
name string
chartAPIVersion string
useAbsolutePath bool
expectedVersion string
}{
{
name: "v2 with relative starter path",
chartAPIVersion: "",
useAbsolutePath: false,
expectedVersion: chartv2.APIVersionV2,
},
{
name: "v2 with absolute starter path",
chartAPIVersion: "",
useAbsolutePath: true,
expectedVersion: chartv2.APIVersionV2,
},
{
name: "v3 with relative starter path",
chartAPIVersion: "v3",
useAbsolutePath: false,
expectedVersion: chartv3.APIVersionV3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Chdir(t.TempDir())
ensure.HelmHome(t)
defer resetEnv()()
// Enable feature gate for v3 charts
if tt.chartAPIVersion == "v3" {
t.Setenv(string(gates.ChartV3), "1")
}
cname := "testchart"
// Create a starter using the appropriate chartutil
starterchart := helmpath.DataPath("starters")
os.MkdirAll(starterchart, 0o755)
var err error
var dest string
if tt.chartAPIVersion == "v3" {
dest, err = chartutilv3.Create("starterchart", starterchart)
} else {
dest, err = chartutil.Create("starterchart", starterchart)
}
if err != nil {
t.Fatalf("Could not create chart: %s", err)
}
t.Logf("Created %s", dest)
tplpath := filepath.Join(starterchart, "starterchart", "templates", "foo.tpl")
if err := os.WriteFile(tplpath, []byte("test"), 0o644); err != nil {
t.Fatalf("Could not write template: %s", err)
}
// Build the command
starterArg := "starterchart"
if tt.useAbsolutePath {
starterArg = filepath.Join(starterchart, "starterchart")
}
cmd := fmt.Sprintf("create --starter=%s", starterArg)
if tt.chartAPIVersion == "v3" {
cmd += fmt.Sprintf(" --chart-api-version=%s", chartv3.APIVersionV3)
} else {
cmd += fmt.Sprintf(" --chart-api-version=%s", chartv2.APIVersionV2)
}
cmd += " " + cname
// Run create
if _, _, err := executeActionCommand(cmd); err != nil {
t.Fatalf("Failed to run create: %s", err)
}
// Test that the chart is there
if fi, err := os.Stat(cname); err != nil {
t.Fatalf("no chart directory: %s", err)
} else if !fi.IsDir() {
t.Fatalf("chart is not directory")
}
// Load and verify the chart
c, err := chartloader.LoadDir(cname)
if err != nil {
t.Fatal(err)
}
acc, err := chart.NewAccessor(c)
if err != nil {
t.Fatal(err)
}
chartName := acc.Name()
metadata := acc.MetadataAsMap()
apiVersion, ok := metadata["APIVersion"].(string)
if !ok {
t.Fatal("APIVersion not found in metadata")
}
var templates []string
for _, tpl := range acc.Templates() {
templates = append(templates, tpl.Name)
}
if chartName != cname {
t.Errorf("Expected %q name, got %q", cname, chartName)
}
if apiVersion != tt.expectedVersion {
t.Errorf("Wrong API version: expected %q, got %q", tt.expectedVersion, apiVersion)
}
// Verify custom template exists
found := false
for _, name := range templates {
if name == "templates/foo.tpl" {
found = true
break
}
}
if !found {
t.Error("Did not find foo.tpl")
}
})
}
}
func TestCreateFileCompletion(t *testing.T) {
checkFileCompletion(t, "create", true)
checkFileCompletion(t, "create myname", false)
}
func TestCreateCmdChartAPIVersionV2(t *testing.T) {
t.Chdir(t.TempDir())
ensure.HelmHome(t)
cname := "testchart"
defer resetEnv()()
// Create a starter.
starterchart := helmpath.DataPath("starters")
os.MkdirAll(starterchart, 0o755)
if dest, err := chartutil.Create("starterchart", starterchart); err != nil {
t.Fatalf("Could not create chart: %s", err)
} else {
t.Logf("Created %s", dest)
}
tplpath := filepath.Join(starterchart, "starterchart", "templates", "foo.tpl")
if err := os.WriteFile(tplpath, []byte("test"), 0o644); err != nil {
t.Fatalf("Could not write template: %s", err)
}
// Run a create
if _, _, err := executeActionCommand(fmt.Sprintf("create --starter=starterchart %s", cname)); err != nil {
t.Errorf("Failed to run create: %s", err)
return
// Run a create with explicit v2
if _, _, err := executeActionCommand("create --chart-api-version=v2 " + cname); err != nil {
t.Fatalf("Failed to run create: %s", err)
}
// Test that the chart is there
@ -90,62 +224,38 @@ func TestCreateStarterCmd(t *testing.T) {
t.Fatalf("chart is not directory")
}
c, err := loader.LoadDir(cname)
c, err := chartloader.LoadDir(cname)
if err != nil {
t.Fatal(err)
}
if c.Name() != cname {
t.Errorf("Expected %q name, got %q", cname, c.Name())
}
if c.Metadata.APIVersion != chart.APIVersionV2 {
t.Errorf("Wrong API version: %q", c.Metadata.APIVersion)
acc, err := chart.NewAccessor(c)
if err != nil {
t.Fatal(err)
}
expectedNumberOfTemplates := 10
if l := len(c.Templates); l != expectedNumberOfTemplates {
t.Errorf("Expected %d templates, got %d", expectedNumberOfTemplates, l)
if acc.Name() != cname {
t.Errorf("Expected %q name, got %q", cname, acc.Name())
}
found := false
for _, tpl := range c.Templates {
if tpl.Name == "templates/foo.tpl" {
found = true
if data := string(tpl.Data); data != "test" {
t.Errorf("Expected template 'test', got %q", data)
}
}
metadata := acc.MetadataAsMap()
apiVersion, ok := metadata["APIVersion"].(string)
if !ok {
t.Fatal("APIVersion not found in metadata")
}
if !found {
t.Error("Did not find foo.tpl")
if apiVersion != chartv2.APIVersionV2 {
t.Errorf("Wrong API version: expected %q, got %q", chartv2.APIVersionV2, apiVersion)
}
}
func TestCreateStarterAbsoluteCmd(t *testing.T) {
func TestCreateCmdChartAPIVersionV3(t *testing.T) {
t.Chdir(t.TempDir())
defer resetEnv()()
ensure.HelmHome(t)
t.Setenv(string(gates.ChartV3), "1")
cname := "testchart"
// Create a starter.
starterchart := helmpath.DataPath("starters")
os.MkdirAll(starterchart, 0o755)
if dest, err := chartutil.Create("starterchart", starterchart); err != nil {
t.Fatalf("Could not create chart: %s", err)
} else {
t.Logf("Created %s", dest)
}
tplpath := filepath.Join(starterchart, "starterchart", "templates", "foo.tpl")
if err := os.WriteFile(tplpath, []byte("test"), 0o644); err != nil {
t.Fatalf("Could not write template: %s", err)
}
starterChartPath := filepath.Join(starterchart, "starterchart")
// Run a create
if _, _, err := executeActionCommand(fmt.Sprintf("create --starter=%s %s", starterChartPath, cname)); err != nil {
t.Errorf("Failed to run create: %s", err)
return
// Run a create with v3
if _, _, err := executeActionCommand("create --chart-api-version=v3 " + cname); err != nil {
t.Fatalf("Failed to run create: %s", err)
}
// Test that the chart is there
@ -155,38 +265,42 @@ func TestCreateStarterAbsoluteCmd(t *testing.T) {
t.Fatalf("chart is not directory")
}
c, err := loader.LoadDir(cname)
c, err := chartloader.LoadDir(cname)
if err != nil {
t.Fatal(err)
}
if c.Name() != cname {
t.Errorf("Expected %q name, got %q", cname, c.Name())
}
if c.Metadata.APIVersion != chart.APIVersionV2 {
t.Errorf("Wrong API version: %q", c.Metadata.APIVersion)
acc, err := chart.NewAccessor(c)
if err != nil {
t.Fatal(err)
}
expectedNumberOfTemplates := 10
if l := len(c.Templates); l != expectedNumberOfTemplates {
t.Errorf("Expected %d templates, got %d", expectedNumberOfTemplates, l)
if acc.Name() != cname {
t.Errorf("Expected %q name, got %q", cname, acc.Name())
}
found := false
for _, tpl := range c.Templates {
if tpl.Name == "templates/foo.tpl" {
found = true
if data := string(tpl.Data); data != "test" {
t.Errorf("Expected template 'test', got %q", data)
}
}
metadata := acc.MetadataAsMap()
apiVersion, ok := metadata["APIVersion"].(string)
if !ok {
t.Fatal("APIVersion not found in metadata")
}
if !found {
t.Error("Did not find foo.tpl")
if apiVersion != chartv3.APIVersionV3 {
t.Errorf("Wrong API version: expected %q, got %q", chartv3.APIVersionV3, apiVersion)
}
}
func TestCreateFileCompletion(t *testing.T) {
checkFileCompletion(t, "create", true)
checkFileCompletion(t, "create myname", false)
func TestCreateCmdInvalidChartAPIVersion(t *testing.T) {
t.Chdir(t.TempDir())
ensure.HelmHome(t)
cname := "testchart"
// Run a create with invalid version
_, _, err := executeActionCommand("create --chart-api-version=v1 " + cname)
if err == nil {
t.Fatal("Expected error for invalid API version, got nil")
}
expectedErr := "unsupported chart API version: v1 (supported: v2, v3)"
if err.Error() != expectedErr {
t.Errorf("Expected error %q, got %q", expectedErr, err.Error())
}
}

Loading…
Cancel
Save