Skip to content
This repository has been archived by the owner on Jul 26, 2024. It is now read-only.

Commit

Permalink
test: add Cloud Function based geo smoke-tests (#364)
Browse files Browse the repository at this point in the history
* test: add smoke-tests directory

* f: add Cloud Function for clients

* f: add Cloud Function for runner

* f: add integration tests for Cloud Functions

* f: address code review comments
  • Loading branch information
hackebrot authored Mar 1, 2022
1 parent a5edfe3 commit 1120399
Show file tree
Hide file tree
Showing 9 changed files with 443 additions and 1 deletion.
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ Dockerfile
PULL_REQUEST_TEMPLATE.md
README.md
target

smoke-tests/
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@ tools/test/venv
.DS_Store
.Python
Python 3.9
venv
venv
__pycache__/
*.py[cod]
.python-version
5 changes: 5 additions & 0 deletions smoke-tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# smoke-tests

This directory contains code for a smoke-test suite for Contile deployments.

**Please note that this is work in progress.** 🚧
119 changes: 119 additions & 0 deletions smoke-tests/client/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Dict, Optional, Union

import requests
from flask import Request, Response, abort, jsonify

# See https://github.com/mozilla-services/contile/blob/main/src/web/dockerflow.rs
LOCATION_ENDPOINT: str = "__loc_test__"


@dataclass
class LocResponseData:
"""Location data returned by Contile."""

country: str
ip: str
provider: str
region: Optional[str]


# See https://github.com/mozilla-services/contile/blob/main/docs/API.md
class Environments(Enum):
"""Enum with accepted Contile environments."""

DEV: str = "https://contile-dev.topsites.nonprod.cloudops.mozgcp.net/"
STAGE: str = "https://contile-stage.topsites.nonprod.cloudops.mozgcp.net/"
PROD: str = "https://contile.services.mozilla.com/"


@dataclass
class RequestData:
"""Data in the HTTP request to the HTTP Cloud Function."""

environment: str
expected_country: str
expected_region: str


# Type alias for location data in errors
LocationData = Dict[str, Optional[str]]


@dataclass
class Error:
"""Information about an error that occured."""

url: str
message: str
want: Union[LocationData, int]
got: Union[LocationData, int]
extra: Dict = field(default_factory=dict)


@dataclass
class ResponseData:
"""Data in the HTTP response."""

error: Optional[Error] = None


def run_geo_smoke_test(request: Request):
"""Triggered by HTTP Cloud Function."""

if request.method != "POST":
return abort(Response("Only HTTP POST requests are allowed", status=405))

try:
request_data = RequestData(**request.get_json())
except TypeError:
return abort(Response("Invalid request data", status=400))

try:
env = Environments[request_data.environment]
except KeyError:
return abort(Response("Invalid environment parameter", status=400))

location_url = f"{env.value}{LOCATION_ENDPOINT}"

loc_response = requests.get(location_url)

if loc_response.status_code != 200:
error = Error(
url=location_url,
message="Unexpected status code",
want=200,
got=loc_response.status_code,
)
return jsonify(asdict(ResponseData(error=error)))

loc_response_data = LocResponseData(**loc_response.json())

want: LocationData = {
"country": request_data.expected_country,
"region": request_data.expected_region,
"provider": "maxmind",
}

got: LocationData = {
"country": loc_response_data.country,
"region": loc_response_data.region,
"provider": loc_response_data.provider,
}

if got != want:
error = Error(
url=location_url,
message="Unexpected geolocation information",
want=want,
got=got,
extra={"ip": loc_response_data.ip},
)
return jsonify(asdict(ResponseData(error=error)))

return jsonify(asdict(ResponseData(error=None)))
16 changes: 16 additions & 0 deletions smoke-tests/client/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# See https://cloud.google.com/functions/docs/writing/specifying-dependencies-python#python39
click==7.1.2
cloudevents==1.2.0
deprecation==2.1.0
Flask==1.1.2
functions-framework==2.2.1
gunicorn==20.0.4
itsdangerous==1.1.0
Jinja2==2.11.2
MarkupSafe==1.1.1
pathtools==0.1.2
watchdog==1.0.2
Werkzeug==1.0.1

# Custom packages
requests==2.26.0
102 changes: 102 additions & 0 deletions smoke-tests/runner/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

import os
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any, Dict, List

import requests
from flask import Request, Response, abort, jsonify


@dataclass
class Client:
"""Geo information about a client."""

country: str
region: str
gcp_region: str


# TODO: Update the following enum based on the regions where you deploy clients
# See https://cloud.google.com/functions/docs/locations
class Clients(Enum):
"""Enum with clients deployed as Cloud Functions."""

US: Client = Client(country="US", region="OR", gcp_region="us-west1")
GB: Client = Client(country="GB", region="LND", gcp_region="europe-west2")
CH: Client = Client(country="CH", region="ZH", gcp_region="europe-west6")


class Environments(Enum):
"""Enum with accepted Contile environments."""

DEV: str = "DEV"
STAGE: str = "STAGE"
PROD: str = "PROD"


@dataclass
class ClientResponse:
"""Information about a response from a client function."""

status_code: int
content: Any


@dataclass
class RequestData:
"""Data in the HTTP request to the HTTP Cloud Function."""

environments: List[str] = field(default_factory=list)


@dataclass
class ResponseData:
"""Data in the HTTP response."""

results: Dict = field(default_factory=dict)


def run_geo_smoke_tests(request: Request):
"""Triggered by HTTP Cloud Function."""

if request.method != "POST":
return abort(Response("Only HTTP POST requests are allowed", status=405))

try:
request_data = RequestData(**request.get_json())
except TypeError:
return abort(Response("Invalid request data", status=400))

try:
environments: List[Environments] = [
Environments[env_name] for env_name in request_data.environments
]
except KeyError:
return abort(Response("Invalid environment parameter", status=400))

if not environments:
return abort(Response("Require list of environments", status=400))

response_data = ResponseData()

for env in environments:
response_data.results[env.name] = {}
for client in Clients:
url = os.environ[f"CLIENT_URL_{client.name}"]
response = requests.post(
url,
json={
"environment": env.value,
"expected_country": client.value.country,
"expected_region": client.value.region,
},
)
response_data.results[env.name][client.name] = ClientResponse(
status_code=response.status_code, content=response.text
)

return jsonify(asdict(response_data))
16 changes: 16 additions & 0 deletions smoke-tests/runner/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# See https://cloud.google.com/functions/docs/writing/specifying-dependencies-python#python39
click==7.1.2
cloudevents==1.2.0
deprecation==2.1.0
Flask==1.1.2
functions-framework==2.2.1
gunicorn==20.0.4
itsdangerous==1.1.0
Jinja2==2.11.2
MarkupSafe==1.1.1
pathtools==0.1.2
watchdog==1.0.2
Werkzeug==1.0.1

# Custom packages
requests==2.26.0
1 change: 1 addition & 0 deletions smoke-tests/tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pytest==7.0.1
Loading

0 comments on commit 1120399

Please sign in to comment.