Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions api/v1beta1/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,16 @@ type HelmOptions struct {
// +optional
PassCredentialsAll bool `json:"passCredentialsAll,omitempty"`

// RunTests if set to true, Sveltos will run helm test after each successful install or upgrade
// operation. The tests are the test hooks defined in the chart (annotated with
// "helm.sh/hook: test"). If any test fails the deployment is considered failed and the
// error is surfaced in the ClusterSummary status, providing operational gating.
// Has no effect in DryRun mode.
// Default to false
// +kubebuilder:default:=false
// +optional
RunTests bool `json:"runTests,omitempty"`

// HelmInstallOptions are options specific to helm install
// +optional
InstallOptions HelmInstallOptions `json:"installOptions,omitempty"`
Expand Down
10 changes: 10 additions & 0 deletions config/crd/bases/config.projectsveltos.io_clusterprofiles.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,16 @@ spec:
description: PassCredentialsAll is the flag to pass credentials
to all domains
type: boolean
runTests:
default: false
description: |-
RunTests if set to true, Sveltos will run helm test after each successful install or upgrade
operation. The tests are the test hooks defined in the chart (annotated with
"helm.sh/hook: test"). If any test fails the deployment is considered failed and the
error is surfaced in the ClusterSummary status, providing operational gating.
Has no effect in DryRun mode.
Default to false
type: boolean
skipCRDs:
default: false
description: |-
Expand Down
10 changes: 10 additions & 0 deletions config/crd/bases/config.projectsveltos.io_clusterpromotions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,16 @@ spec:
description: PassCredentialsAll is the flag to pass
credentials to all domains
type: boolean
runTests:
default: false
description: |-
RunTests if set to true, Sveltos will run helm test after each successful install or upgrade
operation. The tests are the test hooks defined in the chart (annotated with
"helm.sh/hook: test"). If any test fails the deployment is considered failed and the
error is surfaced in the ClusterSummary status, providing operational gating.
Has no effect in DryRun mode.
Default to false
type: boolean
skipCRDs:
default: false
description: |-
Expand Down
10 changes: 10 additions & 0 deletions config/crd/bases/config.projectsveltos.io_clustersummaries.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,16 @@ spec:
description: PassCredentialsAll is the flag to pass
credentials to all domains
type: boolean
runTests:
default: false
description: |-
RunTests if set to true, Sveltos will run helm test after each successful install or upgrade
operation. The tests are the test hooks defined in the chart (annotated with
"helm.sh/hook: test"). If any test fails the deployment is considered failed and the
error is surfaced in the ClusterSummary status, providing operational gating.
Has no effect in DryRun mode.
Default to false
type: boolean
skipCRDs:
default: false
description: |-
Expand Down
10 changes: 10 additions & 0 deletions config/crd/bases/config.projectsveltos.io_profiles.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,16 @@ spec:
description: PassCredentialsAll is the flag to pass credentials
to all domains
type: boolean
runTests:
default: false
description: |-
RunTests if set to true, Sveltos will run helm test after each successful install or upgrade
operation. The tests are the test hooks defined in the chart (annotated with
"helm.sh/hook: test"). If any test fails the deployment is considered failed and the
error is surfaced in the ClusterSummary status, providing operational gating.
Has no effect in DryRun mode.
Default to false
type: boolean
skipCRDs:
default: false
description: |-
Expand Down
131 changes: 127 additions & 4 deletions controllers/handlers_helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -1466,19 +1466,22 @@ func deployHelmChart(ctx context.Context, clusterSummary *configv1beta1.ClusterS
return nil, nil, err
}
var report *configv1beta1.ReleaseReport
deployed := false

if shouldInstall(currentRelease, instantiatedChart) {
_, report, err = handleInstall(ctx, clusterSummary, mgmtResources, instantiatedChart, kubeconfig,
registryOptions, false, false, logger)
if err != nil {
return nil, nil, err
}
deployed = true
} else if shouldUpgrade(ctx, currentRelease, instantiatedChart, clusterSummary, mgmtResources, logger) {
_, report, err = handleUpgrade(ctx, clusterSummary, mgmtResources, instantiatedChart, currentRelease, kubeconfig,
registryOptions, logger)
if err != nil {
return nil, nil, err
}
deployed = true
} else if shouldUninstall(currentRelease, instantiatedChart) {
report, err = handleUninstall(ctx, clusterSummary, instantiatedChart, kubeconfig, registryOptions, logger)
if err != nil {
Expand All @@ -1502,6 +1505,15 @@ func deployHelmChart(ctx context.Context, clusterSummary *configv1beta1.ClusterS
}
}

if deployed && getRunTestsValue(instantiatedChart.Options) &&
clusterSummary.Spec.ClusterProfileSpec.SyncMode != configv1beta1.SyncModeDryRun {

err = runHelmTests(instantiatedChart, kubeconfig, registryOptions, logger)
if err != nil {
return nil, nil, err
}
}

if currentRelease != nil {
err = addExtraMetadata(ctx, instantiatedChart, clusterSummary, kubeconfig, registryOptions, logger)
if err != nil {
Expand Down Expand Up @@ -2944,7 +2956,7 @@ func collectResourcesFromManagedHelmChartsForDriftDetection(ctx context.Context,
return nil, fmt.Errorf("unexpected release type %T", rawResults)
}

resources, err := collectHelmContent(results, install, false, logger)
resources, err := collectHelmContent(results, install, false, false, logger)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -3140,7 +3152,7 @@ func hasHookDeleteAnnotation(obj *unstructured.Unstructured) bool {
}

func collectHelmContent(result *releasev1.Release, helmActionVar helmAction, includeHookResources bool,
logger logr.Logger) ([]*unstructured.Unstructured, error) {
includeTestResources bool, logger logr.Logger) ([]*unstructured.Unstructured, error) {

resources := make([]*unstructured.Unstructured, 0)
if helmActionVar == uninstall {
Expand Down Expand Up @@ -3201,6 +3213,14 @@ func collectHelmContent(result *releasev1.Release, helmActionVar helmAction, inc
resources = append(resources, postHookResources...)
}

if includeTestResources && helmActionVar != uninstall {
testHookResources, err := collectTestHooks(result, logger)
if err != nil {
return nil, err
}
resources = append(resources, testHookResources...)
}

return resources, nil
}

Expand Down Expand Up @@ -3278,6 +3298,32 @@ func collectPostHooks(result *releasev1.Release, helmActionVar helmAction, logge
return resources, nil
}

func collectTestHooks(result *releasev1.Release, logger logr.Logger) ([]*unstructured.Unstructured, error) {
resources := make([]*unstructured.Unstructured, 0)
for _, hook := range result.Hooks {
isTest := false
for _, event := range hook.Events {
if event == releasev1.HookTest {
isTest = true
break
}
}
if !isTest {
continue
}

logger.V(logs.LogDebug).Info(fmt.Sprintf("Test Hook Kind: %s", hook.Kind))

hookResources, err := parseAndAppendResources(hook.Manifest, logger)
if err != nil {
logger.Error(err, "failed to parse test hook manifest")
return nil, err
}
resources = append(resources, hookResources...)
}
return resources, nil
}

func collectHelmDeleteHooks(result *releasev1.Release, logger logr.Logger) ([]*unstructured.Unstructured, error) {
resources := make([]*unstructured.Unstructured, 0)

Expand Down Expand Up @@ -3625,6 +3671,76 @@ func getSubNotesValue(options *configv1beta1.HelmOptions) bool {
return false
}

func getRunTestsValue(options *configv1beta1.HelmOptions) bool {
if options != nil {
return options.RunTests
}

return false
}

// runHelmTests executes helm test hooks for the given release using action.ReleaseTesting.
// It is called after a successful install or upgrade when RunTests is enabled in HelmOptions.
// Any test failure is returned as an error, blocking the deployment lifecycle.
func runHelmTests(requestedChart *configv1beta1.HelmChart, kubeconfig string,
registryOptions *registryClientOptions, logger logr.Logger) error {

logger.V(logs.LogDebug).Info("running helm tests",
"release", requestedChart.ReleaseName,
"namespace", requestedChart.ReleaseNamespace)

actionConfig, err := actionConfigInit(requestedChart.ReleaseNamespace, kubeconfig, registryOptions,
getEnableClientCacheValue(requestedChart.Options))
if err != nil {
return err
}

// Log which test hooks will be executed so operators can observe what is about to run.
// Note: test pods with hook-delete-policy "hook-succeeded" are removed immediately on
// success, so they may never be visible on the cluster even when tests pass.
statusObject := action.NewStatus(actionConfig)
rawRelease, statusErr := statusObject.Run(requestedChart.ReleaseName)
if statusErr == nil {
if rel, ok := rawRelease.(*releasev1.Release); ok {
var testHookNames []string
for _, hook := range rel.Hooks {
for _, event := range hook.Events {
if event == releasev1.HookTest {
testHookNames = append(testHookNames, hook.Name)
break
}
}
}
logger.V(logs.LogDebug).Info(fmt.Sprintf("found %d test hook(s) to execute: %v",
len(testHookNames), testHookNames))
}
}

testClient := action.NewReleaseTesting(actionConfig)
testClient.Namespace = requestedChart.ReleaseNamespace
testClient.Timeout = getTimeoutValue(requestedChart.Options).Duration

_, shutdown, err := testClient.Run(requestedChart.ReleaseName)
if shutdown != nil {
defer func() {
if shutdownErr := shutdown(); shutdownErr != nil {
logger.V(logs.LogDebug).Info(fmt.Sprintf("helm test shutdown error for release %s/%s: %v",
requestedChart.ReleaseNamespace, requestedChart.ReleaseName, shutdownErr))
}
}()
}
if err != nil {
logger.V(logs.LogInfo).Info(fmt.Sprintf("helm tests failed for release %s/%s: %v",
requestedChart.ReleaseNamespace, requestedChart.ReleaseName, err))
return fmt.Errorf("helm test failed for release %s/%s: %w",
requestedChart.ReleaseNamespace, requestedChart.ReleaseName, err)
}

logger.V(logs.LogInfo).Info(fmt.Sprintf("helm tests passed for release %s/%s",
requestedChart.ReleaseNamespace, requestedChart.ReleaseName))
return nil
}

func getHelmInstallClient(ctx context.Context, requestedChart *configv1beta1.HelmChart, kubeconfig string,
registryOptions *registryClientOptions, patches []libsveltosv1beta1.Patch, templateOnly bool, logger logr.Logger,
) (*action.Install, error) {
Expand Down Expand Up @@ -3785,7 +3901,7 @@ func addExtraMetadata(ctx context.Context, requestedChart *configv1beta1.HelmCha
return fmt.Errorf("unexpected release type %T", rawResults)
}

resources, err := collectHelmContent(results, install, true, logger)
resources, err := collectHelmContent(results, install, true, false, logger)
if err != nil {
return err
}
Expand Down Expand Up @@ -4517,7 +4633,7 @@ func prepareChartForAgent(ctx context.Context, clusterSummary *configv1beta1.Clu
if helmActionVar == uninstall && clusterSummary.Spec.ClusterProfileSpec.SyncMode == configv1beta1.SyncModeDryRun {
// if action is install=false and syncMode is dryRun, return empty resources.
} else {
resources, err = collectHelmContent(helmRelease, helmActionVar, true, logger)
resources, err = collectHelmContent(helmRelease, helmActionVar, true, getRunTestsValue(instantiatedChart.Options), logger)
if err != nil {
return nil, nil, err
}
Expand Down Expand Up @@ -4608,6 +4724,7 @@ func splitResources(resources []*unstructured.Unstructured, releaseNamespace str
postRollbackHooks []*unstructured.Unstructured
preDeleteHooks []*unstructured.Unstructured
postDeleteHooks []*unstructured.Unstructured
testHooks []*unstructured.Unstructured
hookDeleteAnnotations []*unstructured.Unstructured
otherResources []*unstructured.Unstructured
)
Expand Down Expand Up @@ -4642,6 +4759,9 @@ func splitResources(resources []*unstructured.Unstructured, releaseNamespace str
} else if isHookResource(resource, "post-delete") {
resource = setNamespace(resource, releaseNamespace)
postDeleteHooks = append(postDeleteHooks, resource)
} else if isHookResource(resource, "test") {
resource = setNamespace(resource, releaseNamespace)
testHooks = append(testHooks, resource)
} else if hasHookDeleteAnnotation(resource) {
resource = setNamespace(resource, releaseNamespace)
hookDeleteAnnotations = append(hookDeleteAnnotations, resource)
Expand Down Expand Up @@ -4682,6 +4802,9 @@ func splitResources(resources []*unstructured.Unstructured, releaseNamespace str
// Put post delete instances in subgroups of no more than 15
result = appendResources(result, postDeleteHooks)

// Put test hook instances in subgroups of no more than 15 (after all other resources)
result = appendResources(result, testHooks)

return result
}

Expand Down
3 changes: 2 additions & 1 deletion controllers/handlers_kustomize.go
Original file line number Diff line number Diff line change
Expand Up @@ -1008,7 +1008,8 @@ func deployKustomizeResources(ctx context.Context, c client.Client, remoteRestCo
key := fmt.Sprintf("%s-%s-%s", ref.Kind, ref.Namespace, ref.Name)
bundleResources[key] = convertPointerSliceToValueSlice(objectsToDeployRemotely)

setters := prepareBundleSettersWithResourceInfo(ref.Kind, ref.Namespace, ref.Name, kustomizationRef.Tier)
setters := prepareBundleSettersWithResourceInfo(ref.Kind, ref.Namespace, ref.Name, kustomizationRef.Tier,
kustomizationRef.SkipNamespaceCreation)

return localReports, nil, pullmode.StageResourcesForDeployment(ctx, getManagementClusterClient(),
clusterSummary.Spec.ClusterNamespace, clusterSummary.Spec.ClusterName, configv1beta1.ClusterSummaryKind,
Expand Down
6 changes: 3 additions & 3 deletions controllers/handlers_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ func deployContent(ctx context.Context, deployingToMgmtCluster bool, destConfig
// In pull mode we return reports with action Create. Those will only be used to update deployed GVK.
// sveltos-applier will take care of sending proper reports

setters := prepareBundleSettersWithResourceInfo(ref.Kind, ref.Namespace, ref.Name, referenceTier)
setters := prepareBundleSettersWithResourceInfo(ref.Kind, ref.Namespace, ref.Name, referenceTier, skipNamespaceCreation)

return prepareReports(resources),
pullmode.StageResourcesForDeployment(ctx, getManagementClusterClient(),
Expand Down Expand Up @@ -1651,12 +1651,12 @@ func getFileWithKubeconfig(ctx context.Context, c client.Client, clusterSummary
}

func prepareBundleSettersWithResourceInfo(referenceKind, referenceNamespace, referenceName string,
tier int32) []pullmode.BundleOption {
tier int32, skipNamespaceCreation bool) []pullmode.BundleOption {

setters := make([]pullmode.BundleOption, 0)

setters = append(setters,
pullmode.WithResourceInfo(referenceKind, referenceNamespace, referenceName, tier))
pullmode.WithResourceInfo(referenceKind, referenceNamespace, referenceName, tier, skipNamespaceCreation))

return setters
}
Expand Down
4 changes: 2 additions & 2 deletions examples/prometheus-grafana.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ spec:
releaseName: prometheus
releaseNamespace: prometheus
helmChartAction: Install
- repositoryURL: https://grafana.github.io/helm-charts
- repositoryURL: https://grafana-community.github.io/helm-charts
repositoryName: grafana
chartName: grafana/grafana
chartVersion: 10.5.15
chartVersion: 11.3.6
releaseName: grafana
releaseNamespace: grafana
helmChartAction: Install
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ require (
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/pkg/errors v0.9.1
github.com/projectsveltos/libsveltos v1.7.0
github.com/projectsveltos/libsveltos v1.7.1-0.20260402195729-75fb826e8eb9
github.com/prometheus/client_golang v1.23.2
github.com/robfig/cron v1.2.0
github.com/spf13/pflag v1.0.10
Expand Down
Loading