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

Accept proxy environment variables from juju model config #33

Merged
merged 10 commits into from
Aug 1, 2023
2 changes: 1 addition & 1 deletion .github/workflows/integration_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ jobs:
uses: canonical/operator-workflows/.github/workflows/integration_test.yaml@main
secrets: inherit
with:
modules: '["test_charm", "test_default", "test_wsgi_path"]'
modules: '["test_charm", "test_proxy", "test_cos", "test_database", "test_default", "test_wsgi_path"]'
34 changes: 34 additions & 0 deletions src/charm_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@

import datetime
import itertools
import os
import pathlib
import typing

# pydantic is causing this no-name-in-module problem
from pydantic import ( # pylint: disable=no-name-in-module
AnyHttpUrl,
BaseModel,
Extra,
Field,
ValidationError,
parse_obj_as,
validator,
)

Expand Down Expand Up @@ -79,6 +82,20 @@ def to_upper(cls, value: str) -> str:
return value.upper()


class ProxyConfig(BaseModel): # pylint: disable=too-few-public-methods
"""Configuration for accessing Jenkins through proxy.

Attributes:
http_proxy: The http proxy URL.
https_proxy: The https proxy URL.
no_proxy: Comma separated list of hostnames to bypass proxy.
"""

http_proxy: typing.Optional[AnyHttpUrl]
https_proxy: typing.Optional[AnyHttpUrl]
no_proxy: typing.Optional[str]


# too-many-instance-attributes is okay since we use a factory function to construct the CharmState
class CharmState: # pylint: disable=too-many-instance-attributes
"""Represents the state of the Flask charm.
Expand All @@ -95,6 +112,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
flask_error_log: the file path for the Flask error log.
flask_statsd_host: the statsd server host for Flask metrics.
flask_secret_key: the charm managed flask secret key.
proxy: proxy information.
"""

def __init__(
Expand Down Expand Up @@ -136,6 +154,22 @@ def __init__(
self._flask_config = flask_config if flask_config is not None else {}
self._app_config = app_config if app_config is not None else {}

@property
def proxy(self) -> "ProxyConfig":
"""Get charm proxy information from juju charm environment.

Returns:
charm proxy information in the form of `ProxyConfig`.
"""
http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY")
https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY")
no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY")
return ProxyConfig(
http_proxy=parse_obj_as(AnyHttpUrl, http_proxy) if http_proxy else None,
https_proxy=parse_obj_as(AnyHttpUrl, https_proxy) if https_proxy else None,
no_proxy=no_proxy,
)

@classmethod
def from_charm(cls, charm: "FlaskCharm", secret_storage: SecretStorage) -> "CharmState":
"""Initialize a new instance of the CharmState class from the associated charm.
Expand Down
7 changes: 3 additions & 4 deletions src/databases.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
FLASK_SERVICE_NAME,
FLASK_SUPPORTED_DB_INTERFACES,
)
from exceptions import InvalidDatabaseRelationDataError, PebbleNotReadyError
from exceptions import InvalidDatabaseRelationDataError

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -62,9 +62,8 @@ def _on_database_requires_event(self, event: DatabaseRequiresEvent) -> None:
Args:
event: the database-requires-changed event that trigger this callback function.
"""
try:
container = self._charm.unit.get_container(FLASK_CONTAINER_NAME)
except PebbleNotReadyError:
container = self._charm.unit.get_container(FLASK_CONTAINER_NAME)
if not container.can_connect():
logger.info(
"pebble client in the Flask container is not ready, defer database-requires-event"
)
Expand Down
5 changes: 5 additions & 0 deletions src/flask_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,9 @@ def flask_environment(self) -> dict[str, str]:
secret_key_env = f"{FLASK_ENV_CONFIG_PREFIX}SECRET_KEY"
if secret_key_env not in env:
env[secret_key_env] = self._charm_state.flask_secret_key
for proxy_variable in ("http_proxy", "https_proxy", "no_proxy"):
weiiwang01 marked this conversation as resolved.
Show resolved Hide resolved
proxy_value = getattr(self._charm_state.proxy, proxy_variable)
if proxy_value:
env[proxy_variable] = str(proxy_value)
env[proxy_variable.upper()] = str(proxy_value)
return env
7 changes: 6 additions & 1 deletion test_rock/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,16 @@ def postgresql_status():
return "FAIL"


@app.route("/env")
def get_env():
"""Return environment variables"""
return jsonify(dict(os.environ))


app2 = Flask("app2")
app2.config.from_prefixed_env()


@app2.route("/")
def app2_hello_world():
return "Hello, Many World!"

39 changes: 28 additions & 11 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import asyncio
import io
import json
import pathlib
import zipfile

import pytest
Expand All @@ -17,6 +18,24 @@
from pytest_operator.plugin import OpsTest


@pytest.fixture(scope="module", name="flask_app_image")
def fixture_flask_app_image(pytestconfig: Config):
"""Return the --flask-app-image test parameter."""
flask_app_image = pytestconfig.getoption("--flask-app-image")
if not flask_app_image:
raise ValueError("the following arguments are required: --flask-app-image")
return flask_app_image


@pytest.fixture(scope="module", name="test_flask_image")
def fixture_test_flask_image(pytestconfig: Config):
"""Return the --test-flask-image test parameter."""
test_flask_image = pytestconfig.getoption("--test-flask-image")
if not test_flask_image:
raise ValueError("the following arguments are required: --test-flask-image")
return test_flask_image


@pytest_asyncio.fixture(scope="module", name="model")
async def fixture_model(ops_test: OpsTest) -> Model:
"""Return the current testing juju model."""
Expand All @@ -32,7 +51,7 @@ def external_hostname_fixture() -> str:

@pytest.fixture(scope="module", name="traefik_app_name")
def traefik_app_name_fixture() -> str:
"""Return the name of the traefix application deployed for tests."""
"""Return the name of the traefik application deployed for tests."""
return "traefik-k8s"


Expand All @@ -54,11 +73,13 @@ def grafana_app_name_fixture() -> str:
return "grafana-k8s"


@pytest.fixture(scope="module", name="charm_file")
def charm_file_fixture(pytestconfig: pytest.Config):
@pytest_asyncio.fixture(scope="module", name="charm_file")
async def charm_file_fixture(pytestconfig: pytest.Config, ops_test: OpsTest) -> pathlib.Path:
"""Get the existing charm file."""
value = pytestconfig.getoption("--charm-file")
yield f"./{value}"
charm_file = pytestconfig.getoption("--charm-file")
if not charm_file:
charm_file = await ops_test.build_charm(".")
return pathlib.Path(charm_file).absolute()


@pytest_asyncio.fixture(scope="module", name="build_charm")
Expand Down Expand Up @@ -100,16 +121,12 @@ async def build_charm_fixture(charm_file: str) -> str:


@pytest_asyncio.fixture(scope="module", name="flask_app")
async def flask_app_fixture(
build_charm: str,
model: Model,
pytestconfig: Config,
):
async def flask_app_fixture(build_charm: str, model: Model, test_flask_image: str):
"""Build and deploy the flask charm."""
app_name = "flask-k8s"

resources = {
"flask-app-image": pytestconfig.getoption("--test-flask-image"),
"flask-app-image": test_flask_image,
"statsd-prometheus-exporter-image": "prom/statsd-exporter",
}
app = await model.deploy(
Expand Down
141 changes: 1 addition & 140 deletions tests/integration/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# See LICENSE file for licensing details.

"""Integration tests for Flask charm."""
import asyncio

import json
import logging
import typing
Expand Down Expand Up @@ -232,142 +232,3 @@ async def test_with_ingress(
)
assert response.status_code == 200
assert "Hello, World!" in response.text


@pytest.mark.parametrize(
"endpoint,db_name, db_channel, trust",
[
("mysql/status", "mysql-k8s", "8.0/stable", True),
("postgresql/status", "postgresql-k8s", "14/stable", True),
],
)
async def test_with_database(
flask_app: Application,
model: juju.model.Model,
get_unit_ips,
endpoint: str,
db_name: str,
db_channel: str,
trust: bool,
):
"""
arrange: build and deploy the flask charm.
act: deploy the database and relate it to the charm.
assert: requesting the charm should return a correct response
"""
db_app = await model.deploy(db_name, channel=db_channel, trust=trust)
# mypy doesn't see that ActiveStatus has a name
await model.wait_for_idle(status=ops.ActiveStatus.name) # type: ignore

await model.add_relation(flask_app.name, db_app.name)

# mypy doesn't see that ActiveStatus has a name
await model.wait_for_idle(status=ops.ActiveStatus.name) # type: ignore

for unit_ip in await get_unit_ips(flask_app.name):
response = requests.get(f"http://{unit_ip}:8000/{endpoint}", timeout=5)
assert response.status_code == 200
assert "SUCCESS" == response.text


async def test_prometheus_integration(
model: juju.model.Model,
prometheus_app_name: str,
flask_app: Application,
prometheus_app, # pylint: disable=unused-argument
get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]],
):
"""
arrange: after Flask charm has been deployed.
act: establish relations established with prometheus charm.
assert: prometheus metrics endpoint for prometheus is active and prometheus has active scrape
targets.
"""
await model.add_relation(prometheus_app_name, flask_app.name)
await model.wait_for_idle(apps=[flask_app.name, prometheus_app_name], status="active")

for unit_ip in await get_unit_ips(prometheus_app_name):
query_targets = requests.get(f"http://{unit_ip}:9090/api/v1/targets", timeout=10).json()
assert len(query_targets["data"]["activeTargets"])


async def test_loki_integration(
model: juju.model.Model,
loki_app_name: str,
flask_app: Application,
loki_app, # pylint: disable=unused-argument
get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]],
):
"""
arrange: after Flask charm has been deployed.
act: establish relations established with loki charm.
assert: loki joins relation successfully, logs are being output to container and to files for
loki to scrape.
"""
await model.add_relation(loki_app_name, flask_app.name)

await model.wait_for_idle(
apps=[flask_app.name, loki_app_name], status="active", idle_period=60
)
flask_ip = (await get_unit_ips(flask_app.name))[0]
# populate the access log
for _ in range(120):
requests.get(f"http://{flask_ip}:8000", timeout=10)
await asyncio.sleep(1)
loki_ip = (await get_unit_ips(loki_app_name))[0]
log_query = requests.get(
f"http://{loki_ip}:3100/loki/api/v1/query",
timeout=10,
params={"query": f'{{juju_application="{flask_app.name}"}}'},
).json()
assert len(log_query["data"]["result"])


async def test_grafana_integration(
model: juju.model.Model,
flask_app: Application,
prometheus_app_name: str,
loki_app_name: str,
grafana_app_name: str,
cos_apps, # pylint: disable=unused-argument
get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]],
):
"""
arrange: after Flask charm has been deployed.
act: establish relations established with grafana charm.
assert: grafana Flask dashboard can be found.
"""
await model.relate(
f"{prometheus_app_name}:grafana-source", f"{grafana_app_name}:grafana-source"
)
await model.relate(f"{loki_app_name}:grafana-source", f"{grafana_app_name}:grafana-source")
await model.relate(flask_app.name, grafana_app_name)

await model.wait_for_idle(
apps=[flask_app.name, prometheus_app_name, loki_app_name, grafana_app_name],
status="active",
idle_period=60,
)

action = await model.applications[grafana_app_name].units[0].run_action("get-admin-password")
await action.wait()
password = action.results["admin-password"]
grafana_ip = (await get_unit_ips(grafana_app_name))[0]
sess = requests.session()
sess.post(
f"http://{grafana_ip}:3000/login",
json={
"user": "admin",
"password": password,
},
).raise_for_status()
datasources = sess.get(f"http://{grafana_ip}:3000/api/datasources", timeout=10).json()
datasource_types = set(datasource["type"] for datasource in datasources)
assert "loki" in datasource_types
assert "prometheus" in datasource_types
dashboards = sess.get(
f"http://{grafana_ip}:3000/api/search",
timeout=10,
params={"query": "Flask Operator"},
).json()
assert len(dashboards)
Loading
Loading