feat: major architectural refactor to 5.1b1 - Service Layer, gRPC & Agent evolution (fragmented secrets)
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
# Remote Plugin Support — Implementation Plan
|
||||
|
||||
## Objetivo
|
||||
|
||||
Cuando connpy opera en modo remoto, el usuario puede usar plugins instalados **solo en el server**. La ejecución es completamente transparente: el cliente construye el argparse localmente (usando el source descargado del server) y todo lo demás corre en el server vía gRPC streaming.
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
Cliente Server
|
||||
─────── ──────
|
||||
connpy aws vpc vpc-123
|
||||
│
|
||||
├─ init() remoto
|
||||
│ ├─ gRPC: list_plugins() → ["aws", "monitor"]
|
||||
│ ├─ gRPC: get_plugin_source("aws") → aws.py source (texto)
|
||||
│ ├─ Carga Parser en RAM → agrega subcomando al argparse
|
||||
│ └─ Marca "aws" como remote_plugin
|
||||
│
|
||||
├─ argparse parsea args localmente (usa el Parser descargado)
|
||||
│
|
||||
└─ dispatch():
|
||||
└─ "aws" es remote_plugin
|
||||
└─ gRPC: invoke_plugin("aws", args_json) streaming
|
||||
└─ Entrypoint(args, parser, connapp) ──→ output
|
||||
←─── chunks de stdout/stderr ──┘
|
||||
```
|
||||
|
||||
### Regla fundamental
|
||||
- **`Parser`** → corre en el **cliente** (construye argparse)
|
||||
- **`Entrypoint`** → corre en el **server** (toda la lógica del plugin)
|
||||
- El plugin **no sabe** si está siendo ejecutado local o remotamente
|
||||
|
||||
---
|
||||
|
||||
## Cache de plugins remotos
|
||||
|
||||
### Estructura en disco
|
||||
```
|
||||
{configdir}/remote_plugins/
|
||||
├── aws.py ← source descargado del server
|
||||
```
|
||||
|
||||
### Cuándo se actualiza
|
||||
Cada vez que `connpy` arranca en modo remoto, descarga y **sobreescribe** la cache en la ruta especificada por el archivo `.folder` activo. Sin hash, sin TTL, sin lógica extra. Siempre fresco.
|
||||
|
||||
### Uso por completion.py
|
||||
`completion.py` incluye `{configdir}/remote_plugins/` como directorio adicional al escanear plugins. Carga `_connpy_tree()` desde el `.py` cacheado y automáticamente le inyecta la función `get_cwd()` para que pueda completar rutas locales sin dependencias extras.
|
||||
|
||||
---
|
||||
|
||||
## Gestión de plugin: `connpy plugin`
|
||||
|
||||
### `--list`: muestra local y remoto
|
||||
|
||||
```
|
||||
connpy plugin --list
|
||||
|
||||
LOCAL:
|
||||
aws [active]
|
||||
tools [active]
|
||||
|
||||
REMOTE:
|
||||
aws [shadowed] ← mismo nombre, local tiene prioridad
|
||||
monitor [active]
|
||||
deploy [active]
|
||||
```
|
||||
|
||||
Estados posibles:
|
||||
| Estado | Significado |
|
||||
|---|---|
|
||||
| `[active]` | Activo y usable |
|
||||
| `[shadowed]` | Existe pero el otro lado tiene prioridad |
|
||||
| `[disabled]` | Explícitamente desactivado |
|
||||
|
||||
### Prioridad cuando el mismo plugin existe en ambos lados
|
||||
|
||||
**Local gana por defecto.** Override guardado en:
|
||||
```json
|
||||
// ~/.config/conn/plugin_preferences.json
|
||||
{
|
||||
"aws": "remote"
|
||||
}
|
||||
```
|
||||
|
||||
### Semántica de `--enable`
|
||||
|
||||
Activa el plugin pedido. Si el mismo plugin existe en el otro lado → ese queda shadowed.
|
||||
|
||||
```bash
|
||||
connpy plugin --enable aws # activa local, remoto queda shadowed
|
||||
connpy plugin --enable aws --remote # activa remoto, local queda shadowed
|
||||
```
|
||||
|
||||
### Semántica de `--disable`
|
||||
|
||||
Desactiva el plugin indicado. **NO activa automáticamente el del otro lado.**
|
||||
Permite tener ambos desactivados si el usuario lo desea.
|
||||
|
||||
```bash
|
||||
connpy plugin --disable aws # desactiva aws local (remoto no cambia)
|
||||
connpy plugin --disable aws --remote # desactiva aws remoto (local no cambia)
|
||||
```
|
||||
|
||||
### `--add`, `--del`, `--update`
|
||||
|
||||
```bash
|
||||
connpy plugin --add myplug myfile.py # instala local
|
||||
connpy plugin --add myplug myfile.py --remote # sube al server vía gRPC
|
||||
connpy plugin --del myplug # borra local
|
||||
connpy plugin --del myplug --remote # borra del server
|
||||
connpy plugin --update myplug myfile.py # actualiza local
|
||||
connpy plugin --update myplug myfile.py --remote # actualiza en server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archivos a modificar
|
||||
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `grpc/connpy_pb2_grpc.py` | Agregar `get_plugin_source` + `invoke_plugin` a PluginService |
|
||||
| `grpc/connpy_pb2.py` | Agregar `PluginInvokeRequest` + `OutputChunk` messages |
|
||||
| `grpc/server.py` | Implementar métodos en `PluginServicer` |
|
||||
| `grpc/stubs.py` | Agregar métodos a `PluginStub` |
|
||||
| `services/plugin_service.py` | Agregar `get_plugin_source()` + `invoke_plugin()` |
|
||||
| `connpy/plugins.py` | Remote loading, preferences, `remote_plugins` dict |
|
||||
| `connapp.py` | Remote plugin init + dispatch proxy |
|
||||
| `cli/plugin_handler.py` | Flag `--remote`, `--list` unificado, enable/disable con prefs |
|
||||
| `completion.py` | Incluir cache remoto en `_get_plugins()` |
|
||||
|
||||
---
|
||||
|
||||
## Nuevo gRPC en `PluginService`
|
||||
|
||||
```python
|
||||
# Mensajes nuevos
|
||||
class PluginInvokeRequest:
|
||||
name: str
|
||||
args_json: str # argparse.Namespace serializado como JSON (solo tipos básicos)
|
||||
|
||||
class OutputChunk:
|
||||
text: str
|
||||
is_error: bool
|
||||
|
||||
# Métodos nuevos en PluginService
|
||||
get_plugin_source(IdRequest) → StringResponse
|
||||
invoke_plugin(PluginInvokeRequest) → stream OutputChunk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Serialización de args
|
||||
|
||||
`argparse.Namespace` se serializa filtrando solo tipos básicos (str, int, float, bool, list, None):
|
||||
|
||||
```python
|
||||
args_dict = {k: v for k, v in vars(args_namespace).items()
|
||||
if isinstance(v, (str, int, float, bool, list, type(None)))}
|
||||
```
|
||||
|
||||
**Limitación conocida**: plugins remotos no pueden usar `argparse.FileType`. Deben recibir paths como strings y abrir el archivo en el server.
|
||||
|
||||
---
|
||||
|
||||
## Flujo de enable/disable con conflictos
|
||||
|
||||
```
|
||||
Estado inicial:
|
||||
LOCAL: aws [active]
|
||||
REMOTE: aws [shadowed]
|
||||
|
||||
connpy plugin --enable aws --remote
|
||||
→ preferences: {"aws": "remote"}
|
||||
LOCAL: aws [shadowed]
|
||||
REMOTE: aws [active]
|
||||
|
||||
connpy plugin --disable aws --remote
|
||||
→ gRPC disable en server, NO toca el local ni el pref
|
||||
LOCAL: aws [shadowed] ← sigue con pref=remote pero remoto está disabled
|
||||
REMOTE: aws [disabled]
|
||||
|
||||
connpy plugin --enable aws
|
||||
→ Borra "aws" del preferences (vuelve a default = local)
|
||||
LOCAL: aws [active]
|
||||
REMOTE: aws [shadowed]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Captura de output en el server
|
||||
|
||||
El server redirige `sys.stdout` durante la ejecución del `Entrypoint` y hace yield de cada línea:
|
||||
|
||||
```python
|
||||
def invoke_plugin(self, name, args_dict):
|
||||
import sys, io
|
||||
from argparse import Namespace
|
||||
args = Namespace(**args_dict)
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = buf = io.StringIO()
|
||||
try:
|
||||
plugin.Entrypoint(args, parser, connapp)
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
for line in buf.getvalue().splitlines(keepends=True):
|
||||
yield line
|
||||
```
|
||||
|
||||
Si el plugin usa Rich o escribe directo al fd, se puede aislar en un subprocess.
|
||||
Reference in New Issue
Block a user