Skip to content

Commit

Permalink
feature(argus): Release Planner
Browse files Browse the repository at this point in the history
This commit adds a new way to manage releases inside Argus, called
Release Planner. Release Planner replaces current Release Scheduling
mechanism (but not the Duty Calendar for now). Main features include:

* Plans are now separate entities (replacing one global release
  schedule)
* Each plan contains tests, groups and people it will be using for
  assignments
* Plans can target a specific scylla version to scope the plan to a
  point or RC release.
* Plans can be edited, deleted and copied.
* On copy, plan will offer to replace missing tests/groups if it is
  being copied to another release and that release is missing required
  tests.
* Each plan automatically creates and maintains a view dashboard for
  said plan and can be viewed from plan overview page or inside views
  overview
* The new assignment logic is now applied to all releases not marked as
  "endless" (perpetual in the admin panel). Currently the only one that
  is marked that way is scylla-master.

Fixes #377
Task: scylladb/qa-tasks#1717
  • Loading branch information
k0machi committed Nov 4, 2024
1 parent 59e4beb commit 8b469e4
Show file tree
Hide file tree
Showing 35 changed files with 2,806 additions and 165 deletions.
10 changes: 8 additions & 2 deletions argus/backend/controller/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from argus.backend.controller.testrun_api import bp as testrun_bp
from argus.backend.controller.team import bp as team_bp
from argus.backend.controller.view_api import bp as view_bp
from argus.backend.controller.planner_api import bp as planner_bp
from argus.backend.service.argus_service import ArgusService, ScheduleUpdateRequest
from argus.backend.service.results_service import ResultsService
from argus.backend.service.user import UserService, api_login_required
Expand All @@ -27,6 +28,7 @@
bp.register_blueprint(testrun_bp)
bp.register_blueprint(team_bp)
bp.register_blueprint(view_bp)
bp.register_blueprint(planner_bp)
bp.register_error_handler(Exception, handle_api_exception)
LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -189,10 +191,12 @@ def release_schedules_assignee_update():
@api_login_required
def group_assignees():
release_id = request.args.get("releaseId")
version = request.args.get("version")
plan_id = request.args.get("planId")
if not release_id:
raise Exception("Missing releaseId")
service = ArgusService()
group_assignees_list = service.get_groups_assignees(release_id)
group_assignees_list = service.get_groups_assignees(release_id, version, plan_id)

return jsonify({
"status": "ok",
Expand All @@ -204,10 +208,12 @@ def group_assignees():
@api_login_required
def tests_assignees():
group_id = request.args.get("groupId")
version = request.args.get("version")
plan_id = request.args.get("planId")
if not group_id:
raise Exception("Missing groupId")
service = ArgusService()
tests_assignees_list = service.get_tests_assignees(group_id)
tests_assignees_list = service.get_tests_assignees(group_id, version, plan_id)

return jsonify({
"status": "ok",
Expand Down
9 changes: 9 additions & 0 deletions argus/backend/controller/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from argus.backend.service.argus_service import ArgusService
from argus.backend.models.web import WebFileStorage
from argus.backend.service.testrun import TestRunService
from argus.backend.service.planner_service import PlanningService
from argus.backend.service.user import UserService, login_required
from argus.backend.service.views import UserViewService

Expand Down Expand Up @@ -121,6 +122,14 @@ def release_scheduler(name: str):
return render_template("release_schedule.html.j2", release_name=name, data=data_json)


@bp.route("/release/<string:name>/planner")
@login_required
def release_planner(name: str):
service = PlanningService()
planner_data = service.release_planner(name)
return render_template("release_planner.html.j2", release_name=planner_data["release"]["name"], planner_data=planner_data)


@bp.route("/release/<string:name>/duty")
@login_required
def duty_planner(name: str):
Expand Down
178 changes: 178 additions & 0 deletions argus/backend/controller/planner_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import logging
from uuid import UUID
from flask import (
Blueprint,
request
)
from argus.backend.error_handlers import handle_api_exception
from argus.backend.service.planner_service import CopyPlanPayload, PlanningService, TempPlanPayload
from argus.backend.service.test_lookup import TestLookup
from argus.backend.service.user import api_login_required
from argus.backend.util.common import get_payload

bp = Blueprint('planning_api', __name__, url_prefix='/planning')
LOGGER = logging.getLogger(__name__)
bp.register_error_handler(Exception, handle_api_exception)


@bp.route("/", methods=["GET"])
@api_login_required
def version():

result = PlanningService().version()

return {
"status": "ok",
"response": result
}


@bp.route("/plan/<string:plan_id>/copy/check", methods=["GET"])
@api_login_required
def is_plan_eligible_for_copy(plan_id: str):
release_id = request.args.get("releaseId")
if not release_id:
raise Exception("Missing release id.")

result = PlanningService().check_plan_copy_eligibility(plan_id=UUID(plan_id), target_release_id=UUID(release_id))

return {
"status": "ok",
"response": result
}


@bp.route("/release/<string:release_id>/gridview", methods=["GET"])
@api_login_required
def grid_view_for_release(release_id: str):

result = PlanningService().get_gridview_for_release(release_id=UUID(release_id))

return {
"status": "ok",
"response": result
}


@bp.route("/search", methods=["GET"])
@api_login_required
def search_tests():
query = request.args.get("query")
release_id = request.args.get('releaseId')
service = TestLookup
if query:
res = service.test_lookup(query, release_id=release_id)
else:
res = []
return {
"status": "ok",
"response": {
"hits": res,
"total": len(res)
}
}

@bp.route("/group/<string:group_id>/explode", methods=["GET"])
@api_login_required
def explode_group(group_id: str):
service = TestLookup
res = service.explode_group(group_id=group_id)
return {
"status": "ok",
"response": res
}


@bp.route("/plan/<string:plan_id>/get", methods=["GET"])
@api_login_required
def get_plan(plan_id: str):
result = PlanningService().get_plan(plan_id)

return {
"status": "ok",
"response": result
}


@bp.route("/release/<string:release_id>/all", methods=["GET"])
@api_login_required
def get_plans_for_release(release_id: str):
result = PlanningService().get_plans_for_release(release_id)

return {
"status": "ok",
"response": result
}


@bp.route("/plan/create", methods=["POST"])
@api_login_required
def create_plan():
payload = get_payload(request)
result = PlanningService().create_plan(payload)

return {
"status": "ok",
"response": result
}


@bp.route("/plan/update", methods=["POST"])
@api_login_required
def update_plan():
payload = get_payload(request)
result = PlanningService().update_plan(payload)

return {
"status": "ok",
"response": result
}

@bp.route("/plan/copy", methods=["POST"])
@api_login_required
def copy_plan():
payload = get_payload(request)
payload["plan"] = TempPlanPayload(**payload["plan"])
result = PlanningService().copy_plan(CopyPlanPayload(**payload))

return {
"status": "ok",
"response": result
}



@bp.route("/plan/<string:plan_id>/delete", methods=["DELETE"])
@api_login_required
def delete_plan(plan_id: str):
result = PlanningService().delete_plan(plan_id)

return {
"status": "ok",
"response": result
}


@bp.route("/plan/<string:plan_id>/owner/set", methods=["POST"])
@api_login_required
def change_plan_owner(plan_id: str):
payload = get_payload(request)
result = PlanningService().change_plan_owner(plan_id=plan_id, new_owner=payload["newOwner"])

return {
"status": "ok",
"response": result
}


@bp.route("/plan/<string:plan_id>/resolve_entities", methods=["GET"])
@api_login_required
def resolve_plan_entities(plan_id: str):

service = PlanningService()
result = service.resolve_plan(plan_id)

return {
"status": "ok",
"response": result,
}
24 changes: 24 additions & 0 deletions argus/backend/models/plan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import datetime
from cassandra.cqlengine import columns
from cassandra.cqlengine.models import Model
from cassandra.cqlengine.usertype import UserType
from cassandra.util import uuid_from_time


class ArgusReleasePlan(Model):
id = columns.TimeUUID(partition_key=True, default=lambda: uuid_from_time(datetime.datetime.now(tz=datetime.UTC)))
name = columns.Text(required=True)
completed = columns.Boolean(default=lambda: False)
description = columns.Text()
owner = columns.UUID(required=True)
participants = columns.List(value_type=columns.UUID)
target_version = columns.Ascii()
assignee_mapping = columns.Map(key_type=columns.UUID, value_type=columns.UUID)
release_id = columns.UUID(index=True)
tests = columns.List(value_type=columns.UUID)
groups = columns.List(value_type=columns.UUID)
view_id = columns.UUID(index=True)
created_from = columns.UUID(index=True)
creation_time = columns.DateTime(default=lambda: datetime.datetime.now(tz=datetime.UTC))
last_updated = columns.DateTime(default=lambda: datetime.datetime.now(tz=datetime.UTC))
ends_at = columns.DateTime()
3 changes: 3 additions & 0 deletions argus/backend/models/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from cassandra.cqlengine import columns
from cassandra.util import uuid_from_time, unix_time_from_uuid1 # pylint: disable=no-name-in-module

from argus.backend.models.plan import ArgusReleasePlan
from argus.backend.models.result import ArgusGenericResultMetadata, ArgusGenericResultData, ArgusBestResultData


Expand Down Expand Up @@ -154,6 +155,7 @@ class ArgusUserView(Model):
display_name = columns.Text()
description = columns.Text()
user_id = columns.UUID(required=True, index=True)
plan_id = columns.UUID(index=True)
tests = columns.List(value_type=columns.UUID, default=lambda: [])
release_ids = columns.List(value_type=columns.UUID, default=lambda: [])
group_ids = columns.List(value_type=columns.UUID, default=lambda: [])
Expand Down Expand Up @@ -382,6 +384,7 @@ class WebFileStorage(Model):
ArgusGenericResultMetadata,
ArgusGenericResultData,
ArgusBestResultData,
ArgusReleasePlan,
]

USED_TYPES: list[UserType] = [
Expand Down
26 changes: 24 additions & 2 deletions argus/backend/plugins/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from cassandra.cqlengine.usertype import UserType
from flask import Blueprint
from argus.backend.db import ScyllaCluster
from argus.backend.models.plan import ArgusReleasePlan
from argus.backend.models.web import (
ArgusTest,
ArgusGroup,
Expand Down Expand Up @@ -65,13 +66,34 @@ def assign_categories(self):
except ArgusTest.DoesNotExist:
LOGGER.warning("Test entity missing for key \"%s\", run won't be visible until this is corrected", key)

def get_assignment(self, version: str | None = None) -> UUID | None:
associated_test: ArgusTest = ArgusTest.get(build_system_id=self.build_id)
associated_release: ArgusRelease = ArgusRelease.get(id=associated_test.release_id)
if associated_release.perpetual:
return self._legacy_get_scheduled_assignee(associated_test=associated_test, associated_release=associated_release)

plans: list[ArgusReleasePlan] = list(ArgusReleasePlan.filter(release_id=associated_release.id))

if version:
plans = [plan for plan in plans if plan.target_version == version]

for plan in plans:
if associated_test.group_id in plan.groups:
return plan.assignee_mapping.get(associated_test.group_id, plan.owner)
if associated_test.id in plan.tests:
return plan.assignee_mapping.get(associated_test.id, plan.owner)

return None


def get_scheduled_assignee(self) -> UUID:
return self.get_assignment()

def _legacy_get_scheduled_assignee(self, associated_test: ArgusTest, associated_release: ArgusRelease) -> UUID:
"""
Iterate over all schedules (groups and tests) and return first available assignee
"""
associated_test = ArgusTest.get(build_system_id=self.build_id)
associated_group = ArgusGroup.get(id=associated_test.group_id)
associated_release = ArgusRelease.get(id=associated_test.release_id)

scheduled_groups = ArgusScheduleGroup.filter(
release_id=associated_release.id,
Expand Down
3 changes: 3 additions & 0 deletions argus/backend/plugins/driver_matrix_tests/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,9 @@ def get_events(self) -> list:

def submit_product_version(self, version: str):
self.scylla_version = version
new_assignee = self.get_assignment(version)
if new_assignee:
self.assignee = new_assignee

def finish_run(self, payload: dict = None):
self.end_time = datetime.utcnow()
Expand Down
3 changes: 3 additions & 0 deletions argus/backend/plugins/generic/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ def submit_product_version(self, version: str):
pattern = re.compile(r"((?P<short>[\w.~]+)-(?P<build>(0\.)?(?P<date>[0-9]{8,8})\.(?P<commit>\w+).*))")
if match := pattern.search(version):
self.scylla_version = match.group("short")
new_assignee = self.get_assignment(match.group("short"))
if new_assignee:
self.assignee = new_assignee
self.set_full_version(version)

@classmethod
Expand Down
3 changes: 3 additions & 0 deletions argus/backend/plugins/sct/testrun.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ def get_events(self) -> list[EventsBySeverity]:

def submit_product_version(self, version: str):
self.scylla_version = version
new_assignee = self.get_assignment(version)
if new_assignee:
self.assignee = new_assignee

def finish_run(self, payload: dict = None):
self.end_time = datetime.utcnow()
Expand Down
3 changes: 3 additions & 0 deletions argus/backend/plugins/sirenada/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ def get_distinct_product_versions(cls, release: ArgusRelease, cluster: ScyllaClu

def submit_product_version(self, version: str):
self.scylla_version = version
new_assignee = self.get_assignment(version)
if new_assignee:
self.assignee = new_assignee

def submit_logs(self, logs: dict[str, str]):
raise SirenadaPluginException("Log submission is not supported for Sirenada")
Expand Down
Loading

0 comments on commit 8b469e4

Please sign in to comment.