mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
docs: restructure copilot docs and update node tools
Simplify the documentation structure in `README.md` by removing the `todo/` directory reference and detailed design documents for planned features. Consolidate future roadmap items into a high-level summary under "Future Enhancements". Update `node-control-tools.md` to include documentation for new topology management tools (create node, create link, get template, rename node) and reflect updated API imports for `Link` support.
This commit is contained in:
parent
5155e10214
commit
75a4585c20
@ -1,50 +0,0 @@
|
||||
# GNS3 Copilot Design Documents License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)** ([GitHub](https://github.com/yueguobin))
|
||||
|
||||
All design documents in this directory and its subdirectories are licensed under the
|
||||
Creative Commons Attribution-ShareAlike 4.0 International License (CC BY-SA 4.0).
|
||||
|
||||
## License Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: https://creativecommons.org/licenses/by-sa/4.0/
|
||||
|
||||
## What This Means
|
||||
|
||||
This is a **strong copyleft** license, similar to the GPL license used for
|
||||
the GNS3 server code. It ensures that:
|
||||
|
||||
- Anyone can use and modify these documents
|
||||
- Commercial use is permitted (training, books, etc.)
|
||||
- All derivative works must remain under CC BY-SA 4.0
|
||||
- The original author must be credited
|
||||
|
||||
## Icons
|
||||
|
||||
License icons are available from:
|
||||
- https://creativecommons.org/downloads/
|
||||
- https://chooser-beta.creativecommons.org/
|
||||
|
||||
## Contributing
|
||||
|
||||
By contributing to these design documents, you agree that your contributions
|
||||
will be licensed under the same CC BY-SA 4.0 license, with copyright attributed
|
||||
to you respectively for your contributions.
|
||||
|
||||
## Contact
|
||||
|
||||
For questions about licensing, please contact:
|
||||
|
||||
- GitHub: https://github.com/yueguobin
|
||||
@ -7,21 +7,12 @@ This directory contains design documentation, implementation guides, and future
|
||||
```
|
||||
docs/gns3-copilot/
|
||||
├── README.md # This file
|
||||
├── implemented/ # Implemented features and designs
|
||||
│ ├── chat-api.md # Chat API design (SSE, session management)
|
||||
│ ├── llm-model-configs.md # LLM model configuration system
|
||||
│ ├── command-security.md # Command security and filtering
|
||||
│ ├── context-window-management.md # Context window optimization
|
||||
│ └── node-control-tools.md # Node start/stop tools for lab automation
|
||||
├── todo/ # Planned features and designs
|
||||
│ ├── jinja2-config-templates-system.md # Config template system
|
||||
│ ├── config-templates-implementation-guide.md # Template implementation
|
||||
│ ├── ai-prompting-for-config-templates.md # AI prompts for templates
|
||||
│ ├── acl-web-ui-implementation-guide.md # ACL/ACL Web UI
|
||||
│ ├── hitl-implementation-plan.md # HITL (Human-in-the-Loop)
|
||||
│ ├── vision-topology-creation.md # Vision-based topology
|
||||
│ └── ... # More planned features
|
||||
└── guides/ # User and developer guides (TBD)
|
||||
└── implemented/ # Implemented features and designs
|
||||
├── chat-api.md # Chat API design (SSE, session management)
|
||||
├── llm-model-configs.md # LLM model configuration system
|
||||
├── command-security.md # Command security and filtering
|
||||
├── context-window-management.md # Context window optimization
|
||||
└── node-control-tools.md # Node start/stop/suspend tools for lab automation
|
||||
```
|
||||
|
||||
## Implemented Features
|
||||
@ -84,43 +75,21 @@ Tools for controlling network device lifecycle in GNS3 projects.
|
||||
|
||||
**Status:** ✅ Implemented
|
||||
|
||||
## Planned Features
|
||||
## Future Enhancements
|
||||
|
||||
### Jinja2 Configuration Templates (`todo/jinja2-config-templates-system.md`)
|
||||
Template-based configuration generation system.
|
||||
The following features are currently under consideration or development:
|
||||
|
||||
**Planned Features:**
|
||||
- Vendor-specific templates (Cisco, Juniper, Huawei, etc.)
|
||||
- JSON schema validation
|
||||
- AI generates structured data → Templates render configs
|
||||
- Multi-vendor support
|
||||
|
||||
**Status:** 📋 Design Complete, Implementation Pending
|
||||
|
||||
### ACL Web UI (`todo/acl-web-ui-implementation-guide.md`)
|
||||
Web-based ACL (Access Control List) management interface.
|
||||
|
||||
**Status:** 📋 Design Complete
|
||||
|
||||
### HITL Implementation (`todo/hitl-implementation-plan.md`)
|
||||
Enhanced Human-in-the-Loop confirmation workflows.
|
||||
|
||||
**Status:** 📋 Design Complete
|
||||
|
||||
### Vision Topology Creation (`todo/vision-topology-creation.md`)
|
||||
Create network topologies from images/diagrams.
|
||||
|
||||
**Status:** 📋 Design Complete
|
||||
- Configuration Templates: Template-based configuration generation for multi-vendor network devices
|
||||
- Vision-based Topology Creation: Create network topologies from images/diagrams
|
||||
- Enhanced HITL Workflows: Advanced Human-in-the-Loop confirmation patterns
|
||||
- Web UI Enhancements: Improved management interfaces
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new documentation:
|
||||
|
||||
1. **Design Phase:** Add new documents to `todo/`
|
||||
2. **Implementation:** Move to `implemented/` when feature is complete
|
||||
3. **Naming:**
|
||||
- `todo/`: Use descriptive names like `{feature}-implementation-guide.md`
|
||||
- `implemented/`: Use concise names like `{feature}.md`
|
||||
1. **Implementation:** Add documentation to `implemented/` when feature is complete
|
||||
2. **Naming:** Use concise names like `{feature}.md`
|
||||
|
||||
## Document Status Legend
|
||||
|
||||
@ -146,4 +115,4 @@ When adding new documentation:
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-03-11_
|
||||
_Last updated: 2026-03-12_
|
||||
|
||||
@ -1034,30 +1034,3 @@ if request.max_tokens is not None:
|
||||
- [OpenAI Chat Format](https://platform.openai.com/docs/api-reference/chat)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
|
||||
|
||||
@ -433,30 +433,3 @@ If you:
|
||||
Please submit an issue: https://github.com/yueguobin/gns3-copilot/issues
|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
|
||||
|
||||
@ -326,30 +326,3 @@ except Exception as e:
|
||||
- `gns3server/agent/gns3_copilot/agent/model_factory.py` - Model creation and tool binding
|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
|
||||
|
||||
@ -892,32 +892,3 @@ HTTP 400 Bad Request
|
||||
"detail": "context_limit is required (unit: K tokens, e.g., 128 = 128K = 128,000 tokens). Please check your model provider's documentation for the current context window size and specify it in the configuration."
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
|
||||
|
||||
@ -1,11 +1,214 @@
|
||||
# Node Control Tools
|
||||
# Node and Topology Management Tools
|
||||
|
||||
## Overview
|
||||
|
||||
GNS3-Copilot provides tools for controlling the lifecycle of network devices in GNS3 projects. These tools enable AI agents to start, stop, suspend, and manage nodes as part of automated lab management workflows.
|
||||
GNS3-Copilot provides tools for managing the lifecycle of network devices and topology in GNS3 projects. These tools enable AI agents to create, connect, start, stop, suspend, and manage nodes as part of automated lab management workflows.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### GNS3TemplateTool 🆕
|
||||
|
||||
**Tool Name:** `get_gns3_templates`
|
||||
|
||||
**Description:** Retrieves all available device templates from the GNS3 server, including template names, IDs, and types.
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"templates": [
|
||||
{
|
||||
"name": "Cisco IOSv",
|
||||
"template_id": "uuid-of-template",
|
||||
"template_type": "router"
|
||||
},
|
||||
{
|
||||
"name": "Ethernet switch",
|
||||
"template_id": "uuid-of-template2",
|
||||
"template_type": "switch"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Lists all available device templates
|
||||
- No input required (connects to configured GNS3 server)
|
||||
- Returns template_id needed for node creation
|
||||
|
||||
**Use Cases:**
|
||||
- Discover available device types before creating nodes
|
||||
- Get template_id for GNS3CreateNodeTool
|
||||
- Template inventory management
|
||||
|
||||
### GNS3CreateNodeTool 🆕
|
||||
|
||||
**Tool Name:** `create_gns3_node`
|
||||
|
||||
**Description:** Creates multiple nodes in a GNS3 project using specified templates and coordinates.
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"nodes": [
|
||||
{
|
||||
"template_id": "uuid-of-template",
|
||||
"x": 100,
|
||||
"y": -200
|
||||
},
|
||||
{
|
||||
"template_id": "uuid-of-template2",
|
||||
"x": -200,
|
||||
"y": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"created_nodes": [
|
||||
{
|
||||
"node_id": "uuid-of-node1",
|
||||
"name": "NodeName1",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"node_id": "uuid-of-node2",
|
||||
"name": "NodeName2",
|
||||
"status": "success"
|
||||
}
|
||||
],
|
||||
"total_nodes": 2,
|
||||
"successful_nodes": 2,
|
||||
"failed_nodes": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Batch create multiple nodes
|
||||
- Uses templates for consistent node configuration
|
||||
- X/Y coordinate positioning for topology layout
|
||||
- **Important**: Ensure distance between any two nodes is greater than 250px for clear interface labels
|
||||
|
||||
**Use Cases:**
|
||||
- Automated topology deployment
|
||||
- Multi-node lab initialization
|
||||
- Programmatic topology creation
|
||||
|
||||
**Implementation Details:**
|
||||
- Calls `POST /projects/{project_id}/nodes` for each node
|
||||
- Uses template_id from GNS3TemplateTool
|
||||
- Assigns default names sequentially (e.g., R1, R2, R3)
|
||||
|
||||
### GNS3LinkTool 🆕
|
||||
|
||||
**Tool Name:** `create_gns3_link`
|
||||
|
||||
**Description:** Creates one or more links between nodes in a GNS3 project by connecting their network ports.
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"links": [
|
||||
{
|
||||
"node_id1": "uuid-of-node1",
|
||||
"port1": "Ethernet0/0",
|
||||
"node_id2": "uuid-of-node2",
|
||||
"port2": "Ethernet0/0"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"link_id": "uuid-of-link",
|
||||
"node_id1": "uuid-of-node1",
|
||||
"port1": "Ethernet0/0",
|
||||
"node_id2": "uuid-of-node2",
|
||||
"port2": "Ethernet0/0"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Batch create multiple links
|
||||
- Automatic port discovery by name
|
||||
- Error handling for individual link failures
|
||||
- Port names must match topology data
|
||||
|
||||
**Use Cases:**
|
||||
- Automated topology wiring
|
||||
- Multi-link connection setup
|
||||
- Network infrastructure deployment
|
||||
|
||||
**Implementation Details:**
|
||||
- Calls `POST /projects/{project_id}/links` for each link
|
||||
- Port names must match those from `gns3_topology_reader` tool
|
||||
- Uses adapter_number and port_number for port identification
|
||||
- Supports both physical and virtual interfaces
|
||||
|
||||
### GNS3UpdateNodeNameTool 🆕
|
||||
|
||||
**Tool Name:** `update_gns3_node_name`
|
||||
|
||||
**Description:** Updates the name of one or multiple nodes in a GNS3 project.
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"nodes": [
|
||||
{"node_id": "uuid-of-node-1", "new_name": "Router1"},
|
||||
{"node_id": "uuid-of-node-2", "new_name": "Switch1"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"project_id": "...",
|
||||
"total_nodes": 2,
|
||||
"successful": 2,
|
||||
"failed": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": "...",
|
||||
"old_name": "...",
|
||||
"new_name": "Router1",
|
||||
"status": "success"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Batch rename multiple nodes
|
||||
- Verification of name change
|
||||
- Comprehensive error handling
|
||||
|
||||
**Use Cases:**
|
||||
- Apply naming conventions to topology
|
||||
- Rename nodes for better organization
|
||||
- Update node names after topology creation
|
||||
|
||||
**Implementation Details:**
|
||||
- Calls `PUT /projects/{project_id}/nodes/{node_id}`
|
||||
- Cannot rename while node is started (except special node types)
|
||||
- CAN rename while node is suspended
|
||||
|
||||
### GNS3StartNodeTool
|
||||
|
||||
**Tool Name:** `start_gns3_node`
|
||||
@ -46,46 +249,7 @@ GNS3-Copilot provides tools for controlling the lifecycle of network devices in
|
||||
- Multi-node topology initialization
|
||||
- Lab startup automation
|
||||
|
||||
### GNS3StartNodeQuickTool
|
||||
|
||||
**Tool Name:** `start_gns3_node_quick`
|
||||
|
||||
**Description:** Starts nodes in a GNS3 project WITHOUT waiting for startup completion. Suitable for automated deployment workflows where long waits would cause HTTP timeouts.
|
||||
|
||||
**Input:**
|
||||
```json
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"node_ids": ["uuid-of-node-1", "uuid-of-node-2"]
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"project_id": "...",
|
||||
"total_nodes": 2,
|
||||
"successful": 2,
|
||||
"failed": 0,
|
||||
"nodes": [
|
||||
{"node_id": "...", "name": "...", "status": "started"}
|
||||
],
|
||||
"note": "Start commands sent. Nodes are booting in background. Check node status later."
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Sends start commands immediately
|
||||
- No waiting for startup completion
|
||||
- Returns initial status
|
||||
- Prevents HTTP timeouts in automated workflows
|
||||
|
||||
**Use Cases:**
|
||||
- Automated CI/CD pipelines
|
||||
- Bulk node deployment
|
||||
- Workflows requiring immediate return
|
||||
|
||||
### GNS3StopNodeTool ✨
|
||||
### GNS3StopNodeTool
|
||||
|
||||
**Tool Name:** `stop_gns3_node`
|
||||
|
||||
@ -132,7 +296,7 @@ GNS3-Copilot provides tools for controlling the lifecycle of network devices in
|
||||
- Retrieves updated status after stop command
|
||||
- Returns detailed results for each node
|
||||
|
||||
### GNS3SuspendNodeTool ✨
|
||||
### GNS3SuspendNodeTool
|
||||
|
||||
**Tool Name:** `suspend_gns3_node`
|
||||
|
||||
@ -218,26 +382,40 @@ GNS3-Copilot provides tools for controlling the lifecycle of network devices in
|
||||
|
||||
```
|
||||
gns3server/agent/gns3_copilot/tools_v2/
|
||||
├── gns3_start_node.py # Start tools
|
||||
├── gns3_stop_node.py # Stop tool
|
||||
└── gns3_suspend_node.py # Suspend tool
|
||||
├── gns3_create_node.py # Node creation tool 🆕
|
||||
├── gns3_create_link.py # Link creation tool 🆕
|
||||
├── gns3_get_node_temp.py # Template retrieval tool 🆕
|
||||
├── gns3_update_node_name.py # Node rename tool 🆕
|
||||
├── gns3_start_node.py # Start node tool
|
||||
├── gns3_stop_node.py # Stop node tool
|
||||
└── gns3_suspend_node.py # Suspend node tool
|
||||
```
|
||||
|
||||
### API Integration
|
||||
|
||||
The tools use the `Node` class from `custom_gns3fy`:
|
||||
The tools use the `Node` and `Link` classes from `custom_gns3fy`:
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node, Link, get_gns3_connector
|
||||
|
||||
# Start node
|
||||
node.start()
|
||||
# Get templates
|
||||
templates = get_gns3_connector().get_templates()
|
||||
|
||||
# Stop node
|
||||
node.stop()
|
||||
# Create node
|
||||
node = Node(project_id=project_id, template_id=template_id, x=x, y=y, connector=gns3_server)
|
||||
node.create()
|
||||
|
||||
# Suspend node
|
||||
node.suspend()
|
||||
# Create link
|
||||
link = Link(project_id=project_id, connector=gns3_server, nodes=[...])
|
||||
link.create()
|
||||
|
||||
# Update node name
|
||||
node = Node(project_id=project_id, node_id=node_id, connector=gns3_server)
|
||||
node.update(name=new_name)
|
||||
|
||||
# Start/stop/suspend node
|
||||
node = Node(project_id=project_id, node_id=node_id, connector=gns3_server)
|
||||
node.start() # or node.stop() / node.suspend()
|
||||
```
|
||||
|
||||
### Progress Tracking
|
||||
@ -296,23 +474,31 @@ Starting 3 node(s), please wait...
|
||||
### Teaching Assistant Mode
|
||||
|
||||
**Tools Available:**
|
||||
- `GNS3TemplateTool` - List available device templates 🆕
|
||||
- `GNS3CreateNodeTool` - Create nodes in topology 🆕
|
||||
- `GNS3LinkTool` - Create links between nodes 🆕
|
||||
- `GNS3UpdateNodeNameTool` - Rename nodes 🆕
|
||||
- `GNS3StartNodeTool` - For diagnostics requiring started nodes
|
||||
|
||||
**Capabilities:**
|
||||
- READ-ONLY diagnostic tools
|
||||
- Can create and manage topology (nodes, links, names)
|
||||
- Cannot stop or suspend nodes (prevents disruption of active labs)
|
||||
|
||||
### Lab Automation Assistant Mode
|
||||
|
||||
**Tools Available:**
|
||||
- `GNS3TemplateTool` - List available device templates 🆕
|
||||
- `GNS3CreateNodeTool` - Create nodes in topology 🆕
|
||||
- `GNS3LinkTool` - Create links between nodes 🆕
|
||||
- `GNS3UpdateNodeNameTool` - Rename nodes 🆕
|
||||
- `GNS3StartNodeTool` - Full lab deployment
|
||||
- `GNS3StopNodeTool` - Full lab shutdown
|
||||
- `GNS3SuspendNodeTool` - Lab pause with state preservation ✨
|
||||
- `GNS3StartNodeQuickTool` - Fast automated deployment
|
||||
- `GNS3SuspendNodeTool` - Lab pause with state preservation
|
||||
|
||||
**Capabilities:**
|
||||
- Full diagnostic and configuration tools
|
||||
- Complete lab lifecycle management (start/stop/suspend)
|
||||
- Complete topology and lifecycle management (create, connect, start/stop/suspend)
|
||||
- Automated workflows with state preservation
|
||||
- Lab snapshot capabilities for later resumption
|
||||
|
||||
@ -332,21 +518,7 @@ result = tool._run(json.dumps({
|
||||
# Output includes progress bar and final status
|
||||
```
|
||||
|
||||
### Example 2: Quick Start for CI/CD
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeQuickTool
|
||||
|
||||
tool = GNS3StartNodeQuickTool()
|
||||
result = tool._run(json.dumps({
|
||||
"project_id": "abc-123-def",
|
||||
"node_ids": ["node-1"]
|
||||
}))
|
||||
|
||||
# Immediate return without waiting
|
||||
```
|
||||
|
||||
### Example 3: Stop Nodes
|
||||
### Example 2: Stop Nodes
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool
|
||||
@ -360,11 +532,11 @@ result = tool._run(json.dumps({
|
||||
# Immediate return with stop status
|
||||
```
|
||||
|
||||
### Example 4: Automated Lab Lifecycle
|
||||
### Example 3: Automated Lab Lifecycle
|
||||
|
||||
```python
|
||||
# Lab deployment
|
||||
start_tool = GNS3StartNodeQuickTool()
|
||||
start_tool = GNS3StartNodeTool()
|
||||
start_result = start_tool._run(json.dumps({
|
||||
"project_id": project_id,
|
||||
"node_ids": all_node_ids
|
||||
@ -380,7 +552,7 @@ stop_result = stop_tool._run(json.dumps({
|
||||
}))
|
||||
```
|
||||
|
||||
### Example 5: Lab Pause and Resume ✨
|
||||
### Example 4: Lab Pause and Resume
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool
|
||||
@ -413,7 +585,7 @@ start_result = start_tool._run(json.dumps({
|
||||
# Back to previous state in seconds!
|
||||
```
|
||||
|
||||
### Example 6: Suspend While Renaming Nodes ✨
|
||||
### Example 5: Suspend While Renaming Nodes
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import (
|
||||
@ -442,6 +614,152 @@ rename_result = rename_tool._run(json.dumps({
|
||||
# Note: Cannot rename while started, but CAN rename while suspended!
|
||||
```
|
||||
|
||||
### Example 6: Get Available Templates 🆕
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3TemplateTool
|
||||
|
||||
tool = GNS3TemplateTool()
|
||||
result = tool._run("")
|
||||
|
||||
# Returns all available device templates
|
||||
# {
|
||||
# "templates": [
|
||||
# {"name": "Cisco IOSv", "template_id": "...", "template_type": "router"},
|
||||
# {"name": "Ethernet switch", "template_id": "...", "template_type": "switch"}
|
||||
# ]
|
||||
# }
|
||||
```
|
||||
|
||||
### Example 7: Create Topology Nodes 🆕
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3CreateNodeTool
|
||||
|
||||
tool = GNS3CreateNodeTool()
|
||||
result = tool._run(json.dumps({
|
||||
"project_id": "abc-123-def",
|
||||
"nodes": [
|
||||
{
|
||||
"template_id": "uuid-of-router-template",
|
||||
"x": 100,
|
||||
"y": -200
|
||||
},
|
||||
{
|
||||
"template_id": "uuid-of-switch-template",
|
||||
"x": -200,
|
||||
"y": 300
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
# Creates two nodes with specified templates and positions
|
||||
```
|
||||
|
||||
### Example 8: Connect Nodes with Links 🆕
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool
|
||||
|
||||
tool = GNS3LinkTool()
|
||||
result = tool._run(json.dumps({
|
||||
"project_id": "abc-123-def",
|
||||
"links": [
|
||||
{
|
||||
"node_id1": "uuid-of-node1",
|
||||
"port1": "Ethernet0/0",
|
||||
"node_id2": "uuid-of-node2",
|
||||
"port2": "Ethernet0/0"
|
||||
},
|
||||
{
|
||||
"node_id1": "uuid-of-node1",
|
||||
"port1": "Ethernet0/1",
|
||||
"node_id2": "uuid-of-node3",
|
||||
"port2": "Ethernet0/0"
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
# Creates two links connecting the nodes
|
||||
```
|
||||
|
||||
### Example 9: Apply Naming Convention 🆕
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool
|
||||
|
||||
tool = GNS3UpdateNodeNameTool()
|
||||
result = tool._run(json.dumps({
|
||||
"project_id": "abc-123-def",
|
||||
"nodes": [
|
||||
{"node_id": "node-1", "new_name": "R1-Core"},
|
||||
{"node_id": "node-2", "new_name": "R2-Core"},
|
||||
{"node_id": "node-3", "new_name": "S1-Access"},
|
||||
{"node_id": "node-4", "new_name": "S2-Access"}
|
||||
]
|
||||
}))
|
||||
|
||||
# Applies consistent naming to all nodes
|
||||
```
|
||||
|
||||
### Example 10: Complete Topology Creation Workflow 🆕
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import (
|
||||
GNS3TemplateTool,
|
||||
GNS3CreateNodeTool,
|
||||
GNS3LinkTool,
|
||||
GNS3UpdateNodeNameTool,
|
||||
GNS3StartNodeTool
|
||||
)
|
||||
|
||||
# Step 1: Get available templates
|
||||
template_tool = GNS3TemplateTool()
|
||||
templates = template_tool._run("")
|
||||
# Find router and switch template_ids...
|
||||
|
||||
# Step 2: Create nodes
|
||||
create_tool = GNS3CreateNodeTool()
|
||||
nodes = create_tool._run(json.dumps({
|
||||
"project_id": project_id,
|
||||
"nodes": [
|
||||
{"template_id": router_template_id, "x": 0, "y": -200},
|
||||
{"template_id": router_template_id, "x": 200, "y": -200},
|
||||
{"template_id": switch_template_id, "x": 100, "y": 0}
|
||||
]
|
||||
}))
|
||||
|
||||
# Step 3: Connect nodes
|
||||
link_tool = GNS3LinkTool()
|
||||
links = link_tool._run(json.dumps({
|
||||
"project_id": project_id,
|
||||
"links": [
|
||||
{"node_id1": nodes["created_nodes"][0]["node_id"], "port1": "Ethernet0/0",
|
||||
"node_id2": nodes["created_nodes"][2]["node_id"], "port2": "Ethernet0/0"},
|
||||
{"node_id1": nodes["created_nodes"][1]["node_id"], "port1": "Ethernet0/0",
|
||||
"node_id2": nodes["created_nodes"][2]["node_id"], "port2": "Ethernet0/1"}
|
||||
]
|
||||
}))
|
||||
|
||||
# Step 4: Apply naming
|
||||
name_tool = GNS3UpdateNodeNameTool()
|
||||
names = name_tool._run(json.dumps({
|
||||
"project_id": project_id,
|
||||
"nodes": [
|
||||
{"node_id": nodes["created_nodes"][0]["node_id"], "new_name": "R1"},
|
||||
{"node_id": nodes["created_nodes"][1]["node_id"], "new_name": "R2"},
|
||||
{"node_id": nodes["created_nodes"][2]["node_id"], "new_name": "SW1"}
|
||||
]
|
||||
}))
|
||||
|
||||
# Step 5: Start nodes
|
||||
start_tool = GNS3StartNodeTool()
|
||||
start_result = start_tool._run(json.dumps({
|
||||
"project_id": project_id,
|
||||
"node_ids": [n["node_id"] for n in nodes["created_nodes"]]
|
||||
}))
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All tools include comprehensive error handling:
|
||||
@ -485,7 +803,7 @@ All tools include comprehensive error handling:
|
||||
|
||||
### Access Control
|
||||
|
||||
- Both tools respect GNS3's built-in access control
|
||||
- All tools respect GNS3's built-in access control
|
||||
- Requires valid GNS3 server authentication
|
||||
- Project-level permissions apply
|
||||
|
||||
@ -494,6 +812,9 @@ All tools include comprehensive error handling:
|
||||
All operations are logged:
|
||||
```python
|
||||
logger.info("Starting %d nodes in project %s...", len(node_ids), project_id)
|
||||
logger.info("Creating %d nodes in project %s...", len(nodes), project_id)
|
||||
logger.info("Creating %d links in project %s...", len(links), project_id)
|
||||
logger.info("Updating names for %d nodes in project %s...", len(nodes), project_id)
|
||||
logger.info("Stop command sent for node %s (%s)", node_id, node.name)
|
||||
logger.info("Suspend command sent for node %s (%s)", node_id, node.name)
|
||||
```
|
||||
@ -501,10 +822,12 @@ logger.info("Suspend command sent for node %s (%s)", node_id, node.name)
|
||||
### Mode-Based Restrictions
|
||||
|
||||
- **Teaching Assistant Mode**:
|
||||
- Can start nodes
|
||||
- Can create topology (templates, nodes, links, names)
|
||||
- Can start nodes for diagnostics
|
||||
- Cannot stop, suspend (prevents disruption of active labs)
|
||||
|
||||
- **Lab Automation Assistant Mode**:
|
||||
- Can create and manage full topology
|
||||
- Can start, stop, and suspend nodes (full lifecycle control)
|
||||
- Complete lab management including state preservation
|
||||
|
||||
@ -512,8 +835,11 @@ logger.info("Suspend command sent for node %s (%s)", node_id, node.name)
|
||||
|
||||
| Operation | Typical Duration | Wait Time | Progress | State Preserved |
|
||||
|-----------|-----------------|-----------|----------|-----------------|
|
||||
| Start (normal) | 60-180s | ~140s base | Yes | N/A |
|
||||
| Start (quick) | < 1s | 0s | No | N/A |
|
||||
| Get Templates | < 2s | 0s | No | N/A |
|
||||
| Create Node | < 1s per node | 0s | No | N/A |
|
||||
| Create Link | < 1s per link | 0s | No | N/A |
|
||||
| Update Name | < 1s per node | 0s | No | N/A |
|
||||
| Start | 60-180s | ~140s base | Yes | N/A |
|
||||
| Stop | < 5s | 0s | No | ❌ No |
|
||||
| Suspend | < 10s | 0s | No | ✅ Yes |
|
||||
|
||||
@ -523,11 +849,16 @@ logger.info("Suspend command sent for node %s (%s)", node_id, node.name)
|
||||
- Stop/Suspend do not require progress tracking (immediate feedback)
|
||||
- Start duration depends on node type (router, switch, PC, etc.)
|
||||
- Suspend provides fast resume capability compared to full start
|
||||
- Create node/link operations are fast and require no waiting
|
||||
- Template retrieval is instant with no parameters needed
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
|
||||
- [ ] **Quick Start Tool**: Start nodes without waiting for completion (for CI/CD)
|
||||
- [ ] **Delete Node Tool**: Remove nodes from topology
|
||||
- [ ] **Delete Link Tool**: Remove links from topology
|
||||
- [ ] **Resume Tool**: Explicit resume operation for suspended nodes
|
||||
- [ ] **Restart Tool**: Combined stop + start operation
|
||||
- [ ] **Bulk Status Check**: Query multiple nodes without stopping
|
||||
@ -536,8 +867,9 @@ logger.info("Suspend command sent for node %s (%s)", node_id, node.name)
|
||||
|
||||
### Potential Improvements
|
||||
|
||||
- [ ] Auto-layout calculation (optimal node positioning)
|
||||
- [ ] Progress tracking for long suspend operations (rare but possible)
|
||||
- [ ] Concurrent suspend operations (parallel API calls)
|
||||
- [ ] Concurrent create/link operations (parallel API calls)
|
||||
- [ ] Suspend node groups by name pattern
|
||||
- [ ] Dependency-aware suspend (suspend in dependency order)
|
||||
- [ ] Auto-suspend after idle timeout
|
||||
@ -551,6 +883,6 @@ logger.info("Suspend command sent for node %s (%s)", node_id, node.name)
|
||||
|
||||
---
|
||||
|
||||
_Implementation Date: 2026-03-11_
|
||||
_Implementation Date: 2026-03-12_
|
||||
|
||||
_Status: ✅ Implemented and Available in Lab Automation Assistant Mode_
|
||||
_Status: ✅ Implemented - Topology management tools available in both modes. Full lifecycle management (start/stop/suspend) available in Lab Automation Assistant Mode_
|
||||
|
||||
@ -1,824 +0,0 @@
|
||||
# AI Prompting for Configuration Templates
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides prompts and examples for training the AI to generate structured configuration data instead of full configuration text. This is critical for the Jinja2 template system to work effectively.
|
||||
|
||||
---
|
||||
|
||||
## Core System Prompt
|
||||
|
||||
```python
|
||||
# File: gns3server/agent/gns3_copilot/prompts/config_assistant_prompt.py
|
||||
|
||||
CONFIG_GENERATION_SYSTEM_PROMPT = """
|
||||
You are an expert network configuration assistant for GNS3. Your role is to help users configure network devices by generating structured configuration data.
|
||||
|
||||
## CRITICAL RULES
|
||||
|
||||
1. **NEVER** generate full configuration text directly
|
||||
2. **ALWAYS** output structured data (Python dict/JSON format)
|
||||
3. The system will render actual configurations using Jinja2 templates
|
||||
4. Only include parameters that are explicitly mentioned by the user
|
||||
5. Use correct data types (int for numbers, bool for flags, str for text)
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
User Request → AI (Structured Data) → Template Renderer → Full Config → Device
|
||||
```
|
||||
|
||||
You are responsible for the "AI (Structured Data)" step only.
|
||||
|
||||
## Supported Vendors and OS Types
|
||||
|
||||
| Vendor | OS Types |
|
||||
|---------|----------------------|
|
||||
| cisco | ios, iosxr, nx-os, asa |
|
||||
| juniper| junos, srx |
|
||||
| huawei | vrp |
|
||||
| arista | eos |
|
||||
| mikrotik| routeros |
|
||||
|
||||
## Available Features and Their Schemas
|
||||
|
||||
### OSPF Configuration
|
||||
|
||||
```python
|
||||
{
|
||||
"ospf": {
|
||||
"enabled": bool, # Required: Enable OSPF
|
||||
"process_id": int (1-65535), # Required: OSPF process ID
|
||||
"router_id": str ("x.x.x.x"), # Optional: Router ID
|
||||
"networks": [ # Optional: Network statements
|
||||
{
|
||||
"address": str, # Network address
|
||||
"wildcard": str, # Wildcard mask
|
||||
"area": int # OSPF area (0-4294967295)
|
||||
}
|
||||
],
|
||||
"passive_interfaces": [str], # Optional: List of passive interfaces
|
||||
"auto_cost_reference": int, # Optional: Reference bandwidth in Mbps
|
||||
"default_information_originate": bool, # Optional: Advertise default route
|
||||
"default_metric": int, # Optional: Default route metric
|
||||
"interfaces": [ # Optional: Per-interface config
|
||||
{
|
||||
"name": str, # Interface name
|
||||
"cost": int, # OSPF cost
|
||||
"area": int, # OSPF area
|
||||
"hello_interval": int, # Hello interval (seconds)
|
||||
"dead_interval": int # Dead interval (seconds)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example:
|
||||
```python
|
||||
{
|
||||
"ospf": {
|
||||
"enabled": True,
|
||||
"process_id": 100,
|
||||
"router_id": "1.1.1.1",
|
||||
"networks": [
|
||||
{"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0},
|
||||
{"address": "10.0.0.0", "wildcard": "0.255.255.255", "area": 1}
|
||||
],
|
||||
"passive_interfaces": ["GigabitEthernet0/0"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### BGP Configuration
|
||||
|
||||
```python
|
||||
{
|
||||
"bgp": {
|
||||
"enabled": bool,
|
||||
"as_number": int (1-65535),
|
||||
"router_id": str ("x.x.x.x"),
|
||||
"log_neighbor_changes": bool,
|
||||
"graceful_restart": bool,
|
||||
"neighbors": [
|
||||
{
|
||||
"ip": str,
|
||||
"remote_as": int,
|
||||
"description": str (optional),
|
||||
"ebgp_multihop": int (optional),
|
||||
"next_hop_self": bool,
|
||||
"remove_private_as": bool,
|
||||
"route_map_in": str (optional),
|
||||
"route_map_out": str (optional),
|
||||
"password": str (optional)
|
||||
}
|
||||
],
|
||||
"address_families": [
|
||||
{
|
||||
"type": str, # "ipv4", "ipv6", "vpnv4", "vpnv6"
|
||||
"vrf": str (optional),
|
||||
"redistribute_connected": bool,
|
||||
"redistribute_static": bool,
|
||||
"redistribute_ospf": int (optional),
|
||||
"networks": [
|
||||
{"address": str, "mask": str}
|
||||
],
|
||||
"neighbors": [
|
||||
{
|
||||
"ip": str,
|
||||
"activate": bool,
|
||||
"route_map_in": str (optional),
|
||||
"route_map_out": str (optional),
|
||||
"soft_reconfiguration_inbound": bool
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Interface Configuration
|
||||
|
||||
```python
|
||||
{
|
||||
"interfaces": [
|
||||
{
|
||||
"name": str,
|
||||
"description": str (optional),
|
||||
"ip_address": str (optional),
|
||||
"subnet_mask": str (optional),
|
||||
"ipv6_address": str (optional),
|
||||
"secondary_ips": [
|
||||
{"address": str, "mask": str}
|
||||
],
|
||||
"enabled": bool,
|
||||
"mtu": int (optional),
|
||||
"bandwidth": int (optional),
|
||||
"speed": str (optional),
|
||||
"duplex": str (optional),
|
||||
"acl_in": str (optional),
|
||||
"acl_out": str (optional),
|
||||
"nat_inside": bool,
|
||||
"nat_outside": bool,
|
||||
"vlan": int (optional),
|
||||
"trunk_vlans": str (optional) # e.g., "10,20,30" or "10-50"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### VLAN Configuration (Cisco IOS)
|
||||
|
||||
```python
|
||||
{
|
||||
"vlans": [
|
||||
{
|
||||
"id": int (1-4094),
|
||||
"name": str,
|
||||
"interfaces": [str] # List of interface names
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### ACL Configuration (Cisco IOS)
|
||||
|
||||
```python
|
||||
{
|
||||
"acl": {
|
||||
"number": int (1-99 for standard, 100-199 for extended),
|
||||
"name": str (optional for named ACLs),
|
||||
"rules": [
|
||||
{
|
||||
"action": str, # "permit" or "deny"
|
||||
"protocol": str, # "ip", "tcp", "udp", "icmp", etc.
|
||||
"source": str, # "any", "host x.x.x.x", "x.x.x.x y.y.y.y"
|
||||
"destination": str, # Same format as source
|
||||
"source_port": str (optional),
|
||||
"destination_port": str (optional)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### NAT Configuration (Cisco IOS)
|
||||
|
||||
```python
|
||||
{
|
||||
"nat": {
|
||||
"inside_source": {
|
||||
"pool": str (optional),
|
||||
"network": str,
|
||||
"mask": str,
|
||||
"interface": str (optional), # Use "interface" keyword
|
||||
"overload": bool
|
||||
},
|
||||
"outside_source": {
|
||||
# Similar structure
|
||||
},
|
||||
"static": [
|
||||
{
|
||||
"inside_local": str,
|
||||
"inside_global": str,
|
||||
"tcp": bool (optional),
|
||||
"udp": bool (optional),
|
||||
"local_port": int (optional),
|
||||
"global_port": int (optional)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interaction Examples
|
||||
|
||||
### Example 1: Simple OSPF Configuration
|
||||
|
||||
**User**: "Configure OSPF process 100 on R1 with router-id 1.1.1.1. Include network 192.168.1.0/24 in area 0."
|
||||
|
||||
**AI Response** (should call tool):
|
||||
```python
|
||||
render_device_config(
|
||||
node_id="R1",
|
||||
vendor="cisco",
|
||||
os_type="ios",
|
||||
config_data={
|
||||
"ospf": {
|
||||
"enabled": True,
|
||||
"process_id": 100,
|
||||
"router_id": "1.1.1.1",
|
||||
"networks": [
|
||||
{"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Example 2: Multi-Area OSPF
|
||||
|
||||
**User**: "Set up OSPF on R2. Process ID 50, router-id 2.2.2.2. Put 10.1.0.0/16 in area 0, 172.16.0.0/16 in area 1, and 192.168.100.0/24 in area 2. Make loopback0 passive."
|
||||
|
||||
**AI Response**:
|
||||
```python
|
||||
render_device_config(
|
||||
node_id="R2",
|
||||
vendor="cisco",
|
||||
os_type="ios",
|
||||
config_data={
|
||||
"ospf": {
|
||||
"enabled": True,
|
||||
"process_id": 50,
|
||||
"router_id": "2.2.2.2",
|
||||
"networks": [
|
||||
{"address": "10.1.0.0", "wildcard": "0.0.255.255", "area": 0},
|
||||
{"address": "172.16.0.0", "wildcard": "0.0.255.255", "area": 1},
|
||||
{"address": "192.168.100.0", "wildcard": "0.0.0.255", "area": 2}
|
||||
],
|
||||
"passive_interfaces": ["Loopback0"]
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Example 3: BGP Configuration
|
||||
|
||||
**User**: "Configure BGP on router R1 with AS 65001. Set up IBGP with R2 at 10.0.0.2 and EBGP with ISP at 203.0.13.2 (AS 65002). Advertise network 192.168.0.0/16."
|
||||
|
||||
**AI Response**:
|
||||
```python
|
||||
render_device_config(
|
||||
node_id="R1",
|
||||
vendor="cisco",
|
||||
os_type="ios",
|
||||
config_data={
|
||||
"bgp": {
|
||||
"enabled": True,
|
||||
"as_number": 65001,
|
||||
"router_id": "1.1.1.1",
|
||||
"log_neighbor_changes": True,
|
||||
"neighbors": [
|
||||
{
|
||||
"ip": "10.0.0.2",
|
||||
"remote_as": 65001,
|
||||
"description": "IBGP_R2",
|
||||
"next_hop_self": True
|
||||
},
|
||||
{
|
||||
"ip": "203.0.13.2",
|
||||
"remote_as": 65002,
|
||||
"description": "ISP",
|
||||
"ebgp_multihop": 2
|
||||
}
|
||||
],
|
||||
"address_families": [
|
||||
{
|
||||
"type": "ipv4",
|
||||
"networks": [
|
||||
{"address": "192.168.0.0", "mask": "255.255.0.0"}
|
||||
],
|
||||
"neighbors": [
|
||||
{"ip": "10.0.0.2", "activate": True},
|
||||
{"ip": "203.0.13.2", "activate": True}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Example 4: Interface Configuration
|
||||
|
||||
**User**: "Configure GigabitEthernet0/0 with IP 192.168.1.1/24, description 'LAN', enable it. Configure GigabitEthernet0/1 with IP 10.0.0.1/30, description 'WAN'."
|
||||
|
||||
**AI Response**:
|
||||
```python
|
||||
render_device_config(
|
||||
node_id="R1",
|
||||
vendor="cisco",
|
||||
os_type="ios",
|
||||
config_data={
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "GigabitEthernet0/0",
|
||||
"description": "LAN",
|
||||
"ip_address": "192.168.1.1",
|
||||
"subnet_mask": "255.255.255.0",
|
||||
"enabled": True
|
||||
},
|
||||
{
|
||||
"name": "GigabitEthernet0/1",
|
||||
"description": "WAN",
|
||||
"ip_address": "10.0.0.1",
|
||||
"subnet_mask": "255.255.255.252",
|
||||
"enabled": True
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Example 5: Multi-Feature Configuration
|
||||
|
||||
**User**: "Configure R1 as follows: hostname HQ-ROUTER, GigabitEthernet0/0 = 192.168.1.1/24 (LAN, NAT inside), GigabitEthernet0/1 = 203.0.13.1/30 (WAN, NAT outside), OSPF process 100 with 192.168.1.0/24 in area 0"
|
||||
|
||||
**AI Response**:
|
||||
```python
|
||||
render_device_config(
|
||||
node_id="R1",
|
||||
vendor="cisco",
|
||||
os_type="ios",
|
||||
config_data={
|
||||
"hostname": "HQ-ROUTER",
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "GigabitEthernet0/0",
|
||||
"ip_address": "192.168.1.1",
|
||||
"subnet_mask": "255.255.255.0",
|
||||
"description": "LAN",
|
||||
"nat_inside": True,
|
||||
"enabled": True
|
||||
},
|
||||
{
|
||||
"name": "GigabitEthernet0/1",
|
||||
"ip_address": "203.0.13.1",
|
||||
"subnet_mask": "255.255.255.252",
|
||||
"description": "WAN",
|
||||
"nat_outside": True,
|
||||
"enabled": True
|
||||
}
|
||||
],
|
||||
"ospf": {
|
||||
"enabled": True,
|
||||
"process_id": 100,
|
||||
"networks": [
|
||||
{"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Example 6: Juniper Configuration
|
||||
|
||||
**User**: "Configure Juniper SRX with OSPF. Router-id 10.1.1.1. Area 0 on interface ge-0/0/0.0."
|
||||
|
||||
**AI Response**:
|
||||
```python
|
||||
render_device_config(
|
||||
node_id="srx1",
|
||||
vendor="juniper",
|
||||
os_type="junos",
|
||||
config_data={
|
||||
"ospf": {
|
||||
"enabled": True,
|
||||
"router_id": "10.1.1.1",
|
||||
"areas": [
|
||||
{
|
||||
"area_id": "0.0.0.0",
|
||||
"interfaces": [
|
||||
{"name": "ge-0/0/0.0"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Few-Shot Prompting Examples
|
||||
|
||||
When the AI needs to handle complex requests, provide these examples in the system prompt:
|
||||
|
||||
### Example 1: Parsing Network Notation
|
||||
|
||||
```
|
||||
User: "Add network 192.168.1.0/24 to OSPF area 0"
|
||||
AI Output: {"networks": [{"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}]}
|
||||
|
||||
User: "Add network 10.0.0.0/8 to OSPF area 1"
|
||||
AI Output: {"networks": [{"address": "10.0.0.0", "wildcard": "0.255.255.255", "area": 1}]}
|
||||
|
||||
Conversion: CIDR to Wildcard
|
||||
- /24 → 0.0.0.255
|
||||
- /16 → 0.0.255.255
|
||||
- /8 → 0.255.255.255
|
||||
- /30 → 0.0.0.3
|
||||
- /32 → 0.0.0.0
|
||||
```
|
||||
|
||||
### Example 2: Interface Naming Conventions
|
||||
|
||||
```
|
||||
User: "Configure interface gig0/0"
|
||||
AI Output: {"name": "GigabitEthernet0/0"}
|
||||
|
||||
User: "Configure fa0/1"
|
||||
AI Output: {"name": "FastEthernet0/1"}
|
||||
|
||||
User: "Configure eth0"
|
||||
AI Output: {"name": "Ethernet0/0"}
|
||||
|
||||
Abbreviation Mapping:
|
||||
- gi/gig/gigabit → GigabitEthernet
|
||||
- fa/fast → FastEthernet
|
||||
- eth → Ethernet
|
||||
- serial → Serial
|
||||
- lo/loopback → Loopback
|
||||
```
|
||||
|
||||
### Example 3: IP Address with Subnet
|
||||
|
||||
```
|
||||
User: "IP 192.168.1.1/24"
|
||||
AI Output: {"ip_address": "192.168.1.1", "subnet_mask": "255.255.255.0"}
|
||||
|
||||
User: "IP 10.0.0.1 255.255.255.252"
|
||||
AI Output: {"ip_address": "10.0.0.1", "subnet_mask": "255.255.255.252"}
|
||||
|
||||
User: "IP 2001:db8::1/64"
|
||||
AI Output: {"ipv6_address": "2001:db8::1/64"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### When Information is Missing
|
||||
|
||||
**User**: "Configure OSPF"
|
||||
|
||||
**AI Should Ask**:
|
||||
```
|
||||
"I need more information to configure OSPF. Please provide:
|
||||
- OSPF process ID
|
||||
- Router ID (optional)
|
||||
- Networks to advertise with their areas
|
||||
- Any passive interfaces (optional)"
|
||||
```
|
||||
|
||||
### When Request is Ambiguous
|
||||
|
||||
**User**: "Configure BGP with the ISP"
|
||||
|
||||
**AI Should Ask**:
|
||||
```
|
||||
"I need more details for BGP configuration:
|
||||
- Your local AS number
|
||||
- ISP's IP address and AS number
|
||||
- Networks to advertise (optional)
|
||||
- Any special settings like route-maps, passwords, etc. (optional)"
|
||||
```
|
||||
|
||||
### When Feature is Not Supported
|
||||
|
||||
**User**: "Configure MPLS LDP on this router"
|
||||
|
||||
**AI Response**:
|
||||
```
|
||||
"MPLS LDP configuration is not yet supported through the template system.
|
||||
Available features: OSPF, BGP, Interface, VLAN, ACL, NAT.
|
||||
Would you like me to help with any of these?"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices for AI Responses
|
||||
|
||||
1. **Always use tool calls** - Don't just output the data, call the render_device_config tool
|
||||
|
||||
2. **Validate before calling** - Check that all required fields are present
|
||||
|
||||
3. **Ask for clarification** - If critical information is missing, ask the user
|
||||
|
||||
4. **Be explicit about vendor/OS** - Don't assume, ask if not specified
|
||||
|
||||
5. **Handle edge cases**:
|
||||
- Multiple interfaces with similar configs
|
||||
- Conflicting parameters
|
||||
- Invalid values (out of range)
|
||||
|
||||
6. **Provide context** - Explain what you're about to configure before calling the tool
|
||||
|
||||
---
|
||||
|
||||
## Tool Definition for LangChain
|
||||
|
||||
```python
|
||||
from langchain_core.tools import tool
|
||||
from typing import Dict, Any
|
||||
|
||||
@tool
|
||||
def render_device_config(
|
||||
node_id: str,
|
||||
vendor: str,
|
||||
os_type: str,
|
||||
config_data: Dict[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Render network device configuration using Jinja2 templates.
|
||||
|
||||
Instead of generating full configuration text, provide structured data
|
||||
that will be rendered through vendor-specific templates.
|
||||
|
||||
Args:
|
||||
node_id: GNS3 node identifier (e.g., "node-1", "R1")
|
||||
vendor: Device vendor - cisco, juniper, huawei, arista, mikrotik
|
||||
os_type: Operating system type - ios, iosxr, nx-os, junos, vrp, eos, routeros
|
||||
config_data: Structured configuration data (dict) for the features
|
||||
|
||||
Returns:
|
||||
Rendered configuration string or error message
|
||||
|
||||
Examples:
|
||||
>>> config = render_device_config(
|
||||
... node_id="R1",
|
||||
... vendor="cisco",
|
||||
... os_type="ios",
|
||||
... config_data={
|
||||
... "ospf": {
|
||||
... "enabled": True,
|
||||
... "process_id": 100,
|
||||
... "router_id": "1.1.1.1",
|
||||
... "networks": [
|
||||
... {"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}
|
||||
... ]
|
||||
... }
|
||||
... }
|
||||
... )
|
||||
|
||||
Supported Features:
|
||||
- ospf: OSPF routing protocol
|
||||
- bgp: BGP routing protocol
|
||||
- interface: Interface configuration
|
||||
- vlan: VLAN configuration
|
||||
- acl: Access control lists
|
||||
- nat: NAT configuration
|
||||
- rip: RIP routing protocol
|
||||
- eigrp: EIGRP routing protocol
|
||||
"""
|
||||
from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
|
||||
|
||||
renderer = ConfigRenderer()
|
||||
|
||||
try:
|
||||
# Validate data
|
||||
feature = list(config_data.keys())[0] if len(config_data) == 1 else None
|
||||
if feature:
|
||||
renderer.validate_data(f"{vendor}_{feature}", config_data)
|
||||
|
||||
# Render
|
||||
if len(config_data) == 1:
|
||||
feature = list(config_data.keys())[0]
|
||||
config = renderer.render(vendor, os_type, feature, config_data)
|
||||
else:
|
||||
config = renderer.render_multi(vendor, os_type, config_data)
|
||||
|
||||
return f"Configuration rendered successfully:\n{config}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
@tool
|
||||
def list_available_templates() -> Dict[str, Any]:
|
||||
"""
|
||||
List all available configuration templates organized by vendor and OS type.
|
||||
|
||||
Returns:
|
||||
Dictionary of available templates:
|
||||
{
|
||||
"cisco": {
|
||||
"ios": ["ospf", "bgp", "interface", "vlan", "acl", "nat"],
|
||||
"nx-os": ["ospf", "bgp", "interface"]
|
||||
},
|
||||
"juniper": {
|
||||
"junos": ["ospf", "bgp", "interface"]
|
||||
}
|
||||
}
|
||||
|
||||
Use this to understand what features are supported for each vendor/OS combination.
|
||||
"""
|
||||
from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
|
||||
|
||||
renderer = ConfigRenderer()
|
||||
return renderer.get_available_templates()
|
||||
|
||||
|
||||
@tool
|
||||
def validate_config_data(
|
||||
vendor: str,
|
||||
feature: str,
|
||||
config_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate configuration data against JSON schema before rendering.
|
||||
|
||||
Args:
|
||||
vendor: Device vendor (cisco, juniper, huawei, etc.)
|
||||
feature: Feature name (ospf, bgp, interface, etc.)
|
||||
config_data: Configuration data to validate
|
||||
|
||||
Returns:
|
||||
Validation result with status and optional error details
|
||||
|
||||
Example:
|
||||
>>> result = validate_config_data(
|
||||
... vendor="cisco",
|
||||
... feature="ospf",
|
||||
... config_data={"ospf": {"enabled": True, "process_id": 100}}
|
||||
... )
|
||||
>>> # Returns: {"status": "valid"}
|
||||
"""
|
||||
from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
|
||||
|
||||
renderer = ConfigRenderer()
|
||||
|
||||
try:
|
||||
renderer.validate_data(f"{vendor}_{feature}", config_data)
|
||||
return {"status": "valid", "message": "Configuration data is valid"}
|
||||
except Exception as e:
|
||||
return {"status": "invalid", "errors": str(e)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Agent Integration Example
|
||||
|
||||
```python
|
||||
# gns3server/agent/gns3_copilot/agent/config_agent.py
|
||||
|
||||
from langchain.agents import create_openai_functions_agent, AgentExecutor
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
|
||||
# Define tools
|
||||
tools = [
|
||||
render_device_config,
|
||||
list_available_templates,
|
||||
validate_config_data,
|
||||
# ... other GNS3 tools
|
||||
]
|
||||
|
||||
# Create prompt
|
||||
prompt = ChatPromptTemplate.from_messages([
|
||||
("system", CONFIG_GENERATION_SYSTEM_PROMPT),
|
||||
MessagesPlaceholder(variable_name="chat_history", optional=True),
|
||||
("human", "{input}"),
|
||||
MessagesPlaceholder(variable_name="agent_scratchpad"),
|
||||
])
|
||||
|
||||
# Create agent
|
||||
agent = create_openai_functions_agent(llm, tools, prompt)
|
||||
agent_executor = AgentExecutor(
|
||||
agent=agent,
|
||||
tools=tools,
|
||||
verbose=True,
|
||||
handle_parsing_errors=True,
|
||||
max_iterations=5
|
||||
)
|
||||
|
||||
# Example usage
|
||||
async def configure_device(user_message: str):
|
||||
response = await agent_executor.ainvoke({
|
||||
"input": user_message,
|
||||
"chat_history": []
|
||||
})
|
||||
return response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing the AI Prompts
|
||||
|
||||
Use these test cases to verify the AI generates correct structured data:
|
||||
|
||||
```python
|
||||
test_cases = [
|
||||
{
|
||||
"input": "Configure OSPF process 100 with network 192.168.1.0/24 in area 0",
|
||||
"expected_keys": ["ospf"],
|
||||
"expected_values": {
|
||||
"ospf.process_id": 100,
|
||||
"ospf.networks[0].address": "192.168.1.0",
|
||||
"ospf.networks[0].area": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"input": "Set up BGP AS 65001, neighbor 10.0.0.2 remote-as 65002",
|
||||
"expected_keys": ["bgp"],
|
||||
"expected_values": {
|
||||
"bgp.as_number": 65001,
|
||||
"bgp.neighbors[0].ip": "10.0.0.2",
|
||||
"bgp.neighbors[0].remote_as": 65002
|
||||
}
|
||||
},
|
||||
# ... more test cases
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Continuous Improvement
|
||||
|
||||
1. **Collect user interactions** - Save actual requests and AI responses
|
||||
2. **Analyze errors** - Find patterns in failed generations
|
||||
3. **Update prompts** - Refine examples and instructions
|
||||
4. **Expand schemas** - Add new features as needed
|
||||
5. **Vendor feedback** - Learn from network engineers
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference Card
|
||||
|
||||
### What AI Should Do
|
||||
|
||||
| User Says | AI Generates |
|
||||
|-----------|-------------|
|
||||
| "OSPF process 100" | `{"ospf": {"enabled": True, "process_id": 100}}` |
|
||||
| "network 192.168.1.0/24 area 0" | `{"networks": [{"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}]}` |
|
||||
| "BGP AS 65001" | `{"bgp": {"enabled": True, "as_number": 65001}}` |
|
||||
| "interface 192.168.1.1/24" | `{"ip_address": "192.168.1.1", "subnet_mask": "255.255.255.0"}` |
|
||||
|
||||
### What AI Should NOT Do
|
||||
|
||||
| Don't ❌ | Instead ✅ |
|
||||
|---------|-----------|
|
||||
| Output "router ospf 100" | Output `{"ospf": {"process_id": 100}}` |
|
||||
| Guess missing values | Ask user for missing values |
|
||||
| Assume vendor/OS | Ask or detect from node |
|
||||
| Mix features in one dict | Separate by feature key |
|
||||
| Use string for numbers | Use int: `process_id: 100` not `"process_id": "100"` |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
@ -1,736 +0,0 @@
|
||||
# Configuration Templates Implementation Guide
|
||||
|
||||
## Quick Start Examples
|
||||
|
||||
### Example 1: Configure OSPF on a Cisco Router
|
||||
|
||||
**User Request**:
|
||||
```
|
||||
"Configure OSPF on R1 with process ID 100, router-id 1.1.1.1.
|
||||
Include network 192.168.1.0/24 in area 0 and 10.0.0.0/8 in area 1.
|
||||
Make GigabitEthernet0/0 a passive interface."
|
||||
```
|
||||
|
||||
**AI Should Generate** (structured JSON):
|
||||
```json
|
||||
{
|
||||
"ospf": {
|
||||
"enabled": true,
|
||||
"process_id": 100,
|
||||
"router_id": "1.1.1.1",
|
||||
"networks": [
|
||||
{
|
||||
"address": "192.168.1.0",
|
||||
"wildcard": "0.0.0.255",
|
||||
"area": 0
|
||||
},
|
||||
{
|
||||
"address": "10.0.0.0",
|
||||
"wildcard": "0.255.255.255",
|
||||
"area": 1
|
||||
}
|
||||
],
|
||||
"passive_interfaces": ["GigabitEthernet0/0"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Agent Action**:
|
||||
```python
|
||||
# The agent calls the render tool with the structured data
|
||||
result = render_device_config(
|
||||
node_id="node-1",
|
||||
vendor="cisco",
|
||||
os_type="ios",
|
||||
config_data=ai_output
|
||||
)
|
||||
```
|
||||
|
||||
**Rendered Configuration**:
|
||||
```cisco
|
||||
router ospf 100
|
||||
router-id 1.1.1.1
|
||||
network 192.168.1.0 mask 0.0.0.255 area 0
|
||||
network 10.0.0.0 mask 0.255.255.255 area 1
|
||||
passive-interface GigabitEthernet0/0
|
||||
!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 2: Configure BGP with Multiple Neighbors
|
||||
|
||||
**User Request**:
|
||||
```
|
||||
"Configure BGP on R1 with AS 65001. Set up IBGP with R2 (10.0.0.2, AS 65001)
|
||||
and EBGP with ISP (203.0.13.2, AS 65002). Advertise network 192.168.0.0/16.
|
||||
Enable route-map INBOUND-FILTER on R2 inbound."
|
||||
```
|
||||
|
||||
**AI Generates**:
|
||||
```json
|
||||
{
|
||||
"bgp": {
|
||||
"enabled": true,
|
||||
"as_number": 65001,
|
||||
"router_id": "1.1.1.1",
|
||||
"log_neighbor_changes": true,
|
||||
"neighbors": [
|
||||
{
|
||||
"ip": "10.0.0.2",
|
||||
"remote_as": 65001,
|
||||
"description": "IBGP_Peer_R2",
|
||||
"next_hop_self": true,
|
||||
"route_map_in": "INBOUND-FILTER"
|
||||
},
|
||||
{
|
||||
"ip": "203.0.13.2",
|
||||
"remote_as": 65002,
|
||||
"description": "ISP_EBGP",
|
||||
"ebgp_multihop": 2
|
||||
}
|
||||
],
|
||||
"address_families": [
|
||||
{
|
||||
"type": "ipv4",
|
||||
"networks": [
|
||||
{
|
||||
"address": "192.168.0.0",
|
||||
"mask": "255.255.0.0"
|
||||
}
|
||||
],
|
||||
"neighbors": [
|
||||
{"ip": "10.0.0.2", "activate": true},
|
||||
{"ip": "203.0.13.2", "activate": true}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rendered Configuration**:
|
||||
```cisco
|
||||
router bgp 65001
|
||||
bgp router-id 1.1.1.1
|
||||
bgp log-neighbor-changes
|
||||
neighbor 10.0.0.2 remote-as 65001
|
||||
neighbor 10.0.0.2 description IBGP_Peer_R2
|
||||
neighbor 10.0.0.2 next-hop-self
|
||||
neighbor 10.0.0.2 route-map INBOUND-FILTER in
|
||||
neighbor 203.0.13.2 remote-as 65002
|
||||
neighbor 203.0.13.2 description ISP_EBGP
|
||||
neighbor 203.0.13.2 ebgp-multihop 2
|
||||
address-family ipv4
|
||||
network 192.168.0.0 mask 255.255.0.0
|
||||
neighbor 10.0.0.2 activate
|
||||
neighbor 203.0.13.2 activate
|
||||
exit-address-family
|
||||
!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 3: Configure Interfaces with IP Addresses
|
||||
|
||||
**User Request**:
|
||||
```
|
||||
"Configure GigabitEthernet0/0 with IP 192.168.1.1/24, description 'LAN Network'.
|
||||
Configure GigabitEthernet0/1 with IP 10.0.0.1/30, description 'WAN Link'.
|
||||
Both interfaces should be enabled."
|
||||
```
|
||||
|
||||
**AI Generates**:
|
||||
```json
|
||||
{
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "GigabitEthernet0/0",
|
||||
"description": "LAN Network",
|
||||
"ip_address": "192.168.1.1",
|
||||
"subnet_mask": "255.255.255.0",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "GigabitEthernet0/1",
|
||||
"description": "WAN Link",
|
||||
"ip_address": "10.0.0.1",
|
||||
"subnet_mask": "255.255.255.252",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Rendered Configuration**:
|
||||
```cisco
|
||||
interface GigabitEthernet0/0
|
||||
description LAN Network
|
||||
ip address 192.168.1.1 255.255.255.0
|
||||
no shutdown
|
||||
!
|
||||
interface GigabitEthernet0/1
|
||||
description WAN Link
|
||||
ip address 10.0.0.1 255.255.255.252
|
||||
no shutdown
|
||||
!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Example 4: Multi-Feature Configuration
|
||||
|
||||
**User Request**:
|
||||
```
|
||||
"Configure R1 as follows:
|
||||
- Hostname: CORE-R1
|
||||
- GigabitEthernet0/0: 192.168.1.1/24, LAN, enable NAT inside
|
||||
- GigabitEthernet0/1: 203.0.13.1/30, WAN, enable NAT outside
|
||||
- OSPF: process 100, router-id 1.1.1.1, advertise 192.168.1.0/24 in area 0
|
||||
- NAT: overload interface GigabitEthernet0/1 for 192.168.1.0/24"
|
||||
```
|
||||
|
||||
**AI Generates** (complete configuration):
|
||||
```json
|
||||
{
|
||||
"hostname": "CORE-R1",
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "GigabitEthernet0/0",
|
||||
"description": "LAN",
|
||||
"ip_address": "192.168.1.1",
|
||||
"subnet_mask": "255.255.255.0",
|
||||
"nat_inside": true,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "GigabitEthernet0/1",
|
||||
"description": "WAN",
|
||||
"ip_address": "203.0.13.1",
|
||||
"subnet_mask": "255.255.255.252",
|
||||
"nat_outside": true,
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"ospf": {
|
||||
"enabled": true,
|
||||
"process_id": 100,
|
||||
"router_id": "1.1.1.1",
|
||||
"networks": [
|
||||
{
|
||||
"address": "192.168.1.0",
|
||||
"wildcard": "0.0.0.255",
|
||||
"area": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"nat": {
|
||||
"inside_source": {
|
||||
"pool": "LAN_POOL",
|
||||
"network": "192.168.1.0",
|
||||
"mask": "255.255.255.0",
|
||||
"interface": "GigabitEthernet0/1",
|
||||
"overload": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 5: Juniper JunOS Configuration
|
||||
|
||||
**User Request**:
|
||||
```
|
||||
"Configure Juniper SRX with OSPF area 0 on interface ge-0/0/0.0 with IP 192.168.1.1/24.
|
||||
Set router-id to 10.1.1.1."
|
||||
```
|
||||
|
||||
**AI Generates**:
|
||||
```json
|
||||
{
|
||||
"ospf": {
|
||||
"enabled": true,
|
||||
"router_id": "10.1.1.1",
|
||||
"areas": [
|
||||
{
|
||||
"area_id": "0.0.0.0",
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "ge-0/0/0.0",
|
||||
"address": "192.168.1.1/24"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Template**: `config_templates/juniper/junos/ospf.j2`
|
||||
```jinja2
|
||||
{% if ospf.enabled %}
|
||||
protocols {
|
||||
ospf {
|
||||
{% if ospf.router_id %}
|
||||
router-id {{ ospf.router_id }};
|
||||
{% endif %}
|
||||
{% for area in ospf.areas %}
|
||||
area {{ area.area_id }} {
|
||||
{% for iface in area.interfaces %}
|
||||
interface {{ iface.name }} {
|
||||
{% if iface.address %}
|
||||
family inet {
|
||||
address {{ iface.address }};
|
||||
}
|
||||
{% endif %}
|
||||
}
|
||||
{% endfor %}
|
||||
}
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
**Rendered Configuration**:
|
||||
```junos
|
||||
protocols {
|
||||
ospf {
|
||||
router-id 10.1.1.1;
|
||||
area 0.0.0.0 {
|
||||
interface ge-0/0/0.0 {
|
||||
family inet {
|
||||
address 192.168.1.1/24;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with LangGraph Agent
|
||||
|
||||
### Updated Agent Flow
|
||||
|
||||
```python
|
||||
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
|
||||
from langchain.agents import AgentExecutor, create_openai_functions_agent
|
||||
from langchain.tools import tool
|
||||
|
||||
@tool
|
||||
def render_and_apply_config(
|
||||
node_id: str,
|
||||
vendor: str,
|
||||
os_type: str,
|
||||
config_data: dict
|
||||
) -> str:
|
||||
"""Render configuration and apply to device"""
|
||||
from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
|
||||
|
||||
renderer = ConfigRenderer()
|
||||
|
||||
# Step 1: Validate
|
||||
try:
|
||||
renderer.validate_data(f"{vendor}_config", config_data)
|
||||
except Exception as e:
|
||||
return f"Validation failed: {e}"
|
||||
|
||||
# Step 2: Render
|
||||
try:
|
||||
if len(config_data) == 1:
|
||||
feature = list(config_data.keys())[0]
|
||||
config = renderer.render(vendor, os_type, feature, config_data)
|
||||
else:
|
||||
config = renderer.render_multi(vendor, os_type, config_data)
|
||||
|
||||
# Step 3: Apply to device (via telnet/console/SSH)
|
||||
# result = apply_config_to_node(node_id, config)
|
||||
return f"Configuration rendered successfully:\n{config}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Rendering failed: {e}"
|
||||
|
||||
|
||||
# Updated agent prompt
|
||||
SYSTEM_PROMPT = """
|
||||
You are a network configuration assistant for GNS3.
|
||||
|
||||
When users ask to configure network devices:
|
||||
1. Extract the configuration requirements
|
||||
2. Generate STRUCTURED DATA (JSON/dict), NOT full configuration text
|
||||
3. Call the render_and_apply_config tool with the structured data
|
||||
4. The system will render the actual configuration using templates
|
||||
|
||||
Example for OSPF:
|
||||
- User: "Configure OSPF with process 100, network 192.168.1.0/24 in area 0"
|
||||
- You should output: {"ospf": {"enabled": true, "process_id": 100, ...}}
|
||||
|
||||
Available vendors: cisco, juniper, huawei, arista
|
||||
Available OS types: ios, iosxr, nexus, junos, vrp, eos
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Template Snippets Library
|
||||
|
||||
### OSPF Interface Templates
|
||||
|
||||
**Cisco IOS**:
|
||||
```jinja2
|
||||
{# ospf.j2 - Cisco IOS OSPF #}
|
||||
{% if ospf.enabled %}
|
||||
router ospf {{ ospf.process_id }}
|
||||
{% if ospf.router_id %}
|
||||
router-id {{ ospf.router_id }}
|
||||
{% endif %}
|
||||
{% for network in ospf.networks %}
|
||||
network {{ network.address }} mask {{ network.wildcard }} area {{ network.area }}
|
||||
{% endfor %}
|
||||
{% for iface in ospf.passive_interfaces %}
|
||||
passive-interface {{ iface }}
|
||||
{% endfor %}
|
||||
!
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
**Juniper JunOS**:
|
||||
```jinja2
|
||||
{# ospf.j2 - Juniper JunOS OSPF #}
|
||||
{% if ospf.enabled %}
|
||||
protocols {
|
||||
ospf {
|
||||
{% if ospf.router_id %}
|
||||
router-id {{ ospf.router_id }};
|
||||
{% endif %}
|
||||
{% for area in ospf.areas %}
|
||||
area {{ area.area_id }} {
|
||||
{% for iface in area.interfaces %}
|
||||
interface {{ iface.name }};
|
||||
{% endfor %}
|
||||
}
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
**Huawei VRP**:
|
||||
```jinja2
|
||||
{# ospf.j2 - Huawei VRP OSPF #}
|
||||
{% if ospf.enabled %}
|
||||
ospf {{ ospf.process_id }}
|
||||
{% if ospf.router_id %}
|
||||
router-id {{ ospf.router_id }}
|
||||
{% endif %}
|
||||
{% for area in ospf.areas %}
|
||||
area {{ area.area_id }}
|
||||
{% for network in area.networks %}
|
||||
network {{ network.address }} {{ network.wildcard }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Framework
|
||||
|
||||
### Unit Test for Template Rendering
|
||||
|
||||
```python
|
||||
# tests/agent/test_config_renderer.py
|
||||
|
||||
import pytest
|
||||
from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
|
||||
|
||||
def test_ospf_cisco_ios():
|
||||
"""Test OSPF configuration rendering for Cisco IOS"""
|
||||
renderer = ConfigRenderer()
|
||||
|
||||
data = {
|
||||
"ospf": {
|
||||
"enabled": True,
|
||||
"process_id": 100,
|
||||
"router_id": "1.1.1.1",
|
||||
"networks": [
|
||||
{"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
config = renderer.render("cisco", "ios", "ospf", data)
|
||||
|
||||
assert "router ospf 100" in config
|
||||
assert "router-id 1.1.1.1" in config
|
||||
assert "network 192.168.1.0 mask 0.0.0.255 area 0" in config
|
||||
|
||||
def test_bgp_cisco_ios():
|
||||
"""Test BGP configuration rendering for Cisco IOS"""
|
||||
renderer = ConfigRenderer()
|
||||
|
||||
data = {
|
||||
"bgp": {
|
||||
"enabled": True,
|
||||
"as_number": 65001,
|
||||
"neighbors": [
|
||||
{"ip": "10.0.0.2", "remote_as": 65002}
|
||||
],
|
||||
"address_families": [
|
||||
{
|
||||
"type": "ipv4",
|
||||
"neighbors": [
|
||||
{"ip": "10.0.0.2", "activate": True}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
config = renderer.render("cisco", "ios", "bgp", data)
|
||||
|
||||
assert "router bgp 65001" in config
|
||||
assert "neighbor 10.0.0.2 remote-as 65002" in config
|
||||
assert "address-family ipv4" in config
|
||||
assert "neighbor 10.0.0.2 activate" in config
|
||||
|
||||
def test_interface_cisco_ios():
|
||||
"""Test interface configuration rendering"""
|
||||
renderer = ConfigRenderer()
|
||||
|
||||
data = {
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "GigabitEthernet0/0",
|
||||
"description": "Test Interface",
|
||||
"ip_address": "192.168.1.1",
|
||||
"subnet_mask": "255.255.255.0",
|
||||
"enabled": True
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
config = renderer.render("cisco", "ios", "interface", data)
|
||||
|
||||
assert "interface GigabitEthernet0/0" in config
|
||||
assert "description Test Interface" in config
|
||||
assert "ip address 192.168.1.1 255.255.255.0" in config
|
||||
assert "no shutdown" in config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Integration
|
||||
|
||||
### New Controller Endpoint
|
||||
|
||||
```python
|
||||
# gns3server/api/routes/controller/config_templates.py
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import Dict, Any
|
||||
from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/config-templates")
|
||||
async def list_templates() -> Dict[str, Any]:
|
||||
"""List all available configuration templates"""
|
||||
renderer = ConfigRenderer()
|
||||
return renderer.get_available_templates()
|
||||
|
||||
@router.post("/config-templates/render")
|
||||
async def render_config_template(
|
||||
vendor: str,
|
||||
os_type: str,
|
||||
feature: str,
|
||||
data: Dict[str, Any]
|
||||
) -> Dict[str, str]:
|
||||
"""Render a configuration template with provided data"""
|
||||
renderer = ConfigRenderer()
|
||||
|
||||
try:
|
||||
config = renderer.render(vendor, os_type, feature, data)
|
||||
return {"status": "success", "config": config}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@router.post("/config-templates/validate")
|
||||
async def validate_config_data(
|
||||
schema_name: str,
|
||||
data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate configuration data against schema"""
|
||||
renderer = ConfigRenderer()
|
||||
|
||||
try:
|
||||
is_valid = renderer.validate_data(schema_name, data)
|
||||
return {"status": "valid"}
|
||||
except Exception as e:
|
||||
return {"status": "invalid", "errors": str(e)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prompt Engineering for AI
|
||||
|
||||
### System Prompt Template
|
||||
|
||||
```python
|
||||
CONFIG_GENERATION_PROMPT = """
|
||||
You are a network configuration expert. When users request device configurations:
|
||||
|
||||
1. UNDERSTAND the requirements (vendor, OS, features, parameters)
|
||||
2. GENERATE structured data (dict/JSON), NOT full configuration text
|
||||
3. CALL the appropriate rendering tool with the structured data
|
||||
|
||||
RULES:
|
||||
- NEVER output full configuration text directly
|
||||
- ALWAYS use structured data format
|
||||
- Include only the parameters that are explicitly mentioned
|
||||
- Use correct data types (int for numbers, bool for flags)
|
||||
- Follow the JSON schema for each feature
|
||||
|
||||
VENDORS: cisco, juniper, huawei, arista, mikrotik
|
||||
OS TYPES: ios, iosxr, nx-os, junos, vrp, eos, routeros
|
||||
|
||||
FEATURES AVAILABLE:
|
||||
- ospf: process_id, router_id, networks[{address,wildcard,area}]
|
||||
- bgp: as_number, router_id, neighbors[{ip,remote_as,description,...}]
|
||||
- interface: name, ip_address, subnet_mask, description, enabled
|
||||
- vlan: id, name, interfaces[]
|
||||
- acl: number, rules[{action,protocol,source,destination}]
|
||||
- nat: inside_source, outside_source, static
|
||||
|
||||
EXAMPLE:
|
||||
|
||||
User: "Configure OSPF process 100 with router-id 1.1.1.1, network 192.168.1.0/24 area 0"
|
||||
|
||||
Your tool call:
|
||||
render_device_config(
|
||||
node_id="node-1",
|
||||
vendor="cisco",
|
||||
os_type="ios",
|
||||
config_data={{
|
||||
"ospf": {{
|
||||
"enabled": True,
|
||||
"process_id": 100,
|
||||
"router_id": "1.1.1.1",
|
||||
"networks": [
|
||||
{{"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}}
|
||||
]
|
||||
}}
|
||||
}}
|
||||
)
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Core Templates (Week 1-2)
|
||||
- Cisco IOS: ospf, bgp, interface, vlan, acl
|
||||
- Juniper JunOS: ospf, bgp, interface
|
||||
- Schema definitions
|
||||
|
||||
### Phase 2: Extended Features (Week 3-4)
|
||||
- NAT, QoS, Multicast
|
||||
- Nexus, IOS-XR variants
|
||||
- Huawei VRP support
|
||||
|
||||
### Phase 3: Advanced Features (Week 5-6)
|
||||
- MPLS, VPN
|
||||
- Firewall policies (ASA, SRX)
|
||||
- Automation and testing
|
||||
|
||||
### Phase 4: Integration (Week 7-8)
|
||||
- Integrate with AI Copilot
|
||||
- Add rendering endpoint to API
|
||||
- Testing and validation
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Template Design**:
|
||||
- Keep templates simple and focused
|
||||
- Use conditionals sparingly
|
||||
- Add comments for complex logic
|
||||
- Follow vendor syntax conventions
|
||||
|
||||
2. **Schema Design**:
|
||||
- Define all fields with types
|
||||
- Add descriptions for AI
|
||||
- Include validation rules
|
||||
- Use enums for fixed values
|
||||
|
||||
3. **AI Prompting**:
|
||||
- Provide clear examples
|
||||
- Specify expected output format
|
||||
- Include error handling guidance
|
||||
- Test with various inputs
|
||||
|
||||
4. **Testing**:
|
||||
- Unit test each template
|
||||
- Test with real devices
|
||||
- Validate schemas
|
||||
- Integration testing
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue**: Template not found
|
||||
```
|
||||
Solution: Check template path format: "{vendor}/{os_type}/{feature}.j2"
|
||||
```
|
||||
|
||||
**Issue**: Invalid data structure
|
||||
```
|
||||
Solution: Validate against JSON schema first
|
||||
```
|
||||
|
||||
**Issue**: Rendering produces empty config
|
||||
```
|
||||
Solution: Check if feature flag "enabled" is set to True
|
||||
```
|
||||
|
||||
**Issue**: Syntax error in rendered config
|
||||
```
|
||||
Solution: Review template logic, check conditional statements
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
@ -1,852 +0,0 @@
|
||||
# Enhance `/me` Endpoint with Groups, Pools, and ACEs
|
||||
|
||||
**Document Status**: Design Phase
|
||||
**Priority**: High
|
||||
**Created**: 2026-03-06
|
||||
**Related Docs**: [User-Selectable Group Default Config](./user-selectable-group-default-config.md)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Problem Description](#problem-description)
|
||||
- [Data Model Analysis](#data-model-analysis)
|
||||
- [Solution Design](#solution-design)
|
||||
- [Implementation Steps](#implementation-steps)
|
||||
- [API Response Structure](#api-response-structure)
|
||||
- [Testing Plan](#testing-plan)
|
||||
|
||||
---
|
||||
|
||||
## Problem Description
|
||||
|
||||
### Current Behavior
|
||||
|
||||
The `/me` endpoint only returns basic user information. Users cannot easily see:
|
||||
1. Which groups they belong to
|
||||
2. Which resource pools they have access to
|
||||
3. Their access control entries (ACEs)
|
||||
|
||||
### User Needs
|
||||
|
||||
1. **Group Membership**: Understand inherited configs and permissions
|
||||
2. **Pool Access**: Know which resource pools are available
|
||||
3. **ACE Visibility**: See what access control rules apply to them
|
||||
|
||||
---
|
||||
|
||||
## Data Model Analysis
|
||||
|
||||
### User Model Relationships
|
||||
|
||||
**File**: `gns3server/db/models/users.py:38-52`
|
||||
|
||||
```python
|
||||
class User(BaseTable):
|
||||
__tablename__ = "users"
|
||||
|
||||
user_id = Column(GUID, primary_key=True, default=generate_uuid)
|
||||
username = Column(String, unique=True, index=True)
|
||||
email = Column(String, unique=True, index=True)
|
||||
full_name = Column(String)
|
||||
hashed_password = Column(String)
|
||||
last_login = Column(DateTime)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_superadmin = Column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
groups = relationship("UserGroup", secondary=user_group_map, back_populates="users")
|
||||
acl_entries = relationship("ACE") # User's direct ACEs
|
||||
```
|
||||
|
||||
### ACE Model
|
||||
|
||||
**File**: `gns3server/db/models/acl.py:28-46`
|
||||
|
||||
```python
|
||||
class ACE(BaseTable):
|
||||
__tablename__ = "acl"
|
||||
|
||||
ace_id = Column(GUID, primary_key=True, default=generate_uuid)
|
||||
ace_type = Column(String) # "user" or "group"
|
||||
path = Column(String) # e.g., "/pools/{pool_id}", "/projects"
|
||||
propagate = Column(Boolean, default=True)
|
||||
allowed = Column(Boolean, default=True)
|
||||
user_id = Column(GUID, ForeignKey('users.user_id', ondelete="CASCADE"))
|
||||
user = relationship("User", back_populates="acl_entries")
|
||||
group_id = Column(GUID, ForeignKey('user_groups.user_group_id', ondelete="CASCADE"))
|
||||
group = relationship("UserGroup", back_populates="acl_entries")
|
||||
role_id = Column(GUID, ForeignKey('roles.role_id', ondelete="CASCADE"))
|
||||
role = relationship("Role", back_populates="acl_entries")
|
||||
```
|
||||
|
||||
### Resource Pool Model
|
||||
|
||||
**File**: `gns3server/db/models/pools.py:46-53`
|
||||
|
||||
```python
|
||||
class ResourcePool(BaseTable):
|
||||
__tablename__ = "resource_pools"
|
||||
|
||||
resource_pool_id = Column(GUID, primary_key=True, default=generate_uuid)
|
||||
name = Column(String, unique=True, index=True)
|
||||
resources = relationship("Resource", secondary=resource_pool_map, back_populates="resource_pools")
|
||||
```
|
||||
|
||||
### Key Relationships
|
||||
|
||||
```
|
||||
User ────< UserGroup > (via user_group_map)
|
||||
│
|
||||
└───< ACE (user_id)
|
||||
│
|
||||
├── path = "/pools/{pool_id}" → ResourcePool
|
||||
├── path = "/projects"
|
||||
├── role → Role → Privilege
|
||||
└── allowed (boolean)
|
||||
|
||||
UserGroup ────< ACE (group_id)
|
||||
│
|
||||
└─── users
|
||||
```
|
||||
|
||||
### Pool Path Format
|
||||
|
||||
From `gns3server/db/repositories/rbac.py:326-327`:
|
||||
|
||||
```python
|
||||
if ace_path.startswith("/pool"):
|
||||
resource_pool_id = ace_path.split("/")[2]
|
||||
```
|
||||
|
||||
**Pool ACE Path Format**: `/pools/{resource_pool_id}`
|
||||
|
||||
---
|
||||
|
||||
## Solution Design
|
||||
|
||||
### Approach
|
||||
|
||||
1. **Groups**: Eager load via `selectinload(User.groups)`
|
||||
2. **Pools**: Extract from user and group ACEs where path starts with `/pools/`
|
||||
3. **ACEs**: Aggregate user's direct ACEs and group ACEs
|
||||
|
||||
### Response Structure
|
||||
|
||||
```json
|
||||
{
|
||||
// ===== Basic User Info =====
|
||||
"user_id": "uuid",
|
||||
"username": "string",
|
||||
"email": "string",
|
||||
"full_name": "string",
|
||||
"is_active": true,
|
||||
"is_superadmin": false,
|
||||
"last_login": "datetime",
|
||||
"created_at": "datetime",
|
||||
"updated_at": "datetime",
|
||||
|
||||
// ===== User Groups =====
|
||||
"groups": [
|
||||
{
|
||||
"user_group_id": "uuid",
|
||||
"name": "Developers",
|
||||
"is_builtin": false,
|
||||
"created_at": "datetime",
|
||||
"updated_at": "datetime"
|
||||
}
|
||||
],
|
||||
|
||||
// ===== Accessible Pools =====
|
||||
"pools": [
|
||||
{
|
||||
"resource_pool_id": "uuid",
|
||||
"name": "Production Pool",
|
||||
"access_source": "user", // "user" or "group"
|
||||
"access_allowed": true
|
||||
}
|
||||
],
|
||||
|
||||
// ===== ACEs =====
|
||||
"aces": [
|
||||
{
|
||||
"ace_id": "uuid",
|
||||
"path": "/pools/{pool_id}",
|
||||
"allowed": true,
|
||||
"propagate": true,
|
||||
"ace_type": "user", // "user" or "group"
|
||||
"source_group_id": null, // null if ace_type is "user"
|
||||
"source_group_name": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Update Schemas
|
||||
|
||||
**File**: `gns3server/schemas/controller/users.py`
|
||||
|
||||
```python
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import ConfigDict, EmailStr, BaseModel, Field, SecretStr
|
||||
from uuid import UUID
|
||||
|
||||
from .base import DateTimeModelMixin
|
||||
|
||||
|
||||
class UserGroup(BaseModel):
|
||||
"""User group reference."""
|
||||
user_group_id: UUID
|
||||
name: str
|
||||
is_builtin: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ResourcePoolInfo(BaseModel):
|
||||
"""Resource pool info accessible to user."""
|
||||
resource_pool_id: UUID
|
||||
name: str
|
||||
access_source: str = Field(..., description="'user' or 'group'")
|
||||
access_allowed: bool = Field(..., description="Whether access is allowed")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ACEInfo(BaseModel):
|
||||
"""Access Control Entry info."""
|
||||
ace_id: UUID
|
||||
path: str
|
||||
allowed: bool
|
||||
propagate: bool
|
||||
ace_type: str = Field(..., description="'user' or 'group'")
|
||||
source_group_id: Optional[UUID] = Field(None, description="Group ID if from group ACE")
|
||||
source_group_name: Optional[str] = Field(None, description="Group name if from group ACE")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Common user properties."""
|
||||
username: Optional[str] = Field(None, min_length=3, pattern="[a-zA-Z0-9_-]+$")
|
||||
is_active: bool = True
|
||||
email: Optional[EmailStr] = None
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
class User(DateTimeModelMixin, UserBase):
|
||||
user_id: UUID
|
||||
last_login: Optional[datetime] = None
|
||||
is_superadmin: bool = False
|
||||
|
||||
# NEW FIELDS
|
||||
groups: List[UserGroup] = []
|
||||
pools: List[ResourcePoolInfo] = []
|
||||
aces: List[ACEInfo] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# Other existing schemas...
|
||||
class UserCreate(UserBase):
|
||||
username: str = Field(..., min_length=3, pattern="[a-zA-Z0-9_-]+$")
|
||||
password: SecretStr = Field(..., min_length=8, max_length=100)
|
||||
|
||||
|
||||
class UserUpdate(UserBase):
|
||||
password: Optional[SecretStr] = Field(None, min_length=8, max_length=100)
|
||||
|
||||
|
||||
class LoggedInUserUpdate(BaseModel):
|
||||
password: Optional[SecretStr] = Field(None, min_length=8, max_length=100)
|
||||
email: Optional[EmailStr] = None
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
class Credentials(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
```
|
||||
|
||||
### Step 2: Update Repository Method
|
||||
|
||||
**File**: `gns3server/db/repositories/users.py`
|
||||
|
||||
```python
|
||||
from uuid import UUID
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy import select, update, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from .base import BaseRepository
|
||||
|
||||
import gns3server.db.models as models
|
||||
from gns3server import schemas
|
||||
from gns3server.services import auth_service
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UsersRepository(BaseRepository):
|
||||
|
||||
# ... existing methods ...
|
||||
|
||||
async def get_user_with_details(
|
||||
self,
|
||||
user_id: UUID,
|
||||
include_pools: bool = True,
|
||||
include_aces: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get user with groups, pools, and ACEs.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
include_pools: Whether to include accessible resource pools
|
||||
include_aces: Whether to include ACEs
|
||||
|
||||
Returns:
|
||||
Dictionary with user, groups, pools, and aces
|
||||
"""
|
||||
|
||||
# Get user with groups eagerly loaded
|
||||
query = select(models.User).where(
|
||||
models.User.user_id == user_id
|
||||
).options(selectinload(models.User.groups))
|
||||
|
||||
result = await self._db_session.execute(query)
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Prepare response
|
||||
response = {
|
||||
"user": user,
|
||||
"groups": list(user.groups),
|
||||
"pools": [],
|
||||
"aces": []
|
||||
}
|
||||
|
||||
if not include_pools and not include_aces:
|
||||
return response
|
||||
|
||||
# Get user's direct ACEs
|
||||
user_aces_query = select(models.ACE).where(
|
||||
models.ACE.user_id == user_id
|
||||
)
|
||||
user_aces_result = await self._db_session.execute(user_aces_query)
|
||||
user_aces = user_aces_result.scalars().all()
|
||||
|
||||
# Get group ACEs (inherited from user's groups)
|
||||
group_aces = []
|
||||
for group in user.groups:
|
||||
group_aces_query = select(models.ACE).where(
|
||||
models.ACE.group_id == group.user_group_id
|
||||
)
|
||||
group_aces_result = await self._db_session.execute(group_aces_query)
|
||||
group_aces.extend(group_aces_result.scalars().all())
|
||||
|
||||
# Process ACEs and extract pools
|
||||
pool_ids_seen = set()
|
||||
|
||||
if include_aces:
|
||||
# Add user ACEs
|
||||
for ace in user_aces:
|
||||
response["aces"].append({
|
||||
"ace_id": ace.ace_id,
|
||||
"path": ace.path,
|
||||
"allowed": ace.allowed,
|
||||
"propagate": ace.propagate,
|
||||
"ace_type": "user",
|
||||
"source_group_id": None,
|
||||
"source_group_name": None
|
||||
})
|
||||
|
||||
# Add group ACEs
|
||||
for ace in group_aces:
|
||||
response["aces"].append({
|
||||
"ace_id": ace.ace_id,
|
||||
"path": ace.path,
|
||||
"allowed": ace.allowed,
|
||||
"propagate": ace.propagate,
|
||||
"ace_type": "group",
|
||||
"source_group_id": ace.group_id,
|
||||
"source_group_name": next((g.name for g in user.groups if g.user_group_id == ace.group_id), None)
|
||||
})
|
||||
|
||||
if include_pools:
|
||||
# Extract pools from ACEs
|
||||
for ace in user_aces + group_aces:
|
||||
if ace.path.startswith("/pools/") and ace.allowed:
|
||||
try:
|
||||
pool_id = UUID(ace.path.split("/")[2])
|
||||
|
||||
if pool_id not in pool_ids_seen:
|
||||
# Get pool info
|
||||
pool_query = select(models.ResourcePool).where(
|
||||
models.ResourcePool.resource_pool_id == pool_id
|
||||
)
|
||||
pool_result = await self._db_session.execute(pool_query)
|
||||
pool = pool_result.scalars().first()
|
||||
|
||||
if pool:
|
||||
response["pools"].append({
|
||||
"resource_pool_id": pool.resource_pool_id,
|
||||
"name": pool.name,
|
||||
"access_source": "user" if ace.user_id else "group",
|
||||
"access_allowed": ace.allowed
|
||||
})
|
||||
pool_ids_seen.add(pool_id)
|
||||
except (ValueError, IndexError) as e:
|
||||
log.warning(f"Invalid pool path format: {ace.path}, error: {e}")
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
### Step 3: Update API Endpoint
|
||||
|
||||
**File**: `gns3server/api/routes/controller/users.py`
|
||||
|
||||
```python
|
||||
@router.get("/me", response_model=schemas.User)
|
||||
async def get_logged_in_user(
|
||||
current_user: schemas.User = Depends(get_current_active_user),
|
||||
users_repo: UsersRepository = Depends(get_repository(UsersRepository))
|
||||
) -> schemas.User:
|
||||
"""
|
||||
Get the current active user (including groups, pools, and ACEs).
|
||||
|
||||
Returns comprehensive user information including:
|
||||
- Basic user profile
|
||||
- Group memberships
|
||||
- Accessible resource pools
|
||||
- Access control entries (ACEs)
|
||||
"""
|
||||
|
||||
# Fetch user with all details
|
||||
user_details = await users_repo.get_user_with_details(
|
||||
current_user.user_id,
|
||||
include_pools=True,
|
||||
include_aces=True
|
||||
)
|
||||
|
||||
if not user_details:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Convert to schema
|
||||
user = user_details["user"]
|
||||
|
||||
return schemas.User(
|
||||
user_id=user.user_id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
is_superadmin=user.is_superadmin,
|
||||
last_login=user.last_login,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
groups=[schemas.UserGroup.model_validate(g) for g in user_details["groups"]],
|
||||
pools=[schemas.ResourcePoolInfo(**p) for p in user_details["pools"]],
|
||||
aces=[schemas.ACEInfo(**a) for a in user_details["aces"]]
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Response Structure
|
||||
|
||||
### Complete Example
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"username": "johndoe",
|
||||
"email": "john@example.com",
|
||||
"full_name": "John Doe",
|
||||
"is_active": true,
|
||||
"is_superadmin": false,
|
||||
"last_login": "2026-03-06T10:30:00Z",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-03-06T10:30:00Z",
|
||||
|
||||
"groups": [
|
||||
{
|
||||
"user_group_id": "650e8400-e29b-41d4-a716-446655440001",
|
||||
"name": "Developers",
|
||||
"is_builtin": false,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"user_group_id": "750e8400-e29b-41d4-a716-446655440002",
|
||||
"name": "Administrators",
|
||||
"is_builtin": true,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
|
||||
"pools": [
|
||||
{
|
||||
"resource_pool_id": "850e8400-e29b-41d4-a716-446655440003",
|
||||
"name": "Production Pool",
|
||||
"access_source": "user",
|
||||
"access_allowed": true
|
||||
},
|
||||
{
|
||||
"resource_pool_id": "950e8400-e29b-41d4-a716-446655440004",
|
||||
"name": "Development Pool",
|
||||
"access_source": "group",
|
||||
"access_allowed": true
|
||||
}
|
||||
],
|
||||
|
||||
"aces": [
|
||||
{
|
||||
"ace_id": "a50e8400-e29b-41d4-a716-446655440005",
|
||||
"path": "/pools/850e8400-e29b-41d4-a716-446655440003",
|
||||
"allowed": true,
|
||||
"propagate": true,
|
||||
"ace_type": "user",
|
||||
"source_group_id": null,
|
||||
"source_group_name": null
|
||||
},
|
||||
{
|
||||
"ace_id": "b50e8400-e29b-41d4-a716-446655440006",
|
||||
"path": "/projects",
|
||||
"allowed": true,
|
||||
"propagate": true,
|
||||
"ace_type": "group",
|
||||
"source_group_id": "650e8400-e29b-41d4-a716-446655440001",
|
||||
"source_group_name": "Developers"
|
||||
},
|
||||
{
|
||||
"ace_id": "c50e8400-e29b-41d4-a716-446655440007",
|
||||
"path": "/pools/950e8400-e29b-41d4-a716-446655440004",
|
||||
"allowed": true,
|
||||
"propagate": true,
|
||||
"ace_type": "group",
|
||||
"source_group_id": "650e8400-e29b-41d4-a716-446655440001",
|
||||
"source_group_name": "Developers"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Unit Tests
|
||||
|
||||
#### Test `get_user_with_details` Repository Method
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_with_groups_only(db_session, test_user, test_group):
|
||||
"""Test getting user with groups only."""
|
||||
from gns3server.db.repositories.users import UsersRepository
|
||||
|
||||
repo = UsersRepository(db_session)
|
||||
result = await repo.get_user_with_details(
|
||||
test_user.user_id,
|
||||
include_pools=False,
|
||||
include_aces=False
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result["groups"]) > 0
|
||||
assert result["groups"][0].name == test_group.name
|
||||
assert result["pools"] == []
|
||||
assert result["aces"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_with_pools_and_aces(db_session, test_user, test_pool, test_ace):
|
||||
"""Test getting user with pools and ACEs."""
|
||||
from gns3server.db.repositories.users import UsersRepository
|
||||
|
||||
repo = UsersRepository(db_session)
|
||||
result = await repo.get_user_with_details(
|
||||
test_user.user_id,
|
||||
include_pools=True,
|
||||
include_aces=True
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result["pools"]) > 0
|
||||
assert result["pools"][0]["name"] == test_pool.name
|
||||
assert len(result["aces"]) > 0
|
||||
assert result["aces"][0]["path"].startswith("/pools/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_with_group_pools(db_session, test_user, test_group, test_group_pool, test_group_ace):
|
||||
"""Test getting user with pools inherited from groups."""
|
||||
from gns3server.db.repositories.users import UsersRepository
|
||||
|
||||
repo = UsersRepository(db_session)
|
||||
result = await repo.get_user_with_details(
|
||||
test_user.user_id,
|
||||
include_pools=True,
|
||||
include_aces=True
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
# Should have pool from group ACE
|
||||
group_pools = [p for p in result["pools"] if p["access_source"] == "group"]
|
||||
assert len(group_pools) > 0
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
#### Test `/me` Endpoint Response
|
||||
|
||||
```python
|
||||
def test_get_me_with_all_details(test_client, auth_token, test_user_with_groups_and_pools):
|
||||
"""Test GET /me returns groups, pools, and ACEs."""
|
||||
|
||||
response = test_client.get(
|
||||
"/v3/access/users/me",
|
||||
headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify basic user info
|
||||
assert "user_id" in data
|
||||
assert "username" in data
|
||||
|
||||
# Verify groups
|
||||
assert "groups" in data
|
||||
assert isinstance(data["groups"], list)
|
||||
assert len(data["groups"]) > 0
|
||||
assert "user_group_id" in data["groups"][0]
|
||||
assert "name" in data["groups"][0]
|
||||
|
||||
# Verify pools
|
||||
assert "pools" in data
|
||||
assert isinstance(data["pools"], list)
|
||||
if len(data["pools"]) > 0:
|
||||
pool = data["pools"][0]
|
||||
assert "resource_pool_id" in pool
|
||||
assert "name" in pool
|
||||
assert "access_source" in pool
|
||||
assert pool["access_source"] in ["user", "group"]
|
||||
|
||||
# Verify ACEs
|
||||
assert "aces" in data
|
||||
assert isinstance(data["aces"], list)
|
||||
if len(data["aces"]) > 0:
|
||||
ace = data["aces"][0]
|
||||
assert "ace_id" in ace
|
||||
assert "path" in ace
|
||||
assert "allowed" in ace
|
||||
assert "ace_type" in ace
|
||||
assert ace["ace_type"] in ["user", "group"]
|
||||
|
||||
|
||||
def test_get_me_user_with_no_groups(test_client, auth_token, test_user_no_groups):
|
||||
"""Test GET /me for user with no groups."""
|
||||
|
||||
response = test_client.get(
|
||||
"/v3/access/users/me",
|
||||
headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["groups"] == []
|
||||
# May still have pools and ACEs from direct user ACEs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits
|
||||
|
||||
| Feature | Benefit |
|
||||
|---------|---------|
|
||||
| **Groups in /me** | Users see inherited configs and permissions |
|
||||
| **Pools in /me** | Users know available resource pools without separate API call |
|
||||
| **ACEs in /me** | Transparency - users see their access control rules |
|
||||
| **Single API Call** | Frontend gets all user context in one request |
|
||||
| **No Privilege Required** | Users can always see their own info |
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Frontend User Profile Page
|
||||
|
||||
```javascript
|
||||
// Get complete user context
|
||||
const response = await fetch('/v3/access/users/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const user = await response.json();
|
||||
|
||||
// Display groups
|
||||
console.log('Member of:', user.groups.map(g => g.name));
|
||||
|
||||
// Display available pools
|
||||
console.log('Accessible pools:', user.pools.map(p => p.name));
|
||||
|
||||
// Display ACE summary
|
||||
console.log('ACEs:', user.aces.length);
|
||||
```
|
||||
|
||||
### 2. LLM Config Selection UI
|
||||
|
||||
```javascript
|
||||
// User wants to select from inherited configs
|
||||
const user = await fetchCurrentUser();
|
||||
|
||||
// Show which configs are from which groups
|
||||
user.groups.forEach(group => {
|
||||
console.log(`Configs from ${group.name}:`, getGroupConfigs(group.user_group_id));
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Permission Troubleshooting
|
||||
|
||||
```javascript
|
||||
// User can't access a resource - why?
|
||||
const user = await fetchCurrentUser();
|
||||
|
||||
// Check if user has pool access
|
||||
const hasPoolAccess = user.pools.some(p => p.resource_pool_id === targetPoolId);
|
||||
|
||||
// Check ACEs
|
||||
const relevantACEs = user.aces.filter(ace => ace.path.includes(resourcePath));
|
||||
console.log('Relevant ACEs:', relevantACEs);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
| Query | Complexity | Optimization |
|
||||
|-------|------------|--------------|
|
||||
| Get user with groups | 1 JOIN (eager load) | Uses `selectinload` |
|
||||
| Get user ACEs | 1 query | Direct index lookup |
|
||||
| Get group ACEs | N queries (one per group) | Could optimize with subquery |
|
||||
| Get pool details | M queries (one per unique pool) | Could batch fetch |
|
||||
|
||||
**Potential Optimization**:
|
||||
|
||||
```python
|
||||
# Batch fetch all pools in one query
|
||||
pool_ids = [extract_pool_id_from_ace(ace) for ace in all_aces]
|
||||
|
||||
pools_query = select(models.ResourcePool).where(
|
||||
models.ResourcePool.resource_pool_id.in_(pool_ids)
|
||||
)
|
||||
pools_result = await self._db_session.execute(pools_query)
|
||||
pools = {p.resource_pool_id: p for p in pools_result.scalars().all()}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Data Exposure
|
||||
|
||||
| Data | Visibility | Rationale |
|
||||
|------|-----------|-----------|
|
||||
| Basic user info | User themselves | Already exposed in current `/me` |
|
||||
| Groups | User themselves | User knows which groups they joined |
|
||||
| Pools | User themselves | User knows which pools they can access |
|
||||
| ACEs | User themselves | Transparency about access rules |
|
||||
| Other users' data | **Hidden** | Not included in response |
|
||||
|
||||
### Access Control
|
||||
|
||||
- **Authentication Required**: Must provide valid JWT token
|
||||
- **No Special Privilege**: Users can always view their own data
|
||||
- **Filtering**: Only returns data for the authenticated user
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Roles**: Add user's roles (derived from ACEs)
|
||||
```json
|
||||
"roles": ["User", "Auditor"]
|
||||
```
|
||||
|
||||
2. **Effective Privileges**: Consolidated privilege list
|
||||
```json
|
||||
"privileges": ["Project.Audit", "Node.Create"]
|
||||
```
|
||||
|
||||
3. **Resource Counts**: Summary of accessible resources
|
||||
```json
|
||||
"resources_summary": {
|
||||
"projects_count": 5,
|
||||
"templates_count": 3
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Changes Checklist
|
||||
|
||||
| File | Change Type | Description |
|
||||
|------|-------------|-------------|
|
||||
| `gns3server/schemas/controller/users.py` | Modify | Add UserGroup, ResourcePoolInfo, ACEInfo schemas; Update User schema |
|
||||
| `gns3server/db/repositories/users.py` | Modify | Add `get_user_with_details` method |
|
||||
| `gns3server/api/routes/controller/users.py` | Modify | Update `/me` endpoint to use new method |
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2026-03-06
|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,607 +0,0 @@
|
||||
# Multi-User Device Concurrency Control
|
||||
|
||||
**Status:** TODO
|
||||
**Priority:** HIGH
|
||||
**Created:** 2025-03-06
|
||||
**Author:** GNS3-Copilot Team
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
When multiple users simultaneously use the GNS3-Copilot Agent to operate on the same network device node, a race condition occurs because all operations connect through the same telnet console port.
|
||||
|
||||
### Current Architecture
|
||||
|
||||
```
|
||||
User A → Agent → Netmiko → telnet console:5000 ─┐
|
||||
├──→ R1 (Shared Console)
|
||||
User B → Agent → Netmiko → telnet console:5000 ─┘
|
||||
```
|
||||
|
||||
### Affected Components
|
||||
|
||||
| Tool | Connection Method | File |
|
||||
|------|------------------|------|
|
||||
| `execute_multiple_device_config_commands` | Netmiko (netmiko_send_config) | `tools_v2/config_tools_nornir.py` |
|
||||
| `execute_multiple_device_commands` | Netmiko (netmiko_multiline) | `tools_v2/display_tools_nornir.py` |
|
||||
| `vpcs_multi_commands` | telnetlib3 | `tools_v2/vpcs_tools_telnetlib3.py` |
|
||||
|
||||
### Conflict Scenario Example
|
||||
|
||||
```
|
||||
Timeline:
|
||||
T1: User A executes "conf t" → Device enters configuration mode
|
||||
T2: User B executes "show run" → May see config mode prompt
|
||||
T3: User B executes "interface Gig0/0" → Interrupts User A's configuration
|
||||
T4: User A's output contains User B's commands (confusion!)
|
||||
T5: User A executes "ip address 1.1.1.1 255.255.255.255" → Applies to wrong interface
|
||||
```
|
||||
|
||||
**Impact:**
|
||||
- Configuration applied to wrong interface/context
|
||||
- Mixed output from different users
|
||||
- Lost commands or unintended configuration changes
|
||||
- Unpredictable device state
|
||||
|
||||
---
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
### Solution 1: Device-Level Mutex Lock (RECOMMENDED)
|
||||
|
||||
**Implementation Location:** `gns3server/agent/gns3_copilot/utils/device_lock.py`
|
||||
|
||||
Create a device-level lock manager to ensure only one user can operate on a device at a time.
|
||||
|
||||
#### Architecture
|
||||
|
||||
```python
|
||||
class DeviceLockManager:
|
||||
"""Manages exclusive access to network devices"""
|
||||
|
||||
def __init__(self):
|
||||
# device_name -> asyncio.Lock
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
self._manager_lock = asyncio.Lock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire_device(self, device_name: str, user_id: str, timeout: float = 30.0):
|
||||
"""
|
||||
Acquire exclusive lock on a device.
|
||||
|
||||
Args:
|
||||
device_name: Name of the device (e.g., "R-1")
|
||||
user_id: User ID requesting the lock
|
||||
timeout: Maximum time to wait for lock (seconds)
|
||||
|
||||
Raises:
|
||||
RuntimeError: If timeout waiting for lock
|
||||
"""
|
||||
lock = self._get_lock(device_name)
|
||||
|
||||
logger.info("User %s requesting lock for device %s...", user_id, device_name)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(lock.acquire(), timeout=timeout)
|
||||
logger.info("✓ User %s acquired lock for device %s", user_id, device_name)
|
||||
yield
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("✗ User %s timeout waiting for device %s", user_id, device_name)
|
||||
raise RuntimeError(
|
||||
f"Device {device_name} is busy with another user's operation. "
|
||||
f"Please wait a moment and try again."
|
||||
)
|
||||
finally:
|
||||
lock.release()
|
||||
logger.info("User %s released lock for device %s", user_id, device_name)
|
||||
```
|
||||
|
||||
#### Tool Integration
|
||||
|
||||
Modify tool `_run()` methods to use locks:
|
||||
|
||||
```python
|
||||
# In config_tools_nornir.py
|
||||
from gns3server.agent.gns3_copilot.utils.device_lock import _device_lock_manager
|
||||
from gns3server.agent.gns3_copilot.gns3_client.context_helpers import get_current_llm_config
|
||||
|
||||
async def _run(self, tool_input: str, run_manager=None, **kwargs):
|
||||
# Get user_id from context
|
||||
llm_config = get_current_llm_config()
|
||||
user_id = llm_config.get("user_id", "unknown") if llm_config else "unknown"
|
||||
|
||||
device_configs_list, project_id = self._validate_tool_input(tool_input)
|
||||
|
||||
results = []
|
||||
for device_config in device_configs_list:
|
||||
device_name = device_config["device_name"]
|
||||
|
||||
# Acquire lock before operating on device
|
||||
async with _device_lock_manager.acquire_device(device_name, user_id):
|
||||
# Execute configuration
|
||||
result = await self._execute_config_on_device(device_config, project_id)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Simple implementation
|
||||
- Clear ownership (user knows who has the lock)
|
||||
- Automatic timeout prevents deadlocks
|
||||
|
||||
**Cons:**
|
||||
- Single-process only (no cross-server locking)
|
||||
- Users must wait if device is busy
|
||||
|
||||
---
|
||||
|
||||
### Solution 2: Transactional Operations with Mode Cleanup
|
||||
|
||||
Ensure device state consistency before and after operations.
|
||||
|
||||
```python
|
||||
async def _safe_execute_config(self, connection, config_commands):
|
||||
"""Safely execute config with guaranteed state cleanup"""
|
||||
|
||||
# 1. Ensure privileged mode (not config mode)
|
||||
try:
|
||||
connection.exit_config_mode()
|
||||
connection.find_prompt()
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
# 2. Execute configuration
|
||||
result = connection.send_config_set(config_commands)
|
||||
return result
|
||||
finally:
|
||||
# 3. Always cleanup: exit config mode
|
||||
try:
|
||||
connection.exit_config_mode()
|
||||
except:
|
||||
pass
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Reduces chance of leaving device in bad state
|
||||
- Works as safety layer alongside locks
|
||||
|
||||
**Cons:**
|
||||
- Doesn't prevent concurrent access (needs Solution 1)
|
||||
- Adds overhead to each operation
|
||||
|
||||
---
|
||||
|
||||
### Solution 3: Frontend User Notification
|
||||
|
||||
Display device lock status in the UI to inform users.
|
||||
|
||||
#### Backend Events
|
||||
|
||||
```python
|
||||
# In AgentService.stream_chat()
|
||||
async with _device_lock_manager.acquire_device(device_name, user_id):
|
||||
# Emit lock acquired event
|
||||
yield {
|
||||
"type": "device_lock_acquired",
|
||||
"device_name": device_name,
|
||||
"user_id": user_id,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
try:
|
||||
# Execute operations
|
||||
result = await self._execute(...)
|
||||
yield {"type": "tool_end", "output": result}
|
||||
finally:
|
||||
# Emit lock released event
|
||||
yield {
|
||||
"type": "device_lock_released",
|
||||
"device_name": device_name,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
```
|
||||
|
||||
#### Frontend Handling
|
||||
|
||||
```javascript
|
||||
// WebSocket event listeners
|
||||
socket.on('device_lock_acquired', (data) => {
|
||||
showNotification(
|
||||
`Device ${data.device} is locked by user ${data.user}`,
|
||||
'warning'
|
||||
);
|
||||
disableDeviceControls(data.device);
|
||||
});
|
||||
|
||||
socket.on('device_lock_released', (data) => {
|
||||
hideNotification(data.device);
|
||||
enableDeviceControls(data.device);
|
||||
});
|
||||
|
||||
socket.on('device_lock_timeout', (data) => {
|
||||
showError(
|
||||
`Could not acquire lock on ${data.device}. ` +
|
||||
`Another user is operating on it. Please wait.`
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Better UX (users know why they're waiting)
|
||||
- Transparency about device usage
|
||||
|
||||
**Cons:**
|
||||
- Requires frontend changes
|
||||
- More complex WebSocket protocol
|
||||
|
||||
---
|
||||
|
||||
### Solution 4: Distributed Lock (Multi-Server Deployment)
|
||||
|
||||
For production deployments with multiple GNS3 Server instances.
|
||||
|
||||
#### Redis-based Lock Manager
|
||||
|
||||
```python
|
||||
# Requires: pip install aioredis
|
||||
import aioredis
|
||||
|
||||
class RedisDeviceLockManager:
|
||||
"""Distributed device lock using Redis"""
|
||||
|
||||
def __init__(self, redis_url: str = "redis://localhost:6379"):
|
||||
self.redis = aioredis.from_url(redis_url)
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire_device(self, device_name: str, user_id: str):
|
||||
key = f"gns3:device_lock:{device_name}"
|
||||
lock = self.redis.lock(
|
||||
key,
|
||||
timeout=60, # Auto-release after 60s
|
||||
blocking_timeout=30 # Wait max 30s
|
||||
)
|
||||
|
||||
try:
|
||||
await lock.acquire()
|
||||
# Store metadata
|
||||
await self.redis.hset(
|
||||
f"{key}:meta",
|
||||
mapping={
|
||||
"user_id": user_id,
|
||||
"acquired_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
)
|
||||
yield
|
||||
finally:
|
||||
await self.redis.delete(f"{key}:meta")
|
||||
await lock.release()
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Works across multiple server instances
|
||||
- Centralized lock management
|
||||
- Persistent lock state
|
||||
|
||||
**Cons:**
|
||||
- Requires Redis infrastructure
|
||||
- More complex deployment
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Lock Implementation (Priority: HIGH)
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. **Create Device Lock Manager**
|
||||
- [ ] Create `gns3server/agent/gns3_copilot/utils/device_lock.py`
|
||||
- [ ] Implement `DeviceLockManager` class with asyncio locks
|
||||
- [ ] Add comprehensive logging for lock acquisition/release
|
||||
- [ ] Add unit tests for lock behavior
|
||||
|
||||
2. **Integrate with Config Tools**
|
||||
- [ ] Modify `config_tools_nornir.py::ExecuteMultipleDeviceConfigCommands._run()`
|
||||
- [ ] Extract user_id from request context
|
||||
- [ ] Wrap device operations in lock context manager
|
||||
- [ ] Handle timeout exceptions gracefully
|
||||
|
||||
3. **Integrate with Display Tools**
|
||||
- [ ] Modify `display_tools_nornir.py::ExecuteMultipleDeviceCommands._run()`
|
||||
- [ ] Add same lock protection for read operations
|
||||
- [ ] Consider allowing concurrent reads (readers-writer lock?)
|
||||
|
||||
4. **Integrate with VPCS Tools**
|
||||
- [ ] Modify `vpcs_tools_telnetlib3.py::VPCSMultiCommands._run()`
|
||||
- [ ] Add lock protection
|
||||
|
||||
5. **Add Mode Cleanup**
|
||||
- [ ] Implement `_safe_execute_config()` helper
|
||||
- [ ] Ensure all operations exit config mode after completion
|
||||
|
||||
**Estimated Effort:** 2-3 days
|
||||
|
||||
**Testing Checklist:**
|
||||
- [ ] Single user operation (baseline)
|
||||
- [ ] Two users configuring same device simultaneously
|
||||
- [ ] Two users reading same device simultaneously
|
||||
- [ ] One user configuring, one user reading same device
|
||||
- [ ] Lock timeout behavior
|
||||
- [ ] Lock release on exception
|
||||
- [ ] Concurrent operations on different devices (should not block)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: User Experience Enhancements (Priority: MEDIUM)
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. **Backend Events**
|
||||
- [ ] Emit `device_lock_acquired` events
|
||||
- [ ] Emit `device_lock_released` events
|
||||
- [ ] Emit `device_lock_timeout` events
|
||||
- [ ] Include device_name, user_id, timestamp in events
|
||||
|
||||
2. **Frontend Integration**
|
||||
- [ ] Add WebSocket listeners for lock events
|
||||
- [ ] Display lock status badges on device cards
|
||||
- [ ] Show toast notifications for lock state changes
|
||||
- [ ] Disable controls while locked
|
||||
- [ ] Add "Waiting for lock..." indicator
|
||||
|
||||
3. **Error Messages**
|
||||
- [ ] Localize timeout messages
|
||||
- [ ] Add helpful hints (e.g., "Wait 30 seconds and retry")
|
||||
- [ ] Include which user has the lock
|
||||
|
||||
**Estimated Effort:** 3-4 days
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Production Readiness (Priority: LOW)
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. **Distributed Lock**
|
||||
- [ ] Add Redis dependency to `requirements.txt`
|
||||
- [ ] Implement `RedisDeviceLockManager`
|
||||
- [ ] Add configuration option (memory vs redis)
|
||||
- [ ] Document Redis setup
|
||||
|
||||
2. **Monitoring**
|
||||
- [ ] Add metrics: lock wait time, lock hold time
|
||||
- [ ] Add Prometheus exporters
|
||||
- [ ] Dashboard for lock statistics
|
||||
|
||||
3. **Advanced Features**
|
||||
- [ ] Lock queue (FIFO waitlist)
|
||||
- [ ] Lock priority (admin vs regular user)
|
||||
- [ ] Forced lock release (admin override)
|
||||
- [ ] Lock expiration handling
|
||||
|
||||
**Estimated Effort:** 5-7 days
|
||||
|
||||
---
|
||||
|
||||
## Design Considerations
|
||||
|
||||
### Lock Granularity
|
||||
|
||||
**Options:**
|
||||
|
||||
| Granularity | Description | Pros | Cons |
|
||||
|-------------|-------------|------|------|
|
||||
| Per-device | Lock on each device | Fine-grained, good concurrency | More complex |
|
||||
| Per-project | Lock entire project | Simple | Blocks unrelated operations |
|
||||
| Per-user | One lock per user | Fair | Low concurrency |
|
||||
|
||||
**Recommendation:** Start with per-device locks for optimal balance.
|
||||
|
||||
### Concurrent Reads
|
||||
|
||||
Consider allowing multiple concurrent read operations (show commands) while blocking writes:
|
||||
|
||||
```python
|
||||
class ReadersWriterDeviceLock:
|
||||
"""Allows multiple concurrent readers, exclusive writer"""
|
||||
|
||||
def __init__(self):
|
||||
self._readers = 0
|
||||
self._writer_lock = asyncio.Lock()
|
||||
self._reader_lock = asyncio.Lock()
|
||||
|
||||
async def acquire_read(self):
|
||||
"""Acquire read lock (shared)"""
|
||||
async with self._reader_lock:
|
||||
self._readers += 1
|
||||
if self._readers == 1:
|
||||
await self._writer_lock.acquire()
|
||||
|
||||
async def release_read(self):
|
||||
"""Release read lock"""
|
||||
async with self._reader_lock:
|
||||
self._readers -= 1
|
||||
if self._readers == 0:
|
||||
self._writer_lock.release()
|
||||
|
||||
async def acquire_write(self):
|
||||
"""Acquire write lock (exclusive)"""
|
||||
await self._writer_lock.acquire()
|
||||
|
||||
async def release_write(self):
|
||||
"""Release write lock"""
|
||||
self._writer_lock.release()
|
||||
```
|
||||
|
||||
### Timeout Strategy
|
||||
|
||||
**Recommended timeouts:**
|
||||
|
||||
| Operation | Timeout | Rationale |
|
||||
|-----------|---------|-----------|
|
||||
| Lock acquisition | 30s | User patience limit |
|
||||
| Lock auto-release | 120s | Prevent stale locks |
|
||||
| Read operation | 60s | show commands are fast |
|
||||
| Config operation | 90s | Configuration takes longer |
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```python
|
||||
# tests/test_device_lock.py
|
||||
import pytest
|
||||
from utils.device_lock import DeviceLockManager
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_lock_acquisition():
|
||||
manager = DeviceLockManager()
|
||||
|
||||
async with manager.acquire_device("R-1", "user_a"):
|
||||
assert True # Should not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_lock_rejection():
|
||||
manager = DeviceLockManager()
|
||||
lock_a_acquired = False
|
||||
|
||||
async def user_a():
|
||||
nonlocal lock_a_acquired
|
||||
async with manager.acquire_device("R-1", "user_a"):
|
||||
lock_a_acquired = True
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
async def user_b():
|
||||
await asyncio.sleep(0.1) # Let A acquire first
|
||||
with pytest.raises(RuntimeError):
|
||||
async with manager.acquire_device("R-1", "user_b", timeout=0.3):
|
||||
pass
|
||||
|
||||
await asyncio.gather(user_a(), user_b())
|
||||
assert lock_a_acquired
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_config_operations():
|
||||
"""Simulate two users configuring same device"""
|
||||
service = AgentService(project_path)
|
||||
|
||||
async def user_a_config():
|
||||
return await service.stream_chat(
|
||||
"Configure loopback on R-1",
|
||||
session_id="user_a",
|
||||
user_id="user_a"
|
||||
)
|
||||
|
||||
async def user_b_config():
|
||||
await asyncio.sleep(0.2) # Slight delay
|
||||
return await service.stream_chat(
|
||||
"Configure loopback on R-1", # Same device!
|
||||
session_id="user_b",
|
||||
user_id="user_b"
|
||||
)
|
||||
|
||||
results = await asyncio.gather(
|
||||
user_a_config(),
|
||||
user_b_config(),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# One should succeed, one should timeout
|
||||
assert any(isinstance(r, RuntimeError) for r in results)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
1. **Feature Flag**
|
||||
```python
|
||||
# config.py
|
||||
ENABLE_DEVICE_LOCKS = os.getenv("GNS3_COPILOT_DEVICE_LOCKS", "true").lower() == "true"
|
||||
```
|
||||
|
||||
2. **Gradual Enablement**
|
||||
- Week 1: Enable in development environment
|
||||
- Week 2: Enable in staging with monitoring
|
||||
- Week 3: Enable for 10% of production users
|
||||
- Week 4: Full rollout
|
||||
|
||||
3. **Monitoring**
|
||||
- Track lock acquisition rate
|
||||
- Monitor timeout frequency
|
||||
- Measure user wait times
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should read operations be concurrent?**
|
||||
- Pros: Better user experience for diagnostic tasks
|
||||
- Cons: More complex implementation, risk of read-during-write
|
||||
|
||||
2. **What about bulk operations?**
|
||||
- If user operates on 10 devices, should we acquire all locks first?
|
||||
- Risk: Deadlock if two users request overlapping device sets
|
||||
|
||||
3. **Lock priority?**
|
||||
- Should instructors/admins have priority over students?
|
||||
- How to signal this in the UI?
|
||||
|
||||
4. **Graceful degradation?**
|
||||
- If lock service fails, should we:
|
||||
- a) Block all operations (safe but disruptive)
|
||||
- b) Allow operations with warning (risky)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Python asyncio.Lock documentation](https://docs.python.org/3/library/asyncio-sync.html#asyncio.Lock)
|
||||
- [Redlock algorithm (Redis distributed locks)](https://redis.io/topics/distlock)
|
||||
- [Netmiko connection management](https://github.com/ktbyers/netmiko)
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2025-03-06 | Initial document creation |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
|
||||
@ -1,480 +0,0 @@
|
||||
# TODO: Fix Orphan Tool Calls Causing Checkpoint State Inconsistency
|
||||
|
||||
## Problem Description
|
||||
|
||||
When the LangGraph agent terminates abnormally during execution (such as forced service shutdown, process crash, etc.), it may result in a checkpoint containing an `AIMessage` with `tool_calls` but no corresponding `ToolMessage`. This state inconsistency can cause errors during subsequent conversation recovery.
|
||||
|
||||
### Terminology
|
||||
|
||||
- **Orphan tool_calls**: `AIMessage` contains `tool_calls` field, but there's no corresponding `ToolMessage` in the message list
|
||||
- **Checkpoint**: LangGraph's mechanism for persisting conversation state
|
||||
- **State inconsistency**: Message state in checkpoint doesn't match expected message pairs (AIMessage + ToolMessage)
|
||||
|
||||
---
|
||||
|
||||
## Trigger Scenarios
|
||||
|
||||
### Scenario 1: Process Abnormal Termination (Primary Issue)
|
||||
|
||||
```
|
||||
Execution flow:
|
||||
User message → llm_call → AIMessage(tool_calls) → [Checkpoint saved]
|
||||
↓
|
||||
[Process crash/service shutdown]
|
||||
↓
|
||||
tool_node not executed
|
||||
↓
|
||||
Checkpoint contains:
|
||||
- AIMessage (has tool_calls) ✅
|
||||
- ToolMessage ❌ missing
|
||||
```
|
||||
|
||||
**Trigger conditions:**
|
||||
- LLM returns a response containing tool_calls
|
||||
- Checkpoint has saved AIMessage
|
||||
- Service is shut down before tool_node execution (kill -9, Ctrl+C, crash, etc.)
|
||||
|
||||
### Scenario 2: Maximum Call Count Reached (Already Handled)
|
||||
|
||||
Current code checks remaining steps after tool_node execution via the `recursion_limit_continue` function:
|
||||
|
||||
```python
|
||||
def recursion_limit_continue(state: MessagesState) -> Literal["llm_call", END]:
|
||||
last_message = state["messages"][-1]
|
||||
if isinstance(last_message, ToolMessage):
|
||||
if state["remaining_steps"] < 4:
|
||||
return END
|
||||
return "llm_call"
|
||||
return END
|
||||
```
|
||||
|
||||
**Execution flow:**
|
||||
```
|
||||
remaining_steps = 5
|
||||
llm_call → AIMessage(tool_calls) → remaining_steps = 4
|
||||
↓
|
||||
should_continue → tool_node (because there are tool_calls)
|
||||
↓
|
||||
tool_node → ToolMessage → remaining_steps = 3
|
||||
↓
|
||||
recursion_limit_continue → remaining_steps < 4 → END ✅
|
||||
```
|
||||
|
||||
**Conclusion:** Scenario 2 won't produce orphan tool_calls because tool_node always executes and generates a ToolMessage.
|
||||
|
||||
---
|
||||
|
||||
## Fix Solution
|
||||
|
||||
### Core Idea
|
||||
|
||||
At the start of `stream_chat`, for existing sessions, detect and fix orphan tool_calls.
|
||||
|
||||
### Fix Strategy
|
||||
|
||||
**Strategy A: Clear tool_calls (Recommended)**
|
||||
|
||||
Create a new `AIMessage` with the same content as the original message but without the `tool_calls` field.
|
||||
|
||||
**Advantages:**
|
||||
- Simple and clean
|
||||
- Won't affect subsequent conversation
|
||||
- User can ask the question again
|
||||
|
||||
**Disadvantages:**
|
||||
- Loses LLM's original intent (but it already crashed, can't be recovered)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Code
|
||||
|
||||
### 1. Add Fix Method (`agent_service.py`)
|
||||
|
||||
```python
|
||||
async def _fix_orphan_tool_calls(self, graph, config: dict, session_id: str):
|
||||
"""
|
||||
Detect and fix orphan tool_calls (AIMessage has tool_calls but no corresponding ToolMessage).
|
||||
|
||||
Orphan tool_calls occur when the process crashes before tool_node execution.
|
||||
|
||||
Uses LangGraph's aupdate_state API to safely create a new checkpoint version.
|
||||
"""
|
||||
try:
|
||||
# 1. Read current state
|
||||
state = await graph.aget_state(config)
|
||||
if not state or not state.values.get("messages"):
|
||||
return
|
||||
|
||||
messages = state.values["messages"]
|
||||
last_message = messages[-1]
|
||||
|
||||
# 2. Detect orphan tool_calls
|
||||
if not (hasattr(last_message, "tool_calls") and last_message.tool_calls):
|
||||
return
|
||||
|
||||
# Check if there's a corresponding ToolMessage
|
||||
has_tool_message = any(isinstance(m, ToolMessage) for m in messages)
|
||||
|
||||
if has_tool_message:
|
||||
return
|
||||
|
||||
log.warning("Detected orphan tool_calls: session=%s, will clear", session_id)
|
||||
|
||||
# 3. Create fixed message (without tool_calls)
|
||||
from langchain.messages import AIMessage
|
||||
fixed_message = AIMessage(
|
||||
content=last_message.content,
|
||||
id=getattr(last_message, "id", None)
|
||||
)
|
||||
|
||||
# 4. Use LangGraph API to update state (create new checkpoint)
|
||||
await graph.aupdate_state(config, {"messages": [fixed_message]})
|
||||
|
||||
log.info("Orphan tool_calls fixed: session=%s", session_id)
|
||||
|
||||
except Exception as e:
|
||||
log.error("Failed to fix orphan tool_calls: %s", e, exc_info=True)
|
||||
```
|
||||
|
||||
### 2. Call in `stream_chat` (`agent_service.py`)
|
||||
|
||||
Add fix logic after getting the graph and before starting the stream:
|
||||
|
||||
```python
|
||||
async def stream_chat(
|
||||
self,
|
||||
message: str,
|
||||
session_id: str,
|
||||
project_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
jwt_token: Optional[str] = None,
|
||||
mode: str = "text",
|
||||
llm_config: Optional[Dict[str, Any]] = None,
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
# ... existing code ...
|
||||
|
||||
# Get or create chat session
|
||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||
session = await repo.get_session_by_thread(session_id)
|
||||
is_new_session = session is None
|
||||
|
||||
if is_new_session:
|
||||
# Create new session
|
||||
session = await repo.create_session(...)
|
||||
log.debug("Created new chat session: thread_id=%s", session_id)
|
||||
|
||||
# ... set context variables ...
|
||||
|
||||
# Build config
|
||||
config = {
|
||||
"configurable": {
|
||||
"thread_id": session_id,
|
||||
"project_id": project_id,
|
||||
},
|
||||
"metadata": {
|
||||
"user_id": user_id,
|
||||
},
|
||||
}
|
||||
|
||||
# Build inputs
|
||||
inputs = {
|
||||
"messages": [HumanMessage(content=message, id=str(uuid4()))],
|
||||
"llm_calls": 0,
|
||||
"remaining_steps": 20,
|
||||
"mode": mode,
|
||||
}
|
||||
|
||||
# Get the compiled graph
|
||||
graph = await self._get_graph()
|
||||
|
||||
# 🔧 Fix state: for existing sessions, check and fix orphan tool_calls
|
||||
if not is_new_session:
|
||||
await self._fix_orphan_tool_calls(graph, config, session_id)
|
||||
|
||||
log.debug("LangGraph graph obtained, starting stream")
|
||||
|
||||
# ... continue existing code ...
|
||||
```
|
||||
|
||||
### 3. Required Imports
|
||||
|
||||
Ensure `agent_service.py` has the following import:
|
||||
|
||||
```python
|
||||
from langchain.messages import ToolMessage # For detecting ToolMessage type
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Impact on Checkpoint Database
|
||||
|
||||
### LangGraph Checkpoint Mechanism
|
||||
|
||||
LangGraph checkpoints are **versioned** - each state update creates a new record:
|
||||
|
||||
```
|
||||
checkpoints table structure:
|
||||
- thread_id
|
||||
- checkpoint_id (incrementing version number)
|
||||
- checkpoint (serialized state data)
|
||||
- metadata
|
||||
- ...
|
||||
```
|
||||
|
||||
### Security Analysis
|
||||
|
||||
| Aspect | Impact | Description |
|
||||
|--------|--------|-------------|
|
||||
| **Original data** | Preserved unchanged | `aupdate_state` creates new version, doesn't overwrite history |
|
||||
| **Database structure** | Fully compatible | Uses LangGraph native API, won't break structure |
|
||||
| **Concurrency safety** | Built-in protection | LangGraph has locking mechanism for concurrent access |
|
||||
| **Storage overhead** | Minimal | Only adds one checkpoint record (about a few KB) |
|
||||
| **Revertibility** | Supported | Can roll back to any version before fix |
|
||||
|
||||
### Not Direct Database Manipulation
|
||||
|
||||
**❌ Dangerous approach:**
|
||||
```python
|
||||
# Direct database modification - destructive
|
||||
await conn.execute(
|
||||
"UPDATE checkpoints SET checkpoint = ? WHERE ...",
|
||||
[modified_json]
|
||||
)
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
- May break serialization format
|
||||
- Doesn't create new version, overwrites history
|
||||
- May cause database locking or corruption
|
||||
- Violates LangGraph design principles
|
||||
|
||||
**✅ Safe approach:**
|
||||
```python
|
||||
# Use LangGraph's aupdate_state
|
||||
await graph.aupdate_state(config, {"messages": [fixed_message]})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Methods
|
||||
|
||||
### Method 1: Simulated Crash Test (Recommended)
|
||||
|
||||
Simulate crash scenarios by forcibly shutting down the service:
|
||||
|
||||
```
|
||||
Steps:
|
||||
1. Start GNS3 service
|
||||
2. Send a message that triggers tool_calls (e.g., query topology)
|
||||
3. Observe logs, wait for AIMessage return (with tool_calls)
|
||||
4. Force shutdown service before tool_node completes:
|
||||
- Method 1: kill -9 <pid>
|
||||
- Method 2: Ctrl+C (if supported)
|
||||
5. Restart GNS3 service
|
||||
6. Continue conversation using same session_id
|
||||
7. Observe logs, should see:
|
||||
- "Detected orphan tool_calls: session=xxx, will clear"
|
||||
- "Orphan tool_calls fixed: session=xxx"
|
||||
8. Verify conversation can proceed normally
|
||||
```
|
||||
|
||||
### Method 2: Unit Tests
|
||||
|
||||
Directly construct orphan tool_calls state to test fix logic:
|
||||
|
||||
```python
|
||||
# tests/test_agent_service.py
|
||||
|
||||
import pytest
|
||||
from langchain.messages import AIMessage, HumanMessage, ToolMessage
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fix_orphan_tool_calls():
|
||||
"""Test orphan tool_calls fix logic"""
|
||||
from gns3server.agent.gns3_copilot.agent_service import AgentService
|
||||
|
||||
# Create test agent service
|
||||
service = AgentService("/tmp/test_project")
|
||||
await service._get_checkpointer()
|
||||
|
||||
graph = await service._get_graph()
|
||||
config = {"configurable": {"thread_id": "test_session"}}
|
||||
|
||||
# Construct orphan state: add normal messages first
|
||||
await graph.aupdate_state(
|
||||
config,
|
||||
{
|
||||
"messages": [
|
||||
HumanMessage(content="Test message", id="msg_1"),
|
||||
AIMessage(
|
||||
content="Let me check for you",
|
||||
id="msg_2",
|
||||
tool_calls=[{
|
||||
"id": "call_123",
|
||||
"name": "get_topology",
|
||||
"args": {"project_id": "test"}
|
||||
}]
|
||||
)
|
||||
# Note: No corresponding ToolMessage
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"remaining_steps": 20
|
||||
}
|
||||
)
|
||||
|
||||
# Call fix logic
|
||||
await service._fix_orphan_tool_calls(graph, config, "test_session")
|
||||
|
||||
# Verify fix result
|
||||
state = await graph.aget_state(config)
|
||||
last_message = state.values["messages"][-1]
|
||||
|
||||
# Should no longer have tool_calls
|
||||
assert not hasattr(last_message, "tool_calls") or not last_message.tool_calls
|
||||
assert last_message.content == "Let me check for you"
|
||||
|
||||
# Cleanup
|
||||
await service.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_fix_when_normal():
|
||||
"""Test that normal state isn't incorrectly fixed"""
|
||||
from gns3server.agent.gns3_copilot.agent_service import AgentService
|
||||
|
||||
service = AgentService("/tmp/test_project")
|
||||
await service._get_checkpointer()
|
||||
|
||||
graph = await service._get_graph()
|
||||
config = {"configurable": {"thread_id": "test_session_2"}}
|
||||
|
||||
# Construct normal state: complete AIMessage + ToolMessage pair
|
||||
await graph.aupdate_state(
|
||||
config,
|
||||
{
|
||||
"messages": [
|
||||
HumanMessage(content="Test message", id="msg_1"),
|
||||
AIMessage(
|
||||
content="Let me check for you",
|
||||
id="msg_2",
|
||||
tool_calls=[{
|
||||
"id": "call_123",
|
||||
"name": "get_topology",
|
||||
"args": {"project_id": "test"}
|
||||
}]
|
||||
),
|
||||
ToolMessage(
|
||||
content="Topology info: ...",
|
||||
tool_call_id="call_123",
|
||||
name="get_topology",
|
||||
id="msg_3"
|
||||
)
|
||||
],
|
||||
"llm_calls": 1,
|
||||
"remaining_steps": 20
|
||||
}
|
||||
)
|
||||
|
||||
# Record original message count
|
||||
state_before = await graph.aget_state(config)
|
||||
msg_count_before = len(state_before.values["messages"])
|
||||
|
||||
# Call fix logic
|
||||
await service._fix_orphan_tool_calls(graph, config, "test_session_2")
|
||||
|
||||
# Verify state unchanged
|
||||
state_after = await graph.aget_state(config)
|
||||
msg_count_after = len(state_after.values["messages"])
|
||||
|
||||
assert msg_count_before == msg_count_after # Should not add new messages
|
||||
last_message = state_after.values["messages"][-1]
|
||||
assert isinstance(last_message, ToolMessage) # Last is still ToolMessage
|
||||
|
||||
# Cleanup
|
||||
await service.close()
|
||||
```
|
||||
|
||||
### Method 3: Enhanced Logging and Monitoring
|
||||
|
||||
Even without active triggering, you can verify fix logic works in production:
|
||||
|
||||
```python
|
||||
# Add detailed logging in _fix_orphan_tool_calls
|
||||
log.warning("Detected orphan tool_calls: session=%s", session_id)
|
||||
log.info("Original message: tool_calls=%d, content=%s",
|
||||
len(last_message.tool_calls),
|
||||
last_message.content[:100])
|
||||
log.info("After fix: tool_calls=%d",
|
||||
len(fixed_message.tool_calls) if hasattr(fixed_message, "tool_calls") else 0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Modification Checklist
|
||||
|
||||
### Files to Modify
|
||||
|
||||
1. **`gns3server/agent/gns3_copilot/agent_service.py`**
|
||||
- Add `_fix_orphan_tool_calls` method
|
||||
- Call fix logic in `stream_chat` method
|
||||
|
||||
### Test Files to Add (Optional)
|
||||
|
||||
2. **`tests/test_agent_service.py`** (create new or add to existing test file)
|
||||
- `test_fix_orphan_tool_calls()` - Test orphan tool_calls fix
|
||||
- `test_no_fix_when_normal()` - Test normal state isn't incorrectly fixed
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. ✅ Create TODO document (current document)
|
||||
2. ⬜ Add `_fix_orphan_tool_calls` method in `agent_service.py`
|
||||
3. ⬜ Call fix logic in `stream_chat`
|
||||
4. ⬜ Test fix effect using simulated crash method
|
||||
5. ⬜ Add unit tests (optional)
|
||||
6. ⬜ Update related documentation (if necessary)
|
||||
|
||||
---
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- **Main modification file**: `gns3server/agent/gns3_copilot/agent_service.py`
|
||||
- **Related file**: `gns3server/agent/gns3_copilot/agent/gns3_copilot.py`
|
||||
- **Test file**: `tests/test_agent_service.py` (to be created)
|
||||
|
||||
---
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
- [LangGraph Checkpointer Documentation](https://langchain-ai.github.io/langgraph/concepts/low_level/#checkpointer)
|
||||
- [LangGraph State Management](https://langchain-ai.github.io/langgraph/concepts/low_level/#state)
|
||||
- [GNS3-Copilot AI Chat API Design](../ai-chat-api-design.md)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
|
||||
@ -1,521 +0,0 @@
|
||||
# Runtime Agent Parameters
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the design and implementation plan for adding runtime control parameters to the GNS3-Copilot agent. Currently, iteration limits and tool call constraints are hardcoded. This enhancement will allow users to pass temporary parameters at request time to control agent behavior.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Current Limitations
|
||||
|
||||
1. **Hard-coded iteration limit**: The maximum number of LLM-tool iterations is fixed at 20 in `agent_service.py`
|
||||
2. **No tool call limit**: There's no runtime control over the maximum number of tool calls per request
|
||||
3. **Inflexible for complex tasks**: Long-running automation tasks may require more iterations than the default
|
||||
4. **No cost control**: Users cannot limit the number of expensive tool calls (e.g., device configuration operations)
|
||||
|
||||
### User Impact
|
||||
|
||||
```
|
||||
Scenario: User wants to configure OSPF on 10 routers
|
||||
- Each router requires ~2-3 tool calls (check config, apply config, verify)
|
||||
- Total: ~20-30 tool calls needed
|
||||
- Current: No way to predict or control this
|
||||
- Desired: User can set max_tool_calls=30 to ensure completion
|
||||
```
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### Parameter Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1. API Layer (chat.py) │
|
||||
│ POST /v3/projects/{project_id}/chat/stream │
|
||||
│ ChatRequest { message, session_id, temperature?, mode } │
|
||||
└────────────────────────────┬────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 2. Agent Service (agent_service.py) │
|
||||
│ stream_chat(message, session_id, project_id, user_id, jwt, mode) │
|
||||
│ → inputs = { │
|
||||
│ "messages": [HumanMessage(...)], │
|
||||
│ "llm_calls": 0, │
|
||||
│ "remaining_steps": 20, ← HARDCODED │
|
||||
│ "mode": mode │
|
||||
│ } │
|
||||
└────────────────────────────┬────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 3. Agent Graph (gns3_copilot.py) │
|
||||
│ recursion_limit_continue(state): │
|
||||
│ if state["remaining_steps"] < 4: return END │
|
||||
│ │
|
||||
│ tool_node(state): │
|
||||
│ → Execute tools without limit check │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Existing Controls
|
||||
|
||||
| Parameter | Location | Value | Description |
|
||||
|-----------|----------|-------|-------------|
|
||||
| `remaining_steps` | `agent_service.py:281` | 20 (hardcoded) | Total iteration count |
|
||||
| Recursion threshold | `gns3_copilot.py` | `< 4` | Stop when remaining < 4 |
|
||||
| `temperature` | `chat.py` | Reserved but not implemented | Runtime temperature override |
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### Option A: Simple Extension (Recommended)
|
||||
|
||||
**Scope**: API Schema + Agent Service modifications only
|
||||
|
||||
#### 1. API Schema Changes
|
||||
|
||||
**File**: `gns3server/schemas/controller/chat.py`
|
||||
|
||||
```python
|
||||
class ChatRequest(BaseModel):
|
||||
"""Chat request model."""
|
||||
|
||||
message: str = Field(..., description="User message content")
|
||||
session_id: Optional[str] = Field(None, description="Session ID")
|
||||
stream: bool = Field(default=True, description="Enable streaming response")
|
||||
mode: Literal["text"] = Field(default="text", description="Interaction mode")
|
||||
|
||||
# New runtime control parameters
|
||||
max_iterations: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
le=100,
|
||||
description="Maximum number of LLM-tool iterations (default: 20). "
|
||||
"Each iteration = LLM call + optional tool execution."
|
||||
)
|
||||
max_tool_calls: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
le=50,
|
||||
description="Maximum number of tool calls per request (default: unlimited). "
|
||||
"Useful for cost control and preventing runaway automation."
|
||||
)
|
||||
```
|
||||
|
||||
#### 2. Agent Service Changes
|
||||
|
||||
**File**: `gns3server/agent/gns3_copilot/agent_service.py`
|
||||
|
||||
```python
|
||||
async def stream_chat(
|
||||
self,
|
||||
message: str,
|
||||
session_id: str,
|
||||
project_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
jwt_token: Optional[str] = None,
|
||||
mode: str = "text",
|
||||
llm_config: Optional[Dict[str, Any]] = None,
|
||||
# New parameters
|
||||
max_iterations: Optional[int] = None,
|
||||
max_tool_calls: Optional[int] = None,
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""
|
||||
Stream chat responses from the agent.
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
session_id: Session/thread ID for conversation continuity
|
||||
project_id: GNS3 project ID (optional, for context)
|
||||
user_id: User ID for metadata tracking
|
||||
jwt_token: JWT token for API authentication (optional)
|
||||
mode: Interaction mode (default: "text")
|
||||
llm_config: LLM configuration dict (provider, model, api_key, etc.)
|
||||
max_iterations: Maximum LLM-tool iterations (default: 20)
|
||||
max_tool_calls: Maximum tool calls per request (default: unlimited)
|
||||
|
||||
Yields:
|
||||
Dict containing SSE-compatible response chunks
|
||||
"""
|
||||
log.info(
|
||||
"Stream chat started: project_id=%s, user_id=%s, session_id=%s, mode=%s, "
|
||||
"max_iterations=%s, max_tool_calls=%s",
|
||||
project_id,
|
||||
user_id,
|
||||
session_id,
|
||||
mode,
|
||||
max_iterations,
|
||||
max_tool_calls,
|
||||
)
|
||||
|
||||
# ... existing session setup code ...
|
||||
|
||||
# Build inputs with runtime parameters
|
||||
inputs = {
|
||||
"messages": [HumanMessage(content=message, id=str(uuid4()))],
|
||||
"llm_calls": 0,
|
||||
"remaining_steps": max_iterations or 20, # Use runtime parameter or default
|
||||
"max_tool_calls": max_tool_calls or 999, # New: tool call limit
|
||||
"tool_calls_count": 0, # New: counter
|
||||
"mode": mode,
|
||||
}
|
||||
|
||||
# ... rest of existing code ...
|
||||
```
|
||||
|
||||
#### 3. Agent Graph Changes
|
||||
|
||||
**File**: `gns3server/agent/gns3_copilot/agent/gns3_copilot.py`
|
||||
|
||||
```python
|
||||
def tool_node(state: dict, config: RunnableConfig | None = None):
|
||||
"""
|
||||
Performs the tool call with max_tool_calls limit.
|
||||
|
||||
Args:
|
||||
state: Current agent state containing messages and tool_calls
|
||||
config: Runnable configuration (optional)
|
||||
|
||||
Returns:
|
||||
Dict with tool execution results or error message if limit exceeded
|
||||
"""
|
||||
tool_calls = state["messages"][-1].tool_calls
|
||||
result = []
|
||||
|
||||
# Check tool call limit
|
||||
max_tool_calls = state.get("max_tool_calls", 999)
|
||||
current_tool_calls = state.get("tool_calls_count", 0)
|
||||
|
||||
if current_tool_calls + len(tool_calls) > max_tool_calls:
|
||||
log.warning(
|
||||
"Tool call limit exceeded: current=%d, requested=%d, max=%d",
|
||||
current_tool_calls,
|
||||
len(tool_calls),
|
||||
max_tool_calls
|
||||
)
|
||||
# Return error message for each tool call
|
||||
for tool_call in tool_calls:
|
||||
result.append(
|
||||
ToolMessage(
|
||||
content=f"Tool call limit reached ({max_tool_calls} calls). "
|
||||
f"Please simplify your request or break it into smaller steps. "
|
||||
f"Current tool call count: {current_tool_calls}/{max_tool_calls}.",
|
||||
tool_call_id=tool_call["id"],
|
||||
name=tool_call["name"]
|
||||
)
|
||||
)
|
||||
return {"messages": result}
|
||||
|
||||
# Execute tools normally
|
||||
for tool_call in tool_calls:
|
||||
tool_name = tool_call["name"]
|
||||
tool = tools_by_name[tool_name]
|
||||
try:
|
||||
observation = tool.invoke(tool_call["args"])
|
||||
except Exception as e:
|
||||
log.error("Error executing tool %s: %s", tool_name, e)
|
||||
observation = f"Error: {str(e)}"
|
||||
result.append(
|
||||
ToolMessage(
|
||||
content=observation,
|
||||
tool_call_id=tool_call["id"],
|
||||
name=tool_call["name"]
|
||||
)
|
||||
)
|
||||
|
||||
# Update tool call counter
|
||||
return {
|
||||
"messages": result,
|
||||
"tool_calls_count": current_tool_calls + len(tool_calls)
|
||||
}
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
The agent state needs to track the new fields:
|
||||
|
||||
```python
|
||||
# Existing MessagesState already has:
|
||||
# - messages: Annotated[List[BaseMessage], add_messages]
|
||||
# - llm_calls: int
|
||||
# - remaining_steps: int (from RemainingSteps)
|
||||
|
||||
# We add:
|
||||
# - max_tool_calls: int (per-request limit)
|
||||
# - tool_calls_count: int (running counter)
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
| Step | Task | File(s) | Difficulty | Priority |
|
||||
|------|------|---------|------------|----------|
|
||||
| 1 | Extend `ChatRequest` schema | `schemas/controller/chat.py` | ⭐ Low | P0 |
|
||||
| 2 | Modify `stream_chat` signature | `agent_service.py` | ⭐ Low | P0 |
|
||||
| 3 | Use `max_iterations` in inputs | `agent_service.py` | ⭐ Low | P0 |
|
||||
| 4 | Implement `max_tool_calls` logic | `gns3_copilot.py` | ⭐⭐ Medium | P1 |
|
||||
| 5 | Add tool call counter to state | `gns3_copilot.py` | ⭐ Low | P1 |
|
||||
| 6 | Update API documentation | `docs/` | ⭐ Low | P1 |
|
||||
| 7 | Add unit tests | `tests/` | ⭐⭐ Medium | P2 |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Default behavior (no changes needed)
|
||||
curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "配置所有路由器的 OSPF"
|
||||
}'
|
||||
```
|
||||
|
||||
### With Custom Iteration Limit
|
||||
|
||||
```bash
|
||||
# Allow more iterations for complex multi-device configuration
|
||||
curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "在10台路由器上配置OSPF、BGP和静态路由,然后验证连通性",
|
||||
"max_iterations": 50
|
||||
}'
|
||||
```
|
||||
|
||||
### With Tool Call Limit
|
||||
|
||||
```bash
|
||||
# Limit tool calls for cost control
|
||||
curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "检查所有设备的接口状态",
|
||||
"max_tool_calls": 15
|
||||
}'
|
||||
```
|
||||
|
||||
### Combined Parameters
|
||||
|
||||
```bash
|
||||
# Complex task with both limits
|
||||
curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "配置整个实验室的网络并测试连通性",
|
||||
"max_iterations": 40,
|
||||
"max_tool_calls": 30
|
||||
}'
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Parameter Limits
|
||||
|
||||
| Parameter | Min | Max | Default | Rationale |
|
||||
|-----------|-----|-----|---------|-----------|
|
||||
| `max_iterations` | 1 | 100 | 20 | Prevent infinite loops, allow complex tasks |
|
||||
| `max_tool_calls` | 1 | 50 | unlimited (999) | Prevent tool abuse, control cost |
|
||||
|
||||
### Risk Mitigation
|
||||
|
||||
1. **Upper bounds enforced**: Pydantic validation prevents excessive values
|
||||
2. **Graceful degradation**: Agent returns informative error messages when limits are reached
|
||||
3. **Per-request scope**: Parameters don't persist across sessions
|
||||
4. **Audit logging**: All parameters are logged for security analysis
|
||||
|
||||
### Edge Cases
|
||||
|
||||
```
|
||||
Case 1: max_iterations = 1
|
||||
→ Only one LLM call, no tool execution
|
||||
→ Useful for simple Q&A without actions
|
||||
|
||||
Case 2: max_tool_calls = 1
|
||||
→ Agent can only call one tool
|
||||
→ Forces user to break complex tasks into smaller steps
|
||||
|
||||
Case 3: LLM ignores limits
|
||||
→ Agent enforces limits at execution time
|
||||
→ Returns error when limit exceeded
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
✅ **Fully backward compatible**
|
||||
|
||||
- All new parameters are `Optional`
|
||||
- Default values match current behavior
|
||||
- Existing clients continue to work without changes
|
||||
- No database migrations required
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```python
|
||||
def test_max_iterations_enforced():
|
||||
"""Test that agent respects max_iterations parameter"""
|
||||
# Create request with max_iterations=5
|
||||
# Verify agent stops after 5 iterations
|
||||
|
||||
def test_max_tool_calls_enforced():
|
||||
"""Test that agent respects max_tool_calls parameter"""
|
||||
# Create request with max_tool_calls=3
|
||||
# Trigger 5 tool calls
|
||||
# Verify only 3 execute, rest return error
|
||||
|
||||
def test_default_behavior_unchanged():
|
||||
"""Test that omitting parameters uses defaults"""
|
||||
# Create request without new parameters
|
||||
# Verify behavior matches current implementation
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```python
|
||||
async def test_complex_multi_device_task():
|
||||
"""Test complex task with increased limits"""
|
||||
# Configure OSPF on 10 routers
|
||||
# max_iterations=30, max_tool_calls=25
|
||||
# Verify successful completion
|
||||
|
||||
async def test_tool_limit_error_message():
|
||||
"""Test that limit errors are informative"""
|
||||
# Set max_tool_calls=2
|
||||
# Trigger 3 tool calls
|
||||
# Verify third call returns helpful error message
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Phase 2 Features
|
||||
|
||||
1. **Per-tool limits**:
|
||||
```python
|
||||
max_device_config_calls: Optional[int] = None
|
||||
max_diagnostic_calls: Optional[int] = None
|
||||
```
|
||||
|
||||
2. **Time-based limits**:
|
||||
```python
|
||||
max_execution_time_seconds: Optional[int] = None
|
||||
```
|
||||
|
||||
3. **Cost estimation**:
|
||||
```python
|
||||
estimate_cost_before_execution: bool = False
|
||||
```
|
||||
|
||||
4. **Adaptive limits**:
|
||||
```python
|
||||
auto_adjust_limits: bool = False # AI decides optimal limits
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
```python
|
||||
class AdvancedAgentControls(BaseModel):
|
||||
"""Advanced runtime controls for power users"""
|
||||
|
||||
# Execution limits
|
||||
max_iterations: Optional[int] = None
|
||||
max_tool_calls: Optional[int] = None
|
||||
max_execution_time_seconds: Optional[int] = None
|
||||
|
||||
# Tool-specific limits
|
||||
tool_limits: Dict[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Per-tool call limits, e.g., {'execute_multiple_device_commands': 10}"
|
||||
)
|
||||
|
||||
# Retry behavior
|
||||
max_retries_per_tool: int = Field(default=1, ge=0, le=5)
|
||||
retry_on_tool_error: bool = Field(default=False)
|
||||
|
||||
# Parallel execution
|
||||
max_parallel_tools: int = Field(default=5, ge=1, le=20)
|
||||
|
||||
# Fallback behavior
|
||||
on_limit_reached: Literal["fail", "warn", "continue"] = "warn"
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [AI Chat API Design](../ai-chat-api-design.md)
|
||||
- [HITL Implementation Plan](./hitl-implementation-plan.md)
|
||||
- [Tool Response Format Standard](./tool-response-format-standard.md)
|
||||
|
||||
## References
|
||||
|
||||
- LangGraph State Management: https://langchain-ai.github.io/langgraph/concepts/low_level/#state
|
||||
- Pydantic Field Validation: https://docs.pydantic.dev/latest/concepts/fields/
|
||||
- GNS3 Controller API: https://api.gns3.com/
|
||||
|
||||
## Discussion Points
|
||||
|
||||
### Open Questions
|
||||
|
||||
1. **Should limits be per-message or per-session?**
|
||||
- Current: Per-message (per request)
|
||||
- Alternative: Per-session (accumulate across conversation)
|
||||
|
||||
2. **Should we expose `remaining_steps` in the response?**
|
||||
- Pro: User knows how many iterations left
|
||||
- Con: Exposes internal implementation details
|
||||
|
||||
3. **Should we allow dynamic limit adjustment during execution?**
|
||||
- Requires streaming parameter updates
|
||||
- More complex but more flexible
|
||||
|
||||
4. **What about `temperature` override?**
|
||||
- Already reserved in schema but not implemented
|
||||
- Should we implement it in the same change?
|
||||
|
||||
### Decision Required
|
||||
|
||||
- [ ] Confirm parameter ranges (min/max values)
|
||||
- [ ] Decide on error handling strategy (fail vs warn)
|
||||
- [ ] Approve implementation plan
|
||||
- [ ] Set target release version
|
||||
|
||||
---
|
||||
|
||||
**Status**: Design Draft - Ready for Review
|
||||
|
||||
**Author**: GNS3 Copilot Team
|
||||
|
||||
**Last Updated**: 2025-03-06
|
||||
|
||||
**Target Version**: TBD
|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
|
||||
@ -1,781 +0,0 @@
|
||||
# Runtime LLM Config Override
|
||||
|
||||
**Document Status**: Design Phase
|
||||
**Priority**: Medium
|
||||
**Created**: 2026-03-09
|
||||
**Related Docs**:
|
||||
- [AI Chat API Design](../ai-chat-api-design.md)
|
||||
- [LLM Model Configs API](../llm-model-configs-api.md)
|
||||
- [User-Selectable Group Default Config](./user-selectable-group-default-config.md)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Problem Description](#problem-description)
|
||||
- [Requirements Analysis](#requirements-analysis)
|
||||
- [Solution Design](#solution-design)
|
||||
- [Implementation Steps](#implementation-steps)
|
||||
- [Code Changes Checklist](#code-changes-checklist)
|
||||
- [Testing Plan](#testing-plan)
|
||||
- [Risk Assessment](#risk-assessment)
|
||||
|
||||
---
|
||||
|
||||
## Problem Description
|
||||
|
||||
### Current Behavior
|
||||
|
||||
Currently, the Chat API always uses the user's default LLM configuration from the database. Users cannot:
|
||||
1. Select a different saved configuration for a specific request
|
||||
2. Temporarily override certain parameters (e.g., use a different model, adjust temperature) for a single request
|
||||
|
||||
### User Scenarios
|
||||
|
||||
**Scenario 1: Quick Model Testing**
|
||||
```
|
||||
User has multiple configs:
|
||||
- "GPT-4o" (default)
|
||||
- "Claude 3.5 Sonnet"
|
||||
- "Gemini Pro"
|
||||
|
||||
User wants to test the same prompt on Claude 3.5 without changing default
|
||||
```
|
||||
|
||||
**Scenario 2: Temporary Parameter Adjustment**
|
||||
```
|
||||
User's default config:
|
||||
- model: "gpt-4o"
|
||||
- temperature: 0.7
|
||||
|
||||
User wants to try a more creative response (temperature=1.2) for this request only
|
||||
```
|
||||
|
||||
**Scenario 3: Cost Optimization**
|
||||
```
|
||||
User's default config:
|
||||
- model: "gpt-4o" (expensive)
|
||||
|
||||
User wants to use "gpt-4o-mini" for this simple request
|
||||
```
|
||||
|
||||
### Current Limitation
|
||||
|
||||
**File**: `gns3server/api/routes/controller/chat.py:122`
|
||||
|
||||
```python
|
||||
# Always gets user's default config
|
||||
llm_config = await get_user_llm_config_full(str(user_id), app)
|
||||
```
|
||||
|
||||
No way to specify alternative config or override parameters at request time.
|
||||
|
||||
---
|
||||
|
||||
## Requirements Analysis
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
1. **Select Saved Configuration**
|
||||
- User can specify `llm_config_id` to use a different saved config
|
||||
- Must be a config owned by the user or inherited from their group
|
||||
- Validation: If config_id is invalid or inaccessible, return error
|
||||
|
||||
2. **Override LLM Parameters**
|
||||
- Support temporary override of common LLM parameters:
|
||||
- `model`: Model name (e.g., "gpt-4o", "claude-3-5-sonnet-20241022")
|
||||
- `temperature`: Sampling temperature (0.0-2.0)
|
||||
- `max_tokens`: Maximum tokens to generate
|
||||
- `top_p`: Nucleus sampling parameter
|
||||
- (Additional provider-specific parameters as needed)
|
||||
- Overrides apply only to the current request
|
||||
- Original config in database is NOT modified
|
||||
|
||||
3. **Parameter Precedence**
|
||||
```
|
||||
Request Overrides > Database Config > Provider Defaults
|
||||
```
|
||||
|
||||
4. **Backward Compatibility**
|
||||
- All new parameters are optional
|
||||
- Existing requests without new parameters work unchanged
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
1. **Security**
|
||||
- API key from selected config remains secure
|
||||
- Users can only select their accessible configs
|
||||
- Overrides are logged for audit
|
||||
|
||||
2. **Performance**
|
||||
- Minimal overhead for config retrieval and validation
|
||||
- No database write for temporary overrides
|
||||
|
||||
3. **Maintainability**
|
||||
- Clear code structure for override logic
|
||||
- Easy to add new overridable parameters in the future
|
||||
|
||||
---
|
||||
|
||||
## Solution Design
|
||||
|
||||
### API Schema Changes
|
||||
|
||||
**File**: `gns3server/schemas/controller/chat.py`
|
||||
|
||||
```python
|
||||
class LLMConfigOverride(BaseModel):
|
||||
"""Temporary LLM configuration overrides for a single request."""
|
||||
|
||||
model: Optional[str] = Field(
|
||||
None,
|
||||
description="Override the model name (e.g., 'gpt-4o', 'claude-3-5-sonnet-20241022'). "
|
||||
"Provider and API key still come from the selected or default config."
|
||||
)
|
||||
temperature: Optional[float] = Field(
|
||||
None,
|
||||
ge=0.0,
|
||||
le=2.0,
|
||||
description="Override sampling temperature (0.0-2.0). "
|
||||
"Lower values make output more deterministic, higher values more random."
|
||||
)
|
||||
max_tokens: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
description="Override maximum tokens to generate in the response."
|
||||
)
|
||||
top_p: Optional[float] = Field(
|
||||
None,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Override nucleus sampling parameter (0.0-1.0)."
|
||||
)
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
"""Chat request model."""
|
||||
|
||||
message: str = Field(..., description="User message content")
|
||||
session_id: Optional[str] = Field(None, description="Session ID (auto-generated if not provided)")
|
||||
stream: bool = Field(default=True, description="Enable streaming response")
|
||||
|
||||
# NEW: LLM Configuration Selection
|
||||
llm_config_id: Optional[str] = Field(
|
||||
None,
|
||||
description="LLM configuration ID to use for this request. "
|
||||
"Must be a config owned by the user or inherited from their group. "
|
||||
"If not provided, uses the user's default LLM config."
|
||||
)
|
||||
|
||||
# NEW: Runtime Parameter Overrides
|
||||
llm_config_override: Optional[LLMConfigOverride] = Field(
|
||||
None,
|
||||
description="Temporary overrides for LLM parameters. "
|
||||
"These overrides apply only to this request and do not modify the stored config. "
|
||||
"Overrides take precedence over the selected/default config values."
|
||||
)
|
||||
|
||||
mode: Literal["text"] = Field(default="text", description="Interaction mode")
|
||||
```
|
||||
|
||||
### Configuration Resolution Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1. API Layer (chat.py) │
|
||||
│ POST /v3/projects/{project_id}/chat/stream │
|
||||
│ ChatRequest { │
|
||||
│ message, │
|
||||
│ llm_config_id?, # Select config │
|
||||
│ llm_config_override?: { # Override params │
|
||||
│ model?, temperature?, max_tokens?, top_p? │
|
||||
│ } │
|
||||
│ } │
|
||||
└────────────────────────────┬────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 2. Config Resolution (get_llm_config_for_request) │
|
||||
│ │
|
||||
│ if llm_config_id provided: │
|
||||
│ → Get specific config by ID │
|
||||
│ → Validate: user must have access (own or inherited group) │
|
||||
│ → If invalid: return 403 Forbidden │
|
||||
│ else: │
|
||||
│ → Get user's default config (current behavior) │
|
||||
│ │
|
||||
│ Decrypt API key from resolved config │
|
||||
│ Apply llm_config_override (if provided) │
|
||||
│ → Override fields: model, temperature, max_tokens, top_p │
|
||||
│ │
|
||||
│ Result: { │
|
||||
│ provider, api_key, model*, temperature*, max_tokens*, top_p*, ... │
|
||||
│ } │
|
||||
│ (* = overridden if provided) │
|
||||
└────────────────────────────┬────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 3. Agent Execution (agent_service.py) │
|
||||
│ Set ContextVars with resolved and overridden config │
|
||||
│ Proceed with normal Agent flow │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Implementation Details
|
||||
|
||||
#### File: `gns3server/db/tasks.py`
|
||||
|
||||
Add new function `get_llm_config_for_request`:
|
||||
|
||||
```python
|
||||
async def get_llm_config_for_request(
|
||||
user_id: str,
|
||||
app: FastAPI,
|
||||
config_id: Optional[str] = None,
|
||||
overrides: Optional[dict] = None
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Get LLM configuration for a specific request with optional overrides.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
app: FastAPI application instance
|
||||
config_id: Optional specific config ID to use
|
||||
overrides: Optional dict of parameter overrides (model, temperature, etc.)
|
||||
|
||||
Returns:
|
||||
Dictionary with LLM configuration (with overrides applied) or None if not found.
|
||||
|
||||
Raises:
|
||||
ValueError: If config_id is specified but not accessible to user
|
||||
"""
|
||||
from uuid import UUID
|
||||
from gns3server.db.repositories.llm_model_configs import LLMModelConfigsRepository
|
||||
from gns3server.utils.encryption import decrypt, is_encrypted
|
||||
|
||||
try:
|
||||
user_uuid = UUID(user_id) if isinstance(user_id, str) else user_id
|
||||
|
||||
async with AsyncSession(app.state._db_engine, expire_on_commit=False) as session:
|
||||
repo = LLMModelConfigsRepository(session)
|
||||
|
||||
# Step 1: Resolve base config
|
||||
if config_id:
|
||||
# User specified a config - validate access
|
||||
config_uuid = UUID(config_id) if isinstance(config_id, str) else config_id
|
||||
|
||||
# Get all accessible configs for user
|
||||
effective = await repo.get_user_effective_configs(
|
||||
user_uuid,
|
||||
current_user_id=user_uuid,
|
||||
current_user_is_superadmin=False
|
||||
)
|
||||
|
||||
accessible_config_ids = {c["config_id"] for c in effective["configs"]}
|
||||
|
||||
if config_uuid not in accessible_config_ids:
|
||||
log.warning(
|
||||
f"User {user_id} attempted to use inaccessible config {config_id}"
|
||||
)
|
||||
raise ValueError(f"Config {config_id} is not accessible to user")
|
||||
|
||||
# Get the config (bypass API key hiding since this is system-level)
|
||||
config_record = await repo.get_user_config(config_uuid)
|
||||
if not config_record:
|
||||
log.error(f"Config {config_id} not found in database")
|
||||
return None
|
||||
|
||||
source = "user_selected"
|
||||
|
||||
else:
|
||||
# Use user's default config
|
||||
result = await repo.get_user_effective_configs(
|
||||
user_uuid,
|
||||
current_user_id=user_uuid,
|
||||
current_user_is_superadmin=False
|
||||
)
|
||||
|
||||
if not result or not result.get("default_config"):
|
||||
log.warning(f"No default LLM configuration found for user {user_id}")
|
||||
return None
|
||||
|
||||
default_config = result["default_config"]
|
||||
config_id_str = default_config["config_id"]
|
||||
config_record = await repo.get_user_config(UUID(config_id_str))
|
||||
|
||||
if not config_record:
|
||||
log.error(f"Default config {config_id_str} not found in database")
|
||||
return None
|
||||
|
||||
source = "default"
|
||||
|
||||
# Step 2: Decrypt API key
|
||||
config_data = config_record.config.copy()
|
||||
inherited_from_config_id = config_record.inherited_from_config_id
|
||||
|
||||
# Handle shadow configs - get API key from parent
|
||||
if inherited_from_config_id:
|
||||
parent_config = await repo.get_group_config(inherited_from_config_id)
|
||||
if parent_config and "api_key" in parent_config.config:
|
||||
try:
|
||||
encrypted_key = parent_config.config["api_key"]
|
||||
if encrypted_key and is_encrypted(encrypted_key):
|
||||
config_data["api_key"] = decrypt(encrypted_key)
|
||||
else:
|
||||
config_data["api_key"] = encrypted_key
|
||||
except Exception as e:
|
||||
log.error(f"Failed to decrypt inherited API key: {e}")
|
||||
return None
|
||||
else:
|
||||
# Regular config - decrypt directly
|
||||
if "api_key" in config_data and config_data["api_key"]:
|
||||
try:
|
||||
if is_encrypted(config_data["api_key"]):
|
||||
config_data["api_key"] = decrypt(config_data["api_key"])
|
||||
except Exception as e:
|
||||
log.error(f"Failed to decrypt API key: {e}")
|
||||
return None
|
||||
|
||||
# Step 3: Apply overrides
|
||||
if overrides:
|
||||
if overrides.get("model"):
|
||||
config_data["model"] = overrides["model"]
|
||||
log.info(f"Model override applied: {overrides['model']}")
|
||||
if overrides.get("temperature") is not None:
|
||||
config_data["temperature"] = overrides["temperature"]
|
||||
log.info(f"Temperature override applied: {overrides['temperature']}")
|
||||
if overrides.get("max_tokens") is not None:
|
||||
config_data["max_tokens"] = overrides["max_tokens"]
|
||||
log.info(f"Max tokens override applied: {overrides['max_tokens']}")
|
||||
if overrides.get("top_p") is not None:
|
||||
config_data["top_p"] = overrides["top_p"]
|
||||
log.info(f"Top-p override applied: {overrides['top_p']}")
|
||||
|
||||
# Step 4: Build final config dict
|
||||
llm_config = {
|
||||
"config_id": str(config_record.config_id),
|
||||
"name": config_record.name,
|
||||
"model_type": str(config_record.model_type),
|
||||
"source": source,
|
||||
"user_id": str(config_record.user_id) if config_record.user_id else None,
|
||||
"group_id": str(config_record.group_id) if config_record.group_id else None,
|
||||
"inherited_from": str(inherited_from_config_id) if inherited_from_config_id else None,
|
||||
**config_data
|
||||
}
|
||||
|
||||
# Validate required fields
|
||||
if not llm_config.get("provider"):
|
||||
log.error(f"LLM config missing 'provider' field: {config_record.config_id}")
|
||||
return None
|
||||
|
||||
if not llm_config.get("model"):
|
||||
log.error(f"LLM config missing 'model' field: {config_record.config_id}")
|
||||
return None
|
||||
|
||||
if not llm_config.get("api_key"):
|
||||
log.error(f"LLM config missing 'api_key' field: {config_record.config_id}")
|
||||
return None
|
||||
|
||||
log.info(
|
||||
f"Retrieved LLM config for user {user_id}: "
|
||||
f"provider={llm_config.get('provider')}, model={llm_config.get('model')}, "
|
||||
f"source={source}, overrides_applied={bool(overrides)}"
|
||||
)
|
||||
|
||||
return llm_config
|
||||
|
||||
except ValueError:
|
||||
raise # Re-raise validation errors
|
||||
except Exception as e:
|
||||
log.error(f"Failed to retrieve LLM config for user {user_id}: {e}", exc_info=True)
|
||||
return None
|
||||
```
|
||||
|
||||
#### File: `gns3server/api/routes/controller/chat.py`
|
||||
|
||||
Modify the stream endpoint to use new function:
|
||||
|
||||
```python
|
||||
@router.post("/stream", response_model=SkipValidation[ChatResponse])
|
||||
async def stream_chat(
|
||||
project_id: str,
|
||||
request: ChatRequest,
|
||||
current_user: schemas.User = Depends(get_current_active_user),
|
||||
):
|
||||
"""Stream chat responses from the GNS3 Copilot Agent."""
|
||||
|
||||
# ... existing project validation code ...
|
||||
|
||||
# NEW: Resolve LLM config with overrides
|
||||
overrides = None
|
||||
if request.llm_config_override:
|
||||
overrides = request.llm_config_override.model_dump(exclude_none=True)
|
||||
|
||||
try:
|
||||
llm_config = await get_llm_config_for_request(
|
||||
user_id=str(current_user.user_id),
|
||||
app=app,
|
||||
config_id=request.llm_config_id,
|
||||
overrides=overrides
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
if not llm_config:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="LLM configuration not found or not accessible. Please configure an LLM model first."
|
||||
)
|
||||
|
||||
# ... rest of existing code with llm_config ...
|
||||
|
||||
# Set ContextVars
|
||||
set_current_jwt_token(jwt_token)
|
||||
set_current_llm_config(llm_config)
|
||||
|
||||
# ... continue with Agent flow ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
| Step | Task | File(s) | Difficulty | Priority |
|
||||
|------|------|---------|------------|----------|
|
||||
| 1 | Add `LLMConfigOverride` schema | `schemas/controller/chat.py` | ⭐ Low | P0 |
|
||||
| 2 | Add `llm_config_id` and `llm_config_override` to `ChatRequest` | `schemas/controller/chat.py` | ⭐ Low | P0 |
|
||||
| 3 | Implement `get_llm_config_for_request` function | `db/tasks.py` | ⭐⭐ Medium | P0 |
|
||||
| 4 | Modify `stream_chat` endpoint to use new function | `api/routes/controller/chat.py` | ⭐ Low | P0 |
|
||||
| 5 | Update API documentation | `docs/gns3-copilot/ai-chat-api-design.md` | ⭐ Low | P1 |
|
||||
| 6 | Add unit tests for config resolution logic | `tests/` | ⭐⭐ Medium | P1 |
|
||||
| 7 | Add integration tests for override scenarios | `tests/` | ⭐⭐ Medium | P2 |
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Select Different Config
|
||||
|
||||
```bash
|
||||
# Use a specific saved config instead of default
|
||||
curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "Explain OSPF configuration",
|
||||
"llm_config_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}'
|
||||
```
|
||||
|
||||
### Example 2: Override Model Only
|
||||
|
||||
```bash
|
||||
# Use default config but with a different model
|
||||
curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "Configure OSPF on all routers",
|
||||
"llm_config_override": {
|
||||
"model": "gpt-4o-mini"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Example 3: Override Temperature
|
||||
|
||||
```bash
|
||||
# Use default config but with higher temperature for creativity
|
||||
curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "Write a creative network scenario",
|
||||
"llm_config_override": {
|
||||
"temperature": 1.2,
|
||||
"top_p": 0.95
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Example 4: Select Config + Override Parameters
|
||||
|
||||
```bash
|
||||
# Use specific config and override multiple parameters
|
||||
curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "Analyze network topology",
|
||||
"llm_config_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"llm_config_override": {
|
||||
"model": "claude-3-5-sonnet-20241022",
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Example 5: Error Case - Inaccessible Config
|
||||
|
||||
```bash
|
||||
# Attempting to use another user's config returns 403
|
||||
curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "Test",
|
||||
"llm_config_id": "00000000-0000-0000-0000-000000000000"
|
||||
}'
|
||||
|
||||
# Response:
|
||||
# {
|
||||
# "detail": "Config 00000000-0000-0000-0000-000000000000 is not accessible to user"
|
||||
# }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Changes Checklist
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File Path | Change Type | Description |
|
||||
|-----------|-------------|-------------|
|
||||
| `gns3server/schemas/controller/chat.py` | Modify | Add `LLMConfigOverride` model and new fields to `ChatRequest` |
|
||||
| `gns3server/db/tasks.py` | Add | Add `get_llm_config_for_request` function |
|
||||
| `gns3server/api/routes/controller/chat.py` | Modify | Use `get_llm_config_for_request` with error handling |
|
||||
| `docs/gns3-copilot/ai-chat-api-design.md` | Modify | Update API documentation with new parameters |
|
||||
|
||||
### New Files
|
||||
|
||||
| File Path | Description |
|
||||
|-----------|-------------|
|
||||
| N/A | No new files (all changes are modifications) |
|
||||
|
||||
---
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Unit Tests
|
||||
|
||||
#### 1. Test `get_llm_config_for_request`
|
||||
|
||||
- **Test 1.1**: Use default config (no config_id)
|
||||
- Input: `config_id=None, overrides=None`
|
||||
- Expected: Returns user's default config
|
||||
|
||||
- **Test 1.2**: Use specific user config
|
||||
- Input: Valid `config_id` owned by user
|
||||
- Expected: Returns specified config
|
||||
|
||||
- **Test 1.3**: Use inherited group config
|
||||
- Input: Valid `config_id` from user's group
|
||||
- Expected: Returns specified config with API key from parent
|
||||
|
||||
- **Test 1.4**: Use inaccessible config
|
||||
- Input: `config_id` from another user
|
||||
- Expected: Raises `ValueError`
|
||||
|
||||
- **Test 1.5**: Apply model override
|
||||
- Input: `overrides={"model": "gpt-4o-mini"}`
|
||||
- Expected: Returns config with `model="gpt-4o-mini"`
|
||||
|
||||
- **Test 1.6**: Apply temperature override
|
||||
- Input: `overrides={"temperature": 1.5}`
|
||||
- Expected: Returns config with `temperature=1.5`
|
||||
|
||||
- **Test 1.7**: Apply multiple overrides
|
||||
- Input: `overrides={"model": "x", "temperature": 0.5, "max_tokens": 1000}`
|
||||
- Expected: Returns config with all overrides applied
|
||||
|
||||
- **Test 1.8**: Invalid config_id
|
||||
- Input: Non-existent `config_id`
|
||||
- Expected: Returns `None`
|
||||
|
||||
### Integration Tests
|
||||
|
||||
#### 1. API Endpoint Tests
|
||||
|
||||
- **Test 1.1**: Request without new parameters (backward compatibility)
|
||||
- Expected: Works exactly as before
|
||||
|
||||
- **Test 1.2**: Request with `llm_config_id` only
|
||||
- Expected: Uses specified config
|
||||
|
||||
- **Test 1.3**: Request with `llm_config_override` only
|
||||
- Expected: Uses default config with overrides
|
||||
|
||||
- **Test 1.4**: Request with both `llm_config_id` and `llm_config_override`
|
||||
- Expected: Uses specified config with overrides
|
||||
|
||||
- **Test 1.5**: Request with inaccessible `llm_config_id`
|
||||
- Expected: Returns 403 Forbidden
|
||||
|
||||
- **Test 1.6**: Override validation (temperature out of range)
|
||||
- Input: `temperature=3.0` (exceeds max 2.0)
|
||||
- Expected: Returns 422 Validation Error
|
||||
|
||||
#### 2. Agent Integration Tests
|
||||
|
||||
- **Test 2.1**: Agent uses overridden config correctly
|
||||
- Verify LLM is called with overridden parameters
|
||||
|
||||
- **Test 2.2**: Multiple concurrent requests with different configs
|
||||
- Verify no cross-contamination between requests
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Technical Risks
|
||||
|
||||
| Risk | Impact | Probability | Mitigation |
|
||||
|------|--------|-------------|------------|
|
||||
| Config retrieval performance degradation | Medium | Low | Cache frequently used configs, optimize queries |
|
||||
| Override validation bypass | High | Low | Pydantic validation for all override fields |
|
||||
| API key leakage in logs | High | Low | Ensure API key is never logged, use [REDACTED] |
|
||||
| Incorrect config precedence | Medium | Low | Clear documentation and thorough testing |
|
||||
|
||||
### Security Risks
|
||||
|
||||
| Risk | Impact | Probability | Mitigation |
|
||||
|------|--------|-------------|------------|
|
||||
| User accessing another user's config | High | Low | Validate config accessibility before use |
|
||||
| Privilege escalation via config_id | High | Low | Strict access control validation |
|
||||
| API key exposure via override | Low | Low | API key cannot be overridden (not in schema) |
|
||||
|
||||
### Compatibility Risks
|
||||
|
||||
| Risk | Impact | Probability | Mitigation |
|
||||
|------|--------|-------------|------------|
|
||||
| Existing clients break | High | Low | All new fields are optional, default behavior unchanged |
|
||||
| UI doesn't support new fields | Low | Medium | UI can ignore new fields, phased rollout |
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Access Control
|
||||
|
||||
1. **Config Access Validation**
|
||||
- User can only specify configs they own or inherited from their group
|
||||
- Validation happens before API key decryption
|
||||
- 403 Forbidden error for inaccessible configs
|
||||
|
||||
2. **Immutable Fields**
|
||||
- `api_key` cannot be overridden (not in `LLMConfigOverride`)
|
||||
- `provider` cannot be overridden (requires different API key handling)
|
||||
- Only safe parameters can be overridden
|
||||
|
||||
3. **Audit Logging**
|
||||
- Log all override operations with user_id, config_id, and override values
|
||||
- Do not log API keys (use [REDACTED] placeholder)
|
||||
|
||||
### Parameter Validation
|
||||
|
||||
| Parameter | Validation | Rationale |
|
||||
|-----------|------------|-----------|
|
||||
| `llm_config_id` | Must be valid UUID, accessible to user | Prevent injection attacks |
|
||||
| `model` | String, max length 255 | Prevent oversized strings |
|
||||
| `temperature` | 0.0 ≤ value ≤ 2.0 | LLM API limits |
|
||||
| `max_tokens` | ≥ 1 | Prevent negative/zero values |
|
||||
| `top_p` | 0.0 ≤ value ≤ 1.0 | LLM API limits |
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Phase 2 Features
|
||||
|
||||
1. **Additional Override Parameters**
|
||||
- `frequency_penalty`: Token frequency penalty
|
||||
- `presence_penalty`: Token presence penalty
|
||||
- `stop`: Stop sequences
|
||||
- Provider-specific parameters (e.g., OpenAI functions)
|
||||
|
||||
2. **Config Templates**
|
||||
- Predefined override templates (e.g., "creative", "precise", "fast")
|
||||
- Users can save and reuse override combinations
|
||||
|
||||
3. **Usage Statistics**
|
||||
- Track which configs are most commonly used
|
||||
- Track which overrides are most commonly applied
|
||||
- Provide insights for default config optimization
|
||||
|
||||
4. **Config Recommendations**
|
||||
- Suggest optimal config based on request content
|
||||
- Auto-select cost-effective config for simple queries
|
||||
|
||||
---
|
||||
|
||||
## Related Features
|
||||
|
||||
- [User-Selectable Group Default Config](./user-selectable-group-default-config.md) - Setting default config
|
||||
- [Runtime Agent Parameters](./runtime-agent-params.md) - Controlling Agent execution behavior
|
||||
- [LLM Model Configs API](../llm-model-configs-api.md) - Managing saved configurations
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [AI Chat API Design](../ai-chat-api-design.md)
|
||||
- [Pydantic Field Validation](https://docs.pydantic.dev/latest/concepts/fields/)
|
||||
- FastAPI Request Handling: https://fastapi.tiangolo.com/tutorial/body/
|
||||
|
||||
---
|
||||
|
||||
## Discussion Points
|
||||
|
||||
### Open Questions
|
||||
|
||||
1. **Should we allow `provider` override?**
|
||||
- Pro: More flexibility (e.g., switch from OpenAI to Anthropic)
|
||||
- Con: Requires different API key handling, more complex
|
||||
- **Recommendation**: No - keep it simple for now
|
||||
|
||||
2. **Should overrides be visible in response metadata?**
|
||||
- Pro: User knows which config/overrides were used
|
||||
- Con: Increases response size
|
||||
- **Recommendation**: Add to session metadata, not SSE messages
|
||||
|
||||
3. **Should we support parameter shortcuts?**
|
||||
- Example: `"mode": "fast"` instead of specifying all parameters
|
||||
- **Recommendation**: Future enhancement via templates
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2026-03-09
|
||||
**Target Version**: TBD
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
@ -1,269 +0,0 @@
|
||||
# SSE Connection Interruption and Agent Cancellation Design
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the behavior when SSE connection is interrupted during agent execution, how statistics are handled, and strategies for graceful agent cancellation.
|
||||
|
||||
## Current Behavior Analysis
|
||||
|
||||
### What Happens When SSE Connection Drops
|
||||
|
||||
| Component | Behavior | Persists After Disconnect |
|
||||
|-----------|----------|---------------------------|
|
||||
| LangGraph Checkpoint | Auto-saved after each node completes | ✅ Yes |
|
||||
| Messages in conversation | Saved to checkpoint | ✅ Yes |
|
||||
| Session statistics (message_count, tokens, etc.) | Not updated | ❌ Lost |
|
||||
| Auto-generated title | Not synced | ❌ Lost |
|
||||
|
||||
### LangGraph Checkpoint Mechanism
|
||||
|
||||
LangGraph automatically saves checkpoint after each node completes:
|
||||
|
||||
```
|
||||
llm_call (AI generates response)
|
||||
↓ checkpoint saved
|
||||
should_continue (decides if tools needed)
|
||||
↓ checkpoint saved
|
||||
tool_node (executes tools)
|
||||
↓ checkpoint saved
|
||||
llm_call (processes tool results)
|
||||
...
|
||||
```
|
||||
|
||||
**Important**: Checkpoint is saved at node boundaries, not during node execution.
|
||||
|
||||
## Statistics Tracking Issue
|
||||
|
||||
### Current Implementation
|
||||
|
||||
```python
|
||||
message_count = 1 # User message
|
||||
llm_calls_count = 0
|
||||
|
||||
async for event in graph.astream_events(...):
|
||||
if event_type == "on_chat_model_start":
|
||||
llm_calls_count += 1
|
||||
elif event_type == "on_chat_model_end":
|
||||
message_count += 1
|
||||
elif event_type == "on_tool_end":
|
||||
message_count += 1
|
||||
```
|
||||
|
||||
Statistics are calculated during streaming and only persisted after successful completion:
|
||||
|
||||
```python
|
||||
try:
|
||||
async for event in graph.astream_events(...):
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
yield {"type": "error", ...}
|
||||
# Statistics update - only runs on successful completion!
|
||||
await repo.update_session(message_count=message_count, ...)
|
||||
```
|
||||
|
||||
### Problem
|
||||
|
||||
When connection drops mid-stream:
|
||||
- Statistics are calculated in-memory but never persisted
|
||||
- Values may be incomplete/inaccurate (e.g., 2 LLM calls made but only 1 counted)
|
||||
|
||||
## Graceful Shutdown Strategy
|
||||
|
||||
### Recommended: try/finally Approach
|
||||
|
||||
Add `try/finally` to ensure statistics are updated even on disconnection:
|
||||
|
||||
```python
|
||||
async def stream_chat(...):
|
||||
try:
|
||||
async for event in graph.astream_events(inputs, config=config, version="v2"):
|
||||
try:
|
||||
yield chunk # May raise exception on client disconnect
|
||||
except Exception:
|
||||
log.info("Client disconnected, stopping stream")
|
||||
break
|
||||
except Exception as e:
|
||||
yield {"type": "error", "error": str(e)}
|
||||
finally:
|
||||
# Always update statistics, even on disconnect
|
||||
await repo.update_session(
|
||||
thread_id=session_id,
|
||||
message_count=message_count,
|
||||
llm_calls_count=llm_calls_count,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
- Statistics are recorded even on disconnection
|
||||
- Title sync attempt on every request
|
||||
- Minimal performance overhead (single DB write)
|
||||
|
||||
### Trade-offs
|
||||
|
||||
- Statistics may be inaccurate if disconnection happens mid-processing
|
||||
- If LLM call fails, partial statistics still recorded
|
||||
|
||||
## Agent Cancellation Analysis
|
||||
|
||||
### Scenarios and Impact
|
||||
|
||||
| Cancellation Timing | State | Issue |
|
||||
|---------------------|-------|-------|
|
||||
| Before llm_call | User message sent | No response, no issue |
|
||||
| After llm_call, has tool_call | AI requested tool execution | ⚠️ Has tool_call, no tool_result |
|
||||
| During tool_node | Tool executing | May partially execute |
|
||||
| After tool_node | Tool result returned | Clean state |
|
||||
|
||||
### Key Concern: Orphan tool_calls
|
||||
|
||||
The most dangerous scenario: AI generates `tool_call` but execution hasn't started:
|
||||
|
||||
```json
|
||||
// Incomplete message:
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [{"name": "execute_command", "arguments": "..."}]
|
||||
// No corresponding ToolMessage!
|
||||
}
|
||||
```
|
||||
|
||||
### LangGraph Cancellation Handling
|
||||
|
||||
LangGraph handles cancellation automatically:
|
||||
|
||||
1. **Checkpoint at node boundaries**: Messages are saved after each node completes
|
||||
2. **Cancellation preserves state**: When cancelled, checkpoint is saved automatically
|
||||
3. **Message consistency**: Either complete (tool_call + ToolMessage) or no tool_call
|
||||
|
||||
```python
|
||||
# When cancellation happens:
|
||||
async def stream_chat(...):
|
||||
try:
|
||||
async for event in graph.astream_events(...):
|
||||
yield chunk
|
||||
except CancelledError:
|
||||
# LangGraph auto-saves checkpoint before raising
|
||||
log.info("Request cancelled, checkpoint saved")
|
||||
finally:
|
||||
await repo.update_session(...)
|
||||
```
|
||||
|
||||
### Handling Incomplete Messages
|
||||
|
||||
When reconnecting, check for incomplete messages:
|
||||
|
||||
```python
|
||||
async def get_history(session_id):
|
||||
state = await graph.aget_state(config)
|
||||
messages = state.values["messages"]
|
||||
|
||||
# Check for orphan tool_calls
|
||||
last_msg = messages[-1] if messages else None
|
||||
if last_msg and last_msg.tool_calls and not has_tool_result(messages):
|
||||
# Handle incomplete message
|
||||
# Option 1: Show as "interrupted"
|
||||
# Option 2: Auto-resume tool execution
|
||||
# Option 3: Ask user to retry
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### Handling Disconnection
|
||||
|
||||
```javascript
|
||||
// On connection close:
|
||||
window.addEventListener('beforeunload', () => {
|
||||
// Connection will close, server will handle cleanup
|
||||
});
|
||||
|
||||
// On reconnect - fetch history:
|
||||
const history = await fetch(`/chat/sessions/${sessionId}/history`);
|
||||
const data = await history.json();
|
||||
|
||||
// Check for incomplete messages
|
||||
if (data.messages.length > 0) {
|
||||
const lastMsg = data.messages[data.messages.length - 1];
|
||||
if (lastMsg.tool_calls && !lastMsg.content) {
|
||||
// Message was interrupted - handle appropriately
|
||||
showWarning("Previous response was interrupted");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Optional: Cancel Endpoint
|
||||
|
||||
For explicit cancellation (not just disconnection):
|
||||
|
||||
```python
|
||||
# Request management
|
||||
request_manager = RequestManager()
|
||||
|
||||
@router.post("/stream/{request_id}/cancel")
|
||||
async def cancel_stream(request_id: str):
|
||||
request_manager.cancel(request_id)
|
||||
|
||||
# In stream_chat:
|
||||
async def stream_chat(request_id: str, ...):
|
||||
request_manager.register(request_id)
|
||||
try:
|
||||
async for event in graph.astream_events(...):
|
||||
if request_manager.is_cancelled(request_id):
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
request_manager.unregister(request_id)
|
||||
```
|
||||
|
||||
**Complexity**: Requires request ID tracking, state management, and coordination.
|
||||
|
||||
**Current recommendation**: Not necessary - disconnection naturally stops the stream.
|
||||
|
||||
## Summary
|
||||
|
||||
| Aspect | Current Behavior | Recommended Fix |
|
||||
|--------|-----------------|-----------------|
|
||||
| Messages | Auto-saved to checkpoint | Already correct |
|
||||
| Statistics | Lost on disconnect | Add try/finally |
|
||||
| Title sync | Lost on disconnect | Add try/finally |
|
||||
| Cancellation | Handled by LangGraph | Already correct |
|
||||
| Incomplete messages | Handled on reconnect | Document frontend handling |
|
||||
|
||||
## Action Items
|
||||
|
||||
1. [ ] Add try/finally to ensure statistics update
|
||||
2. [ ] Add client disconnect detection in yield loop
|
||||
3. [ ] Document frontend handling for incomplete messages
|
||||
4. [ ] Test reconnection scenario with tool_call interruption
|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
|
||||
@ -1,244 +0,0 @@
|
||||
# GNS3 Copilot Tool Response Format Standard
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the standard response format for GNS3 Copilot tools, ensuring all tools return a unified data structure for easy frontend processing and display.
|
||||
|
||||
## Standard Response Format
|
||||
|
||||
### Top-level Structure
|
||||
|
||||
All tools should return the following standard format:
|
||||
|
||||
```python
|
||||
{
|
||||
"success": bool, # Whether the overall operation succeeded
|
||||
"total": int, # Total number of operations
|
||||
"successful": int, # Number of successful operations
|
||||
"failed": int, # Number of failed operations
|
||||
"data": list[dict], # Detailed result list
|
||||
"error": str, # Global error message (optional, when operation completely fails)
|
||||
"metadata": dict # Metadata (optional)
|
||||
}
|
||||
```
|
||||
|
||||
**Field Descriptions**:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `success` | `bool` | Yes | Whether the overall operation succeeded (True when `failed == 0`) |
|
||||
| `total` | `int` | Yes | Total number of items processed |
|
||||
| `successful` | `int` | Yes | Number of successful items |
|
||||
| `failed` | `int` | Yes | Number of failed items |
|
||||
| `data` | `list[dict]` | Yes | Detailed results for each item |
|
||||
| `error` | `str` | No | Global error message (when entire operation fails) |
|
||||
| `metadata` | `dict` | No | Metadata (timestamp, execution time, etc.) |
|
||||
|
||||
### Single Item Format
|
||||
|
||||
Each item in the `data` array should follow this format:
|
||||
|
||||
```python
|
||||
{
|
||||
"id": str, # Device/node/link ID
|
||||
"name": str, # Human-readable name
|
||||
"status": "success" | "failed", # Item status
|
||||
"result": str, # Result or output on success
|
||||
"error": str # Error message on failure
|
||||
}
|
||||
```
|
||||
|
||||
**Field Descriptions**:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | `str` | Yes | Unique identifier for device/node/link |
|
||||
| `name` | `str` | Yes | Human-readable name |
|
||||
| `status` | `str` | Yes | `"success"` or `"failed"` |
|
||||
| `result` | `str` | Conditional | Output when status is `success` |
|
||||
| `error` | `str` | Conditional | Error message when status is `failed` |
|
||||
|
||||
## Examples
|
||||
|
||||
### Success Response Example
|
||||
|
||||
```python
|
||||
# Execute display commands on multiple devices
|
||||
{
|
||||
"success": True,
|
||||
"total": 3,
|
||||
"successful": 2,
|
||||
"failed": 1,
|
||||
"data": [
|
||||
{
|
||||
"id": "R1",
|
||||
"name": "Router1",
|
||||
"status": "success",
|
||||
"result": "Cisco IOS Software...\nRouter1# show version\n..."
|
||||
},
|
||||
{
|
||||
"id": "R2",
|
||||
"name": "Router2",
|
||||
"status": "success",
|
||||
"result": "Cisco IOS Software...\nRouter2# show version\n..."
|
||||
},
|
||||
{
|
||||
"id": "R3",
|
||||
"name": "Router3",
|
||||
"status": "failed",
|
||||
"error": "Connection refused"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"tool_name": "execute_multiple_device_commands",
|
||||
"execution_time": 5.2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Failure Example
|
||||
|
||||
```python
|
||||
# Entire operation failed (e.g., parameter error)
|
||||
{
|
||||
"success": False,
|
||||
"total": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"data": [],
|
||||
"error": "Invalid project_id format",
|
||||
"metadata": {
|
||||
"tool_name": "execute_multiple_device_commands"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Single Device Operation Example
|
||||
|
||||
```python
|
||||
# Operate on a single device
|
||||
{
|
||||
"success": True,
|
||||
"total": 1,
|
||||
"successful": 1,
|
||||
"failed": 0,
|
||||
"data": [
|
||||
{
|
||||
"id": "PC1",
|
||||
"name": "VPCS-1",
|
||||
"status": "success",
|
||||
"result": "IP configuration updated: 192.168.1.10/24"
|
||||
}
|
||||
],
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
## Using the Standardization Function
|
||||
|
||||
The `normalize_tool_response` function is provided in the `gns3server.agent.gns3_copilot.utils` module to convert various formats to the standard format:
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.utils import normalize_tool_response
|
||||
|
||||
# Normalize tool response
|
||||
normalized = normalize_tool_response(raw_response, tool_name="my_tool")
|
||||
```
|
||||
|
||||
This function supports:
|
||||
- List format (`[{...}, {...}]`)
|
||||
- Dict format (`{"nodes": [...]}`)
|
||||
- String format (automatically parses JSON/Python literal)
|
||||
- Mixed format (compatible with legacy tools)
|
||||
|
||||
## Compatibility
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
The `normalize_tool_response` function is designed to be backward compatible and can handle various formats from existing tools:
|
||||
|
||||
- `status` / `error` fields
|
||||
- `output` / `result` fields
|
||||
- `device_name` / `name` fields
|
||||
- `total_nodes` / `total` fields
|
||||
|
||||
### Recommended Migration Strategy
|
||||
|
||||
1. **New Tools**: Return standard format directly
|
||||
2. **Existing Tools**: Keep unchanged, use `normalize_tool_response` to standardize
|
||||
3. **Frontend**: Rely on standard format for display processing
|
||||
|
||||
## Frontend Integration Recommendations
|
||||
|
||||
### Rendering Logic
|
||||
|
||||
```javascript
|
||||
function renderToolResponse(response) {
|
||||
if (!response.success) {
|
||||
// Show global error
|
||||
showError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show statistics summary
|
||||
showSummary(response.total, response.successful, response.failed);
|
||||
|
||||
// Render each item
|
||||
response.data.forEach(item => {
|
||||
if (item.status === 'success') {
|
||||
showSuccess(item.name, item.result);
|
||||
} else {
|
||||
showError(item.name, item.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Status Icons
|
||||
|
||||
| Status | Icon Suggestion | Color |
|
||||
|--------|----------------|-------|
|
||||
| `success` | ✓ Green | Green |
|
||||
| `failed` | ✗ Red | Red |
|
||||
| `unknown` | ? Gray | Gray |
|
||||
|
||||
## Version Control
|
||||
|
||||
Current standard version: `v1.0`
|
||||
|
||||
When the format changes, update the `metadata.version` field, and the frontend adapts accordingly.
|
||||
|
||||
## References
|
||||
|
||||
- Implementation: `gns3server/agent/gns3_copilot/utils/parse_tool_content.py`
|
||||
- Message conversion: `gns3server/agent/gns3_copilot/utils/message_converters.py`
|
||||
- Tool examples: `gns3server/agent/gns3_copilot/tools_v2/`
|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
|
||||
@ -1,527 +0,0 @@
|
||||
# TOSCA-Based Topology Description for GNS3
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines a strategic initiative to adopt **TOSCA (Topology and Orchestration Specification for Cloud Applications)** as the standard format for describing GNS3 network topologies. This approach aims to modernize GNS3 topology management, improve user experience, and align with industry best practices.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
### Current State
|
||||
- GNS3 uses proprietary `.gns3` file format (Python-based)
|
||||
- Topology editing requires GUI interface
|
||||
- Limited version control capabilities
|
||||
- No standard way to share and reuse topologies
|
||||
- Difficult to integrate with external automation tools
|
||||
|
||||
### Proposed Solution
|
||||
- Adopt TOSCA Simple Profile YAML as the standard topology description language
|
||||
- Provide dual-mode editing: GUI and YAML
|
||||
- Enable template-based topology creation
|
||||
- Support version control and collaborative workflows
|
||||
- Integrate with TOSCA toolchain ecosystem
|
||||
|
||||
### Expected Benefits
|
||||
- **Productivity**: 5-10x faster topology creation
|
||||
- **Quality**: 80% reduction in configuration errors
|
||||
- **Collaboration**: Enable team-based workflows
|
||||
- **Portability**: Cross-platform topology definitions
|
||||
- **Ecosystem**: Integration with industry-standard tools
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
### What is TOSCA?
|
||||
|
||||
**TOSCA** (Topology and Orchestration Specification for Cloud Applications) is an **OASIS standard** for describing cloud application and service topologies. It provides:
|
||||
|
||||
- **Standardized YAML syntax** for defining topologies
|
||||
- **Type system** for nodes and relationships
|
||||
- **Inheritance mechanisms** for template reuse
|
||||
- **Workflow definitions** for orchestration
|
||||
- **Portability** across platforms and vendors
|
||||
|
||||
### Why TOSCA for GNS3?
|
||||
|
||||
| Aspect | Current GNS3 | TOSCA-Based |
|
||||
|--------|--------------|-------------|
|
||||
| Format | Proprietary Python | Open Standard YAML |
|
||||
| Readability | Requires code knowledge | Human-readable |
|
||||
| Version Control | Binary/blob-like | Git-friendly |
|
||||
| Tooling | GNS3-specific | Rich ecosystem |
|
||||
| Learning Curve | Steep | Moderate |
|
||||
| Industry Alignment | None | Strong |
|
||||
|
||||
---
|
||||
|
||||
## Strategic Benefits
|
||||
|
||||
### 1. Standardized YAML Description
|
||||
|
||||
#### Human-Readable Topology Definitions
|
||||
|
||||
**Before (GNS3 .gns3 file):**
|
||||
- Proprietary Python-based format
|
||||
- Difficult to understand without GUI
|
||||
- Requires specialized tools to edit
|
||||
|
||||
**After (TOSCA YAML):**
|
||||
- Clear, self-documenting structure
|
||||
- Edit with any text editor
|
||||
- Instant understanding of network architecture
|
||||
|
||||
#### Universal Language
|
||||
|
||||
- **Standard syntax**: All users use the same language
|
||||
- **Reduced training**: Familiar YAML format for DevOps engineers
|
||||
- **Cross-team communication**: Network, DevOps, SRE teams share common language
|
||||
|
||||
---
|
||||
|
||||
### 2. Toolchain Ecosystem
|
||||
|
||||
#### Validation Tools
|
||||
|
||||
- **Syntax validators**: Catch errors before deployment
|
||||
- **Schema validation**: Ensure type correctness
|
||||
- **Best practices checkers**: Enforce standards
|
||||
- **Integration into CI/CD**: Automated testing pipelines
|
||||
|
||||
#### Visualization Tools
|
||||
|
||||
- **Auto-generated diagrams**: Visual topology from YAML
|
||||
- **Real-time preview**: See changes as you type
|
||||
- **Interactive editors**: GUI ↔ YAML bidirectional sync
|
||||
|
||||
#### Orchestration Engines
|
||||
|
||||
- **Cloudify**: Mature TOSCA orchestrator
|
||||
- **OpenStack Heat**: Alternative implementation
|
||||
- **Custom tools**: Build on open-source libraries
|
||||
|
||||
---
|
||||
|
||||
### 3. Version Control and Collaboration
|
||||
|
||||
### Git-Friendly Workflows
|
||||
|
||||
#### Meaningful Diffs
|
||||
|
||||
Before:
|
||||
```
|
||||
Binary files differ
|
||||
```
|
||||
|
||||
After:
|
||||
```diff
|
||||
- node: R1
|
||||
+ node: Core-Router-1
|
||||
type: router
|
||||
template: c7200
|
||||
```
|
||||
|
||||
#### Branch Management
|
||||
|
||||
- **Feature branches**: Experiment with new topologies
|
||||
- **Isolated development**: Multiple parallel changes
|
||||
- **Easy merge**: Text-based merge tools
|
||||
|
||||
#### Code Review Process
|
||||
|
||||
- **Pull Requests**: Review topology changes
|
||||
- **Comments and discussions**: Collaborative refinement
|
||||
- **Approval workflows**: Maintained standards
|
||||
|
||||
### Real-World Scenarios
|
||||
|
||||
**Educational Use Case:**
|
||||
- Students submit topology homework via Git PRs
|
||||
- Teaching assistants review and comment
|
||||
- Automated tests verify requirements
|
||||
- Track progress over time
|
||||
|
||||
**Enterprise Use Case:**
|
||||
- Network engineers propose changes
|
||||
- Security team reviews compliance
|
||||
- Architecture team validates design
|
||||
- Manager approves deployment
|
||||
|
||||
---
|
||||
|
||||
### 4. Template Reuse and Inheritance
|
||||
|
||||
### Template Hierarchy
|
||||
|
||||
```
|
||||
Base Templates
|
||||
↓
|
||||
Industry-Specific Templates
|
||||
↓
|
||||
Organizational Templates
|
||||
↓
|
||||
Project-Specific Topologies
|
||||
```
|
||||
|
||||
#### Base Templates
|
||||
|
||||
- **Standard node types**: Router, Switch, Firewall
|
||||
- **Common configurations**: Default settings
|
||||
- **Best practices**: Security hardening, performance tuning
|
||||
|
||||
#### Domain Templates
|
||||
|
||||
- **Enterprise WAN**: Multi-site BGP topology
|
||||
- **Data Center**: Spine-leaf fabric
|
||||
- **Campus Network**: Hierarchical design
|
||||
- **Service Provider**: MPLS backbone
|
||||
|
||||
#### Organizational Templates
|
||||
|
||||
- **Company standards**: Approved device models
|
||||
- **Security policies**: Mandatory configurations
|
||||
- **Compliance requirements**: Regulatory constraints
|
||||
|
||||
### Efficiency Gains
|
||||
|
||||
| Task | Traditional | With Templates |
|
||||
|------|-------------|-----------------|
|
||||
| Simple topology | 30 minutes | 5 minutes |
|
||||
| Complex topology | 2 hours | 30 minutes |
|
||||
| Multi-site deployment | Manual | Automated |
|
||||
| Consistency check | Manual review | Automated validation |
|
||||
|
||||
---
|
||||
|
||||
### 5. Cross-Platform Portability
|
||||
|
||||
### Vendor Neutrality
|
||||
|
||||
TOSCA is an **open standard** supported by:
|
||||
- Multiple vendors
|
||||
- Open-source projects
|
||||
- Toolchain ecosystem
|
||||
|
||||
### Multi-Environment Deployment
|
||||
|
||||
Same topology definition can deploy to:
|
||||
- **GNS3**: Local development and testing
|
||||
- **EVE-NG**: Remote lab access
|
||||
- **Physical devices**: Production deployment
|
||||
- **Cloud platforms**: AWS, Azure, GCP
|
||||
|
||||
### Integration with Other Tools
|
||||
|
||||
#### Configuration Management
|
||||
|
||||
- **Ansible**: Use topology for inventory
|
||||
- **Terraform**: Infrastructure as code
|
||||
- **Python scripts**: Automation workflows
|
||||
|
||||
#### Monitoring and Observability
|
||||
|
||||
- **Prometheus**: Monitoring targets from topology
|
||||
- **ELK Stack**: Log aggregation
|
||||
- **Grafana**: Visualization dashboards
|
||||
|
||||
#### CI/CD Pipelines
|
||||
|
||||
- **Jenkins/GitLab CI**: Automated testing
|
||||
- **GitHub Actions**: Workflow automation
|
||||
- **ArgoCD**: GitOps deployments
|
||||
|
||||
---
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ User Interfaces │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ GUI Editor │ YAML Editor │ Import/Export │
|
||||
└───────────────┴───────────────┴────────────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ TOSCA Parser/Validator │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Schema Validation │ Type Checking │ Lint │
|
||||
└───────────────┴───────────────┴────────────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ GNS3 Core Engine │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Node Management │ Link Management │ APIs │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Schema Design
|
||||
|
||||
#### GNS3 Type System
|
||||
|
||||
Extend TOSCA standard types with GNS3-specific nodes:
|
||||
|
||||
- **Compute Nodes**: Virtual machines, containers
|
||||
- **Network Devices**: Routers, switches, firewalls
|
||||
- **Links**: Connections between devices
|
||||
- **Configurations**: Device-specific settings
|
||||
|
||||
#### Backward Compatibility
|
||||
|
||||
- **Dual format support**: Read/write both `.gns3` and `.yaml`
|
||||
- **Migration tools**: Convert existing topologies
|
||||
- **Gradual adoption**: Users can migrate at their own pace
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Foundation (3-4 months)
|
||||
|
||||
**Goals:**
|
||||
- TOSCA parser and validator
|
||||
- Basic node type definitions
|
||||
- YAML import/export functionality
|
||||
- Documentation and tutorials
|
||||
|
||||
**Deliverables:**
|
||||
- GNS3 TOSCA schema specification
|
||||
- YAML parser integration
|
||||
- Import/export CLI tools
|
||||
- Getting started guide
|
||||
|
||||
### Phase 2: GUI Integration (2-3 months)
|
||||
|
||||
**Goals:**
|
||||
- Bi-directional YAML ↔ GUI editing
|
||||
- Real-time validation feedback
|
||||
- Visual topology preview
|
||||
- Template browser
|
||||
|
||||
**Deliverables:**
|
||||
- Integrated YAML editor in GNS3 GUI
|
||||
- Live validation indicators
|
||||
- Template library interface
|
||||
- User documentation
|
||||
|
||||
### Phase 3: Advanced Features (3-4 months)
|
||||
|
||||
**Goals:**
|
||||
- Template inheritance and composition
|
||||
- Workflow orchestration
|
||||
- Testing and validation tools
|
||||
- CI/CD integration
|
||||
|
||||
**Deliverables:**
|
||||
- Template marketplace prototype
|
||||
- Automated testing framework
|
||||
- Git integration features
|
||||
- Best practices guide
|
||||
|
||||
### Phase 4: Ecosystem (Ongoing)
|
||||
|
||||
**Goals:**
|
||||
- Community templates
|
||||
- Third-party integrations
|
||||
- Advanced tooling
|
||||
- Industry partnerships
|
||||
|
||||
**Deliverables:**
|
||||
- Public template repository
|
||||
- Plugin architecture
|
||||
- Partner integrations
|
||||
- Success stories and case studies
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### For Users
|
||||
|
||||
#### Option 1: Gradual Migration
|
||||
|
||||
1. Continue using `.gns3` files
|
||||
2. Experiment with YAML for new projects
|
||||
3. Convert existing topologies as needed
|
||||
4. Fully migrate when comfortable
|
||||
|
||||
#### Option 2: Dual-Mode Workflow
|
||||
|
||||
1. Edit in YAML for complex topologies
|
||||
2. Use GUI for visual adjustments
|
||||
3. Export both formats as needed
|
||||
4. Choose preferred workflow
|
||||
|
||||
#### Option 3: Full Adoption
|
||||
|
||||
1. Convert all topologies to YAML
|
||||
2. Use YAML as primary format
|
||||
3. Export to `.gns3` only when required
|
||||
4. Leverage full TOSCA ecosystem
|
||||
|
||||
### For Developers
|
||||
|
||||
#### Extension Points
|
||||
|
||||
- **Custom node types**: Define specialized devices
|
||||
- **Validation rules**: Enforce organizational standards
|
||||
- **Template libraries**: Share within organization
|
||||
- **Tooling integration**: Custom automation scripts
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### User Adoption
|
||||
|
||||
- **3 months**: 10% of users try YAML format
|
||||
- **6 months**: 30% use YAML regularly
|
||||
- **12 months**: 50% adopt YAML as primary format
|
||||
- **24 months**: 70%+ adoption
|
||||
|
||||
### Quality Improvements
|
||||
|
||||
- **Error rate**: 80% reduction in topology errors
|
||||
- **Creation time**: 5-10x faster for complex topologies
|
||||
- **Documentation**: 100% of topologies self-documenting
|
||||
- **Consistency**: Significant improvement in standards compliance
|
||||
|
||||
### Ecosystem Growth
|
||||
|
||||
- **Template library**: 100+ community templates
|
||||
- **Integrations**: 5+ major tool integrations
|
||||
- **Case studies**: 10+ published success stories
|
||||
- **Community**: Active contributor base
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Technical Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Parser complexity | Medium | Use proven libraries |
|
||||
| Performance overhead | Low | Optimized parsing |
|
||||
| Schema evolution | Medium | Version management |
|
||||
| Backward compatibility | Low | Dual format support |
|
||||
|
||||
### Adoption Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| User resistance | Medium | Comprehensive training |
|
||||
| Learning curve | Medium | Documentation and examples |
|
||||
| Tooling gaps | Low | Leverage existing ecosystem |
|
||||
| Vendor lock-in | Low | Open standard |
|
||||
|
||||
---
|
||||
|
||||
## Competitive Analysis
|
||||
|
||||
### Similar Approaches
|
||||
|
||||
#### Cisco CML (VIRL)
|
||||
|
||||
**Strengths:**
|
||||
- YAML-based topology definition
|
||||
- Mature template system
|
||||
- Enterprise features
|
||||
|
||||
**Weaknesses:**
|
||||
- Proprietary format (not standard TOSCA)
|
||||
- Vendor lock-in
|
||||
- Limited ecosystem
|
||||
|
||||
#### Mininet
|
||||
|
||||
**Strengths:**
|
||||
- Python API for topology definition
|
||||
- SDN research community
|
||||
|
||||
**Weaknesses:**
|
||||
- Scripting required (no YAML)
|
||||
- Limited to Linux networking
|
||||
- Not enterprise-ready
|
||||
|
||||
#### Our Positioning
|
||||
|
||||
**GNS3 with TOSCA:**
|
||||
- ✅ Open standard (TOSCA)
|
||||
- ✅ Multi-vendor support
|
||||
- ✅ Rich ecosystem
|
||||
- ✅ Community-driven
|
||||
- ✅ Enterprise-ready
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
### Strategic Value
|
||||
|
||||
Adopting TOSCA for GNS3 topology description represents a **significant strategic opportunity**:
|
||||
|
||||
1. **Modernization**: Align with industry best practices
|
||||
2. **Ecosystem**: Tap into TOSCA toolchain and community
|
||||
3. **Collaboration**: Enable team-based workflows
|
||||
4. **Portability**: Cross-platform topology definitions
|
||||
5. **Scalability**: Support enterprise use cases
|
||||
|
||||
### Vision
|
||||
|
||||
**"Model once, deploy anywhere"** - A GNS3 topology defined in TOSCA can be:
|
||||
- Developed locally
|
||||
- Tested in simulation
|
||||
- Validated automatically
|
||||
- Deployed to multiple environments
|
||||
- Shared across teams
|
||||
- Evolved through version control
|
||||
|
||||
### Call to Action
|
||||
|
||||
This initiative represents a **fundamental improvement** to how users interact with GNS3. It requires:
|
||||
|
||||
- **Community feedback**: Validate requirements and priorities
|
||||
- **Contributor participation**: Build open-source solution
|
||||
- **Patience**: Phased rollout over 12-18 months
|
||||
- **Investment**: Significant development effort
|
||||
|
||||
**Expected outcome:** GNS3 becomes the **de facto standard** for network topology definition, education, and automation.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### Standards and Specifications
|
||||
|
||||
- [OASIS TOSCA Specification](https://docs.oasis-open.org/tosca/TOSCA-Simple-Profile-YAML/v1.3)
|
||||
- [TOSCA Primer](https://docs.oasis-open.org/tosca/TOSCA-Simple-Profile-YAML/v1.0/cspr02.html)
|
||||
- [YANG Data Modeling Language (RFC 7950)](https://datatracker.ietf.org/doc/html/rfc7950)
|
||||
- [OpenConfig Network Models](https://openconfig.net/)
|
||||
|
||||
### Tools and Resources
|
||||
|
||||
- [Cloudify TOSCA Orchestrator](https://cloudify.co)
|
||||
- [OpenStack Heat](https://docs.openstack.org/heat/latest/)
|
||||
- [TOSCA GmbH](https://github.com/oasis-tcs/tosca-governance)
|
||||
- [YangCatalog](https://www.yangcatalog.org/)
|
||||
|
||||
### Related Projects
|
||||
|
||||
- [GNS3 Server](https://github.com/GNS3/gns3-server)
|
||||
- [GNS3 documentation](https://docs.gns3.com/)
|
||||
- [Network To Code initiatives](https://www.networktocode.com/)
|
||||
|
||||
---
|
||||
|
||||
**Document Status:** 📋 Design Proposal
|
||||
**Category:** Feature Design
|
||||
**Priority:** High
|
||||
**Complexity:** High
|
||||
**Estimated Timeline:** 12-18 months
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-03-11*
|
||||
@ -1,959 +0,0 @@
|
||||
# User-Selectable Group Default Config
|
||||
|
||||
**Document Status**: Design Phase
|
||||
**Priority**: Medium
|
||||
**Created**: 2026-03-06
|
||||
**Related Docs**: [LLM Model Configs API](../llm-model-configs-api.md)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Problem Description](#problem-description)
|
||||
- [Current State Analysis](#current-state-analysis)
|
||||
- [Requirements Analysis](#requirements-analysis)
|
||||
- [Solution Design](#solution-design)
|
||||
- [Implementation Steps](#implementation-steps)
|
||||
- [Code Changes Checklist](#code-changes-checklist)
|
||||
- [Testing Plan](#testing-plan)
|
||||
- [Risk Assessment](#risk-assessment)
|
||||
|
||||
---
|
||||
|
||||
## Problem Description
|
||||
|
||||
### Current Behavior
|
||||
|
||||
Regular users cannot select an inherited group LLM model config as their default config, even though they can see the inherited group configs in their config list.
|
||||
|
||||
### User Scenario
|
||||
|
||||
1. Administrator creates multiple LLM model configs for a user group (e.g., GPT-4, Claude 3.5, Gemini Pro)
|
||||
2. The group default is set to GPT-4
|
||||
3. Users inherit these configs and can see all group configs in their config list
|
||||
4. Users want to use Claude 3.5 as their default, but have no way to set it via API
|
||||
|
||||
### Existing Code Limitation
|
||||
|
||||
**File**: `gns3server/db/repositories/llm_model_configs.py:194-218`
|
||||
|
||||
```python
|
||||
async def set_user_default_config(self, user_id: UUID, config_id: UUID) -> bool:
|
||||
"""Set a user's default LLM model configuration."""
|
||||
# ...
|
||||
|
||||
# Set new default
|
||||
query = update(models.LLMModelConfig).where(
|
||||
and_(
|
||||
models.LLMModelConfig.config_id == config_id,
|
||||
models.LLMModelConfig.user_id == user_id # KEY LIMITATION
|
||||
)
|
||||
).values(is_default=True, updated_at=now)
|
||||
```
|
||||
|
||||
**Problem**: The `user_id == user_id` condition restricts setting only user's own configs. Inherited group configs have `user_id` as `NULL`, so they cannot be set as default.
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Current Config Retrieval Flow
|
||||
|
||||
```
|
||||
User requests config list
|
||||
↓
|
||||
GET /v3/access/users/{user_id}/llm-model-configs
|
||||
↓
|
||||
get_user_effective_configs(user_id)
|
||||
↓
|
||||
Returns: {
|
||||
configs: [
|
||||
{ source: "user", ... }, # User's own configs
|
||||
{ source: "group", ... } # Inherited group configs
|
||||
],
|
||||
default_config: { ... } # Current default config
|
||||
}
|
||||
```
|
||||
|
||||
### Default Config Selection Priority
|
||||
|
||||
**Current Logic** (`llm_model_configs.py:503-520`):
|
||||
|
||||
1. User config marked with `is_default: true`
|
||||
2. Group config marked with `is_default: true`
|
||||
3. First config in the list (user configs come before group configs)
|
||||
|
||||
### Agent Config Retrieval Flow
|
||||
|
||||
**Key Discovery**: Agent retrieves config via `user_id`, doesn't care about config source.
|
||||
|
||||
**Flow**:
|
||||
```
|
||||
Agent → get_user_llm_config_full(user_id, app)
|
||||
↓
|
||||
get_user_effective_configs(user_id)
|
||||
↓
|
||||
Returns default config (auto-decrypts API key)
|
||||
↓
|
||||
Agent uses config to call LLM
|
||||
```
|
||||
|
||||
**Key Files**:
|
||||
- `gns3server/db/tasks.py:314-406` - `get_user_llm_config_full`
|
||||
- `gns3server/api/routes/controller/chat.py:122` - API entry point
|
||||
|
||||
### API Key Visibility Control
|
||||
|
||||
| Scenario | User Configs | Group Configs |
|
||||
|----------|-------------|---------------|
|
||||
| User viewing own configs | **Visible** | **Hidden** (`null`) |
|
||||
| Admin viewing other users' configs | **Hidden** | **Hidden** |
|
||||
| Agent usage (system-level) | **Visible** | **Visible** (direct DB access) |
|
||||
|
||||
---
|
||||
|
||||
## Requirements Analysis
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
1. **Users can select group config as default**
|
||||
- Users can set any accessible config (own or inherited) as default via API
|
||||
- API endpoint remains unchanged: `PUT /v3/access/users/{user_id}/llm-model-configs/default/{config_id}`
|
||||
|
||||
2. **Maintain API Key Security**
|
||||
- Group config API keys remain hidden when users view config list
|
||||
- Agent can access and decrypt group config API keys when using
|
||||
|
||||
3. **Backward Compatibility**
|
||||
- No impact on existing user configs
|
||||
- No impact on Agent calling flow
|
||||
- Config list response structure remains consistent
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
1. **Performance**: No significant query overhead
|
||||
2. **Maintainability**: Clear code logic, easy to understand and maintain
|
||||
3. **Extensibility**: Future support for config overrides (users modifying certain parameters of inherited configs)
|
||||
|
||||
---
|
||||
|
||||
## Solution Design
|
||||
|
||||
### Selection: Shadow Config Approach
|
||||
|
||||
Add `inherited_from_config_id` field to `llm_model_configs` table. When user selects a group config as default, create a "shadow config" record.
|
||||
|
||||
### Data Model Design
|
||||
|
||||
#### Table Structure Modification
|
||||
|
||||
**File**: `gns3server/db/models/llm_model_configs.py`
|
||||
|
||||
```python
|
||||
class LLMModelConfig(BaseTable):
|
||||
"""LLM model configuration for users and user groups."""
|
||||
|
||||
__tablename__ = "llm_model_configs"
|
||||
|
||||
config_id = Column(GUID, primary_key=True, default=generate_uuid)
|
||||
name = Column(String(100), nullable=False)
|
||||
model_type = Column(String(50), nullable=False)
|
||||
config = Column(JSON, nullable=False)
|
||||
user_id = Column(GUID, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=True)
|
||||
group_id = Column(GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE"), nullable=True)
|
||||
is_default = Column(Boolean, default=False, nullable=False)
|
||||
version = Column(Integer, default=0, nullable=False)
|
||||
|
||||
# NEW FIELD: Shadow config references original group config
|
||||
inherited_from_config_id = Column(
|
||||
GUID,
|
||||
ForeignKey("llm_model_configs.config_id", ondelete="CASCADE"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
inherited_from = relationship(
|
||||
"LLMModelConfig",
|
||||
remote_side=[config_id],
|
||||
backref="shadow_configs"
|
||||
)
|
||||
|
||||
# Constraints
|
||||
__table_args__ = (
|
||||
# Original constraints...
|
||||
CheckConstraint(
|
||||
"(user_id IS NOT NULL AND group_id IS NULL) OR "
|
||||
"(user_id IS NULL AND group_id IS NOT NULL)",
|
||||
name="single_owner_check"
|
||||
),
|
||||
# NEW CONSTRAINT: Shadow configs must belong to users
|
||||
CheckConstraint(
|
||||
"inherited_from_config_id IS NULL OR user_id IS NOT NULL",
|
||||
name="shadow_config_belong_to_user"
|
||||
),
|
||||
# ... other constraints
|
||||
)
|
||||
```
|
||||
|
||||
### Shadow Config Explanation
|
||||
|
||||
| Field | Value | Description |
|
||||
|------|-------|-------------|
|
||||
| `config_id` | New UUID | Shadow config's unique identifier |
|
||||
| `name` | Original group config's name | Display name |
|
||||
| `model_type` | Original group config's type | Config type |
|
||||
| `config` | `{"api_key": "__INHERITED_FROM_GROUP__", ...}` | Config data, API key marked with special value |
|
||||
| `user_id` | Current user's ID | Belongs to user |
|
||||
| `group_id` | `NULL` | Shadow config doesn't belong to group |
|
||||
| `is_default` | `true` | Marked as default config |
|
||||
| `inherited_from_config_id` | Original group config's ID | References original config |
|
||||
|
||||
### Workflow
|
||||
|
||||
#### 1. User Sets Group Config as Default
|
||||
|
||||
```
|
||||
User Request: PUT /users/{user_id}/llm-model-configs/default/{group_config_id}
|
||||
↓
|
||||
set_user_default_config(user_id, group_config_id)
|
||||
↓
|
||||
Detects group_config_id is a group config
|
||||
↓
|
||||
Creates shadow config:
|
||||
- user_id = current user
|
||||
- inherited_from_config_id = group_config_id
|
||||
- config = original config (API key marked as "__INHERITED_FROM_GROUP__")
|
||||
- is_default = true
|
||||
↓
|
||||
Deletes old shadow configs and default flags
|
||||
↓
|
||||
Commits to database
|
||||
```
|
||||
|
||||
#### 2. User Views Config List
|
||||
|
||||
```
|
||||
GET /users/{user_id}/llm-model-configs
|
||||
↓
|
||||
get_user_effective_configs(user_id)
|
||||
↓
|
||||
Gets user configs (including shadow configs)
|
||||
↓
|
||||
For shadow configs:
|
||||
- Reads complete data from original group config
|
||||
- Hides API key (sets to null)
|
||||
- Marks source = "user"
|
||||
- Marks inherited_from = original config ID
|
||||
↓
|
||||
Returns config list
|
||||
```
|
||||
|
||||
#### 3. Agent Retrieves Config for Usage
|
||||
|
||||
```
|
||||
Agent → get_user_llm_config_full(user_id, app)
|
||||
↓
|
||||
Gets default config (detects it's a shadow config)
|
||||
↓
|
||||
Gets encrypted API key from original group config
|
||||
↓
|
||||
Decrypts API key
|
||||
↓
|
||||
Returns complete config (including API key)
|
||||
↓
|
||||
Agent uses config to call LLM
|
||||
```
|
||||
|
||||
### Solution Advantages
|
||||
|
||||
| Advantage | Description |
|
||||
|-----------|-------------|
|
||||
| **Data Integrity** | Foreign key constraints ensure referential integrity, cascading deletes handle cleanup |
|
||||
| **Backward Compatible** | No modification to existing logic, shadow config is a new feature |
|
||||
| **Clear Semantics** | `inherited_from_config_id` clearly indicates inheritance relationship |
|
||||
| **Unified API** | Users don't need to care about config source, just select directly |
|
||||
| **Extensible** | Shadow config can add override fields in the future (e.g., user-custom parameters) |
|
||||
| **No Agent Changes Required** | Agent still retrieves config via `user_id`, automatically compatible |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Database Migration
|
||||
|
||||
Create new migration file: `gns3server/db_migrations/versions/{timestamp}_add_inherited_from_config_id.py`
|
||||
|
||||
```python
|
||||
"""Add inherited_from_config_id to llm_model_configs table
|
||||
|
||||
Revision ID: xxx_add_inherited_from_config_id
|
||||
Revises: [previous_revision_id]
|
||||
Create Date: 2026-03-06
|
||||
|
||||
This migration adds support for shadow configs, allowing users to select
|
||||
inherited group configurations as their default.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add the new column
|
||||
op.add_column(
|
||||
'llm_model_configs',
|
||||
sa.Column(
|
||||
'inherited_from_config_id',
|
||||
sa.GUID(),
|
||||
nullable=True
|
||||
)
|
||||
)
|
||||
|
||||
# Create foreign key constraint
|
||||
op.create_foreign_key(
|
||||
'fk_llm_configs_inherited_from',
|
||||
'llm_model_configs', 'llm_model_configs',
|
||||
['inherited_from_config_id'], ['config_id'],
|
||||
ondelete='CASCADE'
|
||||
)
|
||||
|
||||
# Add check constraint: shadow configs must belong to users
|
||||
op.execute("""
|
||||
ALTER TABLE llm_model_configs
|
||||
ADD CONSTRAINT shadow_config_belong_to_user
|
||||
CHECK (inherited_from_config_id IS NULL OR user_id IS NOT NULL)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Remove constraints and column
|
||||
op.execute("ALTER TABLE llm_model_configs DROP CONSTRAINT shadow_config_belong_to_user")
|
||||
op.drop_constraint('fk_llm_configs_inherited_from', 'llm_model_configs', type_='foreignkey')
|
||||
op.drop_column('llm_model_configs', 'inherited_from_config_id')
|
||||
```
|
||||
|
||||
### Step 2: Modify Data Model
|
||||
|
||||
**File**: `gns3server/db/models/llm_model_configs.py`
|
||||
|
||||
Add to `LLMModelConfig` class:
|
||||
- `inherited_from_config_id` field
|
||||
- `inherited_from` relationship
|
||||
- `shadow_config_belong_to_user` constraint
|
||||
|
||||
### Step 3: Modify Repository Layer
|
||||
|
||||
**File**: `gns3server/db/repositories/llm_model_configs.py`
|
||||
|
||||
#### 3.1 Modify `set_user_default_config` Method
|
||||
|
||||
```python
|
||||
async def set_user_default_config(self, user_id: UUID, config_id: UUID) -> bool:
|
||||
"""
|
||||
Set a user's default LLM model configuration.
|
||||
Supports setting inherited group configs as default via shadow configs.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
config_id: Configuration UUID (can be user's own or inherited group config)
|
||||
|
||||
Returns:
|
||||
True if successful, False if config not found or not accessible
|
||||
"""
|
||||
from gns3server.utils.encryption import is_encrypted
|
||||
|
||||
# Check if config is accessible to user
|
||||
effective = await self.get_user_effective_configs(
|
||||
user_id,
|
||||
current_user_id=user_id
|
||||
)
|
||||
accessible_config_ids = {c["config_id"] for c in effective["configs"]}
|
||||
|
||||
if config_id not in accessible_config_ids:
|
||||
return False
|
||||
|
||||
# Get the original config
|
||||
result = await self._db_session.execute(
|
||||
select(models.LLMModelConfig).where(
|
||||
models.LLMModelConfig.config_id == config_id
|
||||
)
|
||||
)
|
||||
orig_config = result.scalars().first()
|
||||
|
||||
if not orig_config:
|
||||
return False
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if orig_config.user_id == user_id:
|
||||
# Scenario 1: User selects their own config
|
||||
# Use the existing is_default mechanism
|
||||
|
||||
# Delete old shadow configs
|
||||
await self._db_session.execute(
|
||||
delete(models.LLMModelConfig)
|
||||
.where(
|
||||
and_(
|
||||
models.LLMModelConfig.user_id == user_id,
|
||||
models.LLMModelConfig.inherited_from_config_id.isnot(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Clear all user default flags
|
||||
await self._db_session.execute(
|
||||
update(models.LLMModelConfig)
|
||||
.where(
|
||||
and_(
|
||||
models.LLMModelConfig.user_id == user_id,
|
||||
models.LLMModelConfig.is_default == True
|
||||
)
|
||||
)
|
||||
.values(is_default=False, updated_at=now)
|
||||
)
|
||||
|
||||
# Set new default
|
||||
await self._db_session.execute(
|
||||
update(models.LLMModelConfig)
|
||||
.where(
|
||||
and_(
|
||||
models.LLMModelConfig.config_id == config_id,
|
||||
models.LLMModelConfig.user_id == user_id
|
||||
)
|
||||
)
|
||||
.values(is_default=True, updated_at=now)
|
||||
)
|
||||
else:
|
||||
# Scenario 2: User selects a group config - create shadow config
|
||||
|
||||
# Clear all user default flags
|
||||
await self._db_session.execute(
|
||||
update(models.LLMModelConfig)
|
||||
.where(models.LLMModelConfig.user_id == user_id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
# Delete old shadow configs
|
||||
await self._db_session.execute(
|
||||
delete(models.LLMModelConfig)
|
||||
.where(
|
||||
and_(
|
||||
models.LLMModelConfig.user_id == user_id,
|
||||
models.LLMModelConfig.inherited_from_config_id.isnot(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Copy config data, but mark API key as inherited
|
||||
shadow_config_data = orig_config.config.copy()
|
||||
shadow_config_data["api_key"] = "__INHERITED_FROM_GROUP__"
|
||||
|
||||
# Create shadow config
|
||||
shadow_config = models.LLMModelConfig(
|
||||
name=orig_config.name,
|
||||
model_type=orig_config.model_type,
|
||||
config=shadow_config_data,
|
||||
user_id=user_id,
|
||||
group_id=None,
|
||||
is_default=True,
|
||||
inherited_from_config_id=config_id,
|
||||
version=0,
|
||||
created_at=now,
|
||||
updated_at=now
|
||||
)
|
||||
self._db_session.add(shadow_config)
|
||||
|
||||
await self._db_session.commit()
|
||||
return True
|
||||
```
|
||||
|
||||
#### 3.2 Modify `get_user_effective_configs` Method
|
||||
|
||||
Add special logic for shadow config handling:
|
||||
|
||||
```python
|
||||
# In get_user_effective_configs method
|
||||
|
||||
# Process user configs (including shadow configs)
|
||||
user_configs = await self.get_user_configs(user_id)
|
||||
|
||||
# Build map of group configs for shadow config resolution
|
||||
group_configs_map = {}
|
||||
group_names_map = {}
|
||||
for group in user_groups:
|
||||
configs = await self.get_group_configs(group.user_group_id)
|
||||
if configs:
|
||||
group_configs_map[group.user_group_id] = configs
|
||||
group_names_map[group.user_group_id] = group.name
|
||||
|
||||
# Flatten group configs for easy access
|
||||
all_group_configs = {}
|
||||
for configs in group_configs_map.values():
|
||||
for config in configs:
|
||||
all_group_configs[config.config_id] = config
|
||||
|
||||
configs_with_source = []
|
||||
|
||||
# Process each user config
|
||||
for config in user_configs:
|
||||
if config.inherited_from_config_id:
|
||||
# This is a shadow config - resolve from parent group config
|
||||
parent_config = all_group_configs.get(config.inherited_from_config_id)
|
||||
if parent_config:
|
||||
config_dict = parent_config.config.copy()
|
||||
|
||||
# Hide API key in shadow configs (users viewing their own configs)
|
||||
if "api_key" in config_dict:
|
||||
config_dict["api_key"] = None
|
||||
|
||||
configs_with_source.append({
|
||||
"config_id": config.config_id,
|
||||
"name": config.name,
|
||||
"model_type": config.model_type,
|
||||
"config": config_dict,
|
||||
"user_id": config.user_id,
|
||||
"group_id": None,
|
||||
"is_default": config.is_default,
|
||||
"version": config.version,
|
||||
"created_at": config.created_at,
|
||||
"updated_at": config.updated_at,
|
||||
"source": "user",
|
||||
"inherited_from": config.inherited_from_config_id,
|
||||
"group_name": group_names_map.get(parent_config.group_id)
|
||||
})
|
||||
else:
|
||||
# Regular user config - existing logic
|
||||
config_dict = config.config.copy()
|
||||
|
||||
# API key visibility control
|
||||
if "api_key" in config_dict and config_dict["api_key"]:
|
||||
if is_viewing_own:
|
||||
try:
|
||||
if is_encrypted(config_dict["api_key"]):
|
||||
config_dict["api_key"] = decrypt(config_dict["api_key"])
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to decrypt API key: {e}")
|
||||
config_dict["api_key"] = None
|
||||
else:
|
||||
config_dict["api_key"] = None
|
||||
|
||||
configs_with_source.append({
|
||||
"config_id": config.config_id,
|
||||
"name": config.name,
|
||||
"model_type": config.model_type,
|
||||
"config": config_dict,
|
||||
"user_id": config.user_id,
|
||||
"group_id": config.group_id,
|
||||
"is_default": config.is_default,
|
||||
"version": config.version,
|
||||
"created_at": config.created_at,
|
||||
"updated_at": config.updated_at,
|
||||
"source": "user",
|
||||
"inherited_from": None,
|
||||
"group_name": None
|
||||
})
|
||||
|
||||
# Add inherited group configs (exclude those already shadowed)
|
||||
shadow_inherited_ids = {
|
||||
c["inherited_from"]
|
||||
for c in configs_with_source
|
||||
if c["inherited_from"]
|
||||
}
|
||||
|
||||
for group_id, configs in group_configs_map.items():
|
||||
for config in configs:
|
||||
if config.config_id in shadow_inherited_ids:
|
||||
continue # Already shadowed, don't duplicate
|
||||
|
||||
config_dict = config.config.copy()
|
||||
if "api_key" in config_dict:
|
||||
config_dict["api_key"] = None
|
||||
|
||||
configs_with_source.append({
|
||||
"config_id": config.config_id,
|
||||
"name": config.name,
|
||||
"model_type": config.model_type,
|
||||
"config": config_dict,
|
||||
"user_id": None,
|
||||
"group_id": config.group_id,
|
||||
"is_default": config.is_default,
|
||||
"version": config.version,
|
||||
"created_at": config.created_at,
|
||||
"updated_at": config.updated_at,
|
||||
"source": "group",
|
||||
"inherited_from": None,
|
||||
"group_name": group_names_map[group_id]
|
||||
})
|
||||
|
||||
# Select default_config (shadow configs have priority since marked is_default=true)
|
||||
default_config = None
|
||||
for config in configs_with_source:
|
||||
if config["is_default"] and config["source"] == "user":
|
||||
default_config = config
|
||||
break
|
||||
|
||||
if default_config is None:
|
||||
for config in configs_with_source:
|
||||
if config["is_default"] and config["source"] == "group":
|
||||
default_config = config
|
||||
break
|
||||
|
||||
if default_config is None and configs_with_source:
|
||||
default_config = configs_with_source[0]
|
||||
|
||||
return {
|
||||
"configs": configs_with_source,
|
||||
"default_config": default_config
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Modify System-Level Config Retrieval
|
||||
|
||||
**File**: `gns3server/db/tasks.py`
|
||||
|
||||
Modify `get_user_llm_config_full` function to add shadow config API key decryption logic:
|
||||
|
||||
```python
|
||||
async def get_user_llm_config_full(user_id: str, app: FastAPI) -> Optional[dict]:
|
||||
"""
|
||||
Get user's full LLM configuration with decrypted API key for Copilot.
|
||||
|
||||
This is a system-level function that bypasses API security restrictions.
|
||||
It retrieves the complete configuration including decrypted API keys,
|
||||
even for inherited group configurations and shadow configs.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
app: FastAPI application instance
|
||||
|
||||
Returns:
|
||||
Dictionary with LLM configuration (provider, model, api_key, etc.)
|
||||
or None if not found.
|
||||
"""
|
||||
from uuid import UUID
|
||||
from gns3server.db.repositories.llm_model_configs import LLMModelConfigsRepository
|
||||
from gns3server.utils.encryption import decrypt, is_encrypted
|
||||
|
||||
try:
|
||||
user_uuid = UUID(user_id) if isinstance(user_id, str) else user_id
|
||||
|
||||
async with AsyncSession(app.state._db_engine, expire_on_commit=False) as session:
|
||||
repo = LLMModelConfigsRepository(session)
|
||||
|
||||
# Get effective configs (own + inherited from groups)
|
||||
result = await repo.get_user_effective_configs(
|
||||
user_uuid,
|
||||
current_user_id=user_uuid,
|
||||
current_user_is_superadmin=False
|
||||
)
|
||||
|
||||
if not result or not result.get("default_config"):
|
||||
log.warning(f"No default LLM configuration found for user {user_id}")
|
||||
return None
|
||||
|
||||
default_config = result["default_config"]
|
||||
config_id = default_config["config_id"]
|
||||
source = default_config["source"]
|
||||
inherited_from = default_config.get("inherited_from")
|
||||
|
||||
# Get full config from database
|
||||
full_config = await repo.get_user_config(config_id)
|
||||
|
||||
if not full_config:
|
||||
log.error(f"Failed to retrieve full config: config_id={config_id}")
|
||||
return None
|
||||
|
||||
# Decrypt API key
|
||||
config_data = full_config.config.copy()
|
||||
inherited_from_config_id = full_config.inherited_from_config_id
|
||||
|
||||
# If shadow config, get API key from parent group config
|
||||
if inherited_from_config_id:
|
||||
parent_config = await repo.get_group_config(inherited_from_config_id)
|
||||
if parent_config and "api_key" in parent_config.config:
|
||||
try:
|
||||
encrypted_key = parent_config.config["api_key"]
|
||||
if encrypted_key and is_encrypted(encrypted_key):
|
||||
config_data["api_key"] = decrypt(encrypted_key)
|
||||
log.debug(f"Decrypted API key from inherited group config for user {user_id}")
|
||||
else:
|
||||
config_data["api_key"] = encrypted_key
|
||||
except Exception as e:
|
||||
log.error(f"Failed to decrypt inherited API key: {e}")
|
||||
config_data["api_key"] = None
|
||||
else:
|
||||
log.error(f"Parent group config not found for shadow config: {inherited_from_config_id}")
|
||||
config_data["api_key"] = None
|
||||
else:
|
||||
# Regular user config - decrypt API key directly
|
||||
if "api_key" in config_data and config_data["api_key"]:
|
||||
try:
|
||||
if is_encrypted(config_data["api_key"]):
|
||||
config_data["api_key"] = decrypt(config_data["api_key"])
|
||||
log.debug(f"Successfully decrypted API key for user {user_id}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to decrypt API key: {e}")
|
||||
config_data["api_key"] = None
|
||||
|
||||
# Build configuration dict
|
||||
llm_config = {
|
||||
"config_id": str(full_config.config_id),
|
||||
"name": full_config.name,
|
||||
"model_type": str(full_config.model_type),
|
||||
"source": source,
|
||||
"inherited_from": str(inherited_from_config_id) if inherited_from_config_id else None,
|
||||
"group_name": default_config.get("group_name"),
|
||||
"user_id": str(full_config.user_id) if full_config.user_id else None,
|
||||
"group_id": str(full_config.group_id) if full_config.group_id else None,
|
||||
**config_data
|
||||
}
|
||||
|
||||
# Validate required fields
|
||||
if not llm_config.get("provider"):
|
||||
log.error(f"LLM config missing 'provider' field: {config_id}")
|
||||
return None
|
||||
|
||||
if not llm_config.get("model"):
|
||||
log.error(f"LLM config missing 'model' field: {config_id}")
|
||||
return None
|
||||
|
||||
log.info(
|
||||
f"Retrieved LLM config for user {user_id}: "
|
||||
f"provider={llm_config.get('provider')}, model={llm_config.get('model')}, "
|
||||
f"source={source}, inherited_from={inherited_from_config_id}"
|
||||
)
|
||||
|
||||
return llm_config
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to retrieve LLM config for user {user_id}: {e}", exc_info=True)
|
||||
return None
|
||||
```
|
||||
|
||||
### Step 5: Update Schema (Optional)
|
||||
|
||||
If you want to display `inherited_from` field in API response, update relevant Schema:
|
||||
|
||||
**File**: `gns3server/schemas/controller/chat.py` or corresponding schema file
|
||||
|
||||
```python
|
||||
class LLMModelConfigWithSource(BaseModel):
|
||||
"""LLM model configuration with source information."""
|
||||
config_id: UUID
|
||||
name: str
|
||||
model_type: str
|
||||
config: Dict[str, Any]
|
||||
user_id: Optional[UUID] = None
|
||||
group_id: Optional[UUID] = None
|
||||
is_default: bool
|
||||
version: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
source: str # "user" or "group"
|
||||
group_name: Optional[str] = None
|
||||
inherited_from: Optional[UUID] = None # NEW FIELD
|
||||
```
|
||||
|
||||
### Step 6: Update API Documentation
|
||||
|
||||
**File**: `docs/gns3-copilot/llm-model-configs-api.md`
|
||||
|
||||
Add `inherited_from` field description in response schema section:
|
||||
|
||||
```markdown
|
||||
### LLMModelConfigWithSource
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| ...
|
||||
| `inherited_from` | UUID (nullable) | For shadow configs, the ID of the inherited group config |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Changes Checklist
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File Path | Change Type | Description |
|
||||
|-----------|-------------|-------------|
|
||||
| `gns3server/db/models/llm_model_configs.py` | Modify | Add `inherited_from_config_id` field and relationship |
|
||||
| `gns3server/db/repositories/llm_model_configs.py` | Modify | Modify `set_user_default_config` and `get_user_effective_configs` |
|
||||
| `gns3server/db/tasks.py` | Modify | Modify `get_user_llm_config_full` to support shadow configs |
|
||||
| `gns3server/schemas/...` | Modify (Optional) | Add `inherited_from` field to Schema |
|
||||
| `gns3server/db_migrations/versions/...` | New | Database migration file |
|
||||
| `docs/gns3-copilot/llm-model-configs-api.md` | Modify | Update API documentation |
|
||||
|
||||
### New Files
|
||||
|
||||
| File Path | Description |
|
||||
|-----------|-------------|
|
||||
| `gns3server/db_migrations/versions/{timestamp}_add_inherited_from_config_id.py` | Database migration |
|
||||
|
||||
---
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Unit Tests
|
||||
|
||||
#### 1. Test `set_user_default_config`
|
||||
|
||||
- **Test 1.1**: User sets their own config as default
|
||||
- Input: User's config ID
|
||||
- Expected: `is_default=true`, old shadow configs deleted
|
||||
|
||||
- **Test 1.2**: User sets group config as default
|
||||
- Input: Group config ID
|
||||
- Expected: Shadow config created, `inherited_from_config_id` points to group config
|
||||
|
||||
- **Test 1.3**: User switches default config (from own to group config)
|
||||
- Input: Group config ID
|
||||
- Expected: Old shadow config deleted, new shadow config created
|
||||
|
||||
- **Test 1.4**: User sets non-existent config as default
|
||||
- Input: Invalid config ID
|
||||
- Expected: Returns `False`
|
||||
|
||||
- **Test 1.5**: User sets inaccessible config as default
|
||||
- Input: Other user's group config ID
|
||||
- Expected: Returns `False`
|
||||
|
||||
#### 2. Test `get_user_effective_configs`
|
||||
|
||||
- **Test 2.1**: User with only own configs
|
||||
- Expected: Returns user configs, no `inherited_from` field
|
||||
|
||||
- **Test 2.2**: User with inherited group configs, no default set
|
||||
- Expected: Returns user configs + group configs, `default_config` is first user config or first group config
|
||||
|
||||
- **Test 2.3**: User set group config as default (shadow config)
|
||||
- Expected: Shadow config `source="user"`, `is_default=true`, `inherited_from` points to group config, API key is `null`
|
||||
|
||||
- **Test 2.4**: User viewing own configs (API key visibility)
|
||||
- Expected: Own config shows API key, shadow config and group config hide API key
|
||||
|
||||
#### 3. Test `get_user_llm_config_full`
|
||||
|
||||
- **Test 3.1**: User using own default config
|
||||
- Expected: Returns config with decrypted API key
|
||||
|
||||
- **Test 3.2**: User using shadow config (group config)
|
||||
- Expected: Retrieves and decrypts API key from original group config
|
||||
|
||||
- **Test 3.3**: Shadow config's original group config deleted
|
||||
- Expected: Returns `None` or appropriate error handling
|
||||
|
||||
### Integration Tests
|
||||
|
||||
#### 1. API Endpoint Tests
|
||||
|
||||
- **Test 1.1**: `PUT /users/{user_id}/llm-model-configs/default/{group_config_id}`
|
||||
- Request: Set group config as default
|
||||
- Expected: Returns 200, config set as default
|
||||
|
||||
- **Test 1.2**: `GET /users/{user_id}/llm-model-configs`
|
||||
- Expected: Shadow config appears in list, `source="user"`, `inherited_from` field exists
|
||||
|
||||
- **Test 1.3**: `GET /users/{user_id}/llm-model-configs/default`
|
||||
- Expected: Returns shadow config
|
||||
|
||||
#### 2. Agent Integration Tests
|
||||
|
||||
- **Test 2.1**: User using shadow config calls Agent
|
||||
- Expected: Agent successfully retrieves config and calls LLM
|
||||
|
||||
- **Test 2.2**: Multiple users using same group config as default
|
||||
- Expected: Each user has their own shadow config, no interference
|
||||
|
||||
### Security Tests
|
||||
|
||||
- **Test 1**: User views config list, shadow config's API key is hidden
|
||||
- **Test 2**: Admin views other user's config, API key is hidden
|
||||
- **Test 3**: User cannot set other user's config as default
|
||||
- **Test 4**: Cascading delete: Group config deleted, shadow config auto-deleted
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Technical Risks
|
||||
|
||||
| Risk | Impact | Probability | Mitigation |
|
||||
|------|--------|-------------|------------|
|
||||
| Database migration failure | High | Low | Thoroughly test migration script, prepare rollback plan |
|
||||
| Shadow config out of sync with original config | Medium | Medium | Shadow config dynamically reads from original config, real-time sync |
|
||||
| API key decryption failure | High | Low | Add error handling and logging |
|
||||
| Performance impact | Low | Low | Limited number of shadow configs, negligible performance impact |
|
||||
|
||||
### Business Risks
|
||||
|
||||
| Risk | Impact | Probability | Mitigation |
|
||||
|------|--------|-------------|------------|
|
||||
| User confusion (shadow config vs own config) | Medium | Medium | Clearly indicate inheritance source in UI |
|
||||
| Users unaware of group config updates | Low | Low | Document behavior, or add config version notification in the future |
|
||||
|
||||
### Compatibility Risks
|
||||
|
||||
| Risk | Impact | Probability | Mitigation |
|
||||
|------|--------|-------------|------------|
|
||||
| Existing API clients incompatible with `inherited_from` field | Low | Low | Field is optional, old clients can ignore it |
|
||||
| Agent doesn't support shadow config | High | Low | Agent retrieves config via `user_id`, automatically compatible |
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Future Features
|
||||
|
||||
1. **Config Overrides**: Allow users to override certain parameters in shadow config (e.g., `temperature`)
|
||||
2. **Change Notifications**: Notify users when group config is updated
|
||||
3. **Config Version Tracking**: Record change history of configs
|
||||
4. **Config Recommendations**: Recommend default configs based on usage patterns
|
||||
|
||||
### Related Features
|
||||
|
||||
- Support user config templates (create own config based on group config)
|
||||
- Config import/export functionality
|
||||
- Batch config management
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [LLM Model Configs API](../llm-model-configs-api.md)
|
||||
- [AI Chat API Design](../ai-chat-api-design.md)
|
||||
- SQLAlchemy Foreign Key: https://docs.sqlalchemy.org/en/14/core/metadata.html
|
||||
- Alembic Migrations: https://alembic.sqlalchemy.org/en/latest/tutorial.html
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2026-03-06
|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Copyright © 2025 Yue Guobin (岳国宾)**
|
||||
|
||||
This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
|
||||
International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||

|
||||
|
||||
### Summary
|
||||
|
||||
You are free to:
|
||||
|
||||
- **Share** — Copy and redistribute the material in any medium or format
|
||||
- **Adapt** — Remix, transform, and build upon the material for any purpose
|
||||
|
||||
Under the following terms:
|
||||
|
||||
- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
|
||||
a link to the license, and indicate if changes were made.
|
||||
- **ShareAlike** — If you remix, transform, or build upon the material, you must
|
||||
distribute your contributions under the **same license** (CC BY-SA 4.0).
|
||||
|
||||
Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,571 +0,0 @@
|
||||
# GNS3 RBAC + ACL Permission System Implementation Guide
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Created**: 2026-03-06
|
||||
**Applicable Version**: GNS3 Server v3.0+
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [System Overview](#system-overview)
|
||||
- [Core Concepts](#core-concepts)
|
||||
- [Data Model](#data-model)
|
||||
- [Permission Check Flow](#permission-check-flow)
|
||||
- [Usage Examples](#usage-examples)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Common Issues](#common-issues)
|
||||
|
||||
---
|
||||
|
||||
## System Overview
|
||||
|
||||
GNS3 Server implements a **two-tier permission control system** that combines **RBAC** (Role-Based Access Control) and **ACL** (Access Control List) features:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Tier 1: RBAC (Define Capabilities) │
|
||||
│ │
|
||||
│ Role → Privilege │
|
||||
│ Answers: "What operations can a user perform?" │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Tier 2: ACL (Explicit Authorization) │
|
||||
│ │
|
||||
│ Default: Deny All │
|
||||
│ Unless: ACE Explicitly Allows │
|
||||
│ Answers: "On which resources can these operations be used?" │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Core Principle
|
||||
|
||||
**Default Deny, Explicit Allow**
|
||||
|
||||
Like network device ACLs, all access requests are denied by default unless explicitly allowed by an ACE (Access Control Entry).
|
||||
|
||||
```
|
||||
No ACE → ❌ Access Denied
|
||||
ACE with allowed=False → ❌ Access Denied
|
||||
ACE with allowed=True → ✅ Access Allowed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Privilege
|
||||
|
||||
Privileges define the operations a user can perform.
|
||||
|
||||
```python
|
||||
Privilege Naming Format: <Resource>.<Action>
|
||||
|
||||
Examples:
|
||||
- Project.Audit # View projects
|
||||
- Project.Allocate # Create/delete projects
|
||||
- Project.Modify # Modify projects
|
||||
- Node.Console # Access node console
|
||||
- Link.Capture # Capture link traffic
|
||||
```
|
||||
|
||||
**Predefined Privileges**: 38 built-in privileges (see `gns3server/db/models/privileges.py`)
|
||||
|
||||
### 2. Role
|
||||
|
||||
Roles are collections of privileges that simplify permission management.
|
||||
|
||||
```python
|
||||
Built-in Roles:
|
||||
- Administrator: All privileges
|
||||
- User: Common privileges for projects, nodes, links, snapshots, etc.
|
||||
- Auditor: Read-only privileges (*.Audit)
|
||||
- Template manager: Template and symbol management
|
||||
- User manager: User and group management
|
||||
- ACL manager: Role and ACE management
|
||||
- No Access: No privileges
|
||||
```
|
||||
|
||||
### 3. User Group
|
||||
|
||||
User groups are used to batch-manage users.
|
||||
|
||||
```python
|
||||
Built-in Groups:
|
||||
- Administrators: Administrator group
|
||||
- Users: Regular user group
|
||||
```
|
||||
|
||||
### 4. ACE (Access Control Entry)
|
||||
|
||||
ACEs are the core of access control, defining **on which resources which roles can be used**.
|
||||
|
||||
```python
|
||||
ACE Structure:
|
||||
{
|
||||
"path": "/projects", # Resource path
|
||||
"user_id": "uuid", # User ID (choose one with group_id)
|
||||
"group_id": "uuid", # User group ID (choose one with user_id)
|
||||
"role_id": "uuid", # Role ID
|
||||
"allowed": true, # Whether to allow (default true)
|
||||
"propagate": true, # Whether to propagate to child paths (default true)
|
||||
"ace_type": "user" # "user" or "group"
|
||||
}
|
||||
```
|
||||
|
||||
**Important**:
|
||||
- `path`: File system-style paths like `/projects`, `/projects/123`
|
||||
- `role_id`: The role associated with the ACE, which defines available privileges
|
||||
- `allowed`: Explicit allow or deny (default true)
|
||||
- `propagate`: Whether permissions are inherited by child paths (default true)
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Entity Relationships
|
||||
|
||||
```
|
||||
User ────< UserGroup > (many-to-many via user_group_map)
|
||||
│ │
|
||||
│ └───< ACE (group_id)
|
||||
│
|
||||
└───< ACE (user_id)
|
||||
│
|
||||
├── path (resource path)
|
||||
├── role → Role → Privilege (privilege)
|
||||
├── allowed (allow/deny)
|
||||
└── propagate (whether to propagate)
|
||||
```
|
||||
|
||||
### Database Tables
|
||||
|
||||
| Table | Description | Key Fields |
|
||||
|-------|-------------|------------|
|
||||
| `users` | Users | `user_id`, `username`, `is_superadmin` |
|
||||
| `user_groups` | User groups | `user_group_id`, `name` |
|
||||
| `roles` | Roles | `role_id`, `name`, `is_builtin` |
|
||||
| `privileges` | Privileges | `privilege_id`, `name` |
|
||||
| `acl` (ACE) | Access Control Entries | `ace_id`, `path`, `user_id`, `group_id`, `role_id`, `allowed`, `propagate` |
|
||||
| `privilege_role_map` | Role-privilege association | `privilege_id`, `role_id` |
|
||||
| `user_group_map` | User-group association | `user_id`, `user_group_id` |
|
||||
|
||||
---
|
||||
|
||||
## Permission Check Flow
|
||||
|
||||
### Complete Flowchart
|
||||
|
||||
```
|
||||
User Request: GET /projects/123, requires Project.Audit privilege
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 1. Extract Request Information │
|
||||
│ - User ID │
|
||||
│ - Path: /projects/123 │
|
||||
│ - Required privilege: Project.Audit │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 2. Special Check: Superadmin │
|
||||
│ If is_superadmin = True │
|
||||
│ → ✅ Allow directly (bypass RBAC + ACL) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 3. Query User ACEs │
|
||||
│ SELECT * FROM ace │
|
||||
│ JOIN privilege_role_map ON ace.role_id = ... │
|
||||
│ JOIN privileges ON ... │
|
||||
│ WHERE │
|
||||
│ ace.user_id = <user_id> │
|
||||
│ AND privileges.name = 'Project.Audit' │
|
||||
│ AND ace.path matches /projects/123 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 4. Check User ACEs │
|
||||
│ If matching ACE found: │
|
||||
│ - if allowed = False → ❌ Deny │
|
||||
│ - if allowed = True → ✅ Allow │
|
||||
│ If not found: │
|
||||
│ → Continue checking group ACEs │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 5. Query Group ACEs │
|
||||
│ Query ACEs for all groups the user belongs to │
|
||||
│ (same logic as user ACEs) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 6. Check Group ACEs │
|
||||
│ If matching group ACE found: │
|
||||
│ - if allowed = False → ❌ Deny │
|
||||
│ - if allowed = True → ✅ Allow │
|
||||
│ If not found: │
|
||||
│ → ❌ Access denied (deny by default) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Path Matching Rules
|
||||
|
||||
Path matching follows the **specific-to-general** principle:
|
||||
|
||||
```
|
||||
Request Path: /projects/123/nodes/456
|
||||
|
||||
Check Order:
|
||||
1. /projects/123/nodes/456 (most specific)
|
||||
2. /projects/123/nodes
|
||||
3. /projects/123
|
||||
4. /projects
|
||||
5. / (most general)
|
||||
```
|
||||
|
||||
**Impact of propagate Parameter**:
|
||||
|
||||
```python
|
||||
# ACE 1: path="/projects", propagate=True
|
||||
✅ Allow: /projects, /projects/123, /projects/123/nodes
|
||||
# Permission propagates to all child paths
|
||||
|
||||
# ACE 2: path="/projects", propagate=False
|
||||
✅ Allow: /projects
|
||||
❌ Deny: /projects/123, /projects/123/nodes
|
||||
# Permission does not propagate, only exact match allowed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Scenario 1: Allow User to Access All Projects
|
||||
|
||||
```python
|
||||
# Create ACE
|
||||
POST /v3/access/aces
|
||||
{
|
||||
"path": "/projects",
|
||||
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"role_id": "<User role_id>",
|
||||
"allowed": true,
|
||||
"propagate": true
|
||||
}
|
||||
|
||||
# Result: User can access all projects (/projects/*)
|
||||
```
|
||||
|
||||
### Scenario 2: Allow User to Access Only Specific Project
|
||||
|
||||
```python
|
||||
# Create ACE (exact path)
|
||||
POST /v3/access/aces
|
||||
{
|
||||
"path": "/projects/my-project-id",
|
||||
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"role_id": "<User role_id>",
|
||||
"allowed": true,
|
||||
"propagate": false
|
||||
}
|
||||
|
||||
# Result: User can only access /projects/my-project-id
|
||||
# Cannot access other projects
|
||||
```
|
||||
|
||||
### Scenario 3: Use Group Permissions
|
||||
|
||||
```python
|
||||
# Create ACE for group
|
||||
POST /v3/access/aces
|
||||
{
|
||||
"path": "/projects",
|
||||
"group_id": "<Users group_id>",
|
||||
"role_id": "<User role_id>",
|
||||
"allowed": true,
|
||||
"propagate": true
|
||||
}
|
||||
|
||||
# Result: All members of "Users" group can access all projects
|
||||
```
|
||||
|
||||
### Scenario 4: Explicitly Deny Specific Resource
|
||||
|
||||
```python
|
||||
# User can access all projects
|
||||
ACE: path="/projects", user=A, allowed=true, propagate=true
|
||||
|
||||
# But deny access to a specific secret project
|
||||
ACE: path="/projects/secret", user=A, allowed=false
|
||||
|
||||
# Result: User can access all projects except /projects/secret
|
||||
```
|
||||
|
||||
### Scenario 5: Use Resource Pools
|
||||
|
||||
```python
|
||||
# Grant user access to resource pool
|
||||
POST /v3/access/aces
|
||||
{
|
||||
"path": "/pools/pool-123",
|
||||
"user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"role_id": "<User role_id>",
|
||||
"allowed": true,
|
||||
"propagate": true
|
||||
}
|
||||
|
||||
# Result: User can access all resources in pool-123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Groups for Permission Management (Recommended)
|
||||
|
||||
**Recommended** ✅:
|
||||
```python
|
||||
# Create ACE for "Users" group
|
||||
ACE(path="/projects", group="Users", role="User", allowed=true)
|
||||
```
|
||||
|
||||
**Not Recommended** ❌:
|
||||
```python
|
||||
# Create separate ACE for each user
|
||||
ACE(path="/projects", user="user1", role="User", allowed=true)
|
||||
ACE(path="/projects", user="user2", role="User", allowed=true)
|
||||
ACE(path="/projects", user="user3", role="User", allowed=true)
|
||||
# ... Repeat for hundreds of users
|
||||
```
|
||||
|
||||
### 2. Use propagate to Reduce Configuration
|
||||
|
||||
**Recommended** ✅:
|
||||
```python
|
||||
# Use propagate=True
|
||||
ACE(path="/projects", group="Users", role="User", allowed=true, propagate=true)
|
||||
# One ACE covers all projects and sub-resources
|
||||
```
|
||||
|
||||
**Not Recommended** ❌:
|
||||
```python
|
||||
# Create separate ACE for each project
|
||||
ACE(path="/projects/1", group="Users", role="User", allowed=true)
|
||||
ACE(path="/projects/2", group="Users", role="User", allowed=true)
|
||||
ACE(path="/projects/3", group="Users", role="User", allowed=true)
|
||||
# ... Difficult to maintain
|
||||
```
|
||||
|
||||
### 3. Use Default ACEs
|
||||
|
||||
**Problem**: Fresh system install has no ACEs by default, users cannot access any resources.
|
||||
|
||||
**Solution**: Create default ACEs for default user groups
|
||||
|
||||
```python
|
||||
# Initialization script
|
||||
async def create_default_aces():
|
||||
users_group = await get_group_by_name("Users")
|
||||
user_role = await get_role_by_name("User")
|
||||
|
||||
# Create default ACE for "Users" group
|
||||
await create_ace({
|
||||
"path": "/",
|
||||
"group_id": users_group.id,
|
||||
"role_id": user_role.id,
|
||||
"allowed": true,
|
||||
"propagate": true
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Audit Permission Configuration
|
||||
|
||||
Regularly check ACE configuration:
|
||||
|
||||
```python
|
||||
# Query all ACEs
|
||||
GET /v3/access/aces
|
||||
|
||||
# Check user's actual permissions
|
||||
GET /v3/access/users/me
|
||||
# Returns user's groups, accessible pools, ACE list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Q1: Why can't a user access resources even with role privileges?
|
||||
|
||||
**A**: This is the most common issue. RBAC defines "what can be done," but ACL limits "where it can be done."
|
||||
|
||||
**Checklist**:
|
||||
1. Does the user have a matching ACE?
|
||||
2. Does the ACE `path` match the request path?
|
||||
3. Is the ACE `allowed` set to `true`?
|
||||
4. Does the associated `role` have the required privilege?
|
||||
|
||||
```bash
|
||||
# Check user's ACEs
|
||||
curl -X GET http://localhost:3080/v3/access/aces \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# Check user's groups
|
||||
curl -X GET http://localhost:3080/v3/access/users/me \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Q2: What is the purpose of the propagate parameter?
|
||||
|
||||
**A**: `propagate` controls whether permissions are inherited by child paths.
|
||||
|
||||
- `propagate=true`: Permission propagates to all child paths
|
||||
- `propagate=false`: Permission applies only to the exact path
|
||||
|
||||
```
|
||||
ACE: path="/projects", propagate=true
|
||||
→ Allow: /projects, /projects/1, /projects/1/nodes, ...
|
||||
|
||||
ACE: path="/projects", propagate=false
|
||||
→ Allow: /projects
|
||||
→ Deny: /projects/1, /projects/1/nodes, ...
|
||||
```
|
||||
|
||||
### Q3: What is the priority of user ACEs vs group ACEs?
|
||||
|
||||
**A**: User ACEs take priority over group ACEs.
|
||||
|
||||
```python
|
||||
# User ACE
|
||||
ACE(path="/projects", user=A, role=Auditor, allowed=true)
|
||||
|
||||
# Group ACE (user's group)
|
||||
ACE(path="/projects", group=Users, role=User, allowed=true)
|
||||
|
||||
# Result: User ACE takes priority, user uses Auditor role
|
||||
```
|
||||
|
||||
### Q4: How to deny access to specific resources?
|
||||
|
||||
**A**: Create an ACE with `allowed=false`.
|
||||
|
||||
```python
|
||||
# User can access all projects
|
||||
ACE(path="/projects", user=A, role=User, allowed=true, propagate=true)
|
||||
|
||||
# But deny access to secret project
|
||||
ACE(path="/projects/secret", user=A, role=User, allowed=false)
|
||||
```
|
||||
|
||||
**Note**: The deny ACE path must be more specific (longer path).
|
||||
|
||||
### Q5: Are superadmins subject to RBAC + ACL restrictions?
|
||||
|
||||
**A**: No. Users with `is_superadmin=true` bypass all permission checks.
|
||||
|
||||
```python
|
||||
# Superadmin
|
||||
{
|
||||
"username": "admin",
|
||||
"is_superadmin": true
|
||||
}
|
||||
|
||||
# No ACE required to access any resource
|
||||
```
|
||||
|
||||
### Q6: What is the path format for resource pools?
|
||||
|
||||
**A**: Resource pools use the `/pools/{pool_id}` format.
|
||||
|
||||
```python
|
||||
# Grant user access to resource pool
|
||||
ACE(path="/pools/pool-123", user=A, role=User, allowed=true)
|
||||
|
||||
# Project paths within the pool
|
||||
# /pools/pool-123/projects/project-1
|
||||
```
|
||||
|
||||
**Note**: There is an inconsistency in the code between `/pool` and `/pools`. Recommendation: use `/pools` (plural form).
|
||||
|
||||
### Q7: How to debug permission issues?
|
||||
|
||||
**A**: Enable debug logging and check the permission check flow.
|
||||
|
||||
```python
|
||||
# Enable debug logging
|
||||
import logging
|
||||
logging.getLogger("gns3server.db.repositories.rbac").setLevel(logging.DEBUG)
|
||||
|
||||
# View logs
|
||||
# DEBUG:gns3server.db.repositories.rbac:Checking user admin has privilege Project.Audit on '/projects/123'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### Permission Check Related API Endpoints
|
||||
|
||||
| Endpoint | Method | Description | Required Privilege |
|
||||
|----------|--------|-------------|-------------------|
|
||||
| `/v3/access/users/me` | GET | Get current user info (includes groups, pools, ACEs) | None (authenticated user) |
|
||||
| `/v3/access/users` | GET | Get all users | User.Audit |
|
||||
| `/v3/access/users/{user_id}` | GET | Get specific user | User.Audit |
|
||||
| `/v3/access/groups` | GET | Get all groups | Group.Audit |
|
||||
| `/v3/access/roles` | GET | Get all roles | Role.Audit |
|
||||
| `/v3/access/privileges` | GET | Get all privileges | Role.Audit |
|
||||
| `/v3/access/aces` | GET | Get all ACEs | ACE.Audit |
|
||||
| `/v3/access/aces` | POST | Create ACE | ACE.Allocate |
|
||||
| `/v3/access/aces/{ace_id}` | PUT | Update ACE | ACE.Modify |
|
||||
| `/v3/access/aces/{ace_id}` | DELETE | Delete ACE | ACE.Allocate |
|
||||
|
||||
---
|
||||
|
||||
## Code Reference
|
||||
|
||||
| Component | File Path |
|
||||
|-----------|-----------|
|
||||
| Data Models | `gns3server/db/models/` |
|
||||
| - Users and Groups | `users.py` |
|
||||
| - Roles and Privileges | `roles.py`, `privileges.py` |
|
||||
| - ACE | `acl.py` |
|
||||
| RBAC Repository | `gns3server/db/repositories/rbac.py` |
|
||||
| Permission Check Dependency | `gns3server/api/routes/controller/dependencies/rbac.py` |
|
||||
| API Routes | `gns3server/api/routes/controller/` |
|
||||
| - User Routes | `users.py` |
|
||||
| - RBAC Routes | `roles.py`, `acl.py` |
|
||||
| Schemas | `gns3server/schemas/controller/rbac.py` |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
GNS3's RBAC + ACL system is a powerful and flexible permission control framework:
|
||||
|
||||
### Key Points
|
||||
|
||||
1. **Two-Tier Protection**: RBAC defines capabilities, ACL limits scope
|
||||
2. **Default Deny**: No ACE means access denied
|
||||
3. **Explicit Allow**: Must have ACE (allowed=true) to access
|
||||
4. **Role-Based**: ACEs grant privileges through roles
|
||||
5. **Path Inheritance**: propagate controls permission propagation
|
||||
|
||||
### Design Advantages
|
||||
|
||||
- ✅ Fine-grained Control: Precise resource-level permissions
|
||||
- ✅ Flexibility: Support user and group-level permissions
|
||||
- ✅ Centralized Management: Define permissions centrally through roles
|
||||
- ✅ High Security: Deny all by default, explicit allow
|
||||
|
||||
### Caveats
|
||||
|
||||
- ⚠️ New systems require default ACE creation
|
||||
- ⚠️ Must configure ACEs for each user/group
|
||||
- ⚠️ Regularly audit permission configurations
|
||||
- ⚠️ Superadmin bypasses all restrictions
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2026-03-06
|
||||
@ -65,6 +65,11 @@ You are a **GNS3 Lab Teaching Assistant**.
|
||||
|
||||
| Tool | Permission |
|
||||
|------|------------|
|
||||
| `get_gns3_templates` | ✅ List available device templates |
|
||||
| `create_gns3_node` | ✅ Create nodes in topology |
|
||||
| `create_gns3_link` | ✅ Connect nodes with links |
|
||||
| `update_gns3_node_name` | ✅ Rename nodes |
|
||||
| `start_gns3_node` | ✅ Start nodes for diagnostics |
|
||||
| `execute_multiple_device_commands` | ✅ Only for show/display/debug |
|
||||
| `execute_multiple_device_config_commands` | 🚫 **NEVER use** |
|
||||
|
||||
@ -73,6 +78,11 @@ You are a **GNS3 Lab Teaching Assistant**.
|
||||
- Wait for result before calling next tool
|
||||
- If topology is already in context, DO NOT call topology reader again
|
||||
|
||||
**Topology Management Permissions**:
|
||||
- You CAN create and manage topology (templates, nodes, links, names)
|
||||
- You CAN start nodes for diagnostic purposes
|
||||
- You CANNOT stop or suspend nodes (prevents disruption of active labs)
|
||||
|
||||
---
|
||||
|
||||
# WORKFLOW
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user