diff --git a/backend/migrations/versions/2024_06_11_2002-2745d4e5bc72_create_test_status_update_tables.py b/backend/migrations/versions/2024_06_11_2002-2745d4e5bc72_create_test_status_update_tables.py new file mode 100644 index 00000000..08c64714 --- /dev/null +++ b/backend/migrations/versions/2024_06_11_2002-2745d4e5bc72_create_test_status_update_tables.py @@ -0,0 +1,44 @@ +"""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_msg", 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 ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("test_event") + # ### end Alembic commands ### diff --git a/backend/test_observer/controllers/test_executions/__init__.py b/backend/test_observer/controllers/test_executions/__init__.py index 33027593..778aa106 100644 --- a/backend/test_observer/controllers/test_executions/__init__.py +++ b/backend/test_observer/controllers/test_executions/__init__.py @@ -16,7 +16,7 @@ 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) @@ -24,3 +24,4 @@ router.include_router(end_test.router) router.include_router(patch.router) router.include_router(reruns.router) +router.include_router(status_update.router) diff --git a/backend/test_observer/controllers/test_executions/logic.py b/backend/test_observer/controllers/test_executions/logic.py index 83dcc817..8495d1d9 100644 --- a/backend/test_observer/controllers/test_executions/logic.py +++ b/backend/test_observer/controllers/test_executions/logic.py @@ -24,6 +24,7 @@ ArtefactBuild, TestExecution, TestResult, + TestEvent, ) from test_observer.data_access.models_enums import TestExecutionStatus @@ -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, diff --git a/backend/test_observer/controllers/test_executions/models.py b/backend/test_observer/controllers/test_executions/models.py index 36df1821..d4795d79 100644 --- a/backend/test_observer/controllers/test_executions/models.py +++ b/backend/test_observer/controllers/test_executions/models.py @@ -21,6 +21,7 @@ from enum import Enum from typing import Annotated +from datetime import datetime from pydantic import ( AliasPath, BaseModel, @@ -172,3 +173,13 @@ class PendingRerun(BaseModel): class DeleteReruns(BaseModel): test_execution_ids: set[int] + + +class TestEvent(BaseModel): + event_name: str + timestamp: datetime + detail_msg: str + + +class StatusUpdateRequest(BaseModel): + events: list[TestEvent] diff --git a/backend/test_observer/controllers/test_executions/status_update.py b/backend/test_observer/controllers/test_executions/status_update.py new file mode 100644 index 00000000..c3639e3d --- /dev/null +++ b/backend/test_observer/controllers/test_executions/status_update.py @@ -0,0 +1,53 @@ +# 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 . +# + +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.setup import get_db + +from .logic import delete_previous_test_events +from .models import StatusUpdateRequest + +router = APIRouter() + + +@router.put("/{id}/status_update") +def 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_msg=event.detail_msg, + ) + db.add(test_event) + test_execution.test_events.append(test_event) + db.commit() diff --git a/backend/test_observer/data_access/models.py b/backend/test_observer/data_access/models.py index 1e0cf7f9..1dc350ac 100644 --- a/backend/test_observer/data_access/models.py +++ b/backend/test_observer/data_access/models.py @@ -313,6 +313,11 @@ 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", + ) rerun_request: Mapped[TestExecutionRerunRequest | None] = relationship( back_populates="test_execution", cascade="all, delete" ) @@ -402,3 +407,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_msg: Mapped[str] + test_execution_id: Mapped[int] = mapped_column( + ForeignKey("test_execution.id", ondelete="CASCADE") + ) + test_execution: Mapped["TestExecution"] = relationship(back_populates="test_events") diff --git a/backend/tests/controllers/test_executions/test_status_update.py b/backend/tests/controllers/test_executions/test_status_update.py new file mode 100644 index 00000000..27cd4634 --- /dev/null +++ b/backend/tests/controllers/test_executions/test_status_update.py @@ -0,0 +1,126 @@ +# 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 . +# + +import datetime + +from fastapi.testclient import TestClient + +from tests.data_generator import DataGenerator + + +def test_status_updates_stored(test_client: TestClient, generator: DataGenerator): + artefact = generator.gen_artefact("beta") + artefact_build = generator.gen_artefact_build(artefact) + environment = generator.gen_environment() + test_execution = generator.gen_test_execution( + artefact_build, environment, ci_link="http://localhost" + ) + + response = test_client.put( + f"/v1/test-executions/{test_execution.id}/status_update", + json={ + "agent_id": "test_agent", + "job_queue": "test_job_queue", + "events": [ + { + "event_name": "started_setup", + "timestamp": "2015-03-21T11:08:14.859831", + "detail_msg": "my_detail_msg_one", + }, + { + "event_name": "ended_setup", + "timestamp": "2015-03-21T11:08:15.859831", + "detail_msg": "my_detail_msg_two", + }, + ], + }, + ) + assert response.status_code == 200 + assert test_execution.test_events[0].event_name == "started_setup" + assert test_execution.test_events[0].timestamp == datetime.datetime.fromisoformat( + "2015-03-21T11:08:14.859831" + ) + assert test_execution.test_events[0].detail_msg == "my_detail_msg_one" + assert test_execution.test_events[1].event_name == "ended_setup" + assert test_execution.test_events[1].timestamp == datetime.datetime.fromisoformat( + "2015-03-21T11:08:15.859831" + ) + assert test_execution.test_events[1].detail_msg == "my_detail_msg_two" + + +def test_status_updates_is_idempotent( + test_client: TestClient, generator: DataGenerator +): + artefact = generator.gen_artefact("beta") + artefact_build = generator.gen_artefact_build(artefact) + environment = generator.gen_environment() + test_execution = generator.gen_test_execution( + artefact_build, environment, ci_link="http://localhost" + ) + + for _ in range(3): + test_client.put( + f"/v1/test-executions/{test_execution.id}/status_update", + json={ + "agent_id": "test_agent", + "job_queue": "test_job_queue", + "events": [ + { + "event_name": "started_setup", + "timestamp": "2015-03-21T11:08:14.859831", + "detail_msg": "my_detail_msg_one", + }, + { + "event_name": "ended_setup", + "timestamp": "2015-03-21T11:08:15.859831", + "detail_msg": "my_detail_msg_two", + }, + ], + }, + ) + assert len(test_execution.test_events) == 2 + + +def test_status_updates_invalid_timestamp( + test_client: TestClient, generator: DataGenerator +): + artefact = generator.gen_artefact("beta") + artefact_build = generator.gen_artefact_build(artefact) + environment = generator.gen_environment() + test_execution = generator.gen_test_execution( + artefact_build, environment, ci_link="http://localhost" + ) + + response = test_client.put( + f"/v1/test-executions/{test_execution.id}/status_update", + json={ + "agent_id": "test_agent", + "job_queue": "test_job_queue", + "events": [ + { + "event_name": "started_setup", + "timestamp": "201-03-21T11:08:14.859831", + "detail_msg": "my_detail_msg_one", + }, + { + "event_name": "ended_setup", + "timestamp": "20-03-21T11:08:15.859831", + "detail_msg": "my_detail_msg_two", + }, + ], + }, + ) + assert response.status_code == 422