diff --git a/src/imgtests/database/database.py b/src/imgtests/database/database.py index 4e878c7ff..874b741eb 100644 --- a/src/imgtests/database/database.py +++ b/src/imgtests/database/database.py @@ -10,6 +10,7 @@ from imgtests.database.models.base import Base from imgtests.database.models.configuration import ConfigurationBase +from imgtests.database.models.distribution_connection import DistributionConnection from imgtests.database.models.experiment import ExperimentBase, ExperimentType from imgtests.database.models.util_run_result import UtilRunResult, UtilType @@ -20,7 +21,7 @@ from imgtests.types import TestsCounts logger = logging.getLogger(__name__) -Table = Literal["configurations", "experiments", "util_run_result"] +Table = Literal["configurations", "experiments", "util_run_result", "distribution_connections"] class PostgresCreds(BaseSettings): @@ -208,11 +209,12 @@ def return_table(self, table_name: Table) -> list[Any]: with self.session() as session: models: dict[ Table, - type[ConfigurationBase | ExperimentBase | UtilRunResult], + type[ConfigurationBase | ExperimentBase | UtilRunResult | DistributionConnection], ] = { "configurations": ConfigurationBase, "experiments": ExperimentBase, "util_run_result": UtilRunResult, + "distribution_connections": DistributionConnection, } if table_name not in models: logger.error("Table '%s' doesn't exist.", table_name) @@ -250,6 +252,76 @@ def get_experiment_with_details(self, experiment_id: int) -> ExperimentBase: .one() ) + def get_connection(self, name: str) -> DistributionConnection | None: + if not name: + return None + + self._check_session() + with self.session() as session: + return ( + session.query(DistributionConnection) + .filter( + DistributionConnection.name == name, + DistributionConnection.is_active.is_(True), + ) + .one_or_none() + ) + + def list_connections(self) -> list[DistributionConnection]: + self._check_session() + with self.session() as session: + return session.query(DistributionConnection).order_by(DistributionConnection.name).all() + + def upsert_connection( # noqa: PLR0913 + self, + name: str, + host: str, + user: str, + password: str, + port: int, + is_active: bool = True, + ) -> DistributionConnection: + self._check_session() + with self.session() as session: + connection = ( + session.query(DistributionConnection) + .filter(DistributionConnection.name == name) + .one_or_none() + ) + if connection is None: + connection = DistributionConnection( + name=name, + host=host, + user=user, + password=password, + port=port, + is_active=is_active, + ) + session.add(connection) + else: + connection.host = host + connection.user = user + connection.password = password + connection.port = port + connection.is_active = is_active + session.commit() + session.refresh(connection) + return connection + + def delete_connection(self, name: str) -> bool: + self._check_session() + with self.session() as session: + connection = ( + session.query(DistributionConnection) + .filter(DistributionConnection.name == name) + .one_or_none() + ) + if connection is None: + return False + session.delete(connection) + session.commit() + return True + def _check_session(self) -> None: if not hasattr(self, "session") or self.session is None: error_message = "Database session not initialized." diff --git a/src/imgtests/database/models/distribution_connection.py b/src/imgtests/database/models/distribution_connection.py new file mode 100644 index 000000000..2ddb33b9a --- /dev/null +++ b/src/imgtests/database/models/distribution_connection.py @@ -0,0 +1,23 @@ +from sqlalchemy import Boolean, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from imgtests.database.models.base import Base + + +class DistributionConnection(Base): + __tablename__ = "distribution_connection" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100), unique=True) + host: Mapped[str] = mapped_column(String(100)) + user: Mapped[str] = mapped_column(String(100)) + password: Mapped[str] = mapped_column(String(100)) + port: Mapped[int] = mapped_column(Integer) + is_active: Mapped[bool] = mapped_column(Boolean, server_default="true") + + def __repr__(self) -> str: + return ( + f"DistributionConnection(name={self.name}, " + f"host={self.host}, user={self.user}, " + f"port={self.port}, is_active={self.is_active})" + ) diff --git a/src/imgtests/exec/exec.py b/src/imgtests/exec/exec.py index 443b78729..31ed7d235 100644 --- a/src/imgtests/exec/exec.py +++ b/src/imgtests/exec/exec.py @@ -269,16 +269,16 @@ def download(self, remotepath: Path, localpath: Path) -> ExecResult: def wait_remote( - address_env: str, - user_env: str, - password_env: str, - port_env: str, + hostname: str, + username: str, + password: str, + port: int, ) -> SSHClient | None: wait_sec = 60 * 60 * 5 step_sec = 60 while wait_sec > 0: try: - return SSHClient.build_from_env(address_env, user_env, password_env, port_env) + return SSHClient(hostname, username, password, port) except paramiko.ssh_exception.SSHException: logger.info("Waiting remote node to build and run image.") sleep(step_sec) diff --git a/src/imgtests/runner.py b/src/imgtests/runner.py index 2f3e55ba8..6315cdcbb 100644 --- a/src/imgtests/runner.py +++ b/src/imgtests/runner.py @@ -7,7 +7,7 @@ from datetime import UTC, datetime from pathlib import Path from threading import Event, Thread -from typing import TYPE_CHECKING, Any, Final, Literal +from typing import TYPE_CHECKING, Any, Literal import paramiko import paramiko.ssh_exception @@ -42,24 +42,11 @@ if TYPE_CHECKING: from collections.abc import Iterable + from imgtests.database.models.distribution_connection import DistributionConnection from imgtests.exec.base_util import BaseTestUtil Runner = Literal["default", "profiled"] -Distro = Literal["yocto", "opensuse"] - -YOCTO_CONF: Final = ( - "SSH_YOCTO_ADDR", - "SSH_YOCTO_USER", - "SSH_YOCTO_PASS", - "SSH_YOCTO_PORT", -) -SUSE_156_CONF: Final = ( - "SSH_SUSE_ADDR_156", - "SSH_SUSE_USER", - "SSH_SUSE_PASS", - "SSH_SUSE_PORT_156", -) logger = logging.getLogger() @@ -578,24 +565,22 @@ def _run_single(client: SSHClient, mode: Runner, config: dict[str, Any] | None) def run_tests( - distro: Distro, + distribution: DistributionConnection, mode: Runner = "default", test_runs_count: int = 1, config: dict[str, Any] | None = None, ) -> None: - logger.info("Running tests for %s", distro) + logger.info("Running tests for %s", distribution.name) if mode == "default" and config is None: - config = load_test_config(distro) - client = None - match distro: - case "yocto": - client = wait_remote(*YOCTO_CONF) or sys.exit(1) - case "opensuse": - client = wait_remote(*SUSE_156_CONF) or sys.exit(1) - Touch(client, use_sudo=True)(["/etc/cloud/cloud-init.disabled"]) - case _: - logger.error("Unexpected distro '%s'.", distro) - sys.exit(1) + config = load_test_config(distribution.name) + client = wait_remote( + hostname=distribution.host, + username=distribution.user, + password=distribution.password, + port=distribution.port, + ) or sys.exit(1) + if distribution.name == "opensuse": + Touch(client, use_sudo=True)(["/etc/cloud/cloud-init.disabled"]) for i in range(test_runs_count): logger.info("Starting test run %d of %d", i + 1, test_runs_count) _run_single(client, mode, config) diff --git a/src/imgtests/web/static/js/dashboard.js b/src/imgtests/web/static/js/dashboard.js index c7bd62f2a..77962a585 100644 --- a/src/imgtests/web/static/js/dashboard.js +++ b/src/imgtests/web/static/js/dashboard.js @@ -78,6 +78,16 @@ function addDistro() { true ), ); + const host = prompt(gettext("Enter SSH host (IP address):")); + if (!host) return; + const port = prompt(gettext("Enter SSH port (e.g., 22):"), "22"); + if (!port || isNaN(port) || Number(port) < 1 || Number(port) > 65535) { + alert(gettext("Invalid SSH port value")); + return; + } + const user = prompt(gettext("Enter SSH user:")); + if (!user) return; + const password = prompt(gettext("Enter SSH password (optional):"), ""); fetch("/api/distros/add/", { method: "POST", headers: { @@ -88,6 +98,10 @@ function addDistro() { name: name, display_name: displayName, description: description, + host: host, + port: port, + user: user, + password: password, }), }) .then((response) => response.json()) diff --git a/src/imgtests/web/tests_interface/management/commands/init_distros.py b/src/imgtests/web/tests_interface/management/commands/init_distros.py index 26e87d9b6..dbe689e79 100644 --- a/src/imgtests/web/tests_interface/management/commands/init_distros.py +++ b/src/imgtests/web/tests_interface/management/commands/init_distros.py @@ -3,6 +3,8 @@ from django.core.management.base import BaseCommand from tests_interface.models import Distribution +from imgtests.database.database import ImgtestsDatabase + DEFAULT_DISTROS: list[dict[str, str | int]] = [ { "name": "yocto", @@ -18,6 +20,23 @@ }, ] +DEFAULT_CONNECTIONS: list[dict[str, str | int | bool]] = [ + { + "name": "yocto", + "host": "10.5.0.10", + "user": "root", + "password": "", + "port": 2222, + }, + { + "name": "opensuse", + "host": "10.5.0.13", + "user": "suser", + "password": "password", + "port": 1616, + }, +] + class Command(BaseCommand): def handle(self, *args: Any, **options: Any) -> None: # noqa: ARG002 @@ -29,6 +48,16 @@ def handle(self, *args: Any, **options: Any) -> None: # noqa: ARG002 created += 1 self.stdout.write(f"Created: {distro_data['display_name']}") + database = ImgtestsDatabase() + for connection_data in DEFAULT_CONNECTIONS: + name = str(connection_data["name"]) + if database.get_connection(name) is not None: + self.stdout.write(f"Connection for '{name}' already exists, keeping it.") + continue + database.upsert_connection(**connection_data) + self.stdout.write(f"Created connection for: {name}") + database.session.close_all() + self.stdout.write( self.style.SUCCESS(f"Successfully initialized {created} default distributions"), ) diff --git a/src/imgtests/web/tests_interface/tasks.py b/src/imgtests/web/tests_interface/tasks.py index 3cae36b67..13ac15221 100644 --- a/src/imgtests/web/tests_interface/tasks.py +++ b/src/imgtests/web/tests_interface/tasks.py @@ -2,21 +2,29 @@ from django.tasks import task -from imgtests.runner import Distro, Runner, run_tests +from imgtests.database.database import ImgtestsDatabase +from imgtests.runner import Runner, run_tests DEFAULT_TASK_TIMEOUT_SEC: Final = 3600 @task() def run_test_task( - distro: Distro, + distro: str, mode: Runner = "default", test_runs_count: int = 1, config: dict[str, Any] | None = None, ) -> dict[str, str | int]: try: + database = ImgtestsDatabase() + connection = database.get_connection(distro) + if connection is None: + return { + "status": "failed", + "error": f"Connection for distribution '{distro}' not found.", + } run_tests( - distro=distro, + distribution=connection, mode=mode, test_runs_count=test_runs_count, config=config, diff --git a/src/imgtests/web/tests_interface/views.py b/src/imgtests/web/tests_interface/views.py index c0b35e21e..2d9819047 100644 --- a/src/imgtests/web/tests_interface/views.py +++ b/src/imgtests/web/tests_interface/views.py @@ -284,7 +284,7 @@ def get_test_status(request: HttpRequest, task_id: str) -> JsonResponse: # noqa @csrf_exempt @require_http_methods(["POST"]) -def api_add_distro(request: HttpRequest) -> JsonResponse: +def api_add_distro(request: HttpRequest) -> JsonResponse: # noqa: PLR0911 try: data = json.loads(request.body) except json.JSONDecodeError: @@ -292,6 +292,17 @@ def api_add_distro(request: HttpRequest) -> JsonResponse: name = data.get("name", "").strip() display_name = data.get("display_name", "").strip() description = data.get("description", "").strip() + host = (data.get("host") or "").strip() + user = (data.get("user") or "").strip() + password = data.get("password") or "" + port = int(data.get("port") or 0) + + if not name: + return JsonResponse({"error": "name is required"}, status=400) + if not (0 < port <= 65535): # noqa: PLR2004 + return JsonResponse({"error": "Invalid port value"}, status=400) + if not host or not user: + return JsonResponse({"error": "host and user are required"}, status=400) if Distribution.objects.filter(name=name).exists(): return JsonResponse( @@ -305,6 +316,20 @@ def api_add_distro(request: HttpRequest) -> JsonResponse: description=description or f"Run tests for {display_name} platform", ) + try: + database = ImgtestsDatabase() + database.upsert_connection( + name=name, + host=host, + user=user, + password=password, + port=port, + is_active=True, + ) + database.session.close_all() + except Exception as e: # noqa: BLE001 + return JsonResponse({"error": str(e)}, status=500) + return JsonResponse( { "success": True, @@ -349,6 +374,8 @@ def api_get_distros(request: HttpRequest) -> JsonResponse: # noqa: ARG001 return JsonResponse({"distributions": distributions}) +@csrf_exempt +@require_http_methods(["GET"]) def __find_reports(reports_path: Path) -> list[dict[str, str | float]]: if not reports_path.is_dir(): return []