merge: quick/ssh-pass-stubs-embedding-datascience — SSH, pass, stubs, embedding, datascience, jupyter fix

This commit is contained in:
2026-04-02 22:04:29 +02:00
55 changed files with 2353 additions and 2 deletions
+3
View File
@@ -47,3 +47,6 @@ sources/*/
# OS
.DS_Store
Thumbs.db
# Archivos locales
.local
+32
View File
@@ -0,0 +1,32 @@
---
name: pass_delete
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "pass_delete(entry: string) -> void"
description: "Elimina un secreto del password store (pass)."
tags: [pass, secret, credential, delete]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["elimina entrada de test", "falla con entrada inexistente"]
test_file_path: "bash/functions/infra/pass_test.sh"
file_path: "bash/functions/infra/pass_delete.sh"
---
## Ejemplo
```bash
source pass_delete.sh
pass_delete agentes/viejo-token
```
## Notas
Usa `pass rm -f` para eliminar sin prompt de confirmacion.
+22
View File
@@ -0,0 +1,22 @@
# pass_delete
# -----------
# Elimina un secreto del password store.
# Sale con exit code 1 si la entrada no existe o pass falla.
#
# USO (sourced):
# source pass_delete.sh
# pass_delete agentes/viejo-token
pass_delete() {
local entry="$1"
if [ -z "$entry" ]; then
echo "pass_delete: se requiere nombre de entrada" >&2
return 1
fi
if ! pass rm -f "$entry" >/dev/null 2>&1; then
echo "pass_delete: fallo al eliminar '$entry'" >&2
return 1
fi
}
+33
View File
@@ -0,0 +1,33 @@
---
name: pass_generate
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "pass_generate(entry: string, [length: int]) -> string"
description: "Genera un password aleatorio, lo almacena en el password store e imprime el valor generado."
tags: [pass, secret, credential, generate, random]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["genera password de longitud especifica", "default 24 chars"]
test_file_path: "bash/functions/infra/pass_test.sh"
file_path: "bash/functions/infra/pass_generate.sh"
---
## Ejemplo
```bash
source pass_generate.sh
new_pass=$(pass_generate agentes/nuevo-servicio 32)
echo "password generado: $new_pass"
```
## Notas
Usa `pass generate -f -n` (force overwrite, no symbols). Default 24 caracteres alfanumericos.
+31
View File
@@ -0,0 +1,31 @@
# pass_generate
# -------------
# Genera un password aleatorio y lo almacena en el password store.
# Imprime el password generado a stdout.
# Sale con exit code 1 si pass falla.
#
# USO (sourced):
# source pass_generate.sh
# pass_generate agentes/nuevo-token 32
# pass_generate agentes/api-key # default 24 chars
pass_generate() {
local entry="$1"
local length="${2:-24}"
if [ -z "$entry" ]; then
echo "pass_generate: se requiere nombre de entrada" >&2
return 1
fi
local output
output=$(pass generate -f -n "$entry" "$length" 2>&1)
if [ $? -ne 0 ]; then
echo "pass_generate: fallo al generar '$entry': $output" >&2
return 1
fi
# pass generate imprime ANSI escape codes + header + password
# Extraer ultima linea y limpiar escape codes
echo "$output" | tail -1 | sed 's/\x1b\[[0-9;]*m//g'
}
+33
View File
@@ -0,0 +1,33 @@
---
name: pass_get
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "pass_get(entry: string) -> string"
description: "Lee un secreto del password store (pass) y lo imprime a stdout."
tags: [pass, secret, credential, get]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["lee entrada existente", "falla con entrada inexistente"]
test_file_path: "bash/functions/infra/pass_test.sh"
file_path: "bash/functions/infra/pass_get.sh"
---
## Ejemplo
```bash
source pass_get.sh
token=$(pass_get agentes/dataforge-token)
export GITEA_TOKEN="$token"
```
## Notas
Usa `pass show` internamente. Requiere GPG key desbloqueada. No imprime newline final (usa printf %s).
+26
View File
@@ -0,0 +1,26 @@
# pass_get
# --------
# Lee un secreto del password store y lo imprime a stdout.
# Sale con exit code 1 si la entrada no existe o pass falla.
#
# USO (sourced):
# source pass_get.sh
# token=$(pass_get agentes/dataforge-token)
pass_get() {
local entry="$1"
if [ -z "$entry" ]; then
echo "pass_get: se requiere nombre de entrada" >&2
return 1
fi
local value
value=$(pass show "$entry" 2>/dev/null)
if [ $? -ne 0 ]; then
echo "pass_get: no se pudo leer '$entry'" >&2
return 1
fi
printf '%s' "$value"
}
+33
View File
@@ -0,0 +1,33 @@
---
name: pass_list
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "pass_list([prefix: string]) -> json"
description: "Lista entradas del password store como JSON array. Filtra opcionalmente por prefijo."
tags: [pass, secret, credential, list]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["lista todas las entradas", "filtra por prefijo"]
test_file_path: "bash/functions/infra/pass_test.sh"
file_path: "bash/functions/infra/pass_list.sh"
---
## Ejemplo
```bash
source pass_list.sh
entries=$(pass_list agentes)
# ["dataforge-token","egutierrez-token","gitea-url"]
```
## Notas
Parsea el output tree de `pass ls` y lo convierte a JSON array. Cada entrada es un string con el nombre relativo al prefijo.
+40
View File
@@ -0,0 +1,40 @@
# pass_list
# ---------
# Lista entradas del password store como JSON array.
# Opcionalmente filtra por prefijo.
# Sale con exit code 1 si pass falla.
#
# USO (sourced):
# source pass_list.sh
# pass_list # todas las entradas
# pass_list agentes # solo bajo agentes/
pass_list() {
local prefix="${1:-}"
local raw
raw=$(pass ls "$prefix" 2>/dev/null)
if [ $? -ne 0 ]; then
echo "pass_list: fallo al listar entradas" >&2
return 1
fi
# Parsear output de pass: extraer nombres limpios (sin tree chars)
local entries
entries=$(echo "$raw" | sed 's/[│├└──── ]//g' | sed '/^$/d' | grep -v '^Password' | grep -v '^[[:space:]]*$')
# Construir JSON array
printf '['
local first=true
while IFS= read -r line; do
line=$(echo "$line" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
[ -z "$line" ] && continue
if [ "$first" = true ]; then
first=false
else
printf ','
fi
printf '"%s"' "$line"
done <<< "$entries"
printf ']'
}
+32
View File
@@ -0,0 +1,32 @@
---
name: pass_set
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "pass_set(entry: string, [value: string]) -> void"
description: "Inserta o sobreescribe un secreto en el password store (pass)."
tags: [pass, secret, credential, set, insert]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["inserta valor y lo lee de vuelta", "sobreescribe valor existente"]
test_file_path: "bash/functions/infra/pass_test.sh"
file_path: "bash/functions/infra/pass_set.sh"
---
## Ejemplo
```bash
source pass_set.sh
pass_set agentes/nuevo-servicio "token-abc123"
```
## Notas
Usa `pass insert -m -f` para forzar sobreescritura sin prompt interactivo. Si no se pasa valor como argumento, lee de stdin.
+31
View File
@@ -0,0 +1,31 @@
# pass_set
# --------
# Inserta o sobreescribe un secreto en el password store.
# Lee el valor de stdin si no se pasa como segundo argumento.
# Sale con exit code 1 si pass falla.
#
# USO (sourced):
# source pass_set.sh
# pass_set agentes/nuevo-token "mi-token-secreto"
# echo "mi-token" | pass_set agentes/nuevo-token
pass_set() {
local entry="$1"
local value="$2"
if [ -z "$entry" ]; then
echo "pass_set: se requiere nombre de entrada" >&2
return 1
fi
if [ -n "$value" ]; then
printf '%s' "$value" | pass insert -m -f "$entry" >/dev/null 2>&1
else
pass insert -m -f "$entry" >/dev/null 2>&1
fi
if [ $? -ne 0 ]; then
echo "pass_set: fallo al escribir '$entry'" >&2
return 1
fi
}
+33
View File
@@ -0,0 +1,33 @@
---
name: pass_sync
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "pass_sync() -> json"
description: "Sincroniza el password store con el repositorio git remoto (pull + push)."
tags: [pass, secret, sync, git]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["sincroniza con remoto"]
test_file_path: "bash/functions/infra/pass_test.sh"
file_path: "bash/functions/infra/pass_sync.sh"
---
## Ejemplo
```bash
source pass_sync.sh
result=$(pass_sync)
# {"pull":"Already up to date.","push":"Everything up-to-date"}
```
## Notas
Ejecuta `pass git pull` seguido de `pass git push`. Requiere que el password store tenga un remote git configurado. Retorna JSON con la ultima linea de cada operacion.
+28
View File
@@ -0,0 +1,28 @@
# pass_sync
# ---------
# Sincroniza el password store con el repositorio git remoto (pull + push).
# Sale con exit code 1 si la sincronizacion falla.
#
# USO (sourced):
# source pass_sync.sh
# pass_sync
pass_sync() {
local pull_out
pull_out=$(pass git pull 2>&1)
if [ $? -ne 0 ]; then
echo "pass_sync: fallo en git pull: $pull_out" >&2
return 1
fi
local push_out
push_out=$(pass git push 2>&1)
if [ $? -ne 0 ]; then
echo "pass_sync: fallo en git push: $push_out" >&2
return 1
fi
printf '{"pull":"%s","push":"%s"}' \
"$(echo "$pull_out" | tail -1 | sed 's/"/\\"/g')" \
"$(echo "$push_out" | tail -1 | sed 's/"/\\"/g')"
}
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# pass_test.sh — Tests para funciones pass del registry
# Usa la entrada test/fn_registry_test como sandbox (se limpia al final)
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/pass_get.sh"
source "$SCRIPT_DIR/pass_set.sh"
source "$SCRIPT_DIR/pass_list.sh"
source "$SCRIPT_DIR/pass_delete.sh"
source "$SCRIPT_DIR/pass_generate.sh"
source "$SCRIPT_DIR/pass_sync.sh"
TEST_ENTRY="test/fn_registry_test"
PASS=0
FAIL=0
pass_cleanup() {
pass rm -f "$TEST_ENTRY" >/dev/null 2>&1 || true
}
assert_eq() {
local test_name="$1" got="$2" want="$3"
if [ "$got" = "$want" ]; then
echo " PASS: $test_name"
((PASS++))
else
echo " FAIL: $test_name (got='$got', want='$want')"
((FAIL++))
fi
}
assert_contains() {
local test_name="$1" got="$2" want="$3"
if echo "$got" | grep -q "$want"; then
echo " PASS: $test_name"
((PASS++))
else
echo " FAIL: $test_name (got='$got', want contener '$want')"
((FAIL++))
fi
}
assert_nonzero() {
local test_name="$1" got="$2"
if [ -n "$got" ]; then
echo " PASS: $test_name"
((PASS++))
else
echo " FAIL: $test_name (got vacio)"
((FAIL++))
fi
}
assert_fail() {
local test_name="$1"
shift
set +e
"$@" 2>/dev/null
local rc=$?
set -e
if [ "$rc" -eq 0 ]; then
echo " FAIL: $test_name (esperaba fallo pero exitoso)"
((FAIL++))
else
echo " PASS: $test_name"
((PASS++))
fi
}
# Pre-check
if ! command -v pass &>/dev/null; then
echo "SKIP: pass no disponible"
exit 0
fi
trap pass_cleanup EXIT
echo "=== pass_get ==="
echo " test: lee entrada existente (agentes/gitea-url)"
got=$(pass_get agentes/gitea-url)
assert_nonzero "lee entrada existente" "$got"
echo " test: falla con entrada inexistente"
assert_fail "falla con entrada inexistente" pass_get "no/existe/xyz"
echo ""
echo "=== pass_set ==="
echo " test: inserta valor y lo lee de vuelta"
pass_set "$TEST_ENTRY" "test-value-12345"
got=$(pass_get "$TEST_ENTRY")
assert_eq "inserta y lee" "$got" "test-value-12345"
echo " test: sobreescribe valor existente"
pass_set "$TEST_ENTRY" "overwritten-value"
got=$(pass_get "$TEST_ENTRY")
assert_eq "sobreescribe" "$got" "overwritten-value"
# Limpiar para siguiente test
pass_cleanup
echo ""
echo "=== pass_list ==="
echo " test: lista todas las entradas"
got=$(pass_list)
assert_contains "lista todas" "$got" "dataforge-token"
echo " test: filtra por prefijo agentes"
got=$(pass_list agentes)
assert_contains "filtra agentes" "$got" "gitea-url"
echo ""
echo "=== pass_generate ==="
echo " test: genera password de 16 chars"
generated=$(pass_generate "$TEST_ENTRY" 16)
assert_eq "longitud 16" "${#generated}" "16"
echo " test: valor almacenado coincide"
stored=$(pass_get "$TEST_ENTRY")
assert_eq "stored = generated" "$stored" "$generated"
pass_cleanup
echo " test: default 24 chars"
generated=$(pass_generate "$TEST_ENTRY")
assert_eq "longitud default 24" "${#generated}" "24"
pass_cleanup
echo ""
echo "=== pass_delete ==="
echo " test: elimina entrada de test"
pass_set "$TEST_ENTRY" "to-delete"
pass_delete "$TEST_ENTRY"
assert_fail "despues de delete no se puede leer" pass_get "$TEST_ENTRY"
echo " test: falla con entrada inexistente"
assert_fail "delete inexistente" pass_delete "no/existe/xyz_delete_test"
echo ""
echo "=== pass_sync ==="
echo " test: sincroniza con remoto"
got=$(pass_sync)
assert_contains "sync retorna json" "$got" "pull"
echo ""
echo "================================"
echo "Resultados: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
+2
View File
@@ -1,3 +1,5 @@
//go:build !noclickhouse
package infra
import (
+13
View File
@@ -0,0 +1,13 @@
//go:build noclickhouse
package infra
import (
"database/sql"
"fmt"
)
// ClickHouseOpen is a stub when built with the noclickhouse tag.
func ClickHouseOpen(host string, port int, user, password, database string) (*sql.DB, error) {
return nil, fmt.Errorf("clickhouse_open: clickhouse support not compiled (built with noclickhouse tag)")
}
+2
View File
@@ -1,3 +1,5 @@
//go:build !noduckdb
package infra
import (
+13
View File
@@ -0,0 +1,13 @@
//go:build noduckdb
package infra
import (
"database/sql"
"fmt"
)
// DuckDBOpen is a stub when built with the noduckdb tag.
func DuckDBOpen(path string) (*sql.DB, error) {
return nil, fmt.Errorf("duckdb_open: duckdb support not compiled (built with noduckdb tag)")
}
+22
View File
@@ -0,0 +1,22 @@
package infra
import (
"fmt"
"os/exec"
"strings"
)
// SSHCheck verifica la conectividad SSH ejecutando un comando noop en el host remoto.
// Retorna nil si la conexion fue exitosa, o error con el detalle del fallo.
func SSHCheck(conn SSHConn) error {
args := conn.sshArgs()
args = append(args, "-o", "ConnectTimeout=5")
args = append(args, "-o", "BatchMode=yes")
args = append(args, conn.destination(), "exit", "0")
out, err := exec.Command("ssh", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("ssh check %s: %s", conn.destination(), strings.TrimSpace(string(out)))
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
---
name: ssh_check
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SSHCheck(conn SSHConn) error"
description: "Verifica conectividad SSH ejecutando un comando noop en el host remoto. Timeout de 5 segundos."
tags: [ssh, connection, check, remote]
uses_functions: []
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings]
tested: true
tests: ["conecta a organic-machine", "falla con host inexistente"]
test_file_path: "functions/infra/ssh_check_test.go"
file_path: "functions/infra/ssh_check.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
if err := SSHCheck(conn); err != nil {
log.Fatal("no se puede conectar:", err)
}
```
## Notas
Usa BatchMode=yes para fallar inmediatamente si requiere input interactivo (password prompt). ConnectTimeout=5 para no bloquear si el host no responde. StrictHostKeyChecking=accept-new acepta hosts nuevos automaticamente.
+40
View File
@@ -0,0 +1,40 @@
package infra
import (
"testing"
)
func sshTestConn() SSHConn {
return SSHConn{
Host: "organic-machine.com",
User: "ubuntu",
KeyPath: "/home/lucas/.ssh/organic-machine",
}
}
func skipIfNoSSH(t *testing.T) SSHConn {
t.Helper()
conn := sshTestConn()
if err := SSHCheck(conn); err != nil {
t.Skipf("SSH no disponible: %v", err)
}
return conn
}
func TestSSHCheck(t *testing.T) {
t.Run("conecta a organic-machine", func(t *testing.T) {
conn := sshTestConn()
err := SSHCheck(conn)
if err != nil {
t.Skipf("SSH no disponible: %v", err)
}
})
t.Run("falla con host inexistente", func(t *testing.T) {
conn := SSHConn{Host: "192.0.2.1", Port: 22, User: "nobody"}
err := SSHCheck(conn)
if err == nil {
t.Error("esperaba error con host inexistente")
}
})
}
+49
View File
@@ -0,0 +1,49 @@
package infra
import "fmt"
// SSHConn parametros de conexion SSH reutilizables.
type SSHConn struct {
Host string // Hostname o IP del servidor remoto
Port int // Puerto SSH (0 usa el default 22)
User string // Usuario remoto
KeyPath string // Ruta a clave privada (vacio usa ssh-agent o default)
}
// sshArgs construye los argumentos comunes de ssh/scp a partir de SSHConn.
func (c SSHConn) sshArgs() []string {
var args []string
port := c.Port
if port == 0 {
port = 22
}
args = append(args, "-o", "StrictHostKeyChecking=accept-new")
args = append(args, "-p", fmt.Sprintf("%d", port))
if c.KeyPath != "" {
args = append(args, "-i", c.KeyPath)
}
return args
}
// scpArgs construye los argumentos comunes de scp a partir de SSHConn.
func (c SSHConn) scpArgs() []string {
var args []string
port := c.Port
if port == 0 {
port = 22
}
args = append(args, "-o", "StrictHostKeyChecking=accept-new")
args = append(args, "-P", fmt.Sprintf("%d", port))
if c.KeyPath != "" {
args = append(args, "-i", c.KeyPath)
}
return args
}
// destination retorna user@host.
func (c SSHConn) destination() string {
if c.User != "" {
return c.User + "@" + c.Host
}
return c.Host
}
+73
View File
@@ -0,0 +1,73 @@
package infra
import (
"testing"
)
func TestSSHConnDestination(t *testing.T) {
tests := []struct {
name string
conn SSHConn
want string
}{
{"con user", SSHConn{Host: "example.com", User: "deploy"}, "deploy@example.com"},
{"sin user", SSHConn{Host: "example.com"}, "example.com"},
{"con IP", SSHConn{Host: "10.0.0.1", User: "root"}, "root@10.0.0.1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.conn.destination()
if got != tt.want {
t.Errorf("destination() = %q, want %q", got, tt.want)
}
})
}
}
func TestSSHConnSSHArgs(t *testing.T) {
t.Run("puerto default", func(t *testing.T) {
conn := SSHConn{Host: "example.com", User: "deploy"}
args := conn.sshArgs()
assertContains(t, args, "-p", "22")
})
t.Run("puerto custom", func(t *testing.T) {
conn := SSHConn{Host: "example.com", Port: 2222}
args := conn.sshArgs()
assertContains(t, args, "-p", "2222")
})
t.Run("con key path", func(t *testing.T) {
conn := SSHConn{Host: "example.com", KeyPath: "/home/user/.ssh/id_ed25519"}
args := conn.sshArgs()
assertContains(t, args, "-i", "/home/user/.ssh/id_ed25519")
})
t.Run("sin key path no incluye -i", func(t *testing.T) {
conn := SSHConn{Host: "example.com"}
args := conn.sshArgs()
for _, a := range args {
if a == "-i" {
t.Error("sshArgs() no debe incluir -i si KeyPath esta vacio")
}
}
})
}
func TestSSHConnSCPArgs(t *testing.T) {
t.Run("usa -P mayuscula para puerto", func(t *testing.T) {
conn := SSHConn{Host: "example.com", Port: 2222}
args := conn.scpArgs()
assertContains(t, args, "-P", "2222")
})
}
func assertContains(t *testing.T, args []string, flag, value string) {
t.Helper()
for i, a := range args {
if a == flag && i+1 < len(args) && args[i+1] == value {
return
}
}
t.Errorf("args %v no contiene %s %s", args, flag, value)
}
+20
View File
@@ -0,0 +1,20 @@
package infra
import (
"fmt"
"os/exec"
"strings"
)
// SSHDownload descarga un archivo del host remoto al filesystem local via scp.
func SSHDownload(conn SSHConn, remotePath, localPath string) error {
args := conn.scpArgs()
src := fmt.Sprintf("%s:%s", conn.destination(), remotePath)
args = append(args, src, localPath)
out, err := exec.Command("scp", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("scp download from %s: %s", conn.destination(), strings.TrimSpace(string(out)))
}
return nil
}
+32
View File
@@ -0,0 +1,32 @@
---
name: ssh_download
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SSHDownload(conn SSHConn, remotePath, localPath string) error"
description: "Descarga un archivo del host remoto al filesystem local via scp."
tags: [ssh, scp, download, file, transfer, remote]
uses_functions: []
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings]
tested: true
tests: ["upload y download roundtrip"]
test_file_path: "functions/infra/ssh_transfer_test.go"
file_path: "functions/infra/ssh_download.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
err := SSHDownload(conn, "/var/log/app.log", "./app.log")
```
## Notas
Descarga un archivo remoto al path local indicado. Para descargar directorios, usar SSHExec con tar/rsync como alternativa.
+39
View File
@@ -0,0 +1,39 @@
package infra
import (
"os/exec"
)
// SSHExec ejecuta un comando en el host remoto via SSH y retorna stdout, stderr y exit code.
func SSHExec(conn SSHConn, command string) (string, string, int, error) {
args := conn.sshArgs()
args = append(args, "-o", "BatchMode=yes")
args = append(args, conn.destination(), command)
cmd := exec.Command("ssh", args...)
var stdout, stderr []byte
cmd.Stdout = writerFunc(func(p []byte) (int, error) {
stdout = append(stdout, p...)
return len(p), nil
})
cmd.Stderr = writerFunc(func(p []byte) (int, error) {
stderr = append(stderr, p...)
return len(p), nil
})
err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return "", "", -1, err
}
}
return string(stdout), string(stderr), exitCode, nil
}
// writerFunc adapta una funcion a io.Writer.
type writerFunc func([]byte) (int, error)
func (f writerFunc) Write(p []byte) (int, error) { return f(p) }
+36
View File
@@ -0,0 +1,36 @@
---
name: ssh_exec
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SSHExec(conn SSHConn, command string) (string, string, int, error)"
description: "Ejecuta un comando en el host remoto via SSH. Retorna stdout, stderr y exit code separados."
tags: [ssh, exec, remote, command]
uses_functions: []
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [os/exec]
tested: true
tests: ["echo simple", "captura stderr", "exit code no cero", "comando multilinea"]
test_file_path: "functions/infra/ssh_exec_test.go"
file_path: "functions/infra/ssh_exec.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
stdout, stderr, code, err := SSHExec(conn, "df -h /")
if err != nil {
log.Fatal(err)
}
fmt.Printf("exit=%d\nstdout:\n%s\nstderr:\n%s\n", code, stdout, stderr)
```
## Notas
Retorna stdout y stderr como strings separados mas el exit code. Si el comando remoto falla (exit code != 0), NO retorna error — el exit code indica el fallo. Solo retorna error si ssh no pudo conectar o ejecutar. Usa BatchMode=yes para evitar prompts interactivos.
+60
View File
@@ -0,0 +1,60 @@
package infra
import (
"strings"
"testing"
)
func TestSSHExec(t *testing.T) {
conn := skipIfNoSSH(t)
t.Run("echo simple", func(t *testing.T) {
stdout, stderr, code, err := SSHExec(conn, "echo hello")
if err != nil {
t.Fatalf("SSHExec error: %v", err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0; stderr: %s", code, stderr)
}
if strings.TrimSpace(stdout) != "hello" {
t.Errorf("stdout = %q, want %q", strings.TrimSpace(stdout), "hello")
}
})
t.Run("captura stderr", func(t *testing.T) {
_, stderr, code, err := SSHExec(conn, "echo error >&2")
if err != nil {
t.Fatalf("SSHExec error: %v", err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
if !strings.Contains(stderr, "error") {
t.Errorf("stderr = %q, want contener 'error'", stderr)
}
})
t.Run("exit code no cero", func(t *testing.T) {
_, _, code, err := SSHExec(conn, "exit 42")
if err != nil {
t.Fatalf("SSHExec error: %v", err)
}
if code != 42 {
t.Errorf("exit code = %d, want 42", code)
}
})
t.Run("comando multilinea", func(t *testing.T) {
stdout, _, code, err := SSHExec(conn, "hostname && uname -s")
if err != nil {
t.Fatalf("SSHExec error: %v", err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) < 2 {
t.Errorf("esperaba al menos 2 lineas, got %d: %q", len(lines), stdout)
}
})
}
+60
View File
@@ -0,0 +1,60 @@
package infra
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSSHUploadDownload(t *testing.T) {
conn := skipIfNoSSH(t)
t.Run("upload y download roundtrip", func(t *testing.T) {
// Crear archivo temporal local
tmpDir := t.TempDir()
localFile := filepath.Join(tmpDir, "test_upload.txt")
content := "fn_registry ssh test: upload roundtrip"
if err := os.WriteFile(localFile, []byte(content), 0644); err != nil {
t.Fatal(err)
}
remotePath := "/tmp/fn_registry_ssh_test.txt"
// Upload
err := SSHUpload(conn, localFile, remotePath)
if err != nil {
t.Fatalf("SSHUpload: %v", err)
}
// Verificar que existe en remoto
stdout, _, code, err := SSHExec(conn, "cat "+remotePath)
if err != nil {
t.Fatalf("SSHExec cat: %v", err)
}
if code != 0 {
t.Fatalf("cat remoto fallo con code %d", code)
}
if strings.TrimSpace(stdout) != content {
t.Errorf("contenido remoto = %q, want %q", strings.TrimSpace(stdout), content)
}
// Download
downloadFile := filepath.Join(tmpDir, "test_download.txt")
err = SSHDownload(conn, remotePath, downloadFile)
if err != nil {
t.Fatalf("SSHDownload: %v", err)
}
got, err := os.ReadFile(downloadFile)
if err != nil {
t.Fatal(err)
}
if string(got) != content {
t.Errorf("downloaded = %q, want %q", string(got), content)
}
// Limpiar remoto
SSHExec(conn, "rm -f "+remotePath)
})
}
+21
View File
@@ -0,0 +1,21 @@
package infra
import (
"fmt"
"os"
"syscall"
)
// SSHTunnelClose cierra un tunel SSH enviando SIGTERM al proceso por PID.
func SSHTunnelClose(pid int) error {
proc, err := os.FindProcess(pid)
if err != nil {
return fmt.Errorf("ssh tunnel close: process %d not found: %w", pid, err)
}
err = proc.Signal(syscall.SIGTERM)
if err != nil {
return fmt.Errorf("ssh tunnel close: cannot signal PID %d: %w", pid, err)
}
return nil
}
+35
View File
@@ -0,0 +1,35 @@
---
name: ssh_tunnel_close
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SSHTunnelClose(pid int) error"
description: "Cierra un tunel SSH enviando SIGTERM al proceso por PID."
tags: [ssh, tunnel, close, remote]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os, syscall]
tested: true
tests: ["abre tunel y lo cierra"]
test_file_path: "functions/infra/ssh_tunnel_test.go"
file_path: "functions/infra/ssh_tunnel_close.go"
---
## Ejemplo
```go
// Abrir tunel
pid, _ := SSHTunnelOpen(conn, 5432, "db-server", 5432)
// ... usar el tunel ...
// Cerrar
err := SSHTunnelClose(pid)
```
## Notas
Envia SIGTERM (cierre limpio) al proceso ssh. El PID viene de SSHTunnelOpen. Si el proceso ya termino, retorna error.
+46
View File
@@ -0,0 +1,46 @@
package infra
import (
"fmt"
"os/exec"
"strings"
"time"
)
// SSHTunnelOpen abre un tunel SSH (local port forwarding) en background.
// Mapea localPort en la maquina local a remoteHost:remotePort a traves del servidor SSH.
// Retorna el PID del proceso ssh para cerrarlo despues con SSHTunnelClose.
func SSHTunnelOpen(conn SSHConn, localPort int, remoteHost string, remotePort int) (int, error) {
if remoteHost == "" {
remoteHost = "localhost"
}
forward := fmt.Sprintf("%d:%s:%d", localPort, remoteHost, remotePort)
args := conn.sshArgs()
args = append(args, "-o", "BatchMode=yes")
args = append(args, "-o", "ExitOnForwardFailure=yes")
args = append(args, "-N", "-f", "-L", forward, conn.destination())
cmd := exec.Command("ssh", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return 0, fmt.Errorf("ssh tunnel %s: %s", forward, strings.TrimSpace(string(out)))
}
// ssh -f se detach, buscar el PID del proceso
time.Sleep(200 * time.Millisecond)
pidCmd := exec.Command("sh", "-c",
fmt.Sprintf("ps aux | grep '[s]sh.*-L %s' | awk '{print $2}' | head -1", forward))
pidOut, err := pidCmd.Output()
if err != nil || strings.TrimSpace(string(pidOut)) == "" {
return 0, fmt.Errorf("ssh tunnel started but could not find PID")
}
var pid int
_, err = fmt.Sscanf(strings.TrimSpace(string(pidOut)), "%d", &pid)
if err != nil {
return 0, fmt.Errorf("ssh tunnel: invalid PID %q", strings.TrimSpace(string(pidOut)))
}
return pid, nil
}
+39
View File
@@ -0,0 +1,39 @@
---
name: ssh_tunnel_open
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SSHTunnelOpen(conn SSHConn, localPort int, remoteHost string, remotePort int) (int, error)"
description: "Abre un tunel SSH (local port forwarding) en background. Retorna el PID del proceso para cerrarlo despues."
tags: [ssh, tunnel, port-forwarding, remote]
uses_functions: []
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings, time]
tested: true
tests: ["abre tunel y lo cierra"]
test_file_path: "functions/infra/ssh_tunnel_test.go"
file_path: "functions/infra/ssh_tunnel_open.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "bastion.example.com", User: "deploy"}
// Tunel: localhost:5432 -> db-server:5432 via bastion
pid, err := SSHTunnelOpen(conn, 5432, "db-server", 5432)
if err != nil {
log.Fatal(err)
}
fmt.Println("tunnel PID:", pid)
// Usar localhost:5432 para conectar a la BD remota
// Cerrar con SSHTunnelClose(pid)
```
## Notas
Usa ssh -N -f -L para crear el tunel en background. ExitOnForwardFailure=yes falla inmediatamente si el puerto local esta ocupado. remoteHost vacio se interpreta como "localhost" (el servidor SSH mismo). El PID se obtiene buscando el proceso ssh en la tabla de procesos.
+49
View File
@@ -0,0 +1,49 @@
package infra
import (
"fmt"
"net"
"testing"
"time"
)
func TestSSHTunnelOpenClose(t *testing.T) {
conn := skipIfNoSSH(t)
t.Run("abre tunel y lo cierra", func(t *testing.T) {
// Usar puerto alto aleatorio para evitar conflictos
localPort := 19876
// Tunel a localhost:22 del remoto (el propio sshd)
pid, err := SSHTunnelOpen(conn, localPort, "localhost", 22)
if err != nil {
t.Fatalf("SSHTunnelOpen: %v", err)
}
if pid <= 0 {
t.Fatalf("PID invalido: %d", pid)
}
// Verificar que el puerto local esta escuchando
time.Sleep(300 * time.Millisecond)
c, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", localPort), 2*time.Second)
if err != nil {
// Limpiar antes de fallar
SSHTunnelClose(pid)
t.Fatalf("no se puede conectar al tunel en localhost:%d: %v", localPort, err)
}
c.Close()
// Cerrar tunel
err = SSHTunnelClose(pid)
if err != nil {
t.Errorf("SSHTunnelClose: %v", err)
}
// Verificar que el puerto ya no escucha
time.Sleep(300 * time.Millisecond)
c, err = net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", localPort), 1*time.Second)
if err == nil {
c.Close()
t.Error("el tunel sigue abierto despues de SSHTunnelClose")
}
})
}
+20
View File
@@ -0,0 +1,20 @@
package infra
import (
"fmt"
"os/exec"
"strings"
)
// SSHUpload sube un archivo local al host remoto via scp.
func SSHUpload(conn SSHConn, localPath, remotePath string) error {
args := conn.scpArgs()
dest := fmt.Sprintf("%s:%s", conn.destination(), remotePath)
args = append(args, localPath, dest)
out, err := exec.Command("scp", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("scp upload to %s: %s", conn.destination(), strings.TrimSpace(string(out)))
}
return nil
}
+32
View File
@@ -0,0 +1,32 @@
---
name: ssh_upload
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func SSHUpload(conn SSHConn, localPath, remotePath string) error"
description: "Sube un archivo local al host remoto via scp."
tags: [ssh, scp, upload, file, transfer, remote]
uses_functions: []
uses_types: [ssh_conn_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings]
tested: true
tests: ["upload y download roundtrip"]
test_file_path: "functions/infra/ssh_transfer_test.go"
file_path: "functions/infra/ssh_upload.go"
---
## Ejemplo
```go
conn := SSHConn{Host: "192.168.1.100", User: "deploy", KeyPath: "~/.ssh/id_ed25519"}
err := SSHUpload(conn, "./config.yaml", "/home/deploy/app/config.yaml")
```
## Notas
Usa scp con -P para el puerto (distinto a ssh que usa -p). Para subir directorios, usar SSHExec con tar/rsync como alternativa.
@@ -0,0 +1,55 @@
---
name: ops_to_rdf_triples
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def ops_to_rdf_triples(db_path: str, namespace: str = 'http://osint.local/') -> list[tuple[str, str, str]]"
description: "Convierte entities y relations de operations.db a triples RDF (subject, predicate, object). Prefija IDs con namespace para formar URIs. Solo stdlib."
tags: [rdf, graph, osint, knowledge-graph, triples, operations, semantic-web]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [json, sqlite3]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/ops_to_rdf_triples.py"
---
## Ejemplo
```python
from datascience.ops_to_rdf_triples import ops_to_rdf_triples
triples = ops_to_rdf_triples("apps/my_analysis/operations.db")
for s, p, o in triples[:5]:
print(f"{s} -- {p} --> {o}")
# Con namespace personalizado
triples_ns = ops_to_rdf_triples(
"apps/my_analysis/operations.db",
namespace="http://mi-empresa.com/osint/"
)
```
## Notas
Funcion pura — solo abre la DB en lectura, no escribe nada.
Triples generados por entidad:
- `(ns+id, rdf:type, type_ref)` — si type_ref no es None
- `(ns+id, name, literal)` — si name no es None
- `(ns+id, status, literal)` — si status no es None
- `(ns+id, domain, literal)` — si domain no es None
- `(ns+id, key, str(value))` — por cada clave en el JSON de metadata
Triples generados por relacion:
- `(ns+from_entity, relation_name, ns+to_entity)`
Los subjects de relaciones tipo URI reciben el prefijo de namespace. Los predicados literales (name, status, etc.) no llevan prefijo. Esta separacion sigue la convencion RDF de distinguir recursos de literales sin introducir dependencias externas (rdflib u otras).
Para exportar a Turtle (.ttl) o N-Triples, el notebook puede iterar la lista y formatear segun necesite.
@@ -0,0 +1,82 @@
"""Convierte operations.db a triples RDF (subject, predicate, object)."""
import json
import sqlite3
def ops_to_rdf_triples(
db_path: str,
namespace: str = "http://osint.local/",
) -> list[tuple[str, str, str]]:
"""Convierte entities y relations de operations.db a triples RDF.
Genera triples para:
- Tipo de entidad: (entity_uri, rdf:type, type_ref)
- Nombre: (entity_uri, name, literal)
- Status y domain: (entity_uri, status|domain, literal)
- Cada clave de metadata: (entity_uri, key, str(value))
- Relaciones: (from_entity_uri, relation_name, to_entity_uri)
Los IDs de entidades se prefijarlos con el namespace para formar URIs.
Args:
db_path: Ruta al archivo operations.db.
namespace: Prefijo de namespace para construir URIs. Default: "http://osint.local/".
Returns:
Lista de tuplas (subject, predicate, object) representando los triples RDF.
"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
cur = conn.cursor()
cur.execute(
"SELECT id, name, type_ref, status, domain, metadata FROM entities"
)
raw_entities = [dict(row) for row in cur.fetchall()]
cur.execute(
"SELECT id, name, from_entity, to_entity FROM relations"
)
raw_relations = [dict(row) for row in cur.fetchall()]
finally:
conn.close()
ns = namespace.rstrip("/") + "/"
triples: list[tuple[str, str, str]] = []
for entity in raw_entities:
subject = ns + entity["id"]
# rdf:type
if entity["type_ref"]:
triples.append((subject, "rdf:type", entity["type_ref"]))
# name
if entity["name"]:
triples.append((subject, "name", entity["name"]))
# status
if entity["status"]:
triples.append((subject, "status", entity["status"]))
# domain
if entity["domain"]:
triples.append((subject, "domain", entity["domain"]))
# metadata keys
try:
meta = json.loads(entity["metadata"]) if entity["metadata"] else {}
except (json.JSONDecodeError, TypeError):
meta = {}
for key, value in meta.items():
triples.append((subject, key, str(value)))
for rel in raw_relations:
from_uri = ns + rel["from_entity"]
to_uri = ns + rel["to_entity"]
predicate = rel["name"] or rel["id"]
triples.append((from_uri, predicate, to_uri))
return triples
@@ -0,0 +1,44 @@
---
name: ops_to_sigma_json
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def ops_to_sigma_json(db_path: str) -> dict"
description: "Convierte operations.db al formato JSON de sigma.js/graphology. Lee entities y relations, asigna colores por tipo y calcula tamanio de nodo combinando degree y risk_score."
tags: [graph, sigma, osint, visualization, operations, network]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [json, sqlite3]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/ops_to_sigma_json.py"
---
## Ejemplo
```python
from datascience.ops_to_sigma_json import ops_to_sigma_json
graph = ops_to_sigma_json("apps/my_analysis/operations.db")
print(len(graph["nodes"])) # numero de entidades
print(len(graph["edges"])) # numero de relaciones
```
## Notas
Funcion pura en el sentido de que no escribe ningun archivo ni tiene efectos secundarios observables mas alla de abrir y cerrar la conexion SQLite en modo lectura.
COLOR_MAP cubre los tipos de entidad OSINT mas comunes. Tipos desconocidos reciben `#95a5a6` (gris).
El tamanio del nodo (`size`) se calcula en el rango [5, 20]:
- Si la entidad tiene `risk_score` en metadata: `(degree_norm + risk_norm) / 2`
- Si no: `degree_norm` puro
- degree_norm = `min(degree / 50, 1.0)`
La metadata se aplana como atributos adicionales del nodo, sin sobrescribir campos reservados (`label`, `type`, `color`, `size`, `domain`, `status`).
@@ -0,0 +1,123 @@
"""Convierte operations.db al formato JSON de sigma.js para visualizacion de grafos."""
import json
import sqlite3
COLOR_MAP = {
"person": "#e74c3c",
"organization": "#3498db",
"ip_address": "#2ecc71",
"domain": "#f39c12",
"crypto_wallet": "#f1c40f",
"trading_signal": "#9b59b6",
"vulnerability": "#e67e22",
"malware": "#c0392b",
"email": "#1abc9c",
}
DEFAULT_COLOR = "#95a5a6"
SIZE_MIN = 5.0
SIZE_MAX = 20.0
def _calculate_degree(entity_id: str, relations: list[dict]) -> int:
"""Cuenta cuantas relaciones involucran a esta entidad."""
return sum(
1 for r in relations
if r["from_entity"] == entity_id or r["to_entity"] == entity_id
)
def _calculate_size(entity_id: str, metadata: dict, relations: list[dict]) -> float:
"""Calcula el tamanio del nodo basado en degree y risk_score opcional."""
degree = _calculate_degree(entity_id, relations)
# Normalizar degree a rango [0, 1] asumiendo maximo razonable de 50
degree_norm = min(degree / 50.0, 1.0)
if "risk_score" in metadata:
try:
risk_norm = float(metadata["risk_score"]) / 100.0
risk_norm = max(0.0, min(risk_norm, 1.0))
score = (degree_norm + risk_norm) / 2.0
except (ValueError, TypeError):
score = degree_norm
else:
score = degree_norm
return SIZE_MIN + score * (SIZE_MAX - SIZE_MIN)
def ops_to_sigma_json(db_path: str) -> dict:
"""Convierte operations.db al formato JSON esperado por sigma.js.
Lee entities y relations de la base de datos de operaciones y construye
el dict con nodos y aristas en el formato de graphology/sigma.js.
El tamanio de cada nodo se calcula a partir de su degree en el grafo
y, si esta disponible, de su risk_score en metadata (media 50/50).
Args:
db_path: Ruta al archivo operations.db.
Returns:
Dict con claves 'nodes' y 'edges' compatible con sigma.js / graphology.
"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
cur = conn.cursor()
cur.execute(
"SELECT id, name, type_ref, status, domain, metadata FROM entities"
)
raw_entities = [dict(row) for row in cur.fetchall()]
cur.execute(
"SELECT id, name, from_entity, to_entity, weight FROM relations"
)
raw_relations = [dict(row) for row in cur.fetchall()]
finally:
conn.close()
nodes = []
for entity in raw_entities:
try:
meta = json.loads(entity["metadata"]) if entity["metadata"] else {}
except (json.JSONDecodeError, TypeError):
meta = {}
type_ref = entity["type_ref"] or "unknown"
color = COLOR_MAP.get(type_ref, DEFAULT_COLOR)
size = _calculate_size(entity["id"], meta, raw_relations)
attributes = {
"label": entity["name"] or entity["id"],
"entity_type": type_ref,
"color": color,
"size": size,
"domain": entity["domain"] or "",
"status": entity["status"] or "",
}
# Aplana metadata como atributos adicionales (sin sobrescribir campos reservados)
reserved = {"label", "entity_type", "color", "size", "domain", "status", "type", "x", "y", "hidden", "zIndex"}
for k, v in meta.items():
if k not in reserved:
attributes[k] = v
nodes.append({"key": entity["id"], "attributes": attributes})
edges = []
for rel in raw_relations:
edges.append({
"key": rel["id"],
"source": rel["from_entity"],
"target": rel["to_entity"],
"attributes": {
"label": rel["name"] or "",
"weight": rel["weight"] if rel["weight"] is not None else 1.0,
"type": "arrow",
},
})
return {"nodes": nodes, "edges": edges}
@@ -0,0 +1,57 @@
---
name: render_sigma_html
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def render_sigma_html(graph_data: dict, output_path: str, title: str = 'OSINT Graph') -> str"
description: "Genera un archivo HTML standalone con sigma.js v2.4 que visualiza un grafo OSINT. Aplica ForceAtlas2, dark theme, filtros por tipo de nodo y tooltip con metadata. Retorna el path absoluto del archivo escrito."
tags: [graph, sigma, osint, visualization, html, forceatlas2, network, dark-theme]
uses_functions: [ops_to_sigma_json_py_datascience]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, os]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/render_sigma_html.py"
---
## Ejemplo
```python
from datascience.ops_to_sigma_json import ops_to_sigma_json
from datascience.render_sigma_html import render_sigma_html
graph = ops_to_sigma_json("apps/osint_analysis/operations.db")
path = render_sigma_html(graph, "/tmp/osint_graph.html", title="Red OSINT")
print(f"Abre en el browser: {path}")
```
## Notas
Funcion impura — escribe un archivo en disco.
El HTML es completamente standalone: no necesita servidor web ni backend. Todos los assets se cargan desde jsDelivr CDN:
- graphology 0.25.4
- graphology-layout-forceatlas2 0.10.1
- sigma 2.4.0
El JSON del grafo se embebe directamente en el `<script>` para evitar peticiones CORS al abrir desde `file://`.
ForceAtlas2 se ejecuta sincrono con 500 iteraciones antes de instanciar el renderer. Para grafos grandes (>500 nodos) considerar reducir iteraciones o usar `barnesHutOptimize: true` (que se activa automaticamente para grafos >300 nodos).
Panel lateral (fijo, top-right):
- Titulo del grafo
- Contador de nodos y aristas
- Checkboxes de filtro por tipo (con color dot)
Tooltip (hover sobre nodo):
- Label y tipo del nodo
- status, domain
- Todos los campos adicionales de metadata aplanados
Los caracteres especiales en labels y metadata se escapan correctamente para evitar XSS al abrir el HTML.
@@ -0,0 +1,234 @@
"""Renderiza un grafo sigma.js como HTML standalone con dark theme y layout ForceAtlas2."""
import json
import os
_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<script src="https://cdn.jsdelivr.net/npm/graphology@0.25.4/dist/graphology.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/graphology-library@0.8.0/dist/graphology-library.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sigma@2.4.0/build/sigma.min.js"></script>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ background: #1a1a2e; color: #eee; font-family: 'Segoe UI', system-ui, sans-serif; overflow: hidden; }}
#container {{ width: 100vw; height: 100vh; }}
#panel {{
position: absolute; top: 12px; right: 12px;
background: rgba(10, 10, 30, 0.88);
border: 1px solid rgba(255,255,255,0.12);
padding: 16px; border-radius: 10px;
z-index: 10; min-width: 200px; max-width: 260px;
backdrop-filter: blur(6px);
}}
#panel h3 {{ font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #a0c4ff; letter-spacing: 0.5px; }}
#stats {{ font-size: 11px; color: #888; margin-bottom: 12px; }}
#filters {{ display: flex; flex-direction: column; gap: 6px; }}
.filter-item {{ display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; }}
.filter-item input {{ cursor: pointer; accent-color: #a0c4ff; }}
.color-dot {{ width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }}
#tooltip {{
position: absolute; display: none;
background: rgba(5, 5, 20, 0.95);
border: 1px solid rgba(255,255,255,0.15);
padding: 10px 14px; border-radius: 8px;
pointer-events: none; z-index: 20;
max-width: 300px; font-size: 12px; line-height: 1.6;
}}
#tooltip .tt-title {{ font-weight: 600; color: #a0c4ff; margin-bottom: 6px; font-size: 13px; }}
#tooltip .tt-row {{ display: flex; gap: 6px; }}
#tooltip .tt-key {{ color: #888; min-width: 80px; }}
#tooltip .tt-val {{ color: #eee; word-break: break-all; }}
</style>
</head>
<body>
<div id="container"></div>
<div id="panel">
<h3>{title}</h3>
<div id="stats"></div>
<div id="filters"></div>
</div>
<div id="tooltip"></div>
<script>
(function () {{
const graphData = {json_data};
// ── Build graphology graph ──────────────────────────────────────────────
const Graph = graphology.Graph || graphology;
const g = new Graph({{ multi: true, type: 'directed' }});
// Assign random initial positions
graphData.nodes.forEach(function (n) {{
g.addNode(n.key, Object.assign({{
x: (Math.random() - 0.5) * 10,
y: (Math.random() - 0.5) * 10,
}}, n.attributes));
}});
graphData.edges.forEach(function (e) {{
try {{
g.addEdgeWithKey(e.key, e.source, e.target, e.attributes || {{}});
}} catch (err) {{
// skip duplicate edge keys gracefully
}}
}});
// ── ForceAtlas2 layout (synchronous, 500 iterations) ───────────────────
const FA2 = graphologyLibrary.layoutForceAtlas2;
FA2.assign(g, {{
iterations: 500,
settings: {{
gravity: 1,
scalingRatio: 2,
slowDown: 5,
barnesHutOptimize: g.order > 300,
}},
}});
// ── Sigma renderer ──────────────────────────────────────────────────────
const renderer = new Sigma(g, document.getElementById('container'), {{
renderEdgeLabels: false,
defaultEdgeColor: '#444',
defaultNodeColor: '#95a5a6',
labelColor: {{ color: '#ccc' }},
labelSize: 11,
edgeReducer: function (edge, data) {{
return Object.assign({{}}, data, {{ size: Math.max(1, (data.weight || 1) * 0.8) }});
}},
}});
// ── Stats panel ─────────────────────────────────────────────────────────
document.getElementById('stats').textContent =
graphData.nodes.length + ' nodes · ' + graphData.edges.length + ' edges';
// ── Filter panel by node type ───────────────────────────────────────────
const typeColors = {{}};
graphData.nodes.forEach(function (n) {{
const t = n.attributes.entity_type || 'unknown';
typeColors[t] = n.attributes.color || '#95a5a6';
}});
const hiddenTypes = new Set();
const filtersDiv = document.getElementById('filters');
Object.keys(typeColors).sort().forEach(function (type) {{
const color = typeColors[type];
const label = document.createElement('label');
label.className = 'filter-item';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = true;
cb.addEventListener('change', function () {{
if (cb.checked) hiddenTypes.delete(type);
else hiddenTypes.add(type);
renderer.refresh();
}});
const dot = document.createElement('span');
dot.className = 'color-dot';
dot.style.background = color;
label.appendChild(cb);
label.appendChild(dot);
label.appendChild(document.createTextNode(type));
filtersDiv.appendChild(label);
}});
// Node reducer applies type filter
renderer.setSetting('nodeReducer', function (node, data) {{
if (hiddenTypes.has(data.entity_type)) return Object.assign({{}}, data, {{ hidden: true }});
return data;
}});
// ── Tooltip on hover ────────────────────────────────────────────────────
const tooltip = document.getElementById('tooltip');
renderer.on('enterNode', function (ref) {{
const nodeAttrs = g.getNodeAttributes(ref.node);
const reserved = new Set(['x', 'y', 'size', 'color', 'label', 'type', 'hidden']);
let html = '<div class="tt-title">' + escHtml(nodeAttrs.label || ref.node) + '</div>';
html += '<div class="tt-row"><span class="tt-key">type</span><span class="tt-val">' + escHtml(nodeAttrs.entity_type || '') + '</span></div>';
html += '<div class="tt-row"><span class="tt-key">status</span><span class="tt-val">' + escHtml(nodeAttrs.status || '') + '</span></div>';
html += '<div class="tt-row"><span class="tt-key">domain</span><span class="tt-val">' + escHtml(nodeAttrs.domain || '') + '</span></div>';
Object.keys(nodeAttrs).sort().forEach(function (k) {{
if (!reserved.has(k) && !['status', 'domain', 'type', 'label'].includes(k)) {{
html += '<div class="tt-row"><span class="tt-key">' + escHtml(k) + '</span><span class="tt-val">' + escHtml(String(nodeAttrs[k])) + '</span></div>';
}}
}});
tooltip.innerHTML = html;
tooltip.style.display = 'block';
}});
renderer.on('leaveNode', function () {{
tooltip.style.display = 'none';
}});
document.getElementById('container').addEventListener('mousemove', function (e) {{
tooltip.style.left = (e.clientX + 16) + 'px';
tooltip.style.top = (e.clientY + 16) + 'px';
}});
function escHtml(str) {{
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}}
}})();
</script>
</body>
</html>
"""
def render_sigma_html(
graph_data: dict,
output_path: str,
title: str = "OSINT Graph",
) -> str:
"""Genera un HTML standalone con sigma.js que visualiza el grafo OSINT.
Recibe el dict producido por ops_to_sigma_json, embebe los datos como JSON
en el HTML, aplica ForceAtlas2 (500 iteraciones sincrono) y renderiza con
sigma.js v2.4. Incluye dark theme, panel de filtros por tipo de nodo y
tooltip con metadata al hacer hover.
Args:
graph_data: Dict con claves 'nodes' y 'edges' en formato graphology/sigma.
output_path: Ruta del archivo HTML a escribir.
title: Titulo del grafo mostrado en el panel y la pestana.
Returns:
Ruta absoluta del archivo HTML escrito.
Raises:
Exception: Si no se puede escribir el archivo en output_path.
"""
json_data = json.dumps(graph_data, ensure_ascii=False)
html = _HTML_TEMPLATE.format(
title=title,
json_data=json_data,
)
abs_path = os.path.abspath(output_path)
os.makedirs(os.path.dirname(abs_path) or ".", exist_ok=True)
try:
with open(abs_path, "w", encoding="utf-8") as f:
f.write(html)
except OSError as exc:
raise Exception(f"render_sigma_html: no se pudo escribir '{abs_path}': {exc}") from exc
return abs_path
+5
View File
@@ -0,0 +1,5 @@
"""Embedding functions — model management, encoding, and vector storage/retrieval."""
from embedding.model import embedding_save_model, embedding_load_model, embedding_encode
from embedding.sqlvec import embedding_store_sqlvec, embedding_search_sqlvec
from embedding.usearch_store import embedding_store_usearch, embedding_search_usearch
@@ -0,0 +1,40 @@
---
name: embedding_encode
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def embedding_encode(model: SentenceTransformer, texts: list, mode: str = 'document') -> list"
description: "Genera embeddings normalizados para textos. Aplica prefijos e5 automaticamente segun mode (document/query)."
tags: [embedding, encode, e5, multilingual, python]
uses_functions: [embedding_load_model_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [sentence_transformers]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/embedding/model.py"
---
## Ejemplo
```python
model = embedding_load_model(".local/models/e5-small")
# Indexar documentos
doc_embs = embedding_encode(model, ["La IA transforma la industria", "Python es versatil"], mode="document")
# Buscar
query_embs = embedding_encode(model, ["¿Que es machine learning?"], mode="query")
```
## Notas
mode="document" agrega prefijo "passage: ", mode="query" agrega "query: ".
Estos prefijos son requeridos por modelos e5 para retrieval optimo.
Los embeddings retornados son float32 normalizados (norma L2 = 1).
Para e5-small la dimension es 384. Throughput ~1900 docs/s en CPU.
@@ -0,0 +1,33 @@
---
name: embedding_load_model
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def embedding_load_model(path: str) -> SentenceTransformer"
description: "Carga modelo de embeddings desde path local. Retorna instancia lista para encode."
tags: [embedding, model, load, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [sentence_transformers]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/embedding/model.py"
---
## Ejemplo
```python
model = embedding_load_model(".local/models/e5-small")
# model listo para usar con embedding_encode
```
## Notas
Carga desde path local (~1.8s) es mas rapida que desde HF cache (~4.1s).
El modelo retornado es compatible con embedding_encode.
@@ -0,0 +1,34 @@
---
name: embedding_save_model
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def embedding_save_model(model_id: str, path: str) -> str"
description: "Descarga modelo de embeddings de HuggingFace y lo guarda en path local para carga rapida sin red."
tags: [embedding, model, save, huggingface, e5, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [sentence_transformers]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/embedding/model.py"
---
## Ejemplo
```python
path = embedding_save_model("intfloat/multilingual-e5-small", ".local/models/e5-small")
# path = "/home/lucas/fn_registry/.local/models/e5-small"
```
## Notas
El modelo se guarda en formato sentence-transformers (safetensors + tokenizer).
Para multilingual-e5-small ocupa ~465 MB en disco.
Carga local es ~2.3x mas rapida que desde HF cache.
@@ -0,0 +1,37 @@
---
name: embedding_search_sqlvec
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def embedding_search_sqlvec(db_path: str, table: str, query_embedding: list, k: int = 10) -> list"
description: "Busca los k vecinos mas cercanos en tabla sqlite-vec. Retorna rowids y distancias ordenados."
tags: [embedding, sqlite, vector, search, retrieval, sqlite-vec, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [sqlite3, sqlite_vec, numpy]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/embedding/sqlvec.py"
---
## Ejemplo
```python
model = embedding_load_model(".local/models/e5-small")
q_emb = embedding_encode(model, ["¿Que es machine learning?"], mode="query")[0]
results = embedding_search_sqlvec("vectors.db", "doc_embeddings", q_emb, k=5)
# [{"rowid": 0, "distance": 0.23}, {"rowid": 1, "distance": 0.45}, ...]
```
## Notas
Busqueda brute-force (exacta, no aproximada). Para 50k vectores tarda ~19ms/query.
El campo distance es distancia coseno (menor = mas similar) porque los embeddings estan normalizados.
Cold start rapido (~18ms) porque SQLite no carga todo el indice a RAM.
@@ -0,0 +1,37 @@
---
name: embedding_search_usearch
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def embedding_search_usearch(path: str, query_embedding: list, k: int = 10, dim: int = 384) -> list"
description: "Busca los k vecinos mas cercanos en indice USearch persistido. Busqueda sub-milisegundo."
tags: [embedding, usearch, vector, search, retrieval, ann, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [usearch, numpy]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/embedding/usearch_store.py"
---
## Ejemplo
```python
model = embedding_load_model(".local/models/e5-small")
q_emb = embedding_encode(model, ["¿Que es machine learning?"], mode="query")[0]
results = embedding_search_usearch("docs.usearch", q_emb, k=5)
# [{"key": 0, "distance": 0.82}, {"key": 1, "distance": 0.65}, ...]
```
## Notas
Carga el indice completo a RAM antes de buscar. Cold start ~190ms para 50k vectores.
Busqueda aproximada (HNSW) — puede no encontrar el vecino exacto pero es 150x mas rapido que brute-force.
Distance es inner product (mayor = mas similar, al reves que sqlite-vec).
@@ -0,0 +1,39 @@
---
name: embedding_store_sqlvec
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def embedding_store_sqlvec(db_path: str, table: str, ids: list, embeddings: list, dim: int = 384) -> int"
description: "Inserta embeddings en tabla sqlite-vec. Crea la tabla virtual si no existe. Insercion en batches."
tags: [embedding, sqlite, vector, store, sqlite-vec, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [sqlite3, sqlite_vec, numpy]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/embedding/sqlvec.py"
---
## Ejemplo
```python
model = embedding_load_model(".local/models/e5-small")
docs = ["La IA transforma la industria", "Python es versatil"]
embs = embedding_encode(model, docs, mode="document")
n = embedding_store_sqlvec("vectors.db", "doc_embeddings", [0, 1], embs)
# n = 2
```
## Notas
Usa sqlite-vec (extension pura C para SQLite). Los vectores se almacenan como blobs float32.
Compatible con cualquier SQLite — se puede usar el mismo archivo para metadata con tablas normales.
Insercion en batches de 500 para evitar limits de SQLite.
Para 50k vectores dim=384: ~75 MB en disco, busqueda ~19ms/query.
@@ -0,0 +1,39 @@
---
name: embedding_store_usearch
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def embedding_store_usearch(path: str, ids: list, embeddings: list, dim: int = 384) -> int"
description: "Crea indice USearch con embeddings y lo persiste a archivo. Busqueda sub-milisegundo."
tags: [embedding, usearch, vector, store, ann, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [usearch, numpy]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/embedding/usearch_store.py"
---
## Ejemplo
```python
model = embedding_load_model(".local/models/e5-small")
docs = ["La IA transforma la industria", "Python es versatil"]
embs = embedding_encode(model, docs, mode="document")
n = embedding_store_usearch("docs.usearch", [0, 1], embs)
# n = 2
```
## Notas
USearch usa HNSW (approximate nearest neighbors). Para 50k vectores dim=384:
~80 MB en disco, busqueda ~0.13ms/query (150x mas rapido que sqlite-vec).
El tradeoff es que no soporta metadata nativa — usar junto con SQLite para metadata.
Sobreescribe el archivo si ya existe.
+67
View File
@@ -0,0 +1,67 @@
"""Embedding model management — save, load, and encode with multilingual-e5-small."""
import os
from sentence_transformers import SentenceTransformer
def embedding_save_model(model_id: str, path: str) -> str:
"""Descarga modelo de HuggingFace y lo guarda en path local.
Args:
model_id: ID del modelo en HuggingFace (ej: "intfloat/multilingual-e5-small").
path: Directorio destino para guardar el modelo.
Returns:
Path absoluto donde se guardo el modelo.
Raises:
OSError: Si no se puede escribir en el path.
Exception: Si el modelo no existe en HuggingFace.
"""
os.makedirs(path, exist_ok=True)
model = SentenceTransformer(model_id)
model.save(path)
return os.path.abspath(path)
def embedding_load_model(path: str) -> SentenceTransformer:
"""Carga modelo de embeddings desde path local.
Args:
path: Directorio con el modelo guardado por embedding_save_model.
Returns:
Instancia de SentenceTransformer lista para encode.
Raises:
OSError: Si el path no existe o no contiene un modelo valido.
"""
return SentenceTransformer(path)
def embedding_encode(model: SentenceTransformer, texts: list, mode: str = "document") -> list:
"""Genera embeddings normalizados para una lista de textos.
Aplica automaticamente los prefijos requeridos por modelos e5:
- mode="document" -> "passage: " prefix
- mode="query" -> "query: " prefix
Args:
model: Modelo cargado con embedding_load_model.
texts: Lista de strings a codificar.
mode: "document" para indexar, "query" para buscar.
Returns:
Lista de arrays numpy float32 normalizados (dim depende del modelo).
Raises:
ValueError: Si mode no es "document" ni "query".
"""
if mode not in ("document", "query"):
raise ValueError(f"mode must be 'document' or 'query', got '{mode}'")
prefix = "passage: " if mode == "document" else "query: "
prefixed = [f"{prefix}{t}" for t in texts]
embeddings = model.encode(prefixed, normalize_embeddings=True, show_progress_bar=False)
return embeddings
+74
View File
@@ -0,0 +1,74 @@
"""Vector storage and retrieval with sqlite-vec."""
import sqlite3
import numpy as np
import sqlite_vec
def embedding_store_sqlvec(db_path: str, table: str, ids: list, embeddings: list, dim: int = 384) -> int:
"""Inserta embeddings en una tabla sqlite-vec.
Crea la tabla virtual si no existe. Inserta en batches de 500.
Args:
db_path: Path al archivo SQLite.
table: Nombre de la tabla virtual vec0.
ids: Lista de IDs (int) para cada embedding.
embeddings: Lista de arrays numpy float32.
dim: Dimension de los embeddings (default 384 para e5-small).
Returns:
Numero de embeddings insertados.
Raises:
sqlite3.Error: Si hay error de escritura en la BD.
"""
db = sqlite3.connect(db_path)
db.enable_load_extension(True)
sqlite_vec.load(db)
db.execute(f"CREATE VIRTUAL TABLE IF NOT EXISTS [{table}] USING vec0(embedding float[{dim}])")
batch_size = 500
count = 0
for i in range(0, len(ids), batch_size):
batch = [
(int(ids[j]), np.asarray(embeddings[j], dtype=np.float32).tobytes())
for j in range(i, min(i + batch_size, len(ids)))
]
db.executemany(f"INSERT INTO [{table}](rowid, embedding) VALUES (?, ?)", batch)
count += len(batch)
db.commit()
db.close()
return count
def embedding_search_sqlvec(db_path: str, table: str, query_embedding: list, k: int = 10) -> list:
"""Busca los k vecinos mas cercanos en una tabla sqlite-vec.
Args:
db_path: Path al archivo SQLite con la tabla vec0.
table: Nombre de la tabla virtual.
query_embedding: Array numpy float32 del query.
k: Numero de resultados a retornar.
Returns:
Lista de dicts con 'rowid' y 'distance' ordenados por cercania.
Raises:
sqlite3.Error: Si la tabla no existe o hay error de lectura.
"""
db = sqlite3.connect(db_path)
db.enable_load_extension(True)
sqlite_vec.load(db)
q_bytes = np.asarray(query_embedding, dtype=np.float32).tobytes()
rows = db.execute(
f"SELECT rowid, distance FROM [{table}] WHERE embedding MATCH ? ORDER BY distance LIMIT ?",
(q_bytes, k),
).fetchall()
db.close()
return [{"rowid": r[0], "distance": r[1]} for r in rows]
@@ -0,0 +1,51 @@
"""Vector storage and retrieval with USearch."""
import numpy as np
from usearch.index import Index
def embedding_store_usearch(path: str, ids: list, embeddings: list, dim: int = 384) -> int:
"""Crea o sobreescribe un indice USearch con los embeddings dados.
Args:
path: Path del archivo .usearch para persistir el indice.
ids: Lista de IDs (int) para cada embedding.
embeddings: Lista de arrays numpy float32.
dim: Dimension de los embeddings (default 384 para e5-small).
Returns:
Numero de embeddings insertados.
Raises:
OSError: Si no se puede escribir en el path.
"""
index = Index(ndim=dim, metric="ip", dtype="f32")
keys = np.array(ids, dtype=np.uint64)
vecs = np.array(embeddings, dtype=np.float32)
index.add(keys, vecs)
index.save(path)
return len(ids)
def embedding_search_usearch(path: str, query_embedding: list, k: int = 10, dim: int = 384) -> list:
"""Busca los k vecinos mas cercanos en un indice USearch persistido.
Args:
path: Path del archivo .usearch.
query_embedding: Array numpy float32 del query.
k: Numero de resultados a retornar.
dim: Dimension de los embeddings.
Returns:
Lista de dicts con 'key' y 'distance' ordenados por cercania.
Raises:
OSError: Si el archivo no existe.
"""
index = Index(ndim=dim, metric="ip", dtype="f32")
index.load(path)
q = np.asarray(query_embedding, dtype=np.float32)
results = index.search(q, k)
keys = np.atleast_1d(results.keys)
distances = np.atleast_1d(results.distances)
return [{"key": int(keys[i]), "distance": float(distances[i])} for i in range(len(keys))]
+9 -2
View File
@@ -8,6 +8,7 @@ Tres modos de ejecucion:
import asyncio
import json
from functools import partial
from typing import Any
from urllib.error import URLError
from urllib.request import Request, urlopen
@@ -106,7 +107,10 @@ async def _async_append_execute(
with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
cell_index = nb.add_code_cell(code)
result = nb.execute_cell(cell_index, kernel)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, partial(nb.execute_cell, cell_index, kernel),
)
# Let Y.js propagate changes to other clients (browser)
await asyncio.sleep(2)
@@ -138,7 +142,10 @@ async def _async_execute_cell(
await nb.wait_until_synced()
with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
result = nb.execute_cell(cell_index, kernel)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, partial(nb.execute_cell, cell_index, kernel),
)
await asyncio.sleep(2)
+22
View File
@@ -0,0 +1,22 @@
---
name: ssh_conn
lang: go
domain: infra
version: "1.0.0"
algebraic: product
definition: |
type SSHConn struct {
Host string // Hostname o IP del servidor remoto
Port int // Puerto SSH (0 usa el default 22)
User string // Usuario remoto
KeyPath string // Ruta a clave privada (vacio usa ssh-agent o default)
}
description: "Parametros de conexion SSH reutilizables. Contiene host, puerto, usuario y ruta a clave privada."
tags: [ssh, connection, remote, infra]
uses_types: []
file_path: "functions/infra/ssh_conn.go"
---
## Notas
Tipo producto — todos los campos siempre presentes. Port=0 se interpreta como puerto 22 por defecto. KeyPath vacio delega la autenticacion a ssh-agent o la clave default (~/.ssh/id_rsa). Incluye metodos helper (sshArgs, scpArgs, destination) que las funciones SSH del registry consumen internamente.