Skip to content

Commit

Permalink
#14 fixture operational saas database (#27)
Browse files Browse the repository at this point in the history
* Added fixture waiting until SaaS database is running
* Added project short tag and user name to resources in Exasol Saas with max length
* replaced os.getlogin() by getpass.getuser()
* Added log messages for deleting the database
* Make pytest display log output of tests cases in CI build
* Added sleep before deleting the database
* Added log message for creating a database
* Added parameter region for create_database()
  • Loading branch information
ckunki authored May 8, 2024
1 parent 9ba088e commit 9067e7a
Show file tree
Hide file tree
Showing 10 changed files with 228 additions and 23 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ jobs:
SAAS_HOST: ${{ secrets.INTEGRATION_TEAM_SAAS_STAGING_HOST }}
SAAS_ACCOUNT_ID: ${{ secrets.INTEGRATION_TEAM_SAAS_STAGING_ACCOUNT_ID }}
SAAS_PAT: ${{ secrets.INTEGRATION_TEAM_SAAS_STAGING_PAT }}
run: poetry run nox -s coverage -- --
PYTEST_ADDOPTS: -o log_cli=true -o log_cli_level=INFO
run: |
export PROJECT_SHORT_TAG=$(poetry run nox -s get-project-short-tag)
poetry run nox -s coverage -- --
- name: Upload Artifacts
uses: actions/upload-artifact@v3
Expand Down
4 changes: 4 additions & 0 deletions doc/changes/changes_0.3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ This release adds integration tests for the most important calls to SaaS API.

* #21: Added integration test for operation "create database"
* #23: Added integration test for operation "add IP to whitelist"

## Feature

* #14: Added fixture waiting until SaaS database is running
* #25: Fixed transitive dependencies required by generated API client
4 changes: 2 additions & 2 deletions doc/developer_guide/developer_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ openapi-python-client reads the JSON specification of the SaaS API and generates
The easiest way is to make openapi-python-client create a dedicated file `pyproject.toml` and copy the transitive dependencies from there to SAPIPY's file `pyproject.toml`.

In order to create file `pyproject.toml`
* In file `noxfile.py` you need to replace mode `update` by `generate`
* Additionally in file `openapi_config.yml` you need to specify a non-existing top-level directory as `name` and a package that does not contain slashes, e.g.
* In file `noxfile.py`, function `generate_api` you need to replace mode `update` by `generate`.
* Additionally, in file `openapi_config.yml` you need to specify a non-existing top-level directory as `project_name_override` and a package that does not contain slashes, e.g.

```yaml
project_name_override: "generate"
Expand Down
27 changes: 27 additions & 0 deletions exasol/saas/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,31 @@
Package openapi contains the API generated from the JSON definition.
"""

from dataclasses import dataclass
from typing import Final
from datetime import datetime, timedelta
from exasol.saas.client.openapi.models.status import Status


SAAS_HOST = "https://cloud.exasol.com"

PROMISING_STATES = [
Status.CREATING,
Status.RUNNING,
Status.STARTING,
Status.TOCREATE,
Status.TOSTART,
]


class Limits:
"""
Constants for Exasol SaaS databases.
"""
MAX_DATABASE_NAME_LENGTH: Final[int] = 20
MAX_CLUSTER_NAME_LENGTH: Final[int] = 40
AUTOSTOP_MIN_IDLE_TIME: Final[timedelta] = timedelta(minutes=15)
AUTOSTOP_MAX_IDLE_TIME: Final[timedelta] = timedelta(minutes=10000)
AUTOSTOP_DEFAULT_IDLE_TIME: Final[timedelta] = timedelta(minutes=120)
# If deleting a database too early, then logging and accounting could be invalid.
MIN_DATABASE_LIFETIME: Final[timedelta] = timedelta(seconds=30)
19 changes: 19 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import os
import nox

from pathlib import Path
from nox import Session
from noxconfig import PROJECT_CONFIG
from exasol.saas.client import SAAS_HOST
Expand Down Expand Up @@ -41,3 +43,20 @@ def check_api_outdated(session: Session):
"""
generate_api(session)
session.run("git", "diff", "--exit-code")


@nox.session(name="get-project-short-tag", python=False)
def get_project_short_tag(session: Session):
config_file = Path("error_code_config.yml")
content = config_file.read_text()
header = False
for line in content.splitlines():
line = line.strip()
if header:
print(line.strip().replace(":", ""))
return
if line.startswith("error-tags:"):
header = True
raise RuntimeError(
f"Could not read project short tag from file {config_file}"
)
16 changes: 15 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ python = ">=3.8.0,<4.0"
requests = "^2.31.0"
types-requests = "^2.31.0.6"
ifaddr = "^0.2.0"
tenacity = "^8.2.3"
# generated by openapi-python-client
httpx = ">=0.20.0,<0.28.0"
attrs = ">=21.3.0"
Expand Down Expand Up @@ -56,6 +57,10 @@ source = [
"exasol",
]

omit = [
'*/exasol/saas/client/openapi/*',
]

[tool.coverage.report]
fail_under = 15

Expand Down
116 changes: 104 additions & 12 deletions test/integration/api_access.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,60 @@
import getpass
import logging
import time

from typing import Iterable
from contextlib import contextmanager
from datetime import datetime
from datetime import datetime, timedelta
from tenacity.wait import wait_fixed
from tenacity.stop import stop_after_delay

from exasol.saas.client import openapi
from exasol.saas.client import (
openapi,
Limits,
)
from exasol.saas.client.openapi.models.status import Status
from exasol.saas.client.openapi.api.databases import (
create_database,
delete_database,
list_databases,
get_database,
)
from exasol.saas.client.openapi.api.security import (
list_allowed_i_ps,
add_allowed_ip,
delete_allowed_ip,
)
from tenacity import retry, TryAgain


LOG = logging.getLogger(__name__)
LOG.setLevel(logging.INFO)


def timestamp_name(project_short_tag: str | None = None) -> str:
"""
project_short_tag: Abbreviation of your project
"""
timestamp = f'{datetime.now().timestamp():.0f}'
owner = getpass.getuser()
candidate = f"{timestamp}{project_short_tag or ''}-{owner}"
return candidate[:Limits.MAX_DATABASE_NAME_LENGTH]


def wait_for_delete_clearance(start: datetime.time):
lifetime = datetime.now() - start
if lifetime < Limits.MIN_DATABASE_LIFETIME:
wait = Limits.MIN_DATABASE_LIFETIME - lifetime
LOG.info(f"Waiting {int(wait.seconds)} seconds"
" before deleting the database.")
time.sleep(wait.seconds)


def timestamp() -> str:
return f'{datetime.now().timestamp():.0f}'
class DatabaseStartupFailure(Exception):
"""
If a SaaS database instance during startup reports a status other than
successful.
"""


def create_saas_client(
Expand All @@ -42,19 +80,32 @@ def __init__(self, client: openapi.Client, account_id: str):
self._client = client
self._account_id = account_id

def create_database(self, cluster_size: str = "XS") -> openapi.models.database.Database:
def create_database(
self,
name: str,
cluster_size: str = "XS",
region: str = "eu-central-1",
) -> openapi.models.database.Database:
def minutes(x: timedelta) -> int:
return x.seconds // 60

cluster_spec = openapi.models.CreateCluster(
name="my-cluster",
size=cluster_size,
auto_stop=openapi.models.AutoStop(
enabled=True,
idle_time=minutes(Limits.AUTOSTOP_MIN_IDLE_TIME),
),
)
LOG.info(f"Creating database {name}")
return create_database.sync(
self._account_id,
client=self._client,
body=openapi.models.CreateDatabase(
name=f"pytest-{timestamp()}",
name=name,
initial_cluster=cluster_spec,
provider="aws",
region='us-east-1',
region=region,
)
)

Expand All @@ -77,16 +128,57 @@ def list_database_ids(self) -> Iterable[str]:
@contextmanager
def database(
self,
name: str,
keep: bool = False,
ignore_delete_failure: bool = False,
):
db = None
start = datetime.now()
try:
db = self.create_database()
db = self.create_database(name)
yield db
wait_for_delete_clearance(start)
finally:
if not keep and db:
self.delete_database(db.id, ignore_delete_failure)
if db and not keep:
LOG.info(f"Deleting database {db.name}")
response = self.delete_database(db.id, ignore_delete_failure)
if response.status_code == 200:
LOG.info(f"Successfully deleted database {db.name}.")
else:
LOG.warning(f"Ignoring status code {response.status_code}.")
elif not db:
LOG.warning("Cannot delete db None")
else:
LOG.info(f"Keeping database {db.name} as keep = {keep}")

def get_database(self, database_id: str) -> openapi.models.database.Database:
return get_database.sync(
self._account_id,
database_id,
client=self._client,
)

def wait_until_running(
self,
database_id: str,
timeout: timedelta = timedelta(minutes=30),
interval: timedelta = timedelta(minutes=2),
) -> str:
success = [
Status.RUNNING,
]

@retry(wait=wait_fixed(interval), stop=stop_after_delay(timeout))
def poll_status():
db = self.get_database(database_id)
if db.status not in success:
print(f'status = {db.status}')
raise TryAgain
return db.status

if poll_status() not in success:
raise DatabaseStartupFailure()


def list_allowed_ip_ids(self) -> Iterable[openapi.models.allowed_ip.AllowedIP]:
ips = list_allowed_i_ps.sync(
Expand All @@ -103,7 +195,7 @@ def add_allowed_ip(self, cidr_ip: str = "0.0.0.0/0") -> openapi.models.allowed_i
* ::/0 = all ipv6
"""
rule = openapi.models.create_allowed_ip.CreateAllowedIP(
name=f"pytest-{timestamp()}",
name=timestamp_name(),
cidr_ip=cidr_ip,
)
return add_allowed_ip.sync(
Expand All @@ -129,5 +221,5 @@ def allowed_ip(
ip = self.add_allowed_ip(cidr_ip)
yield ip
finally:
if not keep and ip:
if ip and not keep:
self.delete_allowed_ip(ip.id, ignore_delete_failure)
28 changes: 25 additions & 3 deletions test/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import pytest
import os

from pathlib import Path
from exasol.saas.client import openapi
from api_access import create_saas_client, _OpenApiAccess
from api_access import (
create_saas_client,
_OpenApiAccess,
timestamp_name,
)

@pytest.fixture(scope="session")
def saas_host() -> str:
Expand All @@ -26,10 +31,27 @@ def api_access(saas_host, saas_pat, saas_account_id) -> _OpenApiAccess:


@pytest.fixture(scope="session")
def saas_database(api_access) -> openapi.models.database.Database:
def saas_database(api_access, database_name) -> openapi.models.database.Database:
"""
Note: The SaaS instance database returned by this fixture initially
will not be operational. The startup takes about 20 minutes.
"""
with api_access.database() as db:
with api_access.database(database_name) as db:
yield db


@pytest.fixture(scope="session")
def operational_saas_database_id(api_access, database_name) -> str:
with api_access.database(database_name) as db:
api_access.wait_until_running(db.id)
yield db


@pytest.fixture(scope="session")
def project_short_tag():
return os.environ.get("PROJECT_SHORT_TAG")


@pytest.fixture
def database_name(project_short_tag):
return timestamp_name(project_short_tag)
Loading

0 comments on commit 9067e7a

Please sign in to comment.