diff --git a/.gitignore b/.gitignore index 9df71ef52ead788553d90c771678432f2fc33db0..dfdc67da9e07f8516efdc4e2f5c130db382efcff 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ docs/vale/styles/* venv/ .venv/ +.DS_Store # macOS +Thumbs.db # Windows + config.json diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e2b511ec087450ebec6817b8f894a5225bdb155d..323c1f19acd847dffd33494f6466300e576f2558 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,7 +12,7 @@ run-tox-pipeline: stage: tox tags: - docker-executor - image: python:3.10 + image: python:3.11 # Change pip's cache directory to be inside the project directory since we can # only cache local items. diff --git a/Dockerfile b/Dockerfile index cfbea5eed21afa529766915f91e42991e4d80fe6..8b5f4742ef0870afbb4316193ff198f2184f701d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,31 @@ -FROM python:3.11 +FROM python:3.11-alpine ARG ARTIFACT_VERSION WORKDIR /app -RUN apt update && apt install -y gcc libc-dev libffi-dev curl vim && \ +RUN apk add --update --no-cache gcc libc-dev libffi-dev curl vim ansible bash openssh && \ addgroup -S appgroup && adduser -S appuser -G appgroup -h /app + +# Create ansible.cfg file and set custom paths for collections and roles +RUN mkdir -p /app/gap/collections /app/gap/roles /etc/ansible && \ + printf "[defaults]\ncollections_paths = /app/gap/collections\nroles_path = /app/gap/roles" > /etc/ansible/ansible.cfg + RUN pip install \ --pre \ --extra-index-url https://artifactory.software.geant.org/artifactory/api/pypi/geant-swd-pypi/simple \ --target /app \ - goat-lso==${ARTIFACT_VERSION} + goat-lso==${ARTIFACT_VERSION} && \ + ansible-galaxy collection install \ + community.general \ + juniper.device \ + junipernetworks.junos \ + geant.gap_ansible -p /app/gap/collections && \ + ansible-galaxy role install Juniper.junos -p /app/gap/roles RUN chown -R appuser:appgroup /app USER appuser EXPOSE 8000 +ENTRYPOINT [] CMD ["python", "-m", "uvicorn", "lso.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/config.json.example b/config.json.example index ef9197d0788b9a9576ae86f42866fffa99a7f712..64ddb818152b0fcb6bff2565084c66933b6621a4 100644 --- a/config.json.example +++ b/config.json.example @@ -1,3 +1,3 @@ { - "ansible_playbooks_root_dir": "/" + "ansible_playbooks_root_dir": "/app/gap/collections/ansible_collections/geant/gap_ansible/playbooks" } diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 9fc9409ce0d4f790740f5c356c405191ab7e24fc..0000000000000000000000000000000000000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM python:alpine3.17 - -LABEL version="1.0" -LABEL maintainer="Geant LSO Team <@geant.org>" - -RUN apk add --no-cache bash curl vim gcc libc-dev libffi-dev -# RUN pip install --pre --extra-index-url https://artifactory.software.geant.org/artifactory/api/pypi/geant-swd-pypi/simple goat-lso - -WORKDIR /opt/lso -COPY . . -RUN pip install -e . -RUN pip install httpx sphinx sphinx_rtd_theme vale ansible - -# Generate documentation -RUN ./build-docs.sh - -# Generate sample configuration file, and remove an existing one if present -RUN rm -f config.json >/dev/null 2>&1 -RUN ln -s config.json.example config.json - -# ENTRYPOINT ["sleep", "inf"] -# ENTRYPOINT ["SETTINGS_FILENAME=./config.json", "python", "-m", "lso.app"] -ENTRYPOINT ["./docker/app-run.sh"] diff --git a/docker/app-run.sh b/docker/app-run.sh deleted file mode 100755 index 6b3cd586e0455e9cb8f96d726dd3b6835a957e2b..0000000000000000000000000000000000000000 --- a/docker/app-run.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -SETTINGS_FILENAME=./config.json python -m lso.app diff --git a/docker/docker-start.sh b/docker/docker-start.sh deleted file mode 100755 index 7aba417c617cbb42b57083c693a7a55163266964..0000000000000000000000000000000000000000 --- a/docker/docker-start.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash - -goat_name="goat-lso" -goat_image="goat/lso" -goat_tag="1.0" - -if [[ $(docker image list | grep "${goat_image}" | grep -c "${goat_tag}") -eq 0 ]]; then - docker build -f docker/Dockerfile -t ${goat_image}:${goat_tag} . -fi -if [[ $(docker ps -a | grep -c "${goat_image}:${goat_tag}") -eq 0 ]]; then - docker run -d -p 44444:44444 --name ${goat_name} ${goat_image}:${goat_tag} >/dev/null 2>&1 -fi -if [[ "$( docker container inspect -f '{{.State.Status}}' ${goat_name} )" != "running" ]]; then - docker start ${goat_name} >/dev/null 2>&1 -fi - -sleep 1 - -# Check endpoints -curl -f http://localhost:44444/docs >/dev/null 2>&1 -if [[ $? -eq 0 ]]; then - echo "LSO is running. OpenAPI available at http://localhost:44444/docs" -else - echo "LSO is not running" -fi diff --git a/docker/docker-stop.sh b/docker/docker-stop.sh deleted file mode 100755 index 1cde4b07e467ade9ea2649ec041c08f0041deb37..0000000000000000000000000000000000000000 --- a/docker/docker-stop.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash - -docker stop goat-lso >/dev/null 2>&1 -docker rm goat-lso >/dev/null 2>&1 diff --git a/lso/config.py b/lso/config.py index 9819b9548f925639e2b24c8a027151a0ed72dc8c..edf5546fe2020a81c43d37bbffd68ce2580d71b1 100644 --- a/lso/config.py +++ b/lso/config.py @@ -10,7 +10,7 @@ import os from typing import TextIO import jsonschema -from pydantic import BaseModel, DirectoryPath +from pydantic import BaseModel CONFIG_SCHEMA = { "$schema": "http://json-schema.org/draft-07/schema#", @@ -25,7 +25,7 @@ DEFAULT_REQUEST_TIMEOUT = 10 class Config(BaseModel): """Simple Config class that only contains the path to the used Ansible playbooks.""" - ansible_playbooks_root_dir: DirectoryPath + ansible_playbooks_root_dir: str def load_from_file(file: TextIO) -> Config: diff --git a/lso/playbook.py b/lso/playbook.py index 19ff44e72402499ed111841aa921421e08d006bd..9650985340496fd7e166e8e9dea740f8b2ba6bbb 100644 --- a/lso/playbook.py +++ b/lso/playbook.py @@ -18,8 +18,6 @@ from lso.config import DEFAULT_REQUEST_TIMEOUT logger = logging.getLogger(__name__) -config_params = config.load() - # enum.StrEnum is only available in python 3.11 class PlaybookJobStatus(str, enum.Enum): @@ -50,6 +48,7 @@ class PlaybookLaunchResponse(BaseModel): def get_playbook_path(playbook_name: str) -> str: + config_params = config.load() return os.path.join(config_params.ansible_playbooks_root_dir, playbook_name) diff --git a/test/conftest.py b/test/conftest.py index 78112127ae71436ed6b44fae8a03c290af92c1bf..2e3586910cb413d536185eb6e0dde86a70186157 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,7 +1,8 @@ import json import os import tempfile -from typing import Any, Generator +from io import StringIO +from typing import Any, Callable, Generator import pytest from faker import Faker @@ -10,28 +11,42 @@ from fastapi.testclient import TestClient import lso +@pytest.fixture +def mocked_ansible_runner_run() -> Callable: + class Runner: + def __init__(self) -> None: + self.status = "success" + self.rc = 0 + self.stdout = StringIO("[{'step one': 'results'}, {'step two': 2}]") + + def run(*args: Any, **kwargs: Any) -> Runner: + return Runner() + + return run + + @pytest.fixture(scope="session") -def config_data() -> dict[str, str]: +def configuration_data() -> dict[str, str]: """Start the server with valid configuration data.""" - return {"ansible_playbooks_root_dir": "/"} + return {"ansible_playbooks_root_dir": "/app/gap/collections/ansible_collections/geant/gap_ansible/playbooks"} @pytest.fixture(scope="session") -def config_file(config_data: dict[str, str]) -> Generator[str, Any, None]: +def data_config_filename(configuration_data: dict[str, str]) -> Generator[str, Any, None]: """Fixture that will yield a filename that contains a valid configuration. :return: Path to valid configuration file """ with tempfile.NamedTemporaryFile(mode="w") as file: - file.write(json.dumps(config_data)) + file.write(json.dumps(configuration_data)) file.flush() yield file.name @pytest.fixture(scope="session") -def client(config_file: str) -> Generator[TestClient, Any, None]: +def client(data_config_filename: str) -> Generator[TestClient, Any, None]: """Return a client that can be used to test the server.""" - os.environ["SETTINGS_FILENAME"] = config_file + os.environ["SETTINGS_FILENAME"] = data_config_filename app = lso.create_app() yield TestClient(app) # wait here until calling context ends diff --git a/test/routes/__init__.py b/test/routes/__init__.py index b1490578a4f0cb8871454268461cb9c9b582878b..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 --- a/test/routes/__init__.py +++ b/test/routes/__init__.py @@ -1,15 +0,0 @@ -from io import StringIO -from typing import Any - -TEST_CALLBACK_URL = "https://fqdn.abc.xyz/api/resume" - - -class Runner: - def __init__(self) -> None: - self.status = "success" - self.rc = 0 - self.stdout = StringIO("[{'step one': 'results'}, {'step two': 2}]") - - -def test_ansible_runner_run(**kwargs: Any) -> Runner: - return Runner() diff --git a/test/routes/test_ip_trunk.py b/test/routes/test_ip_trunk.py index 99a8d9afd41b90a7479c2e7fd9521902fe25d7c4..21f74cae880a5aea2b40aedd381a4b4281094a83 100644 --- a/test/routes/test_ip_trunk.py +++ b/test/routes/test_ip_trunk.py @@ -1,4 +1,5 @@ import time +from typing import Callable from unittest.mock import patch import jsonschema @@ -8,7 +9,8 @@ from faker import Faker from starlette.testclient import TestClient from lso.playbook import PlaybookLaunchResponse -from test.routes import TEST_CALLBACK_URL, test_ansible_runner_run + +TEST_CALLBACK_URL = "https://fqdn.abc.xyz/api/resume" @pytest.fixture(scope="session") @@ -157,7 +159,9 @@ def migration_object(faker: Faker) -> dict: @responses.activate -def test_ip_trunk_provisioning(client: TestClient, subscription_object: dict) -> None: +def test_ip_trunk_provisioning( + client: TestClient, subscription_object: dict, mocked_ansible_runner_run: Callable +) -> None: responses.post(url=TEST_CALLBACK_URL, status=200) params = { @@ -170,7 +174,7 @@ def test_ip_trunk_provisioning(client: TestClient, subscription_object: dict) -> "subscription": subscription_object, } - with patch("lso.playbook.ansible_runner.run", new=test_ansible_runner_run) as _: + with patch("lso.playbook.ansible_runner.run", new=mocked_ansible_runner_run) as _: rv = client.post("/api/ip_trunk/", json=params) assert rv.status_code == 200 response = rv.json() @@ -184,7 +188,9 @@ def test_ip_trunk_provisioning(client: TestClient, subscription_object: dict) -> @responses.activate -def test_ip_trunk_modification(client: TestClient, subscription_object: dict) -> None: +def test_ip_trunk_modification( + client: TestClient, subscription_object: dict, mocked_ansible_runner_run: Callable +) -> None: responses.post(url=TEST_CALLBACK_URL, status=200) params = { @@ -197,7 +203,7 @@ def test_ip_trunk_modification(client: TestClient, subscription_object: dict) -> "old_subscription": subscription_object, } - with patch("lso.playbook.ansible_runner.run", new=test_ansible_runner_run) as _: + with patch("lso.playbook.ansible_runner.run", new=mocked_ansible_runner_run) as _: rv = client.put("/api/ip_trunk/", json=params) assert rv.status_code == 200 response = rv.json() @@ -211,7 +217,7 @@ def test_ip_trunk_modification(client: TestClient, subscription_object: dict) -> @responses.activate -def test_ip_trunk_deletion(client: TestClient, subscription_object: dict) -> None: +def test_ip_trunk_deletion(client: TestClient, subscription_object: dict, mocked_ansible_runner_run: Callable) -> None: responses.post(url=TEST_CALLBACK_URL, status=204) params = { @@ -223,7 +229,7 @@ def test_ip_trunk_deletion(client: TestClient, subscription_object: dict) -> Non "subscription": subscription_object, } - with patch("lso.playbook.ansible_runner.run", new=test_ansible_runner_run) as _: + with patch("lso.playbook.ansible_runner.run", new=mocked_ansible_runner_run) as _: rv = client.request(url="/api/ip_trunk/", method=responses.DELETE, json=params) assert rv.status_code == 200 response = rv.json() @@ -237,7 +243,9 @@ def test_ip_trunk_deletion(client: TestClient, subscription_object: dict) -> Non @responses.activate -def test_ip_trunk_migration(client: TestClient, subscription_object: dict, migration_object: dict) -> None: +def test_ip_trunk_migration( + client: TestClient, subscription_object: dict, migration_object: dict, mocked_ansible_runner_run: Callable +) -> None: responses.post(url=TEST_CALLBACK_URL, status=204) params = { @@ -251,7 +259,7 @@ def test_ip_trunk_migration(client: TestClient, subscription_object: dict, migra "new_side": migration_object, } - with patch("lso.playbook.ansible_runner.run", new=test_ansible_runner_run) as _: + with patch("lso.playbook.ansible_runner.run", new=mocked_ansible_runner_run) as _: rv = client.post(url="/api/ip_trunk/migrate", json=params) assert rv.status_code == 200 response = rv.json() diff --git a/test/routes/test_router.py b/test/routes/test_router.py index 1b8a7bde2b279749c24a20d5810f506b03a74056..6a9f2fffa21e847a1e3294f1658ce312a6fe3a10 100644 --- a/test/routes/test_router.py +++ b/test/routes/test_router.py @@ -1,4 +1,5 @@ import time +from typing import Callable from unittest.mock import patch import jsonschema @@ -7,11 +8,12 @@ from faker import Faker from starlette.testclient import TestClient from lso.playbook import PlaybookLaunchResponse -from test.routes import TEST_CALLBACK_URL, test_ansible_runner_run + +TEST_CALLBACK_URL = "https://fqdn.abc.xyz/api/resume" @responses.activate -def test_router_provisioning(client: TestClient, faker: Faker) -> None: +def test_router_provisioning(client: TestClient, faker: Faker, mocked_ansible_runner_run: Callable) -> None: responses.put(url=TEST_CALLBACK_URL, status=200) params = { @@ -40,7 +42,7 @@ def test_router_provisioning(client: TestClient, faker: Faker) -> None: }, } - with patch("lso.playbook.ansible_runner.run", new=test_ansible_runner_run) as _: + with patch("lso.playbook.ansible_runner.run", new=mocked_ansible_runner_run) as _: rv = client.post("/api/router/", json=params) assert rv.status_code == 200 response = rv.json() diff --git a/test/test_config.py b/test/test_config.py index 83a47d172bc486495a85900a56a25c73941020e7..bb724c7f74638f079491d895675674676454b6e2 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -9,12 +9,12 @@ import pytest from lso import config -def test_validate_testenv_config(config_file: str) -> None: +def test_validate_testenv_config(data_config_filename: str) -> None: """Load a configuration from a file. - :param config_file: Configuration file pytest fixture + :param data_config_filename: Configuration file pytest fixture """ - os.environ["SETTINGS_FILENAME"] = config_file + os.environ["SETTINGS_FILENAME"] = data_config_filename params = config.load() assert params diff --git a/tox.ini b/tox.ini index e8715b0ffb439b3ec55e7e9eb56f087b46e21aad..927b8974a48b33a3031b4b28dce6c342837cf1eb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py310 +envlist = py311 [flake8] ignore = W503 @@ -15,19 +15,19 @@ per-file-ignores = [testenv] passenv = XDG_CACHE_HOME,USE_COMPOSE setenv = - SETTINGS_FILENAME = config.json.example + SETTINGS_FILENAME = dummy.json deps = coverage -r requirements.txt commands = - coverage erase - coverage run --source lso -m pytest - coverage xml - coverage html - coverage report --fail-under 80 isort -c . ruff . black --check . mypy . flake8 + coverage erase + coverage run --source lso -m pytest + coverage xml + coverage html + coverage report --fail-under 80