Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2cbbdf600 | |||
| b717337b7b | |||
| 5b375cb822 | |||
| ee4e86ee2e | |||
| 5f8b71b528 | |||
| 1e2582b068 | |||
| ae33d02e75 | |||
| a06946e410 | |||
| 6f6bc714a9 | |||
| 54e62ecb91 | |||
| 1a3e77b0d5 | |||
| 8bc721d53b | |||
| 6d73e1b4be | |||
| f2753e6fff | |||
| 773bb3a523 | |||
| ae1c69eee0 | |||
| e76a5e5ab1 | |||
| 94efefc7bf | |||
| 8f45b40528 | |||
| ac9965220d | |||
| 1344e557e5 | |||
| 2721b9cc8f | |||
| d9414e4cba | |||
| 7aa7790931 | |||
| c3dfc9315f | |||
| cb96e85b69 |
@@ -17,3 +17,5 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
|||||||
| 11 | [sources.md](sources.md) | Extraccion de funciones desde repos externos |
|
| 11 | [sources.md](sources.md) | Extraccion de funciones desde repos externos |
|
||||||
| 12 | [notebook_collaboration.md](notebook_collaboration.md) | Colaboración en notebooks Jupyter via funciones del registry |
|
| 12 | [notebook_collaboration.md](notebook_collaboration.md) | Colaboración en notebooks Jupyter via funciones del registry |
|
||||||
| 13 | [frontend_theming.md](frontend_theming.md) | Componentes propios y sistema de temas en frontends |
|
| 13 | [frontend_theming.md](frontend_theming.md) | Componentes propios y sistema de temas en frontends |
|
||||||
|
| 14 | [deploy.md](deploy.md) | Deploy de apps a VPS remotos via SSH + systemd + rsync |
|
||||||
|
| 15 | [projects.md](projects.md) | Projects: agrupar apps, analysis y vaults bajo un tema |
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
## Deploy de apps a VPS remotos
|
||||||
|
|
||||||
|
### Arquitectura
|
||||||
|
|
||||||
|
El sistema de deploy usa SSH + systemd + rsync. No Docker, no Kubernetes.
|
||||||
|
|
||||||
|
- **Conexiones SSH** → `~/.ssh/config` (alias, IP, user, key). Ya hay funciones CRUD: `ssh_config_read`, `ssh_config_find`, `ssh_config_parse`.
|
||||||
|
- **Config de deploy** → `apps/deploy_server/operations.db` tabla `deploy_targets` (app, host, remote_dir, build_cmd, port, health_path, env).
|
||||||
|
- **Logs de deploy** → misma BD, tabla `deploy_logs` (app, host, status, trigger, duration_ms, error).
|
||||||
|
|
||||||
|
### App: `deploy_server` (`apps/deploy_server/`)
|
||||||
|
|
||||||
|
CLI + servidor HTTP. Binario: `deploy_server`. Build: `CGO_ENABLED=1 go build -o deploy_server .`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/deploy_server
|
||||||
|
|
||||||
|
# Gestionar targets
|
||||||
|
./deploy_server target add --app <app> --host <ssh_alias> --port <N> --health /path --build "comando" [--user deploy] [--env '{"K":"V"}']
|
||||||
|
./deploy_server target list
|
||||||
|
./deploy_server target remove <app>
|
||||||
|
|
||||||
|
# Setup inicial (primera vez, crea dirs + systemd unit)
|
||||||
|
./deploy_server setup <app> --host <ssh_alias>
|
||||||
|
|
||||||
|
# Deploy continuo (build local → rsync → restart → health check)
|
||||||
|
./deploy_server deploy <app> [--host <ssh_alias>]
|
||||||
|
|
||||||
|
# Estado del servicio remoto
|
||||||
|
./deploy_server status <app>
|
||||||
|
./deploy_server status --all
|
||||||
|
|
||||||
|
# Servidor webhook (auto-deploy en cada push a Gitea)
|
||||||
|
./deploy_server serve --port 9090
|
||||||
|
```
|
||||||
|
|
||||||
|
### Funciones del registry involucradas
|
||||||
|
|
||||||
|
| Función | Qué hace | Purity |
|
||||||
|
|---|---|---|
|
||||||
|
| `rsync_deploy_bash_infra` | rsync local→remoto con exclusiones | impure |
|
||||||
|
| `systemd_generate_unit_go_infra` | Genera texto .service | **pure** |
|
||||||
|
| `systemd_install_go_infra` | Sube unit + daemon-reload + enable + start | impure |
|
||||||
|
| `systemd_restart_go_infra` | Reinicia servicio remoto | impure |
|
||||||
|
| `systemd_status_go_infra` | Estado + logs de servicio remoto | impure |
|
||||||
|
| `vps_setup_app_go_infra` | Crea dirs + usuario en VPS | impure |
|
||||||
|
| `gitea_create_webhook_bash_infra` | Crea webhook push en Gitea | impure |
|
||||||
|
| `setup_vps_app_go_infra` | Pipeline: setup completo primera vez | impure |
|
||||||
|
| `deploy_app_remote_go_infra` | Pipeline: deploy continuo | impure |
|
||||||
|
|
||||||
|
Tipo: `DeployConfig_go_infra` — struct con toda la config de deploy.
|
||||||
|
|
||||||
|
### Workflow para un agente
|
||||||
|
|
||||||
|
Cuando el usuario diga **"sube esta app a este VPS"** o **"deploya X en Y"**:
|
||||||
|
|
||||||
|
#### 1. Verificar que el host SSH existe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "^Host " ~/.ssh/config
|
||||||
|
# Si no existe el alias, añadirlo:
|
||||||
|
# Usar ssh_config_add_entry o editar ~/.ssh/config directamente
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Verificar conectividad
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=5 <alias> true
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Registrar el target en deploy_server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/deploy_server
|
||||||
|
# Build deploy_server si no existe el binario
|
||||||
|
CGO_ENABLED=1 go build -o deploy_server .
|
||||||
|
|
||||||
|
./deploy_server target add \
|
||||||
|
--app <nombre_app> \
|
||||||
|
--host <ssh_alias> \
|
||||||
|
--port <puerto> \
|
||||||
|
--health <path_o_vacio> \
|
||||||
|
--build "CGO_ENABLED=0 GOOS=linux go build -o <binario> ." \
|
||||||
|
--user deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Setup inicial
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy_server setup <app> --host <ssh_alias>
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto crea dirs en `/opt/apps/<app>/`, sube el código, genera el unit systemd e instala el servicio.
|
||||||
|
|
||||||
|
#### 5. Deploys posteriores
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy_server deploy <app>
|
||||||
|
```
|
||||||
|
|
||||||
|
Build local → rsync → restart systemd → health check.
|
||||||
|
|
||||||
|
#### 6. Auto-deploy con webhook (opcional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lanzar servidor
|
||||||
|
./deploy_server serve --port 9090
|
||||||
|
|
||||||
|
# Crear webhook en Gitea
|
||||||
|
source bash/functions/infra/gitea_create_webhook.sh
|
||||||
|
gitea_create_webhook "<owner>" "<repo>" "http://<ip_deploy_server>:9090/webhook/push" "<secret>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requisitos en el VPS
|
||||||
|
|
||||||
|
- SSH accesible con key auth (configurado en `~/.ssh/config` local)
|
||||||
|
- El usuario SSH debe tener **sudo sin password** para: `systemctl`, `mv` a `/etc/systemd/system/`, `mkdir` en `/opt/apps/`, `useradd`, `chown`
|
||||||
|
- `rsync` instalado en el VPS
|
||||||
|
- Puerto del servicio abierto en el firewall del VPS
|
||||||
|
|
||||||
|
### Builds por lenguaje
|
||||||
|
|
||||||
|
| Lenguaje | Build command típico |
|
||||||
|
|---|---|
|
||||||
|
| Go | `CGO_ENABLED=0 GOOS=linux go build -o <nombre> .` |
|
||||||
|
| Go + SQLite | `CGO_ENABLED=1 GOOS=linux go build -tags fts5 -o <nombre> .` |
|
||||||
|
| Python | No build — rsync sube los .py, systemd ejecuta `python3 main.py` |
|
||||||
|
| Bash | No build — rsync sube los .sh, systemd ejecuta `bash main.sh` |
|
||||||
|
|
||||||
|
Para Go con CGO (SQLite), el VPS debe tener `gcc` y `libc-dev`, o cross-compilar con `CGO_ENABLED=0` si la app no usa SQLite.
|
||||||
|
|
||||||
|
### Exclusiones de rsync
|
||||||
|
|
||||||
|
El deploy excluye automáticamente: `.git`, `operations.db*`, `*.exe`, `node_modules`, `.venv`, `__pycache__`, `build/`, `*.db-shm`, `*.db-wal`, `registry.db`.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
## Projects: apps, analysis y vaults bajo un tema comun
|
||||||
|
|
||||||
|
Un project agrupa apps, analyses y vaults relacionados. Vive en `projects/{nombre}/` con esta estructura:
|
||||||
|
|
||||||
|
```
|
||||||
|
projects/{nombre}/
|
||||||
|
project.md # Frontmatter obligatorio (name, description, tags)
|
||||||
|
apps/ # Apps del proyecto (cada una con app.md)
|
||||||
|
{app_name}/
|
||||||
|
app.md
|
||||||
|
...
|
||||||
|
analysis/ # Analyses del proyecto (cada uno con analysis.md)
|
||||||
|
{analysis_name}/
|
||||||
|
analysis.md
|
||||||
|
.venv/
|
||||||
|
notebooks/
|
||||||
|
run-jupyter-lab.sh
|
||||||
|
...
|
||||||
|
vaults/ # Datos del proyecto
|
||||||
|
vault.yaml # Manifest de vaults (nombre, descripcion, path, tags)
|
||||||
|
{vault_name} -> /abs/path # Symlinks a directorios reales de datos
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reglas
|
||||||
|
|
||||||
|
- `project.md` sigue el template de `docs/templates/project.md` — campos: `name`, `description`, `tags`, `repo_url`
|
||||||
|
- `analysis.md` sigue el template de `docs/templates/analysis.md` — `dir_path` debe apuntar a `projects/{nombre}/analysis/{tema}/`
|
||||||
|
- `vault.yaml` lista los vaults con nombre, descripcion, path absoluto y tags
|
||||||
|
- Los vaults reales viven fuera del repo (ej: `~/vaults/{nombre}/`) con symlinks en el proyecto
|
||||||
|
- `fn index` escanea `projects/*/` y setea `project_id` automaticamente en apps, analyses y vaults
|
||||||
|
- Apps y analyses sueltos (sin proyecto) siguen en `apps/` y `analysis/` en la raiz
|
||||||
|
|
||||||
|
### Raiz vs proyecto
|
||||||
|
|
||||||
|
| Ubicacion | Para que |
|
||||||
|
|-----------|---------|
|
||||||
|
| `apps/` | Apps independientes que no pertenecen a ningun proyecto |
|
||||||
|
| `analysis/` | Analyses independientes |
|
||||||
|
| `projects/{nombre}/apps/` | Apps de un proyecto — `project_id` se setea automaticamente |
|
||||||
|
| `projects/{nombre}/analysis/` | Analyses de un proyecto — `project_id` se setea automaticamente |
|
||||||
|
|
||||||
|
### Crear un proyecto nuevo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Crear estructura
|
||||||
|
mkdir -p projects/{nombre}/{apps,analysis,vaults}
|
||||||
|
|
||||||
|
# 2. Crear project.md con frontmatter
|
||||||
|
fn add -k project # genera template
|
||||||
|
|
||||||
|
# 3. Crear vault (datos fuera del repo, symlink dentro)
|
||||||
|
mkdir -p ~/vaults/{vault_name}/{raw,processed,exports}
|
||||||
|
ln -s ~/vaults/{vault_name} projects/{nombre}/vaults/{vault_name}
|
||||||
|
# Crear vault.yaml con la entrada
|
||||||
|
|
||||||
|
# 4. Crear analysis dentro del proyecto
|
||||||
|
fn run init_jupyter_analysis {nombre_analysis} [paquetes...]
|
||||||
|
mv analysis/{nombre_analysis} projects/{nombre}/analysis/
|
||||||
|
# Crear analysis.md con dir_path correcto
|
||||||
|
# Regenerar launcher y kernel startup:
|
||||||
|
source bash/functions/infra/write_jupyter_launcher.sh && write_jupyter_launcher projects/{nombre}/analysis/{tema}
|
||||||
|
source bash/functions/infra/write_jupyter_registry_kernel.sh && write_jupyter_registry_kernel projects/{nombre}/analysis/{tema}
|
||||||
|
|
||||||
|
# 5. Indexar
|
||||||
|
fn index
|
||||||
|
fn show {nombre} # verifica el project y sus componentes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Consultas utiles
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Listar proyectos
|
||||||
|
SELECT id, description FROM projects;
|
||||||
|
|
||||||
|
-- Analysis de un proyecto
|
||||||
|
SELECT id, name, dir_path FROM analysis WHERE project_id = 'app_turismo';
|
||||||
|
|
||||||
|
-- Vaults de un proyecto
|
||||||
|
SELECT id, name, path, symlink FROM vaults WHERE project_id = 'app_turismo';
|
||||||
|
|
||||||
|
-- Apps de un proyecto
|
||||||
|
SELECT id, name, dir_path FROM apps WHERE project_id = 'app_turismo';
|
||||||
|
|
||||||
|
-- Todo lo que pertenece a un proyecto
|
||||||
|
SELECT 'analysis' as tipo, id, name FROM analysis WHERE project_id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'vault', id, name FROM vaults WHERE project_id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'app', id, name FROM apps WHERE project_id = ?;
|
||||||
|
```
|
||||||
@@ -37,6 +37,13 @@ python/.venv/
|
|||||||
apps/*/
|
apps/*/
|
||||||
analysis/*/
|
analysis/*/
|
||||||
|
|
||||||
|
# Projects (each is its own git repo, only project.md templates are versioned)
|
||||||
|
projects/*/
|
||||||
|
|
||||||
|
# Vaults — data stores (symlinks, dirs, files); only vault.yaml manifest is versioned
|
||||||
|
vaults/*/
|
||||||
|
!vaults/vault.yaml
|
||||||
|
|
||||||
# Node / pnpm
|
# Node / pnpm
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
|
|
||||||
@@ -55,3 +62,4 @@ Thumbs.db
|
|||||||
|
|
||||||
broken_paths.txt
|
broken_paths.txt
|
||||||
imgui.ini
|
imgui.ini
|
||||||
|
prompts/
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Build output
|
||||||
|
dag_engine
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
frontend/dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterAPI sets up all HTTP routes on the given mux.
|
||||||
|
func RegisterAPI(mux *http.ServeMux, executor *Executor, scheduler *Scheduler, frontendFS fs.FS) {
|
||||||
|
// API routes.
|
||||||
|
mux.HandleFunc("GET /api/dags", handleListDags(executor))
|
||||||
|
mux.HandleFunc("GET /api/dags/{name}", handleGetDag(executor))
|
||||||
|
mux.HandleFunc("POST /api/dags/{name}/run", handleRunDag(executor))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/runs", handleListRuns(executor))
|
||||||
|
mux.HandleFunc("GET /api/runs/{id}", handleGetRun(executor))
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/scheduler/start", handleSchedulerStart(scheduler))
|
||||||
|
mux.HandleFunc("POST /api/scheduler/stop", handleSchedulerStop(scheduler))
|
||||||
|
mux.HandleFunc("GET /api/scheduler/status", handleSchedulerStatus(scheduler))
|
||||||
|
|
||||||
|
// Frontend SPA fallback.
|
||||||
|
if frontendFS != nil {
|
||||||
|
mux.Handle("/", spaHandler(frontendFS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaHandler serves static files from the embedded FS, falling back to index.html
|
||||||
|
// for unknown paths (SPA client-side routing).
|
||||||
|
func spaHandler(fsys fs.FS) http.Handler {
|
||||||
|
fileServer := http.FileServer(http.FS(fsys))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Try to serve the file directly.
|
||||||
|
path := r.URL.Path
|
||||||
|
if path == "/" {
|
||||||
|
path = "index.html"
|
||||||
|
} else {
|
||||||
|
path = path[1:] // strip leading /
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fs.Stat(fsys, path); err != nil {
|
||||||
|
// File not found — serve index.html for SPA routing.
|
||||||
|
r.URL.Path = "/"
|
||||||
|
}
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
name: dag_engine
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
description: "Motor de ejecucion de DAGs con CLI y interfaz web. Reemplaza Dagu con implementacion propia compatible con el formato YAML existente. Almacena historial de ejecuciones en SQLite."
|
||||||
|
tags: [service, dag, workflow, scheduler, web, cron]
|
||||||
|
uses_functions:
|
||||||
|
- dag_parse_go_core
|
||||||
|
- dag_validate_go_core
|
||||||
|
- dag_topo_sort_go_core
|
||||||
|
- dag_resolve_env_go_core
|
||||||
|
- parse_cron_expr_go_core
|
||||||
|
- next_cron_time_go_core
|
||||||
|
- cron_ticker_go_infra
|
||||||
|
- cron_match_go_core
|
||||||
|
- process_spawn_go_infra
|
||||||
|
- process_wait_go_infra
|
||||||
|
- process_kill_go_infra
|
||||||
|
uses_types:
|
||||||
|
- dag_definition_go_core
|
||||||
|
- dag_step_go_core
|
||||||
|
- dag_validation_result_go_core
|
||||||
|
- cron_schedule_go_core
|
||||||
|
- process_handle_go_infra
|
||||||
|
- process_result_go_infra
|
||||||
|
- DagRun_go_infra
|
||||||
|
- DagStepResult_go_infra
|
||||||
|
framework: "net/http + vite + react"
|
||||||
|
entry_point: "main.go"
|
||||||
|
dir_path: "apps/dag_engine"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
CLI + servidor web en un unico binario:
|
||||||
|
|
||||||
|
```
|
||||||
|
dag-engine run <path.yaml> # ejecuta un DAG desde terminal
|
||||||
|
dag-engine list [dir] # lista DAGs con schedule y estado
|
||||||
|
dag-engine status [dag_name] # historial de ejecuciones
|
||||||
|
dag-engine validate <path.yaml> # valida sin ejecutar
|
||||||
|
dag-engine server # arranca HTTP + frontend web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (Go)
|
||||||
|
|
||||||
|
- `net/http` con `ServeMux` (Go 1.22+ pattern routing)
|
||||||
|
- SQLite via `go-sqlite3` para historial de runs
|
||||||
|
- Executor: parse -> validate -> topo_sort -> spawn/wait por nivel -> store
|
||||||
|
- Scheduler: cron_ticker por cada DAG con schedule
|
||||||
|
|
||||||
|
### Frontend (Vite + React + Mantine)
|
||||||
|
|
||||||
|
- DagList: tabla de DAGs con schedule, tags, ultimo status
|
||||||
|
- DagDetail: metadata + "Run Now" + historial
|
||||||
|
- RunDetail: timeline de steps con stdout/stderr expandible
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
SQLite `dag_engine.db`:
|
||||||
|
- `dag_runs`: id, dag_name, status, trigger, started_at, finished_at, error
|
||||||
|
- `dag_step_results`: id, run_id, step_name, status, exit_code, stdout, stderr, duration_ms
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm install && pnpm build
|
||||||
|
cd .. && CGO_ENABLED=1 go build -tags fts5 -o dag-engine .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uso
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CLI
|
||||||
|
./dag-engine run ~/dagu/dags/example.yaml
|
||||||
|
./dag-engine list ~/dagu/dags/
|
||||||
|
|
||||||
|
# Servidor web
|
||||||
|
./dag-engine server --port 8090 --dags-dir ~/dagu/dags/ --scheduler
|
||||||
|
# Browser: http://localhost:8090
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Compatible con el formato YAML de Dagu. Lee DAGs existentes de `~/dagu/dags/` sin modificaciones.
|
||||||
|
Puerto por defecto 8090 (mismo que Dagu).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the runtime configuration for the DAG engine.
|
||||||
|
type Config struct {
|
||||||
|
Port int
|
||||||
|
DagsDir string
|
||||||
|
DBPath string
|
||||||
|
AutoScheduler bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns sensible defaults.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return Config{
|
||||||
|
Port: 8090,
|
||||||
|
DagsDir: filepath.Join(home, "dagu", "dags"),
|
||||||
|
DBPath: "dag_engine.db",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFlags populates config from CLI flags for the "server" subcommand.
|
||||||
|
func (c *Config) ParseFlags(fs *flag.FlagSet, args []string) error {
|
||||||
|
fs.IntVar(&c.Port, "port", c.Port, "HTTP server port")
|
||||||
|
fs.StringVar(&c.DagsDir, "dags-dir", c.DagsDir, "directory containing DAG YAML files")
|
||||||
|
fs.StringVar(&c.DBPath, "db", c.DBPath, "path to SQLite database")
|
||||||
|
fs.BoolVar(&c.AutoScheduler, "scheduler", c.AutoScheduler, "auto-start cron scheduler")
|
||||||
|
return fs.Parse(args)
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/core"
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
|
||||||
|
"dag-engine/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Executor orchestrates DAG parsing, validation, and execution.
|
||||||
|
type Executor struct {
|
||||||
|
store *store.DB
|
||||||
|
dagsDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExecutor creates a new executor.
|
||||||
|
func NewExecutor(s *store.DB, dagsDir string) *Executor {
|
||||||
|
return &Executor{store: s, dagsDir: dagsDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteDAG runs a DAG from a YAML file path and returns the run ID.
|
||||||
|
// It runs asynchronously: steps execute in topological order with parallel levels.
|
||||||
|
func (e *Executor) ExecuteDAG(ctx context.Context, dagPath string, trigger string) (string, error) {
|
||||||
|
data, err := os.ReadFile(dagPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read dag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse dag: %w", err)
|
||||||
|
}
|
||||||
|
dag.FilePath = dagPath
|
||||||
|
|
||||||
|
// Resolve env variables.
|
||||||
|
dag = core.DagResolveEnv(dag, os.Environ())
|
||||||
|
|
||||||
|
// Validate.
|
||||||
|
result := core.DagValidate(dag)
|
||||||
|
if !result.Valid {
|
||||||
|
return "", fmt.Errorf("validate dag: %s", strings.Join(result.Errors, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create run record.
|
||||||
|
runID := generateID()
|
||||||
|
now := time.Now()
|
||||||
|
run := &store.DagRun{
|
||||||
|
ID: runID,
|
||||||
|
DagName: dag.Name,
|
||||||
|
DagPath: dagPath,
|
||||||
|
Status: "running",
|
||||||
|
Trigger: trigger,
|
||||||
|
StartedAt: now,
|
||||||
|
}
|
||||||
|
if err := e.store.CreateRun(run); err != nil {
|
||||||
|
return "", fmt.Errorf("create run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topological sort.
|
||||||
|
levels, err := core.DagTopoSort(dag.Steps)
|
||||||
|
if err != nil {
|
||||||
|
e.failRun(runID, err)
|
||||||
|
return runID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup DAGU_ENV temp file for inter-step communication.
|
||||||
|
daguEnvFile, err := os.CreateTemp("", "dagu_env_*")
|
||||||
|
if err != nil {
|
||||||
|
e.failRun(runID, err)
|
||||||
|
return runID, err
|
||||||
|
}
|
||||||
|
daguEnvPath := daguEnvFile.Name()
|
||||||
|
daguEnvFile.Close()
|
||||||
|
defer os.Remove(daguEnvPath)
|
||||||
|
|
||||||
|
// Track step outputs for ${step_id.stdout} references.
|
||||||
|
stepOutputs := make(map[string]string)
|
||||||
|
|
||||||
|
// Execute levels.
|
||||||
|
runFailed := false
|
||||||
|
var runErr error
|
||||||
|
|
||||||
|
for _, level := range levels {
|
||||||
|
if runFailed {
|
||||||
|
// Skip remaining levels, mark steps as skipped.
|
||||||
|
for _, step := range level {
|
||||||
|
e.recordStepSkipped(runID, step)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
levelFailed := false
|
||||||
|
|
||||||
|
for _, step := range level {
|
||||||
|
step := step
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
if levelFailed {
|
||||||
|
mu.Unlock()
|
||||||
|
e.recordStepSkipped(runID, step)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
err := e.executeStep(ctx, runID, dag, step, daguEnvPath, stepOutputs, &mu)
|
||||||
|
if err != nil && !step.ContinueOn.Failure {
|
||||||
|
mu.Lock()
|
||||||
|
levelFailed = true
|
||||||
|
runFailed = true
|
||||||
|
runErr = fmt.Errorf("step %q failed: %w", stepName(step), err)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run handlers.
|
||||||
|
if runFailed {
|
||||||
|
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Failure, daguEnvPath, stepOutputs)
|
||||||
|
} else {
|
||||||
|
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Success, daguEnvPath, stepOutputs)
|
||||||
|
}
|
||||||
|
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Exit, daguEnvPath, stepOutputs)
|
||||||
|
|
||||||
|
// Finalize run.
|
||||||
|
fin := time.Now()
|
||||||
|
status := "success"
|
||||||
|
errMsg := ""
|
||||||
|
if runFailed {
|
||||||
|
status = "failed"
|
||||||
|
if runErr != nil {
|
||||||
|
errMsg = runErr.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.store.UpdateRunStatus(runID, status, &fin, errMsg)
|
||||||
|
|
||||||
|
return runID, runErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeStep runs a single step, recording results in the store.
|
||||||
|
func (e *Executor) executeStep(ctx context.Context, runID string, dag core.DagDefinition, step core.DagStep, daguEnvPath string, outputs map[string]string, mu *sync.Mutex) error {
|
||||||
|
stepID := generateID()
|
||||||
|
now := time.Now()
|
||||||
|
e.store.InsertStepResult(&store.DagStepResult{
|
||||||
|
ID: stepID,
|
||||||
|
RunID: runID,
|
||||||
|
StepName: stepName(step),
|
||||||
|
Status: "running",
|
||||||
|
StartedAt: &now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build environment.
|
||||||
|
env := buildStepEnv(dag, step, daguEnvPath, outputs)
|
||||||
|
|
||||||
|
// Determine command.
|
||||||
|
command := step.Command
|
||||||
|
if command == "" && step.Script != "" {
|
||||||
|
command = step.Script
|
||||||
|
}
|
||||||
|
if command == "" {
|
||||||
|
e.store.UpdateStepResult(stepID, "skipped", 0, "", "", nil, 0, "no command or script")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve step-level ${VAR} references and ${step_id.stdout} patterns.
|
||||||
|
mu.Lock()
|
||||||
|
command = resolveStepRefs(command, outputs)
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
// Determine working directory.
|
||||||
|
dir := step.Dir
|
||||||
|
if dir == "" {
|
||||||
|
dir = dag.WorkingDir
|
||||||
|
}
|
||||||
|
|
||||||
|
shell := step.Shell
|
||||||
|
if shell == "" {
|
||||||
|
shell = dag.Shell
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn process.
|
||||||
|
handle, err := infra.ProcessSpawn(command, dir, env, shell)
|
||||||
|
if err != nil {
|
||||||
|
fin := time.Now()
|
||||||
|
e.store.UpdateStepResult(stepID, "failed", -1, "", "", &fin, time.Since(now).Milliseconds(), err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for process.
|
||||||
|
result, err := infra.ProcessWait(handle, step.TimeoutSec)
|
||||||
|
fin := time.Now()
|
||||||
|
duration := time.Since(now).Milliseconds()
|
||||||
|
|
||||||
|
if err != nil && result.ExitCode == 0 {
|
||||||
|
result.ExitCode = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "success"
|
||||||
|
errMsg := ""
|
||||||
|
if result.ExitCode != 0 || err != nil {
|
||||||
|
status = "failed"
|
||||||
|
if err != nil {
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.store.UpdateStepResult(stepID, status, result.ExitCode, result.Stdout, result.Stderr, &fin, duration, errMsg)
|
||||||
|
|
||||||
|
// Store output for ${step_id.stdout} references.
|
||||||
|
if step.ID != "" || step.Output != "" {
|
||||||
|
mu.Lock()
|
||||||
|
key := step.ID
|
||||||
|
if key == "" {
|
||||||
|
key = step.Output
|
||||||
|
}
|
||||||
|
outputs[key] = strings.TrimSpace(result.Stdout)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read DAGU_ENV for inter-step env propagation.
|
||||||
|
readDaguEnv(daguEnvPath, outputs)
|
||||||
|
|
||||||
|
if status == "failed" {
|
||||||
|
return fmt.Errorf("exit code %d", result.ExitCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) runHandlers(ctx context.Context, runID string, dag core.DagDefinition, handlers []core.DagStep, daguEnvPath string, outputs map[string]string) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
for _, step := range handlers {
|
||||||
|
e.executeStep(ctx, runID, dag, step, daguEnvPath, outputs, &mu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) failRun(runID string, err error) {
|
||||||
|
fin := time.Now()
|
||||||
|
e.store.UpdateRunStatus(runID, "failed", &fin, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) recordStepSkipped(runID string, step core.DagStep) {
|
||||||
|
now := time.Now()
|
||||||
|
e.store.InsertStepResult(&store.DagStepResult{
|
||||||
|
ID: generateID(),
|
||||||
|
RunID: runID,
|
||||||
|
StepName: stepName(step),
|
||||||
|
Status: "skipped",
|
||||||
|
StartedAt: &now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func stepName(s core.DagStep) string {
|
||||||
|
if s.Name != "" {
|
||||||
|
return s.Name
|
||||||
|
}
|
||||||
|
return s.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStepEnv(dag core.DagDefinition, step core.DagStep, daguEnvPath string, outputs map[string]string) []string {
|
||||||
|
env := os.Environ()
|
||||||
|
|
||||||
|
// Add DAG-level env.
|
||||||
|
for k, v := range dag.Env {
|
||||||
|
env = append(env, k+"="+v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add step-level env.
|
||||||
|
for k, v := range step.Env {
|
||||||
|
env = append(env, k+"="+v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add DAGU_ENV path.
|
||||||
|
env = append(env, "DAGU_ENV="+daguEnvPath)
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStepRefs(command string, outputs map[string]string) string {
|
||||||
|
for k, v := range outputs {
|
||||||
|
command = strings.ReplaceAll(command, "${"+k+".stdout}", v)
|
||||||
|
command = strings.ReplaceAll(command, "$"+k+".stdout", v)
|
||||||
|
}
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDaguEnv(path string, outputs map[string]string) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
outputs[parts[0]] = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateID creates a simple time-based unique ID.
|
||||||
|
func generateID() string {
|
||||||
|
return fmt.Sprintf("%d-%04x", time.Now().UnixNano(), time.Now().Nanosecond()%0xFFFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DAG listing helpers ---
|
||||||
|
|
||||||
|
// DagInfo summarizes a DAG file for listing.
|
||||||
|
type DagInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Schedule []string `json:"schedule,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
LastRun *store.DagRun `json:"last_run,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDAGs scans a directory for YAML files and returns parsed DAG info.
|
||||||
|
func (e *Executor) ListDAGs() ([]DagInfo, error) {
|
||||||
|
entries, err := os.ReadDir(e.dagsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read dags dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dags []DagInfo
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(entry.Name())
|
||||||
|
if ext != ".yaml" && ext != ".yml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(e.dagsDir, entry.Name())
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
dags = append(dags, DagInfo{
|
||||||
|
Name: strings.TrimSuffix(entry.Name(), ext),
|
||||||
|
FilePath: path,
|
||||||
|
Valid: false,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info := DagInfo{
|
||||||
|
Name: dag.Name,
|
||||||
|
Description: dag.Description,
|
||||||
|
Schedule: dag.Schedule,
|
||||||
|
Tags: dag.Tags,
|
||||||
|
Type: dag.Type,
|
||||||
|
FilePath: path,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach last run info.
|
||||||
|
runs, _, _ := e.store.ListRuns(dag.Name, 1, 0)
|
||||||
|
if len(runs) > 0 {
|
||||||
|
info.LastRun = &runs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
dags = append(dags, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDAG returns detailed info for a specific DAG by name.
|
||||||
|
func (e *Executor) GetDAG(name string) (*DagInfo, *core.DagDefinition, *core.DagValidationResult, error) {
|
||||||
|
// Find the YAML file.
|
||||||
|
entries, err := os.ReadDir(e.dagsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
ext := filepath.Ext(entry.Name())
|
||||||
|
base := strings.TrimSuffix(entry.Name(), ext)
|
||||||
|
if (ext != ".yaml" && ext != ".yml") || base != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(e.dagsDir, entry.Name())
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("parse: %w", err)
|
||||||
|
}
|
||||||
|
dag.FilePath = path
|
||||||
|
|
||||||
|
validationResult := core.DagValidate(dag)
|
||||||
|
|
||||||
|
info := &DagInfo{
|
||||||
|
Name: dag.Name,
|
||||||
|
Description: dag.Description,
|
||||||
|
Schedule: dag.Schedule,
|
||||||
|
Tags: dag.Tags,
|
||||||
|
Type: dag.Type,
|
||||||
|
FilePath: path,
|
||||||
|
Valid: validationResult.Valid,
|
||||||
|
}
|
||||||
|
|
||||||
|
runs, _, _ := e.store.ListRuns(dag.Name, 1, 0)
|
||||||
|
if len(runs) > 0 {
|
||||||
|
info.LastRun = &runs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, &dag, &validationResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, nil, fmt.Errorf("dag %q not found in %s", name, e.dagsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDAG parses and validates a DAG file, printing results.
|
||||||
|
func ValidateDAG(path string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := core.DagValidate(dag)
|
||||||
|
|
||||||
|
log.Printf("DAG: %s", dag.Name)
|
||||||
|
log.Printf("Steps: %d", len(dag.Steps))
|
||||||
|
log.Printf("Schedule: %v", dag.Schedule)
|
||||||
|
|
||||||
|
if result.Valid {
|
||||||
|
log.Printf("Validation: PASS")
|
||||||
|
log.Printf("Topological levels: %d", len(result.Levels))
|
||||||
|
for i, level := range result.Levels {
|
||||||
|
log.Printf(" Level %d: %v", i, level)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("Validation: FAIL")
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
log.Printf(" ERROR: %s", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range result.Warnings {
|
||||||
|
log.Printf(" WARNING: %s", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
return fmt.Errorf("validation failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DAG Engine</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "dag-engine-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mantine/core": "^9.0.2",
|
||||||
|
"@mantine/hooks": "^9.0.2",
|
||||||
|
"@tabler/icons-react": "^3.31.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.6",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
|
"postcss": "^8.5.4",
|
||||||
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"postcss-preset-mantine": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { AppShell, Container, Title, Group, Text } from "@mantine/core";
|
||||||
|
import { IconTopologyRing } from "@tabler/icons-react";
|
||||||
|
import { DagList } from "./pages/DagList";
|
||||||
|
import { DagDetail } from "./pages/DagDetail";
|
||||||
|
import { RunDetail } from "./pages/RunDetail";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<AppShell header={{ height: 50 }} padding="md">
|
||||||
|
<AppShell.Header>
|
||||||
|
<Group h="100%" px="md">
|
||||||
|
<IconTopologyRing size={24} />
|
||||||
|
<Title order={4}>DAG Engine</Title>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
fn_registry workflow executor
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</AppShell.Header>
|
||||||
|
|
||||||
|
<AppShell.Main>
|
||||||
|
<Container size="lg">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DagList />} />
|
||||||
|
<Route path="/dags/:name" element={<DagDetail />} />
|
||||||
|
<Route path="/runs/:id" element={<RunDetail />} />
|
||||||
|
</Routes>
|
||||||
|
</Container>
|
||||||
|
</AppShell.Main>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type {
|
||||||
|
DagSummary,
|
||||||
|
DagDetail,
|
||||||
|
DagRun,
|
||||||
|
RunDetail,
|
||||||
|
SchedulerStatus,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const BASE = "/api";
|
||||||
|
|
||||||
|
async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, init);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || res.statusText);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDags(): Promise<DagSummary[]> {
|
||||||
|
return fetchJSON("/dags");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDag(name: string): Promise<DagDetail> {
|
||||||
|
return fetchJSON(`/dags/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerDag(
|
||||||
|
name: string
|
||||||
|
): Promise<{ status: string; dag: string; message: string }> {
|
||||||
|
return fetchJSON(`/dags/${encodeURIComponent(name)}/run`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRuns(params?: {
|
||||||
|
dag?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<{ runs: DagRun[]; total: number }> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params?.dag) search.set("dag", params.dag);
|
||||||
|
if (params?.limit) search.set("limit", String(params.limit));
|
||||||
|
if (params?.offset) search.set("offset", String(params.offset));
|
||||||
|
const qs = search.toString();
|
||||||
|
return fetchJSON(`/runs${qs ? "?" + qs : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRun(id: string): Promise<RunDetail> {
|
||||||
|
return fetchJSON(`/runs/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startScheduler(): Promise<void> {
|
||||||
|
return fetchJSON("/scheduler/start", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopScheduler(): Promise<void> {
|
||||||
|
return fetchJSON("/scheduler/stop", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSchedulerStatus(): Promise<SchedulerStatus> {
|
||||||
|
return fetchJSON("/scheduler/status");
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Badge } from "@mantine/core";
|
||||||
|
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
success: "green",
|
||||||
|
failed: "red",
|
||||||
|
running: "blue",
|
||||||
|
pending: "gray",
|
||||||
|
cancelled: "yellow",
|
||||||
|
skipped: "dimmed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<Badge color={colorMap[status] || "gray"} variant="light" size="sm">
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Timeline, Text, Code, Collapse, Box, Group } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconCircleCheck,
|
||||||
|
IconCircleX,
|
||||||
|
IconLoader,
|
||||||
|
IconCircleMinus,
|
||||||
|
IconClock,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import type { DagStepResult } from "../types";
|
||||||
|
|
||||||
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
|
success: <IconCircleCheck size={16} color="var(--mantine-color-green-6)" />,
|
||||||
|
failed: <IconCircleX size={16} color="var(--mantine-color-red-6)" />,
|
||||||
|
running: <IconLoader size={16} color="var(--mantine-color-blue-6)" />,
|
||||||
|
skipped: <IconCircleMinus size={16} color="var(--mantine-color-dimmed)" />,
|
||||||
|
pending: <IconClock size={16} color="var(--mantine-color-gray-6)" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
function StepItem({ step }: { step: DagStepResult }) {
|
||||||
|
const [opened, { toggle }] = useDisclosure(step.Status === "failed");
|
||||||
|
const hasOutput = step.Stdout || step.Stderr;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline.Item
|
||||||
|
bullet={iconMap[step.Status] || iconMap.pending}
|
||||||
|
title={
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={500}
|
||||||
|
onClick={hasOutput ? toggle : undefined}
|
||||||
|
style={hasOutput ? { cursor: "pointer" } : undefined}
|
||||||
|
>
|
||||||
|
{step.StepName}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{step.DurationMs}ms
|
||||||
|
</Text>
|
||||||
|
{step.ExitCode !== 0 && step.ExitCode !== -1 && (
|
||||||
|
<Text size="xs" c="red">
|
||||||
|
exit {step.ExitCode}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasOutput && (
|
||||||
|
<Collapse in={opened}>
|
||||||
|
<Box mt="xs">
|
||||||
|
{step.Stdout && (
|
||||||
|
<Code block mb="xs" style={{ maxHeight: 200, overflow: "auto" }}>
|
||||||
|
{step.Stdout}
|
||||||
|
</Code>
|
||||||
|
)}
|
||||||
|
{step.Stderr && (
|
||||||
|
<Code
|
||||||
|
block
|
||||||
|
color="red"
|
||||||
|
style={{ maxHeight: 200, overflow: "auto" }}
|
||||||
|
>
|
||||||
|
{step.Stderr}
|
||||||
|
</Code>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Timeline.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepTimeline({ steps }: { steps: DagStepResult[] }) {
|
||||||
|
const activeIndex = steps.findIndex((s) => s.Status === "running");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
active={activeIndex >= 0 ? activeIndex : steps.length - 1}
|
||||||
|
bulletSize={24}
|
||||||
|
>
|
||||||
|
{steps.map((step) => (
|
||||||
|
<StepItem key={step.ID} step={step} />
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import "@mantine/core/styles.css";
|
||||||
|
import { MantineProvider, createTheme } from "@mantine/core";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
primaryColor: "blue",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
});
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
Alert,
|
||||||
|
Loader,
|
||||||
|
Code,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconPlayerPlay, IconArrowLeft } from "@tabler/icons-react";
|
||||||
|
import { getDag, triggerDag } from "../api";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import type { DagDetail as DagDetailType } from "../types";
|
||||||
|
|
||||||
|
export function DagDetail() {
|
||||||
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<DagDetailType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [triggering, setTriggering] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!name) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setData(await getDag(name));
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const handleRun = async () => {
|
||||||
|
if (!name) return;
|
||||||
|
setTriggering(true);
|
||||||
|
try {
|
||||||
|
await triggerDag(name);
|
||||||
|
setTimeout(load, 1000);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setTriggering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <Loader />;
|
||||||
|
if (error) return <Alert color="red">{error}</Alert>;
|
||||||
|
if (!data) return <Text>Not found</Text>;
|
||||||
|
|
||||||
|
const { dag, validation, runs } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconArrowLeft size={14} />}
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>{dag.Name}</Title>
|
||||||
|
{dag.Description && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{dag.Description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlayerPlay size={16} />}
|
||||||
|
onClick={handleRun}
|
||||||
|
loading={triggering}
|
||||||
|
>
|
||||||
|
Run Now
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
{dag.Schedule?.map((s: string) => (
|
||||||
|
<Badge key={s} variant="light" ff="monospace">
|
||||||
|
{s}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<Badge variant="light">{dag.Type || "chain"}</Badge>
|
||||||
|
{dag.Tags?.map((t: string) => (
|
||||||
|
<Badge key={t} variant="dot">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!validation.Valid && (
|
||||||
|
<Alert color="red" title="Validation errors">
|
||||||
|
{validation.Errors.map((e: string, i: number) => (
|
||||||
|
<Text key={i} size="sm">
|
||||||
|
{e}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper p="md" withBorder>
|
||||||
|
<Title order={4} mb="sm">
|
||||||
|
Steps ({dag.Steps?.length || 0})
|
||||||
|
</Title>
|
||||||
|
{validation.Levels?.map((level: string[], i: number) => (
|
||||||
|
<Group key={i} gap="xs" mb="xs">
|
||||||
|
<Text size="xs" c="dimmed" w={60}>
|
||||||
|
Level {i}:
|
||||||
|
</Text>
|
||||||
|
{level.map((name: string) => {
|
||||||
|
const step = dag.Steps?.find(
|
||||||
|
(s) => s.Name === name || s.ID === name
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge key={name} variant="outline" size="sm">
|
||||||
|
{name}
|
||||||
|
{step?.Depends?.length
|
||||||
|
? ` (after ${step.Depends.join(",")})`
|
||||||
|
: ""}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{dag.Env && Object.keys(dag.Env).length > 0 && (
|
||||||
|
<>
|
||||||
|
<Title order={5} mt="md" mb="xs">
|
||||||
|
Environment
|
||||||
|
</Title>
|
||||||
|
<Code block>
|
||||||
|
{Object.entries(dag.Env)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join("\n")}
|
||||||
|
</Code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper p="md" withBorder>
|
||||||
|
<Title order={4} mb="sm">
|
||||||
|
Run History
|
||||||
|
</Title>
|
||||||
|
{runs?.length ? (
|
||||||
|
<Table striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Trigger</Table.Th>
|
||||||
|
<Table.Th>Started</Table.Th>
|
||||||
|
<Table.Th>Duration</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{runs.map((r) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={r.ID}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/runs/${r.ID}`)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<StatusBadge status={r.Status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{r.Trigger}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{new Date(r.StartedAt).toLocaleString()}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{r.FinishedAt
|
||||||
|
? `${Math.round((new Date(r.FinishedAt).getTime() - new Date(r.StartedAt).getTime()) / 1000)}s`
|
||||||
|
: "running..."}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No runs yet
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Title,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Alert,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconPlayerStop,
|
||||||
|
IconRefresh,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { listDags, getSchedulerStatus, startScheduler, stopScheduler } from "../api";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import type { DagSummary, SchedulerStatus } from "../types";
|
||||||
|
|
||||||
|
export function DagList() {
|
||||||
|
const [dags, setDags] = useState<DagSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [scheduler, setScheduler] = useState<SchedulerStatus | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [d, s] = await Promise.all([listDags(), getSchedulerStatus()]);
|
||||||
|
setDags(d || []);
|
||||||
|
setScheduler(s);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const interval = setInterval(load, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleScheduler = async () => {
|
||||||
|
if (scheduler?.running) {
|
||||||
|
await stopScheduler();
|
||||||
|
} else {
|
||||||
|
await startScheduler();
|
||||||
|
}
|
||||||
|
const s = await getSchedulerStatus();
|
||||||
|
setScheduler(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Title order={2}>DAGs</Title>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconRefresh size={14} />}
|
||||||
|
onClick={load}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant={scheduler?.running ? "filled" : "light"}
|
||||||
|
color={scheduler?.running ? "green" : "gray"}
|
||||||
|
leftSection={
|
||||||
|
scheduler?.running ? (
|
||||||
|
<IconPlayerStop size={14} />
|
||||||
|
) : (
|
||||||
|
<IconPlayerPlay size={14} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={toggleScheduler}
|
||||||
|
>
|
||||||
|
Scheduler {scheduler?.running ? "ON" : "OFF"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{error && <Alert color="red">{error}</Alert>}
|
||||||
|
|
||||||
|
{loading && !dags.length ? (
|
||||||
|
<Loader />
|
||||||
|
) : (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Schedule</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Tags</Table.Th>
|
||||||
|
<Table.Th>Last Status</Table.Th>
|
||||||
|
<Table.Th>Last Run</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{dags.map((d) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={d.file_path}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/dags/${d.name}`)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={500}>{d.name}</Text>
|
||||||
|
{d.description && (
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||||
|
{d.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" ff="monospace">
|
||||||
|
{d.schedule?.join(", ") || "-"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="light" size="xs">
|
||||||
|
{d.type || "chain"}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={4}>
|
||||||
|
{d.tags?.map((t) => (
|
||||||
|
<Badge key={t} variant="dot" size="xs">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{d.last_run ? (
|
||||||
|
<StatusBadge status={d.last_run.Status} />
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
-
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">
|
||||||
|
{d.last_run
|
||||||
|
? new Date(d.last_run.StartedAt).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
|
Alert,
|
||||||
|
Loader,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconArrowLeft } from "@tabler/icons-react";
|
||||||
|
import { getRun } from "../api";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import { StepTimeline } from "../components/StepTimeline";
|
||||||
|
import type { RunDetail as RunDetailType } from "../types";
|
||||||
|
|
||||||
|
export function RunDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<RunDetailType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
setData(await getRun(id));
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// Auto-refresh while running.
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (data?.run.Status === "running") {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [id, data?.run.Status]);
|
||||||
|
|
||||||
|
if (loading) return <Loader />;
|
||||||
|
if (error) return <Alert color="red">{error}</Alert>;
|
||||||
|
if (!data) return <Text>Not found</Text>;
|
||||||
|
|
||||||
|
const { run, steps } = data;
|
||||||
|
const duration = run.FinishedAt
|
||||||
|
? `${Math.round((new Date(run.FinishedAt).getTime() - new Date(run.StartedAt).getTime()) / 1000)}s`
|
||||||
|
: "running...";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconArrowLeft size={14} />}
|
||||||
|
onClick={() => navigate(`/dags/${run.DagName}`)}
|
||||||
|
>
|
||||||
|
Back to {run.DagName}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Run {run.ID.substring(0, 16)}...</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{run.DagName} · {run.Trigger} ·{" "}
|
||||||
|
{new Date(run.StartedAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Group gap="xs">
|
||||||
|
<StatusBadge status={run.Status} />
|
||||||
|
<Text size="sm">{duration}</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{run.Error && (
|
||||||
|
<Alert color="red" title="Error">
|
||||||
|
{run.Error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper p="md" withBorder>
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
Steps ({steps?.length || 0})
|
||||||
|
</Title>
|
||||||
|
{steps?.length ? (
|
||||||
|
<StepTimeline steps={steps} />
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No steps recorded
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export interface DagSummary {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
schedule?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
type?: string;
|
||||||
|
file_path: string;
|
||||||
|
valid: boolean;
|
||||||
|
last_run?: DagRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DagRun {
|
||||||
|
ID: string;
|
||||||
|
DagName: string;
|
||||||
|
DagPath: string;
|
||||||
|
Status: string;
|
||||||
|
Trigger: string;
|
||||||
|
StartedAt: string;
|
||||||
|
FinishedAt?: string;
|
||||||
|
Error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DagStepResult {
|
||||||
|
ID: string;
|
||||||
|
RunID: string;
|
||||||
|
StepName: string;
|
||||||
|
Status: string;
|
||||||
|
ExitCode: number;
|
||||||
|
Stdout: string;
|
||||||
|
Stderr: string;
|
||||||
|
StartedAt?: string;
|
||||||
|
FinishedAt?: string;
|
||||||
|
DurationMs: number;
|
||||||
|
Error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DagDetail {
|
||||||
|
info: DagSummary;
|
||||||
|
dag: {
|
||||||
|
Name: string;
|
||||||
|
Description: string;
|
||||||
|
Type: string;
|
||||||
|
Schedule: string[];
|
||||||
|
Steps: { Name: string; ID: string; Command: string; Script: string; Depends: string[] }[];
|
||||||
|
Env: Record<string, string>;
|
||||||
|
Tags: string[];
|
||||||
|
HandlerOn: { Failure: unknown[]; Success: unknown[] };
|
||||||
|
};
|
||||||
|
validation: {
|
||||||
|
Valid: boolean;
|
||||||
|
Errors: string[];
|
||||||
|
Warnings: string[];
|
||||||
|
Levels: string[][];
|
||||||
|
};
|
||||||
|
runs: DagRun[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunDetail {
|
||||||
|
run: DagRun;
|
||||||
|
steps: DagStepResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulerStatus {
|
||||||
|
running: boolean;
|
||||||
|
dags: { name: string; path: string; schedule: string; next_run: string }[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5175,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8090",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
module dag-engine
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
fn-registry v0.0.0-00010101000000-000000000000
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/apache/arrow-go/v18 v18.1.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.3 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
|
github.com/marcboeker/go-duckdb v1.8.5 // indirect
|
||||||
|
github.com/paulmach/orb v0.12.0 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
|
||||||
|
golang.org/x/mod v0.27.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
golang.org/x/tools v0.36.0 // indirect
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace fn-registry => /home/lucas/fn_registry
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||||
|
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
|
||||||
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
|
||||||
|
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
|
||||||
|
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
|
||||||
|
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||||
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
||||||
|
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||||
|
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
|
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||||
|
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
|
||||||
|
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
|
||||||
|
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||||
|
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
||||||
|
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||||
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
|
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||||
|
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||||
|
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||||
|
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
|
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||||
|
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||||
|
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
|
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
|
||||||
|
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleListDags(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dags, err := executor.ListDAGs()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetDag(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
info, dag, validation, err := executor.GetDAG(name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent runs.
|
||||||
|
runs, _, _ := executor.store.ListRuns(dag.Name, 10, 0)
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"info": info,
|
||||||
|
"dag": dag,
|
||||||
|
"validation": validation,
|
||||||
|
"runs": runs,
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRunDag(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
info, _, _, err := executor.GetDAG(name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute asynchronously.
|
||||||
|
go func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
executor.ExecuteDAG(ctx, info.FilePath, "api")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Return run acknowledgment.
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]string{
|
||||||
|
"status": "accepted",
|
||||||
|
"dag": name,
|
||||||
|
"message": "DAG execution started",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON helpers ---
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleListRuns(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dagName := r.URL.Query().Get("dag")
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
runs, total, err := executor.store.ListRuns(dagName, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"runs": runs,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetRun(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
run, err := executor.store.GetRun(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if run == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "run not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
steps, err := executor.store.ListStepResults(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"run": run,
|
||||||
|
"steps": steps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func handleSchedulerStart(scheduler *Scheduler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := scheduler.Start(); err != nil {
|
||||||
|
writeError(w, http.StatusConflict, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSchedulerStop(scheduler *Scheduler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scheduler.Stop()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSchedulerStatus(scheduler *Scheduler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := scheduler.Status()
|
||||||
|
writeJSON(w, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
iofs "io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/core"
|
||||||
|
|
||||||
|
"dag-engine/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:frontend/dist
|
||||||
|
var frontendDist embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
printUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := os.Args[1]
|
||||||
|
args := os.Args[2:]
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "run":
|
||||||
|
cmdRun(args)
|
||||||
|
case "list":
|
||||||
|
cmdList(args)
|
||||||
|
case "status":
|
||||||
|
cmdStatus(args)
|
||||||
|
case "validate":
|
||||||
|
cmdValidate(args)
|
||||||
|
case "server":
|
||||||
|
cmdServer(args)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
printUsage()
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
|
||||||
|
printUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printUsage() {
|
||||||
|
fmt.Println(`dag-engine — DAG workflow executor
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
dag-engine <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
run <path.yaml> Execute a DAG and show results
|
||||||
|
list [dir] List DAGs with schedule and last status
|
||||||
|
status [dag_name] Show execution history
|
||||||
|
validate <path.yaml> Parse and validate without executing
|
||||||
|
server Start HTTP server with web frontend
|
||||||
|
|
||||||
|
Server options:
|
||||||
|
--port <port> HTTP port (default: 8090)
|
||||||
|
--dags-dir <dir> DAGs directory (default: ~/dagu/dags)
|
||||||
|
--db <path> SQLite database path (default: dag_engine.db)
|
||||||
|
--scheduler Auto-start cron scheduler`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CLI Commands ---
|
||||||
|
|
||||||
|
func cmdRun(args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: dag-engine run <path.yaml>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
dagPath := args[0]
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
// Parse optional flags after the path.
|
||||||
|
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
||||||
|
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||||
|
fs.Parse(args[1:])
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
executor := NewExecutor(db, filepath.Dir(dagPath))
|
||||||
|
|
||||||
|
fmt.Printf("Executing %s...\n", dagPath)
|
||||||
|
ctx := context.Background()
|
||||||
|
runID, err := executor.ExecuteDAG(ctx, dagPath, "manual")
|
||||||
|
|
||||||
|
// Print results.
|
||||||
|
if runID != "" {
|
||||||
|
run, _ := db.GetRun(runID)
|
||||||
|
steps, _ := db.ListStepResults(runID)
|
||||||
|
|
||||||
|
if run != nil {
|
||||||
|
fmt.Println()
|
||||||
|
for _, s := range steps {
|
||||||
|
icon := " "
|
||||||
|
switch s.Status {
|
||||||
|
case "success":
|
||||||
|
icon = "OK"
|
||||||
|
case "failed":
|
||||||
|
icon = "!!"
|
||||||
|
case "skipped":
|
||||||
|
icon = "--"
|
||||||
|
case "running":
|
||||||
|
icon = ".."
|
||||||
|
}
|
||||||
|
fmt.Printf("[%s] %s (%dms)\n", icon, s.StepName, s.DurationMs)
|
||||||
|
if s.Status == "failed" && s.Stderr != "" {
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(s.Stderr), "\n") {
|
||||||
|
fmt.Printf(" %s\n", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
dur := ""
|
||||||
|
if run.FinishedAt != nil {
|
||||||
|
dur = fmt.Sprintf(" (%s)", run.FinishedAt.Sub(run.StartedAt).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
fmt.Printf("Run %s: %s%s\n", runID, strings.ToUpper(run.Status), dur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdList(args []string) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||||
|
cfg.DagsDir = args[0]
|
||||||
|
args = args[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
fs := flag.NewFlagSet("list", flag.ExitOnError)
|
||||||
|
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
executor := NewExecutor(db, cfg.DagsDir)
|
||||||
|
dags, err := executor.ListDAGs()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list dags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "NAME\tSCHEDULE\tTYPE\tTAGS\tLAST STATUS\tLAST RUN")
|
||||||
|
for _, d := range dags {
|
||||||
|
sched := strings.Join(d.Schedule, ", ")
|
||||||
|
tags := strings.Join(d.Tags, ", ")
|
||||||
|
lastStatus := "-"
|
||||||
|
lastRun := "-"
|
||||||
|
if d.LastRun != nil {
|
||||||
|
lastStatus = d.LastRun.Status
|
||||||
|
lastRun = d.LastRun.StartedAt.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
typ := d.Type
|
||||||
|
if typ == "" {
|
||||||
|
typ = "chain"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", d.Name, sched, typ, tags, lastStatus, lastRun)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdStatus(args []string) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
||||||
|
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||||
|
limit := fs.Int("limit", 10, "number of runs to show")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
dagName := ""
|
||||||
|
if fs.NArg() > 0 {
|
||||||
|
dagName = fs.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
runs, total, err := db.ListRuns(dagName, *limit, 0)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list runs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintf(w, "Showing %d of %d runs", len(runs), total)
|
||||||
|
if dagName != "" {
|
||||||
|
fmt.Fprintf(w, " for %s", dagName)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
fmt.Fprintln(w, "RUN_ID\tDAG\tSTATUS\tTRIGGER\tSTARTED\tDURATION")
|
||||||
|
for _, r := range runs {
|
||||||
|
dur := "-"
|
||||||
|
if r.FinishedAt != nil {
|
||||||
|
dur = r.FinishedAt.Sub(r.StartedAt).Round(time.Millisecond).String()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
r.ID, r.DagName, r.Status, r.Trigger,
|
||||||
|
r.StartedAt.Format("2006-01-02 15:04:05"), dur)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdValidate(args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: dag-engine validate <path.yaml>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(args[0])
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parse error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := core.DagValidate(dag)
|
||||||
|
|
||||||
|
fmt.Printf("DAG: %s\n", dag.Name)
|
||||||
|
fmt.Printf("Steps: %d\n", len(dag.Steps))
|
||||||
|
fmt.Printf("Schedule: %v\n", dag.Schedule)
|
||||||
|
fmt.Printf("Type: %s\n", dag.Type)
|
||||||
|
|
||||||
|
if result.Valid {
|
||||||
|
fmt.Println("Validation: PASS")
|
||||||
|
for i, level := range result.Levels {
|
||||||
|
fmt.Printf(" Level %d: %v\n", i, level)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("Validation: FAIL")
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
fmt.Printf(" ERROR: %s\n", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range result.Warnings {
|
||||||
|
fmt.Printf(" WARNING: %s\n", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server Command ---
|
||||||
|
|
||||||
|
func cmdServer(args []string) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
||||||
|
cfg.ParseFlags(fs, args)
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
executor := NewExecutor(db, cfg.DagsDir)
|
||||||
|
scheduler := NewScheduler(executor, cfg.DagsDir)
|
||||||
|
|
||||||
|
// Prepare frontend FS.
|
||||||
|
var feFS iofs.FS
|
||||||
|
distFS, err := iofs.Sub(frontendDist, "frontend/dist")
|
||||||
|
if err == nil {
|
||||||
|
// Check if dist has content (built frontend exists).
|
||||||
|
entries, _ := iofs.ReadDir(distFS, ".")
|
||||||
|
if len(entries) > 0 {
|
||||||
|
feFS = distFS
|
||||||
|
log.Printf("serving frontend from embedded dist/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if feFS == nil {
|
||||||
|
log.Printf("no frontend build found, API-only mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterAPI(mux, executor, scheduler, feFS)
|
||||||
|
|
||||||
|
handler := corsMiddleware(loggingMiddleware(mux))
|
||||||
|
|
||||||
|
if cfg.AutoScheduler {
|
||||||
|
if err := scheduler.Start(); err != nil {
|
||||||
|
log.Printf("scheduler start: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||||
|
log.Printf("dag-engine server starting on http://0.0.0.0%s", addr)
|
||||||
|
log.Printf("dags dir: %s", cfg.DagsDir)
|
||||||
|
log.Printf("database: %s", cfg.DBPath)
|
||||||
|
|
||||||
|
srv := &http.Server{Addr: addr, Handler: handler}
|
||||||
|
|
||||||
|
// Graceful shutdown.
|
||||||
|
go func() {
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
log.Println("shutting down...")
|
||||||
|
scheduler.Stop()
|
||||||
|
srv.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// corsMiddleware adds permissive CORS headers for development.
|
||||||
|
func corsMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggingMiddleware logs each HTTP request with method, path and duration.
|
||||||
|
func loggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/core"
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScheduledDAG represents a DAG with a parsed cron schedule.
|
||||||
|
type ScheduledDAG struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Schedule string `json:"schedule"`
|
||||||
|
NextRun time.Time `json:"next_run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduler manages cron-triggered DAG execution.
|
||||||
|
type Scheduler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
cancel context.CancelFunc
|
||||||
|
dagsDir string
|
||||||
|
executor *Executor
|
||||||
|
dags []ScheduledDAG
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScheduler creates a new scheduler.
|
||||||
|
func NewScheduler(executor *Executor, dagsDir string) *Scheduler {
|
||||||
|
return &Scheduler{
|
||||||
|
executor: executor,
|
||||||
|
dagsDir: dagsDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start scans for DAGs with schedules and starts cron tickers for each.
|
||||||
|
func (s *Scheduler) Start() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.running {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return fmt.Errorf("scheduler already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
s.cancel = cancel
|
||||||
|
s.running = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
scheduled, err := s.scanDAGs()
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.dags = scheduled
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[scheduler] started with %d DAGs", len(scheduled))
|
||||||
|
|
||||||
|
for _, dag := range scheduled {
|
||||||
|
dag := dag
|
||||||
|
go s.runTicker(ctx, dag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop cancels all tickers and stops the scheduler.
|
||||||
|
func (s *Scheduler) Stop() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if !s.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cancel()
|
||||||
|
s.running = false
|
||||||
|
s.dags = nil
|
||||||
|
log.Printf("[scheduler] stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning returns true if the scheduler is active.
|
||||||
|
func (s *Scheduler) IsRunning() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.running
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the list of scheduled DAGs with their next run time.
|
||||||
|
type SchedulerStatus struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
DAGs []ScheduledDAG `json:"dags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Status() SchedulerStatus {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return SchedulerStatus{
|
||||||
|
Running: s.running,
|
||||||
|
DAGs: s.dags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanDAGs reads the dags directory and returns DAGs that have cron schedules.
|
||||||
|
func (s *Scheduler) scanDAGs() ([]ScheduledDAG, error) {
|
||||||
|
entries, err := os.ReadDir(s.dagsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheduled []ScheduledDAG
|
||||||
|
for _, entry := range entries {
|
||||||
|
ext := filepath.Ext(entry.Name())
|
||||||
|
if ext != ".yaml" && ext != ".yml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(s.dagsDir, entry.Name())
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expr := range dag.Schedule {
|
||||||
|
sched, err := core.ParseCronExpr(strings.TrimSpace(expr))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[scheduler] invalid cron %q in %s: %v", expr, dag.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next := core.NextCronTime(sched, time.Now())
|
||||||
|
scheduled = append(scheduled, ScheduledDAG{
|
||||||
|
Name: dag.Name,
|
||||||
|
Path: path,
|
||||||
|
Schedule: expr,
|
||||||
|
NextRun: next,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTicker starts a cron ticker for a single DAG schedule.
|
||||||
|
func (s *Scheduler) runTicker(ctx context.Context, dag ScheduledDAG) {
|
||||||
|
sched, err := core.ParseCronExpr(strings.TrimSpace(dag.Schedule))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert core.CronSchedule to infra.CronTickerSchedule.
|
||||||
|
tickerSched := infra.CronTickerSchedule{
|
||||||
|
Minute: sched.Minute,
|
||||||
|
Hour: sched.Hour,
|
||||||
|
DayOfMonth: sched.DayOfMonth,
|
||||||
|
Month: sched.Month,
|
||||||
|
DayOfWeek: sched.DayOfWeek,
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := infra.CronTicker(tickerSched, ctx)
|
||||||
|
log.Printf("[scheduler] ticker started for %s (%s), next: %s", dag.Name, dag.Schedule, dag.NextRun.Format(time.RFC3339))
|
||||||
|
|
||||||
|
for t := range ch {
|
||||||
|
log.Printf("[scheduler] triggered %s at %s", dag.Name, t.Format(time.RFC3339))
|
||||||
|
go func() {
|
||||||
|
runID, err := s.executor.ExecuteDAG(ctx, dag.Path, "cron")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[scheduler] %s failed: %v (run: %s)", dag.Name, err, runID)
|
||||||
|
} else {
|
||||||
|
log.Printf("[scheduler] %s completed (run: %s)", dag.Name, runID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS dag_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dag_name TEXT NOT NULL,
|
||||||
|
dag_path TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','success','failed','cancelled')),
|
||||||
|
trigger TEXT NOT NULL DEFAULT 'manual' CHECK(trigger IN ('manual','cron','api')),
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
error TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dag_step_results (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
run_id TEXT NOT NULL REFERENCES dag_runs(id) ON DELETE CASCADE,
|
||||||
|
step_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','success','failed','skipped')),
|
||||||
|
exit_code INTEGER NOT NULL DEFAULT -1,
|
||||||
|
stdout TEXT NOT NULL DEFAULT '',
|
||||||
|
stderr TEXT NOT NULL DEFAULT '',
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT,
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_dag_name ON dag_runs(dag_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON dag_runs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_started ON dag_runs(started_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_step_results_run ON dag_step_results(run_id);
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/001_init.sql
|
||||||
|
var migrationSQL string
|
||||||
|
|
||||||
|
// DB wraps a SQLite connection for DAG run persistence.
|
||||||
|
type DB struct {
|
||||||
|
conn *sql.DB
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens or creates a DAG engine database at the given path.
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
conn, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=on")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(migrationSQL); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("store: migrate: %w", err)
|
||||||
|
}
|
||||||
|
return &DB{conn: conn, path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection.
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
return db.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DagRun CRUD ---
|
||||||
|
|
||||||
|
// DagRun mirrors infra.DagRun for the store layer.
|
||||||
|
type DagRun struct {
|
||||||
|
ID string
|
||||||
|
DagName string
|
||||||
|
DagPath string
|
||||||
|
Status string
|
||||||
|
Trigger string
|
||||||
|
StartedAt time.Time
|
||||||
|
FinishedAt *time.Time
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRun inserts a new run record.
|
||||||
|
func (db *DB) CreateRun(run *DagRun) error {
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`INSERT INTO dag_runs (id, dag_name, dag_path, status, trigger, started_at, error)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
run.ID, run.DagName, run.DagPath, run.Status, run.Trigger,
|
||||||
|
run.StartedAt.Format(time.RFC3339), run.Error,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRunStatus updates a run's status and optionally its finished_at and error.
|
||||||
|
func (db *DB) UpdateRunStatus(id, status string, finishedAt *time.Time, errMsg string) error {
|
||||||
|
var fin *string
|
||||||
|
if finishedAt != nil {
|
||||||
|
s := finishedAt.Format(time.RFC3339)
|
||||||
|
fin = &s
|
||||||
|
}
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`UPDATE dag_runs SET status=?, finished_at=?, error=? WHERE id=?`,
|
||||||
|
status, fin, errMsg, id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRun retrieves a single run by ID.
|
||||||
|
func (db *DB) GetRun(id string) (*DagRun, error) {
|
||||||
|
row := db.conn.QueryRow(
|
||||||
|
`SELECT id, dag_name, dag_path, status, trigger, started_at, finished_at, error
|
||||||
|
FROM dag_runs WHERE id=?`, id,
|
||||||
|
)
|
||||||
|
return scanRun(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRuns returns runs, newest first, with optional dag name filter.
|
||||||
|
func (db *DB) ListRuns(dagName string, limit, offset int) ([]DagRun, int, error) {
|
||||||
|
var total int
|
||||||
|
var args []interface{}
|
||||||
|
where := ""
|
||||||
|
if dagName != "" {
|
||||||
|
where = " WHERE dag_name=?"
|
||||||
|
args = append(args, dagName)
|
||||||
|
}
|
||||||
|
err := db.conn.QueryRow("SELECT COUNT(*) FROM dag_runs"+where, args...).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "SELECT id, dag_name, dag_path, status, trigger, started_at, finished_at, error FROM dag_runs" +
|
||||||
|
where + " ORDER BY started_at DESC LIMIT ? OFFSET ?"
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := db.conn.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var runs []DagRun
|
||||||
|
for rows.Next() {
|
||||||
|
r, err := scanRunRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
runs = append(runs, *r)
|
||||||
|
}
|
||||||
|
return runs, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DagStepResult CRUD ---
|
||||||
|
|
||||||
|
// DagStepResult mirrors infra.DagStepResult for the store layer.
|
||||||
|
type DagStepResult struct {
|
||||||
|
ID string
|
||||||
|
RunID string
|
||||||
|
StepName string
|
||||||
|
Status string
|
||||||
|
ExitCode int
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
StartedAt *time.Time
|
||||||
|
FinishedAt *time.Time
|
||||||
|
DurationMs int64
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertStepResult inserts a new step result.
|
||||||
|
func (db *DB) InsertStepResult(r *DagStepResult) error {
|
||||||
|
var startedAt, finishedAt *string
|
||||||
|
if r.StartedAt != nil {
|
||||||
|
s := r.StartedAt.Format(time.RFC3339)
|
||||||
|
startedAt = &s
|
||||||
|
}
|
||||||
|
if r.FinishedAt != nil {
|
||||||
|
s := r.FinishedAt.Format(time.RFC3339)
|
||||||
|
finishedAt = &s
|
||||||
|
}
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`INSERT INTO dag_step_results (id, run_id, step_name, status, exit_code, stdout, stderr, started_at, finished_at, duration_ms, error)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
r.ID, r.RunID, r.StepName, r.Status, r.ExitCode, r.Stdout, r.Stderr,
|
||||||
|
startedAt, finishedAt, r.DurationMs, r.Error,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStepResult updates a step result by ID.
|
||||||
|
func (db *DB) UpdateStepResult(id, status string, exitCode int, stdout, stderr string, finishedAt *time.Time, durationMs int64, errMsg string) error {
|
||||||
|
var fin *string
|
||||||
|
if finishedAt != nil {
|
||||||
|
s := finishedAt.Format(time.RFC3339)
|
||||||
|
fin = &s
|
||||||
|
}
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`UPDATE dag_step_results SET status=?, exit_code=?, stdout=?, stderr=?, finished_at=?, duration_ms=?, error=? WHERE id=?`,
|
||||||
|
status, exitCode, stdout, stderr, fin, durationMs, errMsg, id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStepResults returns all step results for a given run.
|
||||||
|
func (db *DB) ListStepResults(runID string) ([]DagStepResult, error) {
|
||||||
|
rows, err := db.conn.Query(
|
||||||
|
`SELECT id, run_id, step_name, status, exit_code, stdout, stderr, started_at, finished_at, duration_ms, error
|
||||||
|
FROM dag_step_results WHERE run_id=? ORDER BY started_at ASC`, runID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []DagStepResult
|
||||||
|
for rows.Next() {
|
||||||
|
var r DagStepResult
|
||||||
|
var startedAt, finishedAt sql.NullString
|
||||||
|
if err := rows.Scan(&r.ID, &r.RunID, &r.StepName, &r.Status, &r.ExitCode,
|
||||||
|
&r.Stdout, &r.Stderr, &startedAt, &finishedAt, &r.DurationMs, &r.Error); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if startedAt.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, startedAt.String)
|
||||||
|
r.StartedAt = &t
|
||||||
|
}
|
||||||
|
if finishedAt.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, finishedAt.String)
|
||||||
|
r.FinishedAt = &t
|
||||||
|
}
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- scan helpers ---
|
||||||
|
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRun(s scanner) (*DagRun, error) {
|
||||||
|
var r DagRun
|
||||||
|
var startedAt string
|
||||||
|
var finishedAt sql.NullString
|
||||||
|
if err := s.Scan(&r.ID, &r.DagName, &r.DagPath, &r.Status, &r.Trigger, &startedAt, &finishedAt, &r.Error); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.StartedAt, _ = time.Parse(time.RFC3339, startedAt)
|
||||||
|
if finishedAt.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, finishedAt.String)
|
||||||
|
r.FinishedAt = &t
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRunRows(rows *sql.Rows) (*DagRun, error) {
|
||||||
|
return scanRun(rows)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: analyze_dns
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "analyze_dns(domain: string, mode: string) -> void"
|
||||||
|
description: "Análisis DNS completo de un dominio: registros A/AAAA/MX/NS/TXT/CNAME/SOA, consulta whois y verificación contra listas negras DNSBL (spamhaus, spamcop, sorbs, barracuda)."
|
||||||
|
tags: [bash, cybersecurity, dns, network, whois, dnsbl, reconnaissance]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: domain
|
||||||
|
desc: "dominio a analizar, ej: example.com"
|
||||||
|
- name: mode
|
||||||
|
desc: "modo de análisis: records (solo registros DNS), whois (solo whois), dnsbl (solo listas negras) o all (todo, por defecto)"
|
||||||
|
output: "imprime registros DNS, información whois y estado DNSBL a stdout con colores ANSI"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/analyze_dns.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/redes/analisis_dns.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/analyze_dns.sh
|
||||||
|
|
||||||
|
# Análisis completo
|
||||||
|
analyze_dns example.com
|
||||||
|
|
||||||
|
# Solo registros DNS
|
||||||
|
analyze_dns example.com records
|
||||||
|
|
||||||
|
# Solo whois
|
||||||
|
analyze_dns example.com whois
|
||||||
|
|
||||||
|
# Solo DNSBL
|
||||||
|
analyze_dns example.com dnsbl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `dig` (paquete dnsutils). `whois` es opcional — si no está instalado y el modo es `all`, se omite el paso whois con aviso. Las listas negras DNSBL se consultan via DNS inverso (técnica estándar sin HTTP). El modo `dnsbl` resuelve primero la IP del dominio y luego construye la consulta invertida para cada blacklist.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# analyze_dns
|
||||||
|
# -----------
|
||||||
|
# Análisis DNS completo de un dominio: registros A/AAAA/MX/NS/TXT/CNAME/SOA,
|
||||||
|
# consulta whois y verificación contra listas negras DNSBL.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# analyze_dns example.com [records|whois|dnsbl|all]
|
||||||
|
#
|
||||||
|
# Depende de: dig, whois (opcional), curl (para DNSBL)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_dns_is_valid_domain() {
|
||||||
|
local domain="$1"
|
||||||
|
[[ -n "$domain" && "$domain" =~ ^[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
_dns_build_dnsbl_query() {
|
||||||
|
local ip="$1"
|
||||||
|
local bl="$2"
|
||||||
|
local reversed
|
||||||
|
reversed="$(echo "$ip" | awk -F. '{print $4"."$3"."$2"."$1}')"
|
||||||
|
echo "${reversed}.${bl}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_dns_query_record() {
|
||||||
|
local domain="$1"
|
||||||
|
local type="$2"
|
||||||
|
local result
|
||||||
|
result="$(dig +short "$type" "$domain" 2>/dev/null || true)"
|
||||||
|
if [[ -z "$result" ]]; then
|
||||||
|
echo " (sin registros)"
|
||||||
|
else
|
||||||
|
echo "$result" | while IFS= read -r line; do
|
||||||
|
echo " * $line"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_dns_show_all_records() {
|
||||||
|
local domain="$1"
|
||||||
|
echo ""
|
||||||
|
for type in A AAAA MX NS TXT CNAME SOA; do
|
||||||
|
echo -e "${CYAN}── ${type} ──────────────────${NC}"
|
||||||
|
_dns_query_record "$domain" "$type"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
_dns_show_whois() {
|
||||||
|
local domain="$1"
|
||||||
|
echo ""
|
||||||
|
info "Consultando whois de ${domain}..."
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
whois "$domain" 2>/dev/null \
|
||||||
|
| grep -iE "(registrar|registrant|creation|expiry|expire|updated|name server|status)" \
|
||||||
|
| head -20 \
|
||||||
|
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_dns_check_dnsbl() {
|
||||||
|
local domain="$1"
|
||||||
|
local ip
|
||||||
|
ip="$(dig +short A "$domain" 2>/dev/null | head -1 || true)"
|
||||||
|
|
||||||
|
if [[ -z "$ip" ]]; then
|
||||||
|
warning "No se pudo resolver la IP de $domain para comprobar DNSBL"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "IP a comprobar: $ip"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local blacklists=(
|
||||||
|
"zen.spamhaus.org"
|
||||||
|
"bl.spamcop.net"
|
||||||
|
"dnsbl.sorbs.net"
|
||||||
|
"b.barracudacentral.org"
|
||||||
|
)
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
for bl in "${blacklists[@]}"; do
|
||||||
|
local query
|
||||||
|
query="$(_dns_build_dnsbl_query "$ip" "$bl")"
|
||||||
|
local result
|
||||||
|
result="$(dig +short A "$query" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$result" ]]; then
|
||||||
|
echo -e " ${RED}LISTADO${NC} ${bl} ($result)"
|
||||||
|
found=$((found + 1))
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}limpio${NC} ${bl}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [[ $found -eq 0 ]]; then
|
||||||
|
success "La IP no aparece en ninguna lista negra comprobada"
|
||||||
|
else
|
||||||
|
warning "La IP aparece en ${found} lista(s) negra(s)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
analyze_dns() {
|
||||||
|
local domain="$1"
|
||||||
|
local mode="${2:-all}"
|
||||||
|
|
||||||
|
if [[ -z "$domain" ]]; then
|
||||||
|
error "analyze_dns: se requiere un dominio como primer argumento" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! _dns_is_valid_domain "$domain"; then
|
||||||
|
error "analyze_dns: dominio no válido: '$domain'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v dig &>/dev/null; then
|
||||||
|
error "analyze_dns: 'dig' no está instalado (sudo apt install dnsutils)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Analizando: ${domain}"
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
records)
|
||||||
|
_dns_show_all_records "$domain"
|
||||||
|
;;
|
||||||
|
whois)
|
||||||
|
if ! command -v whois &>/dev/null; then
|
||||||
|
error "analyze_dns: 'whois' no está instalado (sudo apt install whois)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
_dns_show_whois "$domain"
|
||||||
|
;;
|
||||||
|
dnsbl)
|
||||||
|
_dns_check_dnsbl "$domain"
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
_dns_show_all_records "$domain"
|
||||||
|
if command -v whois &>/dev/null; then
|
||||||
|
_dns_show_whois "$domain"
|
||||||
|
else
|
||||||
|
warning "whois no disponible, omitiendo"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
_dns_check_dnsbl "$domain"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
error "analyze_dns: modo no válido '$mode'. Use: records|whois|dnsbl|all" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
analyze_dns "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: audit_http_headers
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "audit_http_headers(url: string) -> void"
|
||||||
|
description: "Audita las cabeceras HTTP de seguridad de una URL: verifica la presencia de HSTS (con validación de max-age mínimo de 6 meses), Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy y cabeceras CORS. También detecta cabeceras que exponen información del servidor."
|
||||||
|
tags: [bash, cybersecurity, web, http, headers, security, hsts, csp, hardening]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: url
|
||||||
|
desc: "URL del sitio web a auditar; si no tiene esquema se añade https:// automáticamente"
|
||||||
|
output: "imprime el estado de cada cabecera de seguridad (ok/falta/advertencia), el valor de las presentes y cabeceras que exponen información del servidor"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/audit_http_headers.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/web/cabeceras_http.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/audit_http_headers.sh
|
||||||
|
|
||||||
|
# Con URL completa
|
||||||
|
audit_http_headers https://example.com
|
||||||
|
|
||||||
|
# Sin esquema (añade https:// automáticamente)
|
||||||
|
audit_http_headers example.com
|
||||||
|
|
||||||
|
# Seguir redirecciones
|
||||||
|
audit_http_headers http://example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa `curl -sI --location` para seguir redirecciones y obtener solo cabeceras. El check de HSTS valida que `max-age` sea >= 15.768.000 segundos (6 meses), valor mínimo recomendado por OWASP. Las cabeceras Server, X-Powered-By, X-AspNet-Version y X-Generator se marcan como advertencia por revelar información del stack tecnológico. Timeout de 15 segundos para evitar cuelgues.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# audit_http_headers
|
||||||
|
# ------------------
|
||||||
|
# Audita las cabeceras HTTP de seguridad de una URL: HSTS, CSP, X-Frame-Options,
|
||||||
|
# X-Content-Type-Options, Referrer-Policy, Permissions-Policy y otras.
|
||||||
|
# También muestra cabeceras que exponen información del servidor.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# audit_http_headers <url>
|
||||||
|
#
|
||||||
|
# Depende de: curl
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_hdr_normalize_url() {
|
||||||
|
local url="$1"
|
||||||
|
if [[ ! "$url" =~ ^https?:// ]]; then
|
||||||
|
echo "https://${url}"
|
||||||
|
else
|
||||||
|
echo "$url"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_hdr_header_present() {
|
||||||
|
local headers="$1"
|
||||||
|
local name="$2"
|
||||||
|
echo "$headers" | grep -qi "^${name}:"
|
||||||
|
}
|
||||||
|
|
||||||
|
_hdr_extract_value() {
|
||||||
|
local headers="$1"
|
||||||
|
local name="$2"
|
||||||
|
echo "$headers" | grep -i "^${name}:" | cut -d: -f2- | xargs
|
||||||
|
}
|
||||||
|
|
||||||
|
_hdr_hsts_is_strong() {
|
||||||
|
local value="$1"
|
||||||
|
local max_age
|
||||||
|
max_age="$(echo "$value" | grep -oE 'max-age=[0-9]+' | cut -d= -f2 || echo 0)"
|
||||||
|
[[ "$max_age" -ge 15768000 ]] # 6 meses en segundos
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_hdr_fetch() {
|
||||||
|
local url="$1"
|
||||||
|
curl -sI --max-time 15 --location "$url" 2>/dev/null | tr -d '\r'
|
||||||
|
}
|
||||||
|
|
||||||
|
_hdr_check_header() {
|
||||||
|
local headers="$1"
|
||||||
|
local name="$2"
|
||||||
|
local description="$3"
|
||||||
|
|
||||||
|
if _hdr_header_present "$headers" "$name"; then
|
||||||
|
local value
|
||||||
|
value="$(_hdr_extract_value "$headers" "$name")"
|
||||||
|
echo -e " ${GREEN}[ok]${NC} ${name}"
|
||||||
|
echo -e " ${GRAY}${value}${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[x] ${NC} ${name} -- ${description}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_hdr_check_hsts() {
|
||||||
|
local headers="$1"
|
||||||
|
local name="Strict-Transport-Security"
|
||||||
|
if _hdr_header_present "$headers" "$name"; then
|
||||||
|
local value
|
||||||
|
value="$(_hdr_extract_value "$headers" "$name")"
|
||||||
|
if _hdr_hsts_is_strong "$value"; then
|
||||||
|
echo -e " ${GREEN}[ok]${NC} ${name}"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}[!] ${NC} ${name} -- max-age demasiado corto (<6 meses)"
|
||||||
|
fi
|
||||||
|
echo -e " ${GRAY}${value}${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[x] ${NC} ${name} -- HSTS no configurado"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_hdr_show_server_info() {
|
||||||
|
local headers="$1"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}── Información del servidor ──────────────────────${NC}"
|
||||||
|
|
||||||
|
for h in Server X-Powered-By X-AspNet-Version X-Generator; do
|
||||||
|
if _hdr_header_present "$headers" "$h"; then
|
||||||
|
local val
|
||||||
|
val="$(_hdr_extract_value "$headers" "$h")"
|
||||||
|
echo -e " ${YELLOW}[!]${NC} ${h}: ${val} (información expuesta)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local status
|
||||||
|
status="$(echo "$headers" | head -1)"
|
||||||
|
echo -e " ${CYAN}Status:${NC} ${status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
audit_http_headers() {
|
||||||
|
local raw_url="$1"
|
||||||
|
|
||||||
|
if [[ -z "$raw_url" ]]; then
|
||||||
|
error "audit_http_headers: se requiere una URL como argumento" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v curl &>/dev/null; then
|
||||||
|
error "audit_http_headers: 'curl' no está instalado (sudo apt install curl)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local url
|
||||||
|
url="$(_hdr_normalize_url "$raw_url")"
|
||||||
|
|
||||||
|
info "Consultando cabeceras de: ${url}"
|
||||||
|
local headers
|
||||||
|
headers="$(_hdr_fetch "$url")"
|
||||||
|
|
||||||
|
if [[ -z "$headers" ]]; then
|
||||||
|
error "audit_http_headers: no se pudieron obtener las cabeceras. ¿El sitio está disponible?" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════ Cabeceras de Seguridad ════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
_hdr_check_hsts "$headers"
|
||||||
|
_hdr_check_header "$headers" "Content-Security-Policy" "Previene XSS e inyección de contenido"
|
||||||
|
_hdr_check_header "$headers" "X-Frame-Options" "Previene clickjacking"
|
||||||
|
_hdr_check_header "$headers" "X-Content-Type-Options" "Previene MIME sniffing"
|
||||||
|
_hdr_check_header "$headers" "Referrer-Policy" "Controla información del referrer"
|
||||||
|
_hdr_check_header "$headers" "Permissions-Policy" "Controla acceso a APIs del navegador"
|
||||||
|
_hdr_check_header "$headers" "Cross-Origin-Opener-Policy" "Aísla el contexto de navegación"
|
||||||
|
_hdr_check_header "$headers" "Cross-Origin-Resource-Policy" "Controla compartición de recursos"
|
||||||
|
|
||||||
|
_hdr_show_server_info "$headers"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
audit_http_headers "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
name: audit_ssh_config
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "audit_ssh_config(config_path: string) -> void"
|
||||||
|
description: "Audita la configuración de sshd_config evaluando parámetros de seguridad críticos (PermitRootLogin, PasswordAuthentication, Port, MaxAuthTries, X11Forwarding, AllowUsers). También revisa intentos de login fallidos en los logs y lista las claves autorizadas del usuario actual."
|
||||||
|
tags: [bash, cybersecurity, ssh, audit, security, hardening, linux]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: config_path
|
||||||
|
desc: "ruta al archivo sshd_config a auditar (por defecto: /etc/ssh/sshd_config)"
|
||||||
|
output: "imprime checks con nivel ok/warn/bad para cada parámetro, últimos 10 intentos de login fallidos y lista de claves autorizadas"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/audit_ssh_config.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/sistema/auditar_ssh.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/audit_ssh_config.sh
|
||||||
|
|
||||||
|
# Auditar la configuración por defecto
|
||||||
|
audit_ssh_config
|
||||||
|
|
||||||
|
# Auditar un archivo alternativo
|
||||||
|
audit_ssh_config /etc/ssh/sshd_config.d/custom.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Los logs de intentos fallidos se buscan primero en `journalctl` (systemd) y si no está disponible en `/var/log/auth.log`. Leer `/etc/ssh/sshd_config` puede requerir permisos de root en algunos sistemas. Los criterios de evaluación siguen las recomendaciones de CIS Benchmark para SSH: PermitRootLogin=no, PasswordAuthentication=no, MaxAuthTries<=3.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# audit_ssh_config
|
||||||
|
# ----------------
|
||||||
|
# Audita la configuración de sshd_config evaluando parámetros de seguridad,
|
||||||
|
# revisa intentos de login fallidos y lista las claves autorizadas del usuario.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# audit_ssh_config [/ruta/a/sshd_config]
|
||||||
|
#
|
||||||
|
# Depende de: grep, ssh (opcional para validación), journalctl o /var/log/auth.log
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ssh_get_value() {
|
||||||
|
local config="$1"
|
||||||
|
local key="$2"
|
||||||
|
grep -iE "^[[:space:]]*${key}[[:space:]]" "$config" 2>/dev/null \
|
||||||
|
| tail -1 | awk '{print $2}' | xargs
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_eval_permit_root() {
|
||||||
|
local val="${1:-yes}"
|
||||||
|
case "${val,,}" in
|
||||||
|
no|prohibit-password) echo "ok" ;;
|
||||||
|
without-password) echo "warn" ;;
|
||||||
|
*) echo "bad" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_eval_password_auth() {
|
||||||
|
local val="${1:-yes}"
|
||||||
|
[[ "${val,,}" == "no" ]] && echo "ok" || echo "bad"
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_eval_max_auth_tries() {
|
||||||
|
local val="${1:-6}"
|
||||||
|
[[ "$val" -le 3 ]] && echo "ok" || echo "warn"
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_eval_x11_forwarding() {
|
||||||
|
local val="${1:-no}"
|
||||||
|
[[ "${val,,}" == "no" ]] && echo "ok" || echo "warn"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de presentación ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ssh_print_check() {
|
||||||
|
local level="$1"
|
||||||
|
local label="$2"
|
||||||
|
local value="$3"
|
||||||
|
local note="$4"
|
||||||
|
|
||||||
|
case "$level" in
|
||||||
|
ok) echo -e " ${GREEN}[ok]${NC} ${label}: ${value}" ;;
|
||||||
|
warn) echo -e " ${YELLOW}[!] ${NC} ${label}: ${value} -- ${note}" ;;
|
||||||
|
bad) echo -e " ${RED}[x] ${NC} ${label}: ${value} -- ${note}" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_show_config_checks() {
|
||||||
|
local config="$1"
|
||||||
|
|
||||||
|
echo -e "${PURPLE}════════ Configuración sshd_config ════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local permit_root
|
||||||
|
permit_root="$(_ssh_get_value "$config" "PermitRootLogin")"
|
||||||
|
permit_root="${permit_root:-yes (por defecto)}"
|
||||||
|
_ssh_print_check "$(_ssh_eval_permit_root "$permit_root")" \
|
||||||
|
"PermitRootLogin" "$permit_root" "debería ser 'no' o 'prohibit-password'"
|
||||||
|
|
||||||
|
local pass_auth
|
||||||
|
pass_auth="$(_ssh_get_value "$config" "PasswordAuthentication")"
|
||||||
|
pass_auth="${pass_auth:-yes (por defecto)}"
|
||||||
|
_ssh_print_check "$(_ssh_eval_password_auth "$pass_auth")" \
|
||||||
|
"PasswordAuthentication" "$pass_auth" "debería ser 'no' (usar claves)"
|
||||||
|
|
||||||
|
local port
|
||||||
|
port="$(_ssh_get_value "$config" "Port")"
|
||||||
|
port="${port:-22 (por defecto)}"
|
||||||
|
if [[ "$port" == "22"* ]]; then
|
||||||
|
_ssh_print_check "warn" "Port" "$port" "considera cambiar el puerto 22"
|
||||||
|
else
|
||||||
|
_ssh_print_check "ok" "Port" "$port" ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
local max_tries
|
||||||
|
max_tries="$(_ssh_get_value "$config" "MaxAuthTries")"
|
||||||
|
max_tries="${max_tries:-6 (por defecto)}"
|
||||||
|
_ssh_print_check "$(_ssh_eval_max_auth_tries "${max_tries%% *}")" \
|
||||||
|
"MaxAuthTries" "$max_tries" "recomendado <= 3"
|
||||||
|
|
||||||
|
local x11
|
||||||
|
x11="$(_ssh_get_value "$config" "X11Forwarding")"
|
||||||
|
x11="${x11:-no (por defecto)}"
|
||||||
|
_ssh_print_check "$(_ssh_eval_x11_forwarding "$x11")" \
|
||||||
|
"X11Forwarding" "$x11" "deshabilitar si no se usa"
|
||||||
|
|
||||||
|
local allow_users allow_groups
|
||||||
|
allow_users="$(_ssh_get_value "$config" "AllowUsers")"
|
||||||
|
allow_groups="$(_ssh_get_value "$config" "AllowGroups")"
|
||||||
|
if [[ -z "$allow_users" && -z "$allow_groups" ]]; then
|
||||||
|
_ssh_print_check "warn" "AllowUsers/AllowGroups" "(no definidos)" "considera restringir acceso por usuario o grupo"
|
||||||
|
else
|
||||||
|
[[ -n "$allow_users" ]] && _ssh_print_check "ok" "AllowUsers" "$allow_users" ""
|
||||||
|
[[ -n "$allow_groups" ]] && _ssh_print_check "ok" "AllowGroups" "$allow_groups" ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_show_failed_logins() {
|
||||||
|
echo -e "${PURPLE}════════ Últimos intentos de login fallidos ════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if command -v journalctl &>/dev/null; then
|
||||||
|
journalctl -u ssh -u sshd --no-pager -q 2>/dev/null \
|
||||||
|
| grep -i "failed\|invalid\|error" | tail -10 \
|
||||||
|
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done || true
|
||||||
|
elif [[ -f /var/log/auth.log ]]; then
|
||||||
|
grep -i "failed\|invalid" /var/log/auth.log 2>/dev/null | tail -10 \
|
||||||
|
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done || true
|
||||||
|
else
|
||||||
|
info "No se encontró fuente de logs de autenticación"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_show_authorized_keys() {
|
||||||
|
echo -e "${PURPLE}════════ Claves autorizadas (~/.ssh) ═══════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local auth_keys="$HOME/.ssh/authorized_keys"
|
||||||
|
if [[ -f "$auth_keys" ]]; then
|
||||||
|
local count
|
||||||
|
count="$(wc -l < "$auth_keys")"
|
||||||
|
info "${count} clave(s) en authorized_keys:"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" || "$line" == "#"* ]] && continue
|
||||||
|
local key_type key_comment
|
||||||
|
key_type="$(echo "$line" | awk '{print $1}')"
|
||||||
|
key_comment="$(echo "$line" | awk '{print $NF}')"
|
||||||
|
echo -e " ${GREEN}*${NC} ${key_type} -- ${key_comment}"
|
||||||
|
done < "$auth_keys"
|
||||||
|
else
|
||||||
|
info "No existe $auth_keys"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
audit_ssh_config() {
|
||||||
|
local config_path="${1:-/etc/ssh/sshd_config}"
|
||||||
|
|
||||||
|
if [[ ! -f "$config_path" ]]; then
|
||||||
|
warning "audit_ssh_config: no se encontró $config_path -- ¿está instalado sshd?"
|
||||||
|
else
|
||||||
|
_ssh_show_config_checks "$config_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
_ssh_show_failed_logins
|
||||||
|
_ssh_show_authorized_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
audit_ssh_config "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: check_firewall
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "check_firewall() -> void"
|
||||||
|
description: "Detecta el firewall activo del sistema (ufw, firewalld o iptables) y muestra su estado, reglas activas y puertos en escucha para cruzar con las reglas. Si no se detecta ningún firewall, emite una advertencia de exposición."
|
||||||
|
tags: [bash, cybersecurity, firewall, ufw, iptables, network, hardening, linux]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params: []
|
||||||
|
output: "imprime el firewall detectado, su estado (activo/inactivo), reglas vigentes y lista de puertos en escucha"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/check_firewall.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/sistema/firewall_status.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/check_firewall.sh
|
||||||
|
|
||||||
|
check_firewall
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
La detección sigue el orden: ufw > firewalld > iptables > none. Para ufw muestra `ufw status verbose`; para firewalld muestra zona por defecto, servicios y puertos permitidos; para iptables muestra las cadenas INPUT/OUTPUT/FORWARD. Leer reglas de iptables requiere privilegios de root. El cruce de puertos en escucha (via `ss -tlnp`) ayuda a identificar servicios sin regla de firewall correspondiente.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check_firewall
|
||||||
|
# --------------
|
||||||
|
# Detecta el firewall activo del sistema (ufw, firewalld o iptables) y muestra
|
||||||
|
# su estado y reglas. También lista los puertos en escucha para cruzar con reglas.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# check_firewall
|
||||||
|
#
|
||||||
|
# Depende de: ufw, firewall-cmd o iptables (el que esté disponible), ss
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_fw_detect() {
|
||||||
|
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status:"; then
|
||||||
|
echo "ufw"
|
||||||
|
elif command -v firewall-cmd &>/dev/null && firewall-cmd --state 2>/dev/null | grep -q "running"; then
|
||||||
|
echo "firewalld"
|
||||||
|
elif command -v iptables &>/dev/null; then
|
||||||
|
echo "iptables"
|
||||||
|
else
|
||||||
|
echo "none"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_ufw_is_active() {
|
||||||
|
ufw status 2>/dev/null | grep -q "Status: active"
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_firewalld_is_running() {
|
||||||
|
firewall-cmd --state 2>/dev/null | grep -q "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_iptables_has_rules() {
|
||||||
|
local count
|
||||||
|
count="$(iptables -L INPUT --line-numbers 2>/dev/null | grep -c "^[0-9]" || echo 0)"
|
||||||
|
[[ "$count" -gt 0 ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_fw_show_ufw() {
|
||||||
|
echo -e "${PURPLE}════════ UFW ════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if _fw_ufw_is_active; then
|
||||||
|
success "UFW está activo"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[x]${NC} UFW está INACTIVO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Reglas activas:"
|
||||||
|
ufw status verbose 2>/dev/null | while IFS= read -r line; do
|
||||||
|
echo -e " ${DIM_GRAY}${line}${NC}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_show_firewalld() {
|
||||||
|
echo -e "${PURPLE}════════ FirewallD ══════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if _fw_firewalld_is_running; then
|
||||||
|
success "firewalld está activo"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[x]${NC} firewalld está INACTIVO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
local zone
|
||||||
|
zone="$(firewall-cmd --get-default-zone 2>/dev/null || echo "desconocida")"
|
||||||
|
info "Zona por defecto: ${zone}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
info "Servicios permitidos en zona ${zone}:"
|
||||||
|
firewall-cmd --zone="$zone" --list-services 2>/dev/null \
|
||||||
|
| tr ' ' '\n' | while IFS= read -r svc; do
|
||||||
|
echo -e " ${GREEN}*${NC} ${svc}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Puertos permitidos:"
|
||||||
|
firewall-cmd --zone="$zone" --list-ports 2>/dev/null \
|
||||||
|
| tr ' ' '\n' | while IFS= read -r port; do
|
||||||
|
[[ -n "$port" ]] && echo -e " ${YELLOW}*${NC} ${port}"
|
||||||
|
done || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_show_iptables() {
|
||||||
|
echo -e "${PURPLE}════════ iptables ═══════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for chain in INPUT OUTPUT FORWARD; do
|
||||||
|
echo -e "${CYAN}── ${chain} ──${NC}"
|
||||||
|
iptables -L "$chain" --line-numbers -n 2>/dev/null \
|
||||||
|
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! _fw_iptables_has_rules; then
|
||||||
|
echo -e " ${YELLOW}[!]${NC} No hay reglas INPUT definidas -- el sistema puede estar sin filtrar tráfico"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_show_none() {
|
||||||
|
echo ""
|
||||||
|
echo -e " ${RED}[x]${NC} No se detectó ningún firewall activo (ufw, firewalld, iptables)"
|
||||||
|
echo -e " ${YELLOW}[!]${NC} El sistema puede estar completamente expuesto"
|
||||||
|
echo ""
|
||||||
|
info "Para instalar y activar ufw: sudo apt install ufw && sudo ufw enable"
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_show_listening_crosscheck() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════ Puertos en escucha (para cruzar con reglas) ════${NC}"
|
||||||
|
echo ""
|
||||||
|
ss -tlnp 2>/dev/null | tail -n +2 | while IFS= read -r line; do
|
||||||
|
echo -e " ${DIM_GRAY}${line}${NC}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
check_firewall() {
|
||||||
|
local fw
|
||||||
|
fw="$(_fw_detect)"
|
||||||
|
|
||||||
|
info "Firewall detectado: ${fw}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
case "$fw" in
|
||||||
|
ufw) _fw_show_ufw ;;
|
||||||
|
firewalld) _fw_show_firewalld ;;
|
||||||
|
iptables) _fw_show_iptables ;;
|
||||||
|
none) _fw_show_none ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
_fw_show_listening_crosscheck
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
check_firewall "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: detect_suspicious_users
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "detect_suspicious_users() -> void"
|
||||||
|
description: "Revisa el sistema en busca de indicadores de compromiso en cuentas de usuario: UIDs 0 extras (además de root), usuarios con shell de login válida, homes en rutas inusuales, miembros de grupos privilegiados (sudo, docker, wheel, adm, etc.) y sesiones activas."
|
||||||
|
tags: [bash, cybersecurity, users, audit, linux, privilege-escalation, hardening]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params: []
|
||||||
|
output: "imprime secciones con UIDs 0, usuarios con shell, homes inusuales, grupos privilegiados, últimos logins y sesiones activas"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/detect_suspicious_users.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/sistema/usuarios_sospechosos.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/detect_suspicious_users.sh
|
||||||
|
|
||||||
|
detect_suspicious_users
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Los usuarios de sistema (UID <= 999) se excluyen del check de shell válida para evitar falsos positivos. Los grupos privilegiados monitorizados son: sudo, wheel, docker, adm, lxd, libvirt, kvm, disk, shadow. Homes inusuales son aquellos fuera de /home, /root, /var, /srv, /nonexistent y /tmp. `lastlog` puede no estar disponible en todas las distribuciones.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# detect_suspicious_users
|
||||||
|
# -----------------------
|
||||||
|
# Revisa el sistema en busca de usuarios potencialmente sospechosos:
|
||||||
|
# UIDs 0 extras, shells válidas, homes en rutas inusuales, grupos privilegiados
|
||||||
|
# y sesiones activas.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# detect_suspicious_users
|
||||||
|
#
|
||||||
|
# Depende de: /etc/passwd, getent, w, lastlog
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Constantes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SUSPICIOUS_VALID_SHELLS=("/bin/bash" "/bin/sh" "/bin/zsh" "/bin/fish" "/usr/bin/bash" "/usr/bin/zsh" "/usr/bin/fish")
|
||||||
|
_SUSPICIOUS_PRIVILEGED_GROUPS=("sudo" "wheel" "docker" "adm" "lxd" "libvirt" "kvm" "disk" "shadow")
|
||||||
|
_SUSPICIOUS_SYSTEM_USERS_MAX_UID=999
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_sus_is_valid_shell() {
|
||||||
|
local shell="$1"
|
||||||
|
for s in "${_SUSPICIOUS_VALID_SHELLS[@]}"; do
|
||||||
|
[[ "$shell" == "$s" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_is_system_user() {
|
||||||
|
local uid="$1"
|
||||||
|
[[ "$uid" -le $_SUSPICIOUS_SYSTEM_USERS_MAX_UID ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_is_unusual_home() {
|
||||||
|
local home="$1"
|
||||||
|
[[ ! "$home" =~ ^(/home|/root|/var|/srv|/nonexistent|/tmp) ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_sus_show_uid0_users() {
|
||||||
|
echo -e "${PURPLE}════════ Usuarios con UID 0 (root) ═════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
while IFS=: read -r username _ uid _; do
|
||||||
|
if [[ "$uid" -eq 0 ]]; then
|
||||||
|
if [[ "$username" == "root" ]]; then
|
||||||
|
echo -e " ${GREEN}[ok]${NC} root (esperado)"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[x]${NC} ${username} tiene UID 0 -- SOSPECHOSO"
|
||||||
|
fi
|
||||||
|
found=$((found + 1))
|
||||||
|
fi
|
||||||
|
done < /etc/passwd
|
||||||
|
|
||||||
|
if [[ $found -eq 1 ]]; then
|
||||||
|
echo ""
|
||||||
|
success "Solo root tiene UID 0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_show_users_with_shell() {
|
||||||
|
echo -e "${PURPLE}════════ Usuarios con shell de login válida ═════${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GRAY}(excluye usuarios de sistema con UID <= ${_SUSPICIOUS_SYSTEM_USERS_MAX_UID})${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
while IFS=: read -r username _ uid _ _ home shell; do
|
||||||
|
if ! _sus_is_system_user "$uid" && _sus_is_valid_shell "$shell"; then
|
||||||
|
echo -e " ${CYAN}*${NC} ${username} (UID ${uid}) -- shell: ${shell} -- home: ${home}"
|
||||||
|
found=$((found + 1))
|
||||||
|
fi
|
||||||
|
done < /etc/passwd
|
||||||
|
|
||||||
|
[[ $found -eq 0 ]] && info "No se encontraron usuarios normales con shell válida"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_show_unusual_homes() {
|
||||||
|
echo -e "${PURPLE}════════ Usuarios con home inusual ══════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
while IFS=: read -r username _ uid _ _ home shell; do
|
||||||
|
if _sus_is_valid_shell "$shell" && _sus_is_unusual_home "$home"; then
|
||||||
|
echo -e " ${YELLOW}[!]${NC} ${username} -- home: ${home}"
|
||||||
|
found=$((found + 1))
|
||||||
|
fi
|
||||||
|
done < /etc/passwd
|
||||||
|
|
||||||
|
[[ $found -eq 0 ]] && success "No se detectaron homes en rutas inusuales"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_show_privileged_groups() {
|
||||||
|
echo -e "${PURPLE}════════ Grupos privilegiados y sus miembros ════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for group in "${_SUSPICIOUS_PRIVILEGED_GROUPS[@]}"; do
|
||||||
|
local members
|
||||||
|
members="$(getent group "$group" 2>/dev/null | cut -d: -f4 || true)"
|
||||||
|
if [[ -n "$members" ]]; then
|
||||||
|
echo -e " ${YELLOW}*${NC} ${group}: ${members}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_show_active_sessions() {
|
||||||
|
echo -e "${PURPLE}════════ Sesiones activas ═══════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
w 2>/dev/null | while IFS= read -r line; do
|
||||||
|
echo -e " ${DIM_GRAY}${line}${NC}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_show_last_logins() {
|
||||||
|
echo -e "${PURPLE}════════ Últimos logins por usuario ═════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if command -v lastlog &>/dev/null; then
|
||||||
|
lastlog 2>/dev/null | awk 'NR==1 || $NF != "logged" {
|
||||||
|
if (NR==1 || $2 != "**Never") printf " %-16s %-10s %s\n", $1, $2, $NF
|
||||||
|
}' | grep -v "^$" | while IFS= read -r line; do
|
||||||
|
echo -e " ${DIM_GRAY}${line}${NC}"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
warning "lastlog no disponible"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
detect_suspicious_users() {
|
||||||
|
_sus_show_uid0_users
|
||||||
|
_sus_show_users_with_shell
|
||||||
|
_sus_show_unusual_homes
|
||||||
|
_sus_show_privileged_groups
|
||||||
|
_sus_show_last_logins
|
||||||
|
_sus_show_active_sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
detect_suspicious_users "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: encrypt_file
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "encrypt_file(mode: string, file: string) -> void"
|
||||||
|
description: "Cifra o descifra un archivo usando AES-256-CBC con PBKDF2 (310.000 iteraciones) via openssl. La contraseña se lee de la variable de entorno ENCRYPT_PASSWORD o se solicita interactivamente. El archivo cifrado se guarda con extensión .enc; al descifrar se recupera el nombre original."
|
||||||
|
tags: [bash, cybersecurity, encryption, aes256, openssl, crypto, pbkdf2]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: mode
|
||||||
|
desc: "operación a realizar: encrypt (cifrar) o decrypt (descifrar)"
|
||||||
|
- name: file
|
||||||
|
desc: "ruta al archivo a cifrar o descifrar"
|
||||||
|
output: "genera el archivo cifrado (input.enc) o descifrado (input sin .enc, o input.dec) e imprime progreso a stdout"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/encrypt_file.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/utilidades/cifrar_archivo.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/encrypt_file.sh
|
||||||
|
|
||||||
|
# Cifrar (solicita contraseña interactivamente)
|
||||||
|
encrypt_file encrypt documento.pdf
|
||||||
|
|
||||||
|
# Descifrar
|
||||||
|
encrypt_file decrypt documento.pdf.enc
|
||||||
|
|
||||||
|
# Con contraseña via variable de entorno (no interactivo)
|
||||||
|
ENCRYPT_PASSWORD="mi-secreto-seguro" encrypt_file encrypt datos.tar.gz
|
||||||
|
ENCRYPT_PASSWORD="mi-secreto-seguro" encrypt_file decrypt datos.tar.gz.enc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa `openssl enc -aes-256-cbc -pbkdf2 -iter 310000` — compatible con OpenSSL 1.1.1+. Las 310.000 iteraciones de PBKDF2 siguen las recomendaciones NIST para derivación de claves en 2024. La contraseña se limpia de memoria al terminar. Si el archivo de salida ya existe, la función falla silenciosamente (no sobrescribe por seguridad cuando se usa con ENCRYPT_PASSWORD).
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# encrypt_file
|
||||||
|
# ------------
|
||||||
|
# Cifra o descifra un archivo usando AES-256-CBC con PBKDF2 (310.000 iteraciones).
|
||||||
|
# La contraseña se lee de la variable de entorno ENCRYPT_PASSWORD o se solicita
|
||||||
|
# interactivamente por stdin.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# encrypt_file encrypt <archivo>
|
||||||
|
# encrypt_file decrypt <archivo.enc>
|
||||||
|
# ENCRYPT_PASSWORD=secreto encrypt_file encrypt archivo.txt
|
||||||
|
#
|
||||||
|
# Depende de: openssl
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_enc_output_path_encrypt() {
|
||||||
|
local input="$1"
|
||||||
|
echo "${input}.enc"
|
||||||
|
}
|
||||||
|
|
||||||
|
_enc_output_path_decrypt() {
|
||||||
|
local input="$1"
|
||||||
|
if [[ "$input" == *.enc ]]; then
|
||||||
|
echo "${input%.enc}"
|
||||||
|
else
|
||||||
|
echo "${input}.dec"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_enc_human_size() {
|
||||||
|
local file="$1"
|
||||||
|
du -sh "$file" 2>/dev/null | cut -f1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────name──────────
|
||||||
|
|
||||||
|
_enc_ask_password() {
|
||||||
|
local pass
|
||||||
|
read -rsp "Contraseña: " pass
|
||||||
|
echo "" >&2
|
||||||
|
echo "$pass"
|
||||||
|
}
|
||||||
|
|
||||||
|
_enc_ask_password_confirm() {
|
||||||
|
local pass1 pass2
|
||||||
|
read -rsp "Contraseña: " pass1
|
||||||
|
echo "" >&2
|
||||||
|
read -rsp "Confirmar contraseña: " pass2
|
||||||
|
echo "" >&2
|
||||||
|
|
||||||
|
if [[ "$pass1" != "$pass2" ]]; then
|
||||||
|
error "Las contraseñas no coinciden" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#pass1} -lt 8 ]]; then
|
||||||
|
warning "La contraseña es muy corta (mínimo 8 caracteres recomendado)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$pass1"
|
||||||
|
}
|
||||||
|
|
||||||
|
_enc_do_encrypt() {
|
||||||
|
local input="$1"
|
||||||
|
local output="$2"
|
||||||
|
local password="$3"
|
||||||
|
|
||||||
|
openssl enc -aes-256-cbc -pbkdf2 -iter 310000 \
|
||||||
|
-in "$input" -out "$output" \
|
||||||
|
-pass "pass:${password}" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_enc_do_decrypt() {
|
||||||
|
local input="$1"
|
||||||
|
local output="$2"
|
||||||
|
local password="$3"
|
||||||
|
|
||||||
|
openssl enc -d -aes-256-cbc -pbkdf2 -iter 310000 \
|
||||||
|
-in "$input" -out "$output" \
|
||||||
|
-pass "pass:${password}" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
encrypt_file() {
|
||||||
|
local mode="$1"
|
||||||
|
local file="$2"
|
||||||
|
|
||||||
|
if [[ -z "$mode" || -z "$file" ]]; then
|
||||||
|
error "encrypt_file: uso: encrypt_file <encrypt|decrypt> <archivo>" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v openssl &>/dev/null; then
|
||||||
|
error "encrypt_file: 'openssl' no está instalado (sudo apt install openssl)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$file" ]]; then
|
||||||
|
error "encrypt_file: archivo no encontrado: $file" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local password
|
||||||
|
if [[ -n "${ENCRYPT_PASSWORD:-}" ]]; then
|
||||||
|
password="$ENCRYPT_PASSWORD"
|
||||||
|
else
|
||||||
|
case "$mode" in
|
||||||
|
encrypt) password="$(_enc_ask_password_confirm)" || return 1 ;;
|
||||||
|
decrypt) password="$(_enc_ask_password)" ;;
|
||||||
|
*)
|
||||||
|
error "encrypt_file: modo no válido '$mode'. Use: encrypt|decrypt" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
encrypt)
|
||||||
|
local output
|
||||||
|
output="$(_enc_output_path_encrypt "$file")"
|
||||||
|
info "Archivo: ${file} ($(_enc_human_size "$file"))"
|
||||||
|
info "Salida: ${output}"
|
||||||
|
info "Cifrando con AES-256-CBC + PBKDF2..."
|
||||||
|
|
||||||
|
if _enc_do_encrypt "$file" "$output" "$password"; then
|
||||||
|
success "Archivo cifrado: ${output} ($(_enc_human_size "$output"))"
|
||||||
|
else
|
||||||
|
error "encrypt_file: el cifrado falló" >&2
|
||||||
|
rm -f "$output"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
decrypt)
|
||||||
|
local output
|
||||||
|
output="$(_enc_output_path_decrypt "$file")"
|
||||||
|
info "Archivo: ${file} ($(_enc_human_size "$file"))"
|
||||||
|
info "Salida: ${output}"
|
||||||
|
info "Descifrando..."
|
||||||
|
|
||||||
|
if _enc_do_decrypt "$file" "$output" "$password"; then
|
||||||
|
success "Archivo descifrado: ${output} ($(_enc_human_size "$output"))"
|
||||||
|
else
|
||||||
|
error "encrypt_file: el descifrado falló -- ¿contraseña incorrecta?" >&2
|
||||||
|
rm -f "$output"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Limpiar contraseña de memoria
|
||||||
|
password=""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
encrypt_file "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: enumerate_subdomains
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "enumerate_subdomains(domain: string, output_file: string) -> void"
|
||||||
|
description: "Enumera subdominios de un dominio objetivo usando un diccionario integrado de ~100 subdominios comunes (www, mail, api, dev, admin, vpn, etc.). Detecta tanto registros A (IP directa) como CNAME. Muestra progreso cada 20 subdominios y opcionalmente guarda los resultados en un archivo."
|
||||||
|
tags: [bash, cybersecurity, dns, subdomain, enumeration, reconnaissance, osint]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: domain
|
||||||
|
desc: "dominio objetivo a enumerar, ej: example.com"
|
||||||
|
- name: output_file
|
||||||
|
desc: "ruta al archivo donde guardar los resultados (opcional; si se omite, solo imprime a stdout)"
|
||||||
|
output: "imprime subdominios encontrados con su IP o CNAME, progreso cada 20 entradas y resumen final con total encontrados"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/enumerate_subdomains.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/web/subdominios.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/enumerate_subdomains.sh
|
||||||
|
|
||||||
|
# Solo imprimir resultados
|
||||||
|
enumerate_subdomains example.com
|
||||||
|
|
||||||
|
# Guardar resultados en archivo
|
||||||
|
enumerate_subdomains example.com /tmp/subdominios.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `dig` (paquete dnsutils). El diccionario integrado cubre subdominios comunes en entornos corporativos y de desarrollo: www, api, dev, admin, vpn, git, jenkins, staging, prod, db, mail, smtp, ns1/ns2, grafana, kibana, docker, k8s, auth, sso, etc. (~100 entradas). La enumeración es puramente pasiva via DNS — no realiza ningún tipo de conexión al servidor web. Los subdominios con CNAME sin resolución A se marcan en amarillo.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# enumerate_subdomains
|
||||||
|
# --------------------
|
||||||
|
# Enumera subdominios de un dominio objetivo usando un diccionario integrado de
|
||||||
|
# ~100 subdominios comunes. Detecta tanto registros A (IP directa) como CNAME.
|
||||||
|
# Opcionalmente guarda el resultado en un archivo.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# enumerate_subdomains <dominio> [archivo_salida]
|
||||||
|
# enumerate_subdomains example.com
|
||||||
|
# enumerate_subdomains example.com /tmp/resultado.txt
|
||||||
|
#
|
||||||
|
# Depende de: dig (dnsutils)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Diccionario integrado ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SUBDOMAIN_WORDLIST=(
|
||||||
|
www mail ftp api dev admin vpn ssh git gitlab github jenkins ci cd
|
||||||
|
staging prod test demo beta alpha app web portal intranet extranet
|
||||||
|
remote desktop files cdn static assets media img images upload
|
||||||
|
db database mysql postgres redis mongo smtp pop imap webmail mx
|
||||||
|
ns1 ns2 dns autodiscover autoconfig crm erp shop store payment
|
||||||
|
backup old legacy v1 v2 v3 internal corp office support helpdesk
|
||||||
|
wiki docs doc status monitor grafana kibana elastic search
|
||||||
|
registry docker k8s kubernetes auth sso login oauth api2 mobile
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_sub_is_valid_domain() {
|
||||||
|
[[ -n "$1" && "$1" =~ ^[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
_sub_build_fqdn() {
|
||||||
|
local sub="$1"
|
||||||
|
local domain="$2"
|
||||||
|
echo "${sub}.${domain}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_sub_resolve_a() {
|
||||||
|
local fqdn="$1"
|
||||||
|
dig +short A "$fqdn" 2>/dev/null | grep -E '^[0-9]+\.' | head -1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
_sub_resolve_cname() {
|
||||||
|
local fqdn="$1"
|
||||||
|
dig +short CNAME "$fqdn" 2>/dev/null | head -1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enumerate_subdomains() {
|
||||||
|
local domain="$1"
|
||||||
|
local output_file="${2:-}"
|
||||||
|
|
||||||
|
if [[ -z "$domain" ]]; then
|
||||||
|
error "enumerate_subdomains: se requiere un dominio como argumento" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! _sub_is_valid_domain "$domain"; then
|
||||||
|
error "enumerate_subdomains: dominio no válido: '$domain'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v dig &>/dev/null; then
|
||||||
|
error "enumerate_subdomains: 'dig' no está instalado (sudo apt install dnsutils)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local total="${#_SUBDOMAIN_WORDLIST[@]}"
|
||||||
|
local found=0
|
||||||
|
local checked=0
|
||||||
|
|
||||||
|
info "Probando ${total} subdominios en ${domain}..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ -n "$output_file" ]]; then
|
||||||
|
echo "# Subdominios encontrados en ${domain} -- $(date)" > "$output_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for sub in "${_SUBDOMAIN_WORDLIST[@]}"; do
|
||||||
|
local fqdn
|
||||||
|
fqdn="$(_sub_build_fqdn "$sub" "$domain")"
|
||||||
|
checked=$((checked + 1))
|
||||||
|
|
||||||
|
local ip
|
||||||
|
ip="$(_sub_resolve_a "$fqdn")"
|
||||||
|
|
||||||
|
if [[ -n "$ip" ]]; then
|
||||||
|
echo -e " ${GREEN}[ok]${NC} ${fqdn} -> ${CYAN}${ip}${NC}"
|
||||||
|
[[ -n "$output_file" ]] && echo "${fqdn} -> ${ip}" >> "$output_file"
|
||||||
|
found=$((found + 1))
|
||||||
|
else
|
||||||
|
local cname
|
||||||
|
cname="$(_sub_resolve_cname "$fqdn")"
|
||||||
|
if [[ -n "$cname" ]]; then
|
||||||
|
echo -e " ${YELLOW}[cn]${NC} ${fqdn} -> CNAME: ${cname}"
|
||||||
|
[[ -n "$output_file" ]] && echo "${fqdn} -> CNAME: ${cname}" >> "$output_file"
|
||||||
|
found=$((found + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Progreso cada 20 subdominios
|
||||||
|
if (( checked % 20 == 0 )); then
|
||||||
|
echo -e " ${DIM_GRAY}[${checked}/${total} probados, ${found} encontrados]${NC}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
|
||||||
|
if [[ $found -eq 0 ]]; then
|
||||||
|
info "No se encontraron subdominios en el diccionario"
|
||||||
|
else
|
||||||
|
success "Total encontrados: ${found} de ${total} probados"
|
||||||
|
[[ -n "$output_file" ]] && info "Resultado guardado en: ${output_file}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
enumerate_subdomains "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: generate_password
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "generate_password(mode: string, length: int, count: int) -> void"
|
||||||
|
description: "Genera contraseñas seguras en cuatro modos: full (alfanumérico + símbolos, excluye caracteres ambiguos), alpha (solo alfanumérico), passphrase (palabras aleatorias unidas con guión) y pin (numérico). Calcula y muestra la entropía en bits para cada modo."
|
||||||
|
tags: [bash, cybersecurity, password, generator, entropy, security, urandom]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: mode
|
||||||
|
desc: "modo de generación: full (alfanumérico+símbolos, por defecto), alpha (solo letras y números), passphrase (palabras), pin (numérico)"
|
||||||
|
- name: length
|
||||||
|
desc: "longitud en caracteres para full/alpha/pin, o número de palabras para passphrase (por defecto: 16)"
|
||||||
|
- name: count
|
||||||
|
desc: "número de contraseñas a generar (por defecto: 1)"
|
||||||
|
output: "imprime las contraseñas generadas a stdout (una por línea) con información de entropía"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/generate_password.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/utilidades/generar_password.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/generate_password.sh
|
||||||
|
|
||||||
|
# Contraseña completa de 20 caracteres
|
||||||
|
generate_password full 20
|
||||||
|
|
||||||
|
# 5 contraseñas alfanuméricas de 16 caracteres
|
||||||
|
generate_password alpha 16 5
|
||||||
|
|
||||||
|
# Passphrase de 6 palabras
|
||||||
|
generate_password passphrase 6
|
||||||
|
|
||||||
|
# PIN de 8 dígitos
|
||||||
|
generate_password pin 8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa `/dev/urandom` como fuente de aleatoriedad criptográficamente segura. El modo `full` excluye caracteres ambiguos (0, O, l, I, 1) para mejorar legibilidad. El modo `passphrase` requiere un diccionario del sistema (`/usr/share/dict/words` o similar). La entropía se calcula como log2(charset^length) en bits. Las contraseñas nunca se escriben a disco.
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# generate_password
|
||||||
|
# -----------------
|
||||||
|
# Genera contraseñas seguras en varios modos: completo (alfanumérico + símbolos),
|
||||||
|
# solo alfanumérico, passphrase de palabras o PIN numérico.
|
||||||
|
# Calcula la entropía en bits para cada contraseña generada.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# generate_password [full|alpha|passphrase|pin] [longitud] [cantidad]
|
||||||
|
#
|
||||||
|
# Depende de: /dev/urandom, python3 (para entropía), shuf (para passphrases)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Constantes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_GENPW_DEFAULT_LENGTH=16
|
||||||
|
_GENPW_DEFAULT_COUNT=1
|
||||||
|
_GENPW_WORDLIST_PATHS=("/usr/share/dict/words" "/usr/dict/words" "/usr/share/dict/american-english")
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_genpw_find_wordlist() {
|
||||||
|
for path in "${_GENPW_WORDLIST_PATHS[@]}"; do
|
||||||
|
[[ -f "$path" ]] && echo "$path" && return
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_genpw_calc_entropy() {
|
||||||
|
local charset_size="$1"
|
||||||
|
local length="$2"
|
||||||
|
python3 -c "import math; print(f'{math.log2(${charset_size}**${length}):.1f}')" 2>/dev/null || echo "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de generación ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_genpw_gen_full() {
|
||||||
|
local length="$1"
|
||||||
|
# Alfanumérico + símbolos (excluye ambiguos: 0OlI1)
|
||||||
|
tr -dc 'A-HJ-NP-Za-km-z2-9!@#$%^&*()_+-=[]{}|;:,.<>?' \
|
||||||
|
< /dev/urandom | head -c "$length"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
_genpw_gen_alpha() {
|
||||||
|
local length="$1"
|
||||||
|
tr -dc 'A-HJ-NP-Za-km-z2-9' \
|
||||||
|
< /dev/urandom | head -c "$length"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
_genpw_gen_passphrase() {
|
||||||
|
local words="$1"
|
||||||
|
local wordlist
|
||||||
|
wordlist="$(_genpw_find_wordlist)"
|
||||||
|
|
||||||
|
if [[ -z "$wordlist" ]]; then
|
||||||
|
error "generate_password: no se encontró diccionario (sudo apt install wamerican)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local phrase=""
|
||||||
|
for ((i=0; i<words; i++)); do
|
||||||
|
local word
|
||||||
|
word="$(shuf -n1 "$wordlist" | tr -dc 'a-z' | head -c 20)"
|
||||||
|
[[ ${#word} -lt 3 ]] && { i=$((i-1)); continue; }
|
||||||
|
phrase="${phrase}${word}-"
|
||||||
|
done
|
||||||
|
echo "${phrase%-}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_genpw_gen_pin() {
|
||||||
|
local length="$1"
|
||||||
|
tr -dc '0-9' < /dev/urandom | head -c "$length"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
generate_password() {
|
||||||
|
local mode="${1:-full}"
|
||||||
|
local length="${2:-$_GENPW_DEFAULT_LENGTH}"
|
||||||
|
local count="${3:-$_GENPW_DEFAULT_COUNT}"
|
||||||
|
|
||||||
|
# Validar que length y count son numéricos
|
||||||
|
if ! [[ "$length" =~ ^[0-9]+$ ]] || ! [[ "$count" =~ ^[0-9]+$ ]]; then
|
||||||
|
error "generate_password: longitud y cantidad deben ser números enteros positivos" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local charset_size entropy
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
full)
|
||||||
|
charset_size=78
|
||||||
|
entropy="$(_genpw_calc_entropy $charset_size "$length")"
|
||||||
|
info "Contraseñas alfanuméricas + símbolos (longitud: ${length}, entropía: ~${entropy} bits)"
|
||||||
|
echo ""
|
||||||
|
for ((i=1; i<=count; i++)); do
|
||||||
|
_genpw_gen_full "$length"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
alpha)
|
||||||
|
charset_size=56
|
||||||
|
entropy="$(_genpw_calc_entropy $charset_size "$length")"
|
||||||
|
info "Contraseñas alfanuméricas (longitud: ${length}, entropía: ~${entropy} bits)"
|
||||||
|
echo ""
|
||||||
|
for ((i=1; i<=count; i++)); do
|
||||||
|
_genpw_gen_alpha "$length"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
passphrase)
|
||||||
|
info "Passphrases (${length} palabras)"
|
||||||
|
echo ""
|
||||||
|
for ((i=1; i<=count; i++)); do
|
||||||
|
_genpw_gen_passphrase "$length" || return 1
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
pin)
|
||||||
|
charset_size=10
|
||||||
|
entropy="$(_genpw_calc_entropy $charset_size "$length")"
|
||||||
|
info "PINs numéricos (longitud: ${length}, entropía: ~${entropy} bits)"
|
||||||
|
echo ""
|
||||||
|
for ((i=1; i<=count; i++)); do
|
||||||
|
_genpw_gen_pin "$length"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
error "generate_password: modo no válido '$mode'. Use: full|alpha|passphrase|pin" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
generate_password "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
name: geolocate_ip
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "geolocate_ip(target: string) -> void"
|
||||||
|
description: "Geolocaliza una dirección IP o dominio usando la API pública de ip-api.com. Muestra país, región, ciudad, coordenadas, ISP, ASN y detecta VPN, Proxy o infraestructura de hosting."
|
||||||
|
tags: [bash, cybersecurity, network, geoip, ip, osint, reconnaissance]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: target
|
||||||
|
desc: "dirección IP (IPv4) o nombre de dominio a geolocalizar"
|
||||||
|
output: "imprime información de geolocalización a stdout: país, ciudad, ISP, ASN, coordenadas y flags de VPN/Proxy/Hosting"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/geolocate_ip.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/redes/geoip.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/geolocate_ip.sh
|
||||||
|
|
||||||
|
# Geolocalizar una IP
|
||||||
|
geolocate_ip 8.8.8.8
|
||||||
|
|
||||||
|
# Geolocalizar un dominio (resuelve a IP primero)
|
||||||
|
geolocate_ip example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `curl`. Si se pasa un dominio en lugar de una IP, se resuelve a IP usando `dig` antes de consultar la API. La API de ip-api.com es gratuita para uso no comercial con límite de 45 req/min. Los campos `proxy=true` y `hosting=true` indican posible uso de VPN, proxy Tor o datacenter.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# geolocate_ip
|
||||||
|
# ------------
|
||||||
|
# Geolocaliza una IP o dominio usando la API pública de ip-api.com.
|
||||||
|
# Muestra país, ciudad, ISP, ASN y detecta VPN/Proxy/Hosting.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# geolocate_ip <ip_o_dominio>
|
||||||
|
#
|
||||||
|
# Depende de: curl, dig (para resolver dominios)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Constantes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_GEOIP_API="http://ip-api.com/json"
|
||||||
|
_GEOIP_FIELDS="status,message,country,countryCode,regionName,city,zip,lat,lon,isp,org,as,proxy,hosting,query"
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_geo_is_ip() {
|
||||||
|
[[ "$1" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
_geo_build_api_url() {
|
||||||
|
local target="$1"
|
||||||
|
echo "${_GEOIP_API}/${target}?fields=${_GEOIP_FIELDS}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_geo_extract_field() {
|
||||||
|
local json="$1"
|
||||||
|
local key="$2"
|
||||||
|
echo "$json" | grep -o "\"${key}\":[^,}]*" | cut -d: -f2- | tr -d '"' | xargs
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_geo_resolve_domain() {
|
||||||
|
local domain="$1"
|
||||||
|
dig +short A "$domain" 2>/dev/null | head -1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
_geo_fetch() {
|
||||||
|
local target="$1"
|
||||||
|
local url
|
||||||
|
url="$(_geo_build_api_url "$target")"
|
||||||
|
curl -s --max-time 10 "$url" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_geo_display_result() {
|
||||||
|
local json="$1"
|
||||||
|
|
||||||
|
local status
|
||||||
|
status="$(_geo_extract_field "$json" "status")"
|
||||||
|
if [[ "$status" != "success" ]]; then
|
||||||
|
local msg
|
||||||
|
msg="$(_geo_extract_field "$json" "message")"
|
||||||
|
error "La API devolvió error: ${msg:-respuesta inesperada}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local ip_queried country country_code region city zip lat lon isp org asn proxy hosting
|
||||||
|
ip_queried="$(_geo_extract_field "$json" "query")"
|
||||||
|
country="$(_geo_extract_field "$json" "country")"
|
||||||
|
country_code="$(_geo_extract_field "$json" "countryCode")"
|
||||||
|
region="$(_geo_extract_field "$json" "regionName")"
|
||||||
|
city="$(_geo_extract_field "$json" "city")"
|
||||||
|
zip="$(_geo_extract_field "$json" "zip")"
|
||||||
|
lat="$(_geo_extract_field "$json" "lat")"
|
||||||
|
lon="$(_geo_extract_field "$json" "lon")"
|
||||||
|
isp="$(_geo_extract_field "$json" "isp")"
|
||||||
|
org="$(_geo_extract_field "$json" "org")"
|
||||||
|
asn="$(_geo_extract_field "$json" "as")"
|
||||||
|
proxy="$(_geo_extract_field "$json" "proxy")"
|
||||||
|
hosting="$(_geo_extract_field "$json" "hosting")"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e " ${CYAN}IP consultada:${NC} ${ip_queried}"
|
||||||
|
echo -e " ${CYAN}País:${NC} ${country} (${country_code})"
|
||||||
|
echo -e " ${CYAN}Región:${NC} ${region}"
|
||||||
|
echo -e " ${CYAN}Ciudad:${NC} ${city} ${zip}"
|
||||||
|
echo -e " ${CYAN}Coordenadas:${NC} ${lat}, ${lon}"
|
||||||
|
echo -e " ${CYAN}ISP:${NC} ${isp}"
|
||||||
|
echo -e " ${CYAN}Organización:${NC} ${org}"
|
||||||
|
echo -e " ${CYAN}ASN:${NC} ${asn}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "$proxy" == "true" ]]; then
|
||||||
|
echo -e " ${RED}[!] VPN / Proxy detectado${NC}"
|
||||||
|
fi
|
||||||
|
if [[ "$hosting" == "true" ]]; then
|
||||||
|
echo -e " ${YELLOW}[i] Hosting / datacenter detectado${NC}"
|
||||||
|
fi
|
||||||
|
if [[ "$proxy" != "true" && "$hosting" != "true" ]]; then
|
||||||
|
echo -e " ${GREEN}[ok] Sin indicios de VPN, Proxy o Tor${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
geolocate_ip() {
|
||||||
|
local target="$1"
|
||||||
|
|
||||||
|
if [[ -z "$target" ]]; then
|
||||||
|
error "geolocate_ip: se requiere una IP o dominio como argumento" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v curl &>/dev/null; then
|
||||||
|
error "geolocate_ip: 'curl' no está instalado (sudo apt install curl)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local query_target="$target"
|
||||||
|
|
||||||
|
if ! _geo_is_ip "$target"; then
|
||||||
|
info "Resolviendo dominio a IP..."
|
||||||
|
local resolved
|
||||||
|
resolved="$(_geo_resolve_domain "$target")"
|
||||||
|
if [[ -z "$resolved" ]]; then
|
||||||
|
error "geolocate_ip: no se pudo resolver '$target'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
info "Resuelto: ${target} -> ${resolved}"
|
||||||
|
query_target="$resolved"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Consultando geolocalización de ${query_target}..."
|
||||||
|
local json
|
||||||
|
json="$(_geo_fetch "$query_target")"
|
||||||
|
|
||||||
|
if [[ -z "$json" ]]; then
|
||||||
|
error "geolocate_ip: no se obtuvo respuesta de la API" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
_geo_display_result "$json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
geolocate_ip "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: inspect_ssl_cert
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "inspect_ssl_cert(host: string) -> void"
|
||||||
|
description: "Inspecciona el certificado SSL/TLS de un host: muestra sujeto, emisor, fechas de validez, días hasta expiración, SANs (Subject Alternative Names), cadena de confianza completa y detecta soporte de versiones inseguras TLS 1.0/1.1."
|
||||||
|
tags: [bash, cybersecurity, ssl, tls, certificate, web, openssl, security]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: host
|
||||||
|
desc: "host a inspeccionar, acepta formato host o host:puerto (por defecto puerto 443), ej: example.com o example.com:8443"
|
||||||
|
output: "imprime detalles del certificado SSL/TLS, días hasta expiración con nivel de alerta, SANs, cadena de confianza y resultado de checks de versiones TLS"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/inspect_ssl_cert.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/web/ssl_cert_info.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/inspect_ssl_cert.sh
|
||||||
|
|
||||||
|
# Puerto 443 por defecto
|
||||||
|
inspect_ssl_cert example.com
|
||||||
|
|
||||||
|
# Puerto personalizado
|
||||||
|
inspect_ssl_cert example.com:8443
|
||||||
|
|
||||||
|
# API interna
|
||||||
|
inspect_ssl_cert api.internal.example.com:4443
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `openssl` y `timeout`. Usa `openssl s_client` con SNI (`-servername`) para soportar virtual hosting. La alerta de expiración se activa a 30 días o menos. La detección de TLS 1.0/1.1 usa flags `-tls1` y `-tls1_1` de openssl s_client — si el servidor acepta la conexión y negocia un cipher, el protocolo inseguro está habilitado. Cada conexión tiene timeout de 10 segundos para evitar cuelgues en hosts sin respuesta.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# inspect_ssl_cert
|
||||||
|
# ----------------
|
||||||
|
# Inspecciona el certificado SSL/TLS de un host: sujeto, emisor, fechas de validez,
|
||||||
|
# SANs, cadena de confianza y versiones de TLS aceptadas (detecta TLS 1.0/1.1 inseguros).
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# inspect_ssl_cert <host[:puerto]>
|
||||||
|
# inspect_ssl_cert example.com
|
||||||
|
# inspect_ssl_cert example.com:8443
|
||||||
|
#
|
||||||
|
# Depende de: openssl, timeout
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Constantes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SSL_WARN_DAYS=30
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ssl_parse_host_port() {
|
||||||
|
local input="$1"
|
||||||
|
if [[ "$input" =~ ^(.+):([0-9]+)$ ]]; then
|
||||||
|
echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
echo "${input} 443"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssl_days_until_expiry() {
|
||||||
|
local expiry_str="$1"
|
||||||
|
local expiry_epoch
|
||||||
|
expiry_epoch="$(date -d "$expiry_str" +%s 2>/dev/null || echo 0)"
|
||||||
|
local now_epoch
|
||||||
|
now_epoch="$(date +%s)"
|
||||||
|
echo $(( (expiry_epoch - now_epoch) / 86400 ))
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ssl_fetch_subject() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
|
||||||
|
| openssl x509 -noout -subject -issuer 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssl_fetch_dates() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
|
||||||
|
| openssl x509 -noout -dates 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssl_fetch_san() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
|
||||||
|
| openssl x509 -noout -ext subjectAltName 2>/dev/null \
|
||||||
|
| grep -oE 'DNS:[^,]+' | sed 's/DNS://g' | tr '\n' ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssl_fetch_chain() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" \
|
||||||
|
-showcerts 2>/dev/null \
|
||||||
|
| grep -E "^(subject|issuer)=" | sed 's/^/ /'
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssl_check_tls_version() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
local proto="$3"
|
||||||
|
local label="$4"
|
||||||
|
if echo | timeout 5 openssl s_client -connect "${host}:${port}" \
|
||||||
|
-servername "$host" "${proto}" 2>/dev/null | grep -q "Cipher"; then
|
||||||
|
echo -e " ${RED}[x]${NC} ${label} -- soportado (inseguro)"
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}[ok]${NC} ${label} no soportado"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inspect_ssl_cert() {
|
||||||
|
local input="$1"
|
||||||
|
|
||||||
|
if [[ -z "$input" ]]; then
|
||||||
|
error "inspect_ssl_cert: se requiere un host como argumento (ej: example.com o example.com:8443)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v openssl &>/dev/null; then
|
||||||
|
error "inspect_ssl_cert: 'openssl' no está instalado (sudo apt install openssl)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local host port
|
||||||
|
read -r host port <<< "$(_ssl_parse_host_port "$input")"
|
||||||
|
|
||||||
|
info "Conectando a ${host}:${port}..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local subj_issuer
|
||||||
|
subj_issuer="$(_ssl_fetch_subject "$host" "$port")"
|
||||||
|
if [[ -z "$subj_issuer" ]]; then
|
||||||
|
error "inspect_ssl_cert: no se pudo obtener el certificado. ¿El host está disponible?" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local subject issuer
|
||||||
|
subject="$(echo "$subj_issuer" | grep ^subject | cut -d= -f2- | xargs)"
|
||||||
|
issuer="$(echo "$subj_issuer" | grep ^issuer | cut -d= -f2- | xargs)"
|
||||||
|
|
||||||
|
local dates
|
||||||
|
dates="$(_ssl_fetch_dates "$host" "$port")"
|
||||||
|
local not_before not_after
|
||||||
|
not_before="$(echo "$dates" | grep notBefore | cut -d= -f2)"
|
||||||
|
not_after="$(echo "$dates" | grep notAfter | cut -d= -f2)"
|
||||||
|
|
||||||
|
local days_left
|
||||||
|
days_left="$(_ssl_days_until_expiry "$not_after")"
|
||||||
|
|
||||||
|
local sans
|
||||||
|
sans="$(_ssl_fetch_san "$host" "$port")"
|
||||||
|
|
||||||
|
echo -e "${PURPLE}════════ Certificado SSL/TLS ════════════════════${NC}"
|
||||||
|
echo -e " ${CYAN}Sujeto:${NC} ${subject}"
|
||||||
|
echo -e " ${CYAN}Emisor:${NC} ${issuer}"
|
||||||
|
echo -e " ${CYAN}Válido desde:${NC} ${not_before}"
|
||||||
|
echo -e " ${CYAN}Válido hasta:${NC} ${not_after}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ $days_left -le 0 ]]; then
|
||||||
|
echo -e " ${RED}[x] CERTIFICADO EXPIRADO${NC}"
|
||||||
|
elif [[ $days_left -le $_SSL_WARN_DAYS ]]; then
|
||||||
|
echo -e " ${YELLOW}[!] Expira en ${days_left} días -- renovar pronto${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}[ok] Válido -- expira en ${days_left} días${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}SANs:${NC}"
|
||||||
|
echo "$sans" | tr ' ' '\n' | grep -v '^$' | while IFS= read -r san; do
|
||||||
|
echo -e " ${GREEN}*${NC} ${san}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════ Cadena de confianza ════════════════════${NC}"
|
||||||
|
_ssl_fetch_chain "$host" "$port"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════ Versiones TLS aceptadas ════════════════${NC}"
|
||||||
|
_ssl_check_tls_version "$host" "$port" "-tls1" "TLS 1.0"
|
||||||
|
_ssl_check_tls_version "$host" "$port" "-tls1_1" "TLS 1.1"
|
||||||
|
echo -e " ${GREEN}[ok]${NC} TLS 1.2 / 1.3 (estándar)"
|
||||||
|
echo -e "${PURPLE}═════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
inspect_ssl_cert "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: list_active_connections
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "list_active_connections(mode: string) -> void"
|
||||||
|
description: "Muestra conexiones de red activas del sistema usando ss: puertos en escucha, conexiones establecidas y detección de conexiones hacia IPs externas (excluye RFC1918, loopback y link-local)."
|
||||||
|
tags: [bash, cybersecurity, network, connections, monitoring, ss, ports]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: mode
|
||||||
|
desc: "modo de visualización: listening (puertos en escucha), established (conexiones activas), external (solo IPs externas) o all (todo, por defecto)"
|
||||||
|
output: "imprime tabla de conexiones de red a stdout con colores ANSI; las conexiones a IPs externas se resaltan en amarillo"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/list_active_connections.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/redes/conexiones_activas.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/list_active_connections.sh
|
||||||
|
|
||||||
|
# Todas las conexiones
|
||||||
|
list_active_connections
|
||||||
|
|
||||||
|
# Solo puertos en escucha
|
||||||
|
list_active_connections listening
|
||||||
|
|
||||||
|
# Solo conexiones hacia internet
|
||||||
|
list_active_connections external
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `ss` del paquete iproute2 (disponible por defecto en la mayoría de distribuciones modernas). La detección de IPs externas excluye: 127.x, ::1, 0.0.0.0, rangos RFC1918 (10.x, 172.16-31.x, 192.168.x) y link-local (fe80:). Usa `ss -tnp` para mostrar el proceso asociado a cada conexión (puede requerir sudo para ver procesos de otros usuarios).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# list_active_connections
|
||||||
|
# -----------------------
|
||||||
|
# Muestra conexiones de red activas del sistema: puertos en escucha,
|
||||||
|
# conexiones establecidas y detección de IPs externas (no RFC1918).
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# list_active_connections [listening|established|external|all]
|
||||||
|
#
|
||||||
|
# Depende de: ss (iproute2)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_conn_is_external_ip() {
|
||||||
|
local ip="$1"
|
||||||
|
# Devuelve 0 (verdadero) si no es loopback, link-local ni RFC1918
|
||||||
|
[[ ! "$ip" =~ ^(127\.|::1|0\.0\.0\.0|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|fe80:) ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_conn_show_listening() {
|
||||||
|
info "Puertos en escucha con proceso asociado..."
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
ss -tlnp 2>/dev/null \
|
||||||
|
| awk 'NR==1 {printf "%-6s %-25s %-25s %s\n", "Proto", "Local", "Peer", "Proceso"} NR>1 {printf "%-6s %-25s %-25s %s\n", $1, $4, $5, $7}' \
|
||||||
|
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_conn_show_established() {
|
||||||
|
info "Conexiones establecidas..."
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
ss -tnp state established 2>/dev/null \
|
||||||
|
| awk 'NR==1 {printf "%-6s %-25s %-25s %s\n", "Proto", "Local", "Peer", "Proceso"} NR>1 {printf "%-6s %-25s %-25s %s\n", $1, $4, $5, $6}' \
|
||||||
|
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_conn_show_external() {
|
||||||
|
info "Conexiones hacia IPs externas..."
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
while IFS= read -r line; do
|
||||||
|
local peer
|
||||||
|
peer="$(echo "$line" | awk '{print $5}' | cut -d: -f1)"
|
||||||
|
if _conn_is_external_ip "$peer"; then
|
||||||
|
echo -e " ${YELLOW}*${NC} $line"
|
||||||
|
found=$((found + 1))
|
||||||
|
fi
|
||||||
|
done < <(ss -tnp state established 2>/dev/null | tail -n +2)
|
||||||
|
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
if [[ $found -eq 0 ]]; then
|
||||||
|
success "No se detectaron conexiones hacia IPs externas"
|
||||||
|
else
|
||||||
|
info "Total conexiones externas: $found"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
list_active_connections() {
|
||||||
|
local mode="${1:-all}"
|
||||||
|
|
||||||
|
if ! command -v ss &>/dev/null; then
|
||||||
|
error "list_active_connections: 'ss' no está disponible (sudo apt install iproute2)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
listening)
|
||||||
|
_conn_show_listening
|
||||||
|
;;
|
||||||
|
established)
|
||||||
|
_conn_show_established
|
||||||
|
;;
|
||||||
|
external)
|
||||||
|
_conn_show_external
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
_conn_show_listening
|
||||||
|
echo ""
|
||||||
|
_conn_show_established
|
||||||
|
echo ""
|
||||||
|
_conn_show_external
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
error "list_active_connections: modo no válido '$mode'. Use: listening|established|external|all" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
list_active_connections "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: verify_file_hash
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "verify_file_hash(file: string, algorithm: string, expected_hash: string) -> void"
|
||||||
|
description: "Calcula el hash criptográfico de un archivo con el algoritmo especificado (md5, sha1, sha256, sha512) y opcionalmente lo compara con un hash esperado para verificar integridad. Retorna exit code 1 si los hashes no coinciden."
|
||||||
|
tags: [bash, cybersecurity, hash, integrity, checksum, md5, sha256, sha512]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: file
|
||||||
|
desc: "ruta al archivo del que calcular el hash"
|
||||||
|
- name: algorithm
|
||||||
|
desc: "algoritmo de hash a usar: md5, sha1, sha256 (recomendado) o sha512"
|
||||||
|
- name: expected_hash
|
||||||
|
desc: "hash esperado en hexadecimal para verificar integridad (opcional; si se omite, solo calcula e imprime)"
|
||||||
|
output: "imprime el hash calculado; si se proporcionó expected_hash, imprime COINCIDE o NO COINCIDE y retorna exit code 1 si no coinciden"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/verify_file_hash.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/utilidades/verificar_hash.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/verify_file_hash.sh
|
||||||
|
|
||||||
|
# Solo calcular el hash
|
||||||
|
verify_file_hash archivo.iso sha256
|
||||||
|
|
||||||
|
# Verificar contra un hash conocido
|
||||||
|
verify_file_hash archivo.iso sha256 "a1b2c3d4e5f6..."
|
||||||
|
|
||||||
|
# MD5 (solo para compatibilidad, no recomendado para seguridad)
|
||||||
|
verify_file_hash documento.pdf md5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
La comparación de hashes es case-insensitive (normaliza a minúsculas). SHA256 es el algoritmo recomendado para verificación de integridad. MD5 y SHA1 están deprecados para uso en seguridad pero se incluyen para compatibilidad con sumas publicadas en sistemas legacy. Retorna exit code 1 cuando los hashes no coinciden, lo que permite usar la función en scripts con `set -e`.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# verify_file_hash
|
||||||
|
# ----------------
|
||||||
|
# Calcula el hash de un archivo con el algoritmo indicado (md5, sha1, sha256, sha512)
|
||||||
|
# y opcionalmente lo compara con un hash esperado.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# verify_file_hash <archivo> <md5|sha1|sha256|sha512> [hash_esperado]
|
||||||
|
#
|
||||||
|
# Depende de: md5sum, sha1sum, sha256sum, sha512sum
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_hash_select_cmd() {
|
||||||
|
local algo="$1"
|
||||||
|
case "$algo" in
|
||||||
|
md5) echo "md5sum" ;;
|
||||||
|
sha1) echo "sha1sum" ;;
|
||||||
|
sha256) echo "sha256sum" ;;
|
||||||
|
sha512) echo "sha512sum" ;;
|
||||||
|
*) echo "" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
_hash_hashes_match() {
|
||||||
|
local a="${1,,}"
|
||||||
|
local b="${2,,}"
|
||||||
|
[[ "$a" == "$b" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_hash_compute() {
|
||||||
|
local cmd="$1"
|
||||||
|
local file="$2"
|
||||||
|
"$cmd" "$file" 2>/dev/null | awk '{print $1}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
verify_file_hash() {
|
||||||
|
local file="$1"
|
||||||
|
local algorithm="$2"
|
||||||
|
local expected_hash="${3:-}"
|
||||||
|
|
||||||
|
if [[ -z "$file" || -z "$algorithm" ]]; then
|
||||||
|
error "verify_file_hash: uso: verify_file_hash <archivo> <md5|sha1|sha256|sha512> [hash_esperado]" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$file" ]]; then
|
||||||
|
error "verify_file_hash: archivo no encontrado: $file" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local cmd
|
||||||
|
cmd="$(_hash_select_cmd "$algorithm")"
|
||||||
|
|
||||||
|
if [[ -z "$cmd" ]]; then
|
||||||
|
error "verify_file_hash: algoritmo no válido '$algorithm'. Use: md5|sha1|sha256|sha512" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v "$cmd" &>/dev/null; then
|
||||||
|
error "verify_file_hash: '$cmd' no está disponible" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Calculando ${algorithm^^} de: $(basename "$file")"
|
||||||
|
local hash
|
||||||
|
hash="$(_hash_compute "$cmd" "$file")"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}Archivo:${NC} ${file}"
|
||||||
|
echo -e " ${CYAN}${algorithm^^}:${NC} ${hash}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ -n "$expected_hash" ]]; then
|
||||||
|
if _hash_hashes_match "$hash" "$expected_hash"; then
|
||||||
|
echo -e " ${GREEN}[COINCIDE]${NC} La integridad del archivo es correcta"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[NO COINCIDE]${NC} El archivo puede estar corrupto o modificado"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}Calculado:${NC} ${hash}"
|
||||||
|
echo -e " ${CYAN}Esperado: ${NC} ${expected_hash}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
verify_file_hash "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: analyze_disk_space
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "analyze_disk_space([target_dir: string], [mode: string]) -> void"
|
||||||
|
description: "Analiza el uso de espacio en disco. Modos: partitions (df con filtros), top-dirs (du top 10), top-files (find top 20), inodes (df -i), all (todos). Emite advertencias si el uso supera el 90%."
|
||||||
|
tags: [bash, disk, space, analysis, filesystem]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: target_dir
|
||||||
|
desc: "directorio a analizar para top-dirs y top-files (default: /)"
|
||||||
|
- name: mode
|
||||||
|
desc: "qué analizar: partitions|top-dirs|top-files|inodes|all (default: all)"
|
||||||
|
output: "informe de uso de disco a stdout; advertencias a stdout si uso >90%; exit code 1 si modo desconocido"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/analyze_disk_space.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/gestion_linux/espacio_disponible.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/analyze_disk_space.sh
|
||||||
|
|
||||||
|
# Análisis completo del directorio raíz
|
||||||
|
analyze_disk_space
|
||||||
|
|
||||||
|
# Solo particiones
|
||||||
|
analyze_disk_space / partitions
|
||||||
|
|
||||||
|
# Top directorios en home
|
||||||
|
analyze_disk_space "$HOME" top-dirs
|
||||||
|
|
||||||
|
# Solo inodos
|
||||||
|
analyze_disk_space / inodes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Excluye tmpfs, devtmpfs y loop de los resultados de df. No realiza ninguna limpieza destructiva. El modo top-files puede tardar en sistemas con muchos archivos.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# analyze_disk_space
|
||||||
|
# ------------------
|
||||||
|
# Analiza el uso de espacio en disco: particiones, directorios más grandes,
|
||||||
|
# archivos más grandes e inodos.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source analyze_disk_space.sh
|
||||||
|
# analyze_disk_space [target_dir] [mode]
|
||||||
|
#
|
||||||
|
# ARGUMENTOS:
|
||||||
|
# target_dir Directorio a analizar (default: /)
|
||||||
|
# mode Modo de análisis: partitions|top-dirs|top-files|inodes|all (default: all)
|
||||||
|
|
||||||
|
analyze_disk_space() {
|
||||||
|
local target_dir="${1:-/}"
|
||||||
|
local mode="${2:-all}"
|
||||||
|
|
||||||
|
_ads_partitions() {
|
||||||
|
echo "=== Espacio en sistemas de archivos ==="
|
||||||
|
df -h --output=source,fstype,size,used,avail,pcent,target 2>/dev/null \
|
||||||
|
| grep -v "tmpfs\|devtmpfs\|loop" | column -t
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local high_usage
|
||||||
|
high_usage="$(df -h | awk 'NR>1 && $5+0 > 90 {print $6, $5}' | grep -v "tmpfs\|devtmpfs" || true)"
|
||||||
|
if [[ -n "$high_usage" ]]; then
|
||||||
|
echo "ADVERTENCIA: Discos con uso >90%:"
|
||||||
|
echo "$high_usage"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_ads_top_dirs() {
|
||||||
|
local dir="${1:-.}"
|
||||||
|
echo "=== Top 10 carpetas más grandes en: $(realpath "$dir") ==="
|
||||||
|
du -h --max-depth=1 "$dir" 2>/dev/null | sort -rh | head -11 \
|
||||||
|
| awk '{printf "%-10s %s\n", $1, $2}'
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_ads_top_files() {
|
||||||
|
local dir="${1:-.}"
|
||||||
|
echo "=== Top 20 archivos más grandes en: $(realpath "$dir") ==="
|
||||||
|
find "$dir" -type f -exec du -h {} + 2>/dev/null | sort -rh | head -20 \
|
||||||
|
| awk '{printf "%-10s %s\n", $1, $2}'
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_ads_inodes() {
|
||||||
|
echo "=== Inodos disponibles ==="
|
||||||
|
df -i 2>/dev/null | grep -v "tmpfs\|devtmpfs\|loop" | column -t
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local high_inodes
|
||||||
|
high_inodes="$(df -i | awk 'NR>1 && $5+0 > 90 {print $6, $5}' | grep -v "tmpfs\|devtmpfs" || true)"
|
||||||
|
if [[ -n "$high_inodes" ]]; then
|
||||||
|
echo "ADVERTENCIA: Sistemas de archivos con >90% de inodos usados:"
|
||||||
|
echo "$high_inodes"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
partitions)
|
||||||
|
_ads_partitions
|
||||||
|
;;
|
||||||
|
top-dirs)
|
||||||
|
_ads_top_dirs "$target_dir"
|
||||||
|
;;
|
||||||
|
top-files)
|
||||||
|
_ads_top_files "$target_dir"
|
||||||
|
;;
|
||||||
|
inodes)
|
||||||
|
_ads_inodes
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
_ads_partitions
|
||||||
|
_ads_top_dirs "$target_dir"
|
||||||
|
_ads_top_files "$target_dir"
|
||||||
|
_ads_inodes
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "analyze_disk_space: modo desconocido '${mode}'. Usa: partitions|top-dirs|top-files|inodes|all" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
analyze_disk_space "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: detect_wsl
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "detect_wsl([--check]) -> void"
|
||||||
|
description: "Detecta si el sistema es WSL (Windows Subsystem for Linux). Con --check retorna solo exit code (0=WSL, 1=no WSL) sin output. Sin argumentos imprime versión WSL, usuario Windows, distribución, hostname, unidades montadas y ruta Windows del directorio actual."
|
||||||
|
tags: [bash, wsl, windows, detect, integration]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: --check
|
||||||
|
desc: "flag: solo detecta y retorna exit code sin producir output (0=WSL, 1=no WSL)"
|
||||||
|
output: "sin output con --check; informe del entorno WSL a stdout sin argumentos; exit code 1 si no es WSL"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/detect_wsl.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/gestion_linux/wsl_host.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/detect_wsl.sh
|
||||||
|
|
||||||
|
# Verificar si es WSL en scripts (sin output)
|
||||||
|
if detect_wsl --check; then
|
||||||
|
echo "Estamos en WSL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Mostrar información completa del entorno WSL
|
||||||
|
detect_wsl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa tres métodos de detección en orden: /proc/version, /proc/sys/kernel/osrelease, y la presencia de /mnt/c + WSLInterop. No incluye las acciones interactivas del script original (abrir PowerShell, CMD, Explorer, VS Code).
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# detect_wsl
|
||||||
|
# ----------
|
||||||
|
# Detecta si el sistema actual es WSL (Windows Subsystem for Linux).
|
||||||
|
# Con --check solo retorna exit code (0=WSL, 1=no WSL) sin output.
|
||||||
|
# Sin argumentos, imprime información completa del entorno WSL.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source detect_wsl.sh
|
||||||
|
# detect_wsl [--check]
|
||||||
|
|
||||||
|
detect_wsl() {
|
||||||
|
local check_only=false
|
||||||
|
[[ "${1:-}" == "--check" ]] && check_only=true
|
||||||
|
|
||||||
|
# Detección interna de WSL
|
||||||
|
_is_wsl() {
|
||||||
|
if [[ -f /proc/version ]] && grep -qi "microsoft\|wsl" /proc/version; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -f /proc/sys/kernel/osrelease ]] && grep -qi "microsoft\|wsl" /proc/sys/kernel/osrelease; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -d /mnt/c ]] && [[ -f /proc/sys/fs/binfmt_misc/WSLInterop ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_get_wsl_version() {
|
||||||
|
if [[ -f /proc/version ]]; then
|
||||||
|
if grep -qi "WSL2" /proc/version; then
|
||||||
|
echo "WSL2"
|
||||||
|
elif grep -qi "microsoft" /proc/version; then
|
||||||
|
echo "WSL1"
|
||||||
|
else
|
||||||
|
echo "Unknown"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Unknown"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_get_windows_username() {
|
||||||
|
if [[ -n "${WSLENV:-}" ]]; then
|
||||||
|
cmd.exe /c "echo %USERNAME%" 2>/dev/null | tr -d '\r\n' || echo "Unknown"
|
||||||
|
else
|
||||||
|
echo "Unknown"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Modo --check: solo exit code
|
||||||
|
if [[ "$check_only" == true ]]; then
|
||||||
|
_is_wsl && return 0 || return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Modo informativo
|
||||||
|
if ! _is_wsl; then
|
||||||
|
echo "detect_wsl: este sistema NO es WSL" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local wsl_version
|
||||||
|
wsl_version="$(_get_wsl_version)"
|
||||||
|
|
||||||
|
local win_user
|
||||||
|
win_user="$(_get_windows_username)"
|
||||||
|
|
||||||
|
local distro="Unknown"
|
||||||
|
if [[ -f /etc/os-release ]]; then
|
||||||
|
distro="$(. /etc/os-release && echo "${PRETTY_NAME:-${ID:-Unknown}}")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Entorno WSL ==="
|
||||||
|
echo " Versión de WSL: ${wsl_version}"
|
||||||
|
echo " Usuario Windows: ${win_user}"
|
||||||
|
echo " Distribución: ${distro}"
|
||||||
|
echo " Hostname: $(hostname)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Unidades de Windows montadas ==="
|
||||||
|
ls /mnt/ 2>/dev/null | grep -E "^[a-z]$" | while IFS= read -r drive; do
|
||||||
|
echo " ${drive}: → /mnt/${drive}"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local current_win_path
|
||||||
|
current_win_path="$(wslpath -w "$(pwd)" 2>/dev/null || echo "N/A")"
|
||||||
|
echo "=== Directorio actual en Windows ==="
|
||||||
|
echo " ${current_win_path}"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
detect_wsl "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: docker_compose_remote_deploy
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "docker_compose_remote_deploy(host: string, remote_dir: string, branch: string, compose_files: string) -> json"
|
||||||
|
description: "Despliega un stack Docker Compose en un host remoto via SSH. Verifica conectividad, hace git pull del branch indicado, actualiza imagenes con docker-compose pull y levanta/recrea los servicios modificados con docker-compose up -d. Soporta compose files adicionales. Retorna JSON con status, containers corriendo y duracion."
|
||||||
|
tags: [docker, compose, deploy, ssh, remote, git, infra, cicd]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: host
|
||||||
|
desc: "alias SSH del host remoto definido en ~/.ssh/config (ej: prod-server)"
|
||||||
|
- name: remote_dir
|
||||||
|
desc: "ruta absoluta en el host donde esta el repo con docker-compose.yml (ej: /opt/apps/element)"
|
||||||
|
- name: branch
|
||||||
|
desc: "branch de git a hacer pull; default 'main'"
|
||||||
|
- name: compose_files
|
||||||
|
desc: "archivos compose adicionales separados por coma (ej: 'docker-compose.livekit.yml,docker-compose.monitoring.yml'); si vacio usa solo docker-compose.yml"
|
||||||
|
output: "JSON con status ('ok'), host, remote_dir, branch, containers (array de nombres corriendo tras el deploy), duration_ms"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/docker_compose_remote_deploy.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/docker_compose_remote_deploy.sh
|
||||||
|
|
||||||
|
# Deploy basico (solo docker-compose.yml, branch main)
|
||||||
|
result=$(docker_compose_remote_deploy "prod-server" "/opt/apps/element")
|
||||||
|
echo "$result"
|
||||||
|
# {"status":"ok","host":"prod-server","remote_dir":"/opt/apps/element","branch":"main","containers":["element-web","synapse","postgres"],"duration_ms":4200}
|
||||||
|
|
||||||
|
# Deploy con compose files adicionales y branch especifico
|
||||||
|
result=$(docker_compose_remote_deploy "prod-server" "/opt/apps/element" "release" "docker-compose.livekit.yml,docker-compose.monitoring.yml")
|
||||||
|
echo "$result"
|
||||||
|
# {"status":"ok","host":"prod-server","remote_dir":"/opt/apps/element","branch":"release","containers":[...],"duration_ms":8100}
|
||||||
|
|
||||||
|
# Uso desde un pipeline CI/CD
|
||||||
|
source bash/functions/infra/docker_compose_remote_deploy.sh
|
||||||
|
docker_compose_remote_deploy "$SSH_HOST" "$REMOTE_DIR" "$GIT_BRANCH" "$EXTRA_COMPOSE" || exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Flujo: verificar SSH → git pull → docker-compose pull → docker-compose up -d → listar containers.
|
||||||
|
- La verificacion SSH usa `-o BatchMode=yes -o ConnectTimeout=5` para fallar rapido sin pedir password.
|
||||||
|
- Los compose files adicionales se pasan como `-f file1.yml -f file2.yml` a todos los subcomandos compose.
|
||||||
|
- `docker-compose up -d` solo recrea los servicios cuya imagen o config cambio (comportamiento nativo de compose).
|
||||||
|
- La lista de containers al final incluye TODOS los containers corriendo en el host, no solo los del stack.
|
||||||
|
- Requiere `jq` instalado en el host remoto para serializar la lista de containers. Si no esta, `containers` sera `[]`.
|
||||||
|
- Los mensajes de progreso van a stderr; el JSON final va a stdout.
|
||||||
|
- Exit code 1 en cualquier fallo (SSH, git pull, compose pull, compose up); el JSON de error NO se emite — el caller debe manejar el exit code.
|
||||||
|
- El `host` se resuelve con `~/.ssh/config` incluyendo host, user, identityfile y puerto.
|
||||||
|
- Diferencia con `rsync_deploy`: este flujo asume que el codigo ya esta en el remoto (via git) y usa compose. `rsync_deploy` sube archivos locales sin git.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# docker_compose_remote_deploy — Despliega un stack Docker Compose en un host remoto via SSH
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker_compose_remote_deploy() {
|
||||||
|
local host="$1"
|
||||||
|
local remote_dir="$2"
|
||||||
|
local branch="${3:-main}"
|
||||||
|
local compose_files="${4:-}"
|
||||||
|
|
||||||
|
if [[ -z "$host" || -z "$remote_dir" ]]; then
|
||||||
|
echo "docker_compose_remote_deploy: se requieren host y remote_dir" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local start_ts
|
||||||
|
start_ts=$(date +%s)
|
||||||
|
|
||||||
|
# 1. Verificar conectividad SSH
|
||||||
|
echo "docker_compose_remote_deploy: verificando conectividad SSH a '$host'..." >&2
|
||||||
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" true 2>/dev/null; then
|
||||||
|
echo "docker_compose_remote_deploy: no se puede conectar a '$host' via SSH" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Git pull en el host remoto
|
||||||
|
echo "docker_compose_remote_deploy: git pull origin $branch en '$remote_dir'..." >&2
|
||||||
|
if ! ssh "$host" "cd '$remote_dir' && git pull origin '$branch'" >&2; then
|
||||||
|
echo "docker_compose_remote_deploy: git pull falló en '$host:$remote_dir'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Construir los argumentos -f para docker-compose
|
||||||
|
local compose_args="-f docker-compose.yml"
|
||||||
|
if [[ -n "$compose_files" ]]; then
|
||||||
|
local IFS=","
|
||||||
|
local extra_file
|
||||||
|
for extra_file in $compose_files; do
|
||||||
|
extra_file="${extra_file// /}" # trim spaces
|
||||||
|
if [[ -n "$extra_file" ]]; then
|
||||||
|
compose_args="$compose_args -f $extra_file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
unset IFS
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. docker-compose pull
|
||||||
|
echo "docker_compose_remote_deploy: actualizando imagenes ($compose_args)..." >&2
|
||||||
|
if ! ssh "$host" "cd '$remote_dir' && docker-compose $compose_args pull" >&2; then
|
||||||
|
echo "docker_compose_remote_deploy: docker-compose pull falló en '$host:$remote_dir'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. docker-compose up -d
|
||||||
|
echo "docker_compose_remote_deploy: levantando servicios ($compose_args)..." >&2
|
||||||
|
if ! ssh "$host" "cd '$remote_dir' && docker-compose $compose_args up -d" >&2; then
|
||||||
|
echo "docker_compose_remote_deploy: docker-compose up -d falló en '$host:$remote_dir'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Recopilar containers corriendo tras el deploy
|
||||||
|
local containers_json
|
||||||
|
containers_json=$(ssh "$host" \
|
||||||
|
"docker ps --format '{{.Names}}' 2>/dev/null | jq -R . | jq -sc ." 2>/dev/null || echo '[]')
|
||||||
|
|
||||||
|
local end_ts
|
||||||
|
end_ts=$(date +%s)
|
||||||
|
local duration_ms=$(( (end_ts - start_ts) * 1000 ))
|
||||||
|
|
||||||
|
# Emitir JSON a stdout
|
||||||
|
printf '{"status":"ok","host":"%s","remote_dir":"%s","branch":"%s","containers":%s,"duration_ms":%d}\n' \
|
||||||
|
"$host" \
|
||||||
|
"$remote_dir" \
|
||||||
|
"$branch" \
|
||||||
|
"$containers_json" \
|
||||||
|
"$duration_ms"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
docker_compose_remote_deploy "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: gitea_create_webhook
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "gitea_create_webhook(owner: string, repo: string, target_url: string, secret?: string) -> json"
|
||||||
|
description: "Crea un webhook de push en un repositorio Gitea. El webhook notifica a target_url en cada push."
|
||||||
|
tags: [gitea, webhook, push, deploy, ci, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: owner
|
||||||
|
desc: "usuario u organización propietaria del repositorio"
|
||||||
|
- name: repo
|
||||||
|
desc: "nombre del repositorio"
|
||||||
|
- name: target_url
|
||||||
|
desc: "URL que recibirá el POST del webhook en cada push"
|
||||||
|
- name: secret
|
||||||
|
desc: "secreto compartido para firmar el payload (opcional)"
|
||||||
|
output: "JSON con webhook_id, owner, repo, target_url"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/gitea_create_webhook.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/gitea_create_webhook.sh
|
||||||
|
|
||||||
|
export GITEA_URL="https://git.example.com"
|
||||||
|
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||||
|
|
||||||
|
# Crear webhook para auto-deploy
|
||||||
|
gitea_create_webhook "myorg" "dag_engine" "http://vps:9090/webhook/push" "mi_secreto"
|
||||||
|
# {"webhook_id":42,"owner":"myorg","repo":"dag_engine","target_url":"http://vps:9090/webhook/push"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Requiere `GITEA_URL` y `GITEA_TOKEN` como variables de entorno.
|
||||||
|
- Solo escucha eventos `push`. Para otros eventos, modificar el array `events` en el payload.
|
||||||
|
- Si el webhook ya existe para la misma URL, Gitea crea uno duplicado (no es idempotente).
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# gitea_create_webhook — Crea un webhook de push en un repositorio Gitea
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
gitea_create_webhook() {
|
||||||
|
local owner="$1"
|
||||||
|
local repo="$2"
|
||||||
|
local target_url="$3"
|
||||||
|
local secret="${4:-}"
|
||||||
|
|
||||||
|
if [[ -z "$owner" || -z "$repo" || -z "$target_url" ]]; then
|
||||||
|
echo "usage: gitea_create_webhook <owner> <repo> <target_url> [secret]" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local gitea_url="${GITEA_URL:?GITEA_URL no seteada}"
|
||||||
|
local gitea_token="${GITEA_TOKEN:?GITEA_TOKEN no seteada}"
|
||||||
|
|
||||||
|
# Payload JSON para el webhook
|
||||||
|
local payload
|
||||||
|
payload=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"type": "gitea",
|
||||||
|
"active": true,
|
||||||
|
"events": ["push"],
|
||||||
|
"config": {
|
||||||
|
"url": "$target_url",
|
||||||
|
"content_type": "json",
|
||||||
|
"secret": "$secret"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
local response http_code body
|
||||||
|
response=$(curl -s -w "\n%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token $gitea_token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload" \
|
||||||
|
"${gitea_url}/api/v1/repos/${owner}/${repo}/hooks")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
|
||||||
|
# Extraer webhook ID del response
|
||||||
|
local webhook_id
|
||||||
|
webhook_id=$(echo "$body" | grep -oP '"id":\s*\K[0-9]+' | head -1)
|
||||||
|
printf '{"webhook_id":%s,"owner":"%s","repo":"%s","target_url":"%s"}\n' \
|
||||||
|
"$webhook_id" "$owner" "$repo" "$target_url"
|
||||||
|
else
|
||||||
|
echo "gitea_create_webhook: HTTP $http_code — $body" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
gitea_create_webhook "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: install_go
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_go([version: string], [--force]) -> void"
|
||||||
|
description: "Instala Go en Linux descargando desde go.dev/dl. Detecta arquitectura automáticamente (amd64/arm64/armv6l). Idempotente: omite la instalación si Go ya está presente (a menos que se use --force). Configura PATH en ~/.bashrc o ~/.zshrc."
|
||||||
|
tags: [bash, install, go, golang]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: version
|
||||||
|
desc: "versión de Go a instalar, ej: 1.22.0 (default: 1.22.0)"
|
||||||
|
- name: --force
|
||||||
|
desc: "flag para reinstalar aunque Go ya esté instalado"
|
||||||
|
output: "progreso a stdout; exit code 1 si la arquitectura no es soportada o falla la descarga"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_go.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_go.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_go.sh
|
||||||
|
|
||||||
|
# Instalar versión por defecto (1.22.0)
|
||||||
|
install_go
|
||||||
|
|
||||||
|
# Instalar versión específica
|
||||||
|
install_go 1.23.0
|
||||||
|
|
||||||
|
# Reinstalar aunque ya esté instalado
|
||||||
|
install_go 1.22.0 --force
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `curl` y `sudo`. Instala en `/usr/local/go`. Crea `$HOME/go/{bin,src,pkg}` como GOPATH. Después de instalar, hay que recargar el shell (`source ~/.bashrc`) o abrir una nueva terminal.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_go
|
||||||
|
# ----------
|
||||||
|
# Instala Go en Linux. Detecta arquitectura automáticamente (amd64/arm64/armv6l).
|
||||||
|
# Descarga desde go.dev/dl, instala en /usr/local y configura PATH en el shell config.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_go.sh
|
||||||
|
# install_go [version] [--force]
|
||||||
|
#
|
||||||
|
# ARGUMENTOS:
|
||||||
|
# version Versión de Go a instalar (default: 1.22.0)
|
||||||
|
# --force Reinstala aunque Go ya esté instalado
|
||||||
|
|
||||||
|
install_go() {
|
||||||
|
local version="1.22.0"
|
||||||
|
local force=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--force) force=true ;;
|
||||||
|
*) [[ "$arg" =~ ^[0-9] ]] && version="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
local go_os="linux"
|
||||||
|
local go_arch
|
||||||
|
|
||||||
|
# Detectar arquitectura
|
||||||
|
local arch
|
||||||
|
arch="$(uname -m)"
|
||||||
|
case "$arch" in
|
||||||
|
x86_64) go_arch="amd64" ;;
|
||||||
|
aarch64|arm64) go_arch="arm64" ;;
|
||||||
|
armv6l) go_arch="armv6l" ;;
|
||||||
|
*)
|
||||||
|
echo "install_go: arquitectura no soportada: ${arch}" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Verificar si ya está instalado
|
||||||
|
if command -v go &>/dev/null && [[ "$force" != true ]]; then
|
||||||
|
local current_version
|
||||||
|
current_version="$(go version | awk '{print $3}' | sed 's/go//')"
|
||||||
|
echo "install_go: Go ya está instalado (versión: ${current_version}). Usa --force para reinstalar."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tarball="go${version}.${go_os}-${go_arch}.tar.gz"
|
||||||
|
local url="https://go.dev/dl/${tarball}"
|
||||||
|
local install_dir="/usr/local"
|
||||||
|
|
||||||
|
echo "Instalando Go ${version} para ${go_os}-${go_arch}..."
|
||||||
|
|
||||||
|
# Eliminar versión anterior si existe
|
||||||
|
if command -v go &>/dev/null; then
|
||||||
|
echo "Eliminando versión anterior..."
|
||||||
|
sudo rm -rf "${install_dir}/go"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Descargar en directorio temporal
|
||||||
|
local tmp_dir
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
|
||||||
|
echo "Descargando ${url}..."
|
||||||
|
if ! curl -LO --output-dir "$tmp_dir" "$url"; then
|
||||||
|
echo "install_go: error al descargar Go ${version}. Verifica la versión en: https://go.dev/dl/" >&2
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Instalando en ${install_dir}..."
|
||||||
|
sudo tar -C "$install_dir" -xzf "${tmp_dir}/${tarball}"
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
|
||||||
|
# Configurar PATH en shell config
|
||||||
|
local shell_config=""
|
||||||
|
if [[ -f "$HOME/.bashrc" ]]; then
|
||||||
|
shell_config="$HOME/.bashrc"
|
||||||
|
elif [[ -f "$HOME/.zshrc" ]]; then
|
||||||
|
shell_config="$HOME/.zshrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$shell_config" ]]; then
|
||||||
|
if ! grep -q "export PATH=\$PATH:${install_dir}/go/bin" "$shell_config"; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Go configuration"
|
||||||
|
echo "export PATH=\$PATH:${install_dir}/go/bin"
|
||||||
|
echo "export GOPATH=\$HOME/go"
|
||||||
|
echo "export PATH=\$PATH:\$GOPATH/bin"
|
||||||
|
} >> "$shell_config"
|
||||||
|
echo "Variables de PATH añadidas a ${shell_config}"
|
||||||
|
else
|
||||||
|
echo "Variables de entorno ya configuradas en ${shell_config}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Crear estructura GOPATH
|
||||||
|
mkdir -p "$HOME/go"/{bin,src,pkg}
|
||||||
|
|
||||||
|
# Verificar instalación
|
||||||
|
export PATH="$PATH:${install_dir}/go/bin"
|
||||||
|
local installed_version
|
||||||
|
installed_version="$("${install_dir}/go/bin/go" version)"
|
||||||
|
echo ""
|
||||||
|
echo "Go instalado correctamente: ${installed_version}"
|
||||||
|
echo "Reinicia tu terminal o ejecuta: source ${shell_config:-~/.bashrc}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_go "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: install_nodejs
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_nodejs([version: string]) -> void"
|
||||||
|
description: "Instala Node.js en Linux usando nvm. Instala nvm v0.39.7 si no está presente. Instala la versión de Node indicada, la activa con 'nvm use' y la configura como default. Idempotente si nvm ya está instalado."
|
||||||
|
tags: [bash, install, nodejs, nvm]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: version
|
||||||
|
desc: "versión principal de Node.js a instalar (default: 20)"
|
||||||
|
output: "progreso a stdout con versión instalada; exit code 1 si nvm no queda disponible"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_nodejs.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_nodejs.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_nodejs.sh
|
||||||
|
|
||||||
|
# Instalar Node.js 20 (LTS por defecto)
|
||||||
|
install_nodejs
|
||||||
|
|
||||||
|
# Instalar versión específica
|
||||||
|
install_nodejs 18
|
||||||
|
install_nodejs 21
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `curl`. nvm se instala en `$HOME/.nvm`. Después de instalar en una sesión nueva, hay que recargar el shell para que los comandos `node` y `npm` queden disponibles globalmente.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_nodejs
|
||||||
|
# --------------
|
||||||
|
# Instala Node.js en Linux usando nvm (Node Version Manager).
|
||||||
|
# Instala nvm si no está presente, luego instala la versión de Node indicada
|
||||||
|
# y la configura como default.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_nodejs.sh
|
||||||
|
# install_nodejs [version]
|
||||||
|
#
|
||||||
|
# ARGUMENTOS:
|
||||||
|
# version Versión principal de Node.js (default: 20)
|
||||||
|
|
||||||
|
install_nodejs() {
|
||||||
|
local node_version="${1:-20}"
|
||||||
|
|
||||||
|
echo "Instalando Node.js v${node_version} mediante nvm..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Informar si Node ya está instalado
|
||||||
|
if command -v node &>/dev/null; then
|
||||||
|
local current_version
|
||||||
|
current_version="$(node --version)"
|
||||||
|
echo "Node.js ya está instalado: ${current_version}"
|
||||||
|
echo "Continuando con la instalación/actualización..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Instalar nvm si no está presente
|
||||||
|
if [[ -d "$HOME/.nvm" ]]; then
|
||||||
|
echo "nvm ya está instalado."
|
||||||
|
export NVM_DIR="$HOME/.nvm"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
[[ -s "$NVM_DIR/nvm.sh" ]] && source "$NVM_DIR/nvm.sh"
|
||||||
|
else
|
||||||
|
echo "Descargando e instalando nvm..."
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
|
|
||||||
|
export NVM_DIR="$HOME/.nvm"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
[[ -s "$NVM_DIR/nvm.sh" ]] && source "$NVM_DIR/nvm.sh"
|
||||||
|
echo "nvm instalado correctamente."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar que nvm esté disponible
|
||||||
|
if ! command -v nvm &>/dev/null; then
|
||||||
|
echo "install_nodejs: nvm no está disponible después de la instalación" >&2
|
||||||
|
echo " Ejecuta: source ~/.bashrc (o abre una nueva terminal)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Instalando Node.js v${node_version}..."
|
||||||
|
nvm install "$node_version"
|
||||||
|
nvm use "$node_version"
|
||||||
|
nvm alias default "$node_version"
|
||||||
|
|
||||||
|
local installed_node
|
||||||
|
local installed_npm
|
||||||
|
installed_node="$(node --version)"
|
||||||
|
installed_npm="$(npm --version)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Node.js instalado correctamente:"
|
||||||
|
echo " Node.js: ${installed_node}"
|
||||||
|
echo " npm: ${installed_npm}"
|
||||||
|
echo ""
|
||||||
|
echo "Si es una instalación nueva, reinicia tu terminal o ejecuta:"
|
||||||
|
echo " source ~/.bashrc # o ~/.zshrc según tu shell"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_nodejs "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: install_pnpm
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_pnpm() -> void"
|
||||||
|
description: "Instala pnpm globalmente usando npm (npm install -g pnpm). Verifica que npm esté disponible. Idempotente: si pnpm ya está instalado, informa y termina sin hacer nada."
|
||||||
|
tags: [bash, install, pnpm, node]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "(ninguno)"
|
||||||
|
desc: "no acepta argumentos"
|
||||||
|
output: "progreso a stdout con versión instalada; exit code 1 si npm no está disponible o falla la instalación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_pnpm.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_pnpm.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_pnpm.sh
|
||||||
|
|
||||||
|
install_pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere Node.js/npm instalado previamente. Si la instalación global falla por permisos, usar `sudo npm install -g pnpm` manualmente. Idempotente: vuelve a ejecutarse sin error si pnpm ya existe.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_pnpm
|
||||||
|
# ------------
|
||||||
|
# Instala pnpm globalmente usando npm.
|
||||||
|
# Verifica que npm esté disponible antes de instalar.
|
||||||
|
# Idempotente: informa si pnpm ya está instalado.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_pnpm.sh
|
||||||
|
# install_pnpm
|
||||||
|
|
||||||
|
install_pnpm() {
|
||||||
|
echo "Instalando pnpm..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar si pnpm ya está instalado
|
||||||
|
if command -v pnpm &>/dev/null; then
|
||||||
|
local current_version
|
||||||
|
current_version="$(pnpm --version 2>/dev/null || echo "desconocida")"
|
||||||
|
echo "pnpm ya está instalado (versión: ${current_version})."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar que npm esté disponible
|
||||||
|
if ! command -v npm &>/dev/null; then
|
||||||
|
echo "install_pnpm: npm no está instalado (requerido para instalar pnpm)" >&2
|
||||||
|
echo " Instala Node.js primero con install_nodejs" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local npm_version
|
||||||
|
npm_version="$(npm --version 2>/dev/null || echo "?")"
|
||||||
|
echo "npm detectado: ${npm_version}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Instalando pnpm globalmente (npm install -g pnpm)..."
|
||||||
|
if ! npm install -g pnpm; then
|
||||||
|
echo "install_pnpm: falló la instalación de pnpm" >&2
|
||||||
|
echo " Intenta con sudo: sudo npm install -g pnpm" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar instalación
|
||||||
|
if ! command -v pnpm &>/dev/null; then
|
||||||
|
echo "install_pnpm: pnpm no está disponible después de la instalación" >&2
|
||||||
|
echo " Verifica que npm/bin esté en tu PATH" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local installed_version
|
||||||
|
installed_version="$(pnpm --version)"
|
||||||
|
echo ""
|
||||||
|
echo "pnpm instalado correctamente: ${installed_version}"
|
||||||
|
echo ""
|
||||||
|
echo "Comandos útiles:"
|
||||||
|
echo " pnpm install - Instalar dependencias"
|
||||||
|
echo " pnpm add <pkg> - Agregar paquete"
|
||||||
|
echo " pnpm run <cmd> - Ejecutar script"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_pnpm "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: install_python312
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_python312() -> void"
|
||||||
|
description: "Instala Python 3.12 detectando la distribución Linux automáticamente. Ubuntu/Debian/Mint usan deadsnakes PPA; Fedora/RHEL usan dnf; Arch/Manjaro usan pacman. Instala también python3.12-venv, python3.12-dev y verifica pip. Idempotente."
|
||||||
|
tags: [bash, install, python, python312]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "(ninguno)"
|
||||||
|
desc: "no acepta argumentos; detecta la distribución automáticamente"
|
||||||
|
output: "progreso a stdout; exit code 1 si la distribución no es soportada o falla la instalación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_python312.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_python312.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_python312.sh
|
||||||
|
|
||||||
|
install_python312
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `sudo`. Para distribuciones no soportadas, se recomienda usar pyenv. Idempotente: si `python3.12` ya existe en PATH, informa y termina sin hacer nada.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_python312
|
||||||
|
# -----------------
|
||||||
|
# Instala Python 3.12 en Linux detectando la distribución automáticamente.
|
||||||
|
# - Ubuntu/Debian/Pop/Mint/Elementary: usa deadsnakes PPA
|
||||||
|
# - Fedora/RHEL/CentOS: usa dnf
|
||||||
|
# - Arch/Manjaro: usa pacman
|
||||||
|
# Instala también python3.12-venv, python3.12-dev y verifica pip.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_python312.sh
|
||||||
|
# install_python312
|
||||||
|
|
||||||
|
install_python312() {
|
||||||
|
echo "Instalando Python 3.12..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Detectar distribución
|
||||||
|
local distro="unknown"
|
||||||
|
if [[ -f /etc/os-release ]]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
distro="$(. /etc/os-release && echo "${ID:-unknown}")"
|
||||||
|
echo "Distribución detectada: ${distro}"
|
||||||
|
else
|
||||||
|
echo "install_python312: no se pudo detectar la distribución" >&2
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar si Python 3.12 ya está instalado
|
||||||
|
if command -v python3.12 &>/dev/null; then
|
||||||
|
local current_version
|
||||||
|
current_version="$(python3.12 --version 2>&1)"
|
||||||
|
echo "Python 3.12 ya está instalado: ${current_version}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$distro" in
|
||||||
|
ubuntu|debian|pop|mint|elementary)
|
||||||
|
echo "Instalando Python 3.12 usando deadsnakes PPA..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Actualizando repositorios..."
|
||||||
|
if ! sudo apt update; then
|
||||||
|
echo "install_python312: falló la actualización de repositorios" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Verificando software-properties-common..."
|
||||||
|
if ! dpkg -l 2>/dev/null | grep -q software-properties-common; then
|
||||||
|
if ! sudo apt install -y software-properties-common; then
|
||||||
|
echo "install_python312: falló la instalación de software-properties-common" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Añadiendo deadsnakes PPA..."
|
||||||
|
if ! sudo add-apt-repository -y ppa:deadsnakes/ppa; then
|
||||||
|
echo "install_python312: falló al añadir deadsnakes PPA" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Actualizando lista de paquetes..."
|
||||||
|
if ! sudo apt update; then
|
||||||
|
echo "install_python312: falló la actualización después de añadir PPA" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Instalando Python 3.12 y herramientas..."
|
||||||
|
if ! sudo apt install -y python3.12 python3.12-venv python3.12-dev python3-pip; then
|
||||||
|
echo "install_python312: falló la instalación de Python 3.12" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
fedora|rhel|centos)
|
||||||
|
echo "Instalando Python 3.12 usando dnf..."
|
||||||
|
if ! sudo dnf install -y python3.12 python3.12-devel; then
|
||||||
|
echo "install_python312: falló la instalación con dnf" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
arch|manjaro)
|
||||||
|
echo "Instalando Python 3.12 usando pacman..."
|
||||||
|
if ! sudo pacman -S --noconfirm python; then
|
||||||
|
echo "install_python312: falló la instalación con pacman" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "install_python312: distribución no soportada automáticamente: ${distro}" >&2
|
||||||
|
echo " Opciones manuales:" >&2
|
||||||
|
echo " - Compilar desde fuente: https://www.python.org/downloads/" >&2
|
||||||
|
echo " - Usar pyenv: curl https://pyenv.run | bash" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar instalación
|
||||||
|
if ! command -v python3.12 &>/dev/null; then
|
||||||
|
echo "install_python312: Python 3.12 no está disponible después de la instalación" >&2
|
||||||
|
echo " Puede que necesites reiniciar la terminal" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local installed_version
|
||||||
|
installed_version="$(python3.12 --version 2>&1)"
|
||||||
|
echo "Python 3.12 instalado correctamente: ${installed_version}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar pip
|
||||||
|
echo "Verificando pip para Python 3.12..."
|
||||||
|
if ! python3.12 -m pip --version &>/dev/null; then
|
||||||
|
echo "pip no disponible, instalando..."
|
||||||
|
if ! python3.12 -m ensurepip --upgrade; then
|
||||||
|
echo " Instala pip manualmente: curl -sS https://bootstrap.pypa.io/get-pip.py | python3.12"
|
||||||
|
else
|
||||||
|
echo "pip instalado para Python 3.12"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "pip disponible para Python 3.12"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Comandos útiles:"
|
||||||
|
echo " python3.12 -m venv .venv - Crear entorno virtual"
|
||||||
|
echo " source .venv/bin/activate - Activar entorno"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_python312 "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: install_uv
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_uv() -> void"
|
||||||
|
description: "Instala uv, el gestor de paquetes Python ultra-rápido escrito en Rust, usando el instalador oficial de astral.sh. Configura PATH en ~/.bashrc y ~/.zshrc. Idempotente: si uv ya está instalado, informa y termina."
|
||||||
|
tags: [bash, install, uv, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "(ninguno)"
|
||||||
|
desc: "no acepta argumentos"
|
||||||
|
output: "progreso a stdout; exit code 1 si curl no está disponible o falla la instalación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_uv.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_uv.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_uv.sh
|
||||||
|
|
||||||
|
install_uv
|
||||||
|
|
||||||
|
# Uso posterior
|
||||||
|
uv venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
uv pip install requests pandas
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Instala en `$HOME/.cargo/bin`. Requiere `curl`. uv es compatible con pip pero 10-100x más rápido. Después de instalar en una sesión nueva, hay que recargar el shell.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_uv
|
||||||
|
# ----------
|
||||||
|
# Instala uv — gestor de paquetes Python ultra-rápido escrito en Rust.
|
||||||
|
# Usa el instalador oficial de astral.sh. Configura PATH en ~/.bashrc y ~/.zshrc.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_uv.sh
|
||||||
|
# install_uv
|
||||||
|
|
||||||
|
install_uv() {
|
||||||
|
echo "Instalando uv (gestor de paquetes Python)..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar si uv ya está instalado
|
||||||
|
if command -v uv &>/dev/null; then
|
||||||
|
local current_version
|
||||||
|
current_version="$(uv --version 2>/dev/null || echo "desconocida")"
|
||||||
|
echo "uv ya está instalado (versión: ${current_version})."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar curl
|
||||||
|
if ! command -v curl &>/dev/null; then
|
||||||
|
echo "install_uv: curl no está instalado (requerido)" >&2
|
||||||
|
echo " Instálalo con: sudo apt install curl" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Descargando e instalando uv (instalador oficial astral.sh)..."
|
||||||
|
if ! curl -LsSf https://astral.sh/uv/install.sh | sh; then
|
||||||
|
echo "install_uv: falló la instalación de uv" >&2
|
||||||
|
echo " Verifica tu conexión a internet y permisos" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Configurar PATH en ~/.bashrc
|
||||||
|
if ! grep -q ".cargo/bin" "$HOME/.bashrc" 2>/dev/null; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# uv and cargo binaries"
|
||||||
|
echo 'export PATH="$HOME/.cargo/bin:$PATH"'
|
||||||
|
} >> "$HOME/.bashrc"
|
||||||
|
echo "PATH añadido a ~/.bashrc"
|
||||||
|
else
|
||||||
|
echo "PATH ya configurado en ~/.bashrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configurar PATH en ~/.zshrc si existe
|
||||||
|
if [[ -f "$HOME/.zshrc" ]]; then
|
||||||
|
if ! grep -q ".cargo/bin" "$HOME/.zshrc" 2>/dev/null; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# uv and cargo binaries"
|
||||||
|
echo 'export PATH="$HOME/.cargo/bin:$PATH"'
|
||||||
|
} >> "$HOME/.zshrc"
|
||||||
|
echo "PATH añadido a ~/.zshrc"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cargar PATH en la sesión actual
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar instalación
|
||||||
|
if ! command -v uv &>/dev/null; then
|
||||||
|
echo "uv instalado pero no está en el PATH actual."
|
||||||
|
echo " Ejecuta: source ~/.bashrc (o abre una nueva terminal)"
|
||||||
|
else
|
||||||
|
local installed_version
|
||||||
|
installed_version="$(uv --version)"
|
||||||
|
echo "uv instalado correctamente: ${installed_version}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Comandos útiles de uv:"
|
||||||
|
echo " uv venv - Crear entorno virtual"
|
||||||
|
echo " uv pip install <package> - Instalar paquete"
|
||||||
|
echo " uv pip sync requirements.txt - Sincronizar dependencias"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_uv "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: install_volta
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_volta() -> void"
|
||||||
|
description: "Instala Volta, el gestor de versiones de Node.js, usando el instalador oficial de get.volta.sh. Configura VOLTA_HOME y PATH en ~/.bashrc y ~/.zshrc. Idempotente: si Volta ya está instalado, informa y termina."
|
||||||
|
tags: [bash, install, volta, node]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "(ninguno)"
|
||||||
|
desc: "no acepta argumentos"
|
||||||
|
output: "progreso a stdout; exit code 1 si curl no está disponible o falla la instalación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_volta.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_volta.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_volta.sh
|
||||||
|
|
||||||
|
install_volta
|
||||||
|
|
||||||
|
# Uso posterior (tras recargar shell)
|
||||||
|
volta install node
|
||||||
|
volta install pnpm
|
||||||
|
volta list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Volta se instala en `$HOME/.volta`. Requiere `curl`. A diferencia de nvm, Volta gestiona versiones de Node.js a nivel de proyecto via `package.json`. Después de instalar, recargar el shell con `source ~/.bashrc`.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_volta
|
||||||
|
# -------------
|
||||||
|
# Instala Volta — gestor de versiones de Node.js rápido y confiable.
|
||||||
|
# Usa el instalador oficial de get.volta.sh. Configura VOLTA_HOME y PATH
|
||||||
|
# en ~/.bashrc y ~/.zshrc.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_volta.sh
|
||||||
|
# install_volta
|
||||||
|
|
||||||
|
install_volta() {
|
||||||
|
echo "Instalando Volta (gestor de versiones Node.js)..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar si Volta ya está instalado
|
||||||
|
if command -v volta &>/dev/null; then
|
||||||
|
local current_version
|
||||||
|
current_version="$(volta --version 2>/dev/null || echo "desconocida")"
|
||||||
|
echo "Volta ya está instalado (versión: ${current_version})."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar curl
|
||||||
|
if ! command -v curl &>/dev/null; then
|
||||||
|
echo "install_volta: curl no está instalado (requerido)" >&2
|
||||||
|
echo " Instálalo con: sudo apt install curl" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Descargando e instalando Volta (instalador oficial)..."
|
||||||
|
if ! curl https://get.volta.sh | bash; then
|
||||||
|
echo "install_volta: falló la instalación de Volta" >&2
|
||||||
|
echo " Verifica tu conexión a internet" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Configurar variables de entorno
|
||||||
|
local volta_home="$HOME/.volta"
|
||||||
|
export VOLTA_HOME="$volta_home"
|
||||||
|
export PATH="$volta_home/bin:$PATH"
|
||||||
|
|
||||||
|
# Configurar en ~/.bashrc
|
||||||
|
if ! grep -q "VOLTA_HOME" "$HOME/.bashrc" 2>/dev/null; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Volta configuration"
|
||||||
|
echo 'export VOLTA_HOME="$HOME/.volta"'
|
||||||
|
echo 'export PATH="$VOLTA_HOME/bin:$PATH"'
|
||||||
|
} >> "$HOME/.bashrc"
|
||||||
|
echo "Variables añadidas a ~/.bashrc"
|
||||||
|
else
|
||||||
|
echo "Variables ya configuradas en ~/.bashrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configurar en ~/.zshrc si existe
|
||||||
|
if [[ -f "$HOME/.zshrc" ]]; then
|
||||||
|
if ! grep -q "VOLTA_HOME" "$HOME/.zshrc" 2>/dev/null; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Volta configuration"
|
||||||
|
echo 'export VOLTA_HOME="$HOME/.volta"'
|
||||||
|
echo 'export PATH="$VOLTA_HOME/bin:$PATH"'
|
||||||
|
} >> "$HOME/.zshrc"
|
||||||
|
echo "Variables añadidas a ~/.zshrc"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar instalación
|
||||||
|
if command -v volta &>/dev/null; then
|
||||||
|
local installed_version
|
||||||
|
installed_version="$(volta --version)"
|
||||||
|
echo "Volta instalado correctamente: ${installed_version}"
|
||||||
|
elif [[ -f "$HOME/.volta/bin/volta" ]]; then
|
||||||
|
echo "Volta instalado en ${HOME}/.volta/bin pero no está en PATH actual."
|
||||||
|
echo " Ejecuta: source ~/.bashrc (o abre una nueva terminal)"
|
||||||
|
else
|
||||||
|
echo "install_volta: Volta no está disponible después de la instalación" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Próximos pasos:"
|
||||||
|
echo " 1. source ~/.bashrc - Recargar shell"
|
||||||
|
echo " 2. volta install node - Instalar Node.js"
|
||||||
|
echo " 3. volta install pnpm - Instalar pnpm"
|
||||||
|
echo ""
|
||||||
|
echo "Comandos útiles:"
|
||||||
|
echo " volta install node@20 - Instalar Node.js v20"
|
||||||
|
echo " volta list - Ver herramientas instaladas"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_volta "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: install_wails
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_wails() -> void"
|
||||||
|
description: "Instala Wails v2 (framework de apps de escritorio Go). Detecta la distribución Linux e instala las dependencias de sistema (GTK3, WebKit2GTK, build tools) y luego el CLI via 'go install ...@latest'. Requiere Go instalado previamente."
|
||||||
|
tags: [bash, install, wails, desktop]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "(ninguno)"
|
||||||
|
desc: "no acepta argumentos; detecta la distribución automáticamente"
|
||||||
|
output: "progreso a stdout; exit code 1 si Go no está disponible, no se detecta la distribución, o falla la instalación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_wails.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_wails.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_wails.sh
|
||||||
|
|
||||||
|
install_wails
|
||||||
|
|
||||||
|
# Verificar
|
||||||
|
wails doctor
|
||||||
|
|
||||||
|
# Crear proyecto
|
||||||
|
wails init -n my-desktop-app -t react
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere Go y sudo. Para distribuciones no listadas (opensuse, etc.) instala las dependencias manualmente y luego procede con el CLI. Templates disponibles: vanilla, vue, react, svelte, lit, angular.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_wails
|
||||||
|
# -------------
|
||||||
|
# Instala Wails v2 — framework para aplicaciones de escritorio en Go.
|
||||||
|
# Detecta la distribución Linux e instala las dependencias de sistema necesarias
|
||||||
|
# (GTK3, WebKit2GTK, build tools) y luego instala el CLI de Wails via go install.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_wails.sh
|
||||||
|
# install_wails
|
||||||
|
|
||||||
|
install_wails() {
|
||||||
|
echo "Instalando Wails..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar Go
|
||||||
|
if ! command -v go &>/dev/null; then
|
||||||
|
echo "install_wails: Go no está instalado (requerido)" >&2
|
||||||
|
echo " Instálalo primero con install_go" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local go_version
|
||||||
|
go_version="$(go version)"
|
||||||
|
echo "Go detectado: ${go_version}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Detectar distribución
|
||||||
|
local distro="unknown"
|
||||||
|
if [[ -f /etc/os-release ]]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
distro="$(. /etc/os-release && echo "${ID:-unknown}")"
|
||||||
|
else
|
||||||
|
echo "install_wails: no se pudo detectar la distribución de Linux" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Instalando dependencias del sistema para ${distro}..."
|
||||||
|
case "$distro" in
|
||||||
|
ubuntu|debian|linuxmint|pop)
|
||||||
|
sudo apt update
|
||||||
|
if ! sudo apt install -y libgtk-3-dev libwebkit2gtk-4.0-dev build-essential pkg-config; then
|
||||||
|
echo "install_wails: falló la instalación de dependencias de sistema" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
fedora|rhel|centos)
|
||||||
|
if ! sudo dnf install -y gtk3-devel webkit2gtk3-devel gcc-c++ pkgconfig; then
|
||||||
|
echo "install_wails: falló la instalación de dependencias de sistema" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
arch|manjaro)
|
||||||
|
if ! sudo pacman -Sy --noconfirm gtk3 webkit2gtk base-devel; then
|
||||||
|
echo "install_wails: falló la instalación de dependencias de sistema" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
opensuse*)
|
||||||
|
if ! sudo zypper install -y gtk3-devel webkit2gtk3-devel gcc-c++ pkg-config; then
|
||||||
|
echo "install_wails: falló la instalación de dependencias de sistema" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Distribución no reconocida: ${distro}"
|
||||||
|
echo "Instala manualmente: gtk3, webkit2gtk, build-essential, pkg-config"
|
||||||
|
echo "Continuando con la instalación de Wails CLI..."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Instalando Wails CLI (go install github.com/wailsapp/wails/v2/cmd/wails@latest)..."
|
||||||
|
if ! go install github.com/wailsapp/wails/v2/cmd/wails@latest; then
|
||||||
|
echo "install_wails: falló la instalación del CLI de Wails" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Asegurar que $GOPATH/bin esté en PATH
|
||||||
|
if [[ ":$PATH:" != *":$HOME/go/bin:"* ]]; then
|
||||||
|
local shell_config=""
|
||||||
|
if [[ -f "$HOME/.bashrc" ]]; then
|
||||||
|
shell_config="$HOME/.bashrc"
|
||||||
|
elif [[ -f "$HOME/.zshrc" ]]; then
|
||||||
|
shell_config="$HOME/.zshrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$shell_config" ]]; then
|
||||||
|
if ! grep -q 'export PATH=\$PATH:\$HOME/go/bin' "$shell_config" 2>/dev/null; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Go binaries"
|
||||||
|
echo 'export PATH=$PATH:$HOME/go/bin'
|
||||||
|
} >> "$shell_config"
|
||||||
|
echo "PATH de Go añadido a ${shell_config}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
export PATH="$PATH:$HOME/go/bin"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar instalación
|
||||||
|
if command -v wails &>/dev/null; then
|
||||||
|
local wails_version
|
||||||
|
wails_version="$(wails version 2>/dev/null || echo "instalado")"
|
||||||
|
echo "Wails instalado correctamente: ${wails_version}"
|
||||||
|
echo ""
|
||||||
|
echo "Comandos básicos de Wails:"
|
||||||
|
echo " wails init -n myapp -t vanilla - Crear proyecto"
|
||||||
|
echo " wails dev - Modo desarrollo"
|
||||||
|
echo " wails build - Build producción"
|
||||||
|
echo " wails doctor - Verificar instalación"
|
||||||
|
else
|
||||||
|
echo "Wails instalado pero no está en PATH."
|
||||||
|
echo " Reinicia tu terminal o ejecuta: source ~/.bashrc"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_wails "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: list_listening_ports
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "list_listening_ports([mode: string]) -> void"
|
||||||
|
description: "Lista puertos activos del sistema usando ss (preferido) o netstat como fallback. Modos: all (LISTEN), tcp, udp, established (conexiones activas), stats (resumen + interfaces). Imprime salida tabulada a stdout."
|
||||||
|
tags: [bash, ports, network, listening, monitoring]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: mode
|
||||||
|
desc: "qué listar: all|tcp|udp|established|stats (default: all)"
|
||||||
|
output: "tabla de puertos/conexiones a stdout; exit code 1 si no hay ss ni netstat, o si el modo es desconocido"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/list_listening_ports.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/gestion_linux/puertos_activos.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/list_listening_ports.sh
|
||||||
|
|
||||||
|
# Todos los puertos en escucha
|
||||||
|
list_listening_ports
|
||||||
|
|
||||||
|
# Solo TCP
|
||||||
|
list_listening_ports tcp
|
||||||
|
|
||||||
|
# Conexiones establecidas
|
||||||
|
list_listening_ports established
|
||||||
|
|
||||||
|
# Estadísticas e interfaces
|
||||||
|
list_listening_ports stats
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Prefiere `ss` (iproute2) sobre `netstat` (net-tools). El modo `established` limita a 30 filas para no saturar el terminal. No incluye monitor en tiempo real (solo snapshot).
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# list_listening_ports
|
||||||
|
# --------------------
|
||||||
|
# Lista puertos activos en el sistema usando ss o netstat.
|
||||||
|
# Soporta filtrado por protocolo y estadísticas de red.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source list_listening_ports.sh
|
||||||
|
# list_listening_ports [mode]
|
||||||
|
#
|
||||||
|
# ARGUMENTOS:
|
||||||
|
# mode Modo de listado: all|tcp|udp|established|stats (default: all)
|
||||||
|
|
||||||
|
list_listening_ports() {
|
||||||
|
local mode="${1:-all}"
|
||||||
|
|
||||||
|
_has_ss() {
|
||||||
|
command -v ss &>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_has_netstat() {
|
||||||
|
command -v netstat &>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_require_net_tool() {
|
||||||
|
if ! _has_ss && ! _has_netstat; then
|
||||||
|
echo "list_listening_ports: no se encontró ss ni netstat en el sistema" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_llp_all() {
|
||||||
|
echo "=== Puertos en escucha (LISTEN) ==="
|
||||||
|
if _has_ss; then
|
||||||
|
ss -tulnp 2>/dev/null | awk 'NR==1 || /LISTEN/ {print}' | column -t
|
||||||
|
elif _has_netstat; then
|
||||||
|
netstat -tulnp 2>/dev/null | awk 'NR<=2 || /LISTEN/ {print}' | column -t
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_llp_tcp() {
|
||||||
|
echo "=== Puertos TCP ==="
|
||||||
|
if _has_ss; then
|
||||||
|
ss -tnlp 2>/dev/null | column -t
|
||||||
|
elif _has_netstat; then
|
||||||
|
netstat -tnlp 2>/dev/null | column -t
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_llp_udp() {
|
||||||
|
echo "=== Puertos UDP ==="
|
||||||
|
if _has_ss; then
|
||||||
|
ss -unlp 2>/dev/null | column -t
|
||||||
|
elif _has_netstat; then
|
||||||
|
netstat -unlp 2>/dev/null | column -t
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_llp_established() {
|
||||||
|
echo "=== Conexiones TCP establecidas ==="
|
||||||
|
local count=0
|
||||||
|
if _has_ss; then
|
||||||
|
ss -tnp 2>/dev/null | awk 'NR==1 || /ESTAB/ {print}' | column -t | head -30
|
||||||
|
count="$(ss -tnp 2>/dev/null | grep -c ESTAB || echo 0)"
|
||||||
|
elif _has_netstat; then
|
||||||
|
netstat -tnp 2>/dev/null | awk 'NR<=2 || /ESTABLISHED/ {print}' | column -t | head -30
|
||||||
|
count="$(netstat -tnp 2>/dev/null | grep -c ESTABLISHED || echo 0)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "Total de conexiones establecidas: ${count}"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_llp_stats() {
|
||||||
|
echo "=== Estadísticas de red ==="
|
||||||
|
if _has_ss; then
|
||||||
|
ss -s 2>/dev/null
|
||||||
|
echo ""
|
||||||
|
echo "=== Interfaces de red ==="
|
||||||
|
ip -br addr 2>/dev/null || ifconfig -a 2>/dev/null || echo "No se pudo obtener info de interfaces"
|
||||||
|
elif _has_netstat; then
|
||||||
|
netstat -s 2>/dev/null | head -50
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_require_net_tool || return 1
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
all) _llp_all ;;
|
||||||
|
tcp) _llp_tcp ;;
|
||||||
|
udp) _llp_udp ;;
|
||||||
|
established) _llp_established ;;
|
||||||
|
stats) _llp_stats ;;
|
||||||
|
*)
|
||||||
|
echo "list_listening_ports: modo desconocido '${mode}'. Usa: all|tcp|udp|established|stats" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
list_listening_ports "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: rsync_deploy
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "rsync_deploy(local_dir: string, ssh_alias: string, remote_dir: string) -> json"
|
||||||
|
description: "Sincroniza un directorio local a un host remoto via rsync+SSH. Excluye archivos de desarrollo y bases de datos locales. Crea el directorio remoto si no existe."
|
||||||
|
tags: [rsync, deploy, sync, ssh, remote, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: local_dir
|
||||||
|
desc: "ruta al directorio local a sincronizar (ej: apps/dag_engine/)"
|
||||||
|
- name: ssh_alias
|
||||||
|
desc: "alias SSH del host destino definido en ~/.ssh/config (ej: myserver)"
|
||||||
|
- name: remote_dir
|
||||||
|
desc: "ruta absoluta del directorio destino en el host remoto (ej: /opt/apps/dag_engine)"
|
||||||
|
output: "JSON con files_transferred (int), total_size (string), ssh_alias (string), remote_dir (string)"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/rsync_deploy.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/rsync_deploy.sh
|
||||||
|
|
||||||
|
# Deploy de una app al servidor de producción
|
||||||
|
result=$(rsync_deploy "apps/dag_engine/" "prod-server" "/opt/apps/dag_engine")
|
||||||
|
echo "$result"
|
||||||
|
# {"files_transferred": 12, "total_size": "1.23 MB", "ssh_alias": "prod-server", "remote_dir": "/opt/apps/dag_engine"}
|
||||||
|
|
||||||
|
# Deploy con ruta absoluta local
|
||||||
|
rsync_deploy "/home/lucas/fn_registry/apps/myapp/" "myserver" "/opt/myapp"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Usa `rsync -avz --delete`: archivos borrados localmente se borran también en el remoto.
|
||||||
|
- Antes del rsync crea el directorio remoto con `ssh mkdir -p` para evitar errores si no existe.
|
||||||
|
- Archivos excluidos: `.git`, `operations.db*`, `*.exe`, `node_modules`, `.venv`, `__pycache__`, `build/`, `*.db-shm`, `*.db-wal`.
|
||||||
|
- El JSON de salida va a stdout; los mensajes de progreso y errores van a stderr.
|
||||||
|
- Exit code 1 si rsync falla o si el directorio local no existe.
|
||||||
|
- El `ssh_alias` se resuelve con la configuración de `~/.ssh/config`, incluyendo host, user, identityfile y puerto.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# rsync_deploy — Sincroniza un directorio local a un host remoto via rsync+SSH
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
rsync_deploy() {
|
||||||
|
local local_dir="$1"
|
||||||
|
local ssh_alias="$2"
|
||||||
|
local remote_dir="$3"
|
||||||
|
|
||||||
|
if [[ -z "$local_dir" || -z "$ssh_alias" || -z "$remote_dir" ]]; then
|
||||||
|
echo "rsync_deploy: se requieren local_dir, ssh_alias y remote_dir" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$local_dir" ]]; then
|
||||||
|
echo "rsync_deploy: directorio local '$local_dir' no existe" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Crear directorio remoto si no existe
|
||||||
|
echo "rsync_deploy: verificando directorio remoto '$remote_dir' en '$ssh_alias'..." >&2
|
||||||
|
if ! ssh "$ssh_alias" "mkdir -p '$remote_dir'" 2>&1; then
|
||||||
|
echo "rsync_deploy: no se pudo crear el directorio remoto '$remote_dir' en '$ssh_alias'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ejecutar rsync y capturar salida para parsear estadísticas
|
||||||
|
local rsync_output
|
||||||
|
rsync_output=$(rsync -avz --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='operations.db*' \
|
||||||
|
--exclude='*.exe' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='.venv' \
|
||||||
|
--exclude='__pycache__' \
|
||||||
|
--exclude='build/' \
|
||||||
|
--exclude='*.db-shm' \
|
||||||
|
--exclude='*.db-wal' \
|
||||||
|
-e ssh \
|
||||||
|
"$local_dir" \
|
||||||
|
"${ssh_alias}:${remote_dir}" 2>&1) || {
|
||||||
|
echo "rsync_deploy: rsync falló al sincronizar '$local_dir' → '${ssh_alias}:${remote_dir}'" >&2
|
||||||
|
echo "$rsync_output" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "$rsync_output" >&2
|
||||||
|
|
||||||
|
# Parsear número de archivos transferidos
|
||||||
|
local files_transferred
|
||||||
|
files_transferred=$(echo "$rsync_output" | grep -oP 'Number of regular files transferred: \K[0-9,]+' | tr -d ',' || echo "0")
|
||||||
|
if [[ -z "$files_transferred" ]]; then
|
||||||
|
# Intentar formato alternativo de rsync
|
||||||
|
files_transferred=$(echo "$rsync_output" | grep -oP 'Number of files transferred: \K[0-9,]+' | tr -d ',' || echo "0")
|
||||||
|
fi
|
||||||
|
if [[ -z "$files_transferred" ]]; then
|
||||||
|
files_transferred="0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parsear tamaño total transferido
|
||||||
|
local total_size
|
||||||
|
total_size=$(echo "$rsync_output" | grep -oP 'Total transferred file size: \K[0-9,.]+ \w+' || echo "0 bytes")
|
||||||
|
if [[ -z "$total_size" ]]; then
|
||||||
|
total_size="0 bytes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Emitir JSON a stdout
|
||||||
|
printf '{"files_transferred": %s, "total_size": "%s", "ssh_alias": "%s", "remote_dir": "%s"}\n' \
|
||||||
|
"$files_transferred" \
|
||||||
|
"$total_size" \
|
||||||
|
"$ssh_alias" \
|
||||||
|
"$remote_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
rsync_deploy "$@"
|
||||||
|
fi
|
||||||
@@ -34,6 +34,11 @@ echo $PORT > .jupyter-port
|
|||||||
|
|
||||||
source .venv/bin/activate 2>/dev/null || true
|
source .venv/bin/activate 2>/dev/null || true
|
||||||
|
|
||||||
|
# IPython startup: cargar .ipython/ local (FN_REGISTRY_ROOT, helpers, sys.path)
|
||||||
|
if [ -d "$(pwd)/.ipython" ]; then
|
||||||
|
export IPYTHONDIR="$(pwd)/.ipython"
|
||||||
|
fi
|
||||||
|
|
||||||
if ! python -c "import jupyter_collaboration" 2>/dev/null; then
|
if ! python -c "import jupyter_collaboration" 2>/dev/null; then
|
||||||
echo "ERROR: jupyter-collaboration no esta instalado"
|
echo "ERROR: jupyter-collaboration no esta instalado"
|
||||||
echo "Instala con: uv add jupyter-collaboration"
|
echo "Instala con: uv add jupyter-collaboration"
|
||||||
|
|||||||
@@ -33,7 +33,24 @@ import sqlite3
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# ── FN_REGISTRY_ROOT ────────────────────────────────────────
|
# ── FN_REGISTRY_ROOT ────────────────────────────────────────
|
||||||
FN_REGISTRY_ROOT = Path("${registry_root}")
|
# Prioridad: env var > path hardcoded > descubrimiento automatico
|
||||||
|
def _discover_registry_root():
|
||||||
|
if os.environ.get("FN_REGISTRY_ROOT"):
|
||||||
|
return Path(os.environ["FN_REGISTRY_ROOT"]).resolve()
|
||||||
|
hardcoded = Path("${registry_root}")
|
||||||
|
if (hardcoded / "registry.db").exists():
|
||||||
|
return hardcoded
|
||||||
|
# Subir desde CWD hasta encontrar registry.db
|
||||||
|
p = Path.cwd()
|
||||||
|
for _ in range(10):
|
||||||
|
if (p / "registry.db").exists():
|
||||||
|
return p
|
||||||
|
if p.parent == p:
|
||||||
|
break
|
||||||
|
p = p.parent
|
||||||
|
return hardcoded
|
||||||
|
|
||||||
|
FN_REGISTRY_ROOT = _discover_registry_root()
|
||||||
os.environ["FN_REGISTRY_ROOT"] = str(FN_REGISTRY_ROOT)
|
os.environ["FN_REGISTRY_ROOT"] = str(FN_REGISTRY_ROOT)
|
||||||
|
|
||||||
# ── sys.path: importar funciones Python del registry ────────
|
# ── sys.path: importar funciones Python del registry ────────
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: init_go_module
|
||||||
|
kind: pipeline
|
||||||
|
lang: bash
|
||||||
|
domain: pipelines
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "init_go_module([module_path: string]) -> void"
|
||||||
|
description: "Pipeline que inicializa un módulo Go simple en el directorio actual. Crea go.mod (go mod init), main.go con hello world, .gitignore, build.sh (cross-compilation linux/windows/all) y dev.sh para ejecución rápida."
|
||||||
|
tags: [bash, go, module, init, scaffold, launcher]
|
||||||
|
uses_functions: [install_go_bash_infra, assert_command_exists_bash_shell]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: module_path
|
||||||
|
desc: "path del módulo Go, ej: github.com/user/mymodule (default: github.com/user/<dirname>)"
|
||||||
|
output: "crea archivos en el directorio actual y muestra progreso; exit code 1 si Go no está instalado o go mod init falla"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/pipelines/init_go_module.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/inicializar_repos/go/init_go_module.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir mi-modulo && cd mi-modulo
|
||||||
|
|
||||||
|
# Con module_path automático (github.com/user/mi-modulo)
|
||||||
|
bash bash/functions/pipelines/init_go_module.sh
|
||||||
|
|
||||||
|
# Con module_path explícito
|
||||||
|
bash bash/functions/pipelines/init_go_module.sh github.com/miorg/mi-modulo
|
||||||
|
|
||||||
|
# Ejecutar tras crear
|
||||||
|
./dev.sh
|
||||||
|
./build.sh
|
||||||
|
./build.sh all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Crea un módulo minimalista (main.go simple + build tools). Para proyectos con estructura profesional (cmd/, internal/, pkg/) usar `init_go_project`. No inicializa git — añadido manualmente o via gitea_init_app.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# init_go_module
|
||||||
|
# --------------
|
||||||
|
# Pipeline que inicializa un módulo Go simple en el directorio actual.
|
||||||
|
# Crea: go.mod, main.go, .gitignore, build.sh y dev.sh.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# bash init_go_module.sh [module_path]
|
||||||
|
#
|
||||||
|
# ARGUMENTOS:
|
||||||
|
# module_path Path del módulo Go (opcional; default: github.com/user/<dirname>)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
init_go_module() {
|
||||||
|
local module_path="${1:-}"
|
||||||
|
|
||||||
|
local dir_name
|
||||||
|
dir_name="$(basename "$(pwd)")"
|
||||||
|
|
||||||
|
if [[ -z "$module_path" ]]; then
|
||||||
|
module_path="github.com/user/${dir_name}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Inicializar Módulo Go ==="
|
||||||
|
echo " Módulo: ${module_path}"
|
||||||
|
echo " Directorio: $(pwd)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar Go
|
||||||
|
if ! command -v go &>/dev/null; then
|
||||||
|
echo "init_go_module: Go no está instalado" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Go detectado: $(go version)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Inicializar módulo
|
||||||
|
echo "Inicializando go module..."
|
||||||
|
if ! go mod init "$module_path"; then
|
||||||
|
echo "init_go_module: falló go mod init" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "go.mod creado"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Crear main.go
|
||||||
|
echo "Creando main.go..."
|
||||||
|
cat > main.go << 'GOEOF'
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("Hola desde Go!")
|
||||||
|
fmt.Println("Modulo inicializado correctamente")
|
||||||
|
}
|
||||||
|
GOEOF
|
||||||
|
echo "main.go creado"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Crear .gitignore
|
||||||
|
echo "Creando .gitignore..."
|
||||||
|
cat > .gitignore << 'IGNEOF'
|
||||||
|
# Binarios
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
bin/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Archivos de test
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.txt
|
||||||
|
*.prof
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
IGNEOF
|
||||||
|
echo ".gitignore creado"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Crear build.sh
|
||||||
|
echo "Creando build.sh..."
|
||||||
|
cat > build.sh << 'BUILDEOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TARGET="${1:-linux}"
|
||||||
|
mkdir -p bin
|
||||||
|
|
||||||
|
case "$TARGET" in
|
||||||
|
linux)
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o bin/app-linux main.go
|
||||||
|
chmod +x bin/app-linux
|
||||||
|
echo "Compilado: bin/app-linux"
|
||||||
|
;;
|
||||||
|
windows)
|
||||||
|
GOOS=windows GOARCH=amd64 go build -o bin/app-windows.exe main.go
|
||||||
|
echo "Compilado: bin/app-windows.exe"
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o bin/app-linux main.go && echo "Linux OK"
|
||||||
|
GOOS=windows GOARCH=amd64 go build -o bin/app-windows.exe main.go && echo "Windows OK"
|
||||||
|
GOOS=darwin GOARCH=amd64 go build -o bin/app-macos main.go && echo "macOS OK"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Uso: ./build.sh [linux|windows|all]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
BUILDEOF
|
||||||
|
chmod +x build.sh
|
||||||
|
echo "build.sh creado"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Crear dev.sh
|
||||||
|
echo "Creando dev.sh..."
|
||||||
|
cat > dev.sh << 'DEVEOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
go run main.go "$@"
|
||||||
|
DEVEOF
|
||||||
|
chmod +x dev.sh
|
||||||
|
echo "dev.sh creado"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Modulo Go creado exitosamente ==="
|
||||||
|
echo ""
|
||||||
|
echo "Archivos generados:"
|
||||||
|
echo " main.go - Codigo del modulo"
|
||||||
|
echo " go.mod - Modulo Go (${module_path})"
|
||||||
|
echo " .gitignore - Exclusiones git"
|
||||||
|
echo " build.sh - Compilar binario"
|
||||||
|
echo " dev.sh - Ejecutar directamente"
|
||||||
|
echo ""
|
||||||
|
echo "Proximos pasos:"
|
||||||
|
echo " ./dev.sh - Ejecutar el modulo"
|
||||||
|
echo " ./build.sh - Compilar para Linux"
|
||||||
|
echo " ./build.sh windows - Compilar para Windows"
|
||||||
|
echo " ./build.sh all - Compilar para todo"
|
||||||
|
}
|
||||||
|
|
||||||
|
init_go_module "$@"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: init_go_project
|
||||||
|
kind: pipeline
|
||||||
|
lang: bash
|
||||||
|
domain: pipelines
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "init_go_project([module_path: string]) -> void"
|
||||||
|
description: "Pipeline que inicializa un repositorio Go completo con estructura profesional: cmd/app, internal/config, internal/service (con tests), pkg/version, scripts (run/test/build/build-all/lint), Makefile, .gitignore, README y git init con git add."
|
||||||
|
tags: [bash, go, project, init, scaffold, professional, launcher]
|
||||||
|
uses_functions: [install_go_bash_infra, assert_command_exists_bash_shell, create_project_structure_bash_shell]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: module_path
|
||||||
|
desc: "path del módulo Go, ej: github.com/org/myproject (default: github.com/<whoami>/<dirname>)"
|
||||||
|
output: "crea estructura completa en el directorio actual y muestra progreso; exit code 1 si Go o git no están disponibles"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/pipelines/init_go_project.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/inicializar_repos/go/init_go_proyect.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir mi-proyecto && cd mi-proyecto
|
||||||
|
|
||||||
|
# Con module_path automático
|
||||||
|
bash bash/functions/pipelines/init_go_project.sh
|
||||||
|
|
||||||
|
# Con module_path explícito
|
||||||
|
bash bash/functions/pipelines/init_go_project.sh github.com/miorg/mi-proyecto
|
||||||
|
|
||||||
|
# Ejecutar y testear tras crear
|
||||||
|
./scripts/run.sh
|
||||||
|
./scripts/test.sh
|
||||||
|
make build-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Genera una arquitectura funcional con separación clara: cmd/ (entrypoint), internal/ (lógica privada), pkg/ (librería pública). Incluye un test de ejemplo en internal/service/. Ejecuta go mod tidy y git init + git add al final. Para módulos simples sin estructura, usar `init_go_module`.
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# init_go_project
|
||||||
|
# ---------------
|
||||||
|
# Pipeline que inicializa un repositorio Go completo con estructura profesional:
|
||||||
|
# cmd/app, internal/config, internal/service, pkg/version, scripts/, Makefile,
|
||||||
|
# .gitignore, README y git init.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# bash init_go_project.sh [module_path]
|
||||||
|
#
|
||||||
|
# ARGUMENTOS:
|
||||||
|
# module_path Path del módulo Go (opcional; default: github.com/<whoami>/<dirname>)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
init_go_project() {
|
||||||
|
local module_path="${1:-}"
|
||||||
|
|
||||||
|
local project_name
|
||||||
|
project_name="$(basename "$(pwd)")"
|
||||||
|
|
||||||
|
if [[ -z "$module_path" ]]; then
|
||||||
|
module_path="github.com/$(whoami)/${project_name}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Inicializar Proyecto Go Completo ==="
|
||||||
|
echo " Proyecto: ${project_name}"
|
||||||
|
echo " Módulo: ${module_path}"
|
||||||
|
echo " Dir: $(pwd)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar dependencias
|
||||||
|
if ! command -v go &>/dev/null; then
|
||||||
|
echo "init_go_project: Go no está instalado" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! command -v git &>/dev/null; then
|
||||||
|
echo "init_go_project: git no está instalado" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Go: $(go version)"
|
||||||
|
echo "git: $(git --version)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Crear estructura de carpetas
|
||||||
|
echo "Creando estructura de carpetas..."
|
||||||
|
mkdir -p cmd/app internal/config internal/service pkg/version scripts bin
|
||||||
|
echo "Estructura base creada"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# go mod init
|
||||||
|
echo "Inicializando módulo Go..."
|
||||||
|
if ! go mod init "$module_path"; then
|
||||||
|
echo "init_go_project: falló go mod init" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "go.mod creado"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Crear archivos fuente
|
||||||
|
echo "Creando archivos fuente..."
|
||||||
|
|
||||||
|
cat > cmd/app/main.go << GOEOF
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"${module_path}/internal/config"
|
||||||
|
"${module_path}/internal/service"
|
||||||
|
"${module_path}/pkg/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
msg := service.BuildStartupMessage(cfg.AppName, version.Current())
|
||||||
|
fmt.Println(msg)
|
||||||
|
}
|
||||||
|
GOEOF
|
||||||
|
|
||||||
|
cat > internal/config/config.go << 'CFGEOF'
|
||||||
|
package config
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
type AppConfig struct {
|
||||||
|
AppName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() AppConfig {
|
||||||
|
appName := os.Getenv("APP_NAME")
|
||||||
|
if appName == "" {
|
||||||
|
appName = "Go Project"
|
||||||
|
}
|
||||||
|
return AppConfig{AppName: appName}
|
||||||
|
}
|
||||||
|
CFGEOF
|
||||||
|
|
||||||
|
cat > internal/service/message.go << 'SVCEOF'
|
||||||
|
package service
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func BuildStartupMessage(appName, appVersion string) string {
|
||||||
|
return fmt.Sprintf("%s iniciado correctamente (version %s)", appName, appVersion)
|
||||||
|
}
|
||||||
|
SVCEOF
|
||||||
|
|
||||||
|
cat > internal/service/message_test.go << 'TESTEOF'
|
||||||
|
package service
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildStartupMessage(t *testing.T) {
|
||||||
|
result := BuildStartupMessage("MyApp", "0.1.0")
|
||||||
|
want := "MyApp iniciado correctamente (version 0.1.0)"
|
||||||
|
if result != want {
|
||||||
|
t.Fatalf("resultado inesperado:\nwant: %s\ngot: %s", want, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TESTEOF
|
||||||
|
|
||||||
|
cat > pkg/version/version.go << 'VEREOF'
|
||||||
|
package version
|
||||||
|
|
||||||
|
var value = "0.1.0"
|
||||||
|
|
||||||
|
func Current() string {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
VEREOF
|
||||||
|
|
||||||
|
echo "Archivos fuente creados"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Makefile
|
||||||
|
echo "Creando Makefile..."
|
||||||
|
cat > Makefile << 'MAKEEOF'
|
||||||
|
APP_NAME=app
|
||||||
|
CMD_PATH=./cmd/app
|
||||||
|
BIN_DIR=./bin
|
||||||
|
|
||||||
|
.PHONY: run build build-all test fmt tidy clean
|
||||||
|
|
||||||
|
run:
|
||||||
|
go run $(CMD_PATH)
|
||||||
|
|
||||||
|
build:
|
||||||
|
mkdir -p $(BIN_DIR)
|
||||||
|
go build -o $(BIN_DIR)/$(APP_NAME) $(CMD_PATH)
|
||||||
|
|
||||||
|
build-all:
|
||||||
|
bash ./scripts/build-all.sh
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
gofmt -w ./cmd ./internal ./pkg
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(BIN_DIR)
|
||||||
|
MAKEEOF
|
||||||
|
echo "Makefile creado"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# .gitignore
|
||||||
|
cat > .gitignore << 'IGNEOF'
|
||||||
|
bin/
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.out
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
IGNEOF
|
||||||
|
|
||||||
|
# README
|
||||||
|
cat > README.md << READEOF
|
||||||
|
# ${project_name}
|
||||||
|
|
||||||
|
Repositorio Go inicializado con estructura completa.
|
||||||
|
|
||||||
|
## Uso rápido
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
./scripts/run.sh
|
||||||
|
./scripts/test.sh
|
||||||
|
./scripts/build.sh
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Make
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
make run
|
||||||
|
make test
|
||||||
|
make build
|
||||||
|
make build-all
|
||||||
|
\`\`\`
|
||||||
|
READEOF
|
||||||
|
|
||||||
|
# Scripts
|
||||||
|
echo "Creando scripts..."
|
||||||
|
|
||||||
|
cat > scripts/run.sh << 'RUNEOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
go run ./cmd/app
|
||||||
|
RUNEOF
|
||||||
|
|
||||||
|
cat > scripts/test.sh << 'TESTSHEOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
go test ./...
|
||||||
|
TESTSHEOF
|
||||||
|
|
||||||
|
cat > scripts/build.sh << 'BUILDSHEOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p ./bin
|
||||||
|
go build -o ./bin/app ./cmd/app
|
||||||
|
echo "Binario: ./bin/app"
|
||||||
|
BUILDSHEOF
|
||||||
|
|
||||||
|
cat > scripts/build-all.sh << 'BUILDALLEOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p ./bin
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o ./bin/app-linux-amd64 ./cmd/app
|
||||||
|
GOOS=darwin GOARCH=amd64 go build -o ./bin/app-darwin-amd64 ./cmd/app
|
||||||
|
GOOS=windows GOARCH=amd64 go build -o ./bin/app-windows-amd64.exe ./cmd/app
|
||||||
|
echo "Builds en ./bin"
|
||||||
|
BUILDALLEOF
|
||||||
|
|
||||||
|
cat > scripts/lint.sh << 'LINTEOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
unformatted="$(gofmt -l ./cmd ./internal ./pkg)"
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "Archivos sin formato:" >&2
|
||||||
|
echo "$unformatted" >&2
|
||||||
|
echo "Ejecuta: make fmt" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Formato OK"
|
||||||
|
LINTEOF
|
||||||
|
|
||||||
|
chmod +x scripts/*.sh
|
||||||
|
echo "Scripts creados"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# go mod tidy
|
||||||
|
echo "Ajustando dependencias (go mod tidy)..."
|
||||||
|
go mod tidy
|
||||||
|
echo "Dependencias ajustadas"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# git init
|
||||||
|
echo "Inicializando repositorio git..."
|
||||||
|
git init >/dev/null 2>&1
|
||||||
|
git add .
|
||||||
|
echo "Repositorio git inicializado"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Proyecto Go creado exitosamente ==="
|
||||||
|
echo ""
|
||||||
|
echo "Estructura:"
|
||||||
|
echo " cmd/app/main.go"
|
||||||
|
echo " internal/config/config.go"
|
||||||
|
echo " internal/service/message.go + message_test.go"
|
||||||
|
echo " pkg/version/version.go"
|
||||||
|
echo " scripts/ (run, test, build, build-all, lint)"
|
||||||
|
echo " Makefile, go.mod, .gitignore, README.md"
|
||||||
|
echo ""
|
||||||
|
echo "Proximos pasos:"
|
||||||
|
echo " 1. ./scripts/run.sh"
|
||||||
|
echo " 2. ./scripts/test.sh"
|
||||||
|
echo " 3. ./scripts/build-all.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
init_go_project "$@"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: bash_check_deps
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: shell
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "check_command(cmd: string, [error_code: string], [description: string]) -> void; check_commands(cmd...: string) -> void; check_directory(dir: string, [msg: string]) -> void; check_file(file: string, [msg: string]) -> void"
|
||||||
|
description: "Verifica existencia de comandos, directorios y archivos con output formateado. Complementa assert_command_exists con mensajes de error detallados y logging."
|
||||||
|
tags: [bash, check, dependency, command, exists, validation]
|
||||||
|
uses_functions: [bash_log_bash_shell]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: cmd
|
||||||
|
desc: "nombre del comando a verificar en PATH"
|
||||||
|
- name: error_code
|
||||||
|
desc: "codigo identificador del error; default COMMAND_NOT_FOUND"
|
||||||
|
- name: description
|
||||||
|
desc: "mensaje de error personalizado; default 'El comando CMD no esta disponible'"
|
||||||
|
- name: dir
|
||||||
|
desc: "ruta del directorio a verificar"
|
||||||
|
- name: file
|
||||||
|
desc: "ruta del archivo a verificar"
|
||||||
|
output: "exit code 0 si todas las verificaciones pasan; exit code 1 en caso de fallo con mensaje de error formateado"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/shell/bash_check_deps.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/lib/common.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/shell/bash_check_deps.sh
|
||||||
|
|
||||||
|
check_command "docker" "DOCKER_NOT_FOUND" "Docker no esta instalado"
|
||||||
|
check_commands "git" "curl" "jq"
|
||||||
|
check_directory "/var/data" "El directorio de datos no existe"
|
||||||
|
check_file "/etc/config.yaml" "Falta el archivo de configuracion"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
`check_command` acepta un error_code y descripcion opcionales para mensajes mas descriptivos que `assert_command_exists`. Usa `debug` internamente para loggear cada verificacion.
|
||||||
|
|
||||||
|
`check_commands` verifica multiples comandos en una sola llamada y reporta todos los faltantes antes de retornar 1.
|
||||||
|
|
||||||
|
Sourcea `bash_log.sh` automaticamente, que a su vez sourcea `bash_colors.sh`. No es necesario sourcea dependencias por separado.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# bash_check_deps
|
||||||
|
# ---------------
|
||||||
|
# Verifica existencia de comandos, directorios y archivos.
|
||||||
|
# Output formateado con colores via bash_log.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source bash_check_deps.sh
|
||||||
|
# check_command "docker" "DOCKER_NOT_FOUND" "Docker no esta instalado"
|
||||||
|
# check_commands "git" "curl" "jq"
|
||||||
|
# check_directory "/path/to/dir"
|
||||||
|
# check_file "/path/to/file"
|
||||||
|
|
||||||
|
SCRIPT_DIR_BASH_CHECK="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR_BASH_CHECK/bash_log.sh"
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
check_command() {
|
||||||
|
local cmd="$1"
|
||||||
|
local error_code="${2:-COMMAND_NOT_FOUND}"
|
||||||
|
local description="${3:-El comando '$cmd' no esta disponible}"
|
||||||
|
|
||||||
|
debug "Verificando comando: $cmd"
|
||||||
|
|
||||||
|
if ! command -v "$cmd" &> /dev/null; then
|
||||||
|
error "$description ($error_code)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
check_commands() {
|
||||||
|
local failed=0
|
||||||
|
for cmd in "$@"; do
|
||||||
|
if ! command -v "$cmd" &> /dev/null; then
|
||||||
|
error "Comando no encontrado: $cmd"
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $failed -eq 1 ]; then
|
||||||
|
error "Faltan multiples dependencias requeridas"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
check_directory() {
|
||||||
|
local dir="$1"
|
||||||
|
local error_msg="${2:-El directorio '$dir' no existe}"
|
||||||
|
|
||||||
|
debug "Verificando directorio: $dir"
|
||||||
|
|
||||||
|
if [ ! -d "$dir" ]; then
|
||||||
|
error "$error_msg"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
check_file() {
|
||||||
|
local file="$1"
|
||||||
|
local error_msg="${2:-El archivo '$file' no existe}"
|
||||||
|
|
||||||
|
debug "Verificando archivo: $file"
|
||||||
|
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
error "$error_msg"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: bash_colors
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: shell
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "bash_colors() -> void"
|
||||||
|
description: "Exporta variables ANSI de colores, caracteres box drawing y simbolos unicode para uso en scripts de terminal."
|
||||||
|
tags: [bash, colors, ansi, terminal, symbols, box]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: []
|
||||||
|
params: []
|
||||||
|
output: "exporta variables de entorno con codigos ANSI: colores (RED, GREEN, BLUE, etc.), box drawing (BOX_TL, BOX_H, etc.) y simbolos (CHECKMARK, CROSS, ARROW, etc.)"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/shell/bash_colors.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/lib/common.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/shell/bash_colors.sh
|
||||||
|
bash_colors
|
||||||
|
|
||||||
|
echo -e "${GREEN}Todo bien${NC}"
|
||||||
|
echo -e "${RED}${CROSS} Error detectado${NC}"
|
||||||
|
echo -e "${BOX_TL}${BOX_H}${BOX_TR}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Funcion pura: solo define y exporta variables de entorno, sin I/O ni efectos secundarios. Debe llamarse una vez antes de usar cualquier variable de color o simbolo.
|
||||||
|
|
||||||
|
Variables de color disponibles: PURPLE, MAGENTA, GREEN, BLUE, YELLOW, RED, CYAN, ORANGE, GRAY, DIM_GRAY, BOLD, DIM, NC.
|
||||||
|
|
||||||
|
Variables box drawing: BOX_TL, BOX_TR, BOX_BL, BOX_BR, BOX_H, BOX_V, BOX_ML, BOX_MR, BOX_SEP.
|
||||||
|
|
||||||
|
Simbolos unicode: CHECKMARK, CROSS, ARROW, BULLET, WARNING, INFO.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# bash_colors
|
||||||
|
# -----------
|
||||||
|
# Variables ANSI de color, caracteres box drawing y simbolos para scripts.
|
||||||
|
# Diseñado para ser sourced por otros scripts.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source bash_colors.sh
|
||||||
|
# bash_colors
|
||||||
|
|
||||||
|
bash_colors() {
|
||||||
|
export PURPLE='\033[35m'
|
||||||
|
export MAGENTA='\033[35m'
|
||||||
|
export GREEN='\033[0;32m'
|
||||||
|
export BLUE='\033[0;34m'
|
||||||
|
export YELLOW='\033[1;33m'
|
||||||
|
export RED='\033[0;31m'
|
||||||
|
export CYAN='\033[0;36m'
|
||||||
|
export ORANGE='\033[0;33m'
|
||||||
|
export GRAY='\033[0;90m'
|
||||||
|
export DIM_GRAY='\033[2;37m'
|
||||||
|
export BOLD='\033[1m'
|
||||||
|
export DIM='\033[2m'
|
||||||
|
export NC='\033[0m'
|
||||||
|
|
||||||
|
export BOX_TL="╔"
|
||||||
|
export BOX_TR="╗"
|
||||||
|
export BOX_BL="╚"
|
||||||
|
export BOX_BR="╝"
|
||||||
|
export BOX_H="═"
|
||||||
|
export BOX_V="║"
|
||||||
|
export BOX_ML="╠"
|
||||||
|
export BOX_MR="╣"
|
||||||
|
export BOX_SEP="─"
|
||||||
|
|
||||||
|
export CHECKMARK="✓"
|
||||||
|
export CROSS="✗"
|
||||||
|
export ARROW="→"
|
||||||
|
export BULLET="•"
|
||||||
|
export WARNING="⚠"
|
||||||
|
export INFO="ℹ"
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: bash_confirm
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: shell
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "bash_confirm(prompt: string, [default: string]) -> exit_code"
|
||||||
|
description: "Dialogo interactivo de confirmacion y/n con valor por defecto configurable. Soporta respuestas yes/y/si."
|
||||||
|
tags: [bash, confirm, prompt, interactive, dialog]
|
||||||
|
uses_functions: [bash_colors_bash_shell]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: prompt
|
||||||
|
desc: "pregunta a mostrar al usuario; default '¿Continuar?'"
|
||||||
|
- name: default
|
||||||
|
desc: "valor por defecto cuando el usuario presiona Enter sin escribir nada; 'y' o 'n'; default 'n'"
|
||||||
|
output: "exit code 0 si el usuario confirma (y/yes/si), exit code 1 si niega o acepta el default negativo"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/shell/bash_confirm.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/lib/common.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/shell/bash_confirm.sh
|
||||||
|
|
||||||
|
# Con default no (muestra [y/N])
|
||||||
|
if bash_confirm "¿Deseas continuar?"; then
|
||||||
|
echo "Continuando..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Con default yes (muestra [Y/n])
|
||||||
|
bash_confirm "¿Eliminar archivos temporales?" "y" && rm -rf /tmp/cache
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
El prompt muestra el default en mayuscula: `[Y/n]` cuando default=y, `[y/N]` cuando default=n. Si el usuario presiona Enter sin escribir, se usa el valor por defecto.
|
||||||
|
|
||||||
|
Acepta variantes: y, Y, yes, YES, si, SI como afirmativo. Cualquier otra respuesta se trata como negativo.
|
||||||
|
|
||||||
|
Usa colores de `bash_colors` para el prompt en amarillo. Requiere terminal interactiva (lee de stdin con `read -r`).
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# bash_confirm
|
||||||
|
# ------------
|
||||||
|
# Dialogo de confirmacion y/n con valor por defecto.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source bash_confirm.sh
|
||||||
|
# bash_confirm "Continuar?" && echo "Si" || echo "No"
|
||||||
|
# bash_confirm "Eliminar?" "y" # default yes
|
||||||
|
|
||||||
|
SCRIPT_DIR_BASH_CONFIRM="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR_BASH_CONFIRM/bash_colors.sh"
|
||||||
|
bash_colors
|
||||||
|
|
||||||
|
bash_confirm() {
|
||||||
|
local prompt="${1:-¿Continuar?}"
|
||||||
|
local default="${2:-n}"
|
||||||
|
|
||||||
|
if [ "$default" = "y" ]; then
|
||||||
|
prompt="$prompt [Y/n]"
|
||||||
|
else
|
||||||
|
prompt="$prompt [y/N]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -ne "${YELLOW}$prompt ${NC}"
|
||||||
|
read -r response
|
||||||
|
|
||||||
|
response=${response:-$default}
|
||||||
|
case "$response" in
|
||||||
|
[yY][eE][sS]|[yY]|[sS][iI])
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
name: bash_handle_error
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: shell
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "handle_error(error_code: string, error_description: string, [solution: string]) -> exit_code"
|
||||||
|
description: "Muestra un box de error formateado con contexto del fallo: script, linea, funcion, directorio y usuario. Registra en log."
|
||||||
|
tags: [bash, error, handler, box, formatted, context]
|
||||||
|
uses_functions: [bash_colors_bash_shell, bash_log_bash_shell]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: error_code
|
||||||
|
desc: "codigo identificador del error, ej: BUILD_FAILED, DB_CONNECTION_ERROR"
|
||||||
|
- name: error_description
|
||||||
|
desc: "descripcion legible del error para mostrar al usuario"
|
||||||
|
- name: solution
|
||||||
|
desc: "solucion sugerida; opcional; si se provee se muestra en seccion separada"
|
||||||
|
output: "box de error formateado en stdout con contexto (script, linea, funcion, directorio, usuario) + registro en log; retorna exit code 1"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/shell/bash_handle_error.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/lib/common.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/shell/bash_handle_error.sh
|
||||||
|
|
||||||
|
build_project() {
|
||||||
|
go build ./... || handle_error "BUILD_FAILED" \
|
||||||
|
"La compilacion del proyecto fallo" \
|
||||||
|
"Ejecuta 'go mod tidy' y verifica que todas las dependencias esten instaladas"
|
||||||
|
}
|
||||||
|
|
||||||
|
connect_db() {
|
||||||
|
psql "$DB_URL" -c '\q' 2>/dev/null || handle_error "DB_CONNECTION_ERROR" \
|
||||||
|
"No se pudo conectar a la base de datos"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
El box usa caracteres unicode de `bash_colors` (BOX_TL, BOX_H, etc.) para el borde en rojo. La informacion de contexto se extrae de `BASH_SOURCE`, `BASH_LINENO` y `FUNCNAME` con offset 2 para apuntar al caller del caller.
|
||||||
|
|
||||||
|
La funcion siempre retorna 1, permitiendo usarla como `cmd || handle_error ...` en pipelines de error.
|
||||||
|
|
||||||
|
Usa `bash_colors` y `bash_log` como dependencias. Sourcea ambas automaticamente al cargarse.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# bash_handle_error
|
||||||
|
# -----------------
|
||||||
|
# Muestra un box de error formateado con contexto del fallo.
|
||||||
|
# Incluye script, linea, funcion y directorio.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source bash_handle_error.sh
|
||||||
|
# handle_error "BUILD_FAILED" "La compilacion fallo" "Verifica las dependencias"
|
||||||
|
|
||||||
|
SCRIPT_DIR_BASH_HANDLE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR_BASH_HANDLE/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR_BASH_HANDLE/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
handle_error() {
|
||||||
|
local error_code="$1"
|
||||||
|
local error_description="$2"
|
||||||
|
local solution="$3"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${RED}║ ${CROSS} ERROR DETECTADO ║${NC}"
|
||||||
|
echo -e "${RED}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}${BOLD}Error:${NC} ${error_description}"
|
||||||
|
echo -e "${GRAY}Codigo: ${error_code}${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ -n "$solution" ]; then
|
||||||
|
echo -e "${YELLOW}${INFO} Solucion sugerida:${NC}"
|
||||||
|
echo -e " ${solution}"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GRAY}${INFO} Informacion adicional:${NC}"
|
||||||
|
echo -e " ${GRAY}${BULLET} Script: ${BASH_SOURCE[2]:-desconocido}${NC}"
|
||||||
|
echo -e " ${GRAY}${BULLET} Linea: ${BASH_LINENO[1]:-desconocido}${NC}"
|
||||||
|
echo -e " ${GRAY}${BULLET} Funcion: ${FUNCNAME[2]:-main}${NC}"
|
||||||
|
echo -e " ${GRAY}${BULLET} Directorio: $(pwd)${NC}"
|
||||||
|
echo -e " ${GRAY}${BULLET} Usuario: $(whoami)${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
log "ERROR" "Code: $error_code | Description: $error_description | Script: ${BASH_SOURCE[2]} | Line: ${BASH_LINENO[1]}"
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: bash_log
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: shell
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "bash_log_init() -> void; success(msg: string) -> void; info(msg: string) -> void; warning(msg: string) -> void; error(msg: string) -> void; debug(msg: string) -> void; progress(msg: string) -> void"
|
||||||
|
description: "Funciones de logging con colores para scripts bash. Incluye niveles success/info/warning/error/debug/progress con escritura a archivo de log."
|
||||||
|
tags: [bash, log, logging, colors, terminal]
|
||||||
|
uses_functions: [bash_colors_bash_shell]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: msg
|
||||||
|
desc: "mensaje a mostrar y registrar en el archivo de log"
|
||||||
|
output: "mensaje formateado con colores en stdout/stderr y registro con timestamp en archivo de log (ERROR_LOG_FILE)"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/shell/bash_log.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/lib/common.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/shell/bash_log.sh
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
success "Servicio iniciado correctamente"
|
||||||
|
info "Procesando 42 registros..."
|
||||||
|
warning "El puerto 8080 ya esta en uso"
|
||||||
|
error "No se pudo conectar a la base de datos"
|
||||||
|
debug "Valor de variable: $VAR"
|
||||||
|
progress "Descargando imagen Docker..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Llama a `bash_log_init` una vez al inicio para configurar ERROR_LOG_FILE y DEBUG_MODE. Por defecto el log va a `/tmp/script-errors-YYYYMMDD.log`.
|
||||||
|
|
||||||
|
La variable de entorno `DEBUG_MODE=1` activa los mensajes de debug a stderr y muestra timestamps en todos los logs.
|
||||||
|
|
||||||
|
`error` escribe a stderr; el resto a stdout. Todos los niveles escriben al archivo de log independientemente de DEBUG_MODE.
|
||||||
|
|
||||||
|
Fuente: sourcea `bash_colors.sh` automaticamente al cargarse.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# bash_log
|
||||||
|
# --------
|
||||||
|
# Funciones de logging con colores para scripts.
|
||||||
|
# Incluye: log, success, info, warning, error, debug, progress.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source bash_log.sh
|
||||||
|
# bash_log_init
|
||||||
|
# success "Operacion completada"
|
||||||
|
# info "Procesando..."
|
||||||
|
# error "Algo fallo"
|
||||||
|
|
||||||
|
SCRIPT_DIR_BASH_LOG="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR_BASH_LOG/bash_colors.sh"
|
||||||
|
bash_colors
|
||||||
|
|
||||||
|
bash_log_init() {
|
||||||
|
export ERROR_LOG_FILE="${ERROR_LOG_FILE:-/tmp/script-errors-$(date +%Y%m%d).log}"
|
||||||
|
export DEBUG_MODE="${DEBUG_MODE:-0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
local level="$1"
|
||||||
|
shift
|
||||||
|
local message="$@"
|
||||||
|
local timestamp
|
||||||
|
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
echo "[$timestamp] [$level] $message" >> "${ERROR_LOG_FILE:-/tmp/script-errors.log}"
|
||||||
|
|
||||||
|
if [ "${DEBUG_MODE:-0}" = "1" ]; then
|
||||||
|
echo -e "${GRAY}[$timestamp] [$level] $message${NC}" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}${CHECKMARK} $*${NC}"
|
||||||
|
log "SUCCESS" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
info() {
|
||||||
|
echo -e "${BLUE}${INFO} $*${NC}"
|
||||||
|
log "INFO" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
echo -e "${YELLOW}${WARNING} $*${NC}"
|
||||||
|
log "WARNING" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}${CROSS} Error: $*${NC}" >&2
|
||||||
|
log "ERROR" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
debug() {
|
||||||
|
if [ "${DEBUG_MODE:-0}" = "1" ]; then
|
||||||
|
echo -e "${GRAY}[DEBUG] $*${NC}" >&2
|
||||||
|
fi
|
||||||
|
log "DEBUG" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
progress() {
|
||||||
|
echo -e "${CYAN}${ARROW} $*${NC}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: bash_safe_run
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: shell
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "safe_run(cmd: string, [error_code: string], [error_desc: string]) -> void; setup_error_trap() -> void; error_trap_handler(exit_code: int, line_number: int) -> void"
|
||||||
|
description: "Ejecuta comandos con manejo de errores integrado. Incluye trap handler que captura fallos con numero de linea y codigo de salida."
|
||||||
|
tags: [bash, safe, run, error, trap, handler]
|
||||||
|
uses_functions: [bash_log_bash_shell]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: cmd
|
||||||
|
desc: "comando a ejecutar via eval"
|
||||||
|
- name: error_code
|
||||||
|
desc: "codigo identificador del error en caso de fallo; default COMMAND_FAILED"
|
||||||
|
- name: error_desc
|
||||||
|
desc: "descripcion del error a mostrar; default 'El comando fallo: CMD'"
|
||||||
|
output: "exit code 0 si el comando tuvo exito; exit code 1 con mensaje de error formateado si fallo"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/shell/bash_safe_run.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/lib/common.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/shell/bash_safe_run.sh
|
||||||
|
bash_log_init
|
||||||
|
setup_error_trap
|
||||||
|
|
||||||
|
safe_run "go build ./..." "BUILD_FAILED" "La compilacion fallo"
|
||||||
|
safe_run "docker compose up -d" "DOCKER_FAILED" "No se pudo iniciar Docker Compose"
|
||||||
|
safe_run "npm install" "NPM_FAILED"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
`safe_run` usa `eval` internamente para ejecutar el comando, lo que permite pasar comandos con pipes y redirecciones como string. Usar con precaucion en entornos con input no confiable.
|
||||||
|
|
||||||
|
`setup_error_trap` instala un trap `ERR` que llama a `error_trap_handler` automaticamente en cualquier comando fallido del script, mostrando numero de linea y codigo de salida.
|
||||||
|
|
||||||
|
`error_trap_handler` no llama a `exit` — el caller decide si continuar o abortar. Muestra la ruta al log para debugging.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user