Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
76 changes: 74 additions & 2 deletions src/imgtests/database/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."
Expand Down
23 changes: 23 additions & 0 deletions src/imgtests/database/models/distribution_connection.py
Original file line number Diff line number Diff line change
@@ -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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ещё бы добавить путь до приватного ключа, чтобы была возможность безболезненно на них перейти и не хранить пароли в БД.

port: Mapped[int] = mapped_column(Integer)
is_active: Mapped[bool] = mapped_column(Boolean, server_default="true")

def __repr__(self) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Обычно пишется так, чтобы по этой строке можно было точно воссоздать объект в коде, т.е. не хватает остальных полей.

return (
f"DistributionConnection(name={self.name}, "
f"host={self.host}, user={self.user}, "
f"port={self.port}, is_active={self.is_active})"
)
10 changes: 5 additions & 5 deletions src/imgtests/exec/exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,16 +269,16 @@ def download(self, remotepath: Path, localpath: Path) -> ExecResult:


def wait_remote(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ещё бы подтянуть изменения из main, там теперь возможно задавать путь до приватного ключа.

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)
Expand Down
41 changes: 13 additions & 28 deletions src/imgtests/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions src/imgtests/web/static/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
},
]
Comment on lines +23 to +38

@Artanias Artanias Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Надо бы также из переменных окружения доставать host, user, password, port (с помощью env_var_to_type) и определять данный список только при наличии их (переменных окружения).



class Command(BaseCommand):
def handle(self, *args: Any, **options: Any) -> None: # noqa: ARG002
Expand All @@ -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"),
)
14 changes: 11 additions & 3 deletions src/imgtests/web/tests_interface/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 28 additions & 1 deletion src/imgtests/web/tests_interface/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,14 +284,25 @@ 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:
return JsonResponse({"error": "Invalid JSON"}, status=400)
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(
Expand All @@ -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,
Expand Down Expand Up @@ -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 []
Expand Down