docker-swarm 0.8.0 → 0.9.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: 8e27b0beec0476baca5222a41e38aa7b11998641c39cc15eeb16390343e8364b
4
+ data.tar.gz: 61faa29cc088ea4286fb864b0b2237bfd104059236e4b748168344148931ab9e
5
5
  SHA512:
6
- metadata.gz: bfbb5850f24f31b4e00e7da3a90e3ed73996304c757688736cc0dcbe52bba19738e0c4a9615a79f88912f984992d603546a4a46e40f4102f356b7e718b37e983
7
- data.tar.gz: c3d7d6bf0e27dd190bd4dfe78a4b09436f3dada89edbf8628552a7a17d0a63a66ffb49c6c62b2e923c5f58d6cd12a584ba819e12329e13ae90ad50079e8bdc1a
6
+ metadata.gz: 01b0508429e58f4878e8aa5bba874e67d57e87bf691c31ebc144da22cbeb86d89dc438f268ab85374009949e28296546747be6e0b03b718ddaa4a2df6f1e521b
7
+ data.tar.gz: bc02c9d23be1bacc5a8f7bee2e105cf0fdc90a75fa96d4257c41467bff9b7369a031c6c21644ac3cfc84159910bd10657371b2b9407d4c9bb270cf081d52af23
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.9.0] — 2026-08-03
6
+
7
+ ### Nuevas funcionalidades
8
+ - `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
9
+
10
+ ### Breaking changes
11
+ - **`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
12
+ - 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.
13
+
14
+ ### Seguridad
15
+ - **`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
16
+ - **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.
17
+
18
+ ### Otros cambios
19
+ - `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
20
+
5
21
  ## [0.8.0] — 2026-07-22
6
22
 
7
23
  ### 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 (11 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.9.0` · cobertura: 11 flujos load-bearing (8 backfill inicial + 3 nuevos: auth de registry privado, `Image.pull` síncrono, `Container.create`)
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 (11)
12
12
 
13
13
  1. `Service.create` + reload
14
14
  2. `Service.update` con `Version.Index`
@@ -20,12 +20,12 @@ 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`)
23
24
 
24
25
  ### No documentados (ausencia ≠ inexistencia, RFC-007)
25
26
 
26
27
  - Reconexión / reapertura de socket Unix (Excon nativo, fuera de nuestra superficie).
27
28
  - Flujo de configuración / boot (`DockerSwarm.configure`) — trivial, sin secuencia de interés.
28
- - `Container.create` — **no implementado** en F1 (intencional, ver glossary).
29
29
 
30
30
  ## 3. Flujos
31
31
 
@@ -212,26 +212,38 @@ Mismo patrón para `stop`. POST sin body → no se reintenta automáticamente (
212
212
 
213
213
  ### 3.8 `Loggable#logs` streaming
214
214
 
215
- Obtención de logs raw para Service/Task/Container.
215
+ Obtención de logs para Service/Task/Container, ya demultiplexados.
216
216
 
217
217
  ```mermaid
218
218
  sequenceDiagram
219
219
  actor Caller
220
220
  participant Model as Service/Task/Container
221
221
  participant Api
222
+ participant Demux as LogStreamDemuxer
222
223
  participant Docker
223
224
 
224
225
  Caller->>Model: model.logs(stdout: 1, stderr: 1, follow: 0)
225
226
  Model->>Api: request(:logs, id:, query: { stdout:, stderr:, follow: })
226
227
  Api->>Docker: GET /services/abc/logs?stdout=1&stderr=1
227
- Docker-->>Api: 200 raw stream (text/plain)
228
- Api-->>Model: raw body
228
+ Docker-->>Demux: 200 stream + Content-Type
229
+ alt cadena de frames cierra de punta a punta
230
+ Demux->>Demux: saca 8 bytes de cabecera por frame, concatena en orden
231
+ else body sin framing (TTY) o inconsistente
232
+ Demux->>Demux: deja el body intacto
233
+ end
234
+ Demux-->>Api: texto limpio
235
+ Api-->>Model: body
229
236
  Model-->>Caller: String
230
237
  ```
231
238
 
232
239
  **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.
240
+ - El body no se parsea como JSON (`ResponseJSONParser` lo respeta porque el Content-Type no es `application/json`).
241
+ - **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).
242
+ - **`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).
243
+ - **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**.
244
+ - **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.
245
+ - **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`.
246
+ - `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
247
 
236
248
  ### 3.9 Auth de registry privado (`X-Registry-Auth` / `registryAuthFrom`)
237
249
 
@@ -287,12 +299,40 @@ sequenceDiagram
287
299
  - 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
300
  - Es un `POST` → **no** entra en la política de retries (ver flujo 3.5).
289
301
 
302
+ ### 3.11 `Container.create` con nombre por query string
303
+
304
+ 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).
305
+
306
+ ```mermaid
307
+ sequenceDiagram
308
+ actor Caller
309
+ participant Container as DockerSwarm::Container
310
+ participant Api
311
+ participant Docker
312
+
313
+ Caller->>Container: Container.create(name: "acs-seed-helper", Image:, Cmd:)
314
+ Container->>Container: valid?
315
+ Container->>Container: query_params_for_docker → { name: "acs-seed-helper" }
316
+ Container->>Container: payload_for_docker.except("name")
317
+ Container->>Api: request(:create, query_params:, payload:)
318
+ Api->>Docker: POST /containers/create?name=acs-seed-helper
319
+ Docker-->>Api: 201 { Id: "abc" }
320
+ Api-->>Container: { Id: "abc" }
321
+ Container->>Container: self.ID = "abc" → reload
322
+ Container-->>Caller: instancia hidratada
323
+ ```
324
+
325
+ **Notas load-bearing:**
326
+ - 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.
327
+ - **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.
328
+ - 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.
329
+
290
330
  ## 4. Cobertura y fronteras
291
331
 
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.
332
+ - **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 = 11. Esta gema es chica; el backfill completo era factible y se hizo, y a partir de ahí se acreta por PR.
293
333
  - **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
334
  - **Frontera con configuración:** `DockerSwarm.configure` es boot, no flujo de negocio. No se diagrama.
295
335
  - **No localizable / fuera de alcance:**
296
336
  - 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).
337
+ - 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
338
  - **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.9.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 |
41
+ | tasks | logs | `GET tasks/%<id>s/logs` | `?stdout/stderr/...` / stream multiplexado (demux en el cliente) |
42
42
  | services | index / show | `GET services`, `GET services/%<id>s` | `?filters=` / array \| Hash |
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,16 @@ 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
+ **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.
65
+
66
+ El `Content-Type` **no alcanza** para decidir. `application/vnd.docker.multiplexed-stream` existe **desde la API v1.42**; su entrada de changelog dice, textual:
67
+
68
+ > `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.
69
+
70
+ 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.
71
+
72
+ > Anclaje: <https://docs.docker.com/reference/api/engine/version-history/> (entrada de v1.42).
73
+
64
74
  #### d. Errores del proveedor → excepción nuestra
65
75
 
66
76
  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.9.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
 
@@ -63,11 +63,13 @@ Inventario completo de opciones: [`docs/config/configuracion.md`](../config/conf
63
63
  | símbolo | tipo | nota |
64
64
  |---|---|---|
65
65
  | `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 |
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. Los `create_query_params` del modelo viajan por query string y se **excluyen** del payload |
67
+ | `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 |
68
+ | `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
69
  | `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
70
  | `Concerns::Deletable.destroy(id)` | método de clase (mixin) | `DELETE destroy`; `nil` si `Errors::NotFound` |
69
71
  | `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 |
72
+ | `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
73
  | `Concerns::Inspectable#inspect` | método de instancia | render legible (ID/Name/Version/Spec) |
72
74
 
73
75
  ### Modelos (`models/*.rb`)
@@ -77,7 +79,7 @@ Inventario completo de opciones: [`docs/config/configuracion.md`](../config/conf
77
79
  | `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 |
78
80
  | `DockerSwarm::Node` | clase < Base | Updatable, Deletable (sin `create`: los nodos se unen fuera de la gema) |
79
81
  | `DockerSwarm::Task` | clase < Base | Loggable (read-only; generadas por el orquestador) |
80
- | `DockerSwarm::Container` | clase < Base | Deletable, Loggable; `#start`, `#stop` (sin `create`: gap intencional) |
82
+ | `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
83
  | `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
84
  | `DockerSwarm::Network` | clase < Base | Creatable, Updatable, Deletable |
83
85
  | `DockerSwarm::Volume` | clase < Base | Creatable, Deletable; `.root_key = "Volumes"` (respuesta wrapped) |
@@ -99,7 +101,8 @@ Inventario completo de opciones: [`docs/config/configuracion.md`](../config/conf
99
101
  | `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
102
  | `DockerSwarm::RegistryAuth::{HEADER, QUERY, FROM_VALUES}` | constantes | `"X-Registry-Auth"` · `:registryAuthFrom` · `%w[spec previous-spec]` |
101
103
  | `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) |
104
+ | `DockerSwarm::Middleware::{RequestEncoder, LogStreamDemuxer, ResponseJSONParser, ErrorHandler}` | clases | middlewares Excon; públicos por require pero de uso interno (ver §4) |
105
+ | `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
106
 
104
107
  ## 3. Inferencias
105
108
 
@@ -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.9.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.9.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.9.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,7 @@ 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
+ - Infra de transporte: `api_spec`, `connection_spec`, `configuration_spec`, `log_helper_spec`, los 4 middleware specs.
58
58
  - `swarm`, `system` (singletons): `swarm_spec`, `system_spec`.
59
59
 
60
60
  **Cubierto (integration, daemon real):** lifecycle de containers, services, infra (networks/volumes), system (info/version/up/df), security (config/secret create+find+destroy).
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DockerSwarm
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.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
  ---
@@ -58,7 +61,7 @@ Defaults son razonables: en local sin TLS, no necesitás bloque `configure`.
58
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 |
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"` |
@@ -95,7 +98,7 @@ service.restart
95
98
  # Destroy graceful (nil si 404)
96
99
  service.destroy
97
100
 
98
- # Logs raw
101
+ # Logs (ya demultiplexados: sin cabeceras de frame)
99
102
  service.logs(stdout: 1, stderr: 1)
100
103
 
101
104
  # Health check
@@ -130,7 +133,8 @@ Todas heredan de `DockerSwarm::Error`. Tres formas de acceso equivalentes: `Dock
130
133
  - **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
134
  - **`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
135
  - **`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.
136
+ - **`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`.
137
+ - **`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
138
  - **`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
139
  - **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
140
  - **`destroy` es graceful con 404** (retorna `nil`), no con 409. Si el recurso está en uso, `Conflict` se propaga.
@@ -173,8 +177,10 @@ Logs salen en formato KV (`component=docker_swarm.connection event=request_succe
173
177
  - [`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
178
  - [`docs/topology/topology.md`](docs/topology/topology.md) — dependencias runtime (3) + grafo de contexto.
175
179
  - [`docs/test/testing.md`](docs/test/testing.md) — estructura de la suite RSpec (unit + integration) y comandos de corrida.
180
+ - [`docs/release/release.md`](docs/release/release.md) — canal de publicación (tag `v*` → RubyGems) + deploy/rollback/ambientes.
176
181
  - `docs/data/` — `n/a` (gema sin DB).
177
182
  - `docs/api/` (operaciones), `docs/events/` — `n/a` (la gema no expone superficie HTTP/CLI/eventos propia; su superficie pública es la interfaz Ruby).
183
+ - `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
184
 
179
185
  ## Versionado del contrato
180
186
 
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.9.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.9.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.9.0/skill/SKILL.md
141
142
  rubygems_mfa_required: 'true'
142
143
  rdoc_options: []
143
144
  require_paths: