google/cloud/sql/connector/instance.py (128 lines of code) (raw):
"""
Copyright 2019 Google LLC
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
https://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 __future__ import annotations
import asyncio
from datetime import datetime
from datetime import timedelta
from datetime import timezone
import logging
from google.cloud.sql.connector.client import CloudSQLClient
from google.cloud.sql.connector.connection_info import ConnectionInfo
from google.cloud.sql.connector.connection_info import ConnectionInfoCache
from google.cloud.sql.connector.connection_name import ConnectionName
from google.cloud.sql.connector.exceptions import RefreshNotValidError
from google.cloud.sql.connector.rate_limiter import AsyncRateLimiter
from google.cloud.sql.connector.refresh_utils import _is_valid
from google.cloud.sql.connector.refresh_utils import _seconds_until_refresh
logger = logging.getLogger(name=__name__)
APPLICATION_NAME = "cloud-sql-python-connector"
class RefreshAheadCache(ConnectionInfoCache):
"""Cache that refreshes connection info in the background prior to expiration.
Background tasks are used to schedule refresh attempts to get a new
ephemeral certificate and Cloud SQL metadata (IP addresses, etc.) ahead of
expiration.
"""
def __init__(
self,
conn_name: ConnectionName,
client: CloudSQLClient,
keys: asyncio.Future,
enable_iam_auth: bool = False,
) -> None:
"""Initializes a RefreshAheadCache instance.
Args:
conn_name (ConnectionName): The Cloud SQL instance's
connection name.
client (CloudSQLClient): The Cloud SQL Client instance.
keys (asyncio.Future): A future to the client's public-private key
pair.
enable_iam_auth (bool): Enables automatic IAM database authentication
(Postgres and MySQL) as the default authentication method for all
connections.
"""
self._conn_name = conn_name
self._enable_iam_auth = enable_iam_auth
self._keys = keys
self._client = client
self._refresh_rate_limiter = AsyncRateLimiter(
max_capacity=2,
rate=1 / 30,
)
self._refresh_in_progress = asyncio.locks.Event()
self._current: asyncio.Task = self._schedule_refresh(0)
self._next: asyncio.Task = self._current
self._closed = False
@property
def conn_name(self) -> ConnectionName:
return self._conn_name
@property
def closed(self) -> bool:
return self._closed
async def force_refresh(self) -> None:
"""
Forces a new refresh attempt immediately to be used for future connection attempts.
"""
# if next refresh is not already in progress, cancel it and schedule new one immediately
if not self._refresh_in_progress.is_set():
self._next.cancel()
self._next = self._schedule_refresh(0)
# block all sequential connection attempts on the next refresh result if current is invalid
if not await _is_valid(self._current):
self._current = self._next
async def _perform_refresh(self) -> ConnectionInfo:
"""Retrieves instance metadata and ephemeral certificate from the
Cloud SQL Instance.
Returns:
A ConnectionInfo instance containing a string representing the
ephemeral certificate, a dict containing the instances IP adresses,
a string representing a PEM-encoded private key and a string
representing a PEM-encoded certificate authority.
"""
self._refresh_in_progress.set()
logger.debug(
f"['{self._conn_name}']: Connection info refresh operation started"
)
try:
await self._refresh_rate_limiter.acquire()
connection_info = await self._client.get_connection_info(
self._conn_name,
self._keys,
self._enable_iam_auth,
)
logger.debug(
f"['{self._conn_name}']: Connection info refresh operation complete"
)
logger.debug(
f"['{self._conn_name}']: Current certificate "
f"expiration = {connection_info.expiration.isoformat()}"
)
except Exception as e:
logger.debug(
f"['{self._conn_name}']: Connection info "
f"refresh operation failed: {str(e)}"
)
raise
finally:
self._refresh_in_progress.clear()
return connection_info
def _schedule_refresh(self, delay: int) -> asyncio.Task:
"""
Schedule task to sleep and then perform refresh to get ConnectionInfo.
Args:
delay (int): Time in seconds to sleep before performing a refresh.
Returns:
An asyncio.Task representing the scheduled refresh.
"""
async def _refresh_task(self: RefreshAheadCache, delay: int) -> ConnectionInfo:
"""
A coroutine that sleeps for the specified amount of time before
running _perform_refresh.
"""
refresh_task: asyncio.Task
try:
if delay > 0:
await asyncio.sleep(delay)
refresh_task = asyncio.create_task(self._perform_refresh())
refresh_data = await refresh_task
# check that refresh is valid
if not await _is_valid(refresh_task):
raise RefreshNotValidError(
f"['{self._conn_name}']: Invalid refresh operation. Certficate appears to be expired."
)
except asyncio.CancelledError:
logger.debug(
f"['{self._conn_name}']: Scheduled refresh" " operation cancelled"
)
raise
# bad refresh attempt
except Exception as e:
logger.exception(
f"['{self._conn_name}']: "
"An error occurred while performing refresh. "
"Scheduling another refresh attempt immediately",
exc_info=e,
)
# check if current metadata is invalid (expired),
# don't want to replace valid metadata with invalid refresh
if not await _is_valid(self._current):
self._current = refresh_task
# schedule new refresh attempt immediately
self._next = self._schedule_refresh(0)
raise
# if valid refresh, replace current with valid metadata and schedule next refresh
self._current = refresh_task
# calculate refresh delay based on certificate expiration
delay = _seconds_until_refresh(refresh_data.expiration)
logger.debug(
f"['{self._conn_name}']: Connection info refresh"
" operation scheduled for "
f"{(datetime.now(timezone.utc) + timedelta(seconds=delay)).isoformat(timespec='seconds')} "
f"(now + {timedelta(seconds=delay)})"
)
self._next = self._schedule_refresh(delay)
return refresh_data
# schedule refresh task and return it
scheduled_task = asyncio.create_task(_refresh_task(self, delay))
return scheduled_task
async def connect_info(self) -> ConnectionInfo:
"""Retrieves ConnectionInfo instance for establishing a secure
connection to the Cloud SQL instance.
"""
return await self._current
async def close(self) -> None:
"""Cleanup function to make sure tasks have finished to have a
graceful exit.
"""
logger.debug(
f"['{self._conn_name}']: Canceling connection info "
"refresh operation tasks"
)
self._current.cancel()
self._next.cancel()
# gracefully wait for tasks to cancel
tasks = asyncio.gather(self._current, self._next, return_exceptions=True)
await asyncio.wait_for(tasks, timeout=2.0)
self._closed = True