Rebelión en la granja — investigación y laboratorio de dispositivos

Investigación sobre granjas de dispositivos y automatización completa para
levantar un laboratorio Android sobre un chasis de blades Debian.

Conclusión que ordena el repositorio: el cuello de botella nunca fue el
cómputo sino la identidad. Desde que la atestación de dispositivo pasó de
estadística a criptográfica, un servidor no puede hacerse pasar por un
teléfono — le falta una clave grabada en fábrica que ningún software produce.

Contenido:

- 01_investigacion/ — capa de identidad (verificación telefónica, eSIM,
  reputación de IP, atestación por hardware, correlación entre cuentas),
  mercado y players, stack técnico, marco legal en la UE
- 02_device_lab/ansible/ — Ansible para 16 blades: preflight de solo lectura
  que identifica el hardware por DMI, despliegue de redroid en Docker y
  control de flota por adb. Solo ansible.builtin, sin colecciones externas
- docs/ — requisitos, costes y repaso de seguridad con 11 hallazgos
- 03_charla/ — guion para HackMadrid / Hackmeeting València

Estado: la automatización está escrita pero SIN ejecutar contra hardware.
YAML validado, plantillas renderizadas en ambos modos de red y fleet.sh
comprobado con bash -n. El modelo de blade sigue sin confirmar.
This commit is contained in:
s1to 2026-08-08 19:42:14 +02:00
commit 74bfbe9437
42 changed files with 2989 additions and 0 deletions

112
02_device_lab/README.md Normal file
View file

@ -0,0 +1,112 @@
# Device lab con redroid
La vía 1: laboratorio de dispositivos Android propio sobre el rack. Legal, reutilizable, y el
primer uso real es probar `oasis_mobile` a escala — incluido el escenario de dos APKs que se tienen
que ver entre sí (el bloqueo de capa 1).
**No hay móviles físicos.** Cada "dispositivo" es un contenedor redroid: Android real sobre el
kernel de una máquina Debian.
## Dos caminos
| | Para qué |
|---|---|
| **[ansible/](ansible/)** | **La granja de verdad**: los 16 blades del M1000e, provisionados y controlados desde un sitio. Es lo que hay que usar. |
| Este documento | Un solo nodo, a mano. Sirve para entender las piezas y para trastear en el sobremesa. |
El hardware está descrito en [hardware_m1000e.md](hardware_m1000e.md).
## Host
Comprobado el 2026-08-08 sobre esta máquina:
| | |
|---|---|
| CPU | 16 cores |
| RAM | 62 GB |
| SO | Debian 12 bookworm, kernel 6.1.0-51-amd64 |
| Docker | **no instalado** |
| binder | `binder_linux.ko` presente, pero `CONFIG_ANDROID_BINDERFS` **no está activado** |
Con 2 GB por instancia y dejando margen al host, **1220 instancias redroid** simultáneas es el
rango realista aquí. No son las 3060 de un nodo de 32c/128 GB.
## Prerrequisitos
### 1. binder — el punto donde esto se atasca
El kernel de Debian trae `binder_linux` como módulo pero **sin binderfs**, así que redroid no puede
crear los devices por sí mismo. Hay que cargarlos a mano con los tres nombres que Android espera:
```bash
sudo modprobe binder_linux devices=binder,hwbinder,vndbinder
ls -l /dev/binder /dev/hwbinder /dev/vndbinder # deben existir
```
Para que persista entre reinicios:
```bash
echo binder_linux | sudo tee /etc/modules-load.d/redroid.conf
echo "options binder_linux devices=binder,hwbinder,vndbinder" | sudo tee /etc/modprobe.d/redroid.conf
```
Si `modprobe` falla, la alternativa es el módulo DKMS de Waydroid
(`binder_linux-dkms`), que sí aporta binderfs.
### 2. Docker
```bash
sudo apt install docker.io docker-compose-v2
sudo usermod -aG docker $USER # requiere volver a entrar en sesión
```
### 3. adb
```bash
sudo apt install adb
```
## Arrancar
```bash
docker compose up -d
./connect.sh # conecta adb a las instancias levantadas
adb devices # deben salir 127.0.0.1:5555 .. 5558
```
Instalar un APK en todas:
```bash
./install-apk.sh ~/COFRE/CODERS/OASIS_APK/<algo>.apk
```
Ver una instancia:
```bash
sudo apt install scrcpy
scrcpy -s 127.0.0.1:5555
```
## Escalar
`docker-compose.yml` trae 4 instancias como punto de partida. Para más, replicar el bloque
cambiando nombre y puerto. A partir de ~8 conviene generar el compose con un script en vez de
mantenerlo a mano, o pasar a GADS.
## Para la flota entera
Todo lo de arriba, pero en 16 blades y sin hacerlo a mano: **[ansible/](ansible/)**.
Ahí está el preflight que identifica el modelo de blade solo, el despliegue completo, y `fleet`
para hablar con todos los Android desde un único sitio.
## Siguiente capa: GADS
[GADS](https://github.com/shamanec/GADS) da UI web, gestión de dispositivos y Appium integrado
encima de esto. Merece la pena cuando el número de instancias pase de una decena o cuando haga
falta lanzar tests automatizados en vez de trastear a mano.
## Limitación que hay que tener presente
Estas instancias **son detectables como emulador**. Sirven para probar tu propia app, no para
interactuar con plataformas que hacen atestación de dispositivo (ver
[../01_investigacion/stack_tecnico.md](../01_investigacion/stack_tecnico.md)).

View file

@ -0,0 +1,302 @@
# Ansible — provisionar la granja Android sobre los blades Debian
Automatiza el montaje completo: de blades Debian pelados a una flota de Android
virtuales controlables por `adb` desde un único sitio.
**No hay móviles físicos en esta granja.** Cada "dispositivo" es un contenedor
[redroid](https://github.com/remote-android/redroid-doc) — Android real corriendo sobre el kernel
del blade. Es la única forma sensata de hacer esto con el hardware que hay.
## La arquitectura
```
Dell PowerEdge M1000e — 16 blades
├── blade-01 ................ COORDINADOR
│ └── adb + fleet.sh único punto de control de toda la flota
└── blade-02 .. blade-16 .... NODOS DE CARGA (15)
└── Debian 12 pelado
└── Docker
├── redroid-blade-NN-01 Android 13 ~2 GB
├── redroid-blade-NN-02 Android 13 ~2 GB
└── redroid-blade-NN-03 Android 13 ~2 GB
Con 8 GB por blade → 3 instancias → ~45 Android en total
```
Debian va **directo sobre el blade**. Sin Proxmox, sin VMs, sin LXC anidado: con 8 GB por blade,
cada capa de hipervisor se come una instancia Android entera.
---
## Requisitos
**Nodo de control** (tu sobremesa):
```bash
sudo apt install ansible
```
**Blades**:
- Debian 12 bookworm instalado
- Acceso SSH desde el nodo de control, con sudo o como root
- Salida a internet, o un mirror local de `download.docker.com` y `deb.debian.org`
- Kernel con `binder_linux.ko` (el estándar de Debian lo trae)
**Ejecuta siempre desde este directorio** (`ansible/`) — `ansible.cfg` y las rutas de roles son
relativas a él.
---
## Puesta en marcha
### 1. Inventario
```bash
cp inventory/hosts.yml.example inventory/hosts.yml
$EDITOR inventory/hosts.yml # pon las IPs reales de los blades
```
### 2. Preflight — esto va primero, siempre
```bash
ansible-playbook playbooks/preflight.yml
```
**Es de solo lectura, no toca nada.** Contesta lo que ahora mismo no sabemos:
- **Qué blade hay dentro del chasis** (lo lee de DMI — no hace falta ir a mirar el frontal)
- Si esas CPUs tienen las instrucciones que Android x86_64 necesita
- Si el kernel trae binder
- Cuántas instancias caben en cada blade según su RAM real
Deja un informe en `reports/preflight-<fecha>.md` con la tabla de toda la flota y el total de
capacidad. Si algún blade sale **NO APTO**, lo dice ahí y no cuando estés a medio despliegue.
### 3. Montar la granja
```bash
ansible-playbook site.yml
```
Idempotente: relanzable sin romper nada.
### 4. Comprobar
```bash
ansible-playbook playbooks/status.yml
```
### 5. Instalar un APK en toda la flota
```bash
ansible-playbook playbooks/apk.yml -e apk_local_path=~/COFRE/CODERS/OASIS_APK/oasis.apk
```
---
## Control diario desde el coordinador
Entra por SSH al coordinador y usa `fleet`:
```bash
fleet connect # conecta adb a toda la flota
fleet list # qué hay conectado
fleet install app.apk # instala en todos
fleet shell 'getprop ro.build.version.release'
fleet ip # IP de cada Android — útil para el caso de dos APKs
fleet reboot
```
---
## Los dos modos de red
Se controla con `redroid_network_mode` en `group_vars/all.yml`.
### `portmap` (por defecto)
Cada Android detrás de NAT de Docker; adb en `<ip_blade>:<puerto>`, empezando en 5555.
Funciona en cualquier sitio sin saber nada de la LAN. Es el modo con el que empezar.
### `macvlan` — el que quieres para `oasis_mobile`
Cada Android coge una **IP real de la LAN** a través del switch FlexIO del chasis. Los dos APKs
que se tienen que ver entre sí se ven como **pares de red de verdad**, no a través de traducciones.
Es lo más cerca de "dos móviles en la misma wifi" sin tener dos móviles.
```yaml
redroid_network_mode: macvlan
redroid_macvlan_parent: eno1
redroid_macvlan_subnet: 10.0.0.0/24
redroid_macvlan_gateway: 10.0.0.1
redroid_macvlan_ip_prefix: "10.0.0."
```
Y en el inventario, un `redroid_macvlan_ip_offset` por blade (el ejemplo ya trae 20, 30, 40…).
> **El gotcha de macvlan.** Un anfitrión **no puede hablar con sus propios contenedores macvlan**.
> Es una limitación del driver, no un fallo de configuración. Por eso el coordinador es un blade
> **distinto**: desde fuera sí se llega. Si algún día pones adb en un nodo de carga y no ve sus
> propios Android, es esto.
>
> Reserva también un rango de IPs para los Android en el DHCP de la LAN, o acabarás con colisiones.
---
## El binder, que es donde esto se atasca
Debian 12 compila binder como módulo pero **sin binderfs**, así que redroid no puede crear sus
devices solo. El rol `binder` lo resuelve: carga `binder_linux` con los tres nombres que Android
espera (`binder`, `hwbinder`, `vndbinder`) y lo deja persistente para los siguientes arranques.
Los contenedores de un blade **comparten ese binder del kernel anfitrión**. Si no está, no arranca
ni una instancia.
**Si el playbook falla con "faltan devices binder"**: el módulo estaba ya cargado y en uso, así que
no se pudo recargar con los parámetros nuevos. La configuración persistente ya quedó escrita —
reinicia ese blade y relanza. Es un fallo de una sola vez.
---
## Ajustes que vas a querer tocar
Todo en `group_vars/all.yml`.
| Variable | Por defecto | Para qué |
|---|---|---|
| `redroid_image` | `redroid/redroid:13.0.0-latest` | Versión de Android. Hay 11, 12, 13 y 14 |
| `redroid_instances` | `0` (auto) | Instancias por blade. 0 = calculado desde la RAM real |
| `redroid_mem_mb` | `2048` | RAM por Android |
| `redroid_host_reserve_mb` | `1500` | RAM que se le deja a Debian + Docker |
| `redroid_gpu_mode` | `guest` | Sin GPU en los blades → render por software |
| `redroid_network_mode` | `portmap` | `portmap` o `macvlan` |
Cambiar la versión de Android en toda la flota, sin editar nada:
```bash
ansible-playbook playbooks/devices.yml -e redroid_image=redroid/redroid:14.0.0-latest
```
### La palanca real: la RAM
El cuello de botella son **los 8 GB por blade, no la CPU**. Si los blades resultan ser M620
(DDR3 ECC), ampliar a 32 GB cuesta poco en segunda mano:
```bash
ansible-playbook playbooks/devices.yml -e redroid_instances=14
```
De ~45 Android a **~210**, por menos de lo que cuesta un mes de luz del chasis.
---
## Playbooks
| Playbook | Qué hace | Toca algo |
|---|---|---|
| `playbooks/preflight.yml` | Reconocimiento: modelo, CPU, RAM, binder, capacidad | **No** |
| `site.yml` | La granja entera de cero | Sí |
| `playbooks/provision.yml` | Solo el hierro: base + binder + Docker | Sí |
| `playbooks/devices.yml` | Solo los contenedores Android | Sí |
| `playbooks/coordinator.yml` | Regenera el listado de flota y `fleet` | Sí |
| `playbooks/apk.yml` | Instala un APK en toda la flota | Sí |
| `playbooks/status.yml` | Estado real de la granja | **No** |
| `playbooks/destroy.yml` | Para y borra los contenedores | **Sí, destructivo** |
`destroy.yml` exige `-e confirmar=si`, y conserva los `/data` salvo que añadas `-e borrar_datos=si`.
---
## Probarlo antes de tocar el chasis
`inventory/hosts.yml.example` trae un bloque comentado de **piloto de un nodo**: el sobremesa hace
de coordinador y de nodo de carga a la vez. Descoméntalo, comenta el bloque `granja`, y tienes el
flujo entero contra una sola máquina. Con 62 GB de RAM salen unas 8 instancias (tope
`redroid_max_instances`).
Es la forma sensata de empezar: valida todo el pipeline sin encender 2 kW.
---
## Estructura
```
ansible/
├── ansible.cfg
├── site.yml playbook maestro
├── inventory/hosts.yml.example 16 blades + piloto de un nodo
├── group_vars/all.yml los mandos del operador
├── playbooks/
│ ├── preflight.yml reconocimiento (solo lectura)
│ ├── provision.yml base + binder + docker
│ ├── devices.yml contenedores Android
│ ├── coordinator.yml listado de flota + fleet
│ ├── apk.yml instalar APK en la flota
│ ├── status.yml estado (solo lectura)
│ ├── destroy.yml desmontar (destructivo)
│ └── templates/preflight-report.md.j2
├── roles/
│ ├── preflight/ DMI, flags de CPU, binder, capacidad
│ ├── base/ paquetes, hora
│ ├── binder/ los tres devices, persistentes
│ ├── docker/ Docker CE + compose v2
│ ├── redroid/ genera el compose y levanta Android
│ └── coordinator/ adb + devices.txt + fleet.sh
└── reports/ informes de preflight (se genera solo)
```
---
## Estado de verificación
Escrito el 2026-08-08. **No se ha ejecutado contra hardware real** — el chasis todavía no está
accesible y el sobremesa no tiene Docker instalado.
Lo que sí está comprobado:
- Los 18 ficheros YAML parsean correctamente
- La plantilla de `docker-compose` renderiza YAML válido **en los dos modos de red**, con los
puertos y las IPs bien asignados
- `fleet.sh` pasa `bash -n` una vez renderizado, sin Jinja pendiente
Lo que **no** está comprobado y solo dirá la realidad:
- Que las imágenes x86_64 de Android arranquen en la generación de CPU de estos blades
(el riesgo real si resultan ser M610 — preflight lo detecta antes de gastar tiempo)
- El comportamiento de macvlan sobre los switches FlexIO concretos del chasis
- Que 3 instancias por blade sean cómodas con 8 GB en carga real, no solo en el papel
---
## Siguiente capa: GADS
[GADS](https://github.com/shamanec/GADS) da UI web y Appium integrado encima de esto. No está
automatizado aquí a propósito: montar un despliegue de Node que no puedo probar sería añadir
complejidad sin verificar. Merece la pena cuando la flota pase de una decena de dispositivos o
cuando haga falta lanzar tests automatizados en vez de trastear con `fleet shell`.
---
## Seguridad — lee esto antes de encender
El repaso completo está en **[../../docs/seguridad.md](../../docs/seguridad.md)** (11 hallazgos).
Lo que no se puede saltar:
**adb no tiene autenticación**, las imágenes de redroid permiten `adb root`, y los contenedores son
`privileged` porque redroid lo exige. Encadenado: **quien alcance el puerto 5555 de un blade acaba
siendo root en ese blade**, sin exploit ninguno. Con 45 dispositivos son 45 puertas.
Mitigación parcial ya en el código — ata adb solo a la interfaz interna del chasis:
```yaml
blade-02: { ansible_host: 10.0.0.12, redroid_adb_bind_ip: 10.0.0.12 }
```
Lo que lo cierra de verdad es una **VLAN aislada** para la granja. En modo `macvlan`,
`redroid_adb_bind_ip` no aplica (adb escucha en la IP de LAN del propio Android) y la VLAN es la
única defensa.
Y antes de nada: **cambiar las credenciales de fábrica del CMC y de los 16 iDRAC** (`root`/`calvin`).

View file

@ -0,0 +1,18 @@
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
interpreter_python = auto_silent
# 20 > 16 blades: toda la flota en paralelo
forks = 20
timeout = 30
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=120s
[privilege_escalation]
become = True
become_method = sudo

View file

@ -0,0 +1,80 @@
# ---------------------------------------------------------------------------
# Mandos del operador. Lo que se toca normalmente está aquí.
# Los defaults internos de cada rol están en roles/<rol>/defaults/main.yml
# ---------------------------------------------------------------------------
# --- Android ---------------------------------------------------------------
# Imagen de redroid. Tags: 11.0.0-latest, 12.0.0-latest, 13.0.0-latest, 14.0.0-latest
# Android 13 es el punto dulce: memfd en vez de ashmem (un módulo de kernel menos)
# y todavía ligero.
redroid_image: "redroid/redroid:13.0.0-latest"
# RAM por instancia. 2 GB es el mínimo cómodo para Android 13.
redroid_mem_mb: 2048
# RAM que se le deja al anfitrión (Debian + Docker + margen).
redroid_host_reserve_mb: 1500
# Instancias por nodo. 0 = automático a partir de la RAM real del blade:
# (RAM_total - reserva) / redroid_mem_mb
# Con 8 GB por blade eso da 3. Súbelo a mano solo si sabes lo que haces.
redroid_instances: 0
# Tope duro por si un blade tiene mucha RAM y te comes los cores.
redroid_max_instances: 8
# Pantalla de las instancias.
redroid_width: 720
redroid_height: 1280
redroid_dpi: 320
# Sin GPU en los blades -> render por software. Con GPU pasable: "host".
redroid_gpu_mode: guest
# --- Red -------------------------------------------------------------------
# portmap -> cada Android detrás de NAT, adb en <ip_blade>:<puerto>
# Funciona en cualquier sitio. Es el default sensato.
#
# macvlan -> cada Android coge una IP REAL de la LAN a través del switch
# FlexIO del chasis. Los APKs se ven como pares de red de verdad.
# Es lo que quieres para el caso de los dos APKs de oasis_mobile.
# Ojo al gotcha documentado en el README.
redroid_network_mode: portmap
# --- solo si redroid_network_mode == macvlan ---
redroid_macvlan_network: redroid-lan
redroid_macvlan_parent: "" # p.ej. eno1 (interfaz física del blade)
redroid_macvlan_subnet: "" # p.ej. 10.0.0.0/24
redroid_macvlan_gateway: "" # p.ej. 10.0.0.1
redroid_macvlan_ip_prefix: "" # p.ej. "10.0.0." (el offset va por host en el inventario)
# --- solo si redroid_network_mode == portmap ---
# Primer puerto adb en el blade. Las instancias ocupan base, base+1, base+2...
redroid_adb_base_port: 5555
# A qué interfaz se ata adb. 0.0.0.0 = TODAS, y adb no tiene autenticación:
# quien llegue al puerto es root en el Android, y desde un contenedor privilegiado
# eso es una vía hacia el anfitrión. Ver docs/seguridad.md (hallazgo S1).
#
# Pon aquí la IP interna del blade en la red del chasis para que adb no escuche
# en nada más. Por host, en el inventario:
# blade-02: { ansible_host: 10.0.0.12, redroid_adb_bind_ip: 10.0.0.12 }
redroid_adb_bind_ip: "0.0.0.0"
# --- Rutas -----------------------------------------------------------------
redroid_base_dir: /opt/redroid
# --- Preflight -------------------------------------------------------------
# Flags de CPU que Android x86_64 necesita. Si un blade no las tiene,
# preflight lo marca como NO APTO en vez de dejarte descubrirlo a medio despliegue.
preflight_required_cpu_flags:
- sse4_2
- ssse3
- popcnt

View file

@ -0,0 +1,70 @@
# Inventario de la granja. Copia esto a hosts.yml y ajusta IPs.
#
# cp inventory/hosts.yml.example inventory/hosts.yml
#
# Reparto: 1 blade coordinador (adb + control) + 15 blades de carga.
# El coordinador NO corre instancias Android, solo dirige.
all:
children:
granja:
vars:
ansible_user: root
# Si entras con usuario normal + sudo, cambia a:
# ansible_user: sito
# ansible_become_password: "{{ vault_sudo_password }}"
children:
# ---------------------------------------------------------------
# Coordinador: adb, scripts de flota, punto único de control
# ---------------------------------------------------------------
coordinator:
hosts:
blade-01:
ansible_host: 10.0.0.11
# ---------------------------------------------------------------
# Nodos de carga: aquí corren los contenedores redroid
# ---------------------------------------------------------------
redroid_nodes:
hosts:
blade-02: { ansible_host: 10.0.0.12, redroid_macvlan_ip_offset: 20 }
blade-03: { ansible_host: 10.0.0.13, redroid_macvlan_ip_offset: 30 }
blade-04: { ansible_host: 10.0.0.14, redroid_macvlan_ip_offset: 40 }
blade-05: { ansible_host: 10.0.0.15, redroid_macvlan_ip_offset: 50 }
blade-06: { ansible_host: 10.0.0.16, redroid_macvlan_ip_offset: 60 }
blade-07: { ansible_host: 10.0.0.17, redroid_macvlan_ip_offset: 70 }
blade-08: { ansible_host: 10.0.0.18, redroid_macvlan_ip_offset: 80 }
blade-09: { ansible_host: 10.0.0.19, redroid_macvlan_ip_offset: 90 }
blade-10: { ansible_host: 10.0.0.20, redroid_macvlan_ip_offset: 100 }
blade-11: { ansible_host: 10.0.0.21, redroid_macvlan_ip_offset: 110 }
blade-12: { ansible_host: 10.0.0.22, redroid_macvlan_ip_offset: 120 }
blade-13: { ansible_host: 10.0.0.23, redroid_macvlan_ip_offset: 130 }
blade-14: { ansible_host: 10.0.0.24, redroid_macvlan_ip_offset: 140 }
blade-15: { ansible_host: 10.0.0.25, redroid_macvlan_ip_offset: 150 }
blade-16: { ansible_host: 10.0.0.26, redroid_macvlan_ip_offset: 160 }
# -------------------------------------------------------------------------
# PILOTO DE UN NODO
# -------------------------------------------------------------------------
# Para probar todo esto en el sobremesa antes de tocar el chasis, comenta
# el bloque `granja` de arriba y descomenta este. El sobremesa hace de
# coordinador y de nodo de carga a la vez.
#
# ansible-playbook playbooks/preflight.yml
# ansible-playbook site.yml
#
# all:
# children:
# granja:
# children:
# coordinator:
# hosts:
# localhost:
# ansible_connection: local
# redroid_nodes:
# hosts:
# localhost:
# ansible_connection: local
# redroid_macvlan_ip_offset: 20

View file

@ -0,0 +1,60 @@
---
# Instala un APK en toda la flota, desde el coordinador.
#
# ansible-playbook playbooks/apk.yml -e apk_local_path=~/COFRE/CODERS/OASIS_APK/oasis.apk
#
# Se ejecuta desde el coordinador a propósito: en modo macvlan un blade NO puede
# hablar con sus propios contenedores, así que instalar desde cada nodo fallaría.
# Desde el coordinador funcionan los dos modos de red.
- name: Instalar APK en la flota
hosts: coordinator
gather_facts: false
vars:
apk_local_path: ""
tasks:
- name: Exigir la ruta del APK
ansible.builtin.assert:
that: apk_local_path | length > 0
fail_msg: "Falta -e apk_local_path=/ruta/al/app.apk"
- name: Comprobar que el APK existe en el nodo de control
ansible.builtin.stat:
path: "{{ apk_local_path }}"
delegate_to: localhost
become: false
register: apk_local
- name: Abortar si no está
ansible.builtin.assert:
that: apk_local.stat.exists
fail_msg: "No existe: {{ apk_local_path }}"
- name: Copiar el APK al coordinador
ansible.builtin.copy:
src: "{{ apk_local_path }}"
dest: "{{ coordinator_dir }}/apks/{{ apk_local_path | basename }}"
owner: root
group: root
mode: "0644"
- name: Conectar adb a la flota
ansible.builtin.command:
cmd: "{{ coordinator_dir }}/fleet.sh connect"
register: apk_connect
changed_when: false
- name: Resultado de la conexión
ansible.builtin.debug:
var: apk_connect.stdout_lines
- name: Instalar el APK en todos los dispositivos
ansible.builtin.command:
cmd: "{{ coordinator_dir }}/fleet.sh install {{ coordinator_dir }}/apks/{{ apk_local_path | basename }}"
register: apk_install
changed_when: true
- name: Resultado de la instalación
ansible.builtin.debug:
var: apk_install.stdout_lines

View file

@ -0,0 +1,19 @@
---
# Regenera solo el coordinador (listado de flota + script fleet).
#
# ansible-playbook playbooks/coordinator.yml
#
# La primera play recoge facts de los nodos de carga sin tocarlos: el listado
# de dispositivos se calcula a partir de su RAM real.
- name: Recoger facts de los nodos de carga
hosts: redroid_nodes
gather_facts: true
become: false
tasks: []
- name: Configurar el coordinador
hosts: coordinator
gather_facts: true
roles:
- role: coordinator

View file

@ -0,0 +1,48 @@
---
# DESTRUCTIVO. Para y borra los contenedores Android.
#
# ansible-playbook playbooks/destroy.yml -e confirmar=si
#
# Por defecto conserva los volúmenes /data (el estado de cada Android).
# Para borrarlos también — esto no tiene vuelta atrás:
#
# ansible-playbook playbooks/destroy.yml -e confirmar=si -e borrar_datos=si
- name: Desmontar la flota Android
hosts: redroid_nodes
gather_facts: false
vars:
confirmar: "no"
borrar_datos: "no"
tasks:
- name: Exigir confirmación explícita
ansible.builtin.assert:
that: confirmar == "si"
fail_msg: >-
Esto para y borra los contenedores Android de {{ inventory_hostname }}.
Si es lo que quieres, relanza con -e confirmar=si
- name: Comprobar que hay un compose desplegado
ansible.builtin.stat:
path: "{{ redroid_base_dir }}/docker-compose.yml"
register: destroy_compose
- name: Parar y eliminar los contenedores
ansible.builtin.command:
cmd: docker compose down
chdir: "{{ redroid_base_dir }}"
when: destroy_compose.stat.exists
changed_when: true
- name: Borrar los volúmenes /data
ansible.builtin.file:
path: "{{ redroid_base_dir }}/data"
state: absent
when: borrar_datos == "si"
- name: Qué ha quedado
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}: contenedores eliminados.
Datos {{ 'BORRADOS' if borrar_datos == 'si' else 'conservados en ' ~ redroid_base_dir ~ '/data' }}.

View file

@ -0,0 +1,23 @@
---
# Despliega o redespliega solo los contenedores Android.
# Da por hecho que provision.yml ya pasó.
#
# ansible-playbook playbooks/devices.yml
#
# Cambiar la versión de Android en toda la flota:
# ansible-playbook playbooks/devices.yml -e redroid_image=redroid/redroid:14.0.0-latest
#
# Subir instancias por nodo (si has ampliado la RAM):
# ansible-playbook playbooks/devices.yml -e redroid_instances=6
- name: Contenedores Android
hosts: redroid_nodes
gather_facts: true
roles:
- role: redroid
- name: Regenerar el listado de la flota en el coordinador
hosts: coordinator
gather_facts: true
roles:
- role: coordinator

View file

@ -0,0 +1,37 @@
---
# SOLO LECTURA. No cambia nada en los blades.
#
# ansible-playbook playbooks/preflight.yml
#
# Contesta lo que ahora mismo no sabemos: qué modelo de blade hay dentro del
# chasis, si esas CPUs pueden con Android x86_64, y cuántas instancias salen.
# Lánzalo ANTES de site.yml.
- name: Reconocimiento de la granja
hosts: granja
gather_facts: true
become: true
roles:
- role: preflight
- name: Escribir informe en el nodo de control
hosts: localhost
connection: local
gather_facts: true
become: false
tasks:
- name: Crear directorio de informes
ansible.builtin.file:
path: "{{ playbook_dir }}/../reports"
state: directory
mode: "0755"
- name: Generar informe
ansible.builtin.template:
src: preflight-report.md.j2
dest: "{{ playbook_dir }}/../reports/preflight-{{ ansible_date_time.date }}.md"
mode: "0644"
- name: Dónde ha quedado
ansible.builtin.debug:
msg: "Informe en ansible/reports/preflight-{{ ansible_date_time.date }}.md"

View file

@ -0,0 +1,18 @@
---
# Deja los blades listos, pero sin levantar Android todavía.
# Útil para separar "preparar el hierro" de "desplegar la flota".
#
# ansible-playbook playbooks/provision.yml
- name: Base del sistema
hosts: granja
gather_facts: true
roles:
- role: base
- name: Binder y Docker en los nodos de carga
hosts: redroid_nodes
gather_facts: true
roles:
- role: binder
- role: docker

View file

@ -0,0 +1,53 @@
---
# Estado real de la granja. Solo lectura.
#
# ansible-playbook playbooks/status.yml
- name: Estado de los nodos de carga
hosts: redroid_nodes
gather_facts: true
tasks:
- name: Contenedores en marcha
ansible.builtin.command:
cmd: docker ps --filter "name=redroid-" --format '{% raw %}{{.Names}}\t{{.Status}}{% endraw %}'
register: status_ps
changed_when: false
failed_when: false
- name: Devices binder presentes
ansible.builtin.stat:
path: "/dev/{{ item }}"
loop: [binder, hwbinder, vndbinder]
register: status_binder
- name: Memoria libre
ansible.builtin.command:
cmd: free -m
register: status_mem
changed_when: false
- name: Resumen del nodo
ansible.builtin.debug:
msg:
- "--- {{ inventory_hostname }} ---"
- "binder : {{ 'ok' if (status_binder.results | rejectattr('stat.exists') | list | length == 0) else 'FALTAN devices' }}"
- "ram libre : {{ status_mem.stdout_lines[1].split()[6] | default('?') }} MB disponibles de {{ ansible_memtotal_mb }} MB"
- "contenedores:"
- "{{ status_ps.stdout_lines | default(['(ninguno)']) }}"
- name: Estado de la flota adb
hosts: coordinator
gather_facts: false
tasks:
- name: Conectar y listar
ansible.builtin.shell:
cmd: "{{ coordinator_dir }}/fleet.sh connect >/dev/null 2>&1; {{ coordinator_dir }}/fleet.sh list"
register: status_fleet
changed_when: false
failed_when: false
- name: Flota
ansible.builtin.debug:
var: status_fleet.stdout_lines

View file

@ -0,0 +1,28 @@
# Informe de preflight — {{ ansible_date_time.date }}
Generado por `ansible-playbook playbooks/preflight.yml`. Solo lectura: no se tocó nada.
## Flota
| Nodo | Modelo | CPU | vCPU | RAM | Kernel | binder | Flags CPU | Capacidad | Veredicto |
|---|---|---|---|---|---|---|---|---|---|
{% for h in groups['granja'] | sort %}
{% set hv = hostvars[h] %}
| {{ h }} | {{ hv.preflight_vendor | default('?') }} {{ hv.preflight_model | default('?') }} | {{ hv.ansible_processor[2] | default('?') }} | {{ hv.ansible_processor_vcpus | default('?') }} | {{ hv.ansible_memtotal_mb | default('?') }} MB | {{ hv.ansible_kernel | default('?') }} | {{ 'sí' if hv.preflight_binder_ko.stat.exists | default(false) else 'NO' }} | {{ 'ok' if (hv.preflight_missing_flags | default([]) | length) == 0 else 'faltan: ' ~ (hv.preflight_missing_flags | join(', ')) }} | {{ hv.preflight_capacity | default('?') }} | {{ 'APTO' if hv.preflight_ok | default(false) else '**NO APTO**' }} |
{% endfor %}
## Totales
{% set aptos = groups['granja'] | map('extract', hostvars) | selectattr('preflight_ok', 'defined') | selectattr('preflight_ok') | list %}
{% set nodos_carga = groups['redroid_nodes'] | default([]) | map('extract', hostvars) | selectattr('preflight_ok', 'defined') | selectattr('preflight_ok') | list %}
- Nodos aptos: **{{ aptos | length }}** de {{ groups['granja'] | length }}
- Capacidad total en nodos de carga: **{{ nodos_carga | map(attribute='preflight_capacity') | map('int') | sum }} instancias Android**
- RAM total de la flota: {{ groups['granja'] | map('extract', hostvars) | selectattr('ansible_memtotal_mb', 'defined') | map(attribute='ansible_memtotal_mb') | map('int') | sum }} MB
## Cómo leer esto
- **binder = NO** → ese blade no puede correr redroid con su kernel actual. Ver `roles/binder/tasks/main.yml`.
- **Flags CPU faltantes** → CPU demasiado antigua para las imágenes x86_64 de Android. Es el riesgo
real si los blades resultan ser M610 (Xeon 20092011).
- **Capacidad** sale de `(RAM - {{ redroid_host_reserve_mb }} MB de reserva) / {{ redroid_mem_mb }} MB por instancia`.
Si el número es bajo, la palanca es ampliar RAM por blade, no añadir blades.

View file

@ -0,0 +1,38 @@
---
- name: Actualizar índice de apt
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Paquetes base
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
- htop
- iproute2
- python3
- rsync
state: present
- name: Leer zona horaria actual
ansible.builtin.command:
cmd: timedatectl show -p Timezone --value
register: base_tz
changed_when: false
failed_when: false
- name: Fijar zona horaria
ansible.builtin.command:
cmd: "timedatectl set-timezone {{ granja_timezone | default('Europe/Madrid') }}"
when: base_tz.stdout | default('') != (granja_timezone | default('Europe/Madrid'))
changed_when: true
failed_when: false
- name: Sincronización horaria activa
ansible.builtin.systemd:
name: systemd-timesyncd
enabled: true
state: started
failed_when: false

View file

@ -0,0 +1,84 @@
---
# El punto donde esto se atasca en Debian.
#
# Debian 12 compila binder como módulo (CONFIG_ANDROID_BINDER_IPC=m) pero SIN
# binderfs (CONFIG_ANDROID_BINDERFS no está activado). Sin binderfs, redroid no
# puede crear los devices por sí mismo: hay que cargarlos a mano y con los TRES
# nombres que Android espera — binder, hwbinder, vndbinder.
#
# Los contenedores comparten este binder del kernel anfitrión. Si no está, no
# arranca ni una instancia.
- name: Comprobar que el kernel trae binder_linux
ansible.builtin.stat:
path: "/lib/modules/{{ ansible_kernel }}/kernel/drivers/android/binder_linux.ko"
register: binder_ko
- name: Abortar si no hay módulo binder
ansible.builtin.assert:
that: binder_ko.stat.exists
fail_msg: >-
Este kernel ({{ ansible_kernel }}) no trae binder_linux.ko.
Sin binder no hay redroid. Salidas: instalar el kernel estándar de Debian,
o el módulo DKMS de Waydroid (binder_linux-dkms), que además aporta binderfs.
- name: Cargar binder_linux en cada arranque
ansible.builtin.copy:
content: "binder_linux\n"
dest: /etc/modules-load.d/redroid.conf
owner: root
group: root
mode: "0644"
- name: Fijar los tres devices como parámetro del módulo
ansible.builtin.copy:
content: "options binder_linux devices=binder,hwbinder,vndbinder\n"
dest: /etc/modprobe.d/redroid.conf
owner: root
group: root
mode: "0644"
- name: Ver qué devices binder existen ya
ansible.builtin.stat:
path: "/dev/{{ item }}"
loop: [binder, hwbinder, vndbinder]
register: binder_devs_before
- name: ¿Falta alguno?
ansible.builtin.set_fact:
binder_needs_load: "{{ binder_devs_before.results | rejectattr('stat.exists') | list | length > 0 }}"
# Si el módulo ya está cargado pero con los parámetros equivocados, modprobe no
# lo recarga solo. Hay que descargarlo primero.
- name: Descargar binder_linux si está cargado mal
ansible.builtin.command:
cmd: modprobe -r binder_linux
when: binder_needs_load
register: binder_rmmod
failed_when: false
changed_when: binder_rmmod.rc == 0
- name: Cargar binder_linux con los tres devices
ansible.builtin.command:
cmd: modprobe binder_linux devices=binder,hwbinder,vndbinder
when: binder_needs_load
register: binder_modprobe
failed_when: false
changed_when: binder_modprobe.rc == 0
- name: Verificar los devices binder
ansible.builtin.stat:
path: "/dev/{{ item }}"
loop: [binder, hwbinder, vndbinder]
register: binder_devs_after
- name: Confirmar que los tres devices están
ansible.builtin.assert:
that: binder_devs_after.results | rejectattr('stat.exists') | list | length == 0
fail_msg: >-
Faltan devices binder tras el modprobe:
{{ binder_devs_after.results | rejectattr('stat.exists') | map(attribute='item') | join(', ') }}.
Causa habitual: el módulo estaba cargado y en uso, así que no se pudo recargar
con los parámetros nuevos. La configuración persistente YA está escrita, así que
un reinicio del blade lo arregla. Reinicia y relanza este playbook.
success_msg: "binder, hwbinder y vndbinder presentes."

View file

@ -0,0 +1,2 @@
---
coordinator_dir: /opt/granja

View file

@ -0,0 +1,61 @@
---
# El coordinador es el único punto desde el que se habla con toda la flota.
# No corre instancias Android: corre adb y los scripts.
#
# Importante en modo macvlan: un anfitrión NO puede hablar con sus propios
# contenedores macvlan. Por eso adb vive aquí, en un blade distinto — desde
# fuera sí se llega. Ver el README.
- name: Instalar herramientas de control
ansible.builtin.apt:
name:
- adb
- jq
state: present
- name: Crear directorios del coordinador
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- "{{ coordinator_dir }}"
- "{{ coordinator_dir }}/apks"
- name: Escribir el listado de dispositivos de la flota
ansible.builtin.template:
src: devices.txt.j2
dest: "{{ coordinator_dir }}/devices.txt"
owner: root
group: root
mode: "0644"
- name: Instalar el script de flota
ansible.builtin.template:
src: fleet.sh.j2
dest: "{{ coordinator_dir }}/fleet.sh"
owner: root
group: root
mode: "0755"
- name: Enlazar fleet en el PATH
ansible.builtin.file:
src: "{{ coordinator_dir }}/fleet.sh"
dest: /usr/local/bin/fleet
state: link
- name: Contar dispositivos de la flota
ansible.builtin.command:
cmd: "grep -cv '^#' {{ coordinator_dir }}/devices.txt"
register: coordinator_count
changed_when: false
failed_when: false
- name: Resumen
ansible.builtin.debug:
msg:
- "Coordinador listo en {{ inventory_hostname }}."
- "Flota declarada: {{ coordinator_count.stdout | default('0') | trim }} dispositivos."
- "Uso: fleet connect · fleet list · fleet install <apk> · fleet shell '<cmd>'"

View file

@ -0,0 +1,18 @@
# GENERADO POR ANSIBLE — no editar a mano.
# Una dirección adb por línea: <host>:<puerto>
# Regenerar con: ansible-playbook playbooks/coordinator.yml
{% for h in groups['redroid_nodes'] | sort %}
{% set hv = hostvars[h] %}
{% set count = hv.redroid_instance_count | default(
[ (((hv.ansible_memtotal_mb | int) - (redroid_host_reserve_mb | int))
/ (redroid_mem_mb | int)) | int,
redroid_max_instances | int ] | min ) %}
# --- {{ h }} ({{ count }} instancias) ---
{% for i in range(count | int) %}
{% if (hv.redroid_network_mode | default(redroid_network_mode)) == 'macvlan' %}
{{ hv.redroid_macvlan_ip_prefix | default(redroid_macvlan_ip_prefix) }}{{ (hv.redroid_macvlan_ip_offset | int) + i }}:5555
{% else %}
{{ hv.ansible_host | default(h) }}:{{ (hv.redroid_adb_base_port | default(redroid_adb_base_port) | int) + i }}
{% endif %}
{% endfor %}
{% endfor %}

View file

@ -0,0 +1,135 @@
#!/usr/bin/env bash
# GENERADO POR ANSIBLE — no editar a mano.
# Fuente: roles/coordinator/templates/fleet.sh.j2
#
# Control de la flota Android de la granja desde un único sitio.
set -euo pipefail
DEVICES_FILE="{{ coordinator_dir }}/devices.txt"
APK_DIR="{{ coordinator_dir }}/apks"
usage() {
cat <<'USAGE'
fleet — control de la flota Android
fleet connect conecta adb a toda la flota
fleet disconnect desconecta todo
fleet list dispositivos conectados y su estado
fleet install <apk> instala un APK en toda la flota
fleet uninstall <pkg> desinstala un paquete de toda la flota
fleet shell '<cmd>' ejecuta un comando de shell en toda la flota
fleet ip IP de cada dispositivo (útil para el caso de dos APKs)
fleet reboot reinicia toda la flota
El listado de dispositivos sale de devices.txt, que genera Ansible.
USAGE
}
declared_devices() {
grep -v '^#' "$DEVICES_FILE" | grep -v '^[[:space:]]*$'
}
connected_devices() {
adb devices | awk '/\tdevice$/ {print $1}'
}
require_connected() {
local n
n=$(connected_devices | wc -l)
if [[ "$n" -eq 0 ]]; then
echo "No hay dispositivos conectados. Lanza 'fleet connect' primero." >&2
exit 1
fi
}
cmd_connect() {
local ok=0 fail=0
while read -r dev; do
if adb connect "$dev" 2>&1 | grep -qE 'connected to'; then
ok=$((ok + 1))
else
echo " fallo: $dev" >&2
fail=$((fail + 1))
fi
done < <(declared_devices)
echo "conectados: $ok · fallidos: $fail"
}
cmd_disconnect() {
adb disconnect || true
}
cmd_list() {
local declared connected
declared=$(declared_devices | wc -l)
connected=$(connected_devices | wc -l)
echo "declarados: $declared · conectados: $connected"
echo
adb devices -l
}
cmd_install() {
local apk="${1:?uso: fleet install <apk>}"
[[ -f "$apk" ]] || { echo "no existe: $apk" >&2; exit 1; }
require_connected
local ok=0 fail=0
while read -r dev; do
if adb -s "$dev" install -r "$apk" >/dev/null 2>&1; then
echo " ok $dev"
ok=$((ok + 1))
else
echo " FALLO $dev" >&2
fail=$((fail + 1))
fi
done < <(connected_devices)
echo "instalado en $ok · fallos $fail"
[[ "$fail" -eq 0 ]]
}
cmd_uninstall() {
local pkg="${1:?uso: fleet uninstall <paquete>}"
require_connected
while read -r dev; do
printf ' %s: ' "$dev"
adb -s "$dev" uninstall "$pkg" || true
done < <(connected_devices)
}
cmd_shell() {
local command="${1:?uso: fleet shell '<comando>'}"
require_connected
while read -r dev; do
echo "== $dev =="
adb -s "$dev" shell "$command" || true
done < <(connected_devices)
}
cmd_ip() {
require_connected
while read -r dev; do
printf '%-24s %s\n' "$dev" \
"$(adb -s "$dev" shell ip -4 -o addr show scope global 2>/dev/null \
| awk '{print $4}' | paste -sd, - || echo '?')"
done < <(connected_devices)
}
cmd_reboot() {
require_connected
while read -r dev; do
echo " reiniciando $dev"
adb -s "$dev" reboot || true
done < <(connected_devices)
}
case "${1:-}" in
connect) cmd_connect ;;
disconnect) cmd_disconnect ;;
list) cmd_list ;;
install) shift; cmd_install "$@" ;;
uninstall) shift; cmd_uninstall "$@" ;;
shell) shift; cmd_shell "$@" ;;
ip) cmd_ip ;;
reboot) cmd_reboot ;;
*) usage; exit 1 ;;
esac

View file

@ -0,0 +1,53 @@
---
# Docker desde el repo oficial. El `docker.io` de Debian 12 va con un compose
# antiguo (v1, sintaxis `docker-compose`), y las plantillas de aquí usan
# `docker compose` (v2). Requiere que los blades lleguen a internet o a un
# mirror local de download.docker.com.
- name: Directorio de keyrings de apt
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
owner: root
group: root
mode: "0755"
- name: Clave GPG de Docker
ansible.builtin.get_url:
url: https://download.docker.com/linux/debian/gpg
dest: /etc/apt/keyrings/docker.asc
owner: root
group: root
mode: "0644"
- name: Repositorio de Docker
ansible.builtin.apt_repository:
repo: >-
deb [arch={{ 'amd64' if ansible_architecture == 'x86_64' else ansible_architecture }}
signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable
filename: docker
state: present
update_cache: true
- name: Instalar Docker Engine y el plugin de compose
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
- name: Servicio de Docker activo y arrancado
ansible.builtin.systemd:
name: docker
enabled: true
state: started
- name: Añadir el usuario de despliegue al grupo docker
ansible.builtin.user:
name: "{{ docker_user }}"
groups: docker
append: true
when: docker_user | default('') | length > 0

View file

@ -0,0 +1,75 @@
---
# Preflight es de SOLO LECTURA. No cambia nada en los blades.
# Su trabajo es contestar dos preguntas antes de gastar un minuto:
# 1. ¿Qué blade es esto exactamente?
# 2. ¿Puede correr Android x86_64, y cuántas instancias?
- name: Leer identidad DMI del blade
ansible.builtin.slurp:
src: "/sys/class/dmi/id/{{ item }}"
loop:
- sys_vendor
- product_name
register: preflight_dmi
failed_when: false
changed_when: false
- name: Registrar modelo de blade
ansible.builtin.set_fact:
preflight_vendor: "{{ (preflight_dmi.results[0].content | default('') | b64decode | trim) or 'desconocido' }}"
preflight_model: "{{ (preflight_dmi.results[1].content | default('') | b64decode | trim) or 'desconocido' }}"
- name: Leer flags de CPU
ansible.builtin.shell:
cmd: "set -o pipefail; grep -m1 '^flags' /proc/cpuinfo | cut -d: -f2"
executable: /bin/bash
register: preflight_cpuflags
changed_when: false
- name: Detectar flags de CPU que faltan
ansible.builtin.set_fact:
preflight_missing_flags: >-
{{ preflight_required_cpu_flags
| reject('in', preflight_cpuflags.stdout.split())
| list }}
- name: Comprobar módulo binder en el kernel
ansible.builtin.stat:
path: "/lib/modules/{{ ansible_kernel }}/kernel/drivers/android/binder_linux.ko"
register: preflight_binder_ko
changed_when: false
- name: Comprobar si el kernel trae binderfs
ansible.builtin.shell:
cmd: "set -o pipefail; grep -c '^CONFIG_ANDROID_BINDERFS=y' /boot/config-{{ ansible_kernel }} || true"
executable: /bin/bash
register: preflight_binderfs
changed_when: false
failed_when: false
- name: Calcular instancias que caben en este blade
ansible.builtin.set_fact:
preflight_capacity: >-
{{ [ (((ansible_memtotal_mb | int) - (redroid_host_reserve_mb | int))
/ (redroid_mem_mb | int)) | int,
redroid_max_instances | int ] | min }}
- name: Veredicto del nodo
ansible.builtin.set_fact:
preflight_ok: >-
{{ (preflight_missing_flags | length == 0)
and preflight_binder_ko.stat.exists
and (preflight_capacity | int) >= 1 }}
- name: Resumen del blade
ansible.builtin.debug:
msg:
- "modelo : {{ preflight_vendor }} {{ preflight_model }}"
- "cpu : {{ ansible_processor[2] | default('?') }} ({{ ansible_processor_vcpus }} vcpus)"
- "ram : {{ ansible_memtotal_mb }} MB"
- "kernel : {{ ansible_kernel }} / {{ ansible_distribution }} {{ ansible_distribution_version }}"
- "binder .ko : {{ 'sí' if preflight_binder_ko.stat.exists else 'NO — este blade no puede correr redroid' }}"
- "binderfs : {{ 'sí' if (preflight_binderfs.stdout | default('0') | int) > 0 else 'no (se cargan devices a mano)' }}"
- "flags CPU : {{ 'ok' if preflight_missing_flags | length == 0 else 'FALTAN ' ~ (preflight_missing_flags | join(', ')) }}"
- "capacidad : {{ preflight_capacity }} instancias"
- "veredicto : {{ 'APTO' if preflight_ok else 'NO APTO' }}"

View file

@ -0,0 +1,27 @@
---
# Defaults del rol. Lo que se toca normalmente vive en group_vars/all.yml,
# que tiene precedencia sobre esto.
redroid_image: "redroid/redroid:13.0.0-latest"
redroid_mem_mb: 2048
redroid_host_reserve_mb: 1500
redroid_instances: 0
redroid_max_instances: 8
redroid_width: 720
redroid_height: 1280
redroid_dpi: 320
redroid_gpu_mode: guest
redroid_base_dir: /opt/redroid
redroid_network_mode: portmap
redroid_adb_base_port: 5555
redroid_adb_bind_ip: "0.0.0.0"
redroid_macvlan_network: redroid-lan
redroid_macvlan_parent: ""
redroid_macvlan_subnet: ""
redroid_macvlan_gateway: ""
redroid_macvlan_ip_prefix: ""
redroid_macvlan_ip_offset: 20

View file

@ -0,0 +1,115 @@
---
# Despliega N contenedores Android en el blade. N sale de la RAM real salvo
# que se fije redroid_instances a mano.
- name: Calcular cuántas instancias caben
ansible.builtin.set_fact:
redroid_instance_count: >-
{{ (redroid_instances | int) if (redroid_instances | int) > 0
else [ (((ansible_memtotal_mb | int) - (redroid_host_reserve_mb | int))
/ (redroid_mem_mb | int)) | int,
redroid_max_instances | int ] | min }}
- name: Abortar si no cabe ni una instancia
ansible.builtin.assert:
that: (redroid_instance_count | int) >= 1
fail_msg: >-
{{ inventory_hostname }} tiene {{ ansible_memtotal_mb }} MB de RAM. Con
{{ redroid_host_reserve_mb }} MB de reserva y {{ redroid_mem_mb }} MB por
instancia no cabe ninguna. Baja redroid_mem_mb, baja la reserva, o amplía
la RAM del blade (que es la palanca buena — ver hardware_m1000e.md).
- name: Validar configuración de macvlan
ansible.builtin.assert:
that:
- redroid_macvlan_parent | length > 0
- redroid_macvlan_subnet | length > 0
- redroid_macvlan_gateway | length > 0
- redroid_macvlan_ip_prefix | length > 0
- redroid_macvlan_ip_offset is defined
fail_msg: >-
redroid_network_mode es 'macvlan' pero faltan datos de red. Necesito
redroid_macvlan_parent / _subnet / _gateway / _ip_prefix en group_vars/all.yml
y redroid_macvlan_ip_offset por host en el inventario.
when: redroid_network_mode == 'macvlan'
- name: Crear árbol de directorios
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- "{{ redroid_base_dir }}"
- "{{ redroid_base_dir }}/data"
- "{{ redroid_base_dir }}/apks"
- name: Crear el volumen /data de cada instancia
ansible.builtin.file:
path: "{{ redroid_base_dir }}/data/device-{{ '%02d' % (item + 1) }}"
state: directory
owner: root
group: root
mode: "0755"
loop: "{{ range(redroid_instance_count | int) | list }}"
# --- red macvlan -----------------------------------------------------------
- name: Listar redes docker existentes
ansible.builtin.command:
cmd: docker network ls --format '{% raw %}{{.Name}}{% endraw %}'
register: redroid_networks
changed_when: false
when: redroid_network_mode == 'macvlan'
- name: Crear la red macvlan
ansible.builtin.command:
cmd: >-
docker network create -d macvlan
--subnet={{ redroid_macvlan_subnet }}
--gateway={{ redroid_macvlan_gateway }}
-o parent={{ redroid_macvlan_parent }}
{{ redroid_macvlan_network }}
when:
- redroid_network_mode == 'macvlan'
- redroid_macvlan_network not in redroid_networks.stdout_lines
changed_when: true
# --- contenedores ----------------------------------------------------------
- name: Escribir el docker-compose del nodo
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ redroid_base_dir }}/docker-compose.yml"
owner: root
group: root
mode: "0644"
register: redroid_compose
- name: Descargar la imagen de redroid
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ redroid_base_dir }}"
register: redroid_pull
changed_when: "'Downloaded' in redroid_pull.stderr or 'Pulled' in redroid_pull.stderr"
- name: Levantar las instancias Android
ansible.builtin.command:
cmd: docker compose up -d --remove-orphans
chdir: "{{ redroid_base_dir }}"
register: redroid_up
changed_when: "'Started' in redroid_up.stderr or 'Created' in redroid_up.stderr or 'Recreated' in redroid_up.stderr"
- name: Contar contenedores en marcha
ansible.builtin.command:
cmd: docker compose ps --status running --format '{% raw %}{{.Name}}{% endraw %}'
chdir: "{{ redroid_base_dir }}"
register: redroid_running
changed_when: false
- name: Resumen del nodo
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}: {{ redroid_running.stdout_lines | length }}/{{ redroid_instance_count }}
instancias en marcha ({{ redroid_network_mode }})

View file

@ -0,0 +1,38 @@
# GENERADO POR ANSIBLE — no editar a mano.
# Fuente: roles/redroid/templates/docker-compose.yml.j2
# Nodo: {{ inventory_hostname }} · {{ ansible_memtotal_mb }} MB RAM · {{ redroid_instance_count }} instancias
name: redroid-{{ inventory_hostname }}
services:
{% for i in range(redroid_instance_count | int) %}
{% set idx = '%02d' % (i + 1) %}
device-{{ idx }}:
image: {{ redroid_image }}
container_name: redroid-{{ inventory_hostname }}-{{ idx }}
privileged: true
restart: unless-stopped
mem_limit: {{ redroid_mem_mb }}m
volumes:
- {{ redroid_base_dir }}/data/device-{{ idx }}:/data
{% if redroid_network_mode == 'macvlan' %}
networks:
redroid_lan:
ipv4_address: {{ redroid_macvlan_ip_prefix }}{{ (redroid_macvlan_ip_offset | int) + i }}
{% else %}
ports:
- "{{ redroid_adb_bind_ip }}:{{ (redroid_adb_base_port | int) + i }}:5555"
{% endif %}
command:
- androidboot.redroid_width={{ redroid_width }}
- androidboot.redroid_height={{ redroid_height }}
- androidboot.redroid_dpi={{ redroid_dpi }}
- androidboot.redroid_gpu_mode={{ redroid_gpu_mode }}
{% endfor %}
{% if redroid_network_mode == 'macvlan' %}
networks:
redroid_lan:
external: true
name: {{ redroid_macvlan_network }}
{% endif %}

View file

@ -0,0 +1,29 @@
---
# Playbook maestro: provisiona la granja entera de cero.
#
# ansible-playbook site.yml
#
# Antes de esto, SIEMPRE:
# ansible-playbook playbooks/preflight.yml
#
# Es idempotente: se puede relanzar sin romper nada.
- name: Base del sistema en toda la granja
hosts: granja
gather_facts: true
roles:
- role: base
- name: Nodos de carga — binder, Docker y contenedores Android
hosts: redroid_nodes
gather_facts: true
roles:
- role: binder
- role: docker
- role: redroid
- name: Coordinador — adb y scripts de flota
hosts: coordinator
gather_facts: true
roles:
- role: coordinator

16
02_device_lab/connect.sh Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Conecta adb a todas las instancias redroid levantadas por docker-compose.yml
set -euo pipefail
PORTS=(5555 5556 5557 5558)
for port in "${PORTS[@]}"; do
if ss -ltn "sport = :$port" 2>/dev/null | grep -q LISTEN; then
adb connect "127.0.0.1:$port" || true
else
echo "puerto $port sin escuchar — instancia no levantada, salto" >&2
fi
done
echo
adb devices

View file

@ -0,0 +1,40 @@
# redroid — 4 instancias Android 13 como punto de partida.
# Requiere binder cargado en el host: ver README.md
#
# Cada instancia expone adb en un puerto distinto (5555..5558).
x-redroid: &redroid
image: redroid/redroid:13.0.0-latest
privileged: true
restart: unless-stopped
mem_limit: 2048m
command:
- androidboot.redroid_width=720
- androidboot.redroid_height=1280
- androidboot.redroid_dpi=320
- androidboot.redroid_gpu_mode=guest # software; cambiar a "host" si hay GPU pasable
services:
device-01:
<<: *redroid
container_name: redroid-01
ports: ["5555:5555"]
volumes: ["./data/device-01:/data"]
device-02:
<<: *redroid
container_name: redroid-02
ports: ["5556:5555"]
volumes: ["./data/device-02:/data"]
device-03:
<<: *redroid
container_name: redroid-03
ports: ["5557:5555"]
volumes: ["./data/device-03:/data"]
device-04:
<<: *redroid
container_name: redroid-04
ports: ["5558:5555"]
volumes: ["./data/device-04:/data"]

View file

@ -0,0 +1,61 @@
# El hardware: Dell PowerEdge M1000e
Identificado el 2026-08-08. Fuente: `~/Downloads/poweredge-m1000e-spec-sheet.pdf`
(spec sheet oficial Dell EMC, 6 de diciembre de 2016).
No es un servidor suelto: es un **chasis de blades**. Cada blade es una máquina independiente
con su CPU y su RAM.
## Especificaciones del chasis
| | |
|---|---|
| Factor de forma | Modular 10U — 44,0 alto × 44,7 ancho × 75,4 cm fondo |
| Capacidad | **16 blades de media altura** (8 arriba + 8 abajo) · u 8 de altura completa · o 32 de cuarto |
| Alimentación | Hasta 6 fuentes de 3.000 W / 2.700 W (config 3+3, 2+2, 2+1…) |
| Refrigeración | 9 módulos de ventiladores redundantes |
| Gestión | CMC (Chassis Management Controller) + iDRAC por blade; web SSL, SSH/Telnet, LDAP/AD |
| Red interna | Hasta 6 módulos I/O FlexIO — los blades se ven entre sí a 1/10 GbE |
| Peso | 44 kg vacío · 179 kg cargado |
## Configuración de esta máquina
Según lo que recuerda el usuario:
- **16 blades**, 8 arriba y 8 abajo
- **8 GB de RAM por blade** → 128 GB totales, pero **repartidos, no agrupados**
- Modelo de blade: **desconocido** (M610 / M620 / M630 — pendiente de confirmar en el frontal)
- Estado / ubicación / accesibilidad de red: **pendiente**
## Qué implica para el device lab
### A favor
- 16 máquinas independientes con **red interna de chasis**. Mejor topología que un solo host para
el escenario de `oasis_mobile` con **dos APKs que se tienen que ver entre sí**: el switch FlexIO
los pone en la misma L2 sin montar nada.
- Con 8 GB por blade menos Debian + Docker (~11,5 GB), salen **~3 instancias redroid por blade**
**~48 instancias totales**. Casi el triple que el sobremesa (1220).
### En contra
- **Consumo: 1,52,5 kW en marcha.** A precio doméstico en España, del orden de **160350 €/mes**
si está 24/7. Este es el factor que decide la viabilidad, no los cores.
- **Ruido**: no es máquina de casa. Necesita sala con extracción.
- **Sin GPU** en los blades → `redroid_gpu_mode=guest` (render por software). Vale para test
funcional, no para nada gráfico.
- **16 provisiones separadas**: cada blade necesita su Debian + Docker + módulo binder. A partir de
4-5 blades esto pide Ansible o netboot, no hacerlo a mano.
### La palanca real
El cuello de botella son **los 8 GB por blade, no la CPU**. Si los blades son M620 (DDR3 ECC),
ampliar a 32 GB por blade cuesta poco en segunda mano y sube de ~3 a **~14 instancias por blade**.
Eso multiplica por 4-5 la capacidad del proyecto entero por menos de lo que cuesta un mes de luz.
## Pendiente de averiguar
- [ ] **Modelo de blade** (serigrafiado en el frontal) → determina CPU, generación y tipo de RAM
- [ ] ¿Está encendido y accesible el CMC en red? Si responde, da el inventario completo
- [ ] Ubicación física y quién asume el coste eléctrico
- [ ] Con eso: decidir si se levantan los 16 blades o solo 2-3 como piloto

19
02_device_lab/install-apk.sh Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Instala un APK en todas las instancias redroid conectadas.
# Uso: ./install-apk.sh ruta/al/app.apk
set -euo pipefail
APK="${1:?uso: $0 <ruta.apk>}"
[[ -f "$APK" ]] || { echo "no existe: $APK" >&2; exit 1; }
mapfile -t DEVICES < <(adb devices | awk '/^127\.0\.0\.1:[0-9]+\tdevice$/ {print $1}')
if [[ ${#DEVICES[@]} -eq 0 ]]; then
echo "no hay dispositivos conectados — ejecuta ./connect.sh primero" >&2
exit 1
fi
for dev in "${DEVICES[@]}"; do
echo "== $dev =="
adb -s "$dev" install -r "$APK"
done