From 5592bad023fba43044297642e45ef17d3dbaf2ce Mon Sep 17 00:00:00 2001 From: grossmj Date: Sun, 11 Oct 2026 16:58:39 +0700 Subject: [PATCH 1/8] feat: add support for Python 3.15 --- .github/workflows/testing.yml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 4f834576e..f09857260 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v7 diff --git a/pyproject.toml b/pyproject.toml index 44df93d8d..a0cbb0650 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: CPython" ] From a27c1358404b7b3bc09fd093a38e5f6a8470f597 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:22:35 +0000 Subject: [PATCH 2/8] Update tiktoken requirement from >=0.8.0 to >=0.14.0 Updates the requirements on [tiktoken](https://github.com/openai/tiktoken) to permit the latest version. - [Release notes](https://github.com/openai/tiktoken/releases) - [Changelog](https://github.com/openai/tiktoken/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/tiktoken/compare/0.8.0...0.14.0) --- updated-dependencies: - dependency-name: tiktoken dependency-version: 0.14.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- ai-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-requirements.txt b/ai-requirements.txt index 3b178c407..d35a5a0bd 100644 --- a/ai-requirements.txt +++ b/ai-requirements.txt @@ -24,7 +24,7 @@ langchain-deepseek>=1.0.1 langchain-xai>=1.2.2 # Token Counting -tiktoken>=0.8.0 +tiktoken>=0.14.0 # LangSmith SDK langsmith>=0.7.7 From cf75aeeac0041beea3ddfacb7db412f776da56cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:22:45 +0000 Subject: [PATCH 3/8] Update langsmith requirement from >=0.7.7 to >=0.14.7 Updates the requirements on [langsmith](https://github.com/langchain-ai/langsmith-sdk) to permit the latest version. - [Release notes](https://github.com/langchain-ai/langsmith-sdk/releases) - [Commits](https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.7...v0.14.7) --- updated-dependencies: - dependency-name: langsmith dependency-version: 0.14.7 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- ai-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-requirements.txt b/ai-requirements.txt index 3b178c407..57b2ba41b 100644 --- a/ai-requirements.txt +++ b/ai-requirements.txt @@ -27,7 +27,7 @@ langchain-xai>=1.2.2 tiktoken>=0.8.0 # LangSmith SDK -langsmith>=0.7.7 +langsmith>=0.14.7 # Network Automation paramiko>=5.0.0 # install version >= 5.0.0 to address CVE-2026-44405 From c9a367a32f4b927937ab822e90a3be026cd37b85 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:22:50 +0000 Subject: [PATCH 4/8] Update pyjwt requirement from >=2.10.1 to >=2.15.1 Updates the requirements on [pyjwt](https://github.com/jpadilla/pyjwt) to permit the latest version. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/compare/2.10.1...2.15.1) --- updated-dependencies: - dependency-name: pyjwt dependency-version: 2.15.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- ai-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-requirements.txt b/ai-requirements.txt index 3b178c407..fcf830c72 100644 --- a/ai-requirements.txt +++ b/ai-requirements.txt @@ -41,7 +41,7 @@ nornir-salt>=0.23.4 python-dotenv>=1.2.1 # Authentication -PyJWT>=2.10.1 +PyJWT>=2.15.1 psycopg-pool>=3.1.0 # Skills Repository Management From fa3dd827f947c5ee7e637e9c7b0e2ff710b3f3dd Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 15 Oct 2026 22:44:24 +0800 Subject: [PATCH 5/8] chore: add pre-commit hook running ruff and mypy checks on staged files Mirrors the fast static checks from CI (justfile lint + type-check): ruff check, ruff format --check and mypy (gns3server package only, matching the files = ["gns3server"] scope in pyproject.toml). Only staged Python files are checked; pytest and pip check are left to CI. Enable with: just install-hooks --- .githooks/pre-commit | 54 ++++++++++++++++++++++++++++++++++++++++++++ justfile | 5 ++++ 2 files changed, 59 insertions(+) create mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..7e367091d --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Pre-commit checks mirroring the CI checks that are fast enough to run on +# every commit (justfile "lint" + "type-check"; pytest and pip check are not +# run here). Only staged Python files are checked. +# +# Install with: just install-hooks + +set -euo pipefail + +# Prefer the repo virtualenv, fall back to whatever python is on PATH +PYTHON="venv/bin/python" +if [ ! -x "$PYTHON" ]; then + PYTHON="python" +fi +if ! command -v "$PYTHON" >/dev/null 2>&1; then + echo "pre-commit: no python interpreter found, skipping checks" >&2 + exit 0 +fi + +# Staged .py files (deletions excluded) +mapfile -t staged < <(git diff --cached --name-only --diff-filter=d -- '*.py') +if [ ${#staged[@]} -eq 0 ]; then + exit 0 +fi + +status=0 + +echo "pre-commit: ruff check" +"$PYTHON" -m ruff check --exit-non-zero-on-fix "${staged[@]}" || status=1 + +echo "pre-commit: ruff format --check" +"$PYTHON" -m ruff format --check --diff "${staged[@]}" || status=1 + +# mypy is configured with files = ["gns3server"] in pyproject.toml, i.e. CI +# never type-checks tests: only pass staged files from that package. +mypy_targets=() +for f in "${staged[@]}"; do + case "$f" in + gns3server/*) mypy_targets+=("$f") ;; + esac +done + +if [ ${#mypy_targets[@]} -gt 0 ]; then + echo "pre-commit: mypy" + "$PYTHON" -m mypy "${mypy_targets[@]}" || status=1 +fi + +if [ "$status" -ne 0 ]; then + echo "" + echo "pre-commit: checks failed. Fix the issues above (try 'just format')," >&2 + echo "or bypass with: git commit --no-verify" >&2 +fi + +exit "$status" diff --git a/justfile b/justfile index b07fefc81..c9dd64787 100644 --- a/justfile +++ b/justfile @@ -9,6 +9,11 @@ install: python -m pip install --upgrade pip python -m pip install -e '.[ai-features,dev]' +# Install the git pre-commit hook (lint + type-check on staged files) +[group('dev')] +install-hooks: + git config core.hooksPath .githooks + # Apply ruff autofixes for the enforced rules [group('dev')] format: From 6734d2d666e0c2d9f3f977dba16199ed764835b7 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 15 Oct 2026 20:07:14 +0800 Subject: [PATCH 6/8] feat: add auto_connect flag and disconnect endpoint for computes Computes can now be flagged auto_connect in the database. The controller connects to flagged computes in the background at startup (reusing the one-second delayed fire-and-forget mechanism), and enabling the flag via PUT /computes/{id} triggers a background connection without the request ever waiting for it. Disabling the flag does not drop an existing connection. Add POST /computes/{id}/disconnect to tear a compute down manually: the notification stream and HTTP session are closed and every reconnection path (retry backoff, notification stream reconnect, lazy connection in http_query) is blocked until connect() is called again or the controller restarts with auto_connect enabled. Disconnecting a compute used by an opened project is rejected with 409 unless force=true; forcing only drops the controller-side control channel, nodes keep running unmanaged. The local compute cannot be disconnected. Also make test_compute_list create its own compute (it relied on rows from earlier tests and failed under reordering), and stop scheduling the delayed add_compute connection during tests: the timer leaked real connection attempts into other tests' event loops. --- gns3server/api/routes/controller/computes.py | 37 +++++ gns3server/controller/__init__.py | 31 ++-- gns3server/controller/compute.py | 53 +++++- gns3server/db/models/computes.py | 3 +- gns3server/db/repositories/computes.py | 4 + ...d3a9c2e5b1_add_auto_connect_to_computes.py | 26 +++ gns3server/schemas/controller/computes.py | 6 + gns3server/services/computes.py | 47 ++++++ tests/api/routes/controller/test_computes.py | 153 +++++++++++++++++- tests/controller/test_compute.py | 87 ++++++++++ 10 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 gns3server/db_migrations/versions/f7d3a9c2e5b1_add_auto_connect_to_computes.py diff --git a/gns3server/api/routes/controller/computes.py b/gns3server/api/routes/controller/computes.py index 796a72063..c8de19a55 100644 --- a/gns3server/api/routes/controller/computes.py +++ b/gns3server/api/routes/controller/computes.py @@ -26,6 +26,7 @@ from fastapi import APIRouter, Body, Depends, Query, status import gns3server.db.models as models from gns3server import schemas from gns3server.controller import Controller +from gns3server.controller.controller_error import ControllerBadRequestError, ControllerError from gns3server.db.repositories.computes import ComputesRepository from gns3server.db.repositories.rbac import RbacRepository from gns3server.services.computes import ComputesService @@ -80,6 +81,42 @@ async def connect_compute(compute_id: Union[str, UUID]) -> None: await compute.connect(report_failed_connection=True) +@router.post( + "/{compute_id}/disconnect", + status_code=status.HTTP_204_NO_CONTENT, + responses={409: {"model": schemas.ErrorMessage, "description": "Compute is used by opened projects"}}, + # dependencies=[Depends(has_privilege("Compute.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui +) +async def disconnect_compute(compute_id: Union[str, UUID], force: Optional[bool] = False) -> None: + """ + Disconnect from a compute on the controller. + + The compute is not reconnected until an explicit connection is requested, + or the controller restarts with auto_connect enabled for this compute. + + Rejected with 409 when an opened project still uses the compute, unless + force is true. Forcing does not stop anything on the compute: only the + controller-side control channel (notification stream and HTTP session) is + torn down, nodes keep running unmanaged. + + Required privilege: Compute.Audit + """ + + if str(compute_id) == "local": + raise ControllerBadRequestError("Cannot disconnect the local compute") + controller = Controller.instance() + compute = controller.get_compute(str(compute_id)) + if not force: + open_projects = controller.get_compute_open_projects(compute) + if open_projects: + project_names = ", ".join(f"'{project.name}'" for project in open_projects) + raise ControllerError( + f"Compute '{compute_id}' is used by opened projects ({project_names}), " + "disconnect anyway with force=true" + ) + await compute.disconnect() + + @router.get( "/{compute_id}", response_model=schemas.Compute, diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py index 075256d7e..b7fe5b4c9 100644 --- a/gns3server/controller/__init__.py +++ b/gns3server/controller/__init__.py @@ -201,10 +201,12 @@ class Controller: if computes: for c in computes: try: - # FIXME: Task exception was never retrieved await self.add_compute( compute_id=str(c.compute_id), - connect=False, + # computes flagged auto_connect connect in the + # background, one second after the controller started + connect=c.auto_connect, + wait_connection=False, **c.dict(exclude_unset=True, exclude={"compute_id", "created_at", "updated_at"}), ssl_context=self._ssl_context, ) @@ -625,9 +627,12 @@ class Controller: if connect: if wait_connection: await compute.connect() - else: - # call compute.connect() later to give time to the controller to be fully started - asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(compute.connect())) + elif not hasattr(sys, "_called_from_test"): + # connect in the background to give time to the controller to be fully + # started; goes through _try_reconnect so connection errors don't die + # as unretrieved task exceptions. Skipped during tests: the timer would + # leak the connection attempt into another test's event loop. + asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(compute._try_reconnect())) self.notification.controller_emit("compute.created", compute.asdict()) return compute else: @@ -645,6 +650,17 @@ class Controller: if compute in project.computes: await project.close() + def get_compute_open_projects(self, compute): + """ + Return the opened projects using a compute. + + :returns: List of opened projects + """ + + return [ + project for project in self._projects.values() if compute in project.computes and project.status == "opened" + ] + def compute_has_open_project(self, compute): """ Check is compute has a project opened. @@ -652,10 +668,7 @@ class Controller: :returns: True if a project is open """ - for project in self._projects.values(): - if compute in project.computes and project.status == "opened": - return True - return False + return bool(self.get_compute_open_projects(compute)) async def delete_compute(self, compute_id): """ diff --git a/gns3server/controller/compute.py b/gns3server/controller/compute.py index bd0cf01d4..aa2095eca 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -61,6 +61,7 @@ class Compute: name=None, console_host=None, ssl_context=None, + auto_connect=False, ): self._http_session = None assert controller is not None @@ -80,6 +81,11 @@ class Compute: self._connected = False self._notifications = None self._closed = False # Close mean we are destroying the compute node + # True when the user asked to disconnect: blocks every reconnection + # path (retry backoff, notification stream reconnect, lazy connect) + # until connect() is called again + self._manual_disconnect = False + self._auto_connect = auto_connect self._controller = controller self._set_auth(user, password) self._cpu_usage_percent = 0 @@ -154,6 +160,11 @@ class Compute: async def update(self, **kwargs): + if "auto_connect" in kwargs: + # stored separately from the generic attributes below so that + # asdict() reports it (it is persisted in the database, the + # controller-side copy is only a mirror for notifications) + self._auto_connect = kwargs.pop("auto_connect") for kw in kwargs: if kw not in ("user", "password"): setattr(self, kw, kwargs[kw]) @@ -323,6 +334,7 @@ class Compute: "host": self._host, "port": self._port, "user": self._user, + "auto_connect": self._auto_connect, "connected": self._connected, "cpu_usage_percent": self._cpu_usage_percent, "memory_usage_percent": self._memory_usage_percent, @@ -368,7 +380,9 @@ class Compute: :param params: Optional dict of query parameters to append to the URL """ - if not self._connected and not dont_connect: + # a manually disconnected compute is not lazily reconnected either: the + # request fails like for any unreachable compute + if not self._connected and not self._manual_disconnect and not dont_connect: if self._id == "vm" and not self._controller.gns3vm.running: await self._controller.gns3vm.start() await self.connect() @@ -381,6 +395,9 @@ class Compute: """ We catch error during reconnect """ + + if self._manual_disconnect: + return # stop reconnecting, the compute was manually disconnected try: await self.connect() except ControllerError: @@ -413,6 +430,8 @@ class Compute: Check if remote server is accessible """ + # an explicit connection request cancels a manual disconnect + self._manual_disconnect = False if not self._connected and not self._closed and self.host: try: log.info(f"Connecting to compute '{self._id}'") @@ -474,6 +493,31 @@ class Compute: self._last_error = None self._controller.notification.controller_emit("compute.updated", self.asdict()) + async def disconnect(self): + """ + Manually disconnect from the compute. The controller stops reconnecting + (retry backoff, notification stream reconnect and lazy connection are + all blocked) until connect() is called again, or the controller + restarts with auto_connect enabled for this compute. + """ + + self._manual_disconnect = True + if self._notifications: + # Cancelling the notification task runs its finally block: the + # compute is marked disconnected and clients are notified. The + # flag set above keeps it from scheduling a reconnection. + self._notifications.cancel() + try: + await self._notifications + except asyncio.CancelledError: + pass + self._notifications = None + if self._http_session and not self._http_session.closed: + await self._http_session.close() + if self._connected: + self._connected = False + self._controller.notification.controller_emit("compute.updated", self.asdict()) + async def _connect_notification(self): """ Connect to the notification stream @@ -526,9 +570,12 @@ class Compute: # Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a resources usage bomb) from gns3server.api.server import app - if not app.state.exiting and not hasattr(sys, "_called_from_test"): + # The reconnection goes through _try_reconnect (not connect + # directly) so that a manual disconnect landing within the 1 + # second window is still honoured by the time the timer fires + if not self._manual_disconnect and not app.state.exiting and not hasattr(sys, "_called_from_test"): log.info(f"Reconnecting to compute '{self._id}' WebSocket '{ws_url}'") - asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self.connect())) + asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self._try_reconnect())) def _getUrl(self, path): host = self._host diff --git a/gns3server/db/models/computes.py b/gns3server/db/models/computes.py index 4561e9f82..a2524f499 100644 --- a/gns3server/db/models/computes.py +++ b/gns3server/db/models/computes.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from sqlalchemy import Column, Integer, String +from sqlalchemy import Boolean, Column, Integer, String, false from .base import GUID, BaseTable @@ -30,3 +30,4 @@ class Compute(BaseTable): port = Column(Integer) user = Column(String) password = Column(String) + auto_connect = Column(Boolean, nullable=False, server_default=false()) diff --git a/gns3server/db/repositories/computes.py b/gns3server/db/repositories/computes.py index 3ccbc668b..3b3c9971b 100644 --- a/gns3server/db/repositories/computes.py +++ b/gns3server/db/repositories/computes.py @@ -61,6 +61,7 @@ class ComputesRepository(BaseRepository): port=compute_create.port, user=compute_create.user, password=compute_create.password.get_secret_value() if compute_create.password else None, + auto_connect=bool(compute_create.auto_connect), ) self._db_session.add(db_compute) await self._db_session.commit() @@ -74,6 +75,9 @@ class ComputesRepository(BaseRepository): update_values = compute_update.model_dump(exclude_unset=True) if compute_update.password is not None: update_values["password"] = compute_update.password.get_secret_value() + if update_values.get("auto_connect") is None: + # the column is NOT NULL: treat an explicit null as "leave unchanged" + update_values.pop("auto_connect", None) query = update(models.Compute).where(models.Compute.compute_id == compute_id).values(update_values) diff --git a/gns3server/db_migrations/versions/f7d3a9c2e5b1_add_auto_connect_to_computes.py b/gns3server/db_migrations/versions/f7d3a9c2e5b1_add_auto_connect_to_computes.py new file mode 100644 index 000000000..f114e7bef --- /dev/null +++ b/gns3server/db_migrations/versions/f7d3a9c2e5b1_add_auto_connect_to_computes.py @@ -0,0 +1,26 @@ +"""add auto_connect to computes table + +Revision ID: f7d3a9c2e5b1 +Revises: d9e8a2b7c401 +Create Date: 2026-10-15 10:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f7d3a9c2e5b1" +down_revision = "d9e8a2b7c401" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + + op.add_column("computes", sa.Column("auto_connect", sa.Boolean(), nullable=False, server_default=sa.false())) + + +def downgrade() -> None: + + op.drop_column("computes", "auto_connect") diff --git a/gns3server/schemas/controller/computes.py b/gns3server/schemas/controller/computes.py index 229a97660..f7c1fa791 100644 --- a/gns3server/schemas/controller/computes.py +++ b/gns3server/schemas/controller/computes.py @@ -49,6 +49,9 @@ class ComputeBase(BaseModel): user: Optional[str] = None password: Optional[SecretStr] = None name: Optional[str] = None + auto_connect: Optional[bool] = Field( + False, description="Whether to automatically connect to the compute when the controller starts" + ) model_config = ConfigDict(use_enum_values=True) @@ -87,6 +90,7 @@ class ComputeUpdate(ComputeBase): user: Optional[str] = None password: Optional[SecretStr] = None + auto_connect: Optional[bool] = None # no default: update schemas must not publish defaults model_config = ConfigDict( json_schema_extra={ "example": { @@ -107,6 +111,8 @@ class Compute(DateTimeModelMixin, ComputeBase): port: int = Field(..., gt=0, le=65535) compute_id: Union[str, uuid.UUID] name: str + # defaulted: topology files and the local compute dict may not carry it + auto_connect: bool = False connected: Optional[bool] = Field(None, description="Whether the controller is connected to the compute or not") cpu_usage_percent: Optional[float] = Field(None, description="CPU usage of the compute", ge=0, le=100) memory_usage_percent: Optional[float] = Field(None, description="Memory usage of the compute", ge=0, le=100) diff --git a/gns3server/services/computes.py b/gns3server/services/computes.py index ab328ca04..1b5e66e29 100644 --- a/gns3server/services/computes.py +++ b/gns3server/services/computes.py @@ -15,19 +15,44 @@ # along with this program. If not, see . +import asyncio from typing import List, Union from uuid import UUID import gns3server.db.models as models from gns3server import schemas from gns3server.controller import Controller +from gns3server.controller.compute import Compute from gns3server.controller.controller_error import ( ControllerBadRequestError, + ControllerError, ControllerNotFoundError, ) from gns3server.db.repositories.computes import ComputesRepository +async def _connect_in_background(compute: Compute) -> None: + + try: + await compute.connect() + except ControllerError: + # the connection failure is reported via compute.updated and the + # retry backoff is already scheduled by the connect attempt itself + pass + + +# fire-and-forget background connections: keep a reference so the task is not +# garbage-collected mid-flight, drop it once it is done +_background_connect_tasks: set[asyncio.Task] = set() + + +def _schedule_background_connect(compute: Compute) -> None: + + task = asyncio.ensure_future(_connect_in_background(compute)) + _background_connect_tasks.add(task) + task.add_done_callback(_background_connect_tasks.discard) + + class ComputesService: def __init__(self, computes_repo: ComputesRepository): @@ -50,6 +75,13 @@ class ComputesService: **compute_create.model_dump(exclude_unset=True, exclude={"compute_id"}), ) self._controller.notification.controller_emit("compute.created", compute.asdict()) + + # auto_connect connects in the background — including when the compute + # was already registered on the controller — so the create request + # never waits for, or fails on, the connection. An explicit + # ?connect=true keeps its synchronous behavior via add_compute. + if compute_create.auto_connect and not compute.connected: + _schedule_background_connect(compute) return db_compute async def get_compute(self, compute_id: Union[str, UUID]) -> Union[models.Compute, dict]: @@ -68,11 +100,26 @@ class ComputesService: ) -> models.Compute: compute = self._controller.get_compute(str(compute_id)) + + # remember the previous value to detect the false -> true transition below + previous_auto_connect = False + if compute_update.auto_connect: + previous_db_compute = await self._computes_repo.get_compute(compute_id) + if previous_db_compute: + previous_auto_connect = bool(previous_db_compute.auto_connect) + await compute.update(**compute_update.model_dump(exclude_unset=True)) db_compute = await self._computes_repo.update_compute(compute_id, compute_update) if not db_compute: raise ControllerNotFoundError(f"Compute '{compute_id}' not found") self._controller.notification.controller_emit("compute.updated", compute.asdict()) + + # Enabling auto_connect (false -> true) connects in the background: the + # request returns immediately and the connection status flips via + # compute.updated. The true -> true case is skipped on purpose so that + # saving an edit does not reconnect a manually disconnected compute. + if compute_update.auto_connect and not previous_auto_connect and not compute.connected: + _schedule_background_connect(compute) return db_compute async def delete_compute(self, compute_id: Union[str, UUID]) -> None: diff --git a/tests/api/routes/controller/test_computes.py b/tests/api/routes/controller/test_computes.py index 0558a62d7..08e2ec7df 100644 --- a/tests/api/routes/controller/test_computes.py +++ b/tests/api/routes/controller/test_computes.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import asyncio import uuid from unittest.mock import MagicMock, patch @@ -22,6 +23,7 @@ import pytest from fastapi import FastAPI, status from httpx import AsyncClient +from gns3server.controller import Controller from gns3server.schemas.controller.computes import Compute pytestmark = pytest.mark.asyncio @@ -62,7 +64,7 @@ class TestComputeRoutes: for param, value in params.items(): assert response.json()[param] == value - async def test_compute_list(self, app: FastAPI, client: AsyncClient) -> None: + async def test_compute_list(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None: response = await client.get(app.url_path_for("get_computes")) assert response.status_code == status.HTTP_200_OK @@ -263,3 +265,152 @@ class TestComputeFeatures: # response = await client.post(app.url_path_for("autoidlepc", compute_id=compute_id) + "/auto_idlepc", json=params) # assert mock.called # assert response.status_code == status.HTTP_200_OK + + +class TestComputeAutoConnect: + async def test_compute_create_with_auto_connect(self, app: FastAPI, client: AsyncClient) -> None: + + params = { + "protocol": "http", + "host": "localhost", + "port": 84, + "user": "julien", + "password": "secure", + "auto_connect": True, + } + + with patch("gns3server.controller.compute.Compute.connect", new_callable=AsyncioMagicMock) as connect_mock: + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["auto_connect"] is True + # the create request returns immediately, the connection happens + # in the background + await asyncio.sleep(0) + assert connect_mock.call_count == 1 + + # persisted, not just echoed back + compute_id = response.json()["compute_id"] + response = await client.get(app.url_path_for("get_compute", compute_id=compute_id)) + assert response.status_code == status.HTTP_200_OK + assert response.json()["auto_connect"] is True + + async def test_compute_create_auto_connect_defaults_to_false(self, app: FastAPI, client: AsyncClient) -> None: + + params = {"protocol": "http", "host": "localhost", "port": 85, "user": "julien", "password": "secure"} + + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["auto_connect"] is False + + async def test_compute_update_enable_auto_connect_connects(self, app: FastAPI, client: AsyncClient) -> None: + + params = {"protocol": "http", "host": "localhost", "port": 86, "name": "auto-connect-target"} + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + compute_id = response.json()["compute_id"] + + with patch("gns3server.controller.compute.Compute.connect", new_callable=AsyncioMagicMock) as connect_mock: + response = await client.put( + app.url_path_for("update_compute", compute_id=compute_id), json={"auto_connect": True} + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["auto_connect"] is True + await asyncio.sleep(0) # give the background connection task a tick + assert connect_mock.call_count == 1 + + # saving the same value again must not reconnect: a manual + # disconnect has to survive an unrelated edit of the compute + response = await client.put( + app.url_path_for("update_compute", compute_id=compute_id), json={"auto_connect": True} + ) + assert response.status_code == status.HTTP_200_OK + await asyncio.sleep(0) + assert connect_mock.call_count == 1 + + async def test_compute_update_preserves_auto_connect(self, app: FastAPI, client: AsyncClient) -> None: + + params = { + "protocol": "http", + "host": "localhost", + "port": 87, + "user": "julien", + "password": "secure", + "auto_connect": True, + } + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + compute_id = response.json()["compute_id"] + + response = await client.put(app.url_path_for("update_compute", compute_id=compute_id), json={"name": "renamed"}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == "renamed" + + response = await client.get(app.url_path_for("get_compute", compute_id=compute_id)) + assert response.status_code == status.HTTP_200_OK + assert response.json()["auto_connect"] is True + + +class TestComputeDisconnect: + async def test_compute_disconnect(self, app: FastAPI, client: AsyncClient) -> None: + + params = {"protocol": "http", "host": "localhost", "port": 88, "user": "julien", "password": "secure"} + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + compute_id = response.json()["compute_id"] + + compute = Controller.instance().get_compute(compute_id) + compute._connected = True + + response = await client.post(app.url_path_for("disconnect_compute", compute_id=compute_id)) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert compute._manual_disconnect is True + assert compute.connected is False + + async def test_compute_disconnect_in_use_rejected(self, app: FastAPI, client: AsyncClient) -> None: + + params = {"protocol": "http", "host": "localhost", "port": 89, "user": "julien", "password": "secure"} + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + compute_id = response.json()["compute_id"] + + compute = Controller.instance().get_compute(compute_id) + compute._connected = True + + open_project = MagicMock() + open_project.name = "project-using-the-compute" + with patch("gns3server.controller.Controller.get_compute_open_projects", return_value=[open_project]): + response = await client.post(app.url_path_for("disconnect_compute", compute_id=compute_id)) + assert response.status_code == status.HTTP_409_CONFLICT + # the offending project names are reported to the client + assert "project-using-the-compute" in response.json()["message"] + # the compute is left untouched + assert compute._manual_disconnect is False + assert compute.connected is True + + async def test_compute_disconnect_in_use_forced(self, app: FastAPI, client: AsyncClient) -> None: + + params = {"protocol": "http", "host": "localhost", "port": 90, "user": "julien", "password": "secure"} + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + compute_id = response.json()["compute_id"] + + compute = Controller.instance().get_compute(compute_id) + compute._connected = True + + with patch("gns3server.controller.Controller.get_compute_open_projects", return_value=[MagicMock()]): + response = await client.post( + app.url_path_for("disconnect_compute", compute_id=compute_id), params={"force": True} + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert compute._manual_disconnect is True + assert compute.connected is False + + async def test_compute_disconnect_local_rejected(self, app: FastAPI, client: AsyncClient) -> None: + + response = await client.post(app.url_path_for("disconnect_compute", compute_id="local")) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + async def test_compute_disconnect_unknown(self, app: FastAPI, client: AsyncClient) -> None: + + response = await client.post(app.url_path_for("disconnect_compute", compute_id=str(uuid.uuid4()))) + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/controller/test_compute.py b/tests/controller/test_compute.py index 6a8f3b27b..66fc638f9 100644 --- a/tests/controller/test_compute.py +++ b/tests/controller/test_compute.py @@ -29,11 +29,13 @@ from gns3server.api.server import app as gns3_app from gns3server.controller.compute import Compute from gns3server.controller.controller_error import ( ComputeConflictError, + ComputeError, ControllerError, ControllerNotFoundError, ControllerUnauthorizedError, ) from gns3server.controller.project import Project +from gns3server.version import __version__ from tests.utils import AsyncioMagicMock, asyncio_patch @@ -376,6 +378,7 @@ async def test_json(compute): "host": "example.com", "port": 84, "user": "test", + "auto_connect": False, "cpu_usage_percent": 0, "memory_usage_percent": 0, "disk_usage_percent": 0, @@ -696,3 +699,87 @@ async def test_connect_http_error_notifies_schedules_retry_and_raises(compute, m actions = [c.args[0] for c in emit_mock.call_args_list] assert "compute.updated" in actions assert scheduled_delays == [5] # first exponential backoff delay + + +def test_auto_connect_constructor_default(controller): + + assert Compute("default_auto_connect", controller=controller).asdict()["auto_connect"] is False + assert Compute("enabled_auto_connect", controller=controller, auto_connect=True).asdict()["auto_connect"] is True + + +@pytest.mark.asyncio +async def test_update_auto_connect(compute, controller): + + compute._controller._notification = MagicMock() + compute._controller.save = MagicMock() + + assert compute.asdict()["auto_connect"] is False + await compute.update(auto_connect=True) + assert compute.asdict()["auto_connect"] is True + controller.notification.controller_emit.assert_called_with("compute.updated", compute.asdict()) + + +@pytest.mark.asyncio +async def test_disconnect_stops_the_notification_stream(compute, controller): + """ + disconnect() tears down the notification stream, marks the compute + disconnected and notifies clients — without scheduling any reconnection. + """ + + assert compute.connected is True + + compute._controller._notification = MagicMock() + started = asyncio.Event() + + async def fake_notification_stream(): + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + raise + + compute._notifications = asyncio.gather(asyncio.create_task(fake_notification_stream())) + await started.wait() + + await compute.disconnect() + assert compute._manual_disconnect is True + assert compute.connected is False + assert compute._notifications is None + compute._controller._notification.controller_emit.assert_called_with("compute.updated", compute.asdict()) + + +@pytest.mark.asyncio +async def test_manual_disconnect_blocks_reconnect_and_lazy_connect(compute): + """ + After a manual disconnect no reconnection path may bring the compute back: + not the retry backoff, not the lazy connection of http_query. + """ + + await compute.disconnect() + + with patch("gns3server.controller.compute.Compute.connect", new_callable=AsyncioMagicMock) as connect_mock: + await compute._try_reconnect() + connect_mock.assert_not_called() + + with pytest.raises(ComputeError): + await compute.http_query("GET", "/capabilities") + connect_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_connect_cancels_manual_disconnect(compute, monkeypatch): + + compute._manual_disconnect = True + + async def fake_capabilities(method, path, **kwargs): + return SimpleNamespace(json={"version": __version__}) + + async def fake_notification_stream(): + return None + + monkeypatch.setattr(compute, "_run_http_query", fake_capabilities) + monkeypatch.setattr(compute, "_connect_notification", fake_notification_stream) + + await compute.connect() + assert compute._manual_disconnect is False + assert compute.connected is True From 7737630cb485c813187c76bceb6dce105653822d Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 15 Oct 2026 23:02:53 +0800 Subject: [PATCH 7/8] fix(tests): derive head revision in the migration round-trip test The round-trip test builds its catalog from the models (which already carry the head schema), then stamps the revision that was newest when the test was written and replays everything above it. Any migration landing on top therefore ran its DDL against a schema it had not rolled back - the new computes.auto_connect migration failed with "duplicate column name: auto_connect". Stamp the current head instead and let the downgrade roll back every revision above the inventory one before the upgrade replays them. --- tests/services/test_image_reconciliation.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/services/test_image_reconciliation.py b/tests/services/test_image_reconciliation.py index ff90dd186..47beef1b6 100644 --- a/tests/services/test_image_reconciliation.py +++ b/tests/services/test_image_reconciliation.py @@ -401,6 +401,7 @@ async def test_path_matching_does_not_interpret_sql_wildcards(inventory, config) async def test_migration_roundtrip_preserves_rows_and_relationships(inventory, config): from alembic import command from alembic.config import Config + from alembic.script import ScriptDirectory image_file(config) await scan(inventory) @@ -414,7 +415,12 @@ async def test_migration_roundtrip_preserves_rows_and_relationships(inventory, c cfg = Config() cfg.set_main_option("script_location", "gns3server:db_migrations") cfg.attributes["connection"] = connection - command.stamp(cfg, "d9e8a2b7c401") + # The catalog is built from the models, so it already carries the head + # schema. Stamping head (not the revision that happened to be newest when + # this test was written) keeps the round-trip valid for later migrations: + # every revision above the inventory one is rolled back before it is + # replayed, instead of being applied on top of its own schema. + command.stamp(cfg, ScriptDirectory.from_config(cfg).get_current_head()) command.downgrade(cfg, "c7e4a9f1d2b6") assert connection.execute(text("SELECT count(*) FROM image_template_map")).scalar_one() == 1 assert connection.execute(text("SELECT image_id FROM images")).scalar_one() == original["image_id"] From f47cb564954f63762632b353baff93115c2fa430 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 16 Oct 2026 01:27:08 +0800 Subject: [PATCH 8/8] fix: dial per-node console hosts in MCP device config tools The device tools (device_config_send / device_show_run / vpcs_config_set) built their Nornir inventory with only the console port and inherited the group hostname, which is always the controller host. Console ports of nodes on remote computes are bound on the compute's own host, so those nodes were unreachable from the MCP tools - only local-compute nodes worked. Pass the per-node console_host through the project inventory and inject it as a host-level Nornir hostname (which overrides the group's controller-wide default). Missing or wildcard (0.0.0.0/::) values keep the group inheritance, so single-machine setups behave exactly as before. VPCS builds its own host entries, so it carries the hostname over explicitly. --- .../gns3_client/gns3_topology_reader.py | 2 +- .../gns3_client/project_inventory.py | 9 +- .../tools_v2/vpcs_tools_netmiko.py | 8 +- .../utils/get_gns3_device_port.py | 16 +++- tests/agent/mcp/test_device_tools.py | 30 +++++++ tests/agent/test_gns3_client.py | 86 +++++++++++++++++++ 6 files changed, 145 insertions(+), 6 deletions(-) diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py index 208d7074f..057f4e879 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py @@ -67,7 +67,7 @@ class GNS3TopologyTool(BaseTool): Output: Dictionary with: - `project_id`, `name`, `status`: Project metadata - `nodes`: Dict of node details (node_id, name, ports, console_port, - type, etc.) + console_host, type, etc.) - `links`: List of link connections Use this to understand network structure before making changes. diff --git a/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py b/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py index aeefd304a..dd4851cff 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py +++ b/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py @@ -43,9 +43,9 @@ def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None) Build an inventory-style dict keyed by node name. Shape (per node name): - {server, name, node_id, console_port, console_type, type, ports, - status, x, y, tags, netmiko_device_type, default_username, - default_password} + {server, name, node_id, console_port, console_host, console_type, + type, ports, status, x, y, tags, netmiko_device_type, + default_username, default_password} """ inventory: dict[str, Any] = {} for n in nodes: @@ -54,6 +54,9 @@ def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None) "name": n.get("name"), "node_id": n.get("node_id"), "console_port": n.get("console"), + # Where the console port is bound: the compute's own host for + # remote computes, not the controller running this code. + "console_host": n.get("console_host"), "console_type": n.get("console_type"), "type": n.get("node_type"), "ports": n.get("ports"), diff --git a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py index ed33642ef..fb953936f 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py +++ b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py @@ -451,7 +451,7 @@ class VPCSCommands(BaseTool): continue # VPCS devices use gns3_vpcs_telnet device type - hosts_data[device_name] = { + host_entry = { "port": port, "platform": "vpcs", "groups": ["vpcs_devices"], # All VPCS devices share one group @@ -465,6 +465,12 @@ class VPCSCommands(BaseTool): } }, } + # Remote computes bind console ports on their own host; carry the + # per-node hostname from the topology so it overrides the group's + # controller-wide default. + if "hostname" in device_ports[device_name]: + host_entry["hostname"] = device_ports[device_name]["hostname"] + hosts_data[device_name] = host_entry return hosts_data diff --git a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py index 6c75f5b12..840cffe00 100644 --- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -68,7 +68,12 @@ def get_device_ports_from_topology( } } } - Devices that don't exist or missing console_port will not be included + Devices that don't exist or missing console_port will not be included. + Nodes whose console is bound on a remote compute also carry a + host-level "hostname" (copied from the topology's console_host) so + netmiko dials the compute hosting the console port instead of the + controller; for everything else the field is omitted and the shared + group hostname (the controller host) applies. """ # Log received parameters logger.info("Called with device_names=%s, project_id=%s", device_names, project_id) @@ -167,6 +172,15 @@ def get_device_ports_from_topology( "connection_options": {"netmiko": {"extras": {"device_type": device_type}}}, } + # Per-node console hostname. Remote computes bind console ports + # on their own host, not on the controller running this code, so + # the group's controller-wide hostname cannot reach them. A + # host-level hostname (which wins over the group in nornir) fixes + # that; unset or wildcard values keep the group inheritance. + console_host = node_info.get("console_host") + if console_host and console_host not in ("0.0.0.0", "::"): + host_entry["hostname"] = console_host + # Per-node default credentials (seeded from the template appliance # metadata) override the group's empty fallback. Only inject when # set, so credential-less devices keep inheriting the group values. diff --git a/tests/agent/mcp/test_device_tools.py b/tests/agent/mcp/test_device_tools.py index e4ff08fc1..c03bddd88 100644 --- a/tests/agent/mcp/test_device_tools.py +++ b/tests/agent/mcp/test_device_tools.py @@ -130,6 +130,36 @@ class TestVPCSNodeTypeGuard: assert "output" not in result[0] +class TestVPCSConsoleHostPropagation: + """ + VPCS builds its own nornir host entries, so the per-node console + hostname resolved by get_device_ports_from_topology must be carried + over instead of silently falling back to the controller host. + """ + + def test_remote_compute_hostname_reaches_nornir_inventory(self): + from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands + + with patch( + f"{VPCS_MOD}.get_device_ports_from_topology", + return_value={ + "PC1": {"port": 5000, "node_type": "vpcs", "hostname": "192.168.122.10"}, + "PC2": {"port": 5001, "node_type": "vpcs"}, + }, + ): + hosts_data = VPCSCommands()._prepare_device_hosts_data( + [ + {"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}, + {"device_name": "PC2", "commands": ["ip 10.0.0.2/24"]}, + ], + PROJECT_ID, + ) + # remote-compute console hostname lands at host level + assert hosts_data["PC1"]["hostname"] == "192.168.122.10" + # local-compute entries keep inheriting the group hostname + assert "hostname" not in hosts_data["PC2"] + + class TestDeviceToolErrorContract: """ Every in-band error entry carries status "failed" plus an "error" diff --git a/tests/agent/test_gns3_client.py b/tests/agent/test_gns3_client.py index d04dc49a7..5e6a2f90c 100644 --- a/tests/agent/test_gns3_client.py +++ b/tests/agent/test_gns3_client.py @@ -64,6 +64,35 @@ def test_nodes_inventory_emits_default_credentials(): assert inventory["R1"]["tags"] == [] +def test_nodes_inventory_carries_console_host(): + """ + The inventory must keep the per-node console_host: remote computes bind + console ports on their own host, and the Nornir inventory needs it to + dial devices outside the controller machine. + """ + pytest.importorskip("jwt", reason="ai-features extras not installed") + from gns3server.agent.gns3_copilot.gns3_client.project_inventory import ( + build_nodes_inventory, + ) + + nodes = [ + { + "name": "R1", + "node_id": "0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e", + "node_type": "dynamips", + "console": 5000, + "console_host": "192.168.122.10", + "console_type": "telnet", + "status": "started", + }, + ] + + inventory = build_nodes_inventory(nodes, "127.0.0.1") + assert inventory["R1"]["console_host"] == "192.168.122.10" + # the controller-wide "server" stays for backward compatibility + assert inventory["R1"]["server"] == "127.0.0.1" + + def test_links_summary_resolves_names_and_ports(): """ links_summary maps raw link endpoint lists to @@ -234,3 +263,60 @@ def test_device_ports_inject_default_credentials(monkeypatch): assert "password" not in hosts["R2"] assert "username" not in hosts["R3"] assert "password" not in hosts["R3"] + + +def test_device_ports_carry_per_node_console_host(monkeypatch): + """ + Remote computes bind console ports on their own host: console_host from + the topology becomes a host-level nornir hostname (overriding the + group's controller-wide default). Missing or wildcard values keep the + group inheritance. + """ + pytest.importorskip("jwt", reason="ai-features extras not installed") + from gns3server.agent.gns3_copilot import gns3_client + from gns3server.agent.gns3_copilot.utils import get_gns3_device_port + + class _FakeTopology: + def _run(self, project_id=None, jwt_token=None, url=None): + return { + "nodes": { + # node on a remote compute (another GNS3 VM) + "R1": { + "console_port": 5000, + "console_host": "192.168.122.10", + "tags": [], + "netmiko_device_type": "cisco_ios_telnet", + }, + # node on the local compute + "R2": { + "console_port": 5001, + "console_host": "127.0.0.1", + "tags": [], + "netmiko_device_type": "cisco_ios_telnet", + }, + # legacy topology payload without console_host + "R3": { + "console_port": 5002, + "tags": [], + "netmiko_device_type": "cisco_ios_telnet", + }, + # wildcard host cannot be dialed; keep group inheritance + "R4": { + "console_port": 5003, + "console_host": "0.0.0.0", + "tags": [], + "netmiko_device_type": "cisco_ios_telnet", + }, + } + } + + # the function does a lazy from-import inside the body + monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology) + hosts = get_gns3_device_port.get_device_ports_from_topology(["R1", "R2", "R3", "R4"]) + + # remote and local consoles dial their own host + assert hosts["R1"]["hostname"] == "192.168.122.10" + assert hosts["R2"]["hostname"] == "127.0.0.1" + # missing or wildcard console_host keeps inheriting the group hostname + assert "hostname" not in hosts["R3"] + assert "hostname" not in hosts["R4"]