Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2005c86
Added fixture waiting unti SaaS database is running
ckunki Apr 30, 2024
4e51522
fixed typo in changes file
ckunki Apr 30, 2024
6c274ef
Merge branch 'main' into feature/#14-fixture-operational_saas_databas…
ckunki Apr 30, 2024
7e1cb91
Fixed merge errors
ckunki Apr 30, 2024
de6a01f
Fixed review findings
ckunki May 6, 2024
dad0f11
Fixed review findings
ckunki May 6, 2024
ebef57c
Added user name to resources in Exasol Saas
ckunki May 6, 2024
97f44cc
fixed method call
ckunki May 6, 2024
8a400e4
fixed _timestamp_name()
ckunki May 6, 2024
0985a0d
replaced os.getlogin() by getpass.getuser()
ckunki May 6, 2024
bb93dda
shortened database name
ckunki May 6, 2024
6b328bf
Added log messages for deleting the database
ckunki May 6, 2024
b21c96a
Make pytest display log output of tests cases in CI build
ckunki May 6, 2024
2da0580
Added sleep before deleting the database
ckunki May 6, 2024
66026b0
Added log message for creating a database
ckunki May 6, 2024
26cf321
Excluded generated code from coverage
ckunki May 7, 2024
d822647
Fixed first batch of review findings
ckunki May 7, 2024
2b2b4a3
Refactored extracting minutes from timedelta for SaaS API
ckunki May 7, 2024
aace19e
Added parameter region for create_database()
ckunki May 7, 2024
5af0e1d
Moved limits into dedicated class
ckunki May 7, 2024
bd0fcc9
Use project short tag for saas resource
ckunki May 7, 2024
2534a4a
Renamed database limits to meet linter requirements
ckunki May 7, 2024
381e404
Fixed type check
ckunki May 7, 2024
fb30fa7
fixed import
ckunki May 7, 2024
db989b6
Made parameter project short tag for timestamp_name optional
ckunki May 7, 2024
3e23db0
Fixed review finding
ckunki May 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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