Skip to content

Commit 69b626c

Browse files
authored
E2E Test Foundations (#207)
* foundation for e2e testing Signed-off-by: Geoff Flarity <gflarity@nvidia.com>
1 parent 13cd54e commit 69b626c

20 files changed

+4120
-44
lines changed

operator/e2e/setup/helm.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// /*
2+
// Copyright 2025 The Grove Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
// */
16+
17+
package setup
18+
19+
import (
20+
"fmt"
21+
"io"
22+
"net/http"
23+
"os"
24+
"path/filepath"
25+
"strings"
26+
27+
"github.com/ai-dynamo/grove/operator/e2e/utils"
28+
"helm.sh/helm/v3/pkg/action"
29+
"helm.sh/helm/v3/pkg/chart/loader"
30+
"helm.sh/helm/v3/pkg/cli"
31+
"helm.sh/helm/v3/pkg/registry"
32+
"helm.sh/helm/v3/pkg/release"
33+
"k8s.io/cli-runtime/pkg/genericclioptions"
34+
"k8s.io/client-go/rest"
35+
)
36+
37+
// HelmInstallConfig holds configuration for Helm chart installations.
38+
type HelmInstallConfig struct {
39+
// RestConfig is the Kubernetes REST configuration. If nil, uses default kubeconfig.
40+
RestConfig *rest.Config
41+
// ReleaseName is the name of the Helm release. Required unless GenerateName is true.
42+
ReleaseName string
43+
// ChartRef is the chart reference (path, URL, or chart name). Required.
44+
ChartRef string
45+
// ChartVersion is the version of the chart to install. Required.
46+
ChartVersion string
47+
// Namespace is the Kubernetes namespace to install into. Required.
48+
Namespace string
49+
// CreateNamespace creates the namespace if it doesn't exist.
50+
CreateNamespace bool
51+
// Wait blocks until all resources are ready.
52+
Wait bool
53+
// GenerateName generates a random release name with ReleaseName as prefix.
54+
GenerateName bool
55+
// Values are the chart values to use for the installation.
56+
Values map[string]interface{}
57+
// HelmLoggerFunc is called for Helm operation logging.
58+
HelmLoggerFunc func(format string, v ...interface{})
59+
// Logger is the full logger for component operations.
60+
Logger *utils.Logger
61+
// RepoURL is the base URL of the Helm repository (optional, for direct chart downloads).
62+
RepoURL string
63+
}
64+
65+
// Validate validates and sets defaults for the configuration.
66+
func (c *HelmInstallConfig) Validate() error {
67+
if c == nil {
68+
return fmt.Errorf("config cannot be nil")
69+
}
70+
71+
var missing []string
72+
if c.ReleaseName == "" && !c.GenerateName {
73+
missing = append(missing, "release name (or enable GenerateName)")
74+
}
75+
if c.ChartRef == "" {
76+
missing = append(missing, "chart reference")
77+
}
78+
if c.ChartVersion == "" {
79+
missing = append(missing, "chart version")
80+
}
81+
if c.Namespace == "" {
82+
missing = append(missing, "namespace")
83+
}
84+
if len(missing) > 0 {
85+
return fmt.Errorf("missing required fields: %s", strings.Join(missing, ", "))
86+
}
87+
88+
// Set defaults
89+
if c.Values == nil {
90+
c.Values = make(map[string]interface{})
91+
}
92+
if c.HelmLoggerFunc == nil {
93+
c.HelmLoggerFunc = func(_ string, _ ...interface{}) {}
94+
}
95+
return nil
96+
}
97+
98+
// InstallHelmChart installs a Helm chart with the given configuration.
99+
func InstallHelmChart(config *HelmInstallConfig) (*release.Release, error) {
100+
if err := config.Validate(); err != nil {
101+
return nil, err
102+
}
103+
104+
// Initialize Helm action configuration
105+
config.HelmLoggerFunc("Setting up Helm configuration for %s...", config.ReleaseName)
106+
actionConfig, err := setupHelmAction(config)
107+
if err != nil {
108+
return nil, err
109+
}
110+
111+
// Resolve chart location (download from HTTP or locate via Helm)
112+
chartPath, err := resolveChart(actionConfig, config)
113+
if err != nil {
114+
return nil, err
115+
}
116+
117+
// Load and validate the chart
118+
config.HelmLoggerFunc("Loading chart from %s...", chartPath)
119+
chart, err := loader.Load(chartPath)
120+
if err != nil {
121+
return nil, fmt.Errorf("failed to load chart: %w", err)
122+
}
123+
124+
// Install the chart
125+
config.HelmLoggerFunc("Installing chart %s in namespace %s...", config.ChartRef, config.Namespace)
126+
installClient := newInstallClient(actionConfig, config)
127+
rel, err := installClient.Run(chart, config.Values)
128+
if err != nil {
129+
return nil, fmt.Errorf("helm install failed: %w", err)
130+
}
131+
132+
config.HelmLoggerFunc("✅ Release '%s' installed successfully. Status: %s", rel.Name, rel.Info.Status)
133+
return rel, nil
134+
}
135+
136+
// setupHelmAction sets up Helm action configuration.
137+
func setupHelmAction(config *HelmInstallConfig) (*action.Configuration, error) {
138+
actionConfig := new(action.Configuration)
139+
140+
// Create a RESTClientGetter that can handle both a custom rest.Config and the default kubeconfig path.
141+
restClientGetter := newRESTClientGetter(config.Namespace, config.RestConfig)
142+
143+
// Initialize the action configuration with the REST client, namespace, driver, and logger.
144+
if err := actionConfig.Init(restClientGetter, config.Namespace, os.Getenv("HELM_DRIVER"), config.HelmLoggerFunc); err != nil {
145+
return nil, fmt.Errorf("failed to initialize Helm action configuration: %w", err)
146+
}
147+
148+
// Initialize the OCI registry client for pulling charts from OCI registries.
149+
regClient, err := registry.NewClient()
150+
if err != nil {
151+
return nil, fmt.Errorf("failed to create Helm registry client: %w", err)
152+
}
153+
actionConfig.RegistryClient = regClient
154+
155+
return actionConfig, nil
156+
}
157+
158+
// resolveChart determines how to obtain the chart and returns its local path.
159+
func resolveChart(actionConfig *action.Configuration, config *HelmInstallConfig) (string, error) {
160+
// If RepoURL is provided, download the chart directly via HTTP
161+
if config.RepoURL != "" {
162+
config.HelmLoggerFunc("Downloading chart %s version %s...", config.ChartRef, config.ChartVersion)
163+
return downloadChart(config)
164+
}
165+
166+
// Otherwise, use Helm's LocateChart (for OCI registries or local paths)
167+
config.HelmLoggerFunc("Locating chart %s...", config.ChartRef)
168+
installClient := newInstallClient(actionConfig, config)
169+
chartPath, err := installClient.LocateChart(config.ChartRef, cli.New())
170+
if err != nil {
171+
return "", fmt.Errorf("failed to locate chart: %w", err)
172+
}
173+
return chartPath, nil
174+
}
175+
176+
// downloadChart downloads a Helm chart tarball directly via HTTP.
177+
func downloadChart(config *HelmInstallConfig) (string, error) {
178+
// Create a temporary directory for the downloaded chart
179+
tempDir, err := os.MkdirTemp("", "helm-chart-*")
180+
if err != nil {
181+
return "", fmt.Errorf("failed to create temp directory: %w", err)
182+
}
183+
184+
// Construct the chart URL: <repoURL>/charts/<chartName>-<version>.tgz
185+
chartURL := fmt.Sprintf("%s/charts/%s-%s.tgz", config.RepoURL, config.ChartRef, config.ChartVersion)
186+
config.HelmLoggerFunc("Chart URL: %s", chartURL)
187+
188+
// Download the chart using HTTP
189+
resp, err := http.Get(chartURL)
190+
if err != nil {
191+
return "", fmt.Errorf("HTTP GET failed: %w", err)
192+
}
193+
defer resp.Body.Close()
194+
195+
if resp.StatusCode != http.StatusOK {
196+
return "", fmt.Errorf("HTTP request failed with status %d", resp.StatusCode)
197+
}
198+
199+
// Save the chart to a file
200+
chartFileName := fmt.Sprintf("%s-%s.tgz", config.ChartRef, config.ChartVersion)
201+
chartPath := filepath.Join(tempDir, chartFileName)
202+
203+
outFile, err := os.Create(chartPath)
204+
if err != nil {
205+
return "", fmt.Errorf("failed to create chart file: %w", err)
206+
}
207+
defer outFile.Close()
208+
209+
if _, err := io.Copy(outFile, resp.Body); err != nil {
210+
return "", fmt.Errorf("failed to write chart file: %w", err)
211+
}
212+
213+
config.HelmLoggerFunc("Chart downloaded to: %s", chartPath)
214+
return chartPath, nil
215+
}
216+
217+
// newInstallClient creates and configures a Helm install action client from the provided configuration.
218+
func newInstallClient(actionConfig *action.Configuration, config *HelmInstallConfig) *action.Install {
219+
client := action.NewInstall(actionConfig)
220+
221+
client.ReleaseName = config.ReleaseName
222+
client.GenerateName = config.GenerateName
223+
client.Namespace = config.Namespace
224+
client.CreateNamespace = config.CreateNamespace
225+
client.Wait = config.Wait
226+
client.Version = config.ChartVersion
227+
228+
return client
229+
}
230+
231+
// newRESTClientGetter creates a RESTClientGetter for Helm actions
232+
func newRESTClientGetter(namespace string, restConfig *rest.Config) genericclioptions.RESTClientGetter {
233+
flags := genericclioptions.NewConfigFlags(true)
234+
flags.Namespace = &namespace
235+
236+
// Inject custom REST config if provided, otherwise use default kubeconfig
237+
flags.WrapConfigFn = func(c *rest.Config) *rest.Config {
238+
if restConfig != nil {
239+
return restConfig
240+
}
241+
return c
242+
}
243+
244+
return flags
245+
}

0 commit comments

Comments
 (0)