diff --git a/pkg/cmd/flags.go b/pkg/cmd/flags.go index 5a220d1ce..78d2448ac 100644 --- a/pkg/cmd/flags.go +++ b/pkg/cmd/flags.go @@ -25,6 +25,7 @@ import ( "path/filepath" "sort" "strings" + "time" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -65,42 +66,95 @@ func AddWaitFlag(cmd *cobra.Command, wait *kube.WaitStrategy) { cmd.Flags().Lookup("wait").NoOptDefVal = string(kube.StatusWatcherStrategy) } -type waitValue kube.WaitStrategy +func AddOrderedWaitFlag(cmd *cobra.Command, wait *kube.WaitStrategy) { + cmd.Flags().Var( + newOrderedWaitValue(kube.HookOnlyStrategy, wait), + "wait", + "wait until resources are ready (up to --timeout). Use '--wait' alone for 'watcher' strategy, or specify one of: 'watcher', 'hookOnly', 'legacy', 'ordered'. Default when flag is omitted: 'hookOnly'.", + ) + cmd.Flags().Lookup("wait").NoOptDefVal = string(kube.StatusWatcherStrategy) +} + +type waitValue struct { + wait *kube.WaitStrategy + allowOrdered bool +} func newWaitValue(defaultValue kube.WaitStrategy, ws *kube.WaitStrategy) *waitValue { + return newConfiguredWaitValue(defaultValue, ws, false) +} + +func newOrderedWaitValue(defaultValue kube.WaitStrategy, ws *kube.WaitStrategy) *waitValue { + return newConfiguredWaitValue(defaultValue, ws, true) +} + +func newConfiguredWaitValue(defaultValue kube.WaitStrategy, ws *kube.WaitStrategy, allowOrdered bool) *waitValue { *ws = defaultValue - return (*waitValue)(ws) + return &waitValue{wait: ws, allowOrdered: allowOrdered} } func (ws *waitValue) String() string { - if ws == nil { + if ws == nil || ws.wait == nil { return "" } - return string(*ws) + return string(*ws.wait) } func (ws *waitValue) Set(s string) error { switch s { case string(kube.StatusWatcherStrategy), string(kube.LegacyStrategy), string(kube.HookOnlyStrategy): - *ws = waitValue(s) + *ws.wait = kube.WaitStrategy(s) + return nil + case string(kube.OrderedWaitStrategy): + if !ws.allowOrdered { + break + } + *ws.wait = kube.WaitStrategy(s) return nil case "true": slog.Warn("--wait=true is deprecated (boolean value) and can be replaced with --wait=watcher") - *ws = waitValue(kube.StatusWatcherStrategy) + *ws.wait = kube.StatusWatcherStrategy return nil case "false": slog.Warn("--wait=false is deprecated (boolean value) and can be replaced with --wait=hookOnly") - *ws = waitValue(kube.HookOnlyStrategy) + *ws.wait = kube.HookOnlyStrategy return nil default: - return fmt.Errorf("invalid wait input %q. Valid inputs are %s, %s, and %s", s, kube.StatusWatcherStrategy, kube.HookOnlyStrategy, kube.LegacyStrategy) } + + return fmt.Errorf("invalid wait input %q. Valid inputs are %s", s, formatWaitInputs(ws.allowOrdered)) } func (ws *waitValue) Type() string { return "WaitStrategy" } +func addReadinessTimeoutFlag(f *pflag.FlagSet, readinessTimeout *time.Duration) { + f.DurationVar(readinessTimeout, "readiness-timeout", time.Minute, "per-batch timeout when --wait=ordered is used; each resource batch must become ready within this duration (must not exceed --timeout)") +} + +func formatWaitInputs(allowOrdered bool) string { + valid := []string{ + string(kube.StatusWatcherStrategy), + string(kube.HookOnlyStrategy), + string(kube.LegacyStrategy), + } + if allowOrdered { + valid = append(valid, string(kube.OrderedWaitStrategy)) + } + + switch len(valid) { + case 0: + return "" + case 1: + return valid[0] + case 2: + return fmt.Sprintf("%s and %s", valid[0], valid[1]) + default: + return fmt.Sprintf("%s, and %s", strings.Join(valid[:len(valid)-1], ", "), valid[len(valid)-1]) + } +} + func addChartPathOptionsFlags(f *pflag.FlagSet, c *action.ChartPathOptions) { f.StringVar(&c.Version, "version", "", "specify a version constraint for the chart version to use. This constraint can be a specific tag (e.g. 1.1.1) or it may reference a valid range (e.g. ^2.0.0). If this is not specified, the latest version is used") f.BoolVar(&c.Verify, "verify", false, "verify the package before using it") diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index ed10513c9..3e6523cf5 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -173,6 +173,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f := cmd.Flags() addInstallFlags(cmd, f, client, valueOpts) + addReadinessTimeoutFlag(f, &client.ReadinessTimeout) // hide-secret is not available in all places the install flags are used so // it is added separately f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag") @@ -212,7 +213,7 @@ func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Instal f.BoolVar(&client.TakeOwnership, "take-ownership", false, "if set, install will ignore the check for helm annotations and take ownership of the existing resources") addValueOptionsFlags(f, valueOpts) addChartPathOptionsFlags(f, &client.ChartPathOptions) - AddWaitFlag(cmd, &client.WaitStrategy) + AddOrderedWaitFlag(cmd, &client.WaitStrategy) cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index 047fd60df..03c8595af 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -22,7 +22,9 @@ import ( "fmt" "io" "io/fs" + "log/slog" "os" + "path" "path/filepath" "regexp" "slices" @@ -32,11 +34,16 @@ import ( release "helm.sh/helm/v4/pkg/release/v1" "github.com/spf13/cobra" + "sigs.k8s.io/yaml" "helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/v2/loader" + chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/cli/values" "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/kube" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" ) @@ -105,6 +112,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client.Replace = true // Skip the name check client.APIVersions = common.VersionSet(extraAPIs) client.IncludeCRDs = includeCrds + orderedTemplateOutput := client.WaitStrategy == kube.OrderedWaitStrategy && len(showFiles) == 0 && client.OutputDir == "" rel, err := runInstall(args, client, valueOpts, out) if err != nil && !settings.Debug { @@ -117,84 +125,101 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { // We ignore a potential error here because, when the --debug flag was specified, // we always want to print the YAML, even if it is not valid. The error is still returned afterwards. if rel != nil { - var manifests bytes.Buffer - fmt.Fprintln(&manifests, strings.TrimSpace(rel.Manifest)) - if !client.DisableHooks { - fileWritten := make(map[string]bool) - for _, m := range rel.Hooks { - if skipTests && isTestHook(m) { - continue - } - if client.OutputDir == "" { - fmt.Fprintf(&manifests, "---\n# Source: %s\n%s\n", m.Path, m.Manifest) - } else { - newDir := client.OutputDir - if client.UseReleaseName { - newDir = filepath.Join(client.OutputDir, client.ReleaseName) + if orderedTemplateOutput { + templateChart, err := loadTemplateChart(args, client) + if err != nil { + return err + } + if err := renderOrderedTemplate(templateChart, strings.TrimSpace(rel.Manifest), out); err != nil { + return err + } + if !client.DisableHooks { + for _, m := range rel.Hooks { + if skipTests && isTestHook(m) { + continue } - _, err := os.Stat(filepath.Join(newDir, m.Path)) - if err == nil { - fileWritten[m.Path] = true + fmt.Fprintf(out, "---\n# Source: %s\n%s\n", m.Path, m.Manifest) + } + } + } else { + var manifests bytes.Buffer + fmt.Fprintln(&manifests, strings.TrimSpace(rel.Manifest)) + if !client.DisableHooks { + fileWritten := make(map[string]bool) + for _, m := range rel.Hooks { + if skipTests && isTestHook(m) { + continue } - - err = writeToFile(newDir, m.Path, m.Manifest, fileWritten[m.Path]) - if err != nil { - return err + if client.OutputDir == "" { + fmt.Fprintf(&manifests, "---\n# Source: %s\n%s\n", m.Path, m.Manifest) + } else { + newDir := client.OutputDir + if client.UseReleaseName { + newDir = filepath.Join(client.OutputDir, client.ReleaseName) + } + _, err := os.Stat(filepath.Join(newDir, m.Path)) + if err == nil { + fileWritten[m.Path] = true + } + + err = writeToFile(newDir, m.Path, m.Manifest, fileWritten[m.Path]) + if err != nil { + return err + } } } - } - } - // if we have a list of files to render, then check that each of the - // provided files exists in the chart. - if len(showFiles) > 0 { - // This is necessary to ensure consistent manifest ordering when using --show-only - // with globs or directory names. - splitManifests := releaseutil.SplitManifests(manifests.String()) - manifestsKeys := make([]string, 0, len(splitManifests)) - for k := range splitManifests { - manifestsKeys = append(manifestsKeys, k) - } - sort.Sort(releaseutil.BySplitManifestsOrder(manifestsKeys)) - - manifestNameRegex := regexp.MustCompile("# Source: [^/]+/(.+)") - var manifestsToRender []string - for _, f := range showFiles { - missing := true - // Use linux-style filepath separators to unify user's input path - f = filepath.ToSlash(f) - for _, manifestKey := range manifestsKeys { - manifest := splitManifests[manifestKey] - submatch := manifestNameRegex.FindStringSubmatch(manifest) - if len(submatch) == 0 { - continue + // if we have a list of files to render, then check that each of the + // provided files exists in the chart. + if len(showFiles) > 0 { + // This is necessary to ensure consistent manifest ordering when using --show-only + // with globs or directory names. + splitManifests := releaseutil.SplitManifests(manifests.String()) + manifestsKeys := make([]string, 0, len(splitManifests)) + for k := range splitManifests { + manifestsKeys = append(manifestsKeys, k) + } + sort.Sort(releaseutil.BySplitManifestsOrder(manifestsKeys)) + + manifestNameRegex := regexp.MustCompile("# Source: [^/]+/(.+)") + var manifestsToRender []string + for _, f := range showFiles { + missing := true + // Use linux-style filepath separators to unify user's input path + f = filepath.ToSlash(f) + for _, manifestKey := range manifestsKeys { + manifest := splitManifests[manifestKey] + submatch := manifestNameRegex.FindStringSubmatch(manifest) + if len(submatch) == 0 { + continue + } + manifestName := submatch[1] + // manifest.Name is rendered using linux-style filepath separators on Windows as + // well as macOS/linux. + manifestPathSplit := strings.Split(manifestName, "/") + // manifest.Path is connected using linux-style filepath separators on Windows as + // well as macOS/linux + manifestPath := strings.Join(manifestPathSplit, "/") + + // if the filepath provided matches a manifest path in the + // chart, render that manifest + if matched, _ := filepath.Match(f, manifestPath); !matched { + continue + } + manifestsToRender = append(manifestsToRender, manifest) + missing = false } - manifestName := submatch[1] - // manifest.Name is rendered using linux-style filepath separators on Windows as - // well as macOS/linux. - manifestPathSplit := strings.Split(manifestName, "/") - // manifest.Path is connected using linux-style filepath separators on Windows as - // well as macOS/linux - manifestPath := strings.Join(manifestPathSplit, "/") - - // if the filepath provided matches a manifest path in the - // chart, render that manifest - if matched, _ := filepath.Match(f, manifestPath); !matched { - continue + if missing { + return fmt.Errorf("could not find template %s in chart", f) } - manifestsToRender = append(manifestsToRender, manifest) - missing = false } - if missing { - return fmt.Errorf("could not find template %s in chart", f) + for _, m := range manifestsToRender { + fmt.Fprintf(out, "---\n%s\n", m) } + } else { + fmt.Fprintf(out, "%s", manifests.String()) } - for _, m := range manifestsToRender { - fmt.Fprintf(out, "---\n%s\n", m) - } - } else { - fmt.Fprintf(out, "%s", manifests.String()) } } @@ -225,6 +250,244 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return cmd } +func loadTemplateChart(args []string, client *action.Install) (*chart.Chart, error) { + _, chartRef, err := client.NameAndChart(args) + if err != nil { + return nil, err + } + + chartPath, err := client.LocateChart(chartRef, settings) + if err != nil { + return nil, err + } + + return loader.Load(chartPath) +} + +func renderOrderedTemplate(chrt *chart.Chart, manifest string, out io.Writer) error { + if manifest == "" { + return nil + } + + sortedManifests, err := parseTemplateManifests(manifest) + if err != nil { + return fmt.Errorf("parsing manifests for ordered output: %w", err) + } + + return renderOrderedChartLevel(chrt, sortedManifests, chrt.Name(), out) +} + +func parseTemplateManifests(manifest string) ([]releaseutil.Manifest, error) { + rawManifests := releaseutil.SplitManifests(manifest) + keys := make([]string, 0, len(rawManifests)) + for key := range rawManifests { + keys = append(keys, key) + } + sort.Sort(releaseutil.BySplitManifestsOrder(keys)) + + manifests := make([]releaseutil.Manifest, 0, len(keys)) + for _, key := range keys { + content := rawManifests[key] + name := manifestSourcePath(content) + if name == "" { + name = key + } + + var head releaseutil.SimpleHead + if err := yaml.Unmarshal([]byte(content), &head); err != nil { + return nil, fmt.Errorf("YAML parse error on %s: %w", name, err) + } + + manifests = append(manifests, releaseutil.Manifest{ + Name: name, + Content: content, + Head: &head, + }) + } + + return manifests, nil +} + +func manifestSourcePath(content string) string { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "# Source: ") { + return strings.TrimPrefix(line, "# Source: ") + } + } + + return "" +} + +func renderOrderedChartLevel(chrt *chart.Chart, manifests []releaseutil.Manifest, chartPath string, out io.Writer) error { + if len(manifests) == 0 { + return nil + } + + grouped := groupManifestsByChartPath(manifests, chartPath) + renderedSubcharts := make(map[string]struct{}) + + dag, err := chartutil.BuildSubchartDAG(chrt) + if err != nil { + return fmt.Errorf("building subchart DAG for %s: %w", chartPath, err) + } + + batches, err := dag.GetBatches() + if err != nil { + return fmt.Errorf("getting subchart batches for %s: %w", chartPath, err) + } + + for _, batch := range batches { + for _, subchartName := range batch { + if err := renderOrderedSubchart(chrt, chartPath, subchartName, grouped[subchartName], out); err != nil { + return err + } + renderedSubcharts[subchartName] = struct{}{} + } + } + + var remainingSubcharts []string + for subchartName := range grouped { + if subchartName == "" { + continue + } + if _, ok := renderedSubcharts[subchartName]; ok { + continue + } + remainingSubcharts = append(remainingSubcharts, subchartName) + } + sort.Strings(remainingSubcharts) + + for _, subchartName := range remainingSubcharts { + if err := renderOrderedSubchart(chrt, chartPath, subchartName, grouped[subchartName], out); err != nil { + return err + } + } + + return renderOrderedResourceGroups(grouped[""], chartPath, out) +} + +func renderOrderedSubchart(chrt *chart.Chart, chartPath, subchartName string, manifests []releaseutil.Manifest, out io.Writer) error { + if len(manifests) == 0 { + return nil + } + + subchartPath := path.Join(chartPath, subchartName) + subChart := findTemplateSubchart(chrt, subchartName) + if subChart == nil { + slog.Warn("subchart not found in chart dependencies during template rendering; falling back to flat ordered output", "subchart", subchartName) + return renderOrderedResourceGroups(manifests, subchartPath, out) + } + + return renderOrderedChartLevel(subChart, manifests, subchartPath, out) +} + +func renderOrderedResourceGroups(manifests []releaseutil.Manifest, chartPath string, out io.Writer) error { + if len(manifests) == 0 { + return nil + } + + result, warnings, err := releaseutil.ParseResourceGroups(manifests) + if err != nil { + return fmt.Errorf("parsing resource groups for %s: %w", chartPath, err) + } + for _, warning := range warnings { + slog.Warn("resource-group annotation warning during template rendering", "chartPath", chartPath, "warning", warning) + } + + if len(result.Groups) > 0 { + dag, err := releaseutil.BuildResourceGroupDAG(result) + if err != nil { + return fmt.Errorf("building resource-group DAG for %s: %w", chartPath, err) + } + batches, err := dag.GetBatches() + if err != nil { + return fmt.Errorf("getting resource-group batches for %s: %w", chartPath, err) + } + + for _, batch := range batches { + for _, groupName := range batch { + fmt.Fprintf(out, "## START resource-group: %s %s\n", chartPath, groupName) + for _, manifest := range result.Groups[groupName] { + fmt.Fprintf(out, "---\n%s\n", manifest.Content) + } + fmt.Fprintf(out, "## END resource-group: %s %s\n", chartPath, groupName) + } + } + } + + for _, manifest := range result.Unsequenced { + fmt.Fprintf(out, "---\n%s\n", manifest.Content) + } + + return nil +} + +func groupManifestsByChartPath(manifests []releaseutil.Manifest, chartPath string) map[string][]releaseutil.Manifest { + result := make(map[string][]releaseutil.Manifest) + chartsPrefix := chartManifestPrefix(chartPath) + "/charts/" + + for _, manifest := range manifests { + if !strings.HasPrefix(manifest.Name, chartsPrefix) { + result[""] = append(result[""], manifest) + continue + } + + rest := manifest.Name[len(chartsPrefix):] + idx := strings.Index(rest, "/") + if idx < 0 { + result[""] = append(result[""], manifest) + continue + } + + subchartName := rest[:idx] + result[subchartName] = append(result[subchartName], manifest) + } + + return result +} + +func chartManifestPrefix(chartPath string) string { + parts := strings.Split(chartPath, "/") + if len(parts) == 0 { + return chartPath + } + + prefix := parts[0] + for _, part := range parts[1:] { + prefix = path.Join(prefix, "charts", part) + } + + return prefix +} + +func findTemplateSubchart(chrt *chart.Chart, nameOrAlias string) *chart.Chart { + if chrt == nil || chrt.Metadata == nil { + return nil + } + + aliases := make(map[string]string, len(chrt.Metadata.Dependencies)) + for _, dep := range chrt.Metadata.Dependencies { + effectiveName := dep.Name + if dep.Alias != "" { + effectiveName = dep.Alias + } + aliases[dep.Name] = effectiveName + } + + for _, dep := range chrt.Dependencies() { + effectiveName := dep.Name() + if alias, ok := aliases[dep.Name()]; ok { + effectiveName = alias + } + if effectiveName == nameOrAlias || dep.Name() == nameOrAlias { + return dep + } + } + + return nil +} + func isTestHook(h *release.Hook) bool { return slices.Contains(h.Events, release.HookTest) } diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index b71c4ae2d..f66ee4d54 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -305,7 +305,8 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { addValueOptionsFlags(f, valueOpts) bindOutputFlag(cmd, &outfmt) bindPostRenderFlag(cmd, &client.PostRenderer, settings) - AddWaitFlag(cmd, &client.WaitStrategy) + AddOrderedWaitFlag(cmd, &client.WaitStrategy) + addReadinessTimeoutFlag(f, &client.ReadinessTimeout) cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts")