SSL management feature

Add instance API actions to show, enable, disable, and roll back SSL/TLS
configuration. Certificates are supplied as PKCS#12 secrets stored in
Barbican, and support basic, enforced, and mutual-TLS modes.

Implement guest-agent support for MySQL, MariaDB, and PostgreSQL, including
certificate propagation to replicas and Barbican consumer lifecycle handling.

Blueprint: ssl-management-feature
Depends-On: https://review.opendev.org/c/openstack/trove-tempest-plugin/+/988053
Signed-off-by: Erkin Mussurmankulov <mangust404@gmail.com>
Change-Id: I93b9647c30cfcc7823e7d293652816f9040ee201
This commit is contained in:
Erkin Mussurmankulov committed 2026-07-14 17:49:27 +05:00
1 parent db36c51d5b
commit 729f614a73
48 files changed
+2670 -40

No files matched your search

+1
View File
@@ -12,6 +12,7 @@
.. include:: instances.inc
.. include:: instance-actions.inc
.. include:: instance-logs.inc
.. include:: instance-ssl.inc
.. include:: backups.inc
.. include:: backup-strategy.inc
.. include:: configurations.inc
+197
View File
@@ -0,0 +1,197 @@
.. -*- rst -*-
=============
Instance SSL
=============
Show instance SSL status
~~~~~~~~~~~~~~~~~~~~~~~~
.. rest_method:: GET /v1.0/{project_id}/instances/{instanceId}/ssl
Show current SSL status for the instance and information about current
SSL certificate (if present).
Normal response codes: 200
Request
-------
.. rest_parameters:: parameters.yaml
- project_id: project_id
- instanceId: instanceId
- include_certificate: ssl_include_cert
Response Parameters
-------------------
.. rest_parameters:: parameters.yaml
- ssl: instance_ssl
- ssl.status: ssl_status
- ssl.mode: ssl_mode
- ssl.certificate: ssl_certificate
- ssl.certificate.payload: ssl_certificate_payload
- ssl.certificate.cn: ssl_certificate_cn
- ssl.certificate.expire_at: ssl_certificate_expire_at
- ssl.certificate.san: ssl_certificate_san
Response Example
----------------
.. literalinclude:: samples/instance-ssl-show-response.json
:language: javascript
SSL enable action for instance
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. rest_method:: POST /v1.0/{project_id}/instances/{instanceId}/ssl
Enable SSL for a database instance, or renew an existing SSL
certificate on the instance.
Instance should be in `ACTIVE/HEALTHY` state.
Barbican consumer will be added for `container_ref` secret.
For replication, the SSL action should be called against the primary node only.
The configuration will be applied sequentially to each replica in a transactional manner.
If an error occurs on a single node, the entire configuration will be rolled back.
Normal response codes: 200
Request
-------
.. rest_parameters:: parameters.yaml
- project_id: project_id
- instanceId: instanceId
- ssl: instance_ssl
- ssl.enable: ssl_action_enable
- ssl.container_ref: ssl_container_ref
- ssl.password_ref: ssl_password_ref
Request Example
---------------
.. literalinclude:: samples/instance-ssl-action-request.json
:language: javascript
Response Parameters
-------------------
.. rest_parameters:: parameters.yaml
- ssl: instance_ssl
- ssl.status: ssl_status
- ssl.container_ref: ssl_container_ref
- ssl.restart_required: ssl_restart_required
Response Example
----------------
.. literalinclude:: samples/instance-ssl-action-response.json
:language: javascript
SSL disable action for instance
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. rest_method:: POST /v1.0/{project_id}/instances/{instanceId}/ssl
Disable SSL for a database instance.
Note that disabling SSL for some datastores, e.g. for MySQL 8.4 and
above, is not supported.
Barbican consumer will be removed from `container_ref` secret.
Normal response codes: 200
Request
-------
.. rest_parameters:: parameters.yaml
- project_id: project_id
- instanceId: instanceId
- ssl: instance_ssl
- ssl.disable: ssl_action_disable
Request Example
---------------
.. literalinclude:: samples/instance-ssl-action-request-disable.json
:language: javascript
Response Parameters
-------------------
.. rest_parameters:: parameters.yaml
- ssl: instance_ssl
- ssl.status: ssl_status
- ssl.restart_required: ssl_restart_required
Response Example
----------------
.. literalinclude:: samples/instance-ssl-action-response-disable.json
:language: javascript
SSL rollback action for instance
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. rest_method:: POST /v1.0/{project_id}/instances/{instanceId}/ssl
Roll back the last SSL configuration on the instance.
Use only in emergencies. Note that the Barbican consumer state may be
inconsistent after this operation because the guest agent applies the stored
certificate payload directly, without referring to the Barbican secret.
Normal response codes: 200
Request
-------
.. rest_parameters:: parameters.yaml
- project_id: project_id
- instanceId: instanceId
- ssl: instance_ssl
- ssl.rollback: ssl_action_rollback
Request Example
---------------
.. literalinclude:: samples/instance-ssl-action-request-rollback.json
:language: javascript
Response Parameters
-------------------
.. rest_parameters:: parameters.yaml
- ssl: instance_ssl
- ssl.status: ssl_status
- ssl.restart_required: ssl_restart_required
Response Example
----------------
.. literalinclude:: samples/instance-ssl-action-response-rollback.json
:language: javascript
+120 -1
View File
@@ -79,6 +79,13 @@ version:
in: path
required: false
type: string
# variables in query
ssl_include_cert:
description: |
If false or not provided, certificate payload is omitted.
in: query
required: false
type: boolean
# variables in body
access:
description: |
@@ -588,6 +595,30 @@ instance_replicas:
in: body
required: false
type: array
instance_ssl:
description: |
An ``ssl`` object.
in: body
required: true
type: object
properties:
status:
type: string
mode:
type: string
container_ref:
type: string
certificate:
type: object
properties:
payload:
type: string
cn:
type: string
expire_at:
type: string
san:
type: [string, "null"]
instance_status:
description: |
Status of the instance.
@@ -861,6 +892,94 @@ slave_of:
in: body
required: false
type: string
ssl_action_disable:
description: |
Disable installed SSL certificate.
in: body
required: false
type: boolean
ssl_action_enable:
description: |
Enable new SSL certificate.
in: body
required: false
type: boolean
ssl_action_rollback:
description: |
Rollback to last SSL configuration.
in: body
required: false
type: boolean
ssl_certificate:
description: |
Certificate object
in: body
required: false
type: object
ssl_certificate_cn:
description: |
CN for the current installed certificate.
in: body
required: false
type: string
ssl_certificate_expire_at:
description: |
Expiration timestamp of the current certificate.
in: body
required: false
type: string
ssl_certificate_payload:
description: |
Contains public certificate only. Private keys are never exposed.
Provided only if `?include_certificate=true` query param is
present.
in: body
required: false
type: string
ssl_certificate_san:
description: |
Certificate Subject Alternative Names.
in: body
required: false
type: array
items:
type: string
ssl_container_ref:
description: |
Reference to a PKCS#12 container stored in Barbican.
in: body
required: false
type: string
ssl_mode:
description: |
Currently enabled SSL/TLS mode:
- builtin (default certificates installed by DBMS)
- basic
- enforced
- mtls
in: body
required: true
type: string
ssl_password_ref:
description: |
Provide a Barbican reference to the password if the PKCS#12 container is
password-protected, with instance project member scope.
in: body
required: false
type: string
ssl_restart_required:
description: |
True if configuration changes require database restart to take effect.
in: body
required: false
type: boolean
ssl_status:
description: |
"on" if SSL is enabled, "off" if SSL is disabled or misconfigured.
in: body
required: true
type: enum
enum: [on, off]
storage_driver:
description: |
The backup strategy
@@ -1021,4 +1140,4 @@ wal_size:
PostgreSQL manager only.
in: body
required: false
type: integer
type: integer
@@ -0,0 +1,5 @@
{
"ssl": {
"disable": true
}
}
@@ -0,0 +1,5 @@
{
"ssl": {
"rollback": true
}
}
@@ -0,0 +1,8 @@
{
"ssl": {
"enable": true,
"mode": "basic",
"container_ref": "https://barbican.example.kz/v1/secrets/2b4edf0-586b-49c4-a183-716a66d86f4e1",
"password_ref": "https://barbican.example.kz/v1/secrets/3927c462-60eb-4f1f-b64c-5a6835778371"
}
}
@@ -0,0 +1,6 @@
{
"ssl": {
"status": "off",
"restart_required": true
}
}
@@ -0,0 +1,7 @@
{
"ssl": {
"status": "on",
"mode": "basic",
"restart_required": true
}
}
@@ -0,0 +1,7 @@
{
"ssl": {
"status": "on",
"mode": "basic",
"restart_required": false
}
}
@@ -0,0 +1,11 @@
{
"ssl": {
"status": "on",
"mode": "basic",
"certificate": {
"cn": "self-signed certificate",
"expire_at": "2027-02-10T09:06:25.000000",
"san": null
}
}
}
+7
View File
@@ -433,6 +433,13 @@ function configure_tempest_for_trove {
echo "Enable trove-tempest-plugin"
pip_install $DEST/trove-tempest-plugin
if [ -d "$DEST/barbican-tempest-plugin" ]; then
# This command has a meaning only for developers DevStack installtion.
# In Zuul jobs, this plugin installed by tempest_plugins: section in jobs.yaml.
echo "Enable barbican-tempest-plugin"
pip_install -e $DEST/barbican-tempest-plugin
fi
echo "Adding [database] section to tempest.conf"
if [[ -n "$TROVE_DATASTORE_TYPE" ]]; then
iniset $TEMPEST_CONFIG database enabled_datastores $TROVE_DATASTORE_TYPE
+4
View File
@@ -102,6 +102,10 @@ configuration:
enable_plugin trove https://opendev.org/openstack/trove
enable_plugin trove-tempest-plugin https://opendev.org/openstack/trove-tempest-plugin
# barbican service & tempest plugin
enable_plugin barbican https://opendev.org/openstack/barbican
enable_plugin barbican-tempest-plugin https://git.openstack.org/openstack/barbican-tempest-plugin
# required for not interrupting install process
SWIFT_HASH=66a3d6b56c1f479c8b4e70ab5c2000f5
+1
View File
@@ -19,3 +19,4 @@ handling complex administrative tasks.
upgrade-datastore.rst
set-up-clustering.rst
upgrade-cluster-datastore.rst
ssl/index
+189
View File
@@ -0,0 +1,189 @@
.. _ssl_pkcs12:
Preparing Certificates
======================
This section describes how to generate and prepare certificates for use
with Trove.
Generate CA private key
~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: console
$ openssl genrsa -out ca.key 2048
Generating RSA private key, 2048 bit long modulus
........................................+++++
................................+++++
e is 65537 (0x10001)
.. warning::
Keep the CA private key in a secure offline location.
Create self-signed CA certificate
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: console
$ openssl req -x509 -new -nodes \
-key ca.key \
-sha256 \
-days 365 \
-subj "/CN=Trove Test CA" \
-out ca-cert.crt
Generate server private key
~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: console
$ openssl genrsa -out server.key 2048
Generating RSA private key, 2048 bit long modulus
........................................+++++
........................+++++
e is 65537 (0x10001)
Generate server CSR
~~~~~~~~~~~~~~~~~~~
.. code-block:: console
$ openssl req -new \
-key server.key \
-out server.csr \
-subj "/CN=db.example.com" \
-addext "subjectAltName=DNS:db.example.com,DNS:db.internal,IP:10.0.0.10"
.. note::
Please note that domain name and (or) IP address must match the real
IP address that your client will use for connection to DB server.
Sign server certificate with CA
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: console
$ openssl x509 -req \
-in server.csr \
-CA ca-cert.crt \
-CAkey ca.key \
-CAcreateserial \
-out server.crt \
-days 365 \
-sha256
Signature ok
subject=/CN=db.example.com
Getting CA Private Key
Create PKCS#12 container
~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: console
$ openssl pkcs12 -export \
-inkey server.key \
-in server.crt \
-certfile ca-cert.crt \
-passout pass:MySecretPassword01! \
-out server.p12
.. _store_barbican_pass:
Store PKCS#12 password in Barbican
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: console
$ openstack secret store --name my-password-for-pkcs12 \
--payload MySecretPassword01!
+---------------+----------------------------------------------------------------------------------+
| Field | Value |
+---------------+----------------------------------------------------------------------------------+
| Secret href | https://barbican-api.example.com/v1/secrets/f54bc745-00e7-4e42-bd6a-0e80879b62f6 |
| Name | my-password-for-pkcs12 |
| Created | None |
| Status | None |
| Content types | None |
| Algorithm | aes |
| Bit length | 256 |
| Secret type | opaque |
| Mode | cbc |
| Expiration | None |
+---------------+----------------------------------------------------------------------------------+
Upload PKCS#12 to Barbican
~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: console
$ openstack secret store --file server.p12 \
--name my-pkcs12-container-for-ssl \
--secret-type certificate
+---------------+----------------------------------------------------------------------------------+
| Field | Value |
+---------------+----------------------------------------------------------------------------------+
| Secret href | https://barbican-api.example.com/v1/secrets/c3fd1238-7d85-4eec-8e36-53c4953bab39 |
| Name | my-pkcs12-container-for-ssl |
| Created | None |
| Status | None |
| Content types | None |
| Algorithm | aes |
| Bit length | 256 |
| Secret type | certificate |
| Mode | cbc |
| Expiration | None |
+---------------+----------------------------------------------------------------------------------+
.. _ssl_client_cert:
Create client certificate for mTLS authentication
-------------------------------------------------
Generate client private key
~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: console
$ openssl genrsa -out client.key 2048
Generate client CSR
~~~~~~~~~~~~~~~~~~~
.. code-block:: console
$ openssl req -new \
-key client.key \
-out client.csr \
-subj "/CN=dbuser"
.. note::
Please note that subject should be equal to a real user name used
to connect to the database.
Sign client certificate
~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: console
$ openssl x509 -req \
-in client.csr \
-CA ca-cert.crt \
-CAkey ca.key \
-CAcreateserial \
-out client.crt \
-days 365 \
-sha256
PostgreSQL connection using client certificate
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: console
psql "sslmode=verify-full \
sslrootcert=ca-cert.crt \
sslcert=client.crt \
sslkey=client.key \
host=10.0.0.10 user=dbuser dbname=test_db"
+16
View File
@@ -0,0 +1,16 @@
SSL/TLS Support in Trove
========================
Trove provides support for enabling SSL/TLS encryption for database
instances, including optional mutual TLS (mTLS) authentication.
This section describes the concepts, configuration, and usage of SSL/TLS
in Trove.
.. toctree::
:maxdepth: 2
overview
usage
certificates
replication
+59
View File
@@ -0,0 +1,59 @@
Overview
========
Trove supports SSL/TLS to secure connections between clients and database
instances.
Users may enable, disable, and monitor SSL configuration for database
instances.
SSL operating modes
-------------------
By default, most DBMSs generate their own self-signed SSL certificate on
startup. This is referred to as the "builtin" mode and is not managed by
Trove until SSL/TLS is explicitly enabled using one of the supported
operation modes.
Basic mode
~~~~~~~~~~
* SSL is enabled on the database instance.
* Both SSL and non-SSL client connections are allowed.
* SSL encryption is optional.
* Authentication remains password-based (for example, MD5).
* No client certificate validation.
This mode provides basic protection against passive traffic interception
and may be used as intermediate step for implementing enforced or
mTLS mode in production infrastructure.
Enforced mode
~~~~~~~~~~~~~
* SSL is enabled and mandatory.
* Only encrypted connections are accepted.
* Non-SSL connections are rejected.
* Authentication remains password-based.
* No client certificate validation.
This mode improves security and helps prevent insecure client
configurations.
Mutual TLS mode (mTLS)
~~~~~~~~~~~~~~~~~~~~~~
* SSL is enabled and mandatory.
* Clients must present valid certificates. Example of :ref:`creating cert <ssl_client_cert>`
* Certificates are validated using trusted CA.
* Client identity may be based on certificate attributes.
* Password authentication is disabled.
This mode provides strong authentication and encryption.
Limitations
-----------
* SSL configuration requires the instance to be in ACTIVE state
* For replication setups, SSL is applied to all replicas
+108
View File
@@ -0,0 +1,108 @@
.. _ssl_for_replication:
SSL/TLS for replication
-----------------------
Trove supports SSL/TLS configuration for replicated database clusters.
SSL configuration is managed at the primary instance level and is
automatically propagated to all replicas.
General behavior
~~~~~~~~~~~~~~~~
The following rules apply when SSL is used in replication:
* SSL can be enabled only on the primary instance.
* When SSL is enabled on the primary, the certificate and key are applied
to all attached replicas automatically.
* All instances in the replication cluster must remain consistent with
the primary SSL configuration.
These constraints ensure secure communication between replication nodes
and prevent configuration drift.
Prerequisites
~~~~~~~~~~~~~
Before enabling SSL in a replication cluster:
* The replication topology must be fully configured.
* All replicas must be attached to the primary instance.
* The replication cluster must be in ``ACTIVE`` and ``HEALTHY`` state.
If any instance is not ready, the operation fails. This prevents partial
SSL configuration and ensures cluster integrity.
Enable SSL in replication
~~~~~~~~~~~~~~~~~~~~~~~~~
The SSL enable operation must be performed on the primary instance only.
If the target instance is not primary, the operation is rejected.
.. code-block:: console
$ openstack database ssl enable <primary_id> <container_ref>
When the operation starts:
* Trove validates the state of the entire replication cluster.
* Certificates are retrieved from Barbican.
* SSL configuration is applied to the primary and all replicas.
If validation fails, no changes are applied.
Partial changes will be rolled back to the original state.
Restart behavior
~~~~~~~~~~~~~~~~
Depending on the datastore manager and database engine, a restart may be
required to apply SSL configuration.
The recommended restart order is:
1. Restart the primary instance.
2. Restart each replica sequentially.
Sequential restarts help preserve replication consistency and reduce
service disruption.
If Trove indicates that restart is required, users should avoid restarting
replicas in parallel unless supported by the specific database engine.
Replica provisioning
~~~~~~~~~~~~~~~~~~~~
When a new replica is created in an existing replication cluster:
* The replica automatically retrieves the required SSL certificate,
private key, and trusted CA from the primary instance.
* No additional SSL configuration is required from the user.
* The replica becomes consistent with the primary SSL configuration.
This behavior ensures that all replication nodes remain securely configured
and simplifies operational workflows.
Certificate rotation
~~~~~~~~~~~~~~~~~~~~
Trove supports certificate rotation in replication clusters.
To refresh or replace a certificate:
* Call the ``ssl enable`` command on the primary instance.
* Provide a new PKCS#12 container reference and the required SSL mode.
.. code-block:: console
$ openstack database ssl enable <primary_id> <new_container_ref> \
--mode <required_mode>
During this operation:
* The new certificate is retrieved from Barbican.
* The certificate and key are propagated to all replicas.
* The replication cluster remains consistent with the primary.
Depending on the datastore manager, a rolling restart of the replication
cluster may be required to apply the updated certificate.
+126
View File
@@ -0,0 +1,126 @@
Usage
=====
SSL management actions
----------------------
The following actions are available for SSL configuration.
Show current SSL status
~~~~~~~~~~~~~~~~~~~~~~~
Returns the current SSL configuration of a database instance.
The response may include:
* SSL status (on/off)
* Operating mode (basic/enforced/mtls)
* Certificate expiration date
* Certificate CN (common name)
* Certificate SAN (Subject Alternative Name)
* Certificate payload
.. code-block:: console
$ openstack database ssl show $dbid
+-----------------------+----------------------------+
| Field | Value |
+-----------------------+----------------------------+
| certificate_cn | trove-test |
| certificate_expire_at | 2027-02-10T09:06:25.000000 |
| certificate_san | IP:10.0.0.1 |
| mode | basic |
| status | on |
+-----------------------+----------------------------+
Enable SSL
~~~~~~~~~~
Enables SSL on the instance or rotates the existing certificate.
Requirements:
* The user must provide a reference to a PKCS12 container stored in Barbican.
* The container must include:
* Server certificate
* Private key for certificate
* Optional CA chain
Example of creating a PKCS#12 container: :ref:`PKCS#12 example <ssl_pkcs12>`
Optional:
* password_ref should be provided if the container is password-protected.
Example: :ref:`Store Barbican password <store_barbican_pass>`
Parameters:
* container_ref
* password_ref (optional)
Depending on database manager, restart may be required for applying SSL
configuration.
.. code-block:: console
$ openstack database ssl enable $dbid https://barbican-api.example.com/9036aa7c-7c71-45e3-a9c4-a71465c4ccc9 \
--password-ref https://barbican-api.example.com/d7211e91-4c74-43e1-8a7f-47937e56c0bd
+------------------+-------+
| Field | Value |
+------------------+-------+
| restart_required | True |
+------------------+-------+
Please note that secret consumer will be created for provided certificate
container in Barbican, referencing the database instance.
Disable SSL
~~~~~~~~~~~
Disables SSL on the instance.
Depending on database manager, restart may be required for applying SSL
configuration.
.. code-block:: console
$ openstack database ssl disable $dbid
+------------------+-------+
| Field | Value |
+------------------+-------+
| restart_required | True |
+------------------+-------+
Certificate requirements
------------------------
The provided PKCS12 container must:
* Contain a valid private key and certificate.
* Match the database instance hostname.
* Use modern cryptographic parameters.
Recommended:
* TLS 1.2 or higher.
* Proper Subject Alternative Names.
* Certificate signed by a trusted CA.
End-user recommendations
------------------------
The following guidance may help users operating production DBaaS:
* Use at least enforced encryption in production environments.
* Automate certificate rotation.
* Monitor certificate expiration.
* Use separate certificates per environment.
* Consider mTLS for sensitive workloads.
* Validate client TLS compatibility before enforcing encryption.
* Integrate SSL lifecycle with centralized secret management.
These recommendations are based on common production practices and may
vary depending on organizational security policies.
@@ -0,0 +1,6 @@
---
features:
- |
SSL/TLS certificate management for securing the client-server channel.
The PKCS#12 container and credentials are stored in Barbican.
Available modes are basic, enforced, and mTLS. See the documentation for more information.
+2
View File
@@ -21,6 +21,7 @@ python-designateclient>=2.7.0 # Apache-2.0
python-neutronclient>=6.7.0 # Apache-2.0
python-glanceclient>=2.8.0 # Apache-2.0
python-troveclient>=2.2.0 # Apache-2.0
python-barbicanclient>=7.0.0 # Apache-2.0
jsonschema>=3.2.0 # MIT
Jinja2>=2.10 # BSD License (3 clause)
pexpect!=3.3,>=3.1 # ISC License
@@ -46,6 +47,7 @@ docker>=4.2.0 # Apache-2.0
psycopg2-binary>=2.6.2 # LGPL/ZPL
semantic-version>=2.7.0 # BSD
oslo.cache>=1.26.0 # Apache-2.0
pyOpenSSL>=24.2.1 # Apache-2.0
# for trove network driver
Flask>=2.2.3 # BSD
+2
View File
@@ -37,6 +37,8 @@ CONF.register_opts([
openstack_cfg.BoolOpt('network_isolation',
help='whether to plug user defined '
'port to database container'),
openstack_cfg.StrOpt('ssl_mode', default=None,
help='Current SSL enabled mode.'),
openstack_cfg.StrOpt('replication_strategy',
default='trove.guestagent.strategies.replication.'
'mysql_gtid.MysqlGTIDReplication',
+8
View File
@@ -129,6 +129,14 @@ class API(wsgi.Router):
controller=instance_resource,
action="guest_log_action",
conditions={'method': ['POST']})
mapper.connect("/{tenant_id}/instances/{id}/ssl",
controller=instance_resource,
action="ssl_show",
conditions={'method': ['GET']})
mapper.connect("/{tenant_id}/instances/{id}/ssl",
controller=instance_resource,
action="ssl_action",
conditions={'method': ['POST']})
mapper.connect("/{tenant_id}/instances/{id}/modules",
controller=instance_resource,
action="module_list",
+5
View File
@@ -125,6 +125,11 @@ common_opts = [
help='Service type to use when searching catalog.'),
cfg.StrOpt('trove_endpoint_type', default='publicURL',
help='Service endpoint type to use when searching catalog.'),
cfg.URIOpt('barbican_url', help='URL ending in ``AUTH_``.'),
cfg.StrOpt('barbican_service_type', default='key-manager',
help='Service type to use when fetching keys.'),
cfg.StrOpt('barbican_endpoint_type', default='publicURL',
help='Service endpoint type to use when fetching keys.'),
cfg.IPOpt('host', default='0.0.0.0',
help='Host to listen for RPC messages.'),
cfg.IntOpt('report_interval', default=30,
+27
View File
@@ -19,10 +19,12 @@ from trove.common import cfg
from trove.common import exception
from trove.common.strategies.cluster import strategy
from barbicanclient import client as BarbicanClient
from cinderclient.v3 import client as CinderClient
import glanceclient
from keystoneauth1.identity import v3
from keystoneauth1 import session as ka_session
from keystoneauth1 import token_endpoint
from keystoneclient.service_catalog import ServiceCatalog
from neutronclient.v2_0 import client as NeutronClient
from novaclient.client import Client
@@ -214,6 +216,31 @@ def glance_client(context, region_name=None):
)
def barbican_client(context, region_name=None):
if CONF.barbican_url:
endpoint_url = CONF.barbican_url
else:
region = region_name or CONF.service_credentials.region_name
endpoint_url = get_endpoint(
context.service_catalog,
service_type=CONF.barbican_service_type,
endpoint_region=region,
endpoint_type=CONF.barbican_endpoint_type
)
auth = token_endpoint.Token(endpoint_url, context.auth_token)
session = ka_session.Session(auth=auth)
return BarbicanClient.Client(
'v1',
session=session,
project_id=context.project_id,
service_type=CONF.barbican_service_type,
interface=CONF.barbican_endpoint_type,
endpoint=endpoint_url
)
def create_dns_client(*arg, **kwargs):
return import_class(CONF.remote_dns_client)(*arg, **kwargs)
+1
View File
@@ -30,6 +30,7 @@ class RedisConfParser(object):
class MySQLConfParser(object):
SERVER_CONF_SECTION = 'mysqld'
CLIENT_CONF_SECTION = 'client'
CODEC = stream_codecs.IniCodec(
default_value='1', comment_markers=('#', ';', '!'))
+196
View File
@@ -0,0 +1,196 @@
# Copyright 2026 PS Cloud Services
#
# 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.
from barbicanclient import exceptions as barbican_exceptions
from cryptography.hazmat.primitives.serialization \
import pkcs12, Encoding, PrivateFormat, NoEncryption
from cryptography.hazmat.backends import default_backend
from datetime import datetime
import OpenSSL.crypto as crypto
from oslo_log import log as logging
from trove.common import cfg
from trove.common.clients import barbican_client
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
MODE_BUILTIN = 'builtin'
MODE_BASIC = 'basic'
MODE_ENFORCED = 'enforced'
MODE_MTLS = 'mtls'
class TroveSSL(object):
def __init__(self, context):
self._context = context
# Fetch certificate, key and ca from p12 container
def get_p12_bundle(self, p12_ref, password_ref=None):
barbican = barbican_client(self._context)
pkcs12_secret = barbican.secrets.get(p12_ref)
p12_pass = None
if password_ref:
p12_pass = barbican.secrets.get(password_ref).payload
if isinstance(p12_pass, str):
p12_pass = p12_pass.encode("utf-8")
pk, cert, ca_chain = pkcs12.load_key_and_certificates(
pkcs12_secret.payload,
p12_pass,
backend=default_backend()
)
pk = pk.private_bytes(
encoding=Encoding.PEM,
format=PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=NoEncryption()
).decode("utf-8")
cert = cert.public_bytes(Encoding.PEM).decode("utf-8")
ca_pems = ""
if ca_chain:
for ca in ca_chain:
ca_pems += ca.public_bytes(
Encoding.PEM).decode("utf-8")
return {
'private_key': pk,
'certificate': cert,
'ca': ca_pems}
def certificate_details(self, cert_payload):
cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_payload)
subject = cert.get_subject()
expiry_bytes = cert.get_notAfter()
expiry_str = expiry_bytes.decode("ascii")
expiry = datetime.strptime(expiry_str, "%Y%m%d%H%M%SZ")
# Extract SANs
san_list = []
# Loop through all extensions in the certificate
for i in range(cert.get_extension_count()):
ext = cert.get_extension(i)
# Check if the extension is 'subjectAltName'
if ext.get_short_name() == b"subjectAltName":
san_str = str(ext)
san_list = [item.strip() for item in san_str.split(",")]
return {
'cn': subject.CN,
'expire_at': expiry,
'san': san_list if san_list else None
}
def register_consumer(self, uri, resource_type, resource_id):
barbican = barbican_client(self._context)
barbican.secrets.register_consumer(
uri, 'database', resource_type, resource_id)
def remove_consumer(self, uri, resource_type, resource_id):
barbican = barbican_client(self._context)
barbican.secrets.remove_consumer(
uri, 'database', resource_type, resource_id)
def disable_instance_ssl(self, instance):
rollback_changes = []
if instance.db_info.ssl_ref:
try:
args = [instance.db_info.ssl_ref, 'instance', instance.id]
self.remove_consumer(*args)
rollback_changes.append({'type': 'register', 'args': args})
except barbican_exceptions.HTTPClientError as e:
LOG.info('Unable to remove consumer for %s: %s',
instance.db_info.ssl_ref, e)
instance.db_info.ssl_ref = None
instance.db_info.ssl_mode = None
instance.db_info.save()
return rollback_changes
def enable_instance_ssl(self, instance, mode, container_ref):
rollback_changes = []
if instance.db_info.ssl_ref and \
instance.db_info.ssl_ref != container_ref:
LOG.info("Instance %s already has container ref, tidying up %s",
instance.id, instance.db_info.ssl_ref)
# Certificate refresh, remove consumer from previous ref
rollback_changes.extend(self.disable_instance_ssl(instance))
instance.db_info.ssl_mode = mode
if container_ref:
instance.db_info.ssl_ref = container_ref
try:
args = container_ref, 'instance', instance.id
self.register_consumer(*args)
rollback_changes.append({'type': 'remove', 'args': args})
except barbican_exceptions.HTTPClientError as e:
LOG.info('Unable to register consumer for %s: %s',
container_ref, e)
instance.db_info.save()
return rollback_changes
def apply_ssl_state(self, instance, mode, container_ref, enable, disable):
if enable:
return self.enable_instance_ssl(instance, mode, container_ref)
elif disable:
return self.disable_instance_ssl(instance)
def rollback_ssl_state(self, instance, old_mode, old_ref,
consumer_changes):
LOG.info('Rolling back ssl state for %s', instance.id)
instance.db_info.ssl_mode = old_mode
instance.db_info.ssl_ref = old_ref
instance.db_info.save()
for state in reversed(consumer_changes):
if state['type'] == 'remove':
self.remove_consumer(*state['args'])
elif state['type'] == 'register':
self.register_consumer(*state['args'])
def run_ssl_state_transaction(ssl_client, instances, mode, container_ref,
enable, disable, execute, rollback):
"""SSL state change transaction wrapper."""
states = []
try:
# Apply SSL state to DB and Barbican
for instance in instances:
state = {
'instance': instance,
'mode': instance.db_info.ssl_mode,
'ref': instance.db_info.ssl_ref}
state['consumer_changes'] = ssl_client.apply_ssl_state(
instance, mode, container_ref, enable, disable)
states.append(state)
# Execute the wrapped action (guest agent changes)
result = execute()
except Exception as e:
# Rollback all applied states if setup or execution fails
LOG.exception(
"Failed to execute SSL action: %s, rolling back states.", e)
try:
rollback()
except Exception as exc:
LOG.exception(
"Guest SSL rollback failed, error: %s.", exc)
for state in reversed(states):
ssl_client.rollback_ssl_state(
state['instance'], state['mode'], state['ref'],
state['consumer_changes'])
raise
return result
@@ -0,0 +1,55 @@
# 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.
"""add ssl_mode and ssl_ref to instances
Revision ID: 95418fe2a1c8
Revises: f90016d7baf8
Create Date: 2026-02-20 17:23:54.492132
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import String, Text
# revision identifiers, used by Alembic.
revision: str = '95418fe2a1c8'
down_revision: Union[str, None] = 'f90016d7baf8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'instances',
sa.Column(
'ssl_mode',
String(16),
nullable=True
)
)
op.add_column(
'instances',
sa.Column(
'ssl_ref',
Text(),
nullable=True
)
)
def downgrade() -> None:
op.drop_column('instances', 'ssl_mode')
op.drop_column('instances', 'ssl_ref')
+37 -1
View File
@@ -45,7 +45,7 @@ class API(object):
# API_LATEST_VERSION should bump the minor number each time
# a method signature is added or changed
API_LATEST_VERSION = '1.2'
API_LATEST_VERSION = '1.3'
# API_BASE_VERSION should only change on major version upgrade
API_BASE_VERSION = '1.0'
@@ -60,6 +60,7 @@ class API(object):
'ussuri': '1.0',
'victoria': '1.1',
'epoxy': '1.2',
'hibiscus': '1.3',
'latest': API_LATEST_VERSION
}
@@ -663,6 +664,41 @@ class API(object):
enable=enable, disable=disable,
publish=publish, discard=discard)
def ssl_show(self):
LOG.debug("Showing SSL guest status for %s.", self.id)
version = '1.3'
if not self.client.can_send_version(version):
raise exception.GuestError(
original_message=("Operation requires guest version %s or "
"later" % version))
result = self._call("ssl_show", self.agent_high_timeout,
version=version)
return result
def ssl_action(self, mode, container, enable, disable):
LOG.debug("Executing SSL action for %s.", self.id)
version = '1.3'
if not self.client.can_send_version(version):
raise exception.GuestError(
original_message=("Operation requires guest version %s or "
"later" % version))
return self._call("ssl_action", self.agent_high_timeout,
version=version, mode=mode, container=container,
enable=enable, disable=disable)
def ssl_rollback(self):
LOG.debug("Rolling back SSL configuration for %s.", self.id)
version = '1.3'
if not self.client.can_send_version(version):
raise exception.GuestError(
original_message=("Operation requires guest version %s or "
"later" % version))
return self._call("ssl_rollback", self.agent_high_timeout,
version=version)
def module_list(self, include_contents):
LOG.debug("Querying modules on %s (contents: %s).",
self.id, include_contents)
+39 -1
View File
@@ -15,7 +15,9 @@
#
import abc
import configparser
import operator
import os
import docker
from oslo_config import cfg as oslo_cfg
@@ -29,6 +31,7 @@ from trove.common.notification import EndNotification
from trove.guestagent.common import guestagent_utils
from trove.guestagent.common import operating_system
from trove.guestagent.common.operating_system import FileMode
from trove.guestagent.datastore.ssl import SSLManager
from trove.guestagent import dbaas
from trove.guestagent import guest_log
from trove.guestagent.module import driver_manager
@@ -41,7 +44,7 @@ LOG = logging.getLogger(__name__)
CONF = cfg.CONF
class Manager(periodic_task.PeriodicTasks):
class Manager(periodic_task.PeriodicTasks, SSLManager):
"""This is the base class for all datastore managers. Over time, common
functionality should be pulled back here from the existing managers.
"""
@@ -912,6 +915,19 @@ class Manager(periodic_task.PeriodicTasks):
'replica_conf': replica_conf
}
ssl_status = self.show_ssl_status()
if ssl_status['status'] == 'on' and CONF.ssl_mode and \
CONF.ssl_mode != 'None':
LOG.info(
"SSL is enabled, adding CA and certificates "
"to the replication snapshot")
replication_snapshot['cert_container'] = {}
replication_snapshot['ssl_mode'] = CONF.ssl_mode
for key, filename in self._get_ssl_files().items():
content = operating_system.read_file(filename, as_root=True)
replication_snapshot['cert_container'][key] = content
return replication_snapshot
def attach_replica(self, context, snapshot, slave_config, restart=False):
@@ -983,3 +999,25 @@ class Manager(periodic_task.PeriodicTasks):
LOG.error("Run post_create_backup failed, error: %s" % str(e))
return {}
def override_guest_info(self, **kwargs):
if ('/' in CONF.guest_info):
# Set guest_info_file to exactly guest_info from the conf file.
# This should be /etc/guest_info for pre-Kilo compatibility.
guest_info_file = CONF.guest_info
else:
guest_info_file = os.path.join(CONF.injected_config_location,
CONF.guest_info)
config = configparser.ConfigParser()
config.read(guest_info_file)
LOG.debug('Override guest_info.conf: %s', kwargs)
config.defaults().update(kwargs)
with open(guest_info_file, 'w') as f:
config.write(f)
for key, value in kwargs.items():
CONF.set_override(key, value)
@@ -15,6 +15,7 @@
from oslo_log import log as logging
from oslo_utils.excutils import save_and_reraise_exception
import semantic_version
from trove.common import cfg
from trove.common import exception
from trove.common import utils
@@ -143,3 +144,31 @@ class Manager(manager.MySqlManager):
str(err))
pass
LOG.info('Finished to reset password for restore')
def _get_default_tls_versions(self):
mariadb_10_4_6 = semantic_version.Version('10.4.6')
cur_ver = semantic_version.Version.coerce(CONF.datastore_version)
if cur_ver <= mariadb_10_4_6:
return 'TLSv1.2'
else:
return 'TLSv1.2,TLSv1.3'
def _get_disable_ssl_overrides(self):
return {
# Change of ssl_cert to empty value leads to error in MariaDB
# startup process
'ssl': 'false',
# The only reliable way to disable SSL is to unset tls_version.
'tls_version': ''
}
def _get_enable_ssl_overrides(self):
files = self._get_ssl_files()
return {
'ssl_cert': files['certificate'],
'ssl_key': files['private_key'],
'ssl_ca': files['ca'],
'ssl': 'on',
'tls_version': self._get_default_tls_versions()
}
@@ -14,6 +14,7 @@
from oslo_log import log as logging
import semantic_version
from trove.common import cfg
from trove.common import exception
from trove.guestagent.common import operating_system
@@ -65,3 +66,71 @@ class Manager(manager.MySqlManager):
raise exception.BackupCreationError(str(e))
return status
def _get_default_tls_versions(self):
# NOTE(mangust404): to operate correctly, your datastore version
# should contain patch version number. Datastore name like "5.7"
# wouldn't be able to use TLSv1.3
mysql_5_7_0 = semantic_version.Version('5.7.0')
mysql_5_7_27 = semantic_version.Version('5.7.27')
cur_ver = semantic_version.Version.coerce(CONF.datastore_version)
if cur_ver < mysql_5_7_0:
# For MySQL 5.6 there is no TLS support
raise exception.BadRequest(
'No support of TLS for MySQL prior to 5.7')
elif cur_ver <= mysql_5_7_27:
return 'TLSv1,TLSv1.1,TLSv1.2'
elif cur_ver > mysql_5_7_27:
return 'TLSv1.2,TLSv1.3'
def _get_enable_client_ssl_overrides(self):
mysql_8 = semantic_version.Version('8.0.0')
cur_ver = semantic_version.Version.coerce(CONF.datastore_version)
if cur_ver >= mysql_8:
return {'ssl-mode': 'REQUIRED'}
else:
return {'ssl': 'on'}
def _get_disable_client_ssl_overrides(self):
mysql_8 = semantic_version.Version('8.0.0')
cur_ver = semantic_version.Version.coerce(CONF.datastore_version)
if cur_ver >= mysql_8:
return {'ssl-mode': 'DISABLED'}
else:
return {'ssl': 'off'}
def disable_ssl_certificate(self):
cur_ver = semantic_version.Version.coerce(CONF.datastore_version)
mysql_8_0 = semantic_version.Version('8.0.0')
if cur_ver >= mysql_8_0:
# Starting from MySQL 8.0 there is no reliable way to disable
# SSL/TLS completely because caching_sha2_password depends on it
raise exception.TroveError("Not supported for MySQL 8.0 and above")
return super(Manager, self).disable_ssl_certificate()
def _get_enable_ssl_overrides(self):
files = self._get_ssl_files()
overrides = {
'ssl_cert': files['certificate'],
'ssl_key': files['private_key'],
'ssl_ca': files['ca'],
'tls_version': self._get_default_tls_versions(),
'require_secure_transport': 'ON'
}
return overrides
def _get_disable_ssl_overrides(self):
overrides = {
'ssl_cert': '',
'ssl_key': '',
'ssl_ca': '',
# The only reliable way to disable SSL is to unset tls_version.
'tls_version': '',
'require_secure_transport': 'OFF'
}
return overrides
@@ -24,6 +24,7 @@ from trove.common import configurations
from trove.common import constants
from trove.common import exception
from trove.common.notification import EndNotification
from trove.common import ssl
from trove.common import utils
from trove.guestagent.common import operating_system
from trove.guestagent.datastore import manager
@@ -272,6 +273,11 @@ class MySqlManager(manager.Manager):
if 'replication_strategy' in replica_info:
self._validate_slave_for_replication(context, replica_info)
if 'cert_container' in replica_info and \
'ssl_mode' in replica_info:
self.enable_ssl_certificate(
replica_info['ssl_mode'], replica_info['cert_container'],
is_startup=True)
self.replication.enable_as_slave(self.app, replica_info,
slave_config)
except Exception as err:
@@ -322,6 +328,12 @@ class MySqlManager(manager.Manager):
self.app.configuration_manager.reset_configuration(config_contents)
self.app.update_overrides(config_overrides)
if self.ssl_mode_at_least(CONF.ssl_mode, ssl.MODE_BASIC):
cert_container = self._read_ssl_files()
self.enable_ssl_certificate(
CONF.ssl_mode, cert_container,
apply_overrides=False, is_startup=True)
# Start database service.
command = self.get_start_db_params(data_dir)
self.app.start_db(ds_version=ds_version, command=command)
@@ -344,3 +356,98 @@ class MySqlManager(manager.Manager):
LOG.error("Run post_create_backup failed, error: %s" % str(e))
return {}
def _get_default_tls_versions(self):
raise Exception('not implemented')
def _get_ssl_files(self):
datadir = self.app.get_data_dir()
return {
"private_key": f"{datadir}/server-key.pem",
"certificate": f"{datadir}/server-cert.pem",
"ca": f"{datadir}/ca.pem"
}
def _get_enable_ssl_overrides(self):
files = self._get_ssl_files()
return {
'ssl_cert': files['certificate'],
'ssl_key': files['private_key'],
'ssl_ca': files['ca'],
'tls_version': self._get_default_tls_versions()
}
def _get_enable_client_ssl_overrides(self):
return {'ssl': 'on'}
def _enable_ssl_certificate_impl(self, mode, apply_overrides=True):
overrides = self._get_enable_ssl_overrides()
self.app.update_overrides(overrides)
client_overrides = self._get_enable_client_ssl_overrides()
self.app.update_client_overrides(client_overrides)
if apply_overrides:
self.adm.set_users_access_mode(mode)
self.status.set_status(
service_status.ServiceStatuses.RESTART_REQUIRED)
# Restart is required
return True
def _get_disable_ssl_overrides(self):
return {
'ssl_cert': '',
'ssl_key': '',
'ssl_ca': '',
# The only reliable way to disable SSL is to unset tls_version.
'tls_version': ''
}
def _get_disable_client_ssl_overrides(self):
return {'ssl': 'off'}
def _disable_ssl_certificate_impl(self, apply_overrides=True):
overrides = self._get_disable_ssl_overrides()
self.app.update_overrides(overrides)
client_overrides = self._get_disable_client_ssl_overrides()
self.app.update_client_overrides(client_overrides)
self.status.set_status(
service_status.ServiceStatuses.RESTART_REQUIRED)
# Restart is required
return True
def _get_ssl_cert(self):
result = self.app.execute_sql(
'SHOW VARIABLES LIKE "ssl_cert"')
return result.first()._mapping['Value']
def _get_tls_version(self):
result = self.app.execute_sql(
'SHOW VARIABLES LIKE "tls_version"')
return result.first()._mapping['Value']
def show_ssl_status(self):
tls_version = self._get_tls_version()
ssl_cert = self._get_ssl_cert()
if tls_version and ssl_cert:
if not os.path.isabs(ssl_cert):
ssl_cert = os.path.join(
self.app.get_data_dir(),
ssl_cert)
certificate = operating_system.read_file(
ssl_cert,
as_root=True)
return {
'status': 'on',
'certificate': certificate
}
return {'status': 'off'}
@@ -29,6 +29,7 @@ from trove.common import constants
from trove.common.db.mysql import models
from trove.common import exception
from trove.common.i18n import _
from trove.common import ssl
from trove.common import utils
from trove.guestagent.common.configuration import ConfigurationManager
from trove.guestagent.common.configuration import ImportOverrideStrategy
@@ -397,6 +398,61 @@ class BaseMySqlAdmin(object, metaclass=abc.ABCMeta):
user = self._get_user(username, hostname)
return user.databases
def set_users_access_mode(self, mode):
"""Set access mode for all non-system users.
Modes:
- basic: no SSL requirement
- enforced: REQUIRE SSL
- mtls: REQUIRE X509
"""
if mode not in (ssl.MODE_BASIC,
ssl.MODE_ENFORCED, ssl.MODE_MTLS):
raise exception.BadRequest(
_("Unknown access mode: %s") % mode
)
LOG.info("Setting users access mode to '%s'", mode)
with mysql_util.SqlClient(
self.mysql_app.get_engine(), use_flush=True) as client:
# Get all users except system ones
q = sql_query.Query()
q.columns = ['User', 'Host']
q.tables = ['mysql.user']
q.where = ["Host != 'localhost'"]
t = text(str(q))
result = client.execute(t).fetchall()
for row in result:
username = row._mapping['User']
host = row._mapping['Host']
# Skip system users
if username in ['root', 'os_admin', 'exporter']:
continue
if username.startswith('slave_'):
continue
try:
user = models.MySQLUser(name=username, host=host)
user.check_reserved()
except ValueError:
# skip invalid/reserved users
continue
# Build ALTER USER statement
if mode == ssl.MODE_BASIC:
stmt = f"ALTER USER '{username}'@'{host}' REQUIRE NONE"
elif mode == ssl.MODE_ENFORCED:
stmt = f"ALTER USER '{username}'@'{host}' REQUIRE SSL"
elif mode == ssl.MODE_MTLS:
stmt = f"ALTER USER '{username}'@'{host}' REQUIRE X509"
LOG.debug("Altering user access mode: %s", stmt)
client.execute(text(stmt))
class BaseMySqlApp(service.BaseDbApp):
_configuration_manager = None
@@ -528,6 +584,11 @@ class BaseMySqlApp(service.BaseDbApp):
self.configuration_manager.apply_user_override(
{MySQLConfParser.SERVER_CONF_SECTION: overrides})
def update_client_overrides(self, overrides):
if overrides:
self.configuration_manager.apply_user_override(
{MySQLConfParser.CLIENT_CONF_SECTION: overrides})
def apply_overrides(self, overrides):
with mysql_util.SqlClient(self.get_engine()) as client:
for k, v in overrides.items():
+84 -2
View File
@@ -22,6 +22,7 @@ from trove.common import cfg
from trove.common import constants
from trove.common import exception
from trove.common.notification import EndNotification
from trove.common import ssl
from trove.common import utils
from trove.guestagent.common import guestagent_utils
from trove.guestagent.common import operating_system
@@ -48,6 +49,13 @@ class PostgresManager(manager.Manager):
def configuration_manager(self):
return self.app.configuration_manager
def _apply_access_rules(self, mode=None):
mode = mode or CONF.ssl_mode
self.app.apply_access_rules(
ssl=self.ssl_mode_at_least(mode, ssl.MODE_ENFORCED),
mtls=self.ssl_mode_at_least(mode, ssl.MODE_MTLS))
def _get_max_wal_size_bytes(self):
"""Get PostgreSQL max_wal_size as bytes (default 1GB)."""
value = self.app.get_config_param('max_wal_size')
@@ -160,7 +168,7 @@ class PostgresManager(manager.Manager):
self.app.update_overrides(overrides)
# Prepare pg_hba.conf
self.app.apply_access_rules()
self._apply_access_rules()
self.configuration_manager.apply_system_override(
{'hba_file': service.HBA_CONFIG_FILE})
@@ -255,6 +263,11 @@ class PostgresManager(manager.Manager):
"""Set up the standby server."""
self.replication.enable_as_slave(self.app, replica_info, None)
if 'cert_container' in replica_info:
self.enable_ssl_certificate(
replica_info['ssl_mode'], replica_info['cert_container'],
apply_overrides=False, is_startup=True)
# For the previous primary, don't start db service in order to run
# pg_rewind command next.
if restart:
@@ -323,8 +336,15 @@ class PostgresManager(manager.Manager):
self.app.set_data_dir(self.app.datadir)
self.app.update_overrides(config_overrides)
# Enable SSL, if necessary
if self.ssl_mode_at_least(CONF.ssl_mode, ssl.MODE_BASIC):
cert_container = self._read_ssl_files()
self.enable_ssl_certificate(
CONF.ssl_mode, cert_container,
apply_overrides=False, is_startup=True)
# force to create pg_hba.conf on rebuild
self.app.apply_access_rules()
self._apply_access_rules()
self.app.configuration_manager.apply_system_override(
{'hba_file': service.HBA_CONFIG_FILE})
@@ -420,3 +440,65 @@ class PostgresManager(manager.Manager):
'wal_size': operating_system.get_dir_size(wal_dir, as_root=True),
'wal_archive_size': operating_system.get_dir_size(wal_archive_dir)
}
def _get_ssl_files(self):
return {
'private_key': f"{self.app.datadir}/server.key",
'certificate': f"{self.app.datadir}/server.crt",
'ca': f"{self.app.datadir}/ca.crt"
}
# SSL modes (configured via pg_hba.conf):
# basic - SSL enabled, client cert not required, md5 auth
# enforced - SSL required (hostssl), md5 auth still used
# mtls - SSL required, client cert required (verify-full),
# authentication via certificate (CN must match user)
def _enable_ssl_certificate_impl(self, mode, apply_overrides=True):
LOG.debug('Enable SSL mode: %s', mode)
self._apply_access_rules(mode)
overrides = {
'ssl': 'on',
'ssl_ca_file': 'ca.crt',
'ssl_cert_file': 'server.crt',
'ssl_key_file': 'server.key'
}
self.app.update_overrides(overrides)
if apply_overrides:
self.app.apply_overrides(overrides)
# PostgreSQL reloads SSL configuration without restart.
return False
def _disable_ssl_certificate_impl(self, apply_overrides=True):
LOG.debug('Disable SSL')
self._apply_access_rules(None)
overrides = {
'ssl': 'off'
}
self.app.update_overrides(overrides)
if apply_overrides:
self.app.apply_overrides(overrides)
# PostgreSQL reloads SSL configuration without restart.
return False
def show_ssl_status(self):
status = self.app.adm.query('SHOW ssl')[0][0]
if status != 'on':
return {'status': status}
certificate = operating_system.read_file(
self._get_ssl_files()['certificate'],
as_root=True)
return {
'status': status,
'certificate': certificate
}
+27 -9
View File
@@ -11,12 +11,12 @@
# 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.
from collections import OrderedDict
import os
from oslo_log import log as logging
import psycopg2
from collections import OrderedDict
from trove.common import cfg
from trove.common import constants
from trove.common.db.postgresql import models
@@ -35,6 +35,7 @@ LOG = logging.getLogger(__name__)
CONF = cfg.CONF
SUPER_USER_NAME = "postgres"
REPLICATION_USER = 'replicator'
CONFIG_FILE = "/etc/postgresql/postgresql.conf"
CNF_EXT = 'conf'
# The same with include_dir config option
@@ -94,7 +95,8 @@ class PgSqlApp(service.BaseDbApp):
cmd = f"pg_ctl reload -D {self.datadir}"
docker_util.run_command(self.docker_client, cmd)
def apply_access_rules(self):
def apply_access_rules(self, ssl=False, mtls=False,
replication_user=None):
"""PostgreSQL Client authentication settings
The order of entries is important. The first failure to authenticate
@@ -103,18 +105,34 @@ class PgSqlApp(service.BaseDbApp):
"""
LOG.debug("Applying client authentication access rules.")
host_d = 'hostssl' if ssl else 'host'
if mtls:
auth_method = ['cert', 'clientcert=verify-full']
else:
auth_method = ['md5']
host_rules = [
['all', SUPER_USER_NAME, '127.0.0.1/32', 'trust'],
['all', SUPER_USER_NAME, '::1/128', 'trust'],
['all', SUPER_USER_NAME, 'localhost', 'trust'],
['all', SUPER_USER_NAME, '0.0.0.0/0', 'reject'],
['all', SUPER_USER_NAME, '::/0', 'reject'],
['all', 'all', '0.0.0.0/0', *auth_method],
['all', 'all', '::/0', *auth_method],
]
if replication_user:
host_rules.extend([
['replication', replication_user, '0.0.0.0/0', 'md5'],
['replication', replication_user, '::/0', 'md5'],
])
access_rules = OrderedDict(
[('local', [['all', SUPER_USER_NAME, None, 'trust'],
['replication', SUPER_USER_NAME, None, 'trust'],
['all', 'all', None, 'md5']]),
('host', [['all', SUPER_USER_NAME, '127.0.0.1/32', 'trust'],
['all', SUPER_USER_NAME, '::1/128', 'trust'],
['all', SUPER_USER_NAME, 'localhost', 'trust'],
['all', SUPER_USER_NAME, '0.0.0.0/0', 'reject'],
['all', SUPER_USER_NAME, '::/0', 'reject'],
['all', 'all', '0.0.0.0/0', 'md5'],
['all', 'all', '::/0', 'md5']])
(host_d, host_rules)
])
operating_system.write_file(
HBA_CONFIG_FILE, access_rules,
stream_codecs.PropertiesCodec(string_mappings={'\t': None}),
+200
View File
@@ -0,0 +1,200 @@
# Copyright 2026 PS Cloud Services
# All Rights Reserved.
#
# 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.
#
import abc
import json
import os
import tempfile
from oslo_log import log as logging
from trove.common import cfg
from trove.common import exception
from trove.common import ssl
from trove.common.ssl import TroveSSL
from trove.guestagent.common import operating_system
from trove.guestagent.common.operating_system import FileMode
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
class SSLManager(object):
def _get_ssl_state_file(self):
return os.path.join(
CONF.injected_config_location,
"ssl_state.json")
def _write_ssl_state(self, state):
state_file = self._get_ssl_state_file()
with tempfile.NamedTemporaryFile(
mode="w",
dir=os.path.dirname(state_file),
delete=False) as fp:
json.dump(state, fp)
fp.flush()
os.fsync(fp.fileno())
tmp_name = fp.name
os.replace(tmp_name, state_file)
def _read_ssl_state(self):
with open(self._get_ssl_state_file()) as fp:
return json.load(fp)
def _delete_ssl_state(self):
state_file = self._get_ssl_state_file()
if os.path.exists(state_file):
os.unlink(state_file)
def _write_ssl_files(self, cert_container):
for key, filename in self._get_ssl_files().items():
payload = cert_container.get(key)
if not payload:
continue
operating_system.write_file(
filename,
payload,
as_root=True)
operating_system.chown(
filename,
user=self.app.database_service_uid,
group=self.app.database_service_gid,
force=True,
as_root=True)
operating_system.chmod(
filename,
FileMode.SET_USR_RW(),
as_root=True)
operating_system.sync(os.path.dirname(filename))
def _read_ssl_files(self):
files = {}
for key, filename in self._get_ssl_files().items():
files[key] = operating_system.read_file(
filename,
as_root=True)
return files
def _save_ssl_state(self):
status = self.show_ssl_status()
state = {
"status": status["status"],
"mode": CONF.ssl_mode,
}
if status["status"] == "on":
state["files"] = self._read_ssl_files()
self._write_ssl_state(state)
def ssl_mode_at_least(self, current_mode, required_mode):
priorities = [
ssl.MODE_BASIC, ssl.MODE_ENFORCED, ssl.MODE_MTLS]
try:
return (
priorities.index(current_mode) >=
priorities.index(required_mode)
)
except ValueError:
return False
def ssl_show(self, context):
LOG.info("Getting ssl status for instance")
result = self.show_ssl_status()
if result["status"] == "on":
result["mode"] = CONF.ssl_mode
ssl = TroveSSL(context)
cert_details = ssl.certificate_details(result["certificate"])
cert_details["payload"] = result["certificate"]
del result["certificate"]
return {**result, "certificate": cert_details}
else:
return result
def ssl_action(self, context, mode, container, enable, disable):
if enable and disable:
raise exception.BadRequest("Cannot enable and disable ssl")
LOG.info(
"SSL action for instance: mode=%s, enable=%s, disable=%s",
mode, enable, disable)
result = {}
if enable:
result["restart_required"] = self.enable_ssl_certificate(
mode, container)
elif disable:
result["restart_required"] = self.disable_ssl_certificate()
return result
def ssl_rollback(self, context):
state = self._read_ssl_state()
LOG.info("Rolling back ssl configuration for instance")
LOG.debug("Previous ssl status: %s, mode: %s",
state['status'], state['mode'])
result = {
"status": state["status"]
}
if state["status"] == "off":
result["restart_required"] = self._disable_ssl_certificate_impl()
self.override_guest_info(ssl_mode=None)
result["mode"] = None
else:
self._write_ssl_files(state["files"])
result["restart_required"] = self._enable_ssl_certificate_impl(
state["mode"])
self.override_guest_info(ssl_mode=state["mode"])
result["mode"] = state["mode"]
self._delete_ssl_state()
return result
def enable_ssl_certificate(
self, mode, cert_container, apply_overrides=True,
is_startup=False):
LOG.debug("Called enable_ssl_certificate, mode=%s", mode)
if not is_startup:
self._save_ssl_state()
self._write_ssl_files(cert_container)
restart_required = self._enable_ssl_certificate_impl(
mode, apply_overrides=apply_overrides)
self.override_guest_info(ssl_mode=mode)
return restart_required
def disable_ssl_certificate(self):
LOG.debug("Called disable_ssl_certificate")
self._save_ssl_state()
restart_required = self._disable_ssl_certificate_impl()
self.override_guest_info(ssl_mode=None)
return restart_required
def _enable_ssl_certificate_impl(self, mode, apply_overrides=True):
raise exception.TroveError("ssl enable not implemented")
def _disable_ssl_certificate_impl(self, apply_overrides=True):
raise exception.TroveError("ssl disable not implemented")
def show_ssl_status(self):
"""Manager should return current ssl certificate details and current
ssl status (on/off)
"""
return {"status": "not implemented"}
@abc.abstractmethod
def _get_ssl_files(self):
"""Manager should return dict with private_key, certificate and ca
as keys and corresponding paths as values.
"""
return {}
@@ -104,6 +104,17 @@ class MariaDBGTIDReplication(mysql_base.MysqlReplicationBase):
'user': replica_conf['replication_user']['name'],
'password': replica_conf['replication_user']['password']
})
if 'cert_container' in master_info and \
'ssl_mode' in master_info:
change_master_cmd = change_master_cmd + (
", MASTER_SSL_VERIFY_SERVER_CERT=1"
", MASTER_SSL=1")
else:
change_master_cmd = change_master_cmd + (
", MASTER_SSL_VERIFY_SERVER_CERT=0"
", MASTER_SSL=0")
service.execute_sql(change_master_cmd)
service.start_slave()
@@ -73,6 +73,11 @@ class MysqlGTIDReplication(mysql_base.MysqlReplicationBase):
'user': replica_conf['replication_user']['name'],
'password': replica_conf['replication_user']['password']
})
if 'cert_container' in master_info and \
'ssl_mode' in master_info:
change_master_cmd = change_master_cmd + (
",SOURCE_SSL=1"
)
else:
change_master_cmd = (
"CHANGE MASTER TO "
@@ -88,6 +93,11 @@ class MysqlGTIDReplication(mysql_base.MysqlReplicationBase):
'user': replica_conf['replication_user']['name'],
'password': replica_conf['replication_user']['password']
})
if 'cert_container' in master_info and \
'ssl_mode' in master_info:
change_master_cmd = change_master_cmd + (
",MASTER_SSL=1"
)
service.execute_sql(change_master_cmd)
service.start_slave()
@@ -21,6 +21,7 @@ from trove.common import cfg
from trove.common import constants
from trove.common.db.postgresql import models
from trove.common import exception
from trove.common import ssl
from trove.common import utils
from trove.guestagent.common import operating_system
from trove.guestagent.common.operating_system import FileMode
@@ -29,7 +30,7 @@ from trove.guestagent.strategies.replication import base
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
REPL_USER = 'replicator'
REPL_USER = pg_service.REPLICATION_USER
class PostgresqlReplicationStreaming(base.Replication):
@@ -90,32 +91,17 @@ class PostgresqlReplicationStreaming(base.Replication):
return repl_user_info
def enable_as_master(self, service, master_config):
"""Primary postgredql settings.
"""Primary postgresql settings.
For a server to be a master in postgres, we need to enable
the replication user in pg_hba.conf
"""
self._get_or_create_replication_user(service)
hba_entry = f"host replication {REPL_USER} 0.0.0.0/0 md5\n"
tmp_hba = '/tmp/pg_hba'
operating_system.copy(pg_service.HBA_CONFIG_FILE, tmp_hba,
force=True, as_root=True)
operating_system.chmod(tmp_hba, FileMode.SET_ALL_RWX(),
as_root=True)
with open(tmp_hba, 'a+') as hba_file:
hba_file.write(hba_entry)
operating_system.copy(tmp_hba, pg_service.HBA_CONFIG_FILE,
force=True, as_root=True)
operating_system.chown(pg_service.HBA_CONFIG_FILE,
user=service.database_service_uid,
group=service.database_service_gid,
as_root=True)
operating_system.chmod(pg_service.HBA_CONFIG_FILE,
FileMode.SET_USR_RWX(),
as_root=True)
operating_system.remove(tmp_hba, as_root=True)
service.apply_access_rules(
ssl=CONF.ssl_mode in (ssl.MODE_ENFORCED, ssl.MODE_MTLS),
mtls=CONF.ssl_mode == ssl.MODE_MTLS,
replication_user=REPL_USER)
LOG.debug(f"{pg_service.HBA_CONFIG_FILE} changed")
service.restart()
+10 -4
View File
@@ -1067,9 +1067,10 @@ class BaseInstance(SimpleInstance):
"tenant_id=%s\n"
"datastore_registry_ext=%s\n"
"replication_strategy=%s\n"
"ssl_mode=%s\n"
% (self.id, datastore_manager, datastore_version,
self.tenant_id, self.datastore_registry_ext,
self.datastore_repl_strategy)
self.datastore_repl_strategy, self.db_info.ssl_mode)
)
}
@@ -1262,7 +1263,8 @@ class Instance(BuiltInstance):
availability_zone=None, nics=None,
configuration_id=None, slave_of_id=None, cluster_config=None,
replica_count=None, volume_type=None, modules=None,
locality=None, region_name=None, access=None):
locality=None, region_name=None, access=None,
ssl_mode=None, ssl_ref=None):
nova_client = clients.create_nova_client(context)
cinder_client = clients.create_cinder_client(context)
datastore_cfg = CONF.get(datastore_version.manager)
@@ -1396,6 +1398,8 @@ class Instance(BuiltInstance):
context,
instance_id=slave_of_id))
replica_source_instance.validate_can_perform_action()
ssl_mode = replica_source_instance.db_info.ssl_mode
ssl_ref = replica_source_instance.db_info.ssl_ref
multi_replica = slave_of_id and replica_count and replica_count > 1
instance_count = replica_count if multi_replica else 1
@@ -1445,7 +1449,8 @@ class Instance(BuiltInstance):
configuration_id=configuration_id,
slave_of_id=slave_of_id, cluster_id=cluster_id,
shard_id=shard_id, type=instance_type,
region_id=region_name, access=access)
region_id=region_name, access=access,
ssl_mode=ssl_mode, ssl_ref=ssl_ref)
instance_id = db_info.id
instance_name = name
LOG.debug(f"Creating new instance {instance_id}")
@@ -2026,7 +2031,8 @@ class DBInstance(dbmodels.DatabaseModelBase):
'volume_size', 'tenant_id', 'server_status',
'deleted', 'deleted_at', 'datastore_version_id',
'configuration_id', 'slave_of_id', 'cluster_id',
'shard_id', 'type', 'region_id', 'encrypted_key', 'access']
'shard_id', 'type', 'region_id', 'encrypted_key', 'access',
'ssl_mode', 'ssl_ref']
_table_name = 'instances'
def __init__(self, task_status, **kwargs):
+153
View File
@@ -31,6 +31,7 @@ from trove.common import notification
from trove.common.notification import StartNotification
from trove.common import pagination
from trove.common import policy
from trove.common import ssl
from trove.common import utils
from trove.common import wsgi
from trove.datastore import models as ds_models
@@ -280,7 +281,24 @@ class InstanceController(wsgi.Controller):
instance_module['module_id'])
module_models.InstanceModule.delete(
context, instance_module)
try:
if instance.db_info['ssl_mode'] and \
instance.db_info['ssl_mode'] != 'None':
# (mangust404) The problem here that to correctly remove
# consumer, the context should be a project's owner.
# Barbican doesn't allow to manage secrets/consumers
# even for admins.
# If you run instance delettion as admin, secret consumers
# cannot be updated.
ssl_client = ssl.TroveSSL(context)
ssl_client.disable_instance_ssl(instance)
except Exception as e:
LOG.warning(
"Unable to remove consumer for key %s on %s deletion: %s",
instance.db_info['ssl_ref'], instance.id, e,
exc_info=True)
instance.delete()
return wsgi.Result(None, 202)
def _check_nic(self, context, nic):
@@ -496,6 +514,12 @@ class InstanceController(wsgi.Controller):
region_name=region_name,
access=access)
if slave_of_id and replica_source.ssl_mode and \
replica_source.ssl_mode != 'None':
ssl_client = ssl.TroveSSL(context)
ssl_client.enable_instance_ssl(
instance, replica_source.ssl_mode, replica_source.ssl_ref)
view = views.InstanceDetailView(instance, req=req)
return wsgi.Result(view.data(), 200)
@@ -662,6 +686,135 @@ class InstanceController(wsgi.Controller):
publish, discard)
return wsgi.Result({'log': guest_log}, 200)
def ssl_show(self, req, tenant_id, id):
"""Show information about SSL for an instance."""
LOG.debug("Showing SSL status for tenant %s", tenant_id)
context = req.environ[wsgi.CONTEXT_KEY]
instance = models.Instance.load(context, id)
if not instance:
raise exception.NotFound(uuid=id)
self.authorize_instance_action(context, 'show', instance)
client = clients.create_guest_client(context, id)
ssl_info = client.ssl_show()
if instance.db_info.ssl_mode and \
instance.db_info.ssl_mode != 'None':
ssl_info['mode'] = instance.db_info.ssl_mode
else:
ssl_info['mode'] = ssl.MODE_BUILTIN
include_cert = strutils.bool_from_string(
req.GET.get('include_certificate'))
if not include_cert and 'certificate' in ssl_info and \
'payload' in ssl_info['certificate']:
del ssl_info['certificate']['payload']
return wsgi.Result({'ssl': ssl_info}, 200)
def ssl_action(self, req, body, tenant_id, id):
LOG.info("SSL action for tenant %s", tenant_id)
context = req.environ[wsgi.CONTEXT_KEY]
instance = models.Instance.load(context, id)
if not instance:
raise exception.NotFound(uuid=id)
instance.validate_can_perform_action()
self.authorize_instance_action(context, 'update', instance)
client = clients.create_guest_client(context, id)
container_ref = body['ssl'].get('container_ref', None)
password_ref = body['ssl'].get('password_ref', None)
enable = body['ssl'].get('enable', None)
if enable and not container_ref:
raise exception.BadRequest(
"You should provide container_ref for enabling ssl.")
disable = body['ssl'].get('disable', None)
rollback = body['ssl'].get('rollback', None)
actions = [enable, disable, rollback]
if sum(1 for op in actions if op) > 1:
raise exception.BadRequest(
"Cannot do several operations at once.")
# Rolling back for replica is allowed, to cover edge cases
if rollback:
LOG.debug(
"Rolling back SSL configuration for tenant %s", tenant_id)
result = client.ssl_rollback()
# Write real guest mode into database
if 'mode' in result:
instance.db_info.ssl_mode = result['mode']
instance.db_info.save()
return wsgi.Result({'ssl': result}, 200)
if instance.slave_of_id:
raise exception.BadRequest(
"Unable to modify ssl on the replica. "
"Please perform the action on the primary instead.")
mode = body['ssl'].get('mode', ssl.MODE_BASIC)
if mode not in (ssl.MODE_BASIC,
ssl.MODE_ENFORCED, ssl.MODE_MTLS):
raise exception.BadRequest(
"Unknown SSL mode. Available options are: "
"%s, %s, %s" % (
ssl.MODE_BASIC, ssl.MODE_ENFORCED, ssl.MODE_MTLS))
container = None
ssl_client = ssl.TroveSSL(context)
if container_ref:
# Get certificate container from Barbican.
container = ssl_client.get_p12_bundle(container_ref, password_ref)
replicas = []
replica_clients = {}
for dbinfo in instance.slaves:
replica = models.Instance.load(
context, dbinfo.id, needs_server=True)
replica.validate_can_perform_action()
self.authorize_instance_action(
context, 'update', replica)
replicas.append(replica)
replica_clients[replica.id] = (
clients.create_guest_client(context, replica.id))
instances = [instance] + replicas
completed = []
# Define the actual task to be passed into transaction wrapper
def _execute_guest_ssl_actions():
ssl_info = client.ssl_action(mode, container, enable, disable)
completed.append(client)
for replica in replicas:
replica_clients[replica.id].ssl_action(
mode, container, enable, disable)
completed.append(replica_clients[replica.id])
return ssl_info
# Define the rollback task to be passed into transaction wrapper
def _rollback_guest_ssl_actions():
error = None
for guest in reversed(completed):
try:
guest.ssl_rollback()
except Exception as exc:
LOG.exception(
"Failed to rollback guest SSL configuration: %s",
exc)
if error is None:
error = exc
if error:
raise error
ssl_info = ssl.run_ssl_state_transaction(
ssl_client, instances, mode, container_ref, enable, disable,
_execute_guest_ssl_actions, _rollback_guest_ssl_actions)
return wsgi.Result({'ssl': ssl_info}, 200)
def module_list(self, req, tenant_id, id):
"""Return information about modules on an instance."""
context = req.environ[wsgi.CONTEXT_KEY]
@@ -0,0 +1,40 @@
# Copyright 2026 PS Cloud
#
# 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.
from trove.common import ssl
from trove.guestagent.datastore import manager as base_manager
from trove.tests.unittests import trove_testtools
class TestManager(trove_testtools.TestCase):
def setUp(self):
super(TestManager, self).setUp()
self.manager = base_manager.Manager('test')
def test_ssl_mode_at_least(self):
self.assertTrue(self.manager.ssl_mode_at_least(
ssl.MODE_BASIC, ssl.MODE_BASIC))
self.assertTrue(self.manager.ssl_mode_at_least(
ssl.MODE_ENFORCED, ssl.MODE_BASIC))
self.assertTrue(self.manager.ssl_mode_at_least(
ssl.MODE_ENFORCED, ssl.MODE_ENFORCED))
self.assertTrue(self.manager.ssl_mode_at_least(
ssl.MODE_MTLS, ssl.MODE_ENFORCED))
self.assertFalse(self.manager.ssl_mode_at_least(
ssl.MODE_BASIC, ssl.MODE_MTLS))
self.assertFalse(self.manager.ssl_mode_at_least(
'invalid', ssl.MODE_BASIC))
self.assertFalse(self.manager.ssl_mode_at_least(
ssl.MODE_BASIC, 'invalid'))
self.assertFalse(self.manager.ssl_mode_at_least(
None, ssl.MODE_BASIC))
@@ -502,3 +502,411 @@ class TestInstanceController(trove_testtools.TestCase):
ins.attach_configuration.assert_called_once_with('fake_config_id')
ins.detach_configuration.assert_not_called()
@mock.patch('trove.instance.service.clients.create_guest_client')
@mock.patch('trove.instance.models.Instance.load')
def test_ssl_show_calls_guest_agent(self, load_mock, guest_client_mock):
ins_mock = MagicMock()
ins_mock.tenant_id = 'tenant123'
ins_mock.db_info.ssl_mode = 'basic'
load_mock.return_value = ins_mock
guest_mock = MagicMock()
guest_client_mock.return_value = guest_mock
guest_mock.ssl_show.return_value = {
'status': 'on'}
req = MagicMock()
req.environ = {'trove.context': self.context}
result = self.controller.ssl_show(req, 'fake_tenant', 'fake_id')
load_mock.assert_called_once_with(self.context, 'fake_id')
guest_client_mock.assert_called_once_with(self.context, 'fake_id')
guest_mock.ssl_show.assert_called_once_with()
self.assertEqual({'ssl': {'status': 'on', 'mode': 'basic'}},
result.data('application/json'))
@mock.patch('trove.common.ssl.TroveSSL.register_consumer')
@mock.patch('trove.common.ssl.TroveSSL.get_p12_bundle')
@mock.patch('trove.instance.service.clients.create_guest_client')
@mock.patch('trove.instance.models.Instance.load')
def test_ssl_action_calls_guest_agent_enable(
self, load_mock, guest_client_mock, get_bundle_mock,
register_consumer_mock):
ins_mock = MagicMock()
ins_mock.tenant_id = 'tenant123'
ins_mock.db_info = MagicMock()
ins_mock.db_info.ssl_ref = None
ins_mock.db_info.ssl_mode = None
ins_mock.slave_of_id = None
ins_mock.slaves = []
load_mock.return_value = ins_mock
guest_mock = MagicMock()
guest_client_mock.return_value = guest_mock
guest_mock.ssl_action.return_value = {'status': 'disabled'}
get_bundle_mock.return_value = {
'private_key': 'key',
'certificate': 'cert',
'ca': 'ca'
}
req = MagicMock()
req.environ = {'trove.context': self.context}
body = {'ssl': {
'enable': True,
'container_ref': 'container_ref'}}
result = self.controller.ssl_action(req, body,
'fake_tenant', 'fake_id')
load_mock.assert_called_once_with(self.context, 'fake_id')
guest_client_mock.assert_called_once_with(self.context, 'fake_id')
get_bundle_mock.assert_called_once_with('container_ref', None)
guest_mock.ssl_action.assert_called_once_with(
'basic',
get_bundle_mock.return_value,
True,
None
)
register_consumer_mock.assert_called_once()
self.assertEqual({'ssl': {'status': 'disabled'}},
result.data('application/json'))
@mock.patch.object(InstanceController, 'authorize_instance_action')
@mock.patch('trove.instance.service.clients.create_guest_client')
@mock.patch('trove.instance.models.Instance.load')
def test_ssl_action_enable_and_disable_raises(
self, load_mock, guest_client_mock, authorize_mock):
instance = MagicMock()
instance.tenant_id = 'tenant123'
load_mock.return_value = instance
req = MagicMock()
req.environ = {'trove.context': self.context}
body = {'ssl': {'enable': True, 'disable': True}}
self.assertRaises(
exception.BadRequest,
self.controller.ssl_action,
req, body, 'fake_tenant', 'fake_id'
)
load_mock.assert_called_once_with(self.context, 'fake_id')
guest_client_mock.assert_called_once_with(self.context, 'fake_id')
authorize_mock.assert_called_once_with(
self.context, 'update', instance)
@mock.patch('trove.instance.service.clients.create_guest_client')
@mock.patch('trove.instance.models.Instance.load')
def test_ssl_action_against_replica_raises(
self, load_mock, guest_client_mock):
ins_mock = MagicMock()
ins_mock.tenant_id = 'tenant123'
ins_mock.db_info = MagicMock()
ins_mock.db_info.ssl_ref = None
ins_mock.db_info.ssl_mode = None
ins_mock.slave_of_id = 'tenant234'
load_mock.return_value = ins_mock
req = MagicMock()
req.environ = {'trove.context': self.context}
body = {'ssl': {
'enable': True,
'container_ref': 'container_ref'}}
self.assertRaises(
exception.BadRequest,
self.controller.ssl_action,
req, body, 'fake_tenant', 'fake_id'
)
@mock.patch('trove.instance.service.clients.create_guest_client')
@mock.patch('trove.instance.models.Instance.load')
def test_ssl_show_include_certificate_flag(
self, load_mock, guest_client_mock):
ins_mock = MagicMock()
ins_mock.tenant_id = 'tenant123'
ins_mock.db_info.ssl_mode = 'basic'
load_mock.return_value = ins_mock
req = MagicMock()
req.environ = {'trove.context': self.context}
test_cases = [
{
'name': 'parameter is not set',
'query': {},
'expect_payload': False,
},
{
'name': 'include_certificate=1',
'query': {'include_certificate': '1'},
'expect_payload': True,
},
{
'name': 'include_certificate=false',
'query': {'include_certificate': 'false'},
'expect_payload': False,
},
{
'name': 'include_certificate=true',
'query': {'include_certificate': 'true'},
'expect_payload': True,
},
]
for case in test_cases:
guest_mock = MagicMock()
guest_client_mock.return_value = guest_mock
guest_mock.ssl_show.return_value = {
'status': 'on',
'certificate': {
'name': 'server-cert',
'payload': 'secret-cert-data'
}
}
req.GET = case['query']
result = self.controller.ssl_show(
req, 'fake_tenant', 'fake_id')
expected_certificate = {
'name': 'server-cert'
}
if case['expect_payload']:
expected_certificate['payload'] = 'secret-cert-data'
self.assertEqual(
{'ssl': {
'status': 'on',
'mode': 'basic',
'certificate': expected_certificate
}},
result.data('application/json'),
case['name']
)
@mock.patch('trove.common.ssl.TroveSSL.remove_consumer')
@mock.patch('trove.common.ssl.TroveSSL.register_consumer')
@mock.patch('trove.common.ssl.TroveSSL.get_p12_bundle')
@mock.patch('trove.instance.service.clients.create_guest_client')
@mock.patch('trove.instance.models.Instance.load')
def test_ssl_action_rolls_back_metadata_on_guest_failure(
self,
load_mock,
guest_client_mock,
get_bundle_mock,
register_consumer_mock,
remove_consumer_mock):
instance = MagicMock()
instance.id = 'instance-id'
instance.slave_of_id = None
instance.slaves = []
instance.db_info.ssl_mode = None
instance.db_info.ssl_ref = None
load_mock.return_value = instance
guest = MagicMock()
guest.ssl_action.side_effect = RuntimeError('guest failure')
guest_client_mock.return_value = guest
get_bundle_mock.return_value = 'fake-container'
register_consumer_mock.return_value = None
remove_consumer_mock.return_value = None
req = MagicMock()
req.environ = {'trove.context': self.context}
body = {
'ssl': {
'enable': True,
'mode': 'basic',
'container_ref': 'container-ref'
}
}
# The controller should re-raise the RuntimeError after rollback
self.assertRaises(
RuntimeError,
self.controller.ssl_action,
req,
body,
'tenant-id',
'instance-id'
)
# Verify register_consumer was called before the failure
register_consumer_mock.assert_called_once_with(
'container-ref',
'instance',
'instance-id'
)
# Verify rollback logic: remove_consumer should be called on failure
remove_consumer_mock.assert_called_once_with(
'container-ref',
'instance',
'instance-id'
)
# Verify metadata rollback:
# The wrapper should have reset these to None after the guest failure
self.assertIsNone(instance.db_info.ssl_mode)
self.assertIsNone(instance.db_info.ssl_ref)
# Verify saves were executed (one for the initial update, one for
# rollback)
self.assertGreaterEqual(instance.db_info.save.call_count, 2)
@mock.patch('trove.common.ssl.TroveSSL.register_consumer')
@mock.patch('trove.common.ssl.TroveSSL.get_p12_bundle')
@mock.patch('trove.instance.service.clients.create_guest_client')
@mock.patch('trove.instance.models.Instance.load')
def test_ssl_action_success_applies_metadata_and_calls_guest(
self,
load_mock,
guest_client_mock,
get_bundle_mock,
register_consumer_mock):
instance = MagicMock()
instance.id = 'instance-id'
instance.slave_of_id = None
instance.slaves = []
# Initial DB state (No SSL yet)
instance.db_info.ssl_mode = None
instance.db_info.ssl_ref = None
load_mock.return_value = instance
guest = MagicMock()
guest.ssl_action.return_value = {'status': 'enabled'}
guest_client_mock.return_value = guest
get_bundle_mock.return_value = 'fake-container'
# register_consumer returns None by default, simulating success
# --- 4. Execute Test ---
req = MagicMock()
req.environ = {'trove.context': self.context}
body = {
'ssl': {
'enable': True,
'mode': 'basic',
'container_ref': 'container-ref'
}
}
result = self.controller.ssl_action(
req, body, 'tenant-id', 'instance-id'
)
# Verify the Guest Agent was called with data from our get_bundle mock
guest.ssl_action.assert_called_once_with(
'basic',
'fake-container',
True,
None
)
# Verify the wrapper updated the Database state correctly
self.assertEqual('basic', instance.db_info.ssl_mode)
self.assertEqual('container-ref', instance.db_info.ssl_ref)
# Verify Barbican interaction was triggered by the real wrapper
register_consumer_mock.assert_called_once_with(
'container-ref',
'instance',
'instance-id'
)
# Ensure the changes were actually persisted to the DB
instance.db_info.save.assert_called()
# Check response structure
self.assertEqual(
{'ssl': {'status': 'enabled'}},
result.data('application/json')
)
@mock.patch('trove.instance.service.ssl.run_ssl_state_transaction')
@mock.patch('trove.common.ssl.TroveSSL.get_p12_bundle')
@mock.patch('trove.instance.service.clients.create_guest_client')
@mock.patch('trove.instance.models.Instance.load')
def test_ssl_action_passes_guest_actions_to_transaction(
self, load_mock, guest_client_mock, get_bundle_mock,
transaction_mock):
instance = MagicMock()
instance.id = 'instance-id'
instance.slave_of_id = None
instance.slaves = [MagicMock(id='replica-id')]
replica = MagicMock()
replica.id = 'replica-id'
load_mock.side_effect = [instance, replica]
primary_guest = MagicMock()
replica_guest = MagicMock()
guest_client_mock.side_effect = [primary_guest, replica_guest]
primary_guest.ssl_action.return_value = {'status': 'enabled'}
get_bundle_mock.return_value = 'certificate-bundle'
guest_calls = MagicMock()
guest_calls.attach_mock(primary_guest, 'primary')
guest_calls.attach_mock(replica_guest, 'replica')
def run_transaction(ssl_client, instances, mode, container_ref,
enable, disable, execute, rollback):
self.assertEqual([instance, replica], instances)
self.assertEqual('enforced', mode)
self.assertEqual('container-ref', container_ref)
self.assertTrue(enable)
self.assertIsNone(disable)
result = execute()
rollback()
return result
transaction_mock.side_effect = run_transaction
req = MagicMock()
req.environ = {'trove.context': self.context}
body = {
'ssl': {
'enable': True,
'mode': 'enforced',
'container_ref': 'container-ref'
}
}
result = self.controller.ssl_action(
req, body, 'tenant-id', 'instance-id')
transaction_mock.assert_called_once()
self.assertEqual(
[
mock.call.primary.ssl_action(
'enforced', 'certificate-bundle', True, None),
mock.call.replica.ssl_action(
'enforced', 'certificate-bundle', True, None),
mock.call.replica.ssl_rollback(),
mock.call.primary.ssl_rollback(),
],
guest_calls.mock_calls
)
self.assertEqual(
{'ssl': {'status': 'enabled'}},
result.data('application/json')
)
+87
View File
@@ -227,6 +227,93 @@
TROVE_BUILD_BACKUP_IMAGES: True
tempest_test_regex: ^trove_tempest_plugin\.tests\.scenario\.test_replication
- job:
name: trove-tempest-ubuntu-ssl-base
parent: trove-tempest
files:
- ^trove/.*ssl.*\.py$
- ^trove/instance/.*\.py$
- ^trove/guestagent/datastore/manager\.py$
- ^trove/guestagent/datastore/service\.py$
required-projects:
- openstack/trove
- openstack/trove-tempest-plugin
- openstack/barbican
- openstack/barbican-tempest-plugin
- openstack/tempest
vars:
devstack_plugins:
trove: https://opendev.org/openstack/trove.git
barbican: https://opendev.org/openstack/barbican.git
devstack_services:
etcd3: false
cinder: true
c-sch: true
c-api: true
c-vol: true
c-bak: false
swift: true
s-account: true
s-container: true
s-object: true
s-proxy: true
tls-proxy: true
tempest: true
barbican-svc: true
barbican-retry: true
tempest_plugins:
- trove-tempest-plugin
- barbican-tempest-plugin
devstack_localrc:
# Prevent "Secret creation attempt not allowed - please review your user/project privileges"
# In Zuul, barbican works correctly only with enforce_new_defaults = True
ENFORCE_SCOPE: True
tempest_test_regex: ^trove_tempest_plugin\.tests\.scenario\.test_instance_ssl\.(?!.*test_ssl_replication)
- job:
name: trove-tempest-ubuntu-ssl-replication
parent: trove-tempest
files:
- ^trove/.*ssl.*\.py$
- ^trove/instance/.*\.py$
- ^trove/guestagent/datastore/manager\.py$
- ^trove/guestagent/datastore/service\.py$
required-projects:
- openstack/trove
- openstack/trove-tempest-plugin
- openstack/barbican
- openstack/barbican-tempest-plugin
- openstack/tempest
vars:
devstack_plugins:
trove: https://opendev.org/openstack/trove.git
barbican: https://opendev.org/openstack/barbican.git
devstack_services:
etcd3: false
cinder: true
c-sch: true
c-api: true
c-vol: true
c-bak: false
swift: true
s-account: true
s-container: true
s-object: true
s-proxy: true
tls-proxy: true
tempest: true
barbican-svc: true
barbican-retry: true
tempest_plugins:
- trove-tempest-plugin
- barbican-tempest-plugin
devstack_localrc:
TROVE_BUILD_BACKUP_IMAGES: True
# Prevent "Secret creation attempt not allowed - please review your user/project privileges"
# In Zuul, barbican works correctly only with enforce_new_defaults = True
ENFORCE_SCOPE: True
tempest_test_regex: ^trove_tempest_plugin\.tests\.scenario\.test_instance_ssl\..*test_ssl_replication
- job:
name: publish-trove-guest-image
parent: publish-openstack-artifacts
+34
View File
@@ -114,3 +114,37 @@
backup_wait_timeout: 1200
enabled_datastores: "mariadb"
default_datastore_versions: mariadb:11.4
- job:
name: trove-tempest-ubuntu-ssl-mariadb
parent: trove-tempest-ubuntu-ssl-base
files: !inherit
- ^trove/guestagent/.*mariadb.*\.py$
vars:
devstack_localrc:
TROVE_DATASTORE_TYPE: mariadb
TROVE_DATASTORE_VERSION: 11.4
devstack_local_conf:
test-config:
$TEMPEST_CONFIG:
database:
backup_wait_timeout: 1200
enabled_datastores: "mariadb"
default_datastore_versions: mariadb:11.4
- job:
name: trove-tempest-ubuntu-ssl-replication-mariadb
parent: trove-tempest-ubuntu-ssl-replication
files: !inherit
- ^trove/guestagent/.*mariadb.*\.py$
vars:
devstack_localrc:
TROVE_DATASTORE_TYPE: mariadb
TROVE_DATASTORE_VERSION: 11.4
devstack_local_conf:
test-config:
$TEMPEST_CONFIG:
database:
backup_wait_timeout: 1200
enabled_datastores: "mariadb"
default_datastore_versions: mariadb:11.4
+34
View File
@@ -115,3 +115,37 @@
database:
backup_wait_timeout: 1200
default_datastore_versions: mysql:8.4
- job:
name: trove-tempest-ubuntu-ssl-mysql
parent: trove-tempest-ubuntu-ssl-base
files: !inherit
- ^trove/guestagent/.*mysql.*\.py$
vars:
devstack_localrc:
TROVE_DATASTORE_TYPE: mysql
TROVE_DATASTORE_VERSION: 8.4
TROVE_STATE_CHANGE_WAIT_TIME: 900
devstack_local_conf:
test-config:
$TEMPEST_CONFIG:
database:
backup_wait_timeout: 1200
default_datastore_versions: mysql:8.4
- job:
name: trove-tempest-ubuntu-ssl-replication-mysql
parent: trove-tempest-ubuntu-ssl-replication
files: !inherit
- ^trove/guestagent/.*mysql.*\.py$
vars:
devstack_localrc:
TROVE_DATASTORE_TYPE: mysql
TROVE_DATASTORE_VERSION: 8.4
TROVE_STATE_CHANGE_WAIT_TIME: 900
devstack_local_conf:
test-config:
$TEMPEST_CONFIG:
database:
backup_wait_timeout: 1200
default_datastore_versions: mysql:8.4
+34
View File
@@ -115,3 +115,37 @@
backup_wait_timeout: 1200
enabled_datastores: "postgresql"
default_datastore_versions: postgresql:18
- job:
name: trove-tempest-ubuntu-ssl-postgresql
parent: trove-tempest-ubuntu-ssl-base
files: !inherit
- ^trove/guestagent/.*postgresql.*\.py$
vars:
devstack_localrc:
TROVE_DATASTORE_TYPE: postgresql
TROVE_DATASTORE_VERSION: 18
devstack_local_conf:
test-config:
$TEMPEST_CONFIG:
database:
backup_wait_timeout: 1200
enabled_datastores: "postgresql"
default_datastore_versions: postgresql:18
- job:
name: trove-tempest-ubuntu-ssl-replication-postgresql
parent: trove-tempest-ubuntu-ssl-replication
files: !inherit
- ^trove/guestagent/.*postgresql.*\.py$
vars:
devstack_localrc:
TROVE_DATASTORE_TYPE: postgresql
TROVE_DATASTORE_VERSION: 18
devstack_local_conf:
test-config:
$TEMPEST_CONFIG:
database:
backup_wait_timeout: 1200
enabled_datastores: "postgresql"
default_datastore_versions: postgresql:18
+10 -1
View File
@@ -20,18 +20,27 @@
voting: false
- trove-tempest-ubuntu-replication-mysql:
voting: false
- trove-tempest-cinder-storage-driver-mysql:
- trove-tempest-ubuntu-ssl-mysql
- trove-tempest-ubuntu-ssl-replication-mysql:
voting: false
- trove-tempest-cinder-storage-driver-mysql:
voting: false
- trove-tempest-ubuntu-base-mariadb
- trove-tempest-ubuntu-backup-mariadb:
voting: false
- trove-tempest-ubuntu-replication-mariadb:
voting: false
- trove-tempest-ubuntu-ssl-mariadb
- trove-tempest-ubuntu-ssl-replication-mariadb:
voting: false
- trove-tempest-cinder-storage-driver-mariadb:
voting: false
- trove-tempest-ubuntu-base-postgresql
- trove-tempest-ubuntu-backup-postgresql:
voting: false
- trove-tempest-ubuntu-ssl-postgresql
- trove-tempest-ubuntu-ssl-replication-postgresql:
voting: false
- trove-tempest-ubuntu-replication-postgresql:
voting: false
- trove-tempest-cinder-storage-driver-postgresql: