From 202183d09c06a7043e193c4f3378cd8205f5de39 Mon Sep 17 00:00:00 2001 From: Max Bubenick Date: Mon, 18 May 2026 13:24:53 -0300 Subject: [PATCH 1/3] gascan pluging developed by cursor --- app/sep/main.py | 1 + app/sep/plugins/gascan/__init__.py | 20 ++ app/sep/plugins/gascan/api_routes.py | 93 +++++ app/sep/plugins/gascan/deps.py | 317 ++++++++++++++++++ app/sep/plugins/gascan/models.py | 124 +++++++ app/sep/plugins/gascan/routes.py | 196 +++++++++++ app/sep/plugins/gascan/schema.py | 80 +++++ app/tasks/models.py | 3 + frontend/package.json | 1 + frontend/packages/plugins/gascan/package.json | 23 ++ .../plugins/gascan/src/GascanPlugin.tsx | 23 ++ frontend/packages/plugins/gascan/src/index.ts | 19 ++ .../packages/plugins/gascan/src/routes.tsx | 27 ++ .../packages/plugins/gascan/tsconfig.json | 4 + frontend/packages/shell/package.json | 1 + .../shell/src/contexts/navigation.tsx | 2 + frontend/packages/shell/src/router.tsx | 4 + frontend/pnpm-lock.yaml | 28 ++ frontend/pnpm-workspace.yaml | 6 + settings.yaml | 4 + static/css/base.css | 4 + templates/gascan/details.html.j2 | 163 +++++++++ templates/gascan/index.html.j2 | 114 +++++++ templates/gascan/partials/create-form.html.j2 | 121 +++++++ templates/gascan/partials/edit-form.html.j2 | 134 ++++++++ templates/gascan/partials/saved-tasks.html.j2 | 49 +++ tests/app/sep/plugins/gascan/__init__.py | 0 tests/app/sep/plugins/gascan/conftest.py | 21 ++ .../app/sep/plugins/gascan/test_api_routes.py | 153 +++++++++ tests/app/sep/plugins/gascan/test_deps.py | 97 ++++++ tests/app/sep/plugins/gascan/test_routes.py | 51 +++ 31 files changed, 1883 insertions(+) create mode 100644 app/sep/plugins/gascan/__init__.py create mode 100644 app/sep/plugins/gascan/api_routes.py create mode 100644 app/sep/plugins/gascan/deps.py create mode 100644 app/sep/plugins/gascan/models.py create mode 100644 app/sep/plugins/gascan/routes.py create mode 100644 app/sep/plugins/gascan/schema.py create mode 100644 frontend/packages/plugins/gascan/package.json create mode 100644 frontend/packages/plugins/gascan/src/GascanPlugin.tsx create mode 100644 frontend/packages/plugins/gascan/src/index.ts create mode 100644 frontend/packages/plugins/gascan/src/routes.tsx create mode 100644 frontend/packages/plugins/gascan/tsconfig.json create mode 100644 templates/gascan/details.html.j2 create mode 100644 templates/gascan/index.html.j2 create mode 100644 templates/gascan/partials/create-form.html.j2 create mode 100644 templates/gascan/partials/edit-form.html.j2 create mode 100644 templates/gascan/partials/saved-tasks.html.j2 create mode 100644 tests/app/sep/plugins/gascan/__init__.py create mode 100644 tests/app/sep/plugins/gascan/conftest.py create mode 100644 tests/app/sep/plugins/gascan/test_api_routes.py create mode 100644 tests/app/sep/plugins/gascan/test_deps.py create mode 100644 tests/app/sep/plugins/gascan/test_routes.py diff --git a/app/sep/main.py b/app/sep/main.py index b9d3aac719..9bfbe2942a 100644 --- a/app/sep/main.py +++ b/app/sep/main.py @@ -138,6 +138,7 @@ async def sep_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: "backup_mongo", "backup_pg", "checksums", + "gascan", } ) diff --git a/app/sep/plugins/gascan/__init__.py b/app/sep/plugins/gascan/__init__.py new file mode 100644 index 0000000000..1bf01b4c13 --- /dev/null +++ b/app/sep/plugins/gascan/__init__.py @@ -0,0 +1,20 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Gascan management plugin.""" + +from app.sep.plugins.gascan.routes import router + +__all__ = ["router"] diff --git a/app/sep/plugins/gascan/api_routes.py b/app/sep/plugins/gascan/api_routes.py new file mode 100644 index 0000000000..616d1fde73 --- /dev/null +++ b/app/sep/plugins/gascan/api_routes.py @@ -0,0 +1,93 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Define the JSON API router for the Gascan plugin. + +Mounted at ``/api/plugins/gascan/`` via ``plugins_router`` in +``app/sep/api/router.py``. +""" + +import logging + +from fastapi import APIRouter +from fastapi import status as http_status + +from app.sep.deps import TaskAPI +from app.sep.plugins.gascan.deps import ( + build_gascan_api_task_response, + build_gascan_task, + GascanTask, + get_gascan_api_task_responses, + get_gascan_task, + get_gascan_task_status, +) +from app.sep.plugins.gascan.models import GascanTaskResponse, GascanTaskWrite +from app.sep.plugins.gascan.schema import gascan_schema +from app.sep.plugins.framework.api import schema_endpoint +from app.tasks.models import Task, TaskHistoryStatusEnum + +logger = logging.getLogger(__name__) + +router = APIRouter() +schema_endpoint(router=router, plugin_schema=gascan_schema) + + +@router.get("/", response_model=list[GascanTaskResponse]) +async def gascan_api_list( + tasks_api: TaskAPI, + status: TaskHistoryStatusEnum | None = None, +) -> list[GascanTaskResponse]: + """List gascan tasks.""" + return await get_gascan_api_task_responses( + tasks_api, + status=status, + ) + + +@router.get("/{task_name}", response_model=GascanTaskResponse) +async def gascan_api_detail( + task_name: str, + tasks_api: TaskAPI, +) -> GascanTaskResponse: + """Retrieve a single gascan task.""" + task = await get_gascan_task(task_name, tasks_api) + task_status = await get_gascan_task_status(task.name, tasks_api) + return build_gascan_api_task_response(task, status=task_status) + + +@router.post( + "/", + response_model=GascanTaskResponse, + status_code=http_status.HTTP_201_CREATED, +) +async def gascan_api_create( + body: GascanTaskWrite, + tasks_api: TaskAPI, +) -> GascanTaskResponse: + """Create a gascan task from a JSON payload request body.""" + logger.debug("Create gascan task (JSON path): %s", body.task_name) + task_write = build_gascan_task(body) + created = await tasks_api.post("/", json=task_write.model_dump()) + task = Task.model_validate(created) + return build_gascan_api_task_response(task, status=None) + + +@router.delete("/{task_name}", status_code=http_status.HTTP_204_NO_CONTENT) +async def gascan_api_delete( + task: GascanTask, + tasks_api: TaskAPI, +) -> None: + """Delete a gascan task.""" + await tasks_api.delete(f"/{task.name}") diff --git a/app/sep/plugins/gascan/deps.py b/app/sep/plugins/gascan/deps.py new file mode 100644 index 0000000000..1c4623ee24 --- /dev/null +++ b/app/sep/plugins/gascan/deps.py @@ -0,0 +1,317 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Define dependencies for the Gascan plugin.""" + +import logging +import shlex +from collections.abc import Iterable +from typing import Annotated, Any + +from fastapi import Depends, Form + +from app.sep.deps import ( + DefaultContext, + ExecutorHostsCtx, + get_task_by_name, + get_tasks_context, + InventoryAPI, + TaskAPI, +) +from app.sep.plugins.gascan.models import GascanCreate, GascanTaskResponse, GascanTaskWrite +from app.tasks.models import ( + Task, + TaskBackendEnum, + TaskHistoryStatusEnum, + TaskOwner, + TaskWrite, +) + +logger = logging.getLogger(__name__) + + +def _assemble_gascan_payload( + *, + task_name: str, + hostname: str, + playbook: str = "", + limit: str = "", + override: str = "", + alert_on_fail: bool = False, +) -> TaskWrite: + """Build a TaskWrite payload for a gascan run-command job. + + :param task_name: The name of the task. + :type task_name: str + :param hostname: The executor host where gascan runs. + :type hostname: str + :param playbook: The playbook to run. + :type playbook: str + :param limit: Optional limit expression. + :type limit: str + :param override: Optional override values. + :type override: str + :param alert_on_fail: Whether to alert on task failure. + :type alert_on_fail: bool + :return: A fully constructed TaskWrite for the Tasks API. + :rtype: TaskWrite + """ + args: list[str] = [] + if playbook: + args.append(f"--playbook={playbook}") + if limit: + args.append(f"--limit={limit}") + if override: + args.append(f"--override={override}") + + return TaskWrite( + owner=TaskOwner.GASCAN, + backend=TaskBackendEnum.PROXY, + data={ + "task": "run-command", + "meta": { + "command": "gascan", + "args": shlex.join(args), + "target": hostname, + }, + }, + name=task_name, + target=hostname, + alert_on_fail=alert_on_fail, + ) + + +async def build_gascan_task_payload( + form: Annotated[GascanCreate, Form()], +) -> TaskWrite: + """Build the gascan task payload from an HTML form submission. + + :param form: The form data for Gascan task creation. + :type form: GascanCreate + :return: A fully constructed TaskWrite object. + :rtype: TaskWrite + """ + return _assemble_gascan_payload( + task_name=form.task_name, + hostname=form.hostname, + playbook=form.playbook, + limit=form.limit, + override=form.override, + alert_on_fail=form.alert_on_fail, + ) + + +GascanGeneratedTask = Annotated[TaskWrite, Depends(build_gascan_task_payload)] + + +def build_gascan_task(body: GascanTaskWrite) -> TaskWrite: + """Build the gascan task payload from a JSON request body. + + :param body: The validated JSON request body. + :type body: GascanTaskWrite + :return: A fully constructed TaskWrite object. + :rtype: TaskWrite + """ + return _assemble_gascan_payload( + task_name=body.task_name, + hostname=body.hostname, + playbook=body.playbook, + limit=body.limit, + override=body.override, + alert_on_fail=body.alert_on_fail, + ) + + +async def get_gascan_task( + task_name: str, + tasks_api: TaskAPI, +) -> Task: + """Fetch and validate a task for the Gascan plugin. + + :param task_name: The name of the task to retrieve. + :type task_name: str + :param tasks_api: The TaskAPI instance used to query the task service. + :type tasks_api: TaskAPI + :return: The retrieved task. + :rtype: Task + :raises HTTPNotFoundException: If the task is not found or not owned by Gascan. + """ + return await get_task_by_name(tasks_api, task_name, TaskOwner.GASCAN) + + +GascanTask = Annotated[Task, Depends(get_gascan_task)] + + +def _extract_latest_task_status( + histories: Iterable[dict[str, Any]], +) -> TaskHistoryStatusEnum | None: + """Return the latest known status from a task history payload.""" + for history in histories: + if (status := history.get("status")) is not None: + return TaskHistoryStatusEnum(status) + return None + + +async def get_gascan_task_status( + task_name: str, + tasks_api: TaskAPI, +) -> TaskHistoryStatusEnum | None: + """Fetch the latest execution status for a gascan task. + + :param task_name: The name of the gascan task. + :type task_name: str + :param tasks_api: The TaskAPI instance used to query task history. + :type tasks_api: TaskAPI + :return: The latest known task status, or ``None`` if no history exists. + :rtype: TaskHistoryStatusEnum | None + """ + response = await tasks_api.get(f"/{task_name}/history/") + return _extract_latest_task_status(response["items"]) + + +def build_gascan_api_task_response( + task: Task, + status: TaskHistoryStatusEnum | None = None, +) -> GascanTaskResponse: + """Build a gascan task response object for the JSON API. + + :param task: The gascan task retrieved from the Tasks API. + :type task: Task + :param status: The latest known execution status for the task. + :type status: TaskHistoryStatusEnum | None + :return: A validated gascan task API response object. + :rtype: GascanTaskResponse + """ + return GascanTaskResponse( + **task.model_dump(), + status=status, + ) + + +async def get_gascan_api_task_responses( + tasks_api: TaskAPI, + status: TaskHistoryStatusEnum | None = None, +) -> list[GascanTaskResponse]: + """Retrieve gascan task responses for the JSON API. + + :param tasks_api: The TaskAPI instance used to query gascan tasks. + :type tasks_api: TaskAPI + :param status: Optional latest-history status filter. + :type status: TaskHistoryStatusEnum | None + :return: The gascan task responses matching the requested filters. + :rtype: list[GascanTaskResponse] + """ + params = {"owner": TaskOwner.GASCAN.value} + response = await tasks_api.get("/", params=params) + tasks = [Task.model_validate(task) for task in response["items"]] + task_status_pairs = [ + (task, await get_gascan_task_status(task.name, tasks_api)) for task in tasks + ] + + return [ + build_gascan_api_task_response(task, status=task_status) + for task, task_status in task_status_pairs + if status is None or task_status == status + ] + + +def get_gascan_task_info(task: dict[str, Any]) -> dict[str, Any]: + """Extract relevant information from a task for the Gascan plugin. + + :param task: The task data retrieved from the Tasks API. + :type task: dict[str, Any] + :return: A dictionary containing hostname and playbook information. + :rtype: dict[str, Any] + """ + data = task["data"] + meta = data["meta"] + form_values = parse_gascan_task_args(meta) + return { + "hostname": meta["target"], + "playbook": form_values.get("playbook", ""), + "created_by": task.get("created_by"), + "last_updated_by": task.get("last_updated_by"), + } + + +def parse_single_gascan_arg(arg: str, form_values: dict[str, Any]) -> None: + """Parse a single gascan argument and update form values. + + :param arg: The argument to parse. + :type arg: str + :param form_values: The form values dictionary to update. + :type form_values: dict[str, Any] + """ + arg_mappings = { + "--playbook=": "playbook", + "--limit=": "limit", + "--override=": "override", + } + + for arg_pattern, field_name in arg_mappings.items(): + if arg.startswith(arg_pattern): + form_values[field_name] = arg.split("=", 1)[1] + return + + +def parse_gascan_task_args(meta: dict[str, Any]) -> dict[str, Any]: + """Parse existing task arguments back into form field values. + + :param meta: The task meta containing the args string. + :type meta: dict[str, Any] + :return: A dictionary containing form field values. + :rtype: dict[str, Any] + """ + form_values = { + "playbook": "", + "limit": "", + "override": "", + } + + args_string = meta.get("args", "") + for arg in shlex.split(args_string): + parse_single_gascan_arg(arg, form_values) + + return form_values + + +async def get_gascan_index_context( + inventory_api: InventoryAPI, + tasks_api: TaskAPI, + context: DefaultContext, + executor_hosts_ctx: ExecutorHostsCtx, +) -> dict[str, Any]: + """Assemble the context for the Gascan plugin index view. + + :param inventory_api: The Inventory API client (unused for service lookup). + :type inventory_api: InventoryAPI + :param tasks_api: The TaskAPI client for fetching task data. + :type tasks_api: TaskAPI + :param context: The default context to be updated. + :type context: DefaultContext + :param executor_hosts_ctx: The executor hosts context for gascan tasks. + :type executor_hosts_ctx: ExecutorHostsCtx + :return: An updated context dictionary containing gascan-related data. + :rtype: dict[str, Any] + """ + return await get_tasks_context( + inventory_api, + tasks_api, + get_gascan_task_info, + executor_hosts_ctx, + context, + TaskOwner.GASCAN, + alert_on_fail_default=True, + ) diff --git a/app/sep/plugins/gascan/models.py b/app/sep/plugins/gascan/models.py new file mode 100644 index 0000000000..825df23fd7 --- /dev/null +++ b/app/sep/plugins/gascan/models.py @@ -0,0 +1,124 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Define models for the Gascan plugin.""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + +from app.core.utils.fields import NonEmptyStr +from app.tasks.models import TaskBackendEnum, TaskHistoryStatusEnum, TaskOwner + + +class GascanCreate(BaseModel): + """Represent a Gascan creation form. + + :param task_name: The name of the task to be created. + :type task_name: NonEmptyStr + :param hostname: The target hostname for the task execution. + :type hostname: NonEmptyStr + :param playbook: The playbook to run. + :type playbook: NonEmptyStr + :param limit: Optional limit expression for the playbook run. + :type limit: str + :param override: Optional override values for the playbook run. + :type override: str + :param alert_on_fail: If True, send an alert if the task fails. + :type alert_on_fail: bool + """ + + task_name: NonEmptyStr + hostname: NonEmptyStr + playbook: NonEmptyStr + limit: str = "" + override: str = "" + alert_on_fail: bool = False + + +class GascanTaskWrite(BaseModel): + """Represent a JSON request body for creating a gascan task. + + :param task_name: The name of the task to be created. + :type task_name: NonEmptyStr + :param hostname: The target hostname for the task execution. + :type hostname: NonEmptyStr + :param playbook: The playbook to run. + :type playbook: NonEmptyStr + :param limit: Optional limit expression for the playbook run. + :type limit: str + :param override: Optional override values for the playbook run. + :type override: str + :param alert_on_fail: If True, send an alert if the task fails. + :type alert_on_fail: bool + """ + + task_name: NonEmptyStr + hostname: NonEmptyStr + playbook: NonEmptyStr + limit: str = "" + override: str = "" + alert_on_fail: bool = False + + +class GascanTaskBase(BaseModel): + """Define the common fields shared across gascan task API responses. + + :param name: The name of the gascan task. + :type name: str + :param owner: The entity or user that owns the task. + :type owner: TaskOwner + :param status: The current execution status of the task. + :type status: TaskHistoryStatusEnum | None + """ + + name: str + owner: TaskOwner + status: TaskHistoryStatusEnum | None = None + + +class GascanTaskResponse(GascanTaskBase): + """Represent a gascan task API response. + + :param id: The unique identifier for the gascan task. + :type id: int | None + :param backend: The backend worker/engine executing the task. + :type backend: TaskBackendEnum + :param data: The raw configuration and parameters used for execution. + :type data: dict[str, Any] + :param protected: Whether the task is protected from deletion or modification. + :type protected: bool + :param alert_on_fail: If True, notifications are sent upon task failure. + :type alert_on_fail: bool + :param created_at: The timestamp when the task was first created. + :type created_at: datetime | None + :param updated_at: The timestamp of the last modification to the task record. + :type updated_at: datetime | None + :param created_by: The user who initiated the task. + :type created_by: str | None + :param last_updated_by: The user who last modified the task record. + :type last_updated_by: str | None + """ + + id: int | None = None + backend: TaskBackendEnum + data: dict[str, Any] + protected: bool + alert_on_fail: bool + created_at: datetime | None = None + updated_at: datetime | None = None + created_by: str | None = None + last_updated_by: str | None = None diff --git a/app/sep/plugins/gascan/routes.py b/app/sep/plugins/gascan/routes.py new file mode 100644 index 0000000000..1aeae1f8ae --- /dev/null +++ b/app/sep/plugins/gascan/routes.py @@ -0,0 +1,196 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Define routes for the gascan plugin.""" + +import logging +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Form, Request, status +from fastapi.responses import HTMLResponse, RedirectResponse +from pydantic import FutureDatetime + +from app.core.alerts.config import alert_settings +from app.sep.config import sep_settings +from app.sep.deps import ( + DefaultContext, + ExecutorHostsCtx, + get_chainable_tasks, + HasNoConflictedRunningTasks, + IsAuthenticated, + IsCsrfValidated, + TaskAPI, +) +from app.sep.plugins.gascan.deps import ( + GascanGeneratedTask, + GascanTask, + get_gascan_index_context, + parse_gascan_task_args, +) +from app.tasks.models import TaskHistoryStatusEnum + +logger = logging.getLogger(__name__) +router = APIRouter() +templates = sep_settings.TEMPLATES + + +@router.get("/", dependencies=[IsAuthenticated], response_class=HTMLResponse) +async def gascan_index( + request: Request, + context: Annotated[dict[str, Any], Depends(get_gascan_index_context)], +) -> HTMLResponse: + """Homepage of the gascan plugin.""" + context["csrf_token"] = request.state.csrf_token + return templates.TemplateResponse( + request=request, + name="gascan/index.html.j2", + context=context, + ) + + +@router.post( + "/", dependencies=[IsAuthenticated, IsCsrfValidated], response_class=HTMLResponse +) +async def gascan_create( + request: Request, + task: GascanGeneratedTask, + task_api: TaskAPI, +) -> RedirectResponse: + """Create a gascan task.""" + logger.debug("Create gascan task: %s", task) + await task_api.post( + "/", + json=task.model_dump(), + ) + + task_path = request.url_for("gascan_detail", task_name=task.name) + return RedirectResponse( + task_path, + status_code=status.HTTP_303_SEE_OTHER, + ) + + +@router.get("/{task_name}", dependencies=[IsAuthenticated], response_class=HTMLResponse) +async def gascan_detail( + task: GascanTask, + request: Request, + context: DefaultContext, + tasks_api: TaskAPI, + executor_hosts_ctx: ExecutorHostsCtx, +) -> HTMLResponse: + """Retrieve a gascan task.""" + data = task.data + meta = data["meta"] + decoded_entities = task.anonymized_entities + task_data = { + "name": task.name, + "created_at": task.created_at, + "updated_at": task.updated_at, + "created_by": task.created_by, + "last_updated_by": task.last_updated_by, + "hostname": meta["target"], + "cmd": f"{meta['command']} {meta['args']}", + "meta": meta, + "entities": {entity.name: entity.value for entity in decoded_entities}, + "delete_url": request.url_for("gascan_delete", task_name=task.name), + "alert_on_fail": task.alert_on_fail, + "is_edit_enabled": not task.protected, + } + task_data.update(parse_gascan_task_args(meta)) + + context["task"] = task_data + context["executor_hosts"] = executor_hosts_ctx.with_host( + task_data["hostname"] + ).as_template_list() + + response = await tasks_api.get(f"/{task.name}/history/") + context["history"] = response["items"] + response = await tasks_api.get( + f"/{task.name}/history/", params={"status": TaskHistoryStatusEnum.RUNNING} + ) + context["running_tasks"] = response["items"] + context["stats"] = await tasks_api.get(f"/stats/{task.name}") + context["alert_on_fail_default"] = task_data["alert_on_fail"] + context["alert_on_fail_available"] = bool(alert_settings.PROVIDERS) + context["chainable_tasks"] = await get_chainable_tasks( + tasks_api, task.owner, meta["target"], task.name + ) + return templates.TemplateResponse( + request=request, + name="gascan/details.html.j2", + context=context, + ) + + +@router.post( + "/{task_name}", + dependencies=[IsAuthenticated, IsCsrfValidated, HasNoConflictedRunningTasks], + response_class=RedirectResponse, +) +async def gascan_execute( + task: GascanTask, + tasks_api: TaskAPI, + eta: Annotated[FutureDatetime | None, Form()] = None, + chain_task_names: Annotated[list[str] | None, Form()] = None, + chain_on_failure: Annotated[bool | None, Form()] = None, +) -> RedirectResponse: + """Execute a gascan task.""" + await tasks_api.post( + f"/execute/{task.name}", + json={ + "eta": eta, + "chain_task_names": chain_task_names, + "chain_on_failure": chain_on_failure, + }, + ) + return RedirectResponse("/gascan", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post( + "/{task_name}/update", + dependencies=[IsAuthenticated, IsCsrfValidated], + response_class=RedirectResponse, +) +async def gascan_update( + request: Request, + task_name: str, + updated_task: GascanGeneratedTask, + tasks_api: TaskAPI, +) -> RedirectResponse: + """Update a gascan task.""" + logger.debug("Updating gascan task: %s", updated_task) + await tasks_api.put( + f"/{task_name}", + json=updated_task.model_dump(), + ) + + return RedirectResponse( + request.url_for("gascan_detail", task_name=updated_task.name), + status_code=status.HTTP_303_SEE_OTHER, + ) + + +@router.post( + "/{task_name}/delete", + dependencies=[IsAuthenticated, IsCsrfValidated], + response_class=RedirectResponse, +) +async def gascan_delete( + task: GascanTask, + tasks_api: TaskAPI, +) -> RedirectResponse: + """Delete a gascan task.""" + await tasks_api.delete(f"/{task.name}") + return RedirectResponse("/gascan", status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/sep/plugins/gascan/schema.py b/app/sep/plugins/gascan/schema.py new file mode 100644 index 0000000000..48e47926da --- /dev/null +++ b/app/sep/plugins/gascan/schema.py @@ -0,0 +1,80 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Define the PluginSchema for the Gascan plugin.""" + +from app.sep.plugins.framework.schema import ( + Capabilities, + Column, + ColumnFormat, + FormSection, + HostField, + ListView, + PluginSchema, + StringField, +) + +gascan_schema = PluginSchema( + name="gascan", + display_name="Gascan Management", + description="Run gascan playbooks on executor hosts.", + forms=[ + FormSection( + title="Task", + fields=[ + StringField( + name="task_name", + label="Task Name", + required=True, + ), + HostField( + name="hostname", + label="Executor Host", + required=True, + ), + ], + ), + FormSection( + title="Gascan", + fields=[ + StringField( + name="playbook", + label="Playbook", + required=True, + description="Playbook to execute", + ), + StringField( + name="limit", + label="Limit", + description="Optional limit expression", + ), + StringField( + name="override", + label="Override", + description="Optional override values", + ), + ], + ), + ], + capabilities=Capabilities(chaining=True, alert_on_fail=True, scheduling=True), + list_view=ListView( + columns=[ + Column(key="name", label="Name", sortable=True), + Column(key="status", label="Status", format=ColumnFormat.STATUS), + Column(key="created_at", label="Created", format=ColumnFormat.RELATIVE), + Column(key="created_by", label="Created By"), + ], + ), +) diff --git a/app/tasks/models.py b/app/tasks/models.py index 55ac66625e..fced273cba 100644 --- a/app/tasks/models.py +++ b/app/tasks/models.py @@ -202,6 +202,8 @@ class TaskOwner(EnumFieldMixin, StrEnum): :vartype RESTORES: str :cvar CHECKSUMS: Value for checksum tasks. :vartype CHECKSUMS: str + :cvar GASCAN: Value for gascan management tasks. + :vartype GASCAN: str """ ANY = "ANY" @@ -210,6 +212,7 @@ class TaskOwner(EnumFieldMixin, StrEnum): BACKUPS = "BACKUPS" RESTORES = "RESTORES" CHECKSUMS = "CHECKSUMS" + GASCAN = "GASCAN" BACKUP_MONGO = "BACKUP_MONGO" RESTORE_MONGO = "RESTORE_MONGO" BACKUP_PG = "BACKUP_PG" diff --git a/frontend/package.json b/frontend/package.json index 0d4f26e86c..502bdb74c0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "build-storybook": "pnpm --filter @sep/framework build-storybook" }, "devDependencies": { + "nanoid": "3.3.12", "oxfmt": "^0.49.0", "oxlint": "^1.64.0" }, diff --git a/frontend/packages/plugins/gascan/package.json b/frontend/packages/plugins/gascan/package.json new file mode 100644 index 0000000000..8d3cc1b418 --- /dev/null +++ b/frontend/packages/plugins/gascan/package.json @@ -0,0 +1,23 @@ +{ + "name": "@sep/plugin-gascan", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@sep/api": "workspace:*", + "@sep/framework": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "typescript": "~6.0.3" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-router-dom": "^7.0.0" + } +} diff --git a/frontend/packages/plugins/gascan/src/GascanPlugin.tsx b/frontend/packages/plugins/gascan/src/GascanPlugin.tsx new file mode 100644 index 0000000000..16c768dd4e --- /dev/null +++ b/frontend/packages/plugins/gascan/src/GascanPlugin.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (C) 2026 Percona LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { SchemaDrivenPlugin } from '@sep/framework'; +import { PLUGIN_NAME } from './routes'; + +export function GascanPlugin() { + return ; +} diff --git a/frontend/packages/plugins/gascan/src/index.ts b/frontend/packages/plugins/gascan/src/index.ts new file mode 100644 index 0000000000..943463e8ee --- /dev/null +++ b/frontend/packages/plugins/gascan/src/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (C) 2026 Percona LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export { GascanPlugin } from './GascanPlugin'; +export { gascanRoute, PLUGIN_NAME, PLUGIN_BASE_PATH } from './routes'; diff --git a/frontend/packages/plugins/gascan/src/routes.tsx b/frontend/packages/plugins/gascan/src/routes.tsx new file mode 100644 index 0000000000..9fd7d9df61 --- /dev/null +++ b/frontend/packages/plugins/gascan/src/routes.tsx @@ -0,0 +1,27 @@ +/** + * Copyright (C) 2026 Percona LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { RouteObject } from 'react-router-dom'; +import { GascanPlugin } from './GascanPlugin'; + +export const PLUGIN_NAME = 'gascan'; +export const PLUGIN_BASE_PATH = '/plugins/gascan'; + +export const gascanRoute: RouteObject = { + path: 'plugins/gascan/*', + element: , +}; diff --git a/frontend/packages/plugins/gascan/tsconfig.json b/frontend/packages/plugins/gascan/tsconfig.json new file mode 100644 index 0000000000..3b79150329 --- /dev/null +++ b/frontend/packages/plugins/gascan/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["./src"] +} diff --git a/frontend/packages/shell/package.json b/frontend/packages/shell/package.json index 3be308521a..2c1bd1e4ed 100644 --- a/frontend/packages/shell/package.json +++ b/frontend/packages/shell/package.json @@ -28,6 +28,7 @@ "@sep/plugin-alert-troubleshooting": "workspace:*", "@sep/plugin-atw": "workspace:*", "@sep/plugin-checksums": "workspace:*", + "@sep/plugin-gascan": "workspace:*", "@sep/plugin-dipper": "workspace:*", "@sep/plugins-snippets": "workspace:*", "@sep/shared": "workspace:*", diff --git a/frontend/packages/shell/src/contexts/navigation.tsx b/frontend/packages/shell/src/contexts/navigation.tsx index 70efd2e18f..deb701d512 100644 --- a/frontend/packages/shell/src/contexts/navigation.tsx +++ b/frontend/packages/shell/src/contexts/navigation.tsx @@ -31,6 +31,7 @@ import ArchiveIcon from '@mui/icons-material/Archive'; import BarChartIcon from '@mui/icons-material/BarChart'; import SupportAgentIcon from '@mui/icons-material/SupportAgent'; import ScienceIcon from '@mui/icons-material/Science'; +import BuildIcon from '@mui/icons-material/Build'; import { MySqlIcon, MongoIcon, PostgreSqlIcon } from '@percona/percona-ui'; import type { SvgIconComponent } from '@mui/icons-material'; import type { SvgIconProps } from '@mui/material'; @@ -64,6 +65,7 @@ const defaultNavItems: NavItem[] = [ children: [{ title: 'Alters', icon: TableChartIcon, to: '/schema-change/alters' }], }, { title: 'Checksums', icon: CheckCircleIcon, to: '/plugins/checksums' }, + { title: 'Gascan', icon: BuildIcon, to: '/plugins/gascan' }, { title: 'Backups', icon: BackupIcon, diff --git a/frontend/packages/shell/src/router.tsx b/frontend/packages/shell/src/router.tsx index c19e1e73e8..b985fdb1d2 100644 --- a/frontend/packages/shell/src/router.tsx +++ b/frontend/packages/shell/src/router.tsx @@ -31,6 +31,9 @@ const NotFoundPage = lazy(() => import('./pages/NotFoundPage')); const ChecksumsPlugin = lazy(() => import('@sep/plugin-checksums').then((m) => ({ default: m.ChecksumsPlugin })), ); +const GascanPlugin = lazy(() => + import('@sep/plugin-gascan').then((m) => ({ default: m.GascanPlugin })), +); const AtwPlugin = lazy(() => import('@sep/plugin-atw').then((m) => ({ default: m.AtwPlugin }))); const DipperPlugin = lazy(() => import('@sep/plugin-dipper').then((m) => ({ default: m.DipperPlugin })), @@ -80,6 +83,7 @@ export const router = createBrowserRouter([ // Checksums — schema-driven plugin (handles its own sub-routes) { path: 'plugins/checksums/*', element: }, { path: 'schema-change/checksums/*', element: }, + { path: 'plugins/gascan/*', element: }, { path: 'schema-change/inventory/*', element: }, { path: 'backups/mysql', element: }, { path: 'backups/mongodb', element: }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 9776ad93b0..4f742f6cd0 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + nanoid: + specifier: 3.3.12 + version: 3.3.12 oxfmt: specifier: ^0.49.0 version: 0.49.0 @@ -363,6 +366,28 @@ importers: specifier: ^4.0.0 version: 4.1.5(@types/node@22.19.17)(jsdom@29.1.1)(msw@2.14.4(@types/node@22.19.17)(typescript@6.0.3))(vite@8.0.12(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)) + packages/plugins/gascan: + dependencies: + '@sep/api': + specifier: workspace:* + version: link:../../api + '@sep/framework': + specifier: workspace:* + version: link:../../framework + react: + specifier: ^19.0.0 + version: 19.2.6 + react-router-dom: + specifier: ^7.0.0 + version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + devDependencies: + '@types/react': + specifier: ^19.1.0 + version: 19.2.14 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + packages/plugins/inventory: dependencies: '@dagrejs/dagre': @@ -547,6 +572,9 @@ importers: '@sep/plugin-dipper': specifier: workspace:* version: link:../plugins/dipper + '@sep/plugin-gascan': + specifier: workspace:* + version: link:../plugins/gascan '@sep/plugins-snippets': specifier: workspace:* version: link:../plugins/snippets diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 8c726d1f11..d76b91d0e9 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -9,3 +9,9 @@ minimumReleaseAge: 10080 # Activates on pnpm v11+ (silently ignored on 10.x). Keeps fail-closed intent # discoverable once the workspace bumps the pnpm pin. minimumReleaseAgeIgnoreMissingTime: false +# Dev-tooling releases frequently; exclude from the 7-day cooldown (see oxc-project). +minimumReleaseAgeExclude: + - '@oxlint/*' + - oxlint + - '@oxfmt/*' + - oxfmt diff --git a/settings.yaml b/settings.yaml index e5e972ea2a..2dd063c241 100644 --- a/settings.yaml +++ b/settings.yaml @@ -83,6 +83,10 @@ default: MODULE_NAME: checksums URI_PATH: /checksums CSS_CLASS: checksums + - NAME: Gascan + MODULE_NAME: gascan + URI_PATH: /gascan + CSS_CLASS: gascan - NAME: MongoDB Backups MODULE_NAME: backup_mongo URI_PATH: /backup_mongo diff --git a/static/css/base.css b/static/css/base.css index e0347682a2..08ebfd0fd4 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -1250,6 +1250,10 @@ button.list-item:focus-visible { content: "new_releases"; } +.list-item.gascan .icon::before { + content: "build"; +} + .list-item.backup_mongo .icon::before { content: "nature"; } diff --git a/templates/gascan/details.html.j2 b/templates/gascan/details.html.j2 new file mode 100644 index 0000000000..4dfe287cd2 --- /dev/null +++ b/templates/gascan/details.html.j2 @@ -0,0 +1,163 @@ +{# +Copyright (C) 2026 Percona LLC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +#} +{% extends "base.html.j2" %} +{% block title -%} + Gascan - {{ task.name }} +{%- endblock title %} +{% block style %} + + + {% if history or running_tasks %} + + {% endif %} + +{% endblock style %} +{% block behaviours_optimized_extra %} + + + {% if history or running_tasks %} + + {% endif %} + +{% endblock behaviours_optimized_extra %} +{% block main %} +

Overview

+ + + + + + + + + + + + + + + + + + + + + + + + + +
HostnamePlaybookLimitOverrideCreated atUpdated atCreated byLast Updated by
{{ task.hostname }}{{ task.playbook }}{{ task.limit }}{{ task.override }}{{ task.created_at }}{{ task.updated_at }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}
+ {% include "tasks/partials/run-python-form.html.j2" %} + {% include "gascan/partials/edit-form.html.j2" %} + {% if running_tasks %} +
+

Running

+ {% include "tasks/partials/running-tasks.html.j2" %} +
+ {% endif %} +
+

Execution

+
+
Command
+
{{ task.cmd }}
+
Parameters
+ + + {% for k, v in task.meta.items() %} + {% if not k.startswith('_') %} + + + + + {% endif %} + {% endfor %} + +
{{ k }}{{ v }}
+
+
+
+
+

Stats

+ {% if stats and stats.total %} + + + + + + + + + + + + + + + + + + + + + + +
Aggregated statistics for {{ task.name }}
ExecutionsSucceededFailedLast Finished + Avg Duration (s) + + Last Duration (s) +
{{ stats.total }}{{ stats.status.pass }}{{ stats.status.fail }}{{ stats.last_finished_at }}{{ stats.duration.average_seconds or 0|round(3) }}{{ stats.duration.last_seconds or 0|round(3) }}
+ {% else %} +

No data

+ {% endif %} +
+
+
+ {% set pending_tasks = history|selectattr("status", "equalto", "pending")|list %} + {% if pending_tasks %} +
+

Pending

+ {% include "tasks/partials/pending-tasks.html.j2" %} +
+
+
+ {% endif %} +
+

History

+ {% set history_tasks = history %} + {% include "tasks/partials/completed-tasks.html.j2" %} +
+{% endblock main %} diff --git a/templates/gascan/index.html.j2 b/templates/gascan/index.html.j2 new file mode 100644 index 0000000000..b3541873b9 --- /dev/null +++ b/templates/gascan/index.html.j2 @@ -0,0 +1,114 @@ +{# +Copyright (C) 2026 Percona LLC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +#} +{% extends "base.html.j2" %} +{% block title -%} + Gascan Management +{%- endblock title %} +{% block style %} + + {% if history_tasks or running_tasks %} + + {% endif %} + {% if tasks %} + + + + + {% endif %} +{% endblock style %} +{% block behaviours_optimized_extra %} + + {% if history_tasks or running_tasks %} + + {% endif %} + {% if tasks %} + + + + + + + + {% endif %} +{% endblock behaviours_optimized_extra %} +{% block main %} +

Gascan Management

+

Run gascan playbooks on executor hosts.

+ {% if not tasks and not history_tasks %} + {% set modal_required = False %} + {% if executor_hosts|length < 1 %} +

Unable to find suitable executor hosts.

+ {% else %} + + {% endif %} + {% else %} + {% set modal_required = True %} + {% if running_tasks %} +
+

Running

+ {% include "tasks/partials/running-tasks.html.j2" %} +
+ {% endif %} +
+

Saved

+ {% include "gascan/partials/saved-tasks.html.j2" %} +

Periodic Tasks

+ {%- set detail_route = "gascan_detail" %} + {% include "tasks/partials/scheduled-tasks.html.j2" %} +

Configure a gascan task

+ {% include "gascan/partials/create-form.html.j2" %} +
+
+
+
+

Pending

+ {% include "tasks/partials/pending-tasks.html.j2" %} +
+
+
+
+

History

+ {% include "tasks/partials/completed-tasks.html.j2" %} +
+ {% endif %} + {% if executor_hosts|length >= 1 %} + {% include "gascan/partials/create-form.html.j2" %} + {% endif %} +{% endblock main %} diff --git a/templates/gascan/partials/create-form.html.j2 b/templates/gascan/partials/create-form.html.j2 new file mode 100644 index 0000000000..9be5b8118b --- /dev/null +++ b/templates/gascan/partials/create-form.html.j2 @@ -0,0 +1,121 @@ +{# +Copyright (C) 2026 Percona LLC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +#} + +
+ +
+

Configure a gascan task

+
+
+ +
+ Task +
+ {% set hostname_title = "Select the executor host for task execution." %} + +
+
+ + +
+
+
+ Gascan +
+ + +
+
+ + +
+
+ + +
+
+ {% if alert_on_fail_available %} + + {% endif %} +
+
+ + +
+
+
diff --git a/templates/gascan/partials/edit-form.html.j2 b/templates/gascan/partials/edit-form.html.j2 new file mode 100644 index 0000000000..fce0a16fbd --- /dev/null +++ b/templates/gascan/partials/edit-form.html.j2 @@ -0,0 +1,134 @@ +{# +Copyright (C) 2026 Percona LLC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +#} + +
+ +
+

Edit task

+
+
+

+ You are about to edit the task {{ task.name }}. +

+ +
+ Task +
+ {% set hostname_title = "Select the executor host for task execution." %} + +
+
+ + +
+
+
+ Gascan +
+ + +
+
+ + +
+
+ + +
+
+ {% if alert_on_fail_available %} + + {% endif %} +
+
+ + +
+
+
+{% if task.is_edit_enabled %} + +{% endif %} diff --git a/templates/gascan/partials/saved-tasks.html.j2 b/templates/gascan/partials/saved-tasks.html.j2 new file mode 100644 index 0000000000..7137dbffc7 --- /dev/null +++ b/templates/gascan/partials/saved-tasks.html.j2 @@ -0,0 +1,49 @@ +{# +Copyright (C) 2026 Percona LLC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +#} +{% if tasks %} + + + + + + + + + + + + + {% for task in tasks %} + + + + + + + + + {% endfor %} + +
NameHostPlaybookCreated byLast Updated byActions
+ {{ task.name }} + {{ task.hostname }}{{ task.playbook }}{{ user_id_to_username.get(task["created_by"], task["created_by"]) or '' }}{{ user_id_to_username.get(task["last_updated_by"], task["last_updated_by"]) or '' }}{% include "tasks/partials/saved-tasks-actions-column.html.j2" %}
+ {% include "tasks/partials/scheduled-execution-dialog.html.j2" %} +{% else %} +

No data

+{% endif %} +
+
diff --git a/tests/app/sep/plugins/gascan/__init__.py b/tests/app/sep/plugins/gascan/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/app/sep/plugins/gascan/conftest.py b/tests/app/sep/plugins/gascan/conftest.py new file mode 100644 index 0000000000..bb01d3a15b --- /dev/null +++ b/tests/app/sep/plugins/gascan/conftest.py @@ -0,0 +1,21 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Define test fixtures for gascan plugin route tests.""" + +from tests.app.sep.conftest import ( # noqa: F401 + mock_inventory_api_dep, + mock_task_api_dep, +) diff --git a/tests/app/sep/plugins/gascan/test_api_routes.py b/tests/app/sep/plugins/gascan/test_api_routes.py new file mode 100644 index 0000000000..4992451cc1 --- /dev/null +++ b/tests/app/sep/plugins/gascan/test_api_routes.py @@ -0,0 +1,153 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Tests for the gascan plugin JSON API routes under /api/plugins/gascan/.""" + +import shlex +from datetime import UTC, datetime +from unittest.mock import AsyncMock, call + +from fastapi import status + +from app.tasks.models import TaskBackendEnum, TaskHistoryStatusEnum, TaskOwner + + +def build_gascan_task(name: str = "gascan-task") -> dict: + """Build a fake gascan task payload for route tests.""" + return { + "id": 1, + "name": name, + "backend": TaskBackendEnum.PROXY, + "owner": TaskOwner.GASCAN, + "is_template": False, + "protected": False, + "alert_on_fail": False, + "data": { + "task": "run-command", + "meta": { + "command": "gascan", + "args": "--playbook=site.yml --limit=web", + "target": "host1", + }, + }, + "created_at": datetime.now(UTC), + "updated_at": None, + "created_by": "user@example.com", + "last_updated_by": "user@example.com", + } + + +def build_gascan_write_body(task_name: str = "gascan-task", **kwargs) -> dict: + """Build a valid GascanTaskWrite-compatible request body.""" + return { + "task_name": task_name, + "hostname": "host1", + "playbook": "site.yml", + **kwargs, + } + + +class TestGascanSchemaEndpoint: + """Tests for GET /api/plugins/gascan/schema.""" + + def test_gascan_schema_returns_plugin_metadata(self, test_client): + """Assert schema endpoint exposes gascan plugin name and fields.""" + response = test_client.get("/api/plugins/gascan/schema") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "gascan" + assert data["display_name"] == "Gascan Management" + field_names = { + field["name"] + for section in data["forms"] + for field in section["fields"] + } + assert {"task_name", "hostname", "playbook", "limit", "override"} <= field_names + + +class TestGascanListEndpoint: + """Tests for GET /api/plugins/gascan/.""" + + def test_gascan_list_returns_data(self, test_client, mock_task_api_dep): + """Assert list endpoint returns gascan tasks filtered by owner.""" + task = build_gascan_task() + mock_task_api_dep.get = AsyncMock( + side_effect=[ + {"items": [task]}, + {"items": []}, + ] + ) + + response = test_client.get("/api/plugins/gascan/") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()) == 1 + assert response.json()[0]["name"] == "gascan-task" + mock_task_api_dep.get.assert_any_call( + "/", + params={"owner": TaskOwner.GASCAN.value}, + ) + + +class TestGascanCreateEndpoint: + """Tests for POST /api/plugins/gascan/.""" + + def test_gascan_create_posts_assembled_payload(self, test_client, mock_task_api_dep): + """Assert create endpoint builds gascan command args correctly.""" + created = build_gascan_task("new-gascan") + mock_task_api_dep.post = AsyncMock(return_value=created) + + body = build_gascan_write_body( + task_name="new-gascan", + limit="db", + override="x=1", + ) + response = test_client.post("/api/plugins/gascan/", json=body) + assert response.status_code == status.HTTP_201_CREATED + + posted = mock_task_api_dep.post.await_args.kwargs["json"] + assert posted["owner"] == TaskOwner.GASCAN.value + args = shlex.split(posted["data"]["meta"]["args"]) + assert "--playbook=site.yml" in args + assert "--limit=db" in args + assert "--override=x=1" in args + + +class TestGascanDetailAndDeleteEndpoints: + """Tests for GET and DELETE /api/plugins/gascan/{task_name}.""" + + def test_gascan_detail_returns_task(self, test_client, mock_task_api_dep): + """Assert detail endpoint returns a single gascan task.""" + task = build_gascan_task("detail-task") + mock_task_api_dep.get = AsyncMock( + side_effect=[ + task, + {"items": [{"status": TaskHistoryStatusEnum.SUCCESS}]}, + ] + ) + + response = test_client.get("/api/plugins/gascan/detail-task") + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == "detail-task" + assert mock_task_api_dep.get.await_args_list[0] == call("/detail-task") + + def test_gascan_delete_removes_task(self, test_client, mock_task_api_dep): + """Assert delete endpoint removes the task via Tasks API.""" + task = build_gascan_task("delete-me") + mock_task_api_dep.get = AsyncMock(return_value=task) + mock_task_api_dep.delete = AsyncMock() + + response = test_client.delete("/api/plugins/gascan/delete-me") + assert response.status_code == status.HTTP_204_NO_CONTENT + mock_task_api_dep.delete.assert_awaited_once_with("/delete-me") diff --git a/tests/app/sep/plugins/gascan/test_deps.py b/tests/app/sep/plugins/gascan/test_deps.py new file mode 100644 index 0000000000..3a4ba84309 --- /dev/null +++ b/tests/app/sep/plugins/gascan/test_deps.py @@ -0,0 +1,97 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Define tests for the app.sep.plugins.gascan.deps module.""" + +import shlex + +import pytest + +from app.sep.plugins.gascan.deps import ( + _assemble_gascan_payload, + build_gascan_task, + parse_gascan_task_args, +) +from app.sep.plugins.gascan.models import GascanCreate, GascanTaskWrite +from app.tasks.models import TaskOwner, TaskWrite + + +class TestGascanPayloadAssembly: + """Tests for gascan task payload assembly.""" + + def test_assemble_gascan_payload_full_args(self): + """Assert all gascan CLI flags are included when values are provided.""" + payload = _assemble_gascan_payload( + task_name="gascan-test", + hostname="executor1", + playbook="backup.yml", + limit="web", + override="env=prod", + alert_on_fail=True, + ) + + assert payload.owner == TaskOwner.GASCAN + assert payload.name == "gascan-test" + assert payload.target == "executor1" + assert payload.alert_on_fail is True + meta = payload.data["meta"] + assert meta["command"] == "gascan" + assert meta["target"] == "executor1" + args = shlex.split(meta["args"]) + assert "--playbook=backup.yml" in args + assert "--limit=web" in args + assert "--override=env=prod" in args + + def test_assemble_gascan_payload_omits_empty_optional_args(self): + """Assert empty limit and override are not passed to the command.""" + payload = _assemble_gascan_payload( + task_name="minimal", + hostname="host1", + playbook="run.yml", + ) + args = shlex.split(payload.data["meta"]["args"]) + assert args == ["--playbook=run.yml"] + + @pytest.mark.asyncio + async def test_form_and_json_paths_produce_identical_task_write(self): + """Assert HTML form and JSON builders produce the same TaskWrite.""" + common_fields = { + "task_name": "parity-check", + "hostname": "host1", + "playbook": "deploy.yml", + "limit": "db", + "override": "dry_run=false", + "alert_on_fail": True, + } + form_input = GascanCreate(**common_fields) + json_input = GascanTaskWrite(**common_fields) + + from app.sep.plugins.gascan.deps import build_gascan_task_payload + + form_result = await build_gascan_task_payload(form_input) + json_result = build_gascan_task(json_input) + + assert isinstance(form_result, TaskWrite) + assert form_result.model_dump() == json_result.model_dump() + + def test_parse_gascan_task_args_round_trip(self): + """Assert parse_gascan_task_args recovers form values from meta args.""" + meta = { + "args": "--playbook=backup.yml --limit=web --override=env=prod", + } + parsed = parse_gascan_task_args(meta) + assert parsed["playbook"] == "backup.yml" + assert parsed["limit"] == "web" + assert parsed["override"] == "env=prod" diff --git a/tests/app/sep/plugins/gascan/test_routes.py b/tests/app/sep/plugins/gascan/test_routes.py new file mode 100644 index 0000000000..992be12c66 --- /dev/null +++ b/tests/app/sep/plugins/gascan/test_routes.py @@ -0,0 +1,51 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Define tests for the app.sep.plugins.gascan.routes module.""" + +from unittest.mock import AsyncMock + +from fastapi import status + +from app.sep.plugins.gascan.models import GascanCreate +from app.tasks.models import TaskOwner + + +def test_gascan_create_posts_task_with_gascan_owner( + test_client, + mock_task_api_dep, +): + """Test POST /gascan/ creates a task owned by GASCAN.""" + gascan_create = GascanCreate( + task_name="gascan_full_chain", + hostname="localhost", + playbook="site.yml", + limit="all", + override="foo=bar", + ) + mock_task_api_dep.post.return_value = AsyncMock() + + response = test_client.post( + "/gascan/", + data=gascan_create.model_dump(exclude_none=True), + follow_redirects=False, + ) + assert response.status_code == status.HTTP_303_SEE_OTHER + assert response.headers["location"].endswith("/gascan/gascan_full_chain") + mock_task_api_dep.post.assert_awaited_once() + posted = mock_task_api_dep.post.await_args.kwargs["json"] + assert posted["name"] == "gascan_full_chain" + assert posted["owner"] == TaskOwner.GASCAN.value + assert posted["data"]["meta"]["command"] == "gascan" From 57db06c20a9b2d2224ee25808fb06f1d97fa2327 Mon Sep 17 00:00:00 2001 From: Max Bubenick Date: Mon, 18 May 2026 13:27:49 -0300 Subject: [PATCH 2/3] run-pre-commit fixes --- app/sep/plugins/gascan/api_routes.py | 2 +- app/sep/plugins/gascan/deps.py | 6 +++++- frontend/packages/shell/package.json | 2 +- templates/gascan/details.html.j2 | 4 +++- templates/gascan/partials/edit-form.html.j2 | 5 +---- tests/app/sep/plugins/gascan/__init__.py | 14 ++++++++++++++ tests/app/sep/plugins/gascan/test_api_routes.py | 10 +++++----- 7 files changed, 30 insertions(+), 13 deletions(-) diff --git a/app/sep/plugins/gascan/api_routes.py b/app/sep/plugins/gascan/api_routes.py index 616d1fde73..917dbee018 100644 --- a/app/sep/plugins/gascan/api_routes.py +++ b/app/sep/plugins/gascan/api_routes.py @@ -25,6 +25,7 @@ from fastapi import status as http_status from app.sep.deps import TaskAPI +from app.sep.plugins.framework.api import schema_endpoint from app.sep.plugins.gascan.deps import ( build_gascan_api_task_response, build_gascan_task, @@ -35,7 +36,6 @@ ) from app.sep.plugins.gascan.models import GascanTaskResponse, GascanTaskWrite from app.sep.plugins.gascan.schema import gascan_schema -from app.sep.plugins.framework.api import schema_endpoint from app.tasks.models import Task, TaskHistoryStatusEnum logger = logging.getLogger(__name__) diff --git a/app/sep/plugins/gascan/deps.py b/app/sep/plugins/gascan/deps.py index 1c4623ee24..a2d9c6ec84 100644 --- a/app/sep/plugins/gascan/deps.py +++ b/app/sep/plugins/gascan/deps.py @@ -30,7 +30,11 @@ InventoryAPI, TaskAPI, ) -from app.sep.plugins.gascan.models import GascanCreate, GascanTaskResponse, GascanTaskWrite +from app.sep.plugins.gascan.models import ( + GascanCreate, + GascanTaskResponse, + GascanTaskWrite, +) from app.tasks.models import ( Task, TaskBackendEnum, diff --git a/frontend/packages/shell/package.json b/frontend/packages/shell/package.json index 2c1bd1e4ed..0a546e6425 100644 --- a/frontend/packages/shell/package.json +++ b/frontend/packages/shell/package.json @@ -28,8 +28,8 @@ "@sep/plugin-alert-troubleshooting": "workspace:*", "@sep/plugin-atw": "workspace:*", "@sep/plugin-checksums": "workspace:*", - "@sep/plugin-gascan": "workspace:*", "@sep/plugin-dipper": "workspace:*", + "@sep/plugin-gascan": "workspace:*", "@sep/plugins-snippets": "workspace:*", "@sep/shared": "workspace:*", "@tanstack/react-query": "^5.100.7", diff --git a/templates/gascan/details.html.j2 b/templates/gascan/details.html.j2 index 4dfe287cd2..7b793c903b 100644 --- a/templates/gascan/details.html.j2 +++ b/templates/gascan/details.html.j2 @@ -101,7 +101,9 @@ along with this program. If not, see . {% if not k.startswith('_') %} {{ k }} - {{ v }} + + {{ v }} + {% endif %} {% endfor %} diff --git a/templates/gascan/partials/edit-form.html.j2 b/templates/gascan/partials/edit-form.html.j2 index fce0a16fbd..6df34f69d7 100644 --- a/templates/gascan/partials/edit-form.html.j2 +++ b/templates/gascan/partials/edit-form.html.j2 @@ -100,10 +100,7 @@ along with this program. If not, see .
- +
{% if alert_on_fail_available %} diff --git a/tests/app/sep/plugins/gascan/__init__.py b/tests/app/sep/plugins/gascan/__init__.py index e69de29bb2..bc39b9ba8a 100644 --- a/tests/app/sep/plugins/gascan/__init__.py +++ b/tests/app/sep/plugins/gascan/__init__.py @@ -0,0 +1,14 @@ +# Copyright (C) 2026 Percona LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . diff --git a/tests/app/sep/plugins/gascan/test_api_routes.py b/tests/app/sep/plugins/gascan/test_api_routes.py index 4992451cc1..ced0b72527 100644 --- a/tests/app/sep/plugins/gascan/test_api_routes.py +++ b/tests/app/sep/plugins/gascan/test_api_routes.py @@ -16,7 +16,7 @@ """Tests for the gascan plugin JSON API routes under /api/plugins/gascan/.""" import shlex -from datetime import UTC, datetime +from datetime import datetime, UTC from unittest.mock import AsyncMock, call from fastapi import status @@ -70,9 +70,7 @@ def test_gascan_schema_returns_plugin_metadata(self, test_client): assert data["name"] == "gascan" assert data["display_name"] == "Gascan Management" field_names = { - field["name"] - for section in data["forms"] - for field in section["fields"] + field["name"] for section in data["forms"] for field in section["fields"] } assert {"task_name", "hostname", "playbook", "limit", "override"} <= field_names @@ -103,7 +101,9 @@ def test_gascan_list_returns_data(self, test_client, mock_task_api_dep): class TestGascanCreateEndpoint: """Tests for POST /api/plugins/gascan/.""" - def test_gascan_create_posts_assembled_payload(self, test_client, mock_task_api_dep): + def test_gascan_create_posts_assembled_payload( + self, test_client, mock_task_api_dep + ): """Assert create endpoint builds gascan command args correctly.""" created = build_gascan_task("new-gascan") mock_task_api_dep.post = AsyncMock(return_value=created) From 0601171875df1151630cf2f2bf41719fcce2edd1 Mon Sep 17 00:00:00 2001 From: Max Bubenick Date: Mon, 18 May 2026 13:38:27 -0300 Subject: [PATCH 3/3] arguments to use - instead of -- --- app/sep/plugins/gascan/deps.py | 12 ++++++------ tests/app/sep/plugins/gascan/test_api_routes.py | 8 ++++---- tests/app/sep/plugins/gascan/test_deps.py | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/app/sep/plugins/gascan/deps.py b/app/sep/plugins/gascan/deps.py index a2d9c6ec84..f1ba7d7e31 100644 --- a/app/sep/plugins/gascan/deps.py +++ b/app/sep/plugins/gascan/deps.py @@ -74,11 +74,11 @@ def _assemble_gascan_payload( """ args: list[str] = [] if playbook: - args.append(f"--playbook={playbook}") + args.append(f"-playbook={playbook}") if limit: - args.append(f"--limit={limit}") + args.append(f"-limit={limit}") if override: - args.append(f"--override={override}") + args.append(f"-override={override}") return TaskWrite( owner=TaskOwner.GASCAN, @@ -259,9 +259,9 @@ def parse_single_gascan_arg(arg: str, form_values: dict[str, Any]) -> None: :type form_values: dict[str, Any] """ arg_mappings = { - "--playbook=": "playbook", - "--limit=": "limit", - "--override=": "override", + "-playbook=": "playbook", + "-limit=": "limit", + "-override=": "override", } for arg_pattern, field_name in arg_mappings.items(): diff --git a/tests/app/sep/plugins/gascan/test_api_routes.py b/tests/app/sep/plugins/gascan/test_api_routes.py index ced0b72527..0cc17d9dd0 100644 --- a/tests/app/sep/plugins/gascan/test_api_routes.py +++ b/tests/app/sep/plugins/gascan/test_api_routes.py @@ -38,7 +38,7 @@ def build_gascan_task(name: str = "gascan-task") -> dict: "task": "run-command", "meta": { "command": "gascan", - "args": "--playbook=site.yml --limit=web", + "args": "-playbook=site.yml -limit=web", "target": "host1", }, }, @@ -119,9 +119,9 @@ def test_gascan_create_posts_assembled_payload( posted = mock_task_api_dep.post.await_args.kwargs["json"] assert posted["owner"] == TaskOwner.GASCAN.value args = shlex.split(posted["data"]["meta"]["args"]) - assert "--playbook=site.yml" in args - assert "--limit=db" in args - assert "--override=x=1" in args + assert "-playbook=site.yml" in args + assert "-limit=db" in args + assert "-override=x=1" in args class TestGascanDetailAndDeleteEndpoints: diff --git a/tests/app/sep/plugins/gascan/test_deps.py b/tests/app/sep/plugins/gascan/test_deps.py index 3a4ba84309..6cd14874c5 100644 --- a/tests/app/sep/plugins/gascan/test_deps.py +++ b/tests/app/sep/plugins/gascan/test_deps.py @@ -50,9 +50,9 @@ def test_assemble_gascan_payload_full_args(self): assert meta["command"] == "gascan" assert meta["target"] == "executor1" args = shlex.split(meta["args"]) - assert "--playbook=backup.yml" in args - assert "--limit=web" in args - assert "--override=env=prod" in args + assert "-playbook=backup.yml" in args + assert "-limit=web" in args + assert "-override=env=prod" in args def test_assemble_gascan_payload_omits_empty_optional_args(self): """Assert empty limit and override are not passed to the command.""" @@ -62,7 +62,7 @@ def test_assemble_gascan_payload_omits_empty_optional_args(self): playbook="run.yml", ) args = shlex.split(payload.data["meta"]["args"]) - assert args == ["--playbook=run.yml"] + assert args == ["-playbook=run.yml"] @pytest.mark.asyncio async def test_form_and_json_paths_produce_identical_task_write(self): @@ -89,7 +89,7 @@ async def test_form_and_json_paths_produce_identical_task_write(self): def test_parse_gascan_task_args_round_trip(self): """Assert parse_gascan_task_args recovers form values from meta args.""" meta = { - "args": "--playbook=backup.yml --limit=web --override=env=prod", + "args": "-playbook=backup.yml -limit=web -override=env=prod", } parsed = parse_gascan_task_args(meta) assert parsed["playbook"] == "backup.yml"