diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml
index fb62354466..16fd1d9229 100644
--- a/api-ref/source/parameters.yaml
+++ b/api-ref/source/parameters.yaml
@@ -1320,6 +1320,26 @@ timeout_tcp_inspect-optional:
min_version: 2.1
required: false
type: integer
+tls_container_ref:
+ description: |
+ The reference to the `key manager service
+ `__ secret containing a
+ PKCS12 format certificate/key bundle for ``tls_enabled`` pools for
+ TLS client authentication to the member servers.
+ in: body
+ min_version: 2.8
+ required: true
+ type: string
+tls_container_ref-optional:
+ description: |
+ The reference to the `key manager service
+ `__ secret containing a
+ PKCS12 format certificate/key bundle for ``tls_enabled`` pools for
+ TLS client authentication to the member servers.
+ in: body
+ min_version: 2.8
+ required: false
+ type: string
total_connections:
description: |
The total connections handled.
diff --git a/api-ref/source/v2/examples/pool-create-curl b/api-ref/source/v2/examples/pool-create-curl
index fe12f38383..a2a4fcf72b 100644
--- a/api-ref/source/v2/examples/pool-create-curl
+++ b/api-ref/source/v2/examples/pool-create-curl
@@ -1 +1 @@
-curl -X POST -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"pool":{"lb_algorithm":"ROUND_ROBIN","protocol":"HTTP","description":"Super Round Robin Pool","admin_state_up":true,"session_persistence":{"cookie_name":"ChocolateChip","type":"APP_COOKIE"},"listener_id":"023f2e34-7806-443b-bfae-16c324569a3d","name":"super-pool","tags":["test_tag"]}}' http://198.51.100.10:9876/v2/lbaas/pools
+curl -X POST -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"pool":{"lb_algorithm":"ROUND_ROBIN","protocol":"HTTP","description":"Super Round Robin Pool","admin_state_up":true,"session_persistence":{"cookie_name":"ChocolateChip","type":"APP_COOKIE"},"listener_id":"023f2e34-7806-443b-bfae-16c324569a3d","name":"super-pool","tags":["test_tag"],"tls_container_ref":"http://198.51.100.10:9311/v1/containers/4073846f-1d5e-42e1-a4cf-a7046419d0e6"}}' http://198.51.100.10:9876/v2/lbaas/pools
diff --git a/api-ref/source/v2/examples/pool-create-request.json b/api-ref/source/v2/examples/pool-create-request.json
index 20f46575e5..8b2b7cb322 100644
--- a/api-ref/source/v2/examples/pool-create-request.json
+++ b/api-ref/source/v2/examples/pool-create-request.json
@@ -10,6 +10,7 @@
},
"listener_id": "023f2e34-7806-443b-bfae-16c324569a3d",
"name": "super-pool",
- "tags": ["test_tag"]
+ "tags": ["test_tag"],
+ "tls_container_ref": "http://198.51.100.10:9311/v1/containers/4073846f-1d5e-42e1-a4cf-a7046419d0e6"
}
}
diff --git a/api-ref/source/v2/examples/pool-create-response.json b/api-ref/source/v2/examples/pool-create-response.json
index 4ae94d2c4a..578c61630d 100644
--- a/api-ref/source/v2/examples/pool-create-response.json
+++ b/api-ref/source/v2/examples/pool-create-response.json
@@ -27,6 +27,7 @@
"id": "4029d267-3983-4224-a3d0-afb3fe16a2cd",
"operating_status": "ONLINE",
"name": "super-pool",
- "tags": ["test_tag"]
+ "tags": ["test_tag"],
+ "tls_container_ref": "http://198.51.100.10:9311/v1/containers/4073846f-1d5e-42e1-a4cf-a7046419d0e6"
}
}
diff --git a/api-ref/source/v2/examples/pool-show-response.json b/api-ref/source/v2/examples/pool-show-response.json
index 4ae94d2c4a..578c61630d 100644
--- a/api-ref/source/v2/examples/pool-show-response.json
+++ b/api-ref/source/v2/examples/pool-show-response.json
@@ -27,6 +27,7 @@
"id": "4029d267-3983-4224-a3d0-afb3fe16a2cd",
"operating_status": "ONLINE",
"name": "super-pool",
- "tags": ["test_tag"]
+ "tags": ["test_tag"],
+ "tls_container_ref": "http://198.51.100.10:9311/v1/containers/4073846f-1d5e-42e1-a4cf-a7046419d0e6"
}
}
diff --git a/api-ref/source/v2/examples/pool-update-curl b/api-ref/source/v2/examples/pool-update-curl
index 9468472770..7b9f195ad0 100644
--- a/api-ref/source/v2/examples/pool-update-curl
+++ b/api-ref/source/v2/examples/pool-update-curl
@@ -1 +1 @@
-curl -X PUT -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"pool":{"lb_algorithm":"LEAST_CONNECTIONS","session_persistence":{"type":"SOURCE_IP"},"description":"second description","name":"second_name","tags":["updated_tag"]}}' http://198.51.100.10:9876/v2/lbaas/pools/4029d267-3983-4224-a3d0-afb3fe16a2cd
+curl -X PUT -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"pool":{"lb_algorithm":"LEAST_CONNECTIONS","session_persistence":{"type":"SOURCE_IP"},"description":"second description","name":"second_name","tags":["updated_tag"],"tls_container_ref":"http://198.51.100.10:9311/v1/containers/c1cd501d-3cf9-4873-a11b-a74bebcde929"}}' http://198.51.100.10:9876/v2/lbaas/pools/4029d267-3983-4224-a3d0-afb3fe16a2cd
diff --git a/api-ref/source/v2/examples/pool-update-request.json b/api-ref/source/v2/examples/pool-update-request.json
index 775de253ef..2fcdc2f5d3 100644
--- a/api-ref/source/v2/examples/pool-update-request.json
+++ b/api-ref/source/v2/examples/pool-update-request.json
@@ -6,6 +6,7 @@
},
"description": "Super Least Connections Pool",
"name": "super-least-conn-pool",
- "tags": ["updated_tag"]
+ "tags": ["updated_tag"],
+ "tls_container_ref": "http://198.51.100.10:9311/v1/containers/c1cd501d-3cf9-4873-a11b-a74bebcde929"
}
}
diff --git a/api-ref/source/v2/examples/pool-update-response.json b/api-ref/source/v2/examples/pool-update-response.json
index 1cf7e26d21..8f0a757482 100644
--- a/api-ref/source/v2/examples/pool-update-response.json
+++ b/api-ref/source/v2/examples/pool-update-response.json
@@ -27,6 +27,7 @@
"id": "4029d267-3983-4224-a3d0-afb3fe16a2cd",
"operating_status": "ONLINE",
"name": "super-least-conn-pool",
- "tags": ["updated_tag"]
+ "tags": ["updated_tag"],
+ "tls_container_ref": "http://198.51.100.10:9311/v1/containers/c1cd501d-3cf9-4873-a11b-a74bebcde929"
}
}
diff --git a/api-ref/source/v2/examples/pools-list-response.json b/api-ref/source/v2/examples/pools-list-response.json
index b4d85f5815..a19eb6074f 100644
--- a/api-ref/source/v2/examples/pools-list-response.json
+++ b/api-ref/source/v2/examples/pools-list-response.json
@@ -33,7 +33,8 @@
"id": "ddb2b28f-89e9-45d3-a329-a359c3e39e4a",
"operating_status": "ONLINE",
"name": "round_robin_pool",
- "tags": ["test_tag"]
+ "tags": ["test_tag"],
+ "tls_container_ref": "http://198.51.100.10:9311/v1/containers/4073846f-1d5e-42e1-a4cf-a7046419d0e6"
}
]
}
diff --git a/api-ref/source/v2/pool.inc b/api-ref/source/v2/pool.inc
index 31ced664e9..111898c5e0 100644
--- a/api-ref/source/v2/pool.inc
+++ b/api-ref/source/v2/pool.inc
@@ -61,6 +61,7 @@ Response Parameters
- provisioning_status: provisioning_status
- session_persistence: session_persistence
- tags: tags
+ - tls_container_ref: tls_container_ref
- updated_at: updated_at
Response Example
@@ -169,6 +170,7 @@ Request
- protocol: protocol-pools
- session_persistence: session_persistence-optional
- tags: tags-optional
+ - tls_container_ref: tls_container_ref-optional
.. _session_persistence:
@@ -246,6 +248,7 @@ Response Parameters
- provisioning_status: provisioning_status
- session_persistence: session_persistence
- tags: tags
+ - tls_container_ref: tls_container_ref
- updated_at: updated_at
Response Example
@@ -313,6 +316,7 @@ Response Parameters
- provisioning_status: provisioning_status
- session_persistence: session_persistence
- tags: tags
+ - tls_container_ref: tls_container_ref
- updated_at: updated_at
Response Example
@@ -361,6 +365,7 @@ Request
- pool_id: path-pool-id
- session_persistence: session_persistence-optional
- tags: tags-optional
+ - tls_container_ref: tls_container_ref-optional
Request Example
---------------
@@ -395,6 +400,7 @@ Response Parameters
- provisioning_status: provisioning_status
- session_persistence: session_persistence
- tags: tags
+ - tls_container_ref: tls_container_ref
- updated_at: updated_at
Response Example
diff --git a/doc/source/contributor/guides/providers.rst b/doc/source/contributor/guides/providers.rst
index ea158e3b20..7e0b9a342b 100644
--- a/doc/source/contributor/guides/providers.rst
+++ b/doc/source/contributor/guides/providers.rst
@@ -676,6 +676,11 @@ contain the following:
| | | {'type': 'APP_COOKIE', |
| | | 'cookie_name': } |
+-----------------------+--------+------------------------------------------+
+| tls_container_data | dict | A `TLS container`_ dict. |
++-----------------------+--------+------------------------------------------+
+| tls_container_ref | string | The reference to the secrets |
+| | | container. |
++-----------------------+--------+------------------------------------------+
Delete
^^^^^^
@@ -724,6 +729,11 @@ contain the following:
| | | {'type': 'APP_COOKIE', |
| | | 'cookie_name': } |
+-----------------------+--------+------------------------------------------+
+| tls_container_data | dict | A `TLS container`_ dict. |
++-----------------------+--------+------------------------------------------+
+| tls_container_ref | string | The reference to the secrets |
+| | | container. |
++-----------------------+--------+------------------------------------------+
The pool will be in the ``PENDING_UPDATE`` provisioning_status when it is
passed to the driver. The driver will update the provisioning_status of the
diff --git a/octavia/amphorae/drivers/haproxy/rest_api_driver.py b/octavia/amphorae/drivers/haproxy/rest_api_driver.py
index e682850176..b7441d9510 100644
--- a/octavia/amphorae/drivers/haproxy/rest_api_driver.py
+++ b/octavia/amphorae/drivers/haproxy/rest_api_driver.py
@@ -14,6 +14,7 @@
# under the License.
import functools
import hashlib
+import os
import time
import warnings
@@ -120,6 +121,7 @@ class HaproxyAmphoraLoadBalancerDriver(
listener, listener.client_ca_tls_certificate_id)
crl_filename = self._process_secret(
listener, listener.client_crl_container_id)
+ pool_tls_certs = self._process_listener_pool_certs(listener)
# Generate HaProxy configuration from listener object
config = self.jinja.build_config(
@@ -127,7 +129,8 @@ class HaproxyAmphoraLoadBalancerDriver(
tls_cert=certs['tls_cert'],
haproxy_versions=haproxy_versions,
client_ca_filename=client_ca_filename,
- client_crl=crl_filename)
+ client_crl=crl_filename,
+ pool_tls_certs=pool_tls_certs)
self.client.upload_config(amp, listener.id, config,
timeout_dict=timeout_dict)
self.client.reload_listener(amp, listener.id,
@@ -161,6 +164,7 @@ class HaproxyAmphoraLoadBalancerDriver(
listener, listener.client_ca_tls_certificate_id)
crl_filename = self._process_secret(
listener, listener.client_crl_container_id)
+ pool_tls_certs = self._process_listener_pool_certs(listener)
for amp in listener.load_balancer.amphorae:
if amp.status != consts.DELETED:
@@ -173,7 +177,8 @@ class HaproxyAmphoraLoadBalancerDriver(
tls_cert=certs['tls_cert'],
haproxy_versions=haproxy_versions,
client_ca_filename=client_ca_filename,
- client_crl=crl_filename)
+ client_crl=crl_filename,
+ pool_tls_certs=pool_tls_certs)
self.client.upload_config(amp, listener.id, config)
self.client.reload_listener(amp, listener.id)
@@ -301,12 +306,51 @@ class HaproxyAmphoraLoadBalancerDriver(
return None
context = oslo_context.RequestContext(project_id=listener.project_id)
secret = self.cert_manager.get_secret(context, secret_ref)
- md5 = hashlib.md5(secret.encode('utf-8')).hexdigest() # nosec
- id = hashlib.sha1(secret.encode('utf-8')).hexdigest() # nosec
+ try:
+ secret = secret.encode('utf-8')
+ except AttributeError:
+ pass
+ md5 = hashlib.md5(secret).hexdigest() # nosec
+ id = hashlib.sha1(secret).hexdigest() # nosec
name = '{id}.pem'.format(id=id)
self._apply(self._upload_cert, listener, None, secret, md5, name)
return name
+ def _process_listener_pool_certs(self, listener):
+ # {'POOL-ID': {
+ # 'client_cert': client_full_filename,
+ # 'ca_cert': ca_cert_full_filename,
+ # 'crl': crl_full_filename}}
+ pool_certs_dict = dict()
+ for pool in listener.pools:
+ if pool.id not in pool_certs_dict:
+ pool_certs_dict[pool.id] = self._process_pool_certs(listener,
+ pool)
+ for l7policy in listener.l7policies:
+ if (l7policy.redirect_pool and
+ l7policy.redirect_pool.id not in pool_certs_dict):
+ pool_certs_dict[l7policy.redirect_pool.id] = (
+ self._process_pool_certs(listener, l7policy.redirect_pool))
+ return pool_certs_dict
+
+ def _process_pool_certs(self, listener, pool):
+ pool_cert_dict = dict()
+
+ # Handle the cleint cert(s) and key
+ if pool.tls_certificate_id:
+ data = cert_parser.load_certificates_data(self.cert_manager, pool)
+ pem = cert_parser.build_pem(data)
+ try:
+ pem = pem.encode('utf-8')
+ except AttributeError:
+ pass
+ md5 = hashlib.md5(pem).hexdigest() # nosec
+ name = '{id}.pem'.format(id=data.id)
+ self._apply(self._upload_cert, listener, None, pem, md5, name)
+ pool_cert_dict['client_cert'] = os.path.join(
+ CONF.haproxy_amphora.base_cert_dir, listener.id, name)
+ return pool_cert_dict
+
def _upload_cert(self, amp, listener_id, pem, md5, name):
try:
if self.client.get_cert_md5sum(
@@ -315,8 +359,7 @@ class HaproxyAmphoraLoadBalancerDriver(
except exc.NotFound:
pass
- self.client.upload_cert_pem(
- amp, listener_id, name, pem)
+ self.client.upload_cert_pem(amp, listener_id, name, pem)
def update_amphora_agent_config(self, amphora, agent_config,
timeout_dict=None):
@@ -488,24 +531,29 @@ class AmphoraAPIClient(object):
def upload_cert_pem(self, amp, listener_id, pem_filename, pem_file):
r = self.put(
- amp,
- 'listeners/{listener_id}/certificates/{filename}'.format(
+ amp, 'listeners/{listener_id}/certificates/{filename}'.format(
listener_id=listener_id, filename=pem_filename),
data=pem_file)
return exc.check_exception(r)
- def update_cert_for_rotation(self, amp, pem_file):
- r = self.put(amp, 'certificate', data=pem_file)
- return exc.check_exception(r)
-
def get_cert_md5sum(self, amp, listener_id, pem_filename, ignore=tuple()):
- r = self.get(amp,
- 'listeners/{listener_id}/certificates/{filename}'.format(
- listener_id=listener_id, filename=pem_filename))
+ r = self.get(
+ amp, 'listeners/{listener_id}/certificates/{filename}'.format(
+ listener_id=listener_id, filename=pem_filename))
if exc.check_exception(r, ignore):
return r.json().get("md5sum")
return None
+ def delete_cert_pem(self, amp, listener_id, pem_filename):
+ r = self.delete(
+ amp, 'listeners/{listener_id}/certificates/{filename}'.format(
+ listener_id=listener_id, filename=pem_filename))
+ return exc.check_exception(r, (404,))
+
+ def update_cert_for_rotation(self, amp, pem_file):
+ r = self.put(amp, 'certificate', data=pem_file)
+ return exc.check_exception(r)
+
def delete_listener(self, amp, listener_id):
r = self.delete(
amp, 'listeners/{listener_id}'.format(listener_id=listener_id))
@@ -529,13 +577,6 @@ class AmphoraAPIClient(object):
return r.json()
return None
- def delete_cert_pem(self, amp, listener_id, pem_filename):
- r = self.delete(
- amp,
- 'listeners/{listener_id}/certificates/{filename}'.format(
- listener_id=listener_id, filename=pem_filename))
- return exc.check_exception(r, (404,))
-
def plug_network(self, amp, port):
r = self.post(amp, 'plug/network',
json=port)
diff --git a/octavia/api/drivers/amphora_driver/driver.py b/octavia/api/drivers/amphora_driver/driver.py
index c029e04bd6..5b1953bfb0 100644
--- a/octavia/api/drivers/amphora_driver/driver.py
+++ b/octavia/api/drivers/amphora_driver/driver.py
@@ -135,7 +135,9 @@ class AmphoraProviderDriver(driver_base.ProviderDriver):
if 'admin_state_up' in pool_dict:
pool_dict['enabled'] = pool_dict.pop('admin_state_up')
pool_id = pool_dict.pop('pool_id')
-
+ if 'tls_container_ref' in pool_dict:
+ pool_dict['tls_container_id'] = pool_dict.pop('tls_container_ref')
+ pool_dict.pop('tls_container_data', None)
payload = {consts.POOL_ID: pool_id,
consts.POOL_UPDATES: pool_dict}
self.client.cast({}, 'update_pool', **payload)
diff --git a/octavia/api/drivers/data_models.py b/octavia/api/drivers/data_models.py
index b7fb4d49c5..ef95500528 100644
--- a/octavia/api/drivers/data_models.py
+++ b/octavia/api/drivers/data_models.py
@@ -170,7 +170,8 @@ class Pool(BaseDataModel):
healthmonitor=Unset, lb_algorithm=Unset,
loadbalancer_id=Unset, members=Unset, name=Unset,
pool_id=Unset, listener_id=Unset, protocol=Unset,
- session_persistence=Unset):
+ session_persistence=Unset, tls_container_ref=Unset,
+ tls_container_data=Unset):
self.admin_state_up = admin_state_up
self.description = description
@@ -183,6 +184,8 @@ class Pool(BaseDataModel):
self.listener_id = listener_id
self.protocol = protocol
self.session_persistence = session_persistence
+ self.tls_container_ref = tls_container_ref
+ self.tls_container_data = tls_container_data
class Member(BaseDataModel):
diff --git a/octavia/api/drivers/utils.py b/octavia/api/drivers/utils.py
index 779c173757..2a2865b8a6 100644
--- a/octavia/api/drivers/utils.py
+++ b/octavia/api/drivers/utils.py
@@ -281,6 +281,25 @@ def db_pool_to_provider_pool(db_pool):
def pool_dict_to_provider_dict(pool_dict):
new_pool_dict = _base_to_provider_dict(pool_dict)
new_pool_dict['pool_id'] = new_pool_dict.pop('id')
+
+ # Pull the certs out of the certificate manager to pass to the provider
+ if 'tls_certificate_id' in new_pool_dict:
+ new_pool_dict['tls_container_ref'] = new_pool_dict.pop(
+ 'tls_certificate_id')
+
+ pool_obj = data_models.Pool(**pool_dict)
+ if pool_obj.tls_certificate_id:
+ cert_manager = stevedore_driver.DriverManager(
+ namespace='octavia.cert_manager',
+ name=CONF.certificates.cert_manager,
+ invoke_on_load=True,
+ ).driver
+ cert_dict = cert_parser.load_certificates_data(cert_manager,
+ pool_obj)
+ if 'tls_cert' in cert_dict and cert_dict['tls_cert']:
+ new_pool_dict['tls_container_data'] = (
+ cert_dict['tls_cert'].to_dict())
+
# Remove the DB back references
if ('session_persistence' in new_pool_dict and
new_pool_dict['session_persistence']):
diff --git a/octavia/api/v2/controllers/pool.py b/octavia/api/v2/controllers/pool.py
index 2866046676..9b5b75515c 100644
--- a/octavia/api/v2/controllers/pool.py
+++ b/octavia/api/v2/controllers/pool.py
@@ -18,6 +18,7 @@ from oslo_db import exception as odb_exceptions
from oslo_log import log as logging
from oslo_utils import excutils
import pecan
+from stevedore import driver as stevedore_driver
from wsme import types as wtypes
from wsmeext import pecan as wsme_pecan
@@ -46,6 +47,11 @@ class PoolsController(base.BaseController):
def __init__(self):
super(PoolsController, self).__init__()
+ self.cert_manager = stevedore_driver.DriverManager(
+ namespace='octavia.cert_manager',
+ name=CONF.certificates.cert_manager,
+ invoke_on_load=True,
+ ).driver
@wsme_pecan.wsexpose(pool_types.PoolRootResponse, wtypes.text,
[wtypes.text], ignore_extra_args=True)
@@ -98,6 +104,19 @@ class PoolsController(base.BaseController):
raise exceptions.ImmutableObject(resource=_('Load Balancer'),
id=lb_id)
+ def _validate_tls_refs(self, tls_refs):
+ context = pecan.request.context.get('octavia_context')
+ bad_refs = []
+ for ref in tls_refs:
+ try:
+ self.cert_manager.set_acls(context, ref)
+ self.cert_manager.get_cert(context, ref, check_only=True)
+ except Exception:
+ bad_refs.append(ref)
+
+ if bad_refs:
+ raise exceptions.CertificateRetrievalException(ref=bad_refs)
+
def _validate_create_pool(self, lock_session, pool_dict, listener_id=None):
"""Validate creating pool on load balancer.
@@ -105,6 +124,9 @@ class PoolsController(base.BaseController):
provisioning status.
"""
try:
+ tls_certificate_id = pool_dict.get('tls_certificate_id', None)
+ tls_refs = [tls_certificate_id] if tls_certificate_id else []
+ self._validate_tls_refs(tls_refs)
return self.repositories.create_pool_on_load_balancer(
lock_session, pool_dict,
listener_id=listener_id)
@@ -327,6 +349,9 @@ class PoolsController(base.BaseController):
sp_dict = pool.session_persistence.to_dict(render_unsets=False)
validate.check_session_persistence(sp_dict)
+ if pool.tls_container_ref:
+ self._validate_tls_refs([pool.tls_container_ref])
+
# Load the driver early as it also provides validation
driver = driver_factory.get_driver(provider)
diff --git a/octavia/api/v2/types/pool.py b/octavia/api/v2/types/pool.py
index a5fdeec8e7..d6cd5c95da 100644
--- a/octavia/api/v2/types/pool.py
+++ b/octavia/api/v2/types/pool.py
@@ -52,7 +52,8 @@ class SessionPersistencePUT(types.BaseType):
class BasePoolType(types.BaseType):
_type_to_model_map = {'admin_state_up': 'enabled',
'healthmonitor': 'health_monitor',
- 'healthmonitor_id': 'health_monitor.id'}
+ 'healthmonitor_id': 'health_monitor.id',
+ 'tls_container_ref': 'tls_certificate_id'}
_child_map = {'health_monitor': {'id': 'healthmonitor_id'}}
@@ -76,6 +77,7 @@ class PoolResponse(BasePoolType):
healthmonitor_id = wtypes.wsattr(wtypes.UuidType())
members = wtypes.wsattr([types.IdOnlyType])
tags = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType()))
+ tls_container_ref = wtypes.wsattr(wtypes.StringType())
@classmethod
def from_data_model(cls, data_model, children=False):
@@ -147,6 +149,8 @@ class PoolPOST(BasePoolType):
healthmonitor = wtypes.wsattr(health_monitor.HealthMonitorSingleCreate)
members = wtypes.wsattr([member.MemberSingleCreate])
tags = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType(max_length=255)))
+ tls_container_ref = wtypes.wsattr(
+ wtypes.StringType(max_length=255))
class PoolRootPOST(types.BaseType):
@@ -162,6 +166,7 @@ class PoolPUT(BasePoolType):
wtypes.Enum(str, *constants.SUPPORTED_LB_ALGORITHMS))
session_persistence = wtypes.wsattr(SessionPersistencePUT)
tags = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType(max_length=255)))
+ tls_container_ref = wtypes.wsattr(wtypes.StringType(max_length=255))
class PoolRootPut(types.BaseType):
@@ -180,6 +185,7 @@ class PoolSingleCreate(BasePoolType):
healthmonitor = wtypes.wsattr(health_monitor.HealthMonitorSingleCreate)
members = wtypes.wsattr([member.MemberSingleCreate])
tags = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType(max_length=255)))
+ tls_container_ref = wtypes.wsattr(wtypes.StringType(max_length=255))
class PoolStatusResponse(BasePoolType):
diff --git a/octavia/common/data_models.py b/octavia/common/data_models.py
index 47b5cc5558..602700a08f 100644
--- a/octavia/common/data_models.py
+++ b/octavia/common/data_models.py
@@ -263,7 +263,7 @@ class Pool(BaseDataModel):
session_persistence=None, load_balancer_id=None,
load_balancer=None, listeners=None, l7policies=None,
created_at=None, updated_at=None, provisioning_status=None,
- tags=None):
+ tags=None, tls_certificate_id=None):
self.id = id
self.project_id = project_id
self.name = name
@@ -283,6 +283,7 @@ class Pool(BaseDataModel):
self.updated_at = updated_at
self.provisioning_status = provisioning_status
self.tags = tags
+ self.tls_certificate_id = tls_certificate_id
def update(self, update_dict):
for key, value in update_dict.items():
diff --git a/octavia/common/jinja/haproxy/jinja_cfg.py b/octavia/common/jinja/haproxy/jinja_cfg.py
index b239506f0b..9b3a026a47 100644
--- a/octavia/common/jinja/haproxy/jinja_cfg.py
+++ b/octavia/common/jinja/haproxy/jinja_cfg.py
@@ -83,7 +83,8 @@ class JinjaTemplater(object):
def build_config(self, host_amphora, listener, tls_cert,
haproxy_versions, socket_path=None,
- client_ca_filename=None, client_crl=None):
+ client_ca_filename=None, client_crl=None,
+ pool_tls_certs=None):
"""Convert a logical configuration to the HAProxy version
:param host_amphora: The Amphora this configuration is hosted on
@@ -105,7 +106,8 @@ class JinjaTemplater(object):
return self.render_loadbalancer_obj(
host_amphora, listener, tls_cert=tls_cert, socket_path=socket_path,
feature_compatibility=feature_compatibility,
- client_ca_filename=client_ca_filename, client_crl=client_crl)
+ client_ca_filename=client_ca_filename, client_crl=client_crl,
+ pool_tls_certs=pool_tls_certs)
def _get_template(self):
"""Returns the specified Jinja configuration template."""
@@ -124,7 +126,8 @@ class JinjaTemplater(object):
def render_loadbalancer_obj(self, host_amphora, listener,
tls_cert=None, socket_path=None,
feature_compatibility=None,
- client_ca_filename=None, client_crl=None):
+ client_ca_filename=None, client_crl=None,
+ pool_tls_certs=None):
"""Renders a templated configuration from a load balancer object
:param host_amphora: The Amphora this configuration is hosted on
@@ -142,7 +145,8 @@ class JinjaTemplater(object):
tls_cert,
feature_compatibility,
client_ca_filename=client_ca_filename,
- client_crl=client_crl)
+ client_crl=client_crl,
+ pool_tls_certs=pool_tls_certs)
if not socket_path:
socket_path = '%s/%s.sock' % (self.base_amp_path, listener.id)
return self._get_template().render(
@@ -155,14 +159,16 @@ class JinjaTemplater(object):
def _transform_loadbalancer(self, host_amphora, loadbalancer, listener,
tls_cert, feature_compatibility,
- client_ca_filename=None, client_crl=None):
+ client_ca_filename=None, client_crl=None,
+ pool_tls_certs=None):
"""Transforms a load balancer into an object that will
be processed by the templating system
"""
t_listener = self._transform_listener(
listener, tls_cert, feature_compatibility,
- client_ca_filename=client_ca_filename, client_crl=client_crl)
+ client_ca_filename=client_ca_filename, client_crl=client_crl,
+ pool_tls_certs=pool_tls_certs)
ret_value = {
'id': loadbalancer.id,
'vip_address': loadbalancer.vip.ip_address,
@@ -202,7 +208,8 @@ class JinjaTemplater(object):
}
def _transform_listener(self, listener, tls_cert, feature_compatibility,
- client_ca_filename=None, client_crl=None):
+ client_ca_filename=None, client_crl=None,
+ pool_tls_certs=None):
"""Transforms a listener into an object that will
be processed by the templating system
@@ -251,17 +258,28 @@ class JinjaTemplater(object):
os.path.join(self.base_crt_dir, listener.id, client_crl))
if listener.default_pool:
+ kwargs = {}
+ if pool_tls_certs and pool_tls_certs.get(listener.default_pool.id):
+ kwargs = {'pool_tls_certs': pool_tls_certs.get(
+ listener.default_pool.id)}
ret_value['default_pool'] = self._transform_pool(
- listener.default_pool, feature_compatibility)
- pools = [self._transform_pool(x, feature_compatibility)
- for x in listener.pools]
+ listener.default_pool, feature_compatibility, **kwargs)
+ pools = []
+ for x in listener.pools:
+ kwargs = {}
+ if pool_tls_certs and pool_tls_certs.get(x.id):
+ kwargs = {'pool_tls_certs': pool_tls_certs.get(x.id)}
+ pools.append(self._transform_pool(
+ x, feature_compatibility, **kwargs))
ret_value['pools'] = pools
- l7policies = [self._transform_l7policy(x, feature_compatibility)
+ l7policies = [self._transform_l7policy(
+ x, feature_compatibility, pool_tls_certs)
for x in listener.l7policies]
ret_value['l7policies'] = l7policies
return ret_value
- def _transform_pool(self, pool, feature_compatibility):
+ def _transform_pool(self, pool, feature_compatibility,
+ pool_tls_certs=None):
"""Transforms a pool into an object that will
be processed by the templating system
@@ -289,6 +307,10 @@ class JinjaTemplater(object):
ret_value[
'session_persistence'] = self._transform_session_persistence(
pool.session_persistence, feature_compatibility)
+ if (pool.tls_certificate_id and pool_tls_certs and
+ pool_tls_certs.get('client_cert')):
+ ret_value['client_cert'] = pool_tls_certs.get('client_cert')
+
return ret_value
@staticmethod
@@ -343,7 +365,8 @@ class JinjaTemplater(object):
'enabled': monitor.enabled,
}
- def _transform_l7policy(self, l7policy, feature_compatibility):
+ def _transform_l7policy(self, l7policy, feature_compatibility,
+ pool_tls_certs=None):
"""Transforms an L7 policy into an object that will
be processed by the templating system
@@ -356,8 +379,13 @@ class JinjaTemplater(object):
'enabled': l7policy.enabled
}
if l7policy.redirect_pool:
+ kwargs = {}
+ if pool_tls_certs and pool_tls_certs.get(
+ l7policy.redirect_pool.id):
+ kwargs = {'pool_tls_certs':
+ pool_tls_certs.get(l7policy.redirect_pool.id)}
ret_value['redirect_pool'] = self._transform_pool(
- l7policy.redirect_pool, feature_compatibility)
+ l7policy.redirect_pool, feature_compatibility, **kwargs)
else:
ret_value['redirect_pool'] = None
l7rules = [self._transform_l7rule(x, feature_compatibility)
diff --git a/octavia/common/jinja/haproxy/templates/macros.j2 b/octavia/common/jinja/haproxy/templates/macros.j2
index e84d3a6f58..ae46c4b0db 100644
--- a/octavia/common/jinja/haproxy/templates/macros.j2
+++ b/octavia/common/jinja/haproxy/templates/macros.j2
@@ -210,10 +210,19 @@ frontend {{ listener.id }}
{% else %}
{% set member_enabled_opt = " disabled" %}
{% endif %}
- {{ "server %s %s:%d weight %s%s%s%s%s%s"|e|format(
+ {% if pool.client_cert %}
+ {% set def_opt_prefix = " ssl" %}
+ {% set def_crt_opt = " crt %s"|format(pool.client_cert) %}
+ {% set def_verify_opt = " verify none" %}
+ {% else %}
+ {% set def_opt_prefix = "" %}
+ {% set def_crt_opt = "" %}
+ {% set def_verify_opt = "" %}
+ {% endif %}
+ {{ "server %s %s:%d weight %s%s%s%s%s%s%s%s%s"|e|format(
member.id, member.address, member.protocol_port, member.weight,
hm_opt, persistence_opt, proxy_protocol_opt, member_backup_opt,
- member_enabled_opt)|trim() }}
+ member_enabled_opt, def_opt_prefix, def_crt_opt, def_verify_opt)|trim() }}
{% endmacro %}
diff --git a/octavia/common/tls_utils/cert_parser.py b/octavia/common/tls_utils/cert_parser.py
index ac99f689e0..3e5e7c5399 100644
--- a/octavia/common/tls_utils/cert_parser.py
+++ b/octavia/common/tls_utils/cert_parser.py
@@ -336,23 +336,23 @@ def build_pem(tls_container):
return b'\n'.join(pem) + b'\n'
-def load_certificates_data(cert_mngr, listener, context=None):
- """Load TLS certificate data from the listener.
+def load_certificates_data(cert_mngr, obj, context=None):
+ """Load TLS certificate data from the listener/pool.
return TLS_CERT and SNI_CERTS
"""
tls_cert = None
sni_certs = []
if not context:
- context = oslo_context.RequestContext(project_id=listener.project_id)
+ context = oslo_context.RequestContext(project_id=obj.project_id)
- if listener.tls_certificate_id:
+ if obj.tls_certificate_id:
tls_cert = _map_cert_tls_container(
cert_mngr.get_cert(context,
- listener.tls_certificate_id,
+ obj.tls_certificate_id,
check_only=True))
- if listener.sni_containers:
- for sni_cont in listener.sni_containers:
+ if hasattr(obj, 'sni_containers') and obj.sni_containers:
+ for sni_cont in obj.sni_containers:
cert_container = _map_cert_tls_container(
cert_mngr.get_cert(context,
sni_cont.tls_container_id,
diff --git a/octavia/db/migration/alembic_migrations/versions/a1f689aecc1d_extend_pool_for_support_backend_reencryption.py b/octavia/db/migration/alembic_migrations/versions/a1f689aecc1d_extend_pool_for_support_backend_reencryption.py
new file mode 100644
index 0000000000..f19934a85c
--- /dev/null
+++ b/octavia/db/migration/alembic_migrations/versions/a1f689aecc1d_extend_pool_for_support_backend_reencryption.py
@@ -0,0 +1,35 @@
+# Copyright 2018 Huawei
+#
+# Licensed 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.
+#
+
+"""Extend pool for support backend re-encryption
+
+Revision ID: a1f689aecc1d
+Revises: 1afc932f1ca2
+Create Date: 2018-10-23 20:47:52.405865
+
+"""
+
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'a1f689aecc1d'
+down_revision = '1afc932f1ca2'
+
+
+def upgrade():
+ op.add_column(u'pool', sa.Column(u'tls_certificate_id', sa.String(255),
+ nullable=True))
diff --git a/octavia/db/models.py b/octavia/db/models.py
index c0f07f237e..0b68a6d219 100644
--- a/octavia/db/models.py
+++ b/octavia/db/models.py
@@ -328,6 +328,7 @@ class Pool(base_models.BASE, base_models.IdMixin, base_models.ProjectMixin,
cascade='all,delete-orphan',
primaryjoin='and_(foreign(Tags.resource_id)==Pool.id)'
)
+ tls_certificate_id = sa.Column(sa.String(255), nullable=True)
# This property should be a unique list of any listeners that reference
# this pool as its default_pool and any listeners referenced by enabled
@@ -544,7 +545,6 @@ class SNI(base_models.BASE):
__table_args__ = (
sa.PrimaryKeyConstraint('listener_id', 'tls_container_id'),
)
-
listener_id = sa.Column(
sa.String(36),
sa.ForeignKey("listener.id", name="fk_sni_listener_id"),
diff --git a/octavia/tests/functional/api/v2/test_pool.py b/octavia/tests/functional/api/v2/test_pool.py
index b5065686e6..3cdeb34d7d 100644
--- a/octavia/tests/functional/api/v2/test_pool.py
+++ b/octavia/tests/functional/api/v2/test_pool.py
@@ -858,6 +858,43 @@ class TestPool(base.BaseAPITest):
pool_prov_status=constants.PENDING_CREATE,
pool_op_status=constants.OFFLINE)
+ @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data')
+ def test_create_with_tls_container_ref(self, mock_cert_data):
+ tls_container_ref = uuidutils.generate_uuid()
+ pool_cert = data_models.TLSContainer(certificate='pool cert')
+ mock_cert_data.return_value = {'tls_cert': pool_cert,
+ 'sni_certs': [],
+ 'client_ca_cert': None}
+ api_pool = self.create_pool(
+ self.lb_id,
+ constants.PROTOCOL_HTTP,
+ constants.LB_ALGORITHM_ROUND_ROBIN,
+ listener_id=self.listener_id,
+ tls_container_ref=tls_container_ref).get(self.root_tag)
+ self.assert_correct_status(
+ lb_id=self.lb_id, listener_id=self.listener_id,
+ pool_id=api_pool.get('id'),
+ lb_prov_status=constants.PENDING_UPDATE,
+ listener_prov_status=constants.PENDING_UPDATE,
+ pool_prov_status=constants.PENDING_CREATE,
+ pool_op_status=constants.OFFLINE)
+ self.set_lb_status(self.lb_id)
+ self.assertEqual(tls_container_ref, api_pool.get('tls_container_ref'))
+ self.assert_correct_status(
+ lb_id=self.lb_id, listener_id=self.listener_id,
+ pool_id=api_pool.get('id'))
+
+ def test_create_with_bad_tls_container_ref(self):
+ tls_container_ref = uuidutils.generate_uuid()
+ self.cert_manager_mock().get_cert.side_effect = [Exception(
+ "bad secret")]
+ api_pool = self.create_pool(
+ self.lb_id, constants.PROTOCOL_HTTP,
+ constants.LB_ALGORITHM_ROUND_ROBIN,
+ listener_id=self.listener_id,
+ tls_container_ref=tls_container_ref, status=400)
+ self.assertIn(tls_container_ref, api_pool['faultstring'])
+
def test_negative_create_udp_case(self):
# Error create pool with udp protocol but non-udp-type
sp = {"type": constants.SESSION_PERSISTENCE_HTTP_COOKIE,
@@ -1205,6 +1242,39 @@ class TestPool(base.BaseAPITest):
self.assert_correct_status(
lb_id=self.udp_lb_id, listener_id=self.udp_listener_id)
+ @mock.patch(
+ 'octavia.common.tls_utils.cert_parser.load_certificates_data')
+ def test_update_with_tls_container_ref(self, mock_cert_data):
+ tls_container_ref = uuidutils.generate_uuid()
+ api_pool = self.create_pool(
+ self.lb_id,
+ constants.PROTOCOL_HTTP,
+ constants.LB_ALGORITHM_ROUND_ROBIN,
+ listener_id=self.listener_id).get(self.root_tag)
+ self.set_lb_status(lb_id=self.lb_id)
+ new_pool = {'tls_container_ref': tls_container_ref}
+ pool_cert = data_models.TLSContainer(certificate='pool cert')
+ mock_cert_data.return_value = {'tls_cert': pool_cert,
+ 'sni_certs': [],
+ 'client_ca_cert': None}
+ self.put(self.POOL_PATH.format(pool_id=api_pool.get('id')),
+ self._build_body(new_pool))
+ self.assert_correct_status(
+ lb_id=self.lb_id, listener_id=self.listener_id,
+ pool_id=api_pool.get('id'),
+ lb_prov_status=constants.PENDING_UPDATE,
+ listener_prov_status=constants.PENDING_UPDATE,
+ pool_prov_status=constants.PENDING_UPDATE)
+ self.set_lb_status(self.lb_id)
+ response = self.get(self.POOL_PATH.format(
+ pool_id=api_pool.get('id'))).json.get(self.root_tag)
+ self.assertEqual(tls_container_ref, response.get('tls_container_ref'))
+ self.assertIsNotNone(response.get('created_at'))
+ self.assertIsNotNone(response.get('updated_at'))
+ self.assert_correct_status(
+ lb_id=self.lb_id, listener_id=self.listener_id,
+ pool_id=response.get('id'))
+
def test_bad_update(self):
api_pool = self.create_pool(
self.lb_id,
@@ -1256,6 +1326,22 @@ class TestPool(base.BaseAPITest):
self.assert_correct_status(
lb_id=self.udp_lb_id, listener_id=self.udp_listener_id)
+ def test_update_with_bad_tls_container_ref(self):
+ api_pool = self.create_pool(
+ self.lb_id,
+ constants.PROTOCOL_HTTP,
+ constants.LB_ALGORITHM_ROUND_ROBIN,
+ listener_id=self.listener_id).get(self.root_tag)
+ self.set_lb_status(lb_id=self.lb_id)
+ tls_container_ref = uuidutils.generate_uuid()
+ new_pool = {'tls_container_ref': tls_container_ref}
+
+ self.cert_manager_mock().get_cert.side_effect = [Exception(
+ "bad secret")]
+ resp = self.put(self.POOL_PATH.format(pool_id=api_pool.get('id')),
+ self._build_body(new_pool), status=400).json
+ self.assertIn(tls_container_ref, resp['faultstring'])
+
def test_delete(self):
api_pool = self.create_pool(
self.lb_id,
diff --git a/octavia/tests/functional/db/test_repositories.py b/octavia/tests/functional/db/test_repositories.py
index 2a5ed91221..a4bb948fd9 100644
--- a/octavia/tests/functional/db/test_repositories.py
+++ b/octavia/tests/functional/db/test_repositories.py
@@ -179,7 +179,8 @@ class AllRepositoriesTest(base.OctaviaDBTestBase):
'project_id': uuidutils.generate_uuid(),
'id': uuidutils.generate_uuid(),
'provisioning_status': constants.ACTIVE,
- 'tags': ['test_tag']}
+ 'tags': ['test_tag'],
+ 'tls_certificate_id': uuidutils.generate_uuid()}
pool_dm = self.repos.create_pool_on_load_balancer(
self.session, pool, listener_id=self.listener.id)
pool_dm_dict = pool_dm.to_dict()
@@ -205,7 +206,8 @@ class AllRepositoriesTest(base.OctaviaDBTestBase):
'project_id': uuidutils.generate_uuid(),
'id': uuidutils.generate_uuid(),
'provisioning_status': constants.ACTIVE,
- 'tags': ['test_tag']}
+ 'tags': ['test_tag'],
+ 'tls_certificate_id': uuidutils.generate_uuid()}
sp = {'type': constants.SESSION_PERSISTENCE_HTTP_COOKIE,
'cookie_name': 'cookie_monster',
'pool_id': pool['id'],
@@ -261,6 +263,7 @@ class AllRepositoriesTest(base.OctaviaDBTestBase):
del pool_dm_dict['created_at']
del pool_dm_dict['updated_at']
pool.update(update_pool)
+ pool['tls_certificate_id'] = None
self.assertEqual(pool, pool_dm_dict)
self.assertIsNone(new_pool_dm.session_persistence)
@@ -272,7 +275,8 @@ class AllRepositoriesTest(base.OctaviaDBTestBase):
'project_id': uuidutils.generate_uuid(),
'id': uuidutils.generate_uuid(),
'provisioning_status': constants.ACTIVE,
- 'tags': ['test_tag']}
+ 'tags': ['test_tag'],
+ 'tls_certificate_id': uuidutils.generate_uuid()}
sp = {'type': constants.SESSION_PERSISTENCE_HTTP_COOKIE,
'cookie_name': 'cookie_monster',
'pool_id': pool['id'],
@@ -364,6 +368,33 @@ class AllRepositoriesTest(base.OctaviaDBTestBase):
self.session, pool_dm.id, update_pool)
self.assertIsNone(new_pool_dm.session_persistence)
+ def test_update_pool_with_cert(self):
+ pool = {'protocol': constants.PROTOCOL_HTTP, 'name': 'pool1',
+ 'description': 'desc1',
+ 'lb_algorithm': constants.LB_ALGORITHM_ROUND_ROBIN,
+ 'enabled': True, 'operating_status': constants.ONLINE,
+ 'project_id': uuidutils.generate_uuid(),
+ 'id': uuidutils.generate_uuid(),
+ 'provisioning_status': constants.ACTIVE}
+ pool_dm = self.repos.create_pool_on_load_balancer(
+ self.session, pool, listener_id=self.listener.id)
+ update_pool = {'tls_certificate_id': uuidutils.generate_uuid()}
+ new_pool_dm = self.repos.update_pool_and_sp(
+ self.session, pool_dm.id, update_pool)
+ pool_dm_dict = new_pool_dm.to_dict()
+ del pool_dm_dict['members']
+ del pool_dm_dict['health_monitor']
+ del pool_dm_dict['session_persistence']
+ del pool_dm_dict['listeners']
+ del pool_dm_dict['load_balancer']
+ del pool_dm_dict['load_balancer_id']
+ del pool_dm_dict['l7policies']
+ del pool_dm_dict['created_at']
+ del pool_dm_dict['updated_at']
+ del pool_dm_dict['tags']
+ pool.update(update_pool)
+ self.assertEqual(pool, pool_dm_dict)
+
def test_create_load_balancer_tree(self):
project_id = uuidutils.generate_uuid()
member = {'project_id': project_id, 'ip_address': '11.0.0.1',
diff --git a/octavia/tests/unit/amphorae/drivers/haproxy/test_rest_api_driver.py b/octavia/tests/unit/amphorae/drivers/haproxy/test_rest_api_driver.py
index d1b24a57c3..ae4040542b 100644
--- a/octavia/tests/unit/amphorae/drivers/haproxy/test_rest_api_driver.py
+++ b/octavia/tests/unit/amphorae/drivers/haproxy/test_rest_api_driver.py
@@ -73,6 +73,8 @@ class TestHaproxyAmphoraLoadBalancerDriverTest(base.TestCase):
persistence_timeout=33,
persistence_granularity='255.255.0.0',
monitor_proto=constants.HEALTH_MONITOR_UDP_CONNECT)
+ self.pool_has_cert = sample_configs.sample_pool_tuple(
+ pool_cert=True, full_store=True)
self.amp = self.sl.load_balancer.amphorae[0]
self.sv = sample_configs.sample_vip_tuple()
self.lb = self.sl.load_balancer
@@ -152,11 +154,9 @@ class TestHaproxyAmphoraLoadBalancerDriverTest(base.TestCase):
sconts = []
for sni_container in self.sl.sni_containers:
sconts.append(sni_container.tls_container)
- mock_load_crt.return_value = {
- 'tls_cert': self.sl.default_tls_container,
- 'sni_certs': sconts,
- 'client_ca_cert': self.sl.client_ca_tls_certificate
- }
+ mock_load_crt.side_effect = [{
+ 'tls_cert': self.sl.default_tls_container, 'sni_certs': sconts},
+ {'tls_cert': None, 'sni_certs': []}]
self.driver.client.get_cert_md5sum.side_effect = [
exc.NotFound, 'Fake_MD5', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'CA_CERT_MD5']
@@ -243,8 +243,7 @@ class TestHaproxyAmphoraLoadBalancerDriverTest(base.TestCase):
sconts.append(sni_container.tls_container)
mock_load_crt.return_value = {
'tls_cert': self.sl.default_tls_container,
- 'sni_certs': sconts,
- 'client_ca_cert': None
+ 'sni_certs': sconts
}
self.driver.client.get_cert_md5sum.side_effect = [
exc.NotFound, 'Fake_MD5', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa']
@@ -298,12 +297,12 @@ class TestHaproxyAmphoraLoadBalancerDriverTest(base.TestCase):
sample_listener = sample_configs.sample_listener_tuple(
tls=True, sni=True, client_ca_cert=True)
fake_context = 'fake context'
- fake_secret = 'fake cert'
+ fake_secret = b'fake cert'
mock_oslo.return_value = fake_context
self.driver.cert_manager.get_secret.reset_mock()
self.driver.cert_manager.get_secret.return_value = fake_secret
- ref_md5 = hashlib.md5(fake_secret.encode('utf-8')).hexdigest() # nosec
- ref_id = hashlib.sha1(fake_secret.encode('utf-8')).hexdigest() # nosec
+ ref_md5 = hashlib.md5(fake_secret).hexdigest() # nosec
+ ref_id = hashlib.sha1(fake_secret).hexdigest() # nosec
ref_name = '{id}.pem'.format(id=ref_id)
result = self.driver._process_secret(
@@ -318,6 +317,60 @@ class TestHaproxyAmphoraLoadBalancerDriverTest(base.TestCase):
ref_md5, ref_name)
self.assertEqual(ref_name, result)
+ @mock.patch('octavia.amphorae.drivers.haproxy.rest_api_driver.'
+ 'HaproxyAmphoraLoadBalancerDriver._process_pool_certs')
+ def test__process_listener_pool_certs(self, mock_pool_cert):
+ sample_listener = sample_configs.sample_listener_tuple(l7=True)
+
+ ref_pool_cert_1 = {'client_cert': '/some/fake/cert-1.pem'}
+ ref_pool_cert_2 = {'client_cert': '/some/fake/cert-2.pem'}
+
+ mock_pool_cert.side_effect = [ref_pool_cert_1, ref_pool_cert_2]
+
+ ref_cert_dict = {'sample_pool_id_1': ref_pool_cert_1,
+ 'sample_pool_id_2': ref_pool_cert_2}
+
+ result = self.driver._process_listener_pool_certs(sample_listener)
+
+ pool_certs_calls = [
+ mock.call(sample_listener, sample_listener.default_pool),
+ mock.call(sample_listener, sample_listener.pools[1])
+ ]
+
+ mock_pool_cert.assert_has_calls(pool_certs_calls, any_order=True)
+
+ self.assertEqual(ref_cert_dict, result)
+
+ @mock.patch('octavia.amphorae.drivers.haproxy.rest_api_driver.'
+ 'HaproxyAmphoraLoadBalancerDriver._apply')
+ @mock.patch('octavia.common.tls_utils.cert_parser.build_pem')
+ @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data')
+ def test__process_pool_certs(self, mock_load_certs, mock_build_pem,
+ mock_apply):
+ fake_cert_dir = '/fake/cert/dir'
+ conf = oslo_fixture.Config(cfg.CONF)
+ conf.config(group="haproxy_amphora", base_cert_dir=fake_cert_dir)
+ sample_listener = sample_configs.sample_listener_tuple(pool_cert=True)
+ cert_data_mock = mock.MagicMock()
+ cert_data_mock.id = uuidutils.generate_uuid()
+ mock_load_certs.return_value = cert_data_mock
+ fake_pem = b'fake pem'
+ mock_build_pem.return_value = fake_pem
+ ref_md5 = hashlib.md5(fake_pem).hexdigest() # nosec
+ ref_name = '{id}.pem'.format(id=cert_data_mock.id)
+ ref_path = '{cert_dir}/{list_id}/{name}'.format(
+ cert_dir=fake_cert_dir, list_id=sample_listener.id, name=ref_name)
+ ref_result = {'client_cert': ref_path}
+
+ result = self.driver._process_pool_certs(sample_listener,
+ sample_listener.default_pool)
+
+ mock_build_pem.assert_called_once_with(cert_data_mock)
+ mock_apply.assert_called_once_with(
+ self.driver._upload_cert, sample_listener, None, fake_pem,
+ ref_md5, ref_name)
+ self.assertEqual(ref_result, result)
+
def test_stop(self):
self.driver.client.stop_listener.__name__ = 'stop_listener'
# Execute driver method
@@ -340,6 +393,7 @@ class TestHaproxyAmphoraLoadBalancerDriverTest(base.TestCase):
listener.id = uuidutils.generate_uuid()
listener.load_balancer.amphorae = [amp1, amp2]
listener.protocol = 'listener_protocol'
+ del listener.listeners
self.driver.client.start_listener.__name__ = 'start_listener'
# Execute driver method
self.driver.start(listener, self.sv)
diff --git a/octavia/tests/unit/api/drivers/sample_data_models.py b/octavia/tests/unit/api/drivers/sample_data_models.py
index b307688e54..2a8e96dcdd 100644
--- a/octavia/tests/unit/api/drivers/sample_data_models.py
+++ b/octavia/tests/unit/api/drivers/sample_data_models.py
@@ -39,6 +39,7 @@ class SampleDriverDataModels(object):
self.sni_container_ref_2 = uuidutils.generate_uuid()
self.client_ca_tls_certificate_ref = uuidutils.generate_uuid()
self.client_crl_container_ref = uuidutils.generate_uuid()
+ self.pool_sni_container_ref = uuidutils.generate_uuid()
self.pool1_id = uuidutils.generate_uuid()
self.pool2_id = uuidutils.generate_uuid()
@@ -205,7 +206,9 @@ class SampleDriverDataModels(object):
'health_monitor': self.test_hm1_dict,
'session_persistence': {'type': 'SOURCE'},
'listeners': [],
- 'l7policies': []}
+ 'l7policies': [],
+ 'tls_certificate_id':
+ self.pool_sni_container_ref}
self.test_pool1_dict.update(self._common_test_dict)
@@ -214,6 +217,7 @@ class SampleDriverDataModels(object):
self.test_pool2_dict['name'] = 'pool2'
self.test_pool2_dict['description'] = 'Pool 2'
self.test_pool2_dict['members'] = self.test_pool2_members_dict
+ del self.test_pool2_dict['tls_certificate_id']
self.test_pools = [self.test_pool1_dict, self.test_pool2_dict]
@@ -225,6 +229,7 @@ class SampleDriverDataModels(object):
self.db_pool2.members = self.db_pool2_members
self.test_db_pools = [self.db_pool1, self.db_pool2]
+ pool_cert = data_models.TLSContainer(certificate='pool cert')
self.provider_pool1_dict = {
'admin_state_up': True,
@@ -236,7 +241,9 @@ class SampleDriverDataModels(object):
'name': 'pool1',
'pool_id': self.pool1_id,
'protocol': 'avian',
- 'session_persistence': {'type': 'SOURCE'}}
+ 'session_persistence': {'type': 'SOURCE'},
+ 'tls_container_ref': self.pool_sni_container_ref,
+ 'tls_container_data': pool_cert.to_dict()}
self.provider_pool2_dict = copy.deepcopy(self.provider_pool1_dict)
self.provider_pool2_dict['pool_id'] = self.pool2_id
@@ -244,6 +251,8 @@ class SampleDriverDataModels(object):
self.provider_pool2_dict['description'] = 'Pool 2'
self.provider_pool2_dict['members'] = self.provider_pool2_members_dict
self.provider_pool2_dict['healthmonitor'] = self.provider_hm2_dict
+ self.provider_pool2_dict['tls_container_ref'] = None
+ del self.provider_pool2_dict['tls_container_data']
self.provider_pool1 = driver_dm.Pool(**self.provider_pool1_dict)
self.provider_pool1.members = self.provider_pool1_members
diff --git a/octavia/tests/unit/api/drivers/test_utils.py b/octavia/tests/unit/api/drivers/test_utils.py
index a0dbe77dd9..f0186deaf5 100644
--- a/octavia/tests/unit/api/drivers/test_utils.py
+++ b/octavia/tests/unit/api/drivers/test_utils.py
@@ -91,8 +91,12 @@ class TestUtils(base.TestCase):
cert2 = data_models.TLSContainer(certificate='cert 2')
cert3 = data_models.TLSContainer(certificate='cert 3')
mock_secret.side_effect = ['ca cert', 'X509 CRL FILE']
- mock_load_cert.return_value = {'tls_cert': cert1,
- 'sni_certs': [cert2, cert3]}
+ listener_certs = {'tls_cert': cert1, 'sni_certs': [cert2, cert3]}
+ pool_cert = data_models.TLSContainer(certificate='pool cert')
+ pool_certs = {'tls_cert': pool_cert, 'sni_certs': []}
+ mock_load_cert.side_effect = [pool_certs, listener_certs,
+ listener_certs, listener_certs,
+ listener_certs]
test_lb_dict = {'name': 'lb1',
'project_id': self.sample_data.project_id,
'vip_subnet_id': self.sample_data.subnet_id,
@@ -180,8 +184,10 @@ class TestUtils(base.TestCase):
cert1 = data_models.TLSContainer(certificate='cert 1')
cert2 = data_models.TLSContainer(certificate='cert 2')
cert3 = data_models.TLSContainer(certificate='cert 3')
- mock_load_cert.return_value = {'tls_cert': cert1,
- 'sni_certs': [cert2, cert3]}
+ listener_certs = {'tls_cert': cert1, 'sni_certs': [cert2, cert3]}
+ pool_cert = data_models.TLSContainer(certificate='pool cert')
+ pool_certs = {'tls_cert': pool_cert, 'sni_certs': []}
+ mock_load_cert.side_effect = [listener_certs, pool_certs]
# The reason to do this, as before the logic arrives the test func,
# there are two data sources, one is from db_dict, the other is from
# the api layer model_dict, actually, they are different and contain
@@ -191,8 +197,6 @@ class TestUtils(base.TestCase):
expect_prov = copy.deepcopy(self.sample_data.provider_listener1_dict)
provider_listener = utils.listener_dict_to_provider_dict(
self.sample_data.test_listener1_dict)
- expect_prov.pop('client_crl_container_ref')
- provider_listener.pop('client_crl_container_ref')
self.assertEqual(expect_prov, provider_listener)
@mock.patch('octavia.api.drivers.utils._get_secret_data')
@@ -212,23 +216,43 @@ class TestUtils(base.TestCase):
utils.listener_dict_to_provider_dict,
test_listener)
- def test_db_pool_to_provider_pool(self):
+ @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data')
+ def test_db_pool_to_provider_pool(self, mock_load_cert):
+ pool_cert = data_models.TLSContainer(certificate='pool cert')
+ mock_load_cert.return_value = {'tls_cert': pool_cert,
+ 'sni_certs': None,
+ 'client_ca_cert': None}
provider_pool = utils.db_pool_to_provider_pool(
self.sample_data.db_pool1)
self.assertEqual(self.sample_data.provider_pool1, provider_pool)
- def test_db_pool_to_provider_pool_partial(self):
+ @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data')
+ def test_db_pool_to_provider_pool_partial(self, mock_load_cert):
+ pool_cert = data_models.TLSContainer(certificate='pool cert')
+ mock_load_cert.return_value = {'tls_cert': pool_cert,
+ 'sni_certs': None,
+ 'client_ca_cert': None}
test_db_pool = self.sample_data.db_pool1
test_db_pool.members = [self.sample_data.db_member1]
provider_pool = utils.db_pool_to_provider_pool(test_db_pool)
self.assertEqual(self.sample_data.provider_pool1, provider_pool)
- def test_db_pools_to_provider_pools(self):
+ @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data')
+ def test_db_pools_to_provider_pools(self, mock_load_cert):
+ pool_cert = data_models.TLSContainer(certificate='pool cert')
+ mock_load_cert.return_value = {'tls_cert': pool_cert,
+ 'sni_certs': None,
+ 'client_ca_cert': None}
provider_pools = utils.db_pools_to_provider_pools(
self.sample_data.test_db_pools)
self.assertEqual(self.sample_data.provider_pools, provider_pools)
- def test_pool_dict_to_provider_dict(self):
+ @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data')
+ def test_pool_dict_to_provider_dict(self, mock_load_cert):
+ pool_cert = data_models.TLSContainer(certificate='pool cert')
+ mock_load_cert.return_value = {'tls_cert': pool_cert,
+ 'sni_certs': None,
+ 'client_ca_cert': None}
provider_pool_dict = utils.pool_dict_to_provider_dict(
self.sample_data.test_pool1_dict)
self.assertEqual(self.sample_data.provider_pool1_dict,
diff --git a/octavia/tests/unit/common/jinja/haproxy/test_jinja_cfg.py b/octavia/tests/unit/common/jinja/haproxy/test_jinja_cfg.py
index e4d9fdcfd3..eb4bd63bc4 100644
--- a/octavia/tests/unit/common/jinja/haproxy/test_jinja_cfg.py
+++ b/octavia/tests/unit/common/jinja/haproxy/test_jinja_cfg.py
@@ -13,6 +13,9 @@
# License for the specific language governing permissions and limitations
# under the License.
+import copy
+import os
+
from octavia.common import constants
from octavia.common.jinja.haproxy import jinja_cfg
from octavia.tests.unit import base
@@ -744,6 +747,39 @@ class TestHaproxyCfg(base.TestCase):
sample_configs.sample_base_expected_config(backend=be),
rendered_obj)
+ def test_render_template_pool_cert(self):
+ cert_file_path = os.path.join(self.jinja_cfg.base_crt_dir,
+ 'sample_listener_id_1', 'fake path')
+ be = ("backend sample_pool_id_1\n"
+ " mode http\n"
+ " balance roundrobin\n"
+ " cookie SRV insert indirect nocache\n"
+ " timeout check 31s\n"
+ " option httpchk GET /index.html\n"
+ " http-check expect rstatus 418\n"
+ " fullconn {maxconn}\n"
+ " option allbackups\n"
+ " timeout connect 5000\n"
+ " timeout server 50000\n"
+ " server sample_member_id_1 10.0.0.99:82 weight 13 "
+ "check inter 30s fall 3 rise 2 cookie sample_member_id_1 "
+ "{opts}\n"
+ " server sample_member_id_2 10.0.0.98:82 weight 13 "
+ "check inter 30s fall 3 rise 2 cookie sample_member_id_2 "
+ "{opts}\n\n").format(
+ maxconn=constants.HAPROXY_MAX_MAXCONN,
+ opts="%s %s %s %s" % ("ssl", "crt", cert_file_path, "verify none"))
+ rendered_obj = self.jinja_cfg.render_loadbalancer_obj(
+ sample_configs.sample_amphora_tuple(),
+ sample_configs.sample_listener_tuple(pool_cert=True),
+ pool_tls_certs={
+ 'sample_pool_id_1':
+ {'client_cert': cert_file_path,
+ 'sni_certs': []}})
+ self.assertEqual(
+ sample_configs.sample_base_expected_config(backend=be),
+ rendered_obj)
+
def test_transform_session_persistence(self):
in_persistence = sample_configs.sample_session_persistence_tuple()
ret = self.jinja_cfg._transform_session_persistence(in_persistence, {})
@@ -774,11 +810,20 @@ class TestHaproxyCfg(base.TestCase):
in_pool = sample_configs.sample_pool_tuple(sample_pool=2)
ret = self.jinja_cfg._transform_pool(
in_pool, {constants.HTTP_REUSE: True})
- import copy
expected_config = copy.copy(sample_configs.RET_POOL_2)
expected_config[constants.HTTP_REUSE] = True
self.assertEqual(expected_config, ret)
+ def test_transform_pool_cert(self):
+ in_pool = sample_configs.sample_pool_tuple(pool_cert=True)
+ cert_path = os.path.join(self.jinja_cfg.base_crt_dir,
+ 'test_listener_id', 'pool_cert.pem')
+ ret = self.jinja_cfg._transform_pool(
+ in_pool, {}, pool_tls_certs={'client_cert': cert_path})
+ expected_config = copy.copy(sample_configs.RET_POOL_1)
+ expected_config['client_cert'] = cert_path
+ self.assertEqual(expected_config, ret)
+
def test_transform_listener(self):
in_listener = sample_configs.sample_listener_tuple()
ret = self.jinja_cfg._transform_listener(in_listener, None, {})
diff --git a/octavia/tests/unit/common/sample_configs/sample_configs.py b/octavia/tests/unit/common/sample_configs/sample_configs.py
index 3fdb3cf9de..1aded01cb7 100644
--- a/octavia/tests/unit/common/sample_configs/sample_configs.py
+++ b/octavia/tests/unit/common/sample_configs/sample_configs.py
@@ -23,22 +23,27 @@ from octavia.tests.unit.common.sample_configs import sample_certs
CONF = cfg.CONF
-def sample_amphora_tuple():
+def sample_amphora_tuple(id='sample_amphora_id_1', lb_network_ip='10.0.1.1',
+ vrrp_ip='10.1.1.1', ha_ip='192.168.10.1',
+ vrrp_port_id='1234', ha_port_id='1234', role=None,
+ status='ACTIVE', vrrp_interface=None,
+ vrrp_priority=None):
in_amphora = collections.namedtuple(
'amphora', 'id, lb_network_ip, vrrp_ip, ha_ip, vrrp_port_id, '
'ha_port_id, role, status, vrrp_interface,'
'vrrp_priority')
return in_amphora(
- id='sample_amphora_id_1',
- lb_network_ip='10.0.1.1',
- vrrp_ip='10.1.1.1',
- ha_ip='192.168.10.1',
- vrrp_port_id='1234',
- ha_port_id='1234',
- role=None,
- status='ACTIVE',
- vrrp_interface=None,
- vrrp_priority=None)
+ id=id,
+ lb_network_ip=lb_network_ip,
+ vrrp_ip=vrrp_ip,
+ ha_ip=ha_ip,
+ vrrp_port_id=vrrp_port_id,
+ ha_port_id=ha_port_id,
+ role=role,
+ status=status,
+ vrrp_interface=vrrp_interface,
+ vrrp_priority=vrrp_priority)
+
RET_PERSISTENCE = {
'type': 'HTTP_COOKIE',
@@ -464,7 +469,11 @@ def sample_loadbalancer_tuple(proto=None, monitor=True, persistence=True,
def sample_listener_loadbalancer_tuple(proto=None, topology=None,
enabled=True):
proto = 'HTTP' if proto is None else proto
- topology = 'SINGLE' if topology is None else topology
+ if topology and topology in ['ACTIVE_STANDBY', 'ACTIVE_ACTIVE']:
+ more_amp = True
+ else:
+ more_amp = False
+ topology = constants.TOPOLOGY_SINGLE
in_lb = collections.namedtuple(
'load_balancer', 'id, name, protocol, vip, amphorae, topology, '
'enabled')
@@ -473,7 +482,13 @@ def sample_listener_loadbalancer_tuple(proto=None, topology=None,
name='test-lb',
protocol=proto,
vip=sample_vip_tuple(),
- amphorae=[sample_amphora_tuple()],
+ amphorae=[sample_amphora_tuple(role=constants.ROLE_MASTER),
+ sample_amphora_tuple(
+ id='sample_amphora_id_2',
+ lb_network_ip='10.0.1.2',
+ vrrp_ip='10.1.1.2',
+ role=constants.ROLE_BACKUP)]
+ if more_amp else [sample_amphora_tuple()],
topology=topology,
enabled=enabled
)
@@ -513,7 +528,7 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True,
timeout_member_data=50000,
timeout_tcp_inspect=0,
client_ca_cert=False, client_crl_cert=False,
- ssl_type_l7=False):
+ ssl_type_l7=False, pool_cert=False):
proto = 'HTTP' if proto is None else proto
if be_proto is None:
be_proto = 'HTTP' if proto is 'TERMINATED_HTTPS' else proto
@@ -537,12 +552,14 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True,
proto=be_proto, monitor=monitor, persistence=persistence,
persistence_type=persistence_type,
persistence_cookie=persistence_cookie,
- monitor_ip_port=monitor_ip_port, monitor_proto=monitor_proto),
+ monitor_ip_port=monitor_ip_port, monitor_proto=monitor_proto,
+ pool_cert=pool_cert),
sample_pool_tuple(
proto=be_proto, monitor=monitor, persistence=persistence,
persistence_type=persistence_type,
persistence_cookie=persistence_cookie, sample_pool=2,
- monitor_ip_port=monitor_ip_port, monitor_proto=monitor_proto)]
+ monitor_ip_port=monitor_ip_port, monitor_proto=monitor_proto,
+ pool_cert=pool_cert)]
l7policies = [
sample_l7policy_tuple('sample_l7policy_id_1', sample_policy=1),
sample_l7policy_tuple('sample_l7policy_id_2', sample_policy=2),
@@ -561,7 +578,8 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True,
persistence_type=persistence_type,
persistence_cookie=persistence_cookie,
monitor_ip_port=monitor_ip_port, monitor_proto=monitor_proto,
- backup_member=backup_member, disabled_member=disabled_member)]
+ backup_member=backup_member, disabled_member=disabled_member,
+ pool_cert=pool_cert)]
l7policies = []
return in_listener(
id='sample_listener_id_1',
@@ -578,7 +596,8 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True,
persistence_timeout=persistence_timeout,
persistence_granularity=persistence_granularity,
monitor_ip_port=monitor_ip_port,
- monitor_proto=monitor_proto) if alloc_default_pool else '',
+ monitor_proto=monitor_proto,
+ pool_cert=pool_cert) if alloc_default_pool else '',
connection_limit=connection_limit,
tls_certificate_id='cont_id_1' if tls else '',
sni_container_ids=['cont_id_2', 'cont_id_3'] if sni else [],
@@ -651,13 +670,15 @@ def sample_pool_tuple(proto=None, monitor=True, persistence=True,
persistence_timeout=None, persistence_granularity=None,
sample_pool=1, monitor_ip_port=False,
monitor_proto=None, backup_member=False,
- disabled_member=False, has_http_reuse=True):
+ disabled_member=False, has_http_reuse=True,
+ pool_cert=False, full_store=False):
proto = 'HTTP' if proto is None else proto
monitor_proto = proto if monitor_proto is None else monitor_proto
in_pool = collections.namedtuple(
'pool', 'id, protocol, lb_algorithm, members, health_monitor, '
- 'session_persistence, enabled, operating_status, ' +
- constants.HTTP_REUSE)
+ 'session_persistence, enabled, operating_status, '
+ 'tls_certificate_id, tls_container, load_balancer, '
+ 'listeners, ' + constants.HTTP_REUSE)
if (proto == constants.PROTOCOL_UDP and
persistence_type == constants.SESSION_PERSISTENCE_SOURCE_IP):
kwargs = {'persistence_type': persistence_type,
@@ -684,6 +705,8 @@ def sample_pool_tuple(proto=None, monitor=True, persistence=True,
monitor_ip_port=monitor_ip_port)]
if monitor is True:
mon = sample_health_monitor_tuple(proto=monitor_proto, sample_hm=2)
+
+ in_pool_listener = collections.namedtuple('listener', 'id',)
return in_pool(
id=id,
protocol=proto,
@@ -692,7 +715,15 @@ def sample_pool_tuple(proto=None, monitor=True, persistence=True,
health_monitor=mon,
session_persistence=persis if persistence is True else None,
enabled=True,
- operating_status='ACTIVE', has_http_reuse=has_http_reuse)
+ operating_status='ACTIVE', has_http_reuse=has_http_reuse,
+ tls_certificate_id='pool_cont_1' if pool_cert else None,
+ tls_container={'client_cert': 'fake path'} if pool_cert else '',
+ load_balancer=sample_listener_loadbalancer_tuple(
+ proto=proto,
+ topology=constants.TOPOLOGY_ACTIVE_STANDBY) if full_store else '',
+ listeners=[in_pool_listener(id='used_as_default_pool_listener_id'),
+ in_pool_listener(id='used_as_redirect_pool_l7_listener_id')]
+ if full_store else [])
def sample_member_tuple(id, ip, enabled=True, operating_status='ACTIVE',
diff --git a/releasenotes/notes/Add-pool-tls-client-auth-01d3b8acfb78ab14.yaml b/releasenotes/notes/Add-pool-tls-client-auth-01d3b8acfb78ab14.yaml
new file mode 100644
index 0000000000..d57ee6f644
--- /dev/null
+++ b/releasenotes/notes/Add-pool-tls-client-auth-01d3b8acfb78ab14.yaml
@@ -0,0 +1,5 @@
+---
+features:
+ - |
+ You can now specify a tls_container_ref on pools for TLS client
+ authentication to pool members.