Repository: kubermatic/kubeone

Issue:
KO 1.9.x does not respect customized Addons manifests anymore
### What happened?
We use KubeOne 1.8.6 on our cluster and need to upgrade to Kubernetes 1.31.x. Therefor we need to upgrade to KubeOne 1.9.x, which supports this Kubernetes version.
Before we upgrade, we always first upgrade KubeOne to the newer version against the same Kubernetes version.

When we run KubeOne 1.9.x, it started installing addon `vsphere-csi`, which is not expected and not the behavior we see when using 1.8.6.
We overruled the `ccm-vsphere` and `vsphere-csi` with an empty folder in the addons folder, to make sure that our own manifests is being used. This behavior is described in the [documentation](https://docs.kubermatic.com/kubeone/v1.9/guides/addons/#overriding-embedded-addons) and works like a charm until version 1.8.6.

### Expected behavior
When kubeone runs, it should check the `.\addons\` folder if there are custom manifests for specific addons. Because there is a `vsphere-csi` folder with a yaml file, it should use the files in this folder. But 1.9.x starts installing by using the embeddedAddon files.

### How to reproduce the issue?
1. Create a kubernetes cluster with kubeone
2. Create a `addons\vsphere-csi` folder and place an empty yaml file with the name `empty.yaml` in this folder
3. Run kubeone 1.8.6 against the cluster (verbose) with the values 
```yaml
addons:
  enable: true
  path: ./addons
```
4. Check output and see that the `vsphere-csi` addon is being skipped, because the addon folder is empty. The message shown in the logs are:
```console
INFO[11:55:20 CEST] Applying addon csi-vsphere...
WARN[11:55:20 CEST] Addon directory "csi-vsphere" is empty, skipping...
```
5. Run the kubeone 1.9.2 binary with the same input file (and verbose) and the logs will show the following:
```console
INFO[11:57:57 CEST] Applying addon csi-vsphere...
DEBU[11:57:57 CEST] Parsing addons manifest '1-vsphere-csi-driver.yaml'
DEBU[11:57:57 CEST] Parsing addons manifest '2-validatingwebhook.yaml'
DEBU[11:57:57 CEST] Parsing addons manifest '3-poddisruptionbudgets.yaml'
```
As you see the 1.9.2 version starts parsing and applying manifests which are NOT in the addon folder and therefor must come from the embeddedAddons.

### What KubeOne version are you using?
Tested okay with: 1.8.2 and 1.8.6
Tested and not working with: 1.9.0 / 1.9.1 / 1.9.2

<details>

```console
$ kubeone version
{
  "kubeone": {
    "major": "1",
    "minor": "9",
    "gitVersion": "1.9.2",
    "gitCommit": "191d8a45a9c6a09d8acd5772da27fe732b5f0a11",
    "gitTreeState": "",
    "buildDate": "2025-02-05T12:02:58Z",
    "goVersion": "go1.23.4",
    "compiler": "gc",
    "platform": "linux/amd64"
  },
  "machine_controller": {
    "major": "1",
    "minor": "61",
    "gitVersion": "v1.61.0",
    "gitCommit": "",
    "gitTreeState": "",
    "buildDate": "",
    "goVersion": "",
    "compiler": "",
    "platform": "linux/amd64"
  }
}
```
</details>

### Provide your KubeOneCluster manifest here (if applicable)

<details>

```yaml
apiVersion: kubeone.k8c.io/v1beta2
kind: KubeOneCluster
versions:
  kubernetes: 1.30.5
addons:
  enable: true
  path: ./addons
features:
  staticAuditLog:
    enable: true
    config:
      policyFilePath: audit-policy.yaml
      logMaxAge: 3
  openidConnect:
    enable: true
    config:
      issuerUrl: https://REDACTED/dex
      clientId: kubernetes
      usernameClaim: email
      usernamePrefix: 'oidc:'
      groupsClaim: groups
      groupsPrefix: 'oidc:'
containerRuntime:
  containerd: {}
cloudProvider:
  vsphere: {}
  external: true
  cloudConfig: |
    [Global]
    secret-name = "vsphere-ccm-credentials"
    secret-namespace = "kube-system"
    port = "443"
    insecure-flag = "0"

    [VirtualCenter "REDACTED"]

    [Workspace]
    server = "REDACTED"
    datacenter = "REDACTED"
    default-datastore = "REDACTED"
    resourcepool-path = "/REDACTED/vm/REDACTED"
    folder = "/REDACTED/vm/REDACTED"

    [Disk]
    scsicontrollertype = pvscsi

    [Network]
    public-network = "REDACTED"

    [Labels]
    region = k8s-region
    zone = k8s-zone
  csiConfig: |
    cloudConfig: |
    [Global]
    secret-name = "vsphere-ccm-credentials"
    secret-namespace = "kube-system"
    port = "443"
    insecure-flag = "0"

    [VirtualCenter "REDACTED"]

    [Workspace]
    server = "REDACTED"
    datacenter = "REDACTED"
    default-datastore = "REDACTED"
    resourcepool-path = "/REDACTED/vm/REDACTED"
    folder = "/REDACTED/vm/REDACTED"

    [Disk]
    scsicontrollertype = pvscsi

    [Network]
    public-network = "REDACTED"

    [Labels]
    region = k8s-region
    zone = k8s-zone
```

</details>

### What cloud provider are you running on?
VMware vSphere

### What operating system are you running in your cluster?
Ubuntu 22.04

### Additional information
We don't want the CSI en CCM being installed by Kubeone, therefor we filter out addons `ccm-vsphere` `csi-external-snapshotter` `csi-vsphere` `csi-vsphere-ks` by creating an empty addons folder.

Hints from issue comments:
@PoudNL: After some investigation, we found change #3248 that updated file `apis/kubeone/helpers.go` on line [386](https://github.com/kubermatic/kubeone/blob/0707abd50bc36f5271fd4e3ae41692f058455b05/pkg/apis/kubeone/helpers.go#L385C1-L387C2) causes the problem.

The function that evaluates if addons are enabled is updated and evaluates to False when no `addons.addons` is not supplied in `kubeone.yaml`. The documentation does not describe that this field is mandatory and also the changelog does not mention anything about this changed behavior. Or are we missing something?

As a workaround we currently updated the code to make sure it evaluates to TRUE
```go
func (ads *Addons) Enabled() bool {
	return ads != nil
}
```

After looking further in the API definition, we see a new API in development `kubeone.k8c.io/v1beta3` but there is no documentation found of this new API.
In the code we see that the [struct for the addon](https://github.com/kubermatic/kubeone/blob/33f27b00235957ac23da431b9bcbbd7486fe8a8e/pkg/apis/kubeone/v1beta3/types.go#L1017C1-L1023C2) has been updated in comparison with the `v1beta2`. In this new API version the `Enabled` boolean is not there anymore. This explains the code change, but it seems to me a important breaking change if this was intended.
@kron4eg: IDK, I've tried to reproduce.

Provider was AWS (with terraform)

```yaml
apiVersion: kubeone.k8c.io/v1beta2
kind: KubeOneCluster

versions:
  kubernetes: "1.32.1"

addons:
  enable: true
  path: ./addons
```

And addons:
```shell
$ ll -T addons
 addons
└──  csi-aws-ebs
   └──  cm.yaml

$ cat addons/csi-aws-ebs/cm.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: csi-aws-ebs
  namespace: default
data:
  player_initial_lives: "3"
```

and I got this CM in the cluster and no
```yaml
kind: DaemonSet
apiVersion: apps/v1
metadata:
  name: ebs-csi-node
  namespace: kube-system
```
near.

Then I've tried to delete the `cm.yaml` file, so there will be only `csi-aws-ebs` which is empty dir.

Yes, I got the log message about empty dir `csi-aws-ebs` but still no `ebs-csi-node` daemonset in kube-system (which is automatically selected for CSI when we use AWS as a provider).

```
INFO[16:06:20 EEST] Applying addon coredns-pdb...
INFO[16:06:22 EEST] Applying addon metrics-server...
INFO[16:06:24 EEST] Applying addon nodelocaldns...
INFO[16:06:26 EEST] Applying addon machinecontroller...
INFO[16:06:35 EEST] Applying addon operating-system-manager...
INFO[16:06:47 EEST] Applying addon csi-aws-ebs...
WARN[16:06:47 EEST] Addon directory "csi-aws-ebs" is empty, skipping...
INFO[16:06:47 EEST] Applying addon csi-external-snapshotter...
INFO[16:06:51 EEST] Applying addon ccm-aws...
```

To test used latest main as of now.@PoudNL: The issues still exists with the latest version. Can still reproduce the issue. @kron4eg @xmudrii: Thanks for reporting @PoudNL, we'll look into it!
/reopen@kubermatic-bot: @xmudrii: Reopened this issue.

<details>

In response to [this](https://github.com/kubermatic/kubeone/issues/3629#issuecomment-3233028759):

>Thanks for reporting @PoudNL, we'll look into it!
>/reopen


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>@kubermatic-bot: Issues go stale after 90d of inactivity.
After a furter 30 days, they will turn rotten.
Mark the issue as fresh with `/remove-lifecycle stale`.

If this issue is safe to close now please do so with `/close`.

/lifecycle stale@appiepollo14: /remove-lifecycle stale as I've provided a PR and this is still an issue.@kron4eg: /remove-lifecycle stale

Solution patch:
diff --git a/docs/api_reference/v1beta2.en.md b/docs/api_reference/v1beta2.en.md
index 1f375ded..a05f2997 100644
--- a/docs/api_reference/v1beta2.en.md
+++ b/docs/api_reference/v1beta2.en.md
@@ -1,6 +1,6 @@
 +++
 title = "v1beta2 API Reference"
-date = 2025-10-14T16:55:58+03:00
+date = 2025-11-26T13:51:14+01:00
 weight = 11
 +++
 ## v1beta2
@@ -35,6 +35,7 @@ weight = 11
 * [ExternalCNISpec](#externalcnispec)
 * [Features](#features)
 * [GCESpec](#gcespec)
+* [HelmAuth](#helmauth)
 * [HelmRelease](#helmrelease)
 * [HelmValues](#helmvalues)
 * [HetznerSpec](#hetznerspec)
@@ -434,6 +435,17 @@ GCESpec defines the GCE cloud provider
 
 [Back to Group](#v1beta2)
 
+### HelmAuth
+
+
+
+| Field | Description | Scheme | Required |
+| ----- | ----------- | ------ | -------- |
+| username | Username for chart repository authentication. | string | false |
+| password | Password for chart repository authentication. | string | false |
+
+[Back to Group](#v1beta2)
+
 ### HelmRelease
 
 
@@ -447,8 +459,10 @@ GCESpec defines the GCE cloud provider
 | releaseName | ReleaseName is [RELEASE] part of the `helm upgrade [RELEASE] [CHART]` command. Empty is defaulted to chart. | string | false |
 | namespace | Namespace is --namespace flag of the `helm upgrade` command. A namespace to use for a release. | string | true |
 | wait | Wait is --wait flag of the `helm install` command. | bool | false |
+| insecure | Insecure enables insecure TLS connection when fetching the chart | bool | false |
 | timeout | WaitTimeout --timeout flag of the `helm install` command. | metav1.Duration | false |
 | values | Values provide optional overrides of the helm values. | [][HelmValues](#helmvalues) | false |
+| auth | Auth is used for chart repository authentication. | *[HelmAuth](#helmauth) | false |
 
 [Back to Group](#v1beta2)
 
diff --git a/docs/api_reference/v1beta3.en.md b/docs/api_reference/v1beta3.en.md
index 8ad3c73e..db911661 100644
--- a/docs/api_reference/v1beta3.en.md
+++ b/docs/api_reference/v1beta3.en.md
@@ -1,6 +1,6 @@
 +++
 title = "v1beta3 API Reference"
-date = 2025-10-14T16:55:58+03:00
+date = 2025-11-26T13:51:14+01:00
 weight = 11
 +++
 ## v1beta3
@@ -35,6 +35,7 @@ weight = 11
 * [ExternalCNISpec](#externalcnispec)
 * [Features](#features)
 * [GCESpec](#gcespec)
+* [HelmAuth](#helmauth)
 * [HelmRelease](#helmrelease)
 * [HelmValues](#helmvalues)
 * [HetznerSpec](#hetznerspec)
@@ -436,6 +437,17 @@ GCESpec defines the GCE cloud provider
 
 [Back to Group](#v1beta3)
 
+### HelmAuth
+
+
+
+| Field | Description | Scheme | Required |
+| ----- | ----------- | ------ | -------- |
+| username | Username for chart repository authentication. | string | false |
+| password | Password for chart repository authentication. | string | false |
+
+[Back to Group](#v1beta3)
+
 ### HelmRelease
 
 
@@ -450,7 +462,9 @@ GCESpec defines the GCE cloud provider
 | namespace | Namespace is --namespace flag of the `helm upgrade` command. A namespace to use for a release. | string | true |
 | wait | Wait is --wait flag of the `helm install` command. | bool | false |
 | timeout | WaitTimeout --timeout flag of the `helm install` command. Default to 5m | metav1.Duration | false |
+| insecure | Insecure enables insecure TLS connection when fetching the chart | bool | false |
 | values | Values provide optional overrides of the helm values. | [][HelmValues](#helmvalues) | false |
+| auth | Auth is used for chart repository authentication. | *[HelmAuth](#helmauth) | false |
 
 [Back to Group](#v1beta3)
 
diff --git a/pkg/addons/ensure.go b/pkg/addons/ensure.go
index 075d657a..0e6308f4 100644
--- a/pkg/addons/ensure.go
+++ b/pkg/addons/ensure.go
@@ -43,6 +43,7 @@ var embeddedAddons = map[string]string{
 	resources.AddonCCMAzure:               "",
 	resources.AddonCCMDigitalOcean:        "",
 	resources.AddonCCMHetzner:             "",
+	resources.AddonCCMGCP:                 "",
 	resources.AddonCCMOpenStack:           "",
 	resources.AddonCCMEquinixMetal:        "",
 	resources.AddonCCMPacket:              "",
@@ -206,7 +207,7 @@ func EnsureUserAddons(s *state.State) error {
 		}
 	}
 
-	for _, embeddedAddon := range s.Cluster.Addons.OnlyAddons() {
+	for _, embeddedAddon := range s.Cluster.Addons.DeclaredAddonsOnly() {
 		if _, ok := embeddedAddons[embeddedAddon.Name]; ok {
 			continue
 		}
diff --git a/pkg/addons/helpers.go b/pkg/addons/helpers.go
index 0954d8c3..935a06bd 100644
--- a/pkg/addons/helpers.go
+++ b/pkg/addons/helpers.go
@@ -359,7 +359,7 @@ func EmbeddedAddonsOnly(addons *kubeoneapi.Addons) (bool, error) {
 	}
 
 	// Iterate over addons specified in the KubeOneCluster object
-	for _, addon := range addons.OnlyAddons() {
+	for _, addon := range addons.DeclaredAddonsOnly() {
 		embedded := false
 		// Iterate over embedded addons directory to check if the addon exists
 		for _, embeddedAddon := range embeddedAddons {
diff --git a/pkg/addons/list.go b/pkg/addons/list.go
index fdb81468..975f844e 100644
--- a/pkg/addons/list.go
+++ b/pkg/addons/list.go
@@ -107,7 +107,7 @@ func List(s *state.State, outputFormat string) error {
 			}
 		}
 
-		for _, embeddedAddon := range s.Cluster.Addons.OnlyAddons() {
+		for _, embeddedAddon := range s.Cluster.Addons.DeclaredAddonsOnly() {
 			combinedAddons[embeddedAddon.Name] = addonItem{
 				Name:   embeddedAddon.Name,
 				Status: addonStatusInstall,
diff --git a/pkg/addons/manifest.go b/pkg/addons/manifest.go
index 51c2b1a7..6277b3c7 100644
--- a/pkg/addons/manifest.go
+++ b/pkg/addons/manifest.go
@@ -61,7 +61,7 @@ func (a *applier) getManifestsFromDirectory(st *state.State, fsys fs.FS, addonNa
 		disableTemplating = false
 	)
 	if st.Cluster.Addons.Enabled() {
-		for _, addon := range st.Cluster.Addons.OnlyAddons() {
+		for _, addon := range st.Cluster.Addons.DeclaredAddonsOnly() {
 			if addon.Name == addonName {
 				addonParams = addon.Params
 				disableTemplating = addon.DisableTemplating
diff --git a/pkg/apis/kubeone/helpers.go b/pkg/apis/kubeone/helpers.go
index 9080034c..085cf0c7 100644
--- a/pkg/apis/kubeone/helpers.go
+++ b/pkg/apis/kubeone/helpers.go
@@ -402,11 +402,15 @@ func (r *RegistryConfiguration) InsecureRegistryAddress() string {
 	return insecureRegistry
 }
 
+// Enabled returns whether addons are enabled.
+// this is true when either an addonsPath is provided or the addonsRef are provided
 func (ads *Addons) Enabled() bool {
-	return ads != nil && len(ads.Addons) > 0
+	return ads != nil && (ads.Path != "" || len(ads.Addons) > 0)
 }
 
-func (ads *Addons) OnlyAddons() []Addon {
+// DeclaredAddonsOnly returns a slice of Addons which are declared in the kubeone config manifest
+// it does not return any addons which might exist in the addons' path.
+func (ads *Addons) DeclaredAddonsOnly() []Addon {
 	if ads == nil {
 		return nil
 	}
diff --git a/pkg/apis/kubeone/types.go b/pkg/apis/kubeone/types.go
index e96c378b..e0855dcc 100644
--- a/pkg/apis/kubeone/types.go
+++ b/pkg/apis/kubeone/types.go
@@ -180,8 +180,22 @@ type HelmRelease struct {
 	// WaitTimeout --timeout flag of the `helm install` command. Default to 5m
 	WaitTimeout metav1.Duration `json:"timeout,omitempty"`
 
+	// Insecure enables insecure TLS connection when fetching the chart
+	Insecure bool `json:"insecure,omitempty"`
+
 	// Values provide optional overrides of the helm values.
 	Values []HelmValues `json:"values,omitempty"`
+
+	// Auth is used for chart repository authentication.
+	Auth *HelmAuth `json:"auth,omitempty"`
+}
+
+type HelmAuth struct {
+	// Username for chart repository authentication.
+	Username string `json:"username,omitempty"`
+
+	// Password for chart repository authentication.
+	Password string `json:"password,omitempty"`
 }
 
 // HelmValues configure inputs to `helm upgrade --install` command analog.
diff --git a/pkg/apis/kubeone/v1beta2/types.go b/pkg/apis/kubeone/v1beta2/types.go
index 5b1f5266..095a3219 100644
--- a/pkg/apis/kubeone/v1beta2/types.go
+++ b/pkg/apis/kubeone/v1beta2/types.go
@@ -178,11 +178,25 @@ type HelmRelease struct {
 	// Wait is --wait flag of the `helm install` command.
 	Wait bool `json:"wait,omitempty"`
 
+	// Insecure enables insecure TLS connection when fetching the chart
+	Insecure bool `json:"insecure,omitempty"`
+
 	// WaitTimeout --timeout flag of the `helm install` command.
 	WaitTimeout metav1.Duration `json:"timeout,omitempty"`
 
 	// Values provide optional overrides of the helm values.
 	Values []HelmValues `json:"values,omitempty"`
+
+	// Auth is used for chart repository authentication.
+	Auth *HelmAuth `json:"auth,omitempty"`
+}
+
+type HelmAuth struct {
+	// Username for chart repository authentication.
+	Username string `json:"username,omitempty"`
+
+	// Password for chart repository authentication.
+	Password string `json:"password,omitempty"`
 }
 
 // HelmValues configure inputs to `helm upgrade --install` command analog.
diff --git a/pkg/apis/kubeone/v1beta2/zz_generated.conversion.go b/pkg/apis/kubeone/v1beta2/zz_generated.conversion.go
index ea2bc701..5f5a50c0 100644
--- a/pkg/apis/kubeone/v1beta2/zz_generated.conversion.go
+++ b/pkg/apis/kubeone/v1beta2/zz_generated.conversion.go
@@ -294,6 +294,16 @@ func RegisterConversions(s *runtime.Scheme) error {
 	}); err != nil {
 		return err
 	}
+	if err := s.AddGeneratedConversionFunc((*HelmAuth)(nil), (*kubeone.HelmAuth)(nil), func(a, b interface{}, scope conversion.Scope) error {
+		return Convert_v1beta2_HelmAuth_To_kubeone_HelmAuth(a.(*HelmAuth), b.(*kubeone.HelmAuth), scope)
+	}); err != nil {
+		return err
+	}
+	if err := s.AddGeneratedConversionFunc((*kubeone.HelmAuth)(nil), (*HelmAuth)(nil), func(a, b interface{}, scope conversion.Scope) error {
+		return Convert_kubeone_HelmAuth_To_v1beta2_HelmAuth(a.(*kubeone.HelmAuth), b.(*HelmAuth), scope)
+	}); err != nil {
+		return err
+	}
 	if err := s.AddGeneratedConversionFunc((*HelmRelease)(nil), (*kubeone.HelmRelease)(nil), func(a, b interface{}, scope conversion.Scope) error {
 		return Convert_v1beta2_HelmRelease_To_kubeone_HelmRelease(a.(*HelmRelease), b.(*kubeone.HelmRelease), scope)
 	}); err != nil {
@@ -1446,6 +1456,28 @@ func Convert_kubeone_GCESpec_To_v1beta2_GCESpec(in *kubeone.GCESpec, out *GCESpe
 	return autoConvert_kubeone_GCESpec_To_v1beta2_GCESpec(in, out, s)
 }
 
+func autoConvert_v1beta2_HelmAuth_To_kubeone_HelmAuth(in *HelmAuth, out *kubeone.HelmAuth, s conversion.Scope) error {
+	out.Username = in.Username
+	out.Password = in.Password
+	return nil
+}
+
+// Convert_v1beta2_HelmAuth_To_kubeone_HelmAuth is an autogenerated conversion function.
+func Convert_v1beta2_HelmAuth_To_kubeone_HelmAuth(in *HelmAuth, out *kubeone.HelmAuth, s conversion.Scope) error {
+	return autoConvert_v1beta2_HelmAuth_To_kubeone_HelmAuth(in, out, s)
+}
+
+func autoConvert_kubeone_HelmAuth_To_v1beta2_HelmAuth(in *kubeone.HelmAuth, out *HelmAuth, s conversion.Scope) error {
+	out.Username = in.Username
+	out.Password = in.Password
+	return nil
+}
+
+// Convert_kubeone_HelmAuth_To_v1beta2_HelmAuth is an autogenerated conversion function.
+func Convert_kubeone_HelmAuth_To_v1beta2_HelmAuth(in *kubeone.HelmAuth, out *HelmAuth, s conversion.Scope) error {
+	return autoConvert_kubeone_HelmAuth_To_v1beta2_HelmAuth(in, out, s)
+}
+
 func autoConvert_v1beta2_HelmRelease_To_kubeone_HelmRelease(in *HelmRelease, out *kubeone.HelmRelease, s conversion.Scope) error {
 	out.Chart = in.Chart
 	out.RepoURL = in.RepoURL
@@ -1454,8 +1486,10 @@ func autoConvert_v1beta2_HelmRelease_To_kubeone_HelmRelease(in *HelmRelease, out
 	out.ReleaseName = in.ReleaseName
 	out.Namespace = in.Namespace
 	out.Wait = in.Wait
+	out.Insecure = in.Insecure
 	out.WaitTimeout = in.WaitTimeout
 	out.Values = *(*[]kubeone.HelmValues)(unsafe.Pointer(&in.Values))
+	out.Auth = (*kubeone.HelmAuth)(unsafe.Pointer(in.Auth))
 	return nil
 }
 
@@ -1473,7 +1507,9 @@ func autoConvert_kubeone_HelmRelease_To_v1beta2_HelmRelease(in *kubeone.HelmRele
 	out.Namespace = in.Namespace
 	out.Wait = in.Wait
 	out.WaitTimeout = in.WaitTimeout
+	out.Insecure = in.Insecure
 	out.Values = *(*[]HelmValues)(unsafe.Pointer(&in.Values))
+	out.Auth = (*HelmAuth)(unsafe.Pointer(in.Auth))
 	return nil
 }
 
diff --git a/pkg/apis/kubeone/v1beta2/zz_generated.deepcopy.go b/pkg/apis/kubeone/v1beta2/zz_generated.deepcopy.go
index 6594ef72..c8deb9f4 100644
--- a/pkg/apis/kubeone/v1beta2/zz_generated.deepcopy.go
+++ b/pkg/apis/kubeone/v1beta2/zz_generated.deepcopy.go
@@ -789,6 +789,22 @@ func (in *GCESpec) DeepCopy() *GCESpec {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HelmAuth) DeepCopyInto(out *HelmAuth) {
+	*out = *in
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmAuth.
+func (in *HelmAuth) DeepCopy() *HelmAuth {
+	if in == nil {
+		return nil
+	}
+	out := new(HelmAuth)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *HelmRelease) DeepCopyInto(out *HelmRelease) {
 	*out = *in
@@ -800,6 +816,11 @@ func (in *HelmRelease) DeepCopyInto(out *HelmRelease) {
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	if in.Auth != nil {
+		in, out := &in.Auth, &out.Auth
+		*out = new(HelmAuth)
+		**out = **in
+	}
 	return
 }
 
diff --git a/pkg/apis/kubeone/v1beta3/types.go b/pkg/apis/kubeone/v1beta3/types.go
index a55c6f50..94097047 100644
--- a/pkg/apis/kubeone/v1beta3/types.go
+++ b/pkg/apis/kubeone/v1beta3/types.go
@@ -177,8 +177,22 @@ type HelmRelease struct {
 	// WaitTimeout --timeout flag of the `helm install` command. Default to 5m
 	WaitTimeout metav1.Duration `json:"timeout,omitempty"`
 
+	// Insecure enables insecure TLS connection when fetching the chart
+	Insecure bool `json:"insecure,omitempty"`
+
 	// Values provide optional overrides of the helm values.
 	Values []HelmValues `json:"values,omitempty"`
+
+	// Auth is used for chart repository authentication.
+	Auth *HelmAuth `json:"auth,omitempty"`
+}
+
+type HelmAuth struct {
+	// Username for chart repository authentication.
+	Username string `json:"username,omitempty"`
+
+	// Password for chart repository authentication.
+	Password string `json:"password,omitempty"`
 }
 
 // HelmValues configure inputs to `helm upgrade --install` command analog.
diff --git a/pkg/apis/kubeone/v1beta3/zz_generated.conversion.go b/pkg/apis/kubeone/v1beta3/zz_generated.conversion.go
index 99b23912..b7aaf4b2 100644
--- a/pkg/apis/kubeone/v1beta3/zz_generated.conversion.go
+++ b/pkg/apis/kubeone/v1beta3/zz_generated.conversion.go
@@ -324,6 +324,16 @@ func RegisterConversions(s *runtime.Scheme) error {
 	}); err != nil {
 		return err
 	}
+	if err := s.AddGeneratedConversionFunc((*HelmAuth)(nil), (*kubeone.HelmAuth)(nil), func(a, b interface{}, scope conversion.Scope) error {
+		return Convert_v1beta3_HelmAuth_To_kubeone_HelmAuth(a.(*HelmAuth), b.(*kubeone.HelmAuth), scope)
+	}); err != nil {
+		return err
+	}
+	if err := s.AddGeneratedConversionFunc((*kubeone.HelmAuth)(nil), (*HelmAuth)(nil), func(a, b interface{}, scope conversion.Scope) error {
+		return Convert_kubeone_HelmAuth_To_v1beta3_HelmAuth(a.(*kubeone.HelmAuth), b.(*HelmAuth), scope)
+	}); err != nil {
+		return err
+	}
 	if err := s.AddGeneratedConversionFunc((*HelmRelease)(nil), (*kubeone.HelmRelease)(nil), func(a, b interface{}, scope conversion.Scope) error {
 		return Convert_v1beta3_HelmRelease_To_kubeone_HelmRelease(a.(*HelmRelease), b.(*kubeone.HelmRelease), scope)
 	}); err != nil {
@@ -1479,6 +1489,28 @@ func Convert_kubeone_GCESpec_To_v1beta3_GCESpec(in *kubeone.GCESpec, out *GCESpe
 	return autoConvert_kubeone_GCESpec_To_v1beta3_GCESpec(in, out, s)
 }
 
+func autoConvert_v1beta3_HelmAuth_To_kubeone_HelmAuth(in *HelmAuth, out *kubeone.HelmAuth, s conversion.Scope) error {
+	out.Username = in.Username
+	out.Password = in.Password
+	return nil
+}
+
+// Convert_v1beta3_HelmAuth_To_kubeone_HelmAuth is an autogenerated conversion function.
+func Convert_v1beta3_HelmAuth_To_kubeone_HelmAuth(in *HelmAuth, out *kubeone.HelmAuth, s conversion.Scope) error {
+	return autoConvert_v1beta3_HelmAuth_To_kubeone_HelmAuth(in, out, s)
+}
+
+func autoConvert_kubeone_HelmAuth_To_v1beta3_HelmAuth(in *kubeone.HelmAuth, out *HelmAuth, s conversion.Scope) error {
+	out.Username = in.Username
+	out.Password = in.Password
+	return nil
+}
+
+// Convert_kubeone_HelmAuth_To_v1beta3_HelmAuth is an autogenerated conversion function.
+func Convert_kubeone_HelmAuth_To_v1beta3_HelmAuth(in *kubeone.HelmAuth, out *HelmAuth, s conversion.Scope) error {
+	return autoConvert_kubeone_HelmAuth_To_v1beta3_HelmAuth(in, out, s)
+}
+
 func autoConvert_v1beta3_HelmRelease_To_kubeone_HelmRelease(in *HelmRelease, out *kubeone.HelmRelease, s conversion.Scope) error {
 	out.Chart = in.Chart
 	out.RepoURL = in.RepoURL
@@ -1488,7 +1520,9 @@ func autoConvert_v1beta3_HelmRelease_To_kubeone_HelmRelease(in *HelmRelease, out
 	out.Namespace = in.Namespace
 	out.Wait = in.Wait
 	out.WaitTimeout = in.WaitTimeout
+	out.Insecure = in.Insecure
 	out.Values = *(*[]kubeone.HelmValues)(unsafe.Pointer(&in.Values))
+	out.Auth = (*kubeone.HelmAuth)(unsafe.Pointer(in.Auth))
 	return nil
 }
 
@@ -1506,7 +1540,9 @@ func autoConvert_kubeone_HelmRelease_To_v1beta3_HelmRelease(in *kubeone.HelmRele
 	out.Namespace = in.Namespace
 	out.Wait = in.Wait
 	out.WaitTimeout = in.WaitTimeout
+	out.Insecure = in.Insecure
 	out.Values = *(*[]HelmValues)(unsafe.Pointer(&in.Values))
+	out.Auth = (*HelmAuth)(unsafe.Pointer(in.Auth))
 	return nil
 }
 
diff --git a/pkg/apis/kubeone/v1beta3/zz_generated.deepcopy.go b/pkg/apis/kubeone/v1beta3/zz_generated.deepcopy.go
index e31344f7..25ec6a53 100644
--- a/pkg/apis/kubeone/v1beta3/zz_generated.deepcopy.go
+++ b/pkg/apis/kubeone/v1beta3/zz_generated.deepcopy.go
@@ -782,6 +782,22 @@ func (in *GCESpec) DeepCopy() *GCESpec {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HelmAuth) DeepCopyInto(out *HelmAuth) {
+	*out = *in
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmAuth.
+func (in *HelmAuth) DeepCopy() *HelmAuth {
+	if in == nil {
+		return nil
+	}
+	out := new(HelmAuth)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *HelmRelease) DeepCopyInto(out *HelmRelease) {
 	*out = *in
@@ -793,6 +809,11 @@ func (in *HelmRelease) DeepCopyInto(out *HelmRelease) {
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	if in.Auth != nil {
+		in, out := &in.Auth, &out.Auth
+		*out = new(HelmAuth)
+		**out = **in
+	}
 	return
 }
 
diff --git a/pkg/apis/kubeone/validation/validation.go b/pkg/apis/kubeone/validation/validation.go
index 13ef4036..47633337 100644
--- a/pkg/apis/kubeone/validation/validation.go
+++ b/pkg/apis/kubeone/validation/validation.go
@@ -740,6 +740,15 @@ func validateHelmReleases(helmReleases []kubeoneapi.HelmRelease, fldPath *field.
 			allErrs = append(allErrs, field.Required(fldPath.Child("chart"), hr.Chart))
 		}
 
+		if hr.Auth != nil {
+			if hr.Auth.Username != "" && hr.Auth.Password == "" {
+				allErrs = append(allErrs, field.Required(fldPath.Child("auth").Child("password"), "password is required when username is set"))
+			}
+			if hr.Auth.Password != "" && hr.Auth.Username == "" {
+				allErrs = append(allErrs, field.Required(fldPath.Child("auth").Child("username"), "username is required when password is set"))
+			}
+		}
+
 		if hr.Namespace == "" {
 			allErrs = append(allErrs, field.Required(fldPath.Child("namespace"), hr.Namespace))
 		}
diff --git a/pkg/apis/kubeone/zz_generated.deepcopy.go b/pkg/apis/kubeone/zz_generated.deepcopy.go
index 6d8ca15e..0cf577cc 100644
--- a/pkg/apis/kubeone/zz_generated.deepcopy.go
+++ b/pkg/apis/kubeone/zz_generated.deepcopy.go
@@ -822,6 +822,22 @@ func (in *GCESpec) DeepCopy() *GCESpec {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HelmAuth) DeepCopyInto(out *HelmAuth) {
+	*out = *in
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmAuth.
+func (in *HelmAuth) DeepCopy() *HelmAuth {
+	if in == nil {
+		return nil
+	}
+	out := new(HelmAuth)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *HelmRelease) DeepCopyInto(out *HelmRelease) {
 	*out = *in
@@ -833,6 +849,11 @@ func (in *HelmRelease) DeepCopyInto(out *HelmRelease) {
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	if in.Auth != nil {
+		in, out := &in.Auth, &out.Auth
+		*out = new(HelmAuth)
+		**out = **in
+	}
 	return
 }
 
diff --git a/pkg/cmd/local.go b/pkg/cmd/local.go
index 3ae4688b..8f9525a3 100644
--- a/pkg/cmd/local.go
+++ b/pkg/cmd/local.go
@@ -92,13 +92,13 @@ func (opts *localOpts) BuildState() (*state.State, error) {
 		}
 
 		// Check if only embedded addons are being used; path is not required for embedded addons and no validation is required
-		embeddedAddonsOnly, err := addons.EmbeddedAddonsOnly(s.Cluster.Addons)
+		_, err = addons.EmbeddedAddonsOnly(s.Cluster.Addons)
 		if err != nil {
 			return nil, err
 		}
 
-		// If custom addons are being used then addons path is required and should be a valid directory
-		if !embeddedAddonsOnly {
+		// If an addons path is provided, that should be a valid directory
+		if addonsPath != "" {
 			if _, err := os.Stat(addonsPath); os.IsNotExist(err) {
 				return nil, fail.Runtime(err, "checking addons directory")
 			}
diff --git a/pkg/cmd/shared.go b/pkg/cmd/shared.go
index 1bcce2e6..f85d8160 100644
--- a/pkg/cmd/shared.go
+++ b/pkg/cmd/shared.go
@@ -86,13 +86,13 @@ func (opts *globalOptions) BuildState() (*state.State, error) {
 		}
 
 		// Check if only embedded addons are being used; path is not required for embedded addons and no validation is required
-		embeddedAddonsOnly, err := addons.EmbeddedAddonsOnly(s.Cluster.Addons)
+		_, err = addons.EmbeddedAddonsOnly(s.Cluster.Addons)
 		if err != nil {
 			return nil, err
 		}
 
-		// If custom addons are being used then addons path is required and should be a valid directory
-		if !embeddedAddonsOnly {
+		// If an addons path is provided, that should be a valid directory
+		if addonsPath != "" {
 			if _, err := os.Stat(addonsPath); os.IsNotExist(err) {
 				return nil, fail.Runtime(err, "checking addons directory")
 			}
diff --git a/pkg/localhelm/helm3.go b/pkg/localhelm/helm3.go
index 962dbf4f..b45ef099 100644
--- a/pkg/localhelm/helm3.go
+++ b/pkg/localhelm/helm3.go
@@ -323,6 +323,15 @@ func upgradeRelease(
 	helmUpgrade.Namespace = release.Namespace
 	helmUpgrade.RepoURL = release.RepoURL
 	helmUpgrade.Version = release.Version
+	helmUpgrade.InsecureSkipTLSverify = release.Insecure
+	helmUpgrade.PlainHTTP = release.Insecure
+	helmUpgrade.Wait = release.Wait
+	helmUpgrade.Timeout = release.WaitTimeout.Duration
+
+	if release.Auth != nil {
+		helmUpgrade.Username = release.Auth.Username
+		helmUpgrade.Password = release.Auth.Password
+	}
 
 	chartRequested, err := getChart(release, helmUpgrade.ChartPathOptions, helmSettings, providers)
 	if err != nil {
@@ -353,6 +362,13 @@ func newHelmInstallClient(cfg *helmaction.Configuration, release kubeoneapi.Helm
 	helmInstall.Version = release.Version
 	helmInstall.Wait = release.Wait
 	helmInstall.Timeout = release.WaitTimeout.Duration
+	helmInstall.InsecureSkipTLSverify = release.Insecure
+	helmInstall.PlainHTTP = release.Insecure
+
+	if release.Auth != nil {
+		helmInstall.Username = release.Auth.Username
+		helmInstall.Password = release.Auth.Password
+	}
 
 	return helmInstall
 }
diff --git a/pkg/templates/resources/resources.go b/pkg/templates/resources/resources.go
index ff532e94..8067cf5a 100644
--- a/pkg/templates/resources/resources.go
+++ b/pkg/templates/resources/resources.go
@@ -64,6 +64,7 @@ func CloudAddons() []string {
 	return []string{
 		AddonCCMAws,
 		AddonCCMAzure,
+		AddonCCMGCP,
 		AddonCCMDigitalOcean,
 		AddonCCMEquinixMetal,
 		AddonCCMHetzner,



Test patch:
diff --git a/pkg/apis/kubeone/helpers_test.go b/pkg/apis/kubeone/helpers_test.go
index 05a80b45..1830a676 100644
--- a/pkg/apis/kubeone/helpers_test.go
+++ b/pkg/apis/kubeone/helpers_test.go
@@ -271,3 +271,69 @@ func TestDefaultAssetConfiguration(t *testing.T) {
 		})
 	}
 }
+
+func TestAddons_Enabled(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name   string
+		addons *Addons
+		want   bool
+	}{
+		{
+			name:   "nil addons",
+			addons: nil,
+			want:   false,
+		},
+		{
+			name:   "empty addons struct",
+			addons: &Addons{},
+			want:   false,
+		},
+		{
+			name: "only path set",
+			addons: &Addons{
+				Path: "addons/",
+			},
+			want: true,
+		},
+		{
+			name: "one addon reference (Addon)",
+			addons: &Addons{
+				Addons: []AddonRef{
+					{Addon: &Addon{}},
+				},
+			},
+			want: true,
+		},
+		{
+			name: "one addon reference (HelmRelease)",
+			addons: &Addons{
+				Addons: []AddonRef{
+					{HelmRelease: &HelmRelease{}},
+				},
+			},
+			want: true,
+		},
+		{
+			name: "path + addon ref",
+			addons: &Addons{
+				Path: "addons/",
+				Addons: []AddonRef{
+					{Addon: &Addon{Name: "addon1"}},
+				},
+			},
+			want: true,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			got := tc.addons.Enabled()
+			if got != tc.want {
+				t.Errorf("Enabled() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
diff --git a/pkg/apis/kubeone/validation/validation_test.go b/pkg/apis/kubeone/validation/validation_test.go
index ca8536ae..611127dd 100644
--- a/pkg/apis/kubeone/validation/validation_test.go
+++ b/pkg/apis/kubeone/validation/validation_test.go
@@ -2136,6 +2136,61 @@ func TestValidateAddons(t *testing.T) {
 			},
 			expectedError: true,
 		},
+		{
+			name: "helm release authentication: without password",
+			addons: &kubeoneapi.Addons{
+				Addons: []kubeoneapi.AddonRef{
+					{
+						HelmRelease: &kubeoneapi.HelmRelease{
+							Namespace: "ns1",
+							Chart:     "chart",
+							RepoURL:   "https://repo.localhost/chart-repo",
+							Auth: &kubeoneapi.HelmAuth{
+								Username: "user1",
+							},
+						},
+					},
+				},
+			},
+			expectedError: true,
+		},
+
+		{
+			name: "helm release authentication: without username",
+			addons: &kubeoneapi.Addons{
+				Addons: []kubeoneapi.AddonRef{
+					{
+						HelmRelease: &kubeoneapi.HelmRelease{
+							Namespace: "ns1",
+							Chart:     "chart",
+							RepoURL:   "https://repo.localhost/chart-repo",
+							Auth: &kubeoneapi.HelmAuth{
+								Password: "1234589",
+							},
+						},
+					},
+				},
+			},
+			expectedError: true,
+		},
+
+		{
+			name: "helm release authentication: empty credentials",
+			addons: &kubeoneapi.Addons{
+				Addons: []kubeoneapi.AddonRef{
+					{
+						HelmRelease: &kubeoneapi.HelmRelease{
+							Namespace: "ns1",
+							Chart:     "chart",
+							RepoURL:   "https://repo.localhost/chart-repo",
+							Auth:      &kubeoneapi.HelmAuth{},
+						},
+					},
+				},
+			},
+			expectedError: false,
+		},
+
 		{
 			name: "helm release",
 			addons: &kubeoneapi.Addons{