Skip to content

Commit

Permalink
Add status update endpoint to receive updates from Testflinger (#184)
Browse files Browse the repository at this point in the history
* Add status update endpoint to receive updates from Testflinger

* Add frontend changes to display event log

* Add resource_url to the test_execution table

* Added horizontal scrolling to event log

* Prevent status updates from overwriting test results

* Add last event to execution name and fix padding issue

* Changed ended to ended_prematurely
  • Loading branch information
val500 authored Jul 26, 2024
1 parent a339e5b commit 07cadbb
Show file tree
Hide file tree
Showing 16 changed files with 627 additions and 27 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Create test status update tables
Revision ID: 2745d4e5bc72
Revises: 33c0383ea9ca
Create Date: 2024-06-11 20:02:00.064753+00:00
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "2745d4e5bc72"
down_revision = "33c0383ea9ca"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"test_event",
sa.Column("event_name", sa.String(), nullable=False),
sa.Column("timestamp", sa.DateTime(), nullable=False),
sa.Column("detail", sa.String(), nullable=False),
sa.Column("test_execution_id", sa.Integer(), nullable=False),
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["test_execution_id"],
["test_execution.id"],
name=op.f("test_event_test_execution_id_fkey"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("test_event_pkey")),
)
# ### end Alembic commands ###
op.execute(
"ALTER TABLE test_execution ADD COLUMN "
"resource_url VARCHAR NOT NULL DEFAULT ''"
)

with op.get_context().autocommit_block():
op.execute("ALTER TYPE testexecutionstatus ADD VALUE 'ENDED_PREMATURELY'")


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("test_event")
# ### end Alembic commands ###
op.execute("ALTER TYPE testexecutionstatus RENAME TO testexecutionstatus_old")
op.execute(
"CREATE TYPE testexecutionstatus AS "
"ENUM('NOT_STARTED', 'IN_PROGRESS', 'PASSED', 'FAILED', 'NOT_TESTED')"
)
op.execute(
"ALTER TABLE test_execution ALTER COLUMN status TYPE testexecutionstatus USING "
"status::text::testexecutionstatus"
)
op.execute("DROP TYPE testexecutionstatus_old")
op.drop_column("test_execution", "resource_url")
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@

from fastapi import APIRouter

from . import end_test, get_test_results, patch, reruns, start_test
from . import end_test, get_test_results, patch, reruns, start_test, status_update

router = APIRouter(tags=["test-executions"])
router.include_router(start_test.router)
router.include_router(get_test_results.router)
router.include_router(end_test.router)
router.include_router(patch.router)
router.include_router(reruns.router)
router.include_router(status_update.router)
11 changes: 11 additions & 0 deletions backend/test_observer/controllers/test_executions/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
ArtefactBuild,
TestExecution,
TestResult,
TestEvent,
)
from test_observer.data_access.models_enums import TestExecutionStatus

Expand Down Expand Up @@ -55,6 +56,16 @@ def delete_previous_results(
db.commit()


def delete_previous_test_events(
db: Session,
test_execution: TestExecution,
):
db.execute(
delete(TestEvent).where(TestEvent.test_execution_id == test_execution.id)
)
db.commit()


def get_previous_artefact_builds_query(
session: Session,
artefact: Artefact,
Expand Down
11 changes: 11 additions & 0 deletions backend/test_observer/controllers/test_executions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from enum import Enum
from typing import Annotated

from datetime import datetime
from pydantic import (
AliasPath,
BaseModel,
Expand Down Expand Up @@ -172,3 +173,13 @@ class PendingRerun(BaseModel):

class DeleteReruns(BaseModel):
test_execution_ids: set[int]


class TestEvent(BaseModel):
event_name: str
timestamp: datetime
detail: str


class StatusUpdateRequest(BaseModel):
events: list[TestEvent]
84 changes: 84 additions & 0 deletions backend/test_observer/controllers/test_executions/status_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Copyright 2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload

from test_observer.data_access.models import (
TestEvent,
TestExecution,
)
from test_observer.data_access.models_enums import TestExecutionStatus
from test_observer.data_access.setup import get_db

from .logic import delete_previous_test_events
from .models import StatusUpdateRequest
from .testflinger_event_parser import TestflingerEventParser

router = APIRouter()


@router.put("/{id}/status_update")
def put_status_update(
id: int, request: StatusUpdateRequest, db: Session = Depends(get_db)
):
test_execution = db.get(
TestExecution,
id,
options=[joinedload(TestExecution.test_events)],
)
if test_execution is None:
raise HTTPException(status_code=404, detail="TestExecution not found")

delete_previous_test_events(db, test_execution)

for event in request.events:
test_event = TestEvent(
event_name=event.event_name,
timestamp=event.timestamp,
detail=event.detail,
)
db.add(test_event)
test_execution.test_events.append(test_event)
event_parser = TestflingerEventParser()
event_parser.process_events(test_execution.test_events)
if event_parser.resource_url is not None:
test_execution.resource_url = event_parser.resource_url
if (
event_parser.is_ended_prematurely
and test_execution.status is not TestExecutionStatus.FAILED
and test_execution.status is not TestExecutionStatus.PASSED
):
test_execution.status = TestExecutionStatus.ENDED_PREMATURELY
db.commit()


@router.get("/{id}/status_update")
def get_status_update(id: int, db: Session = Depends(get_db)):
test_execution = db.get(
TestExecution,
id,
options=[joinedload(TestExecution.test_events)],
)

if test_execution is None:
raise HTTPException(status_code=404, detail="TestExecution not found")

test_events = []
for test_event in test_execution.test_events:
test_events.append(test_event)

return test_events
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Copyright 2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

from test_observer.data_access.models import TestEvent


class TestflingerEventParser:
def __init__(self):
self.is_ended_prematurely = False
self.resource_url = None

def process_events(self, events: list[TestEvent]):
final_event = events[-1]
if final_event.event_name == "job_end" and final_event.detail != "normal_exit":
self.is_ended_prematurely = True
if events[0].event_name == "job_start":
self.resource_url = events[0].detail
22 changes: 22 additions & 0 deletions backend/test_observer/data_access/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,12 @@ class TestExecution(Base):
test_results: Mapped[list["TestResult"]] = relationship(
back_populates="test_execution", cascade="all, delete"
)
test_events: Mapped[list["TestEvent"]] = relationship(
back_populates="test_execution",
cascade="all, delete",
order_by="TestEvent.timestamp",
)
resource_url: Mapped[str] = mapped_column(default="")
rerun_request: Mapped[TestExecutionRerunRequest | None] = relationship(
back_populates="test_execution", cascade="all, delete"
)
Expand Down Expand Up @@ -425,3 +431,19 @@ def __repr__(self) -> str:
"test_execution_id",
"test_case_id",
)


class TestEvent(Base):
"""
A table to represent test events that have ocurred during a job
"""

__tablename__ = "test_event"

event_name: Mapped[str]
timestamp: Mapped[datetime]
detail: Mapped[str]
test_execution_id: Mapped[int] = mapped_column(
ForeignKey("test_execution.id", ondelete="CASCADE")
)
test_execution: Mapped["TestExecution"] = relationship(back_populates="test_events")
1 change: 1 addition & 0 deletions backend/test_observer/data_access/models_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class TestExecutionStatus(str, Enum):
PASSED = "PASSED"
FAILED = "FAILED"
NOT_TESTED = "NOT_TESTED"
ENDED_PREMATURELY = "ENDED_PREMATURELY"


class TestExecutionReviewDecision(str, Enum):
Expand Down
Loading

0 comments on commit 07cadbb

Please sign in to comment.