diff --git a/internal/embed/networks/ethereum/helmfile.yaml.gotmpl b/internal/embed/networks/ethereum/helmfile.yaml.gotmpl index 3ae45e91..4afde7ce 100644 --- a/internal/embed/networks/ethereum/helmfile.yaml.gotmpl +++ b/internal/embed/networks/ethereum/helmfile.yaml.gotmpl @@ -16,6 +16,9 @@ releases: executionClient: '{{ .Values.executionClient }}' consensusClient: '{{ .Values.consensusClient }}' network: '{{ .Values.network }}' + mode: '{{ .Values.mode }}' + executionStorageSize: '{{ index .Values "executionStorageSize" | default "" }}' + consensusStorageSize: '{{ index .Values "consensusStorageSize" | default "" }}' # Ethereum node (execution and consensus clients) # Uses the external ethereum-helm-charts/ethereum-node umbrella chart @@ -58,24 +61,23 @@ releases: {{- end }} {{- if ne .Values.mode "archive" }} - --full - {{- else if eq (.Values.pruneKind | default "") "before" }} - # Partial archive: prune state history before block N. - # Receipts/bodies are pruned to the same cutoff via the - # pre-merge presets where applicable; otherwise reth - # uses the same `before` value. + {{- else if eq (index .Values "pruneKind" | default "") "before" }} + # Partial archive: keep state, receipts and bodies from + # the requested block forward. Reth v2.2.0 and current + # main both support exact .before cutoffs for these + # segments, so avoid the coarser pre-merge preset. - --prune.account-history.before={{ .Values.pruneBlock }} - --prune.storage-history.before={{ .Values.pruneBlock }} - {{- if le (int .Values.pruneBlock) 15537394 }} - - --prune.receipts.pre-merge - - --prune.bodies.pre-merge - {{- else }} - --prune.receipts.before={{ .Values.pruneBlock }} - --prune.bodies.before={{ .Values.pruneBlock }} - {{- end }} - {{- else if eq (.Values.pruneKind | default "") "distance" }} - # Partial archive: keep last N blocks of history. + {{- else if eq (index .Values "pruneKind" | default "") "distance" }} + # Partial archive: keep last N blocks of state, receipts + # and bodies. Reth interprets .distance as anchored to + # the chain tip at run time. - --prune.account-history.distance={{ .Values.pruneDistance }} - --prune.storage-history.distance={{ .Values.pruneDistance }} + - --prune.receipts.distance={{ .Values.pruneDistance }} + - --prune.bodies.distance={{ .Values.pruneDistance }} {{- end }} {{- end }} @@ -105,7 +107,7 @@ releases: {{- end }} persistence: enabled: true - size: {{ if eq .Values.network "mainnet" }}{{ if eq .Values.mode "archive" }}4500Gi{{ else }}500Gi{{ end }}{{ else }}{{ if eq .Values.mode "archive" }}300Gi{{ else }}100Gi{{ end }}{{ end }} + size: {{ if (index .Values "executionStorageSize" | default "") }}{{ index .Values "executionStorageSize" }}{{ else }}{{ if eq .Values.network "mainnet" }}{{ if eq .Values.mode "archive" }}4500Gi{{ else }}500Gi{{ end }}{{ else }}{{ if eq .Values.mode "archive" }}300Gi{{ else }}100Gi{{ end }}{{ end }}{{ end }} existingClaim: execution-{{ .Values.executionClient }}-{{ .Values.network }} # Consensus client (pinned versions — Renovate-tracked) @@ -131,7 +133,7 @@ releases: {{- end }} persistence: enabled: true - size: {{ if eq .Values.network "mainnet" }}{{ if eq .Values.mode "archive" }}500Gi{{ else }}200Gi{{ end }}{{ else }}{{ if eq .Values.mode "archive" }}100Gi{{ else }}50Gi{{ end }}{{ end }} + size: {{ if (index .Values "consensusStorageSize" | default "") }}{{ index .Values "consensusStorageSize" }}{{ else }}{{ if eq .Values.network "mainnet" }}{{ if eq .Values.mode "archive" }}500Gi{{ else }}200Gi{{ end }}{{ else }}{{ if eq .Values.mode "archive" }}100Gi{{ else }}50Gi{{ end }}{{ end }}{{ end }} existingClaim: consensus-{{ .Values.consensusClient }}-{{ .Values.network }} # Metadata ConfigMap for frontend discovery diff --git a/internal/embed/networks/ethereum/templates/pvc.yaml b/internal/embed/networks/ethereum/templates/pvc.yaml index 38c41093..3d7860ed 100644 --- a/internal/embed/networks/ethereum/templates/pvc.yaml +++ b/internal/embed/networks/ethereum/templates/pvc.yaml @@ -1,30 +1,30 @@ {{- if eq .Release.Name "ethereum-pvcs" }} {{- /* - PVC sizing is a function of (network, mode). Sizes are estimates with - ~30% headroom for chain growth. local-path storage does not pre-allocate, - so these requests primarily document intent and serve as soft caps when - a sized storage class is swapped in later. + PVC sizing is resolved by the CLI and written into values.yaml so partial + archive scopes get smaller requests than full genesis archives. The fallback + keeps older generated values usable. */ -}} {{- $mode := default "full" .Values.mode -}} {{- $isArchive := eq $mode "archive" -}} -{{- $execSize := "500Gi" -}} -{{- $consensusSize := "200Gi" -}} -{{- if eq .Values.network "mainnet" -}} - {{- if $isArchive -}} - {{- $execSize = "4500Gi" -}} - {{- $consensusSize = "500Gi" -}} +{{- $execSize := index .Values "executionStorageSize" | default "" -}} +{{- $consensusSize := index .Values "consensusStorageSize" | default "" -}} +{{- if or (eq $execSize "") (eq $consensusSize "") -}} + {{- $execSize = "500Gi" -}} + {{- $consensusSize = "200Gi" -}} + {{- if eq .Values.network "mainnet" -}} + {{- if $isArchive -}} + {{- $execSize = "4500Gi" -}} + {{- $consensusSize = "500Gi" -}} + {{- end -}} {{- else -}} - {{- $execSize = "500Gi" -}} - {{- $consensusSize = "200Gi" -}} - {{- end -}} -{{- else -}} - {{- /* sepolia, hoodi and other testnets */ -}} - {{- if $isArchive -}} - {{- $execSize = "300Gi" -}} - {{- $consensusSize = "100Gi" -}} - {{- else -}} - {{- $execSize = "100Gi" -}} - {{- $consensusSize = "50Gi" -}} + {{- /* sepolia, hoodi and other testnets */ -}} + {{- if $isArchive -}} + {{- $execSize = "300Gi" -}} + {{- $consensusSize = "100Gi" -}} + {{- else -}} + {{- $execSize = "100Gi" -}} + {{- $consensusSize = "50Gi" -}} + {{- end -}} {{- end -}} {{- end -}} --- diff --git a/internal/network/network.go b/internal/network/network.go index 7b7c0490..68c202ad 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -148,7 +148,8 @@ func Install(cfg *config.Config, u *ui.UI, network string, overrides map[string] if modeValue == "" { modeValue = "full" } - if err := CheckNetworkDiskSpace(u, cfg.DataDir, netValue, modeValue); err != nil { + executionClient := templateData["ExecutionClient"] + if err := CheckNetworkDiskSpace(u, cfg.DataDir, netValue, modeValue, executionClient, archiveScope); err != nil { return err } } @@ -176,7 +177,7 @@ func Install(cfg *config.Config, u *ui.UI, network string, overrides map[string] if network == "ethereum" { var sb strings.Builder sb.Write(buf.Bytes()) - appendArchiveScopeYAML(&sb, archiveScope) + appendArchiveScopeYAML(&sb, templateData["Network"], templateData["Mode"], templateData["ExecutionClient"], archiveScope) buf.Reset() buf.WriteString(sb.String()) } diff --git a/internal/network/picker.go b/internal/network/picker.go index 964d5f24..ef3effd4 100644 --- a/internal/network/picker.go +++ b/internal/network/picker.go @@ -214,7 +214,9 @@ func promptCustomBlock(u *ui.UI) (ArchiveScope, error) { // appendArchiveScopeYAML serializes the resolved scope into a YAML // fragment for values.yaml, in a form that helmfile reads at deploy time // to emit per-client prune args. -func appendArchiveScopeYAML(b *strings.Builder, scope ArchiveScope) { +func appendArchiveScopeYAML(b *strings.Builder, network, mode, executionClient string, scope ArchiveScope) { + profile := resolveEthereumStorageProfile(network, mode, executionClient, scope) + b.WriteString("\n# Pruning scope, resolved by `obol network install` from --mode/--since.\n") b.WriteString("# Edit via the CLI flags rather than this file; helmfile reads these\n") b.WriteString("# verbatim and emits client-specific prune args at deploy time.\n") @@ -233,4 +235,9 @@ func appendArchiveScopeYAML(b *strings.Builder, scope ArchiveScope) { case "distance": fmt.Fprintf(b, "pruneKind: \"distance\"\npruneBlock: 0\npruneDistance: %d\n", scope.Distance) } + + b.WriteString("\n# Storage profile derived from the resolved archive scope.\n") + fmt.Fprintf(b, "executionStorageSize: %s\n", profile.ExecutionSize) + fmt.Fprintf(b, "consensusStorageSize: %s\n", profile.ConsensusSize) + fmt.Fprintf(b, "diskRequirementGB: %d\n", profile.DiskRequirementGB) } diff --git a/internal/network/preflight.go b/internal/network/preflight.go index e25679ba..16b9ac56 100644 --- a/internal/network/preflight.go +++ b/internal/network/preflight.go @@ -7,27 +7,6 @@ import ( "github.com/ObolNetwork/obol-stack/internal/ui" ) -// diskSpaceRequirementGB returns the recommended free-disk minimum for -// (network, mode) in gigabytes. Numbers include ~30% headroom for chain -// growth between releases. Sizes are reth-anchored; other clients are in -// the same ballpark. -func diskSpaceRequirementGB(network, mode string) uint64 { - archive := mode == "archive" - switch network { - case "mainnet": - if archive { - return 5000 - } - return 700 - default: - // sepolia, hoodi, and other testnets - if archive { - return 400 - } - return 150 - } -} - // freeDiskBytes returns the free disk bytes available at path. Used to // check whether a network install has room to grow before we let helmfile // schedule a 4TB PVC that will silently fill the host overnight. @@ -46,8 +25,9 @@ func freeDiskBytes(path string) (uint64, error) { // in non-interactive contexts (no TTY, JSON mode) the prompt auto-accepts // so scripted installs don't deadlock. The user only blocks the install by // explicitly declining at an interactive prompt. -func CheckNetworkDiskSpace(u *ui.UI, dataDir, network, mode string) error { - requiredGB := diskSpaceRequirementGB(network, mode) +func CheckNetworkDiskSpace(u *ui.UI, dataDir, network, mode, executionClient string, scope ArchiveScope) error { + profile := resolveEthereumStorageProfile(network, mode, executionClient, scope) + requiredGB := profile.DiskRequirementGB freeBytes, err := freeDiskBytes(dataDir) if err != nil { @@ -58,7 +38,7 @@ func CheckNetworkDiskSpace(u *ui.UI, dataDir, network, mode string) error { freeGB := freeBytes / (1024 * 1024 * 1024) - u.Detail("Disk space", fmt.Sprintf("%d GB free at %s (this network needs ~%d GB)", freeGB, dataDir, requiredGB)) + u.Detail("Disk space", fmt.Sprintf("%d GB free at %s (this network needs ~%d GB for %s)", freeGB, dataDir, requiredGB, profile.Label)) if freeGB >= requiredGB { return nil diff --git a/internal/network/storage_profile.go b/internal/network/storage_profile.go new file mode 100644 index 00000000..959b765c --- /dev/null +++ b/internal/network/storage_profile.go @@ -0,0 +1,134 @@ +package network + +import ( + "fmt" + "math" +) + +// ethereumStorageProfile is the single source of truth for Ethereum local +// node storage sizing. The profile is intentionally conservative because +// local-path storage does not reserve bytes up front, but sized storage +// classes do enforce these requests. +type ethereumStorageProfile struct { + ExecutionSize string + ConsensusSize string + DiskRequirementGB uint64 + Label string +} + +func resolveEthereumStorageProfile(network, mode, executionClient string, scope ArchiveScope) ethereumStorageProfile { + if mode != "archive" { + if network == "mainnet" { + return ethereumStorageProfile{ + ExecutionSize: "500Gi", + ConsensusSize: "200Gi", + DiskRequirementGB: 700, + Label: "full mainnet", + } + } + return ethereumStorageProfile{ + ExecutionSize: "100Gi", + ConsensusSize: "50Gi", + DiskRequirementGB: 150, + Label: "full testnet", + } + } + + if network != "mainnet" { + return ethereumStorageProfile{ + ExecutionSize: "300Gi", + ConsensusSize: "100Gi", + DiskRequirementGB: 400, + Label: "archive testnet", + } + } + + if !partialArchiveClients[executionClient] || scope.Kind == "" || scope.Kind == "all" { + return ethereumStorageProfile{ + ExecutionSize: "4500Gi", + ConsensusSize: "500Gi", + DiskRequirementGB: 5000, + Label: "archive mainnet from genesis", + } + } + + switch scope.Kind { + case "before": + if hf := hardforkProfileForBlock(scope.Block); hf != nil { + execGi := roundUpGiB(uint64(math.Ceil(hf.ApproxArchiveSizeTB * 1024 * 1.2))) + return ethereumStorageProfile{ + ExecutionSize: formatGi(execGi), + ConsensusSize: "500Gi", + DiskRequirementGB: execGi + 700, + Label: "partial archive mainnet " + hf.Name, + } + } + + // A raw block before the oldest known partial-archive preset could + // retain almost all history, so size it as a full archive. + return ethereumStorageProfile{ + ExecutionSize: "4500Gi", + ConsensusSize: "500Gi", + DiskRequirementGB: 5000, + Label: "custom archive mainnet block", + } + case "distance": + execGi := executionGiForDistance(scope.Distance) + return ethereumStorageProfile{ + ExecutionSize: formatGi(execGi), + ConsensusSize: "500Gi", + DiskRequirementGB: diskRequirementForMainnetArchive(execGi), + Label: "partial archive mainnet distance", + } + default: + return ethereumStorageProfile{ + ExecutionSize: "4500Gi", + ConsensusSize: "500Gi", + DiskRequirementGB: 5000, + Label: "archive mainnet", + } + } +} + +func hardforkProfileForBlock(block uint64) *Hardfork { + var matched *Hardfork + for i := range MainnetHardforks { + if MainnetHardforks[i].Block <= block { + matched = &MainnetHardforks[i] + continue + } + break + } + return matched +} + +func diskRequirementForMainnetArchive(execGi uint64) uint64 { + if execGi >= 4500 { + return 5000 + } + return execGi + 700 +} + +func executionGiForDistance(distance uint64) uint64 { + days := float64(distance*12) / float64(24*60*60) + execGi := uint64(math.Ceil(500 + days*0.82)) + if execGi < 500 { + execGi = 500 + } + if execGi > 4500 { + execGi = 4500 + } + return roundUpGiB(execGi) +} + +func roundUpGiB(gib uint64) uint64 { + const step = 100 + if gib%step == 0 { + return gib + } + return ((gib / step) + 1) * step +} + +func formatGi(gib uint64) string { + return fmt.Sprintf("%dGi", gib) +} diff --git a/internal/network/storage_profile_test.go b/internal/network/storage_profile_test.go new file mode 100644 index 00000000..0b4f3313 --- /dev/null +++ b/internal/network/storage_profile_test.go @@ -0,0 +1,368 @@ +package network + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/embed" + "github.com/ObolNetwork/obol-stack/internal/ui" +) + +func TestResolveEthereumStorageProfile(t *testing.T) { + distanceScope, err := ParseSince("365d") + if err != nil { + t.Fatal(err) + } + mergeScope, err := ParseSince("merge") + if err != nil { + t.Fatal(err) + } + cancunScope, err := ParseSince("cancun") + if err != nil { + t.Fatal(err) + } + rawPragueScope, err := ParseSince("22500000") + if err != nil { + t.Fatal(err) + } + rawPreMergeScope, err := ParseSince("1000000") + if err != nil { + t.Fatal(err) + } + longDistanceScope, err := ParseSince("20y") + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + network string + mode string + client string + scope ArchiveScope + wantExec string + wantConsensus string + wantDisk uint64 + }{ + { + name: "full mainnet", + network: "mainnet", + mode: "full", + client: "reth", + wantExec: "500Gi", + wantConsensus: "200Gi", + wantDisk: 700, + }, + { + name: "genesis archive mainnet", + network: "mainnet", + mode: "archive", + client: "reth", + scope: ArchiveScope{Kind: "all"}, + wantExec: "4500Gi", + wantConsensus: "500Gi", + wantDisk: 5000, + }, + { + name: "merge partial archive mainnet", + network: "mainnet", + mode: "archive", + client: "reth", + scope: mergeScope, + wantExec: "1900Gi", + wantConsensus: "500Gi", + wantDisk: 2600, + }, + { + name: "cancun partial archive mainnet", + network: "mainnet", + mode: "archive", + client: "reth", + scope: cancunScope, + wantExec: "1000Gi", + wantConsensus: "500Gi", + wantDisk: 1700, + }, + { + name: "distance partial archive mainnet", + network: "mainnet", + mode: "archive", + client: "reth", + scope: distanceScope, + wantExec: "800Gi", + wantConsensus: "500Gi", + wantDisk: 1500, + }, + { + name: "raw block uses nearest conservative hardfork profile", + network: "mainnet", + mode: "archive", + client: "reth", + scope: rawPragueScope, + wantExec: "500Gi", + wantConsensus: "500Gi", + wantDisk: 1200, + }, + { + name: "raw pre-merge block stays genesis sized", + network: "mainnet", + mode: "archive", + client: "reth", + scope: rawPreMergeScope, + wantExec: "4500Gi", + wantConsensus: "500Gi", + wantDisk: 5000, + }, + { + name: "long duration caps at genesis archive size", + network: "mainnet", + mode: "archive", + client: "reth", + scope: longDistanceScope, + wantExec: "4500Gi", + wantConsensus: "500Gi", + wantDisk: 5000, + }, + { + name: "unsupported client stays genesis sized", + network: "mainnet", + mode: "archive", + client: "geth", + scope: distanceScope, + wantExec: "4500Gi", + wantConsensus: "500Gi", + wantDisk: 5000, + }, + { + name: "testnet archive", + network: "hoodi", + mode: "archive", + client: "reth", + scope: ArchiveScope{Kind: "all"}, + wantExec: "300Gi", + wantConsensus: "100Gi", + wantDisk: 400, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := resolveEthereumStorageProfile(c.network, c.mode, c.client, c.scope) + if got.ExecutionSize != c.wantExec { + t.Fatalf("ExecutionSize = %q, want %q", got.ExecutionSize, c.wantExec) + } + if got.ConsensusSize != c.wantConsensus { + t.Fatalf("ConsensusSize = %q, want %q", got.ConsensusSize, c.wantConsensus) + } + if got.DiskRequirementGB != c.wantDisk { + t.Fatalf("DiskRequirementGB = %d, want %d", got.DiskRequirementGB, c.wantDisk) + } + }) + } +} + +func TestInstallEthereumArchiveScopeWritesStorageProfile(t *testing.T) { + cases := []struct { + name string + overrides map[string]string + wantValues []string + wantStderr []string + }{ + { + name: "full mainnet", + overrides: map[string]string{ + "mode": "full", + }, + wantValues: []string{ + `mode: full`, + `pruneKind: ""`, + `pruneBlock: 0`, + `pruneDistance: 0`, + `executionStorageSize: 500Gi`, + `consensusStorageSize: 200Gi`, + `diskRequirementGB: 700`, + }, + }, + { + name: "genesis archive", + overrides: map[string]string{ + "mode": "archive", + "since": "genesis", + }, + wantValues: []string{ + `mode: archive`, + `since: genesis`, + `pruneKind: "all"`, + `executionStorageSize: 4500Gi`, + `consensusStorageSize: 500Gi`, + `diskRequirementGB: 5000`, + }, + }, + { + name: "non-interactive archive without since defaults to genesis", + overrides: map[string]string{ + "mode": "archive", + }, + wantValues: []string{ + `mode: archive`, + `since: `, + `pruneKind: "all"`, + `executionStorageSize: 4500Gi`, + `consensusStorageSize: 500Gi`, + `diskRequirementGB: 5000`, + }, + }, + { + name: "merge partial archive", + overrides: map[string]string{ + "mode": "archive", + "since": "merge", + }, + wantValues: []string{ + `mode: archive`, + `since: merge`, + `pruneKind: "before"`, + `pruneBlock: 15537394`, + `executionStorageSize: 1900Gi`, + `consensusStorageSize: 500Gi`, + `diskRequirementGB: 2600`, + }, + }, + { + name: "duration partial archive", + overrides: map[string]string{ + "mode": "archive", + "since": "365d", + }, + wantValues: []string{ + `mode: archive`, + `since: 365d`, + `pruneKind: "distance"`, + `pruneDistance: 2628000`, + `executionStorageSize: 800Gi`, + `consensusStorageSize: 500Gi`, + `diskRequirementGB: 1500`, + }, + }, + { + name: "raw pre-merge block stays full sized", + overrides: map[string]string{ + "mode": "archive", + "since": "1000000", + }, + wantValues: []string{ + `mode: archive`, + `since: 1000000`, + `pruneKind: "before"`, + `pruneBlock: 1000000`, + `executionStorageSize: 4500Gi`, + `diskRequirementGB: 5000`, + }, + }, + { + name: "unsupported client with since stays full sized", + overrides: map[string]string{ + "execution-client": "geth", + "mode": "archive", + "since": "365d", + }, + wantValues: []string{ + `executionClient: geth`, + `mode: archive`, + `since: 365d`, + `pruneKind: "distance"`, + `executionStorageSize: 4500Gi`, + `diskRequirementGB: 5000`, + }, + wantStderr: []string{ + `--since is currently wired only for reth`, + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + values, stderr := installEthereumValues(t, c.overrides) + for _, want := range c.wantValues { + if !strings.Contains(values, want) { + t.Fatalf("values.yaml missing %q:\n%s", want, values) + } + } + for _, want := range c.wantStderr { + if !strings.Contains(stderr, want) { + t.Fatalf("stderr missing %q:\n%s", want, stderr) + } + } + }) + } +} + +func installEthereumValues(t *testing.T, overrides map[string]string) (string, string) { + t.Helper() + + tmp := t.TempDir() + cfg := &config.Config{ + ConfigDir: filepath.Join(tmp, "config"), + DataDir: filepath.Join(tmp, "data"), + BinDir: filepath.Join(tmp, "bin"), + StateDir: filepath.Join(tmp, "state"), + } + if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { + t.Fatal(err) + } + + installOverrides := map[string]string{"id": "test"} + for k, v := range overrides { + installOverrides[k] = v + } + + var stdout, stderr bytes.Buffer + u := ui.NewForTest(&stdout, &stderr) + err := Install(cfg, u, "ethereum", installOverrides, false) + if err != nil { + t.Fatalf("Install() error = %v\nstderr:\n%s", err, stderr.String()) + } + + valuesPath := filepath.Join(cfg.ConfigDir, "networks", "ethereum", "test", "values.yaml") + valuesBytes, err := os.ReadFile(valuesPath) + if err != nil { + t.Fatal(err) + } + return string(valuesBytes), stderr.String() +} + +func TestEthereumHelmfileRethPruneFlagsCoverAllHistorySegments(t *testing.T) { + content, err := embed.ReadEmbeddedNetworkFile("ethereum", "helmfile.yaml.gotmpl") + if err != nil { + t.Fatal(err) + } + helmfile := string(content) + + for _, want := range []string{ + "--prune.account-history.before={{ .Values.pruneBlock }}", + "--prune.storage-history.before={{ .Values.pruneBlock }}", + "--prune.receipts.before={{ .Values.pruneBlock }}", + "--prune.bodies.before={{ .Values.pruneBlock }}", + "--prune.account-history.distance={{ .Values.pruneDistance }}", + "--prune.storage-history.distance={{ .Values.pruneDistance }}", + "--prune.receipts.distance={{ .Values.pruneDistance }}", + "--prune.bodies.distance={{ .Values.pruneDistance }}", + } { + if !strings.Contains(helmfile, want) { + t.Fatalf("helmfile missing %q", want) + } + } + + for _, notWant := range []string{ + "--prune.receipts.pre-merge", + "--prune.bodies.pre-merge", + } { + if strings.Contains(helmfile, notWant) { + t.Fatalf("helmfile still uses coarse pre-merge flag %q", notWant) + } + } +}