merge: quick/ssh-pass-stubs-embedding-datascience — SSH, pass, stubs, embedding, datascience, jupyter fix
This commit is contained in:
@@ -47,3 +47,6 @@ sources/*/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Archivos locales
|
||||
.local
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 ']'
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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')"
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !noclickhouse
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !noduckdb
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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) }
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}}
|
||||
}})();
|
||||
</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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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))]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user