docker-swarm 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61bc38a1b8f94704857812a859abbe425f57a63130655bcbaf7938f5621ad09a
4
- data.tar.gz: 1b824044722bc3a3c840889b5fef843aa6eafd25576ea25811ac8ee8e688d4e3
3
+ metadata.gz: 824016bc8286430e75b85e1daa74f5fa30039667230585bcd548ac76afacd1da
4
+ data.tar.gz: c8d56047e0e282aa35dcdd3998780f0bfcd7c5ba1be6464068bff91b99276934
5
5
  SHA512:
6
- metadata.gz: bfbb5850f24f31b4e00e7da3a90e3ed73996304c757688736cc0dcbe52bba19738e0c4a9615a79f88912f984992d603546a4a46e40f4102f356b7e718b37e983
7
- data.tar.gz: c3d7d6bf0e27dd190bd4dfe78a4b09436f3dada89edbf8628552a7a17d0a63a66ffb49c6c62b2e923c5f58d6cd12a584ba819e12329e13ae90ad50079e8bdc1a
6
+ metadata.gz: bc733a3eb22266fe1ca82dcfbc56445bf0118a8fd9c077cf697849fe15004463571c94248cd48f236d42a67d2ccbb415419e8e1a05096842bb72fec035d63fc9
7
+ data.tar.gz: e65002ee881d436b549918321b415b3acc224679c40690b08651ff48960f3d8c6ec3f00849ffc7830c91a8ab00d834940299e355e4225a814e231abc51a133c5
data/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [0.10.0] — 2026-08-05
8
+
9
+ ### Nuevas funcionalidades
10
+ - **`Service.where(status: true)` puebla `ServiceStatus`** (#22). `status` es un query param **propio** de `GET /services` (Engine API ≥ v1.41), no un filtro, y hasta ahora la gema no tenía forma de mandarlo: caía en el JSON de `?filters=`, donde el Engine no lo acepta como filtro de `/services`. Con esto cada elemento del listado trae `ServiceStatus` (`RunningTasks` · `DesiredTasks` · `CompletedTasks`), que es el **único** lugar donde el Engine publica el deseado de un service en modo `global` — un replicado lo expone en `Spec.Mode.Replicated.Replicas`, pero para un global ese campo no existe. Sin `DesiredTasks` un global corriendo en 2 de 3 nodos elegibles es indistinguible de uno sano: solo se detecta el caso extremo de cero tasks — @Pslp
11
+ - La whitelist de query params del listado sale a `Base.index_query_params` (default `%i[all force limit since before]`, **override por modelo**), y `Service` la extiende con `:status`. Se resuelve así, y **no** subiendo `status` a `Base`, porque en `/containers/json` `status` **sí** es un filtro válido (`running`, `exited`, …): globalizarlo lo sacaría del `?filters=` y rompería `Container.where(status: "running")`. Hay un spec de regresión que lo fija.
12
+ - **Compatible hacia atrás:** sin pasar `status` el listado no cambia. Y **degrada en silencio**: la gema no fija `?version=`, así que en un Engine por debajo de v1.41 el parámetro se ignora sin error y `ServiceStatus` llega ausente → el consumidor tiene que tolerar `nil`, no hay señal de "no soportado".
13
+ - **Trampa del consumidor:** en un service `global` recién creado `DesiredTasks` vale **0** — el Engine publica los contadores antes de evaluar los nodos elegibles y lo completa ~1s después (medido contra un Engine `1.54`). En esa ventana `RunningTasks == DesiredTasks == 0`, así que comparar los dos números para decidir salud miente; `DesiredTasks.positive?` va como precondición. Documentado en `docs/consumed/` y `skill/SKILL.md`.
14
+
15
+ ## [0.9.0] — 2026-08-03
16
+
17
+ ### Nuevas funcionalidades
18
+ - `Container` pasa a incluir `Concerns::Creatable`: la gema ya puede **crear** containers, no solo operar los existentes. El nombre viaja por **query string** (`POST /containers/create?name=`), que es donde el Engine lo espera — en el body lo descarta en silencio y responde `201`, dejando el container con nombre aleatorio. `Concerns::Creatable` gana `create_query_params` (default `[]`, override por modelo; `Container` declara `%w[name]`) y `query_params_for_docker`; `save` los manda en la URL y los excluye del payload. Implementa ADR-025 cláusula 1 — @gedera
19
+
20
+ ### Breaking changes
21
+ - **`logs` devuelve texto demultiplexado.** Sin TTY el Engine enmarca cada fragmento con 8 bytes de cabecera (tipo de stream · relleno · tamaño big-endian); el nuevo `Middleware::LogStreamDemuxer` los saca, así que `Loggable#logs` entrega texto limpio en `Container`, `Service` y `Task`. **Cambia el valor de retorno** para cualquier consumidor que hoy reciba el body crudo. Un stream sin framing (TTY) pasa intacto. Implementa ADR-025 cláusula 3 — @gedera
22
+ - El dispatch **no** se decide solo por `Content-Type`: `application/vnd.docker.multiplexed-stream` existe desde la **API v1.42**, y antes un stream multiplexado viajaba igual como `application/vnd.docker.raw-stream`. Como la gema no fija `?version=`, un Engine que tope en v1.41 devuelve `raw-stream` **con** framing. Ante `raw-stream` el middleware valida la **forma del frame** y solo demultiplexa si la cadena cierra de punta a punta; ante cualquier inconsistencia devuelve el body intacto. Registrado en **ADR-027**, que corrige un dato de apoyo del §Alternativas de ADR-025 **sin** superseder su Decisión.
23
+
24
+ ### Seguridad
25
+ - **`LogHelper.sanitize` redacta los secretos que viajan como `"CLAVE=VALOR"` en `Env`** (#24). Antes redactaba solo por **clave de hash**: un String caía al `else` y pasaba intacto, y el `Env` de un `ContainerSpec` es un **array de strings** `"CLAVE=VALOR"` donde el nombre del secreto vive *dentro* del elemento. Como `"Env"` tampoco matchea `SENSITIVE_KEYS`, el valor de todo secreto pasado por variable de entorno **se logueaba entero en `request_success` — camino feliz, nivel INFO** — en cada create/update de un service. Se agrega `redact_kv_string` y una rama `when String`: si la parte izquierda matchea `SENSITIVE_KEYS` se reemplaza el valor y **se conserva el nombre** (saber qué secreto apareció es diagnóstico útil; su valor no). El regex usa `[^=]+` a la izquierda para no partir en un `=` del valor (base64, URLs) y `/m` para valores multilínea. Afecta a **todas** las versiones anteriores — @Pslp
26
+ - **Hueco declarado, no cubierto por este fix:** `private_key` **no está** en `SENSITIVE_KEYS`, así que un PEM con ese nombre sigue saliendo en claro (hay un spec que lo fija como comportamiento conocido). Ampliar la lista va aparte: cambia la redacción para todos los consumidores.
27
+
28
+ ### Otros cambios
29
+ - `spec.homepage` del gemspec pasa a `https://github.com/sequre/docker-swarm` (#27): el repo se transfirió de la cuenta personal `gedera` a la org `sequre`. De ahí derivan los cuatro metadata URIs (`source_code_uri`, `changelog_uri`, `bug_tracker_uri`, `documentation_uri`), así que desde esta versión apuntan a la ubicación nueva. Las versiones ya publicadas conservan la URL anterior — las salva el redirect de GitHub — @gedera
30
+
5
31
  ## [0.8.0] — 2026-07-22
6
32
 
7
33
  ### Nuevas funcionalidades
data/README.md CHANGED
@@ -43,7 +43,7 @@ Documentación normada (RFC-001) por capa:
43
43
  |---|---|---|
44
44
  | Datos | — | `n/a` (gema sin DB) |
45
45
  | Glosario | [`docs/glossary/glossary.md`](docs/glossary/glossary.md) | completo (primitivas + arquitectura interna) |
46
- | Comportamiento | [`docs/behavior/behavior.md`](docs/behavior/behavior.md) | backfill on-demand (8 flujos) |
46
+ | Comportamiento | [`docs/behavior/behavior.md`](docs/behavior/behavior.md) | backfill on-demand + incremental (12 flujos) |
47
47
  | Configuración | [`docs/config/configuracion.md`](docs/config/configuracion.md) | inventario base (7 opciones, sin env vars) |
48
48
  | Interfaz | [`docs/interface/interface.md`](docs/interface/interface.md) | API Ruby pública (11 modelos + Base + concerns) |
49
49
  | Topología | [`docs/topology/topology.md`](docs/topology/topology.md) | 3 deps runtime + grafo de contexto |
@@ -1,6 +1,6 @@
1
1
  # Comportamiento — docker-swarm
2
2
 
3
- > meta: artefacto · RFC-007 · generado dev-enrich · anclado a `29856f1` · cobertura: 10 flujos load-bearing (8 backfill inicial + 2 nuevos: auth de registry privado, `Image.pull` síncrono)
3
+ > meta: artefacto · RFC-007 · generado dev-enrich · anclado a `v0.10.0` · cobertura: 12 flujos load-bearing (8 backfill inicial + 4 nuevos: auth de registry privado, `Image.pull` síncrono, `Container.create`, partición query params/filters del listado)
4
4
 
5
5
  ## 1. Resumen
6
6
 
@@ -8,7 +8,7 @@ Flujos de ejecución load-bearing de `docker-swarm`: cómo se materializan en ru
8
8
 
9
9
  ## 2. Cobertura declarada
10
10
 
11
- ### Documentados (8)
11
+ ### Documentados (12)
12
12
 
13
13
  1. `Service.create` + reload
14
14
  2. `Service.update` con `Version.Index`
@@ -20,12 +20,13 @@ Flujos de ejecución load-bearing de `docker-swarm`: cómo se materializan en ru
20
20
  8. `Loggable#logs` streaming
21
21
  9. Auth de registry privado (`RegistryAuth.resolve` → `X-Registry-Auth` / `registryAuthFrom`)
22
22
  10. `Image.pull` síncrono (stream NDJSON → error tipado → resultado explícito)
23
+ 11. `Container.create` con nombre por query string (`create_query_params`)
24
+ 12. `Model.where` — partición query params propios vs. `?filters=` (`index_query_params`)
23
25
 
24
26
  ### No documentados (ausencia ≠ inexistencia, RFC-007)
25
27
 
26
28
  - Reconexión / reapertura de socket Unix (Excon nativo, fuera de nuestra superficie).
27
29
  - Flujo de configuración / boot (`DockerSwarm.configure`) — trivial, sin secuencia de interés.
28
- - `Container.create` — **no implementado** en F1 (intencional, ver glossary).
29
30
 
30
31
  ## 3. Flujos
31
32
 
@@ -212,26 +213,38 @@ Mismo patrón para `stop`. POST sin body → no se reintenta automáticamente (
212
213
 
213
214
  ### 3.8 `Loggable#logs` streaming
214
215
 
215
- Obtención de logs raw para Service/Task/Container.
216
+ Obtención de logs para Service/Task/Container, ya demultiplexados.
216
217
 
217
218
  ```mermaid
218
219
  sequenceDiagram
219
220
  actor Caller
220
221
  participant Model as Service/Task/Container
221
222
  participant Api
223
+ participant Demux as LogStreamDemuxer
222
224
  participant Docker
223
225
 
224
226
  Caller->>Model: model.logs(stdout: 1, stderr: 1, follow: 0)
225
227
  Model->>Api: request(:logs, id:, query: { stdout:, stderr:, follow: })
226
228
  Api->>Docker: GET /services/abc/logs?stdout=1&stderr=1
227
- Docker-->>Api: 200 raw stream (text/plain)
228
- Api-->>Model: raw body
229
+ Docker-->>Demux: 200 stream + Content-Type
230
+ alt cadena de frames cierra de punta a punta
231
+ Demux->>Demux: saca 8 bytes de cabecera por frame, concatena en orden
232
+ else body sin framing (TTY) o inconsistente
233
+ Demux->>Demux: deja el body intacto
234
+ end
235
+ Demux-->>Api: texto limpio
236
+ Api-->>Model: body
229
237
  Model-->>Caller: String
230
238
  ```
231
239
 
232
240
  **Notas load-bearing:**
233
- - El body se devuelve sin parseo (no es JSON; `ResponseJSONParser` lo respeta porque Content-Type no es `application/json`).
234
- - `follow: 1` mantiene la conexión abierta el caller debe manejar el stream/timeout.
241
+ - El body no se parsea como JSON (`ResponseJSONParser` lo respeta porque el Content-Type no es `application/json`).
242
+ - **El demux vive en un middleware, no en `Loggable`:** `Connection#request` devuelve `response.body` y descarta los headers, así que aguas abajo ya no hay `Content-Type` con el que decidir (ADR-025 cláusula 3).
243
+ - **`raw-stream` no implica TTY.** Ese `Content-Type` era el único que existía antes de la API v1.42, y la gema no fija `?version=` → un Engine 20.10 devuelve `raw-stream` con framing. El middleware decide por la forma del frame, no por el header (detalle y cita del changelog en [`docs/consumed/docker-engine-api.md`](../consumed/docker-engine-api.md) §b).
244
+ - **Desviación de ADR-025, acotada.** La **Decisión** normativa (`ADR-025:130-131` — *"un middleware que decide por `Content-Type`"*) **se cumple**: el middleware corta si el header falta o no es uno de los dos. Lo que la implementación contradice es el **rationale de §Alternativas** (`ADR-025:106-107`), que da por sentado que `raw-stream` implica TTY. Ese dato de apoyo es falso para Engines que topan en la API v1.41. Asentar la corrección en el reino queda **pendiente**.
245
+ - **El demux limpia los frames, no separa señal de ruido.** `stdout` y `stderr` siguen intercalados en un solo String: quien necesite un dato puntual tiene que delimitarlo en origen.
246
+ - **Un frame partido entre chunks no se reensambla:** el demux es todo-o-nada, así que devuelve el body **intacto** en vez de texto a medias. El comportamiento está definido y cubierto por spec — los cuatro casos que pide `ADR-025:196-198` (cadena de frames, varios en un chunk, tamaño/cola truncados, TTY sin framing) están en `spec/docker/swarm/middleware/log_stream_demuxer_spec.rb`.
247
+ - `follow: 1` mantiene la conexión abierta — el caller debe manejar el stream/timeout. `[inferred]` Con `follow` el body no llega completo, así que el demux no aplica por diseño; no está ejercitado por spec ni contemplado en ADR-025 — es extrapolación de esta capa, no una limitación declarada por la ADR.
235
248
 
236
249
  ### 3.9 Auth de registry privado (`X-Registry-Auth` / `registryAuthFrom`)
237
250
 
@@ -287,12 +300,67 @@ sequenceDiagram
287
300
  - El digest sale del frame `Digest: sha256:...` (el stream de pull **no** trae campo `aux` — verificado contra Docker 29.5.3), escaneando desde el final.
288
301
  - Es un `POST` → **no** entra en la política de retries (ver flujo 3.5).
289
302
 
303
+ ### 3.11 `Container.create` con nombre por query string
304
+
305
+ El Engine toma el nombre del container por query string. En el body lo **descarta en silencio** y responde `201` igual: el container nace con nombre aleatorio, y una lógica de adopción por nombre determinista no lo encuentra → el reintento duplica en vez de adoptar (ADR-025 cláusula 1).
306
+
307
+ ```mermaid
308
+ sequenceDiagram
309
+ actor Caller
310
+ participant Container as DockerSwarm::Container
311
+ participant Api
312
+ participant Docker
313
+
314
+ Caller->>Container: Container.create(name: "acs-seed-helper", Image:, Cmd:)
315
+ Container->>Container: valid?
316
+ Container->>Container: query_params_for_docker → { name: "acs-seed-helper" }
317
+ Container->>Container: payload_for_docker.except("name")
318
+ Container->>Api: request(:create, query_params:, payload:)
319
+ Api->>Docker: POST /containers/create?name=acs-seed-helper
320
+ Docker-->>Api: 201 { Id: "abc" }
321
+ Api-->>Container: { Id: "abc" }
322
+ Container->>Container: self.ID = "abc" → reload
323
+ Container-->>Caller: instancia hidratada
324
+ ```
325
+
326
+ **Notas load-bearing:**
327
+ - El split lo declara el modelo con `.create_query_params` (`%w[name]` en `Container`); el default del concern es `[]`, así que los demás modelos Creatable no cambian de comportamiento.
328
+ - **El atributo se excluye del payload**, no se duplica: mandarlo en los dos lados no da error pero deja el body con una clave que Docker ignora.
329
+ - Hereda el flujo §3.1: `valid?` antes del POST, `reload` después, y **sin retry** por ser `POST` (§3.5). Si el `create` falla por `Communication`, el caller decide — con nombre determinista puede adoptar el existente en el reintento.
330
+
331
+ ### 3.12 `Model.where` — partición query params propios vs. `?filters=`
332
+
333
+ El Engine parte los parámetros de un listado en dos grupos, y la gema tiene que decidir a cuál va cada clave **antes** de armar la URL. `Base.index_query_params` es esa declaración; todo lo que no esté ahí se serializa dentro del JSON de `?filters=`.
334
+
335
+ La trampa: un query param propio que el modelo no declaró **no llega**. Viaja dentro de `filters`, donde el Engine lo rechaza o lo ignora según el recurso — no hay error que diga "esa clave iba en la URL".
336
+
337
+ ```mermaid
338
+ flowchart TD
339
+ A["Model.where(status: true, name: 'web')"] --> B["_fetch_all"]
340
+ B --> C{"clave ∈ index_query_params?"}
341
+ C -->|sí| D["query params propios<br/>{ status: true }"]
342
+ C -->|no| E["docker_filters<br/>{ name: 'web' }"]
343
+ E --> F["downcase claves + Array(valor)<br/>→ JSON"]
344
+ D --> G["query = { status: true,<br/>filters: '{\"name\":[\"web\"]}' }"]
345
+ F --> G
346
+ G --> H["GET /services?status=true&filters=..."]
347
+ H --> I["array de Hash → instancias"]
348
+ ```
349
+
350
+ **Notas load-bearing:**
351
+ - **La lista es por modelo, no global.** `Base` declara `%i[all force limit since before]`; `Service` la extiende con `:status`. Deliberadamente no se generaliza: `status` es query param propio en `/services` pero **filtro válido** en `/containers/json` (`running`, `exited`, …), así que subirlo a `Base` lo sacaría del `?filters=` donde containers lo necesita. Hay un spec de regresión que lo fija.
352
+ - **`Service.where(status: true)` es lo que habilita leer `ServiceStatus`** (`RunningTasks` · `DesiredTasks` · `CompletedTasks`), única superficie donde el Engine publica el **deseado** de un service en modo `global` — un replicado lo tiene en `Spec.Mode.Replicated.Replicas`, un global no tiene ese campo. Sin eso, un global corriendo en 2 de 3 nodos elegibles es indistinguible de uno sano: se ven las tasks que corren, no contra qué comparar.
353
+ - **Requiere API ≥ v1.41 y degrada en silencio.** La gema no fija `?version=` (§3.5 / [`docs/consumed/`](../consumed/docker-engine-api.md)); en un Engine anterior el parámetro se ignora sin error y `ServiceStatus` llega ausente → el consumidor tolera `nil`, no hay señal de "no soportado".
354
+ - **`DesiredTasks` arranca en 0 en un `global` recién creado** y el Engine lo completa después de evaluar los nodos (~1s, medido). En esa ventana `RunningTasks == DesiredTasks == 0`: el deseado no está calculado, así que comparar los dos números miente. Precondición de salud = `DesiredTasks.positive?`. Detalle en [`docs/consumed/`](../consumed/docker-engine-api.md).
355
+ - **Las claves se matchean como símbolos.** `where("status" => true)` cae en `docker_filters` (`slice(:status)` no matchea la string). Comportamiento preexistente y común a todos los query params del listado, no introducido por `:status`.
356
+ - Sin filtros no hay query: `all` manda `query_params: {}` — el listado por default no cambió.
357
+
290
358
  ## 4. Cobertura y fronteras
291
359
 
292
- - **Cobertura (RFC-007 backfill on-demand + incremental):** 8 flujos load-bearing en el backfill inicial + 2 agregados con el soporte de auth de registry privado (auth de registry privado, `Image.pull` síncrono) = 10. Esta gema es chica; el backfill completo era factible y se hizo, y a partir de ahí se acreta por PR.
360
+ - **Cobertura (RFC-007 backfill on-demand + incremental):** 8 flujos load-bearing en el backfill inicial + 2 agregados con el soporte de auth de registry privado (auth de registry privado, `Image.pull` síncrono) + 1 con el `create` de containers + 1 con la partición query params/filters del listado = 12. Esta gema es chica; el backfill completo era factible y se hizo, y a partir de ahí se acreta por PR.
293
361
  - **Frontera con glosario:** términos (Service, Spec, Version.Index, etc.) viven en [`docs/glossary/glossary.md`](../glossary/glossary.md). Esta capa documenta secuencias, no significado.
294
362
  - **Frontera con configuración:** `DockerSwarm.configure` es boot, no flujo de negocio. No se diagrama.
295
363
  - **No localizable / fuera de alcance:**
296
364
  - Lógica interna de Excon (retry timing, socket pool) — vive en Excon, no se inventa diagrama.
297
- - Flujo de auth registry para `Image.create` la gema **no implementa** `X-Registry-Auth` (gap, no flujo a documentar).
365
+ - Nada pendiente por este motivo. (Hasta el 2026-08-03 esta línea decía que la gema no implementaba `X-Registry-Auth` y citaba `Image.create`; las dos cosas quedaron obsoletas — la auth de registry está documentada en §3.9 e `Image.create` fue retirado.)
298
366
  - **Cadencia incremental a partir de acá:** sólo se diagrama un flujo cuando un PR lo toca o agrega. No barrido retroactivo de legacy.
@@ -1,6 +1,6 @@
1
1
  # Dependencias consumidas — docker-swarm
2
2
 
3
- > meta: artefacto · RFC-018 · generado arch-structure + enriquecido arch-enrich · anclado a `29856f1` · cobertura: superficie del Docker Engine API consumida por la gema (`api.rb` ENDPOINTS + `connection.rb`); §c/§e enriquecidas 1/1
3
+ > meta: artefacto · RFC-018 · generado arch-structure + enriquecido arch-enrich · anclado a `v0.10.0` · cobertura: superficie del Docker Engine API consumida por la gema (`api.rb` ENDPOINTS + `connection.rb`); §c/§e enriquecidas 1/1
4
4
 
5
5
  ## 1. Resumen
6
6
 
@@ -19,7 +19,7 @@ La gema consume **una** dependencia externa: el **Docker Engine API** (HTTP REST
19
19
  | transporte | HTTP/REST sobre Unix socket (`unix:///var/run/docker.sock`, default) o TCP (`http://host:2375`) |
20
20
  | cliente nuestro | `DockerSwarm::Connection` (Excon) + `DockerSwarm::Api` (`api.rb`) |
21
21
  | auth | ninguna por default (socket local). TCP/TLS → fuera de alcance del cliente (no inyecta credenciales; 401 si el daemon las exige) |
22
- | versión de API | v1.41 (referencia en los `@see` de los modelos; no se negocia explícitamente) |
22
+ | versión de API | v1.41 (referencia en los `@see` de los modelos; no se negocia explícitamente). **La gema no fija `?version=`** → el daemon sirve su versión **máxima**, así que la versión efectiva la decide cada host. Piso relevante: un Engine 20.10 topa en **v1.41**. Techo del parque: `unknown` (no derivable de este repo). Consecuencia en los logs: ver §b |
23
23
  | ancla | doc oficial: <https://docs.docker.com/engine/api/v1.41/> |
24
24
 
25
25
  #### b. Operaciones consumidas
@@ -38,22 +38,22 @@ Subset que la gema invoca, derivado de `Api::ENDPOINTS` (`api.rb:5-72`). `destin
38
38
  | nodes | update | `POST nodes/%<id>s/update` | `?version=` + payload / — |
39
39
  | nodes | destroy | `DELETE nodes/%<id>s` | — / — |
40
40
  | tasks | index / show | `GET tasks`, `GET tasks/%<id>s` | `?filters=` / array \| Hash |
41
- | tasks | logs | `GET tasks/%<id>s/logs` | `?stdout/stderr/...` / stream raw |
42
- | services | index / show | `GET services`, `GET services/%<id>s` | `?filters=` / array \| Hash |
41
+ | tasks | logs | `GET tasks/%<id>s/logs` | `?stdout/stderr/...` / stream multiplexado (demux en el cliente) |
42
+ | services | index / show | `GET services`, `GET services/%<id>s` | `?filters=` + `?status=` (query propio, **no** filtro; ≥ v1.41) / array \| Hash; con `?status=true` cada elemento trae `ServiceStatus` |
43
43
  | services | create | `POST services/create` | payload (Spec aplanado) + header `X-Registry-Auth` (opcional, registry privado) / `{ID}` |
44
44
  | services | update | `POST services/%<id>s/update` | `?version=` (+ `?registryAuthFrom=` opcional: `spec`\|`previous-spec`) + payload + header `X-Registry-Auth` (opcional; excluyente con `registryAuthFrom`) / — |
45
45
  | services | destroy | `DELETE services/%<id>s` | — / — |
46
- | services | logs | `GET services/%<id>s/logs` | `?stdout/stderr/...` / stream raw |
46
+ | services | logs | `GET services/%<id>s/logs` | `?stdout/stderr/...` / stream multiplexado (demux en el cliente) |
47
47
  | configs | index / show / create / destroy | `GET configs`, `GET configs/%<id>s`, `POST configs/create`, `DELETE configs/%<id>s` | payload en create / `{ID}` |
48
48
  | secrets | index / show / create / destroy | `GET secrets`, `GET secrets/%<id>s`, `POST secrets/create`, `DELETE secrets/%<id>s` | payload en create (`Data` filtrado en logs) / `{ID}` |
49
49
  | networks | index / show / create / update / destroy | `GET/POST networks...`, `POST networks/%<id>s/update`, `DELETE networks/%<id>s` | payload / `{ID}` |
50
50
  | volumes | index / show / create / destroy | `GET volumes`, `GET volumes/%<id>s`, `POST volumes/create`, `DELETE volumes/%<id>s` | payload / respuesta wrapped en `Volumes` |
51
51
  | containers | index | `GET containers/json` | `?filters=` / array |
52
52
  | containers | show | `GET containers/%<id>s/json` | — / Hash |
53
- | containers | create | `POST containers/create` | payload / `{Id}` |
53
+ | containers | create | `POST containers/create` | `?name=` (query, NO en el body) + payload / `{Id}` |
54
54
  | containers | start / stop | `POST containers/%<id>s/start`, `POST containers/%<id>s/stop` | — / — |
55
55
  | containers | destroy | `DELETE containers/%<id>s` | — / — |
56
- | containers | logs | `GET containers/%<id>s/logs` | `?stdout/stderr/...` / stream raw |
56
+ | containers | logs | `GET containers/%<id>s/logs` | `?stdout/stderr/...` / stream multiplexado (demux en el cliente) |
57
57
  | images | index | `GET images/json` | — / array |
58
58
  | images | show | `GET images/%<id>s/json` | — / Hash |
59
59
  | images | pull | `POST images/create?fromImage=<ref>` | header `X-Registry-Auth` (opcional, registry privado) / stream NDJSON de progreso |
@@ -61,6 +61,32 @@ Subset que la gema invoca, derivado de `Api::ENDPOINTS` (`api.rb:5-72`). `destin
61
61
 
62
62
  Serialización: request body no-String → JSON (`Content-Type: application/json`) vía `Middleware::RequestEncoder`; response `application/json` → `HashWithIndifferentAccess` recursivo vía `Middleware::ResponseJSONParser`.
63
63
 
64
+ **Query params propios vs. `?filters=` (listados).** El Engine parte los parámetros de un listado en dos: query params propios de la ruta (`?limit=`, `?all=`, `?status=`) y el JSON de `?filters=`. La gema declara los primeros en `Base.index_query_params` (override por modelo); **todo lo demás se serializa como filtro**. Un query param propio no declarado ahí no llega nunca: viaja dentro del JSON de `filters` y el Engine lo rechaza (si valida la clave) o lo ignora.
65
+
66
+ Los nombres **no** son globales — el mismo `status` es query param propio en `/services` y filtro válido en `/containers/json` (`running`, `exited`, …). De ahí que la lista sea por modelo y no una whitelist única.
67
+
68
+ **`?status=true` en `/services`** (API ≥ v1.41) agrega a cada elemento:
69
+
70
+ ```json
71
+ { "ServiceStatus": { "RunningTasks": 2, "DesiredTasks": 3, "CompletedTasks": 0 } }
72
+ ```
73
+
74
+ Es la **única** superficie donde el Engine publica el deseado de un service en modo `global`: un replicado lo expone en `Spec.Mode.Replicated.Replicas`, pero para un global ese campo no existe (el deseado es "una task por nodo elegible"). Sin `DesiredTasks` no hay número contra el cual comparar las tasks que corren.
75
+
76
+ Como la gema no fija `?version=`, la versión efectiva la decide el host (ver arriba): en un Engine por debajo de v1.41 el parámetro se **ignora sin error** y `ServiceStatus` llega ausente. El consumidor tiene que tolerar `nil` — no hay señal de "no soportado".
77
+
78
+ **`DesiredTasks: 0` no significa "sin nodos elegibles".** En un service `global` recién creado el Engine publica el objeto con los tres contadores en **0** y recién después evalúa los nodos (medido contra un Engine `1.54`, swarm de un nodo: `DesiredTasks` pasa de 0 a 1 en ~1s). O sea que hay una ventana en la que `RunningTasks == DesiredTasks == 0` — un consumidor que compare los dos números lee "sano" (0 de 0) o "degradado total" según cómo ordene la comparación, y en ninguno de los dos casos es cierto: el deseado todavía no está calculado. Para decidir salud hace falta `DesiredTasks.positive?` como precondición, no como resultado.
79
+
80
+ **Streams de logs (`containers`/`services`/`tasks` → `logs`).** Sin TTY el Engine multiplexa: 8 bytes de cabecera por frame (1 tipo de stream · 3 de relleno en cero · 4 de tamaño big-endian). `Middleware::LogStreamDemuxer` los saca, así que `Loggable#logs` entrega texto limpio.
81
+
82
+ El `Content-Type` **no alcanza** para decidir. `application/vnd.docker.multiplexed-stream` existe **desde la API v1.42**; su entrada de changelog dice, textual:
83
+
84
+ > `GET /containers/{id}/attach`, `GET /exec/{id}/start`, `GET /containers/{id}/logs` `GET /services/{id}/logs` and `GET /tasks/{id}/logs` now set Content-Type header to `application/vnd.docker.multiplexed-stream` when a multiplexed stdout/stderr stream is sent to client, `application/vnd.docker.raw-stream` otherwise.
85
+
86
+ Antes de v1.42 ese valor no existía y un stream multiplexado viajaba igual como `application/vnd.docker.raw-stream`. Como la gema no fija `?version=`, un Engine 20.10 (tope v1.41) devuelve `raw-stream` **con** framing. Por eso el middleware, ante `raw-stream`, decide por la **forma del frame** —recorre el body entero y solo demultiplexa si la cadena de frames cierra de punta a punta— en vez de confiar en el header. Ante cualquier inconsistencia deja el body intacto.
87
+
88
+ > Anclaje: <https://docs.docker.com/reference/api/engine/version-history/> (entrada de v1.42).
89
+
64
90
  #### d. Errores del proveedor → excepción nuestra
65
91
 
66
92
  El daemon responde con status HTTP; `Middleware::ErrorHandler` los mapea a la jerarquía `DockerSwarm::Error`. La tabla completa status→excepción está en [`docs/errors/errors.md`](../errors/errors.md) §b (este artefacto **referencia**, no la redefine).
@@ -1,6 +1,6 @@
1
1
  # Glosario — docker-swarm
2
2
 
3
- > meta: artefacto · RFC-009 · generado dev-enrich · anclado a `8f2e1f7` · cobertura: completo inicial (primitivas Docker + arquitectura interna); no se acrecienta sin tocar el flujo/concepto
3
+ > meta: artefacto · RFC-009 · generado dev-enrich · anclado a `v0.9.0` · cobertura: completo inicial (primitivas Docker + arquitectura interna); no se acrecienta sin tocar el flujo/concepto
4
4
 
5
5
  ## 1. Resumen
6
6
 
@@ -25,7 +25,7 @@ Unidad de ejecución de un Service en un Node específico. Read-only: las tasks
25
25
 
26
26
  ## Container
27
27
 
28
- Container Docker standalone (no Swarm). La gema expone start/stop/destroy/logs sobre containers existentes. Creación intencionalmente fuera de scope F1 — el caso de uso primario de la gema es Swarm.
28
+ Container Docker standalone (no Swarm). La gema expone create/start/stop/destroy/logs. La creación estuvo fuera de scope en F1 —el caso de uso primario de la gema es Swarm— y entró con ADR-025 cláusula 1: operar datos on-host durante una migración necesita un **helper container efímero** con nombre determinista, que es lo que habilita adoptarlo en un reintento en vez de duplicarlo.
29
29
  **Binding:** [`DockerSwarm::Container`](../../lib/docker_swarm/models/container.rb)
30
30
 
31
31
  ## Image
@@ -126,7 +126,6 @@ Transformación interna que prepara un modelo para enviarlo al API: descarta atr
126
126
 
127
127
  | Término | Inferencia | Confidence | Verificar |
128
128
  |---|---|---|---|
129
- | Container | "creación intencionalmente fuera de scope F1" | inferred | ¿se quiere documentar como decisión explícita o como gap a cubrir? |
130
129
  | Spec deep_merge | "razón: updates parciales no pierden campos" | declared | confirmado en CLAUDE.md decisión arquitectura |
131
130
  | Dynamic Accessor | "Docker evoluciona y agrega campos" | declared | confirmado en CLAUDE.md decisión arquitectura |
132
131
 
@@ -138,4 +137,5 @@ Transformación interna que prepara un modelo para enviarlo al API: descarta atr
138
137
  - **Fuera de alcance:**
139
138
  - Términos técnicos puros sin significado de negocio (ej: `instance_values`, `attr_accessor`) — son detalles de implementación, no contrato.
140
139
  - Glossary del Docker Engine API (cómo funciona internamente Swarm, raft, gossip) — vive en docs de Docker, no se duplica acá.
140
+ - **Inferencia resuelta (2026-08-03):** §3 registraba como `inferred` la pregunta de si *"creación intencionalmente fuera de scope F1"* era una decisión de alcance o un gap a cubrir. Quedó resuelta: **era una decisión de alcance** (Swarm-first), y ADR-025 cláusula 1 **amplió el alcance** al aparecer un caso de uso real (el helper container efímero de la migración del ACS). La fila salió de §3 porque ya no es una inferencia pendiente.
141
141
  - **Cadencia:** incremental por PR a partir de acá; ausencia ≠ inexistencia (RFC-009).
@@ -1,6 +1,6 @@
1
1
  # Interfaz — docker-swarm
2
2
 
3
- > meta: artefacto · RFC-004 · generado arch-structure · anclado a `29856f1` · cobertura: API Ruby pública de la gema (`lib/docker_swarm/**`); símbolos internos marcados en §4
3
+ > meta: artefacto · RFC-004 · generado arch-structure · anclado a `v0.10.0` · cobertura: API Ruby pública de la gema (`lib/docker_swarm/**`); símbolos internos marcados en §4
4
4
 
5
5
  ## 1. Resumen
6
6
 
@@ -15,7 +15,7 @@ Proyección RBS-conceptual: `símbolo · tipo · nota` (raíz → profundidad
15
15
  | símbolo | tipo | nota |
16
16
  |---|---|---|
17
17
  | `DockerSwarm` | módulo | namespace raíz |
18
- | `DockerSwarm::VERSION` | constante | `"0.8.0"` (`version.rb`) |
18
+ | `DockerSwarm::VERSION` | constante | `"0.10.0"` (`version.rb`) |
19
19
  | `DockerSwarm.configuration` | attr (r/w) | instancia de `Configuration`; lazy-init en `configure`/`connection` |
20
20
  | `DockerSwarm.configure { \|config\| ... }` | método de módulo | crea/yields `Configuration`; aplica `log_level` al logger; resetea la conexión memoizada |
21
21
  | `DockerSwarm.connection` | método de módulo | `Connection` memoizada (auto-`configure` si falta) |
@@ -48,6 +48,7 @@ Inventario completo de opciones: [`docs/config/configuracion.md`](../config/conf
48
48
  | `.all(filters = {})` | método de clase | `GET index`; mapea a instancias; aplica `root_key`; `[]` si vacío |
49
49
  | `.find(id)` | método de clase | `GET show`; `nil` si `Errors::NotFound` |
50
50
  | `.where(filters)` | método de clase | alias de `all` |
51
+ | `.index_query_params` | método de clase | `%i[all force limit since before]` por default; override por modelo (`+ :status` en `Service`). Claves que viajan como **query params propios** del listado; **todo lo no declarado se serializa dentro del JSON de `?filters=`** — si tampoco es filtro válido del recurso, el Engine lo rechaza o lo ignora |
51
52
  | `#initialize(attributes = {})` | método de instancia | `assign_attributes` si presente |
52
53
  | `#assign_attributes(new_attributes)` | método de instancia | normaliza `Id`→`ID`; `deep_merge` del campo `Spec`; `ArgumentError` si no es Hash |
53
54
  | `#attributes` | método de instancia | `instance_values` sin internos de ActiveModel |
@@ -63,21 +64,23 @@ Inventario completo de opciones: [`docs/config/configuracion.md`](../config/conf
63
64
  | símbolo | tipo | nota |
64
65
  |---|---|---|
65
66
  | `Concerns::Creatable.create(attributes = {}, **opts)` | método de clase (mixin) | `new` + `save`; retorna la instancia. `opts` reservado `registry_auth:` (→ header `X-Registry-Auth`); el resto se pliega como atributos |
66
- | `Concerns::Creatable#save(registry_auth: nil)` | método de instancia | `false` si `!valid?`; `update` si `persisted?`; si no `POST create` + `reload`. `registry_auth` viaja como header, nunca en el payload |
67
+ | `Concerns::Creatable#save(registry_auth: nil)` | método de instancia | `false` si `!valid?`; `update` si `persisted?`; si no `POST create` + `reload`. `registry_auth` viaja como header, nunca en el payload. Los `create_query_params` del modelo viajan por query string y se **excluyen** del payload |
68
+ | `Concerns::Creatable.create_query_params` | método de clase (mixin) | `[]` por default; override por modelo. Atributos que el Engine toma por query string en el `create` y **descarta en silencio** si van en el body |
69
+ | `Concerns::Creatable#query_params_for_docker` | método de instancia | los `create_query_params` seteados en esta instancia, con claves símbolo; `{}` si el modelo no declara ninguno |
67
70
  | `Concerns::Updatable#update(new_attributes = {}, **opts)` | método de instancia | extrae `Version.Index`; `false` si `!valid?`; `POST update` con `?version=`. `opts` reservado `registry_auth:` (header) / `registry_auth_from:` (query `registryAuthFrom`, `spec`\|`previous-spec`, excluyente con `registry_auth`); el resto se pliega como atributos |
68
71
  | `Concerns::Deletable.destroy(id)` | método de clase (mixin) | `DELETE destroy`; `nil` si `Errors::NotFound` |
69
72
  | `Concerns::Deletable#destroy` | método de instancia | delega en `.destroy(self.ID)` |
70
- | `Concerns::Loggable#logs(query_params = { stdout: 1, stderr: 1 })` | método de instancia | `GET logs`; retorna el stream raw |
73
+ | `Concerns::Loggable#logs(query_params = { stdout: 1, stderr: 1 })` | método de instancia | `GET logs`; retorna **texto ya demultiplexado** (sin los 8 bytes de cabecera por frame) — el demux lo hace `Middleware::LogStreamDemuxer`, el consumidor no ve el framing. Un stream de TTY (sin framing) pasa intacto |
71
74
  | `Concerns::Inspectable#inspect` | método de instancia | render legible (ID/Name/Version/Spec) |
72
75
 
73
76
  ### Modelos (`models/*.rb`)
74
77
 
75
78
  | símbolo | tipo | concerns + métodos propios |
76
79
  |---|---|---|
77
- | `DockerSwarm::Service` | clase < Base | Creatable, Updatable, Deletable, Loggable; `#restart` (incrementa `TaskTemplate.ForceUpdate`); `create`/`update` aceptan `registry_auth:` (+ `update`: `registry_auth_from:`) para auth de registry privado |
80
+ | `DockerSwarm::Service` | clase < Base | Creatable, Updatable, Deletable, Loggable; `#restart` (incrementa `TaskTemplate.ForceUpdate`); `create`/`update` aceptan `registry_auth:` (+ `update`: `registry_auth_from:`) para auth de registry privado; `.index_query_params` agrega `:status` → `where(status: true)` puebla `ServiceStatus` (`RunningTasks`/`DesiredTasks`/`CompletedTasks`), único lugar donde el Engine publica el deseado de un service `global` |
78
81
  | `DockerSwarm::Node` | clase < Base | Updatable, Deletable (sin `create`: los nodos se unen fuera de la gema) |
79
82
  | `DockerSwarm::Task` | clase < Base | Loggable (read-only; generadas por el orquestador) |
80
- | `DockerSwarm::Container` | clase < Base | Deletable, Loggable; `#start`, `#stop` (sin `create`: gap intencional) |
83
+ | `DockerSwarm::Container` | clase < Base | Creatable, Deletable, Loggable; `#start`, `#stop`; `.create_query_params == %w[name]` (el Engine toma el nombre por query string — en el body lo descarta en silencio y el container nace con nombre aleatorio). El `create` **no** es gap intencional desde ADR-025 cláusula 1 |
81
84
  | `DockerSwarm::Image` | clase < Base | Deletable + `.pull(image_reference, registry_auth: nil)`. **NO** es Creatable (`Image.create` retirado sin alias). `.pull` = pull explícito síncrono: consume el stream NDJSON hasta EOF, eleva `DockerSwarm::Error` ante frame `error`/`errorDetail`, retorna `{ status: :pulled, image_ref:, digest? }` (sin `find` posterior) |
82
85
  | `DockerSwarm::Network` | clase < Base | Creatable, Updatable, Deletable |
83
86
  | `DockerSwarm::Volume` | clase < Base | Creatable, Deletable; `.root_key = "Volumes"` (respuesta wrapped) |
@@ -99,7 +102,8 @@ Inventario completo de opciones: [`docs/config/configuracion.md`](../config/conf
99
102
  | `DockerSwarm::RegistryAuth.resolve(registry_auth:, registry_auth_from:)` | método de módulo | traduce las opciones de auth a `[headers, query_params]` (`X-Registry-Auth` / `registryAuthFrom`); valida exclusión mutua + enum antes de la request. Usado por `Image.pull` / `#save` / `#update`; la credencial nunca toca payload ni estado del modelo |
100
103
  | `DockerSwarm::RegistryAuth::{HEADER, QUERY, FROM_VALUES}` | constantes | `"X-Registry-Auth"` · `:registryAuthFrom` · `%w[spec previous-spec]` |
101
104
  | `DockerSwarm::Error` + subclases + aliases + `DockerSwarm::Errors` | clases/módulo | jerarquía de errores — detalle en [`docs/errors/errors.md`](../errors/errors.md) |
102
- | `DockerSwarm::Middleware::{RequestEncoder, ResponseJSONParser, ErrorHandler}` | clases | middlewares Excon; públicos por require pero de uso interno (ver §4) |
105
+ | `DockerSwarm::Middleware::{RequestEncoder, LogStreamDemuxer, ResponseJSONParser, ErrorHandler}` | clases | middlewares Excon; públicos por require pero de uso interno (ver §4) |
106
+ | `DockerSwarm::Middleware::LogStreamDemuxer::{MULTIPLEXED_CONTENT_TYPE, RAW_CONTENT_TYPE, HEADER_SIZE, STREAM_TYPES}` | constantes | `"application/vnd.docker.multiplexed-stream"` · `"application/vnd.docker.raw-stream"` · `8` · `[0, 1, 2]` |
103
107
 
104
108
  ## 3. Inferencias
105
109
 
@@ -1,6 +1,6 @@
1
1
  # Release — docker-swarm
2
2
 
3
- > meta: artefacto · RFC-014 · generado arch-structure + enriquecido arch-enrich · anclado a `cccfe63` · cobertura: §a estructura completa (versión · changelog · build-trigger · patrón); §b enrich completa (deploy · rollback · ambientes · dueño)
3
+ > meta: artefacto · RFC-014 · generado arch-structure + enriquecido arch-enrich · anclado a `v0.10.0` · cobertura: §a estructura completa (versión · changelog · build-trigger · patrón); §b enrich completa (deploy · rollback · ambientes · dueño)
4
4
 
5
5
  ## 1. Resumen
6
6
 
@@ -12,7 +12,7 @@ Gema Ruby publicada en RubyGems. Release por **tag `v*`**: el push del tag dispa
12
12
 
13
13
  | campo | valor | fuente |
14
14
  |---|---|---|
15
- | versión actual | `0.7.2` | `lib/docker_swarm/version.rb` (`DockerSwarm::VERSION`) |
15
+ | versión actual | `0.10.0` | `lib/docker_swarm/version.rb` (`DockerSwarm::VERSION`) |
16
16
  | esquema de versión | SemVer (`MAJOR.MINOR.PATCH`) | `CHANGELOG.md` (breaking/mejoras/correcciones por bump) |
17
17
  | artefacto liberado | gema `docker-swarm` a RubyGems | `docker-swarm.gemspec` (`spec.name`), `.github/workflows/release.yml` |
18
18
  | changelog | `CHANGELOG.md`, formato Keep a Changelog, entradas fechadas por versión | `CHANGELOG.md` |
data/docs/test/testing.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Test — docker-swarm
2
2
 
3
- > meta: artefacto · RFC-013 · generado arch-structure + enriquecido arch-enrich · anclado a `8f2e1f7` · cobertura: estructura de la suite (`spec/`, `.github/workflows/main.yml`); §e enriquecida, §f enriquecida, §g `unknown` (sin incidentes registrados), §h enriquecida
3
+ > meta: artefacto · RFC-013 · generado arch-structure + enriquecido arch-enrich · anclado a `v0.10.0` · cobertura: estructura de la suite (`spec/`, `.github/workflows/main.yml`); §e enriquecida, §f enriquecida, §g `unknown` (sin incidentes registrados), §h enriquecida
4
4
 
5
5
  ## 1. Resumen
6
6
 
@@ -15,7 +15,7 @@ Framework: **RSpec** (`~> 3.0`). `verify_partial_doubles = true` (mocks estricto
15
15
  | subdirectorio | propósito | nivel | helper |
16
16
  |---|---|---|---|
17
17
  | `spec/docker/swarm/*_spec.rb` | api, configuration, connection, log_helper, registry_auth | unit | `spec_helper` |
18
- | `spec/docker/swarm/middleware/*_spec.rb` | error_handler, request_encoder, response_json_parser | unit | `spec_helper` |
18
+ | `spec/docker/swarm/middleware/*_spec.rb` | error_handler, log_stream_demuxer, request_encoder, response_json_parser | unit | `spec_helper` |
19
19
  | `spec/docker/swarm/models/*_spec.rb` | base, container, image, network, node, service, task + `shared_crud_spec` | unit | `spec_helper` |
20
20
  | `spec/integration/*_spec.rb` | containers, infra, security, services, system | integration | `integration_helper` |
21
21
 
@@ -54,7 +54,8 @@ Ninguna. No hay `SimpleCov`/`.simplecov` ni umbral declarado en el repo (verific
54
54
  - CRUD genérico de `config`, `secret`, `volume`: vía `shared_crud_spec` (`it_behaves_like "a crud resource"`) — no tienen spec dedicado pero **sí** están cubiertos (create/find/destroy). `image` salió del CRUD genérico (su `create` era un pull) → tiene spec propio (abajo).
55
55
  - `image`: `image_spec` (dedicado) — `Image.pull` (stream NDJSON, extracción de digest del frame `Digest:`, error tipado ante `error`/`errorDetail`, forma polimórfica del body) + `Deletable` y listado.
56
56
  - Auth de registry privado: `registry_auth_spec` (helper `RegistryAuth`: exclusión mutua `registry_auth`/`registry_auth_from`, enum del `from`, traducción a header/query) + bloque registry-auth en `service_spec` (create/update, no-exposición de la credencial en logs).
57
- - Infra de transporte: `api_spec`, `connection_spec`, `configuration_spec`, `log_helper_spec`, los 3 middleware specs.
57
+ - Partición query params propios vs. `?filters=` del listado (`index_query_params`): `base_spec` (default, override, partición mixta), `service_spec` (`status: true` → query param; `ServiceStatus` expuesto y tolerancia a su ausencia), `container_spec` (**regresión**: `status` sigue viajando como filtro). Integration: `services_spec` verifica contra el daemon que `ServiceStatus` aparece **solo** con `status: true`, y un `context` en modo **`global`** pinnea el caso que justifica la feature — `DesiredTasks` legible donde `Spec.Mode.Replicated` no existe. Ese context es el que vuelve necesario el poll del helper `listed_with_status`: en un global el deseado arranca en 0 y el Engine lo completa después (~1s), así que la condición de corte es `DesiredTasks.positive?`, no `ServiceStatus.present?`.
58
+ - Infra de transporte: `api_spec`, `connection_spec`, `configuration_spec`, `log_helper_spec`, los 4 middleware specs.
58
59
  - `swarm`, `system` (singletons): `swarm_spec`, `system_spec`.
59
60
 
60
61
  **Cubierto (integration, daemon real):** lifecycle de containers, services, infra (networks/volumes), system (info/version/up/df), security (config/secret create+find+destroy).
@@ -46,14 +46,33 @@ module DockerSwarm
46
46
  all(filters)
47
47
  end
48
48
 
49
+ # Claves que el Engine toma como **query params propios** del listado
50
+ # (`?limit=`, `?all=`, …), no como *filters*.
51
+ #
52
+ # Todo lo que NO esté declarado acá se serializa dentro del JSON de `?filters=`.
53
+ # Si además no es un filtro válido del recurso, el Engine lo rechaza o lo ignora:
54
+ # el parámetro **no llega** y no hay forma de pedirlo desde la gema.
55
+ #
56
+ # Override en el modelo que declare uno propio (p. ej. `status` en
57
+ # {DockerSwarm::Service}). La lista **no** se generaliza acá por comodidad: un
58
+ # mismo nombre puede ser query param en un recurso y filtro legítimo en otro
59
+ # (`status` lo es en `/containers/json`), y globalizarlo lo saca del `?filters=`
60
+ # donde ese otro recurso lo necesita.
61
+ #
62
+ # @return [Array<Symbol>] nombres de query param
63
+ def index_query_params
64
+ %i[all force limit since before].freeze
65
+ end
66
+
49
67
  private
50
68
 
51
69
  def _fetch_all(filters = {})
52
70
  query = {}
53
71
 
54
72
  if filters.present?
55
- global_params = filters.slice(:all, :force, :limit, :since, :before)
56
- docker_filters = filters.except(:all, :force, :limit, :since, :before)
73
+ query_keys = index_query_params
74
+ global_params = filters.slice(*query_keys)
75
+ docker_filters = filters.except(*query_keys)
57
76
 
58
77
  query = global_params
59
78
 
@@ -15,6 +15,15 @@ module DockerSwarm
15
15
  resource.save(registry_auth: registry_auth)
16
16
  resource
17
17
  end
18
+
19
+ # Atributos que el Engine toma por **query string** en el +create+, no en el body.
20
+ # Un atributo declarado acá viaja en la URL y se excluye del payload: mandarlo en
21
+ # el body no le da error a Docker, lo **descarta en silencio**.
22
+ # Override en el modelo que lo necesite (p. ej. +name+ en {DockerSwarm::Container}).
23
+ # @return [Array<String>] nombres de atributo
24
+ def create_query_params
25
+ [].freeze
26
+ end
18
27
  end
19
28
 
20
29
  # @param registry_auth [String, nil] credencial opaca para el header X-Registry-Auth
@@ -25,7 +34,8 @@ module DockerSwarm
25
34
  headers, = RegistryAuth.resolve(registry_auth: registry_auth)
26
35
  response = Api.request(
27
36
  action: self.class.routes[:create],
28
- payload: payload_for_docker,
37
+ query_params: query_params_for_docker,
38
+ payload: payload_for_docker.except(*self.class.create_query_params),
29
39
  headers: headers
30
40
  )
31
41
 
@@ -33,6 +43,15 @@ module DockerSwarm
33
43
  reload
34
44
  true
35
45
  end
46
+
47
+ # Los +create_query_params+ que este recurso tiene seteados, listos para la URL.
48
+ # @return [Hash{Symbol => Object}] vacío si el modelo no declara ninguno
49
+ def query_params_for_docker
50
+ keys = self.class.create_query_params
51
+ return {} if keys.empty?
52
+
53
+ attributes.slice(*keys).compact.symbolize_keys
54
+ end
36
55
  end
37
56
  end
38
57
  end
@@ -96,6 +96,7 @@ module DockerSwarm
96
96
  Excon.defaults[:middlewares] + [
97
97
  Excon::Middleware::RedirectFollower,
98
98
  Middleware::RequestEncoder,
99
+ Middleware::LogStreamDemuxer,
99
100
  Middleware::ResponseJSONParser,
100
101
  Middleware::ErrorHandler
101
102
  ]
@@ -105,9 +106,17 @@ module DockerSwarm
105
106
  # NO habilitamos el debug de Excon ni le pasamos el logger. El instrumentor
106
107
  # de Excon redacta solo Authorization/Proxy-Authorization, NUNCA headers de
107
108
  # autenticación custom (p. ej. X-Registry-Auth) → filtraría esa credencial.
108
- # Nuestro #log_event ya loguea request/response con redacción recursiva
109
- # (LogHelper.sanitize). Para wire-debug explícito y consciente del riesgo queda
110
- # EXCON_DEBUG (mecanismo nativo de Excon, off por defecto).
109
+ #
110
+ # Nuestro #log_event loguea request/response pasando por
111
+ # {LogHelper.sanitize}, que cubre DOS formas: la clave de hash sensible
112
+ # (headers anidados) y el `"CLAVE=VALOR"` dentro de un String (el `Env` de
113
+ # un ContainerSpec, que es un array de strings). Lo que NO cubre —y hay que
114
+ # tenerlo presente antes de sumar un logueo nuevo— es un secreto embebido
115
+ # en texto libre sin la forma `CLAVE=VALOR`: ahí el nombre de la clave no
116
+ # aparece y no hay por dónde reconocerlo.
117
+ #
118
+ # Para wire-debug explícito y consciente del riesgo queda EXCON_DEBUG
119
+ # (mecanismo nativo de Excon, off por defecto).
111
120
  options = {
112
121
  middlewares: common_middlewares,
113
122
  retry_limit: 0
@@ -9,12 +9,27 @@ module DockerSwarm
9
9
  SENSITIVE_KEYS = /password|pass|passwd|secret|token|api_key|auth|\bdata\b/i.freeze
10
10
  FILTERED = "[FILTERED]"
11
11
 
12
- # Redacta recursivamente los valores cuya CLAVE es sensible, a cualquier
13
- # profundidad (hashes y arrays anidados). No muta la entrada: devuelve copias.
12
+ # Un elemento de `Env` de Docker: `"CLAVE=VALOR"`. El `[^=]+` a la izquierda
13
+ # evita partir en un `=` que pertenezca al valor (los valores base64 y las
14
+ # URLs los traen), y `/m` cubre un valor multilínea — una clave PEM pasada
15
+ # por variable de entorno.
16
+ KV_STRING = /\A([^=]+)=(.+)\z/m
17
+
18
+ # Redacta recursivamente los valores sensibles, a cualquier profundidad
19
+ # (hashes y arrays anidados). No muta la entrada: devuelve copias.
20
+ #
21
+ # Cubre DOS formas, porque el nombre de un secreto no siempre es una clave
22
+ # de hash:
14
23
  #
15
- # Un header sensible puede viajar anidado (`headers: { "X-Registry-Auth" => "<cred>" }`)
16
- # y el match por clave de primer nivel no lo alcanzaba — el hash interno se
17
- # interpolaba entero.
24
+ # 1. **Clave de hash sensible** `headers: { "X-Registry-Auth" => "<cred>" }`.
25
+ # Un header sensible puede viajar anidado, y el match por clave de primer
26
+ # nivel no lo alcanzaba: el hash interno se interpolaba entero.
27
+ # 2. **`"CLAVE=VALOR"` dentro de un String** — el `Env` de un `ContainerSpec`
28
+ # es un ARRAY DE STRINGS, así que el nombre del secreto vive dentro del
29
+ # elemento y no como clave. Sin esto, `Env` no matchea {SENSITIVE_KEYS},
30
+ # sus elementos caen al `else`, y **el valor de todo secreto pasado por
31
+ # variable de entorno se loguea entero** — en `request_success`, o sea en
32
+ # el camino feliz, a nivel INFO.
18
33
  #
19
34
  # @param value [Object] hash, array o escalar
20
35
  # @return [Object] copia con los valores sensibles reemplazados por [FILTERED]
@@ -26,11 +41,30 @@ module DockerSwarm
26
41
  end
27
42
  when Array
28
43
  value.map { |v| sanitize(v) }
44
+ when String
45
+ redact_kv_string(value)
29
46
  else
30
47
  value
31
48
  end
32
49
  end
33
50
 
51
+ # Redacta el VALOR de un String con forma `"CLAVE=VALOR"` cuando la clave es
52
+ # sensible, conservando el nombre: saber QUÉ secreto apareció es diagnóstico
53
+ # útil, su valor no.
54
+ #
55
+ # Un String que no tiene esa forma —o cuya clave no es sensible— vuelve tal
56
+ # cual, así que `"RAILS_LOG_LEVEL=info"` y cualquier mensaje de error quedan
57
+ # intactos.
58
+ #
59
+ # @param str [String]
60
+ # @return [String] con el valor reemplazado por {FILTERED}, o el original
61
+ def self.redact_kv_string(str)
62
+ match = KV_STRING.match(str)
63
+ return str unless match && match[1].match?(SENSITIVE_KEYS)
64
+
65
+ "#{match[1]}=#{FILTERED}"
66
+ end
67
+
34
68
  # Formats a hash into a KV structured string with sensitive data masking
35
69
  # @param payload [Hash] The data to format
36
70
  # @return [String] KV formatted string
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockerSwarm
4
+ module Middleware
5
+ # Demultiplexa el stream de logs del Engine para que +Concerns::Loggable#logs+
6
+ # devuelva texto limpio en +Container+, +Service+ y +Task+.
7
+ #
8
+ # Sin TTY el Engine enmarca cada fragmento con 8 bytes de cabecera: 1 de tipo de
9
+ # stream, 3 de relleno en cero y 4 de tamaño en big-endian. Ese framing tiene que
10
+ # morir en un middleware y no en +Loggable+: +Connection#request+ devuelve
11
+ # +response.body+ y descarta los headers, así que aguas abajo ya no queda
12
+ # +Content-Type+ con el que decidir. Ver ADR-025 cláusula 3.
13
+ #
14
+ # @see https://docs.docker.com/engine/api/v1.41/#tag/Container/operation/ContainerAttach
15
+ class LogStreamDemuxer < Excon::Middleware::Base
16
+ # Content-Type que **afirma** el framing. Existe desde la API v1.42.
17
+ MULTIPLEXED_CONTENT_TYPE = "application/vnd.docker.multiplexed-stream"
18
+ # Content-Type ambiguo: con TTY no hay framing, pero antes de v1.42 era el único
19
+ # que existía y también viajaba en streams multiplexados.
20
+ RAW_CONTENT_TYPE = "application/vnd.docker.raw-stream"
21
+
22
+ # Tamaño de la cabecera de frame, en bytes.
23
+ HEADER_SIZE = 8
24
+ # Valores válidos del byte 0: stdin, stdout, stderr.
25
+ STREAM_TYPES = [ 0, 1, 2 ].freeze
26
+
27
+ def response_call(env)
28
+ demux!(env) if env[:response]
29
+
30
+ @stack.response_call(env)
31
+ end
32
+
33
+ private
34
+
35
+ def demux!(env)
36
+ body = env[:response][:body]
37
+ return unless body.is_a?(String)
38
+ return if body.empty?
39
+
40
+ content_type = (env[:response][:headers] || {})["Content-Type"]
41
+ return if content_type.nil?
42
+
43
+ # Sobre +raw-stream+ no alcanza el Content-Type para descartar el framing: la
44
+ # gema no fija +?version=+ (habla la versión máxima del Engine) y un nodo del
45
+ # parque puede topar en v1.41, donde un stream multiplexado llega igual con
46
+ # este Content-Type. Ahí decide la forma del frame, no el header.
47
+ return unless content_type.include?(MULTIPLEXED_CONTENT_TYPE) ||
48
+ content_type.include?(RAW_CONTENT_TYPE)
49
+
50
+ demuxed = demux(body)
51
+ env[:response][:body] = demuxed unless demuxed.nil?
52
+ end
53
+
54
+ # Recorre el body entero como cadena de frames y concatena las cargas en orden.
55
+ #
56
+ # Es todo-o-nada a propósito: alcanza **una** inconsistencia —tipo de stream fuera
57
+ # de rango, relleno distinto de cero, un tamaño que se pasa del buffer, una cola
58
+ # suelta— para devolver +nil+ y dejar el body intacto. Un log de TTY tendría que
59
+ # ser una cadena perfecta de frames válidos de punta a punta para confundirse.
60
+ #
61
+ # @param body [String] el body crudo tal como vino del Engine
62
+ # @return [String, nil] el texto sin cabeceras, o +nil+ si el body no está enmarcado
63
+ def demux(body)
64
+ bytes = body.b
65
+ size = bytes.bytesize
66
+ offset = 0
67
+ out = +""
68
+
69
+ while offset < size
70
+ return nil if size - offset < HEADER_SIZE
71
+
72
+ stream_type, pad_a, pad_b, pad_c, length =
73
+ bytes.byteslice(offset, HEADER_SIZE).unpack("C4N")
74
+
75
+ return nil unless STREAM_TYPES.include?(stream_type)
76
+ return nil unless pad_a.zero? && pad_b.zero? && pad_c.zero?
77
+
78
+ offset += HEADER_SIZE
79
+ return nil if size - offset < length
80
+
81
+ out << bytes.byteslice(offset, length)
82
+ offset += length
83
+ end
84
+
85
+ out.force_encoding(Encoding::UTF_8)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -4,9 +4,19 @@ module DockerSwarm
4
4
  # Represents a Docker Container
5
5
  # @see https://docs.docker.com/engine/api/v1.41/#tag/Container
6
6
  class Container < Base
7
+ include Concerns::Creatable
7
8
  include Concerns::Deletable
8
9
  include Concerns::Loggable
9
10
 
11
+ # +POST /containers/create+ toma el nombre por query string. En el body Docker lo
12
+ # **descarta en silencio** y responde +201+: el container nace con nombre aleatorio
13
+ # y la adopción por nombre determinista en un reintento no encuentra nada, así que
14
+ # el reintento duplica. Ver ADR-025 cláusula 1.
15
+ # @return [Array<String>]
16
+ def self.create_query_params
17
+ %w[name].freeze
18
+ end
19
+
10
20
  # Starts the container
11
21
  # @return [Boolean] true if successful
12
22
  def start
@@ -9,6 +9,30 @@ module DockerSwarm
9
9
  include Concerns::Deletable
10
10
  include Concerns::Loggable
11
11
 
12
+ # `status` es un query param **propio** de `GET /services` (Engine API ≥ v1.41), no un
13
+ # filtro: con `?status=true` cada elemento del listado trae `ServiceStatus`
14
+ # (`RunningTasks` · `DesiredTasks` · `CompletedTasks`).
15
+ #
16
+ # Es el **único** lugar donde el Engine publica el deseado de un service en modo
17
+ # `global`. Un service replicado lo expone en `Spec.Mode.Replicated.Replicas`, pero
18
+ # para uno global ese campo no existe —el deseado es "una task por nodo elegible"—,
19
+ # así que sin `DesiredTasks` no hay número contra el cual comparar las tasks que
20
+ # corren: un global degradado *parcialmente* (2 de 3 nodos) es indistinguible de uno
21
+ # sano, y solo se detecta el caso extremo de cero tasks.
22
+ #
23
+ # Va acá y no en la whitelist de {DockerSwarm::Base} porque en `/containers/json`
24
+ # `status` **sí** es un filtro válido (`running`, `exited`, …): globalizarlo lo sacaría
25
+ # del `?filters=` y rompería `Container.where(status: "running")`.
26
+ #
27
+ # En un Engine por debajo de v1.41 el parámetro se ignora sin error y `ServiceStatus`
28
+ # llega ausente → el consumidor tiene que tolerar `nil`.
29
+ #
30
+ # @return [Array<Symbol>] Symbols (no Strings como +create_query_params+): acá se
31
+ # matchea contra las claves de +filters+.
32
+ def self.index_query_params
33
+ (super + %i[status]).freeze
34
+ end
35
+
12
36
  # Restarts the service by incrementing ForceUpdate, which causes
13
37
  # Docker to recreate all tasks.
14
38
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DockerSwarm
4
- VERSION = "0.8.0"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/docker_swarm.rb CHANGED
@@ -40,6 +40,7 @@ require_relative "docker_swarm/log_helper"
40
40
  require_relative "docker_swarm/version"
41
41
  require_relative "docker_swarm/errors"
42
42
  require_relative "docker_swarm/middleware/request_encoder"
43
+ require_relative "docker_swarm/middleware/log_stream_demuxer"
43
44
  require_relative "docker_swarm/middleware/response_json_parser"
44
45
  require_relative "docker_swarm/middleware/error_handler"
45
46
  require_relative "docker_swarm/connection"
data/skill/SKILL.md CHANGED
@@ -10,15 +10,18 @@ description: >-
10
10
  actualizar/eliminar recursos del cluster, leer logs de services/tasks/
11
11
  containers, hacer health-check del daemon (System.up/info/df), filtrar por
12
12
  labels, pullear imágenes (incl. de registries privados vía X-Registry-Auth),
13
- o capturar errores tipados de Docker (Conflict/NotFound/Communication). NO
14
- activar para builds de imágenes (no implementado) o flujos que no son Swarm
15
- (Docker Compose, raw containers).
13
+ o capturar errores tipados de Docker (Conflict/NotFound/Communication).
14
+ También cubre containers standalone (no Swarm): crear/correr/limpiar un
15
+ helper container efímero para operar datos on-host. NO activar para builds
16
+ de imágenes (no implementado) ni para Docker Compose (no parsea
17
+ `docker-compose.yml`).
16
18
  triggers:
17
19
  - "DockerSwarm::"
18
20
  - "docker-swarm gem"
19
21
  - "Docker Engine API desde Ruby"
20
22
  - "Service.create / Service.update / Service.restart"
21
- - "Container.start / Container.stop"
23
+ - "Container.create / Container.start / Container.stop"
24
+ - "helper container efímero"
22
25
  - "logs de un servicio Docker"
23
26
  - "Version.Index"
24
27
  ---
@@ -55,10 +58,10 @@ Defaults son razonables: en local sin TLS, no necesitás bloque `configure`.
55
58
 
56
59
  | Modelo | Class methods | Instance methods | Notas |
57
60
  |---|---|---|---|
58
- | `Service` | `all(filters)`, `find(id)`, `where(filters)`, `create(attrs)` | `update(attrs)`, `restart`, `destroy`, `logs(query)`, `reload`, `persisted?`, `id` | CRUD completo + force-recreate de tasks; `create`/`update` aceptan `registry_auth:` (+ `update`: `registry_auth_from:`) para auth de registry privado |
61
+ | `Service` | `all(filters)`, `find(id)`, `where(filters)`, `create(attrs)` | `update(attrs)`, `restart`, `destroy`, `logs(query)`, `reload`, `persisted?`, `id` | CRUD completo + force-recreate de tasks; `create`/`update` aceptan `registry_auth:` (+ `update`: `registry_auth_from:`) para auth de registry privado; `where(status: true)` puebla `ServiceStatus` (único deseado legible de un service `global`) |
59
62
  | `Node` | `all(filters)`, `find(id)`, `where(filters)` | `update(attrs)`, `destroy` | No `create` (los nodos se unen fuera de la gema) |
60
63
  | `Task` | `all(filters)`, `find(id)`, `where(filters)` | `logs(query)`, `reload` | Read-only (generados por orquestador) |
61
- | `Container` | `all(filters)`, `find(id)`, `where(filters)` | `start`, `stop`, `destroy`, `logs(query)` | **No `create`** (gap conocido, fuera F1) |
64
+ | `Container` | `all(filters)`, `find(id)`, `where(filters)`, `create(attrs)` | `start`, `stop`, `destroy`, `logs(query)` | `create` manda `name` por query string (`create_query_params`) — en el body Docker lo descarta en silencio |
62
65
  | `Image` | `all(filters)`, `find(id)`, `pull(image_reference, registry_auth:)` | `destroy` | **No `create`** (retirado; `Image` ya no es Creatable). `pull` = pull explícito síncrono → `{status, image_ref, digest?}`; **soporta `X-Registry-Auth`** para registries privados |
63
66
  | `Network` | `all(filters)`, `find(id)`, `create(attrs)` | `update(attrs)`, `destroy` | CRUD completo |
64
67
  | `Volume` | `all(filters)`, `find(id)`, `create(attrs)` | `destroy` | No `update` (Docker no lo soporta). Respuesta wrapped vía `root_key = "Volumes"` |
@@ -77,6 +80,11 @@ DockerSwarm::Service.all(label: ["env=production"])
77
80
  DockerSwarm::Node.all(role: ["manager"])
78
81
  DockerSwarm::Container.all(status: ["running"])
79
82
 
83
+ # Listar pidiendo ServiceStatus (query param propio, NO filtro; Engine API >= v1.41)
84
+ DockerSwarm::Service.where(status: true).each do |svc|
85
+ svc.ServiceStatus # => { "RunningTasks" => 2, "DesiredTasks" => 3, "CompletedTasks" => 0 } | nil
86
+ end
87
+
80
88
  # Lookup graceful (nil si 404)
81
89
  service = DockerSwarm::Service.find("svc-id") # => Service | nil
82
90
 
@@ -95,7 +103,7 @@ service.restart
95
103
  # Destroy graceful (nil si 404)
96
104
  service.destroy
97
105
 
98
- # Logs raw
106
+ # Logs (ya demultiplexados: sin cabeceras de frame)
99
107
  service.logs(stdout: 1, stderr: 1)
100
108
 
101
109
  # Health check
@@ -130,7 +138,10 @@ Todas heredan de `DockerSwarm::Error`. Tres formas de acceso equivalentes: `Dock
130
138
  - **Retries automáticos sólo en métodos seguros** (GET/HEAD/PUT/DELETE/OPTIONS). POST/PATCH **no** reintentan para evitar duplicados — si el socket se cae durante un `create`, el caller decide qué hacer. Ver §3.5 de `docs/behavior/behavior.md`.
131
139
  - **`Spec` se mergea con `deep_merge` en updates**, no se reemplaza. Pasale sólo los campos que cambian: `service.update(Mode: {...})`, no `service.update(Spec: {...completo})`.
132
140
  - **`assign_attributes` muta antes de validar.** Si `update` falla por `valid?` o por el API, la instancia local quedó mutada. Hacé `reload` si necesitás estado limpio.
133
- - **`Container.create` no existe** en la gema (gap intencional F1). Si necesitás crear containers standalone, usá `DockerSwarm.request(method: :post, path: "containers/create", ...)` directo.
141
+ - **`Container.create` manda el nombre por query string.** Docker **descarta en silencio** un `name:` en el body y responde `201`: el container nace con nombre aleatorio y un reintento duplica en vez de adoptar. La gema lo resuelve sola vía `Container.create_query_params == %w[name]` pero si armás el request por afuera (`DockerSwarm.request`), el `?name=` es tuyo. Ver ADR-025 cláusula 1 y §3.11 de `docs/behavior/behavior.md`.
142
+ - **`where` parte las claves en dos: query params propios vs. `?filters=`.** Solo lo declarado en `Base.index_query_params` (`%i[all force limit since before]`, más `:status` en `Service`) viaja en la URL; **todo lo demás se serializa como filtro de Docker**. Si le pasás un query param propio que el modelo no declara, no llega: va dentro de `filters` y el Engine lo rechaza o lo ignora — sin error que indique que iba en la URL. Los nombres no son globales: `status` es query param en `/services` y **filtro válido** en `/containers/json`.
143
+ - **`ServiceStatus` solo aparece con `Service.where(status: true)`**, y es el **único** lugar donde el Engine publica el deseado de un service en modo `global` (un replicado lo tiene en `Spec.Mode.Replicated.Replicas`; un global no tiene ese campo). Sin `DesiredTasks` un global corriendo en 2 de 3 nodos elegibles es indistinguible de uno sano. Requiere API ≥ v1.41 y **degrada en silencio**: la gema no fija `?version=`, así que en un Engine anterior el parámetro se ignora y `ServiceStatus` llega `nil` — no hay señal de "no soportado", el consumidor tiene que tolerarlo. Y **`DesiredTasks: 0` no es "sin nodos elegibles"**: en un `global` recién creado el Engine publica los tres contadores en 0 y calcula el deseado después (~1s, medido), así que hay una ventana donde `RunningTasks == DesiredTasks == 0` y comparar los dos números miente — tratá `DesiredTasks.positive?` como precondición para decidir salud. Ver §3.12 de `docs/behavior/behavior.md`.
144
+ - **`logs` devuelve texto ya demultiplexado.** Sin TTY el Engine enmarca cada fragmento con 8 bytes de cabecera; `Middleware::LogStreamDemuxer` los saca en `Container`, `Service` y `Task`. Dos consecuencias: **`stdout` y `stderr` vienen intercalados** en un solo String (si necesitás un dato puntual, delimitalo en origen desde el `Cmd`), y **un frame partido entre chunks no se reensambla** — el demux es todo-o-nada, así que ante cualquier inconsistencia te devuelve el body intacto en vez de texto a medias. Con `follow: 1` el body no llega completo, así que no esperes demux ahí.
134
145
  - **`Image.create` retirado (breaking).** Ya no existe (`Image` dejó de ser Creatable; el `create` estaba roto y sin consumidores). Usá `Image.pull(image_reference, registry_auth:)`.
135
146
  - **Auth de registry privado soportado** vía credencial opaca base64url en `registry_auth:` — `Image.pull(ref, registry_auth:)`, `Service.create(..., registry_auth:)` y `Service#update(..., registry_auth:` / `registry_auth_from:)`. Viaja por header `X-Registry-Auth` (o query `registryAuthFrom`: `spec`\|`previous-spec`, excluyentes); la gema no la mintea ni decodifica. Ver flujos 3.9/3.10 de `docs/behavior/behavior.md`.
136
147
  - **`destroy` es graceful con 404** (retorna `nil`), no con 409. Si el recurso está en uso, `Conflict` se propaga.
@@ -173,8 +184,10 @@ Logs salen en formato KV (`component=docker_swarm.connection event=request_succe
173
184
  - [`docs/config/configuracion.md`](docs/config/configuracion.md) — inventario de configuración runtime (7 opciones del bloque `configure`, sin env vars, ninguna secreta). El bloque de arriba es el resumen; shape/defaults/consumidores en el detalle.
174
185
  - [`docs/topology/topology.md`](docs/topology/topology.md) — dependencias runtime (3) + grafo de contexto.
175
186
  - [`docs/test/testing.md`](docs/test/testing.md) — estructura de la suite RSpec (unit + integration) y comandos de corrida.
187
+ - [`docs/release/release.md`](docs/release/release.md) — canal de publicación (tag `v*` → RubyGems) + deploy/rollback/ambientes.
176
188
  - `docs/data/` — `n/a` (gema sin DB).
177
189
  - `docs/api/` (operaciones), `docs/events/` — `n/a` (la gema no expone superficie HTTP/CLI/eventos propia; su superficie pública es la interfaz Ruby).
190
+ - `docs/security/`, `docs/multi-tenancy/`, `docs/data-lifecycle/` — `n/a` (sin authn/authz propios —la frontera auth-hacia-Docker está en `docs/consumed` §a—; gema stateless sin scope de tenant; sin persistencia/PII/retención).
178
191
 
179
192
  ## Versionado del contrato
180
193
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docker-swarm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel
@@ -113,6 +113,7 @@ files:
113
113
  - lib/docker_swarm/errors.rb
114
114
  - lib/docker_swarm/log_helper.rb
115
115
  - lib/docker_swarm/middleware/error_handler.rb
116
+ - lib/docker_swarm/middleware/log_stream_demuxer.rb
116
117
  - lib/docker_swarm/middleware/request_encoder.rb
117
118
  - lib/docker_swarm/middleware/response_json_parser.rb
118
119
  - lib/docker_swarm/models/config.rb
@@ -129,15 +130,15 @@ files:
129
130
  - lib/docker_swarm/registry_auth.rb
130
131
  - lib/docker_swarm/version.rb
131
132
  - skill/SKILL.md
132
- homepage: https://github.com/gedera/docker-swarm
133
+ homepage: https://github.com/sequre/docker-swarm
133
134
  licenses:
134
135
  - MIT
135
136
  metadata:
136
- homepage_uri: https://github.com/gedera/docker-swarm
137
- source_code_uri: https://github.com/gedera/docker-swarm
138
- changelog_uri: https://github.com/gedera/docker-swarm/blob/v0.8.0/CHANGELOG.md
139
- bug_tracker_uri: https://github.com/gedera/docker-swarm/issues
140
- documentation_uri: https://github.com/gedera/docker-swarm/blob/v0.8.0/skill/SKILL.md
137
+ homepage_uri: https://github.com/sequre/docker-swarm
138
+ source_code_uri: https://github.com/sequre/docker-swarm
139
+ changelog_uri: https://github.com/sequre/docker-swarm/blob/v0.10.0/CHANGELOG.md
140
+ bug_tracker_uri: https://github.com/sequre/docker-swarm/issues
141
+ documentation_uri: https://github.com/sequre/docker-swarm/blob/v0.10.0/skill/SKILL.md
141
142
  rubygems_mfa_required: 'true'
142
143
  rdoc_options: []
143
144
  require_paths: