This is an automated email from the ASF dual-hosted git repository.

guoqqqi pushed a commit to branch codex/etcd-tls-options-without-client-cert
in repository https://gitbox.apache.org/repos/asf/apisix-helm-chart.git

commit 60387f35c3da8a9436e7761b611fa92494e4b164
Author: Qi Guo <[email protected]>
AuthorDate: Wed Nov 11 01:33:03 2026 +0800

    fix(apisix): allow etcd TLS options without client certificates
    
    Signed-off-by: Qi Guo <[email protected]>
---
 .github/workflows/ci.yaml              |   1 +
 charts/apisix/Chart.yaml               |   2 +-
 charts/apisix/README.md                |  47 +++++++++-
 charts/apisix/README.md.gotmpl         |  41 +++++++++
 charts/apisix/templates/configmap.yaml |   4 +-
 charts/apisix/values.yaml              |   6 +-
 test/render/test_etcd_tls.py           | 161 +++++++++++++++++++++++++++++++++
 7 files changed, 253 insertions(+), 9 deletions(-)

diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index e2f15d3..b2b5e3a 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -33,6 +33,7 @@ jobs:
                && cd ../.. \
                && helm repo add bitnami https://charts.bitnami.com/bitnami \
                && helm repo add apisix 
https://apache.github.io/apisix-helm-chart \
+               && python3 test/render/test_etcd_tls.py \
                && ct lint \
                   --validate-maintainers=false \
                   --charts charts/apisix \
diff --git a/charts/apisix/Chart.yaml b/charts/apisix/Chart.yaml
index f442860..b0a1a21 100644
--- a/charts/apisix/Chart.yaml
+++ b/charts/apisix/Chart.yaml
@@ -31,7 +31,7 @@ type: application
 # This is the chart version. This version number should be incremented each 
time you make changes
 # to the chart and its templates, including the app version.
 # Versions are expected to follow Semantic Versioning (https://semver.org/)
-version: 2.18.2
+version: 2.18.3
 
 # This is the version number of the application being deployed. This version 
number should be
 # incremented each time you make changes to the application. Versions are not 
expected to
diff --git a/charts/apisix/README.md b/charts/apisix/README.md
index ed6844c..4fb056e 100644
--- a/charts/apisix/README.md
+++ b/charts/apisix/README.md
@@ -72,6 +72,47 @@ OpenShift that assign a UID, omit the explicit `runAsUser` 
value.
 These options do not configure `extraInitContainers` and have no effect when
 `etcd.enabled` is false.
 
+## Connect to external etcd with server-only TLS
+
+For external etcd, the URL scheme in `externalEtcd.host` selects HTTP or HTTPS.
+`etcd.auth.tls.enabled` controls client certificates; leave it `false` when 
etcd
+does not require a client certificate. The `verify` and `sni` settings apply
+independently of that flag.
+
+For example, to connect to an IP endpoint using a CA stored in a Secret:
+
+```yaml
+apisix:
+  ssl:
+    enabled: true
+    existingCASecret: etcd-ca
+    certCAFilename: ca.crt
+etcd:
+  enabled: false
+  auth:
+    tls:
+      enabled: false
+      verify: true
+      sni: etcd.example.test
+externalEtcd:
+  host:
+    - https://192.0.2.10:2379
+  user: "" # This example assumes etcd username/password authentication is 
disabled.
+```
+
+Replace the endpoint, Secret name, CA key and SNI with your actual values. The 
CA
+Secret must be in the same namespace as APISIX. If etcd username/password
+authentication is enabled, configure `externalEtcd.user` and its password or
+password Secret as usual.
+
+The SNI must be a DNS name covered by every endpoint's server certificate. It
+does not change the connection address or require DNS resolution when the
+endpoints are IP addresses. An empty SNI is omitted from the APISIX 
configuration
+so the client can use the endpoint hostname.
+
+For mTLS, set `etcd.auth.tls.enabled: true` and configure `existingSecret`,
+`certFilename` and `certKeyFilename` under `etcd.auth.tls` as usual.
+
 ## Parameters
 
 ## Values
@@ -255,10 +296,10 @@ These options do not configure `extraInitContainers` and 
have no effect when
 | etcd.auth.rbac.rootPassword | string | `""` | root password for etcd. 
Requires etcd.auth.rbac.create to be true. |
 | etcd.auth.tls.certFilename | string | `""` | etcd client cert filename using 
in etcd.auth.tls.existingSecret |
 | etcd.auth.tls.certKeyFilename | string | `""` | etcd client cert key 
filename using in etcd.auth.tls.existingSecret |
-| etcd.auth.tls.enabled | bool | `false` | enable etcd client certificate |
+| etcd.auth.tls.enabled | bool | `false` | Enable etcd client certificates. 
For external etcd, select HTTPS with externalEtcd.host; verify and sni also 
apply when client certificates are disabled. |
 | etcd.auth.tls.existingSecret | string | `""` | name of the secret contains 
etcd client cert |
-| etcd.auth.tls.sni | string | `""` | specify the TLS Server Name Indication 
extension, the ETCD endpoint hostname will be used when this setting is unset. 
Set it to a DNS name in the etcd certificate if the etcd endpoints are IP 
addresses, since IP SANs are not matched. |
-| etcd.auth.tls.verify | bool | `true` | whether to verify the etcd endpoint 
certificate when setup a TLS connection to etcd |
+| etcd.auth.tls.sni | string | `""` | TLS server name, independently of client 
certificate authentication. Omitted when empty so the endpoint hostname is 
used. For IP endpoints, set a DNS name covered by every endpoint certificate, 
since IP SANs are not matched. |
+| etcd.auth.tls.verify | bool | `true` | Whether to verify the etcd endpoint 
certificate when setting up TLS, independently of client certificate 
authentication. |
 | etcd.autoCompactionMode | string | `"periodic"` | Auto compaction mode 
(periodic or revision), only used when etcd.enabled is true |
 | etcd.autoCompactionRetention | string | `"1h"` | Auto compaction retention 
for mvcc key value store, only used when etcd.enabled is true |
 | etcd.containerSecurityContext | object | `{"enabled":false}` | added for 
backward compatibility with old kubernetes versions, as seccompProfile is not 
supported in kubernetes < 1.19 |
diff --git a/charts/apisix/README.md.gotmpl b/charts/apisix/README.md.gotmpl
index dad784c..7b9e8fb 100644
--- a/charts/apisix/README.md.gotmpl
+++ b/charts/apisix/README.md.gotmpl
@@ -72,6 +72,47 @@ OpenShift that assign a UID, omit the explicit `runAsUser` 
value.
 These options do not configure `extraInitContainers` and have no effect when
 `etcd.enabled` is false.
 
+## Connect to external etcd with server-only TLS
+
+For external etcd, the URL scheme in `externalEtcd.host` selects HTTP or HTTPS.
+`etcd.auth.tls.enabled` controls client certificates; leave it `false` when 
etcd
+does not require a client certificate. The `verify` and `sni` settings apply
+independently of that flag.
+
+For example, to connect to an IP endpoint using a CA stored in a Secret:
+
+```yaml
+apisix:
+  ssl:
+    enabled: true
+    existingCASecret: etcd-ca
+    certCAFilename: ca.crt
+etcd:
+  enabled: false
+  auth:
+    tls:
+      enabled: false
+      verify: true
+      sni: etcd.example.test
+externalEtcd:
+  host:
+    - https://192.0.2.10:2379
+  user: "" # This example assumes etcd username/password authentication is 
disabled.
+```
+
+Replace the endpoint, Secret name, CA key and SNI with your actual values. The 
CA
+Secret must be in the same namespace as APISIX. If etcd username/password
+authentication is enabled, configure `externalEtcd.user` and its password or
+password Secret as usual.
+
+The SNI must be a DNS name covered by every endpoint's server certificate. It
+does not change the connection address or require DNS resolution when the
+endpoints are IP addresses. An empty SNI is omitted from the APISIX 
configuration
+so the client can use the endpoint hostname.
+
+For mTLS, set `etcd.auth.tls.enabled: true` and configure `existingSecret`,
+`certFilename` and `certKeyFilename` under `etcd.auth.tls` as usual.
+
 ## Parameters
 
 {{ template "chart.valuesSection" . }}
diff --git a/charts/apisix/templates/configmap.yaml 
b/charts/apisix/templates/configmap.yaml
index a30ec1b..cf96eda 100644
--- a/charts/apisix/templates/configmap.yaml
+++ b/charts/apisix/templates/configmap.yaml
@@ -438,15 +438,15 @@ data:
         user: "root"
         password: "{{ print "${{APISIX_ETCD_PASSWORD}}" }}"
         {{- end }}
-        {{- if .Values.etcd.auth.tls.enabled }}
         tls:
+          {{- if .Values.etcd.auth.tls.enabled }}
           cert: "/etcd-ssl/{{ .Values.etcd.auth.tls.certFilename }}"
           key: "/etcd-ssl/{{ .Values.etcd.auth.tls.certKeyFilename }}"
+          {{- end }}
           verify: {{ .Values.etcd.auth.tls.verify }}
           {{- with .Values.etcd.auth.tls.sni }}
           sni: {{ . | quote }}
           {{- end }}
-        {{- end }}
       {{- end }}
       {{- end }}
      
diff --git a/charts/apisix/values.yaml b/charts/apisix/values.yaml
index 1f2156a..a5f91d1 100644
--- a/charts/apisix/values.yaml
+++ b/charts/apisix/values.yaml
@@ -892,7 +892,7 @@ etcd:
       # -- root password for etcd. Requires etcd.auth.rbac.create to be true.
       rootPassword: ""
     tls:
-      # -- enable etcd client certificate
+      # -- Enable etcd client certificates. For external etcd, select HTTPS 
with externalEtcd.host; verify and sni also apply when client certificates are 
disabled.
       enabled: false
       # -- name of the secret contains etcd client cert
       existingSecret: ""
@@ -900,9 +900,9 @@ etcd:
       certFilename: ""
       # -- etcd client cert key filename using in etcd.auth.tls.existingSecret
       certKeyFilename: ""
-      # -- whether to verify the etcd endpoint certificate when setup a TLS 
connection to etcd
+      # -- Whether to verify the etcd endpoint certificate when setting up 
TLS, independently of client certificate authentication.
       verify: true
-      # -- specify the TLS Server Name Indication extension, the ETCD endpoint 
hostname will be used when this setting is unset. Set it to a DNS name in the 
etcd certificate if the etcd endpoints are IP addresses, since IP SANs are not 
matched.
+      # -- TLS server name, independently of client certificate 
authentication. Omitted when empty so the endpoint hostname is used. For IP 
endpoints, set a DNS name covered by every endpoint certificate, since IP SANs 
are not matched.
       sni: ""
 
   # -- ref: 
https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-the-security-context-for-a-container
diff --git a/test/render/test_etcd_tls.py b/test/render/test_etcd_tls.py
new file mode 100644
index 0000000..0261ab8
--- /dev/null
+++ b/test/render/test_etcd_tls.py
@@ -0,0 +1,161 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Render etcd TLS settings with Helm and PyYAML; no cluster is required."""
+
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+import yaml
+
+
+CHART = Path(__file__).resolve().parents[2] / "charts" / "apisix"
+
+
+def external_etcd_values(client_auth=False, sni="etcd.example.test", 
verify=True):
+    return {
+        "apisix": {
+            "ssl": {
+                "enabled": True,
+                "existingCASecret": "etcd-ca",
+                "certCAFilename": "ca.crt",
+            }
+        },
+        "etcd": {
+            "enabled": False,
+            "auth": {
+                "tls": {
+                    "enabled": client_auth,
+                    "existingSecret": "etcd-client",
+                    "certFilename": "client.crt",
+                    "certKeyFilename": "client.key",
+                    "verify": verify,
+                    "sni": sni,
+                }
+            },
+        },
+        "externalEtcd": {"host": ["https://192.0.2.10:2379";], "user": ""},
+    }
+
+
+def render(values):
+    with tempfile.TemporaryDirectory() as directory:
+        path = Path(directory) / "values.yaml"
+        path.write_text(yaml.safe_dump(values), encoding="utf-8")
+        result = subprocess.run(
+            ["helm", "template", "test-etcd", str(CHART), "-f", str(path)],
+            check=True,
+            capture_output=True,
+            text=True,
+        )
+    resources = [resource for resource in yaml.safe_load_all(result.stdout) if 
resource]
+    config_map = next(
+        resource
+        for resource in resources
+        if resource.get("kind") == "ConfigMap"
+        and "config.yaml" in resource.get("data", {})
+    )
+    workload = next(
+        resource
+        for resource in resources
+        if resource.get("kind") in ("Deployment", "DaemonSet")
+    )
+    return (
+        yaml.safe_load(config_map["data"]["config.yaml"]),
+        workload["spec"]["template"]["spec"],
+    )
+
+
+class EtcdTLSRenderingTest(unittest.TestCase):
+    def assert_client_mount(self, pod, enabled):
+        container = next(item for item in pod["containers"] if item["name"] == 
"apisix")
+        volumes = [item for item in pod["volumes"] if item["name"] == 
"etcd-ssl"]
+        mounts = [
+            item for item in container["volumeMounts"] if item["mountPath"] == 
"/etcd-ssl"
+        ]
+        if enabled:
+            self.assertEqual(len(volumes), 1)
+            self.assertEqual(volumes[0]["secret"]["secretName"], "etcd-client")
+            self.assertEqual(len(mounts), 1)
+            self.assertEqual(mounts[0]["name"], "etcd-ssl")
+        else:
+            self.assertEqual(volumes, [])
+            self.assertEqual(mounts, [])
+
+    def 
test_server_only_tls_keeps_sni_and_verify_without_client_credentials(self):
+        for daemon_set in (False, True):
+            for verify in (False, True):
+                with self.subTest(daemon_set=daemon_set, verify=verify):
+                    values = external_etcd_values(verify=verify)
+                    values["useDaemonSet"] = daemon_set
+                    config, pod = render(values)
+                    self.assertEqual(
+                        config["deployment"]["etcd"]["tls"],
+                        {"verify": verify, "sni": "etcd.example.test"},
+                    )
+                    self.assertEqual(
+                        config["apisix"]["ssl"]["ssl_trusted_certificate"],
+                        "/usr/local/apisix/conf/ssl/ca.crt",
+                    )
+                    self.assertEqual(
+                        config["deployment"]["etcd"]["host"],
+                        ["https://192.0.2.10:2379";],
+                    )
+                    self.assert_client_mount(pod, False)
+
+    def test_mtls_keeps_client_credentials_and_mounts(self):
+        for daemon_set in (False, True):
+            with self.subTest(daemon_set=daemon_set):
+                values = external_etcd_values(client_auth=True)
+                values["useDaemonSet"] = daemon_set
+                config, pod = render(values)
+                self.assertEqual(
+                    config["deployment"]["etcd"]["tls"],
+                    {
+                        "cert": "/etcd-ssl/client.crt",
+                        "key": "/etcd-ssl/client.key",
+                        "verify": True,
+                        "sni": "etcd.example.test",
+                    },
+                )
+                self.assert_client_mount(pod, True)
+
+    def test_empty_sni_is_omitted_with_or_without_client_credentials(self):
+        for client_auth in (False, True):
+            with self.subTest(client_auth=client_auth):
+                config, pod = 
render(external_etcd_values(client_auth=client_auth, sni=""))
+                self.assertNotIn("sni", config["deployment"]["etcd"]["tls"])
+                self.assertTrue(config["deployment"]["etcd"]["tls"]["verify"])
+                self.assert_client_mount(pod, client_auth)
+
+    def test_external_http_scheme_is_preserved(self):
+        values = external_etcd_values(sni="")
+        values["externalEtcd"]["host"] = ["http://192.0.2.10:2379";]
+        config, pod = render(values)
+        self.assertEqual(config["deployment"]["etcd"]["host"], 
["http://192.0.2.10:2379";])
+        self.assert_client_mount(pod, False)
+
+    def test_default_bundled_etcd_keeps_http_scheme(self):
+        config, pod = render({})
+        self.assertTrue(
+            all(host.startswith("http://";) for host in 
config["deployment"]["etcd"]["host"])
+        )
+        self.assert_client_mount(pod, False)
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to