docker-swarm 0.7.2 → 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: 4173c79f1bd808f22729c09eda7b4b70a494c4f080625fff33285a410f1224dc
4
- data.tar.gz: a8d0aaa08dc6ed272e16ef3577d59f7571791d793a7a2192afb4b5be762c2578
3
+ metadata.gz: 8e27b0beec0476baca5222a41e38aa7b11998641c39cc15eeb16390343e8364b
4
+ data.tar.gz: 61faa29cc088ea4286fb864b0b2237bfd104059236e4b748168344148931ab9e
5
5
  SHA512:
6
- metadata.gz: 88d788fdd06036b76b6c666f46acfc6880bbb74b8f80feb706a8cfe71aa6e7a62164717de75b5711dc34086218a3feaf5e90323461ab27f76685417d199a07cd
7
- data.tar.gz: 44bd8aa057c5b1d9e9e160deed73f13e08d0c441ba4f63a2f0d4142a89c2dc5c3939f732c80adca3c298e7e61b3192506dcf30064c93c9caaf6fa01ee4d4efc9
6
+ metadata.gz: 01b0508429e58f4878e8aa5bba874e67d57e87bf691c31ebc144da22cbeb86d89dc438f268ab85374009949e28296546747be6e0b03b718ddaa4a2df6f1e521b
7
+ data.tar.gz: bc02c9d23be1bacc5a8f7bee2e105cf0fdc90a75fa96d4257c41467bff9b7369a031c6c21644ac3cfc84159910bd10657371b2b9407d4c9bb270cf081d52af23
data/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
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
+
21
+ ## [0.8.0] — 2026-07-22
22
+
23
+ ### Nuevas funcionalidades
24
+ - `Service.create`/`Service#update`: soporte de autenticación de registry privado — `registry_auth` viaja en el header `X-Registry-Auth` y `registry_auth_from` (`spec`|`previous-spec`) en la query `registryAuthFrom` del update; mutuamente excluyentes y validados antes del request. La credencial nunca toca el payload ni el estado del modelo (helper `RegistryAuth`) — @Pslp
25
+ - `Image.pull`: pull explícito síncrono (consume el stream NDJSON hasta EOF, eleva error tipado ante `error`/`errorDetail`, devuelve `{status: :pulled, image_ref:, digest?}` sin `find`; el digest sale del frame `Digest: sha256:…`, verificado contra Docker 29.5.3). **Capacidad sin consumidor activo hoy**: el deploy de imágenes privadas se autentica vía `Service.create` (X-Registry-Auth distribuido a los nodos por Swarm), no por pull explícito. `Image.pull` queda disponible para un futuro requerimiento (pre-pull / warm-cache) — @Pslp
26
+
27
+ ### Breaking changes
28
+ - `Image` deja de incluir `Creatable`: se retira `Image.create` (roto y sin consumidores) en favor de `Image.pull`. `Image` conserva `Deletable` y el listado — @Pslp
29
+
30
+ ### Seguridad
31
+ - `LogHelper` sanitiza recursivamente los headers de autenticación (`X-Registry-Auth`, `Authorization`) para no filtrar credenciales en logs de wire-debug — @Pslp
32
+
5
33
  ## [0.7.2] — 2026-06-29
6
34
 
7
35
  ### Documentación
data/README.md CHANGED
@@ -42,15 +42,22 @@ Documentación normada (RFC-001) por capa:
42
42
  | Capa | Artefacto | Estado |
43
43
  |---|---|---|
44
44
  | Datos | — | `n/a` (gema sin DB) |
45
- | Glosario | [`docs/glossary/glossary.md`](docs/glossary/glossary.md) | F1 completo |
46
- | Comportamiento | [`docs/behavior/behavior.md`](docs/behavior/behavior.md) | F1 backfill on-demand (8 flujos) |
47
- | Configuración | [`docs/config/configuracion.md`](docs/config/configuracion.md) | F6 inventario base (7 opciones, sin env vars) |
48
- | API (operaciones) | | F2 pendiente; contrato resumido inline en `skill/SKILL.md` |
49
- | Interfaz | | F2 pendiente; contrato resumido inline en `skill/SKILL.md` |
50
- | Topología | | F2 pendiente |
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 + incremental (11 flujos) |
47
+ | Configuración | [`docs/config/configuracion.md`](docs/config/configuracion.md) | inventario base (7 opciones, sin env vars) |
48
+ | Interfaz | [`docs/interface/interface.md`](docs/interface/interface.md) | API Ruby pública (11 modelos + Base + concerns) |
49
+ | Topología | [`docs/topology/topology.md`](docs/topology/topology.md) | 3 deps runtime + grafo de contexto |
50
+ | Errores | [`docs/errors/errors.md`](docs/errors/errors.md) | jerarquía + mapeo HTTP + política §c |
51
+ | Consumidas | [`docs/consumed/docker-engine-api.md`](docs/consumed/docker-engine-api.md) | Docker Engine API + retry/degradación §c/§e |
52
+ | Test | [`docs/test/testing.md`](docs/test/testing.md) | suite RSpec unit+integration + gaps/contract/PII §e-h (§g sin incidentes) |
53
+ | Release | [`docs/release/release.md`](docs/release/release.md) | completo — §a estructura (tag `v*`→RubyGems, patrón 1) + §b enrich (deploy/rollback/ambientes/dueño) |
54
+ | API (operaciones) | — | `n/a` (gema sin superficie HTTP/CLI/eventos; superficie pública = Interfaz) |
51
55
  | Eventos | — | `n/a` (la gema no emite eventos) |
56
+ | Seguridad | — | `n/a` (sin authn/authz propios; frontera auth-hacia-Docker en `docs/consumed/docker-engine-api.md` §a) |
57
+ | Multi-tenancy | — | `n/a` (gema stateless sin DB ni scope de tenant) |
58
+ | Data-lifecycle | — | `n/a` (sin persistencia/PII/retención; fixtures sintéticas) |
52
59
 
53
- `n/a` = no aplica al tipo de repo. F2 pendiente = capa declarada en la RFC pero todavía no implementada en `arch-structure`; el contenido relevante vive transitoriamente en `skill/SKILL.md` (RFC-008 §2 coexistencia transitoria).
60
+ `n/a` = no aplica al tipo de repo.
54
61
 
55
62
  ## Desarrollo
56
63
 
@@ -1,6 +1,6 @@
1
1
  # Comportamiento — docker-swarm
2
2
 
3
- > meta: artefacto · RFC-007 · generado dev-enrich · anclado a `a4e3129` · cobertura: backfill on-demand inicial (8 flujos load-bearing)
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`
@@ -18,13 +18,14 @@ Flujos de ejecución load-bearing de `docker-swarm`: cómo se materializan en ru
18
18
  6. Error mapping (HTTP status → excepción tipada)
19
19
  7. `Container.start` / `Container.stop`
20
20
  8. `Loggable#logs` streaming
21
+ 9. Auth de registry privado (`RegistryAuth.resolve` → `X-Registry-Auth` / `registryAuthFrom`)
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`)
21
24
 
22
25
  ### No documentados (ausencia ≠ inexistencia, RFC-007)
23
26
 
24
27
  - Reconexión / reapertura de socket Unix (Excon nativo, fuera de nuestra superficie).
25
28
  - Flujo de configuración / boot (`DockerSwarm.configure`) — trivial, sin secuencia de interés.
26
- - Pull de imágenes con autenticación de registry (`X-Registry-Auth`) — **no implementado** en la gema (gap conocido).
27
- - `Container.create` — **no implementado** en F1 (intencional, ver glossary).
28
29
 
29
30
  ## 3. Flujos
30
31
 
@@ -211,33 +212,127 @@ Mismo patrón para `stop`. POST sin body → no se reintenta automáticamente (
211
212
 
212
213
  ### 3.8 `Loggable#logs` streaming
213
214
 
214
- Obtención de logs raw para Service/Task/Container.
215
+ Obtención de logs para Service/Task/Container, ya demultiplexados.
215
216
 
216
217
  ```mermaid
217
218
  sequenceDiagram
218
219
  actor Caller
219
220
  participant Model as Service/Task/Container
220
221
  participant Api
222
+ participant Demux as LogStreamDemuxer
221
223
  participant Docker
222
224
 
223
225
  Caller->>Model: model.logs(stdout: 1, stderr: 1, follow: 0)
224
226
  Model->>Api: request(:logs, id:, query: { stdout:, stderr:, follow: })
225
227
  Api->>Docker: GET /services/abc/logs?stdout=1&stderr=1
226
- Docker-->>Api: 200 raw stream (text/plain)
227
- 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
228
236
  Model-->>Caller: String
229
237
  ```
230
238
 
231
239
  **Notas load-bearing:**
232
- - El body se devuelve sin parseo (no es JSON; `ResponseJSONParser` lo respeta porque Content-Type no es `application/json`).
233
- - `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.
247
+
248
+ ### 3.9 Auth de registry privado (`X-Registry-Auth` / `registryAuthFrom`)
249
+
250
+ El caller pasa una credencial **opaca base64url** (`registry_auth`) y/o la fuente a reusar (`registry_auth_from`). `RegistryAuth.resolve` valida **antes** de la request (exclusión mutua + enum del `from`) y traduce a canales de transporte, sin tocar el payload ni el estado del modelo. La credencial se sanitiza en logs vía `LogHelper`. Aplica a `Service.create`/`#update` e `Image.pull`.
251
+
252
+ ```mermaid
253
+ flowchart TD
254
+ Start[Caller pasa registry_auth y/o registry_auth_from] --> Resolve[RegistryAuth.resolve valida y traduce]
255
+ Resolve --> Both{ambos presentes?}
256
+ Both -->|si| Err1[ArgumentError mutuamente excluyentes]
257
+ Both -->|no| Enum{registry_auth_from en spec o previous-spec?}
258
+ Enum -->|invalido| Err2[ArgumentError valor invalido]
259
+ Enum -->|valido o ausente| Split[arma headers y query_params]
260
+ Split --> Header[registry_auth va al header X-Registry-Auth]
261
+ Split --> Query[registry_auth_from va a la query registryAuthFrom]
262
+ Header --> Req[Api.request en create update o pull]
263
+ Query --> Req
264
+ ```
265
+
266
+ **Notas load-bearing:**
267
+ - La validación es **fail-fast local**: un caller que pasa ambos, o un `from` fuera de `spec`/`previous-spec`, corta con `ArgumentError` antes de tocar el Engine (no se deriva la ambigüedad a Docker).
268
+ - La credencial viaja **solo** por header/query — nunca en el payload ni en el estado del modelo. `LogHelper::SENSITIVE_KEYS` la enmascara en el wire-debug.
269
+
270
+ ### 3.10 `Image.pull` síncrono (stream NDJSON → error tipado → resultado)
271
+
272
+ `Image.pull` es síncrono: consume el stream de progreso NDJSON hasta EOF, eleva error tipado ante un frame `error`/`errorDetail` (que Docker manda **con HTTP 200**), y solo tras terminación limpia devuelve un resultado explícito construido desde el stream — sin un `find` posterior.
273
+
274
+ ```mermaid
275
+ sequenceDiagram
276
+ actor Caller
277
+ participant Image as DockerSwarm::Image
278
+ participant RegAuth as RegistryAuth
279
+ participant Api as DockerSwarm::Api
280
+ participant Docker as Docker Engine
281
+
282
+ Caller->>Image: pull(image_reference, registry_auth)
283
+ Image->>RegAuth: resolve(registry_auth)
284
+ RegAuth-->>Image: headers con X-Registry-Auth
285
+ Image->>Api: request pull con fromImage y headers
286
+ Api->>Docker: POST /images/create?fromImage=ref
287
+ Docker-->>Image: stream NDJSON de progreso
288
+ Image->>Image: parse_progress_stream + raise_on_stream_error!
289
+ alt frame error o errorDetail
290
+ Image-->>Caller: raise Error tipado
291
+ else terminacion limpia
292
+ Image->>Image: extract_digest desde frame Digest
293
+ Image-->>Caller: status pulled + image_ref + digest
294
+ end
295
+ ```
296
+
297
+ **Notas load-bearing:**
298
+ - El middleware entrega el stream como `String` (multi-frame NDJSON) o `Hash` (frame único); `parse_progress_stream` normaliza ambos a lista de frames.
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.
300
+ - Es un `POST` → **no** entra en la política de retries (ver flujo 3.5).
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.
234
329
 
235
330
  ## 4. Cobertura y fronteras
236
331
 
237
- - **Cobertura inicial (RFC-007 backfill on-demand):** 8 flujos load-bearing documentados. Esta gema es chica; el backfill completo es factible y se hace ahora.
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.
238
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.
239
334
  - **Frontera con configuración:** `DockerSwarm.configure` es boot, no flujo de negocio. No se diagrama.
240
335
  - **No localizable / fuera de alcance:**
241
336
  - Lógica interna de Excon (retry timing, socket pool) — vive en Excon, no se inventa diagrama.
242
- - 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.)
243
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.
@@ -0,0 +1,126 @@
1
+ # Dependencias consumidas — docker-swarm
2
+
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
+
5
+ ## 1. Resumen
6
+
7
+ La gema consume **una** dependencia externa: el **Docker Engine API** (HTTP REST sobre Unix socket o TCP). Es el único servicio que invoca; toda la gema es un cliente tipado de esa API. Cliente propio = `DockerSwarm::Connection` (Excon) + `DockerSwarm::Api` (mapa de endpoints).
8
+
9
+ ## 2. Cuerpo
10
+
11
+ ### Docker Engine API
12
+
13
+ #### a. Identidad
14
+
15
+ | campo | valor |
16
+ |---|---|
17
+ | proveedor / servicio | Docker Engine API (daemon `dockerd`) |
18
+ | sub-tipo | **externo** (no es repo del fleet) |
19
+ | transporte | HTTP/REST sobre Unix socket (`unix:///var/run/docker.sock`, default) o TCP (`http://host:2375`) |
20
+ | cliente nuestro | `DockerSwarm::Connection` (Excon) + `DockerSwarm::Api` (`api.rb`) |
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). **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
+ | ancla | doc oficial: <https://docs.docker.com/engine/api/v1.41/> |
24
+
25
+ #### b. Operaciones consumidas
26
+
27
+ Subset que la gema invoca, derivado de `Api::ENDPOINTS` (`api.rb:5-72`). `destino` = `método HTTP + path` (los `%<id>s` se interpolan en runtime).
28
+
29
+ | recurso | operación | destino | qué mandamos / esperamos |
30
+ |---|---|---|---|
31
+ | swarm | show | `GET swarm` | — / info del cluster (Hash) |
32
+ | system | info | `GET info` | — / Hash |
33
+ | system | version | `GET version` | — / Hash |
34
+ | system | up | `GET _ping` | — / `"OK"` |
35
+ | system | df | `GET system/df` | — / Hash de uso de disco |
36
+ | nodes | index | `GET nodes` | `?filters=` opcional / array |
37
+ | nodes | show | `GET nodes/%<id>s` | — / Hash |
38
+ | nodes | update | `POST nodes/%<id>s/update` | `?version=` + payload / — |
39
+ | nodes | destroy | `DELETE nodes/%<id>s` | — / — |
40
+ | tasks | index / show | `GET tasks`, `GET tasks/%<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=` / array \| Hash |
43
+ | services | create | `POST services/create` | payload (Spec aplanado) + header `X-Registry-Auth` (opcional, registry privado) / `{ID}` |
44
+ | services | update | `POST services/%<id>s/update` | `?version=` (+ `?registryAuthFrom=` opcional: `spec`\|`previous-spec`) + payload + header `X-Registry-Auth` (opcional; excluyente con `registryAuthFrom`) / — |
45
+ | services | destroy | `DELETE services/%<id>s` | — / — |
46
+ | services | logs | `GET services/%<id>s/logs` | `?stdout/stderr/...` / stream multiplexado (demux en el cliente) |
47
+ | configs | index / show / create / destroy | `GET configs`, `GET configs/%<id>s`, `POST configs/create`, `DELETE configs/%<id>s` | payload en create / `{ID}` |
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
+ | networks | index / show / create / update / destroy | `GET/POST networks...`, `POST networks/%<id>s/update`, `DELETE networks/%<id>s` | payload / `{ID}` |
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
+ | containers | index | `GET containers/json` | `?filters=` / array |
52
+ | containers | show | `GET containers/%<id>s/json` | — / Hash |
53
+ | containers | create | `POST containers/create` | `?name=` (query, NO en el body) + payload / `{Id}` |
54
+ | containers | start / stop | `POST containers/%<id>s/start`, `POST containers/%<id>s/stop` | — / — |
55
+ | containers | destroy | `DELETE containers/%<id>s` | — / — |
56
+ | containers | logs | `GET containers/%<id>s/logs` | `?stdout/stderr/...` / stream multiplexado (demux en el cliente) |
57
+ | images | index | `GET images/json` | — / array |
58
+ | images | show | `GET images/%<id>s/json` | — / Hash |
59
+ | images | pull | `POST images/create?fromImage=<ref>` | header `X-Registry-Auth` (opcional, registry privado) / stream NDJSON de progreso |
60
+ | images | destroy | `DELETE images/%<id>s` | — / — |
61
+
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
+
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
+
74
+ #### d. Errores del proveedor → excepción nuestra
75
+
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).
77
+
78
+ | condición del proveedor | excepción nuestra |
79
+ |---|---|
80
+ | status 4xx/5xx mapeado | la `DockerSwarm::Error::*` correspondiente (ver `docs/errors` §b) |
81
+ | status no-2xx no mapeado | `DockerSwarm::Error` (`HTTP <status>`) |
82
+ | socket caído / timeout de conexión (`Excon::Error::Socket`) | `DockerSwarm::Error::Communication` (preserva `cause`) |
83
+
84
+ #### c. Retry / idempotencia (semántica)
85
+
86
+ **Estructural** (anclado a `connection.rb:24-33`):
87
+
88
+ | aspecto | valor |
89
+ |---|---|
90
+ | métodos con retry | `get/head/put/delete/options` (`Connection::IDEMPOTENT_METHODS`) |
91
+ | reintentos | `max_retries` (default 3), solo en métodos idempotentes; POST/PATCH = 0 |
92
+ | errores reintentados | `Excon::Error::Socket`, `Excon::Error::Timeout` |
93
+ | backoff | ninguno — reintento inmediato (no se setea `retry_interval`) |
94
+
95
+ **Semántica:** la frontera idempotente/no-idempotente refleja la del Docker Engine API:
96
+
97
+ - `GET`/`DELETE`/`PUT` son seguros de reintentar: re-listar, re-borrar (404 → `nil` graceful) o re-actualizar produce el mismo estado final → la gema los reintenta automáticamente ante caída de socket.
98
+ - `POST create` (services/networks/volumes/configs/secrets/containers) **no** se reintenta: un replay tras fallo parcial podría crear un recurso duplicado (el daemon no deduplica por nombre en todos los recursos). El caller decide qué hacer si un `create` falla por `Communication`.
99
+ - `POST update`/`start`/`stop`/`restart` tampoco se reintentan (son POST); `update` además acarrea `?version=` → un replay con versión vieja daría 409 `Conflict`, no un duplicado.
100
+ - **Sin backoff** es aceptable acá: el socket Unix local rara vez está transitoriamente saturado; ante un daemon caído, 3 reintentos inmediatos fallan rápido y se propaga `Communication`.
101
+
102
+ #### e. Degradación (si la dependencia cae)
103
+
104
+ | escenario | comportamiento de la gema |
105
+ |---|---|
106
+ | socket caído / daemon no responde | tras `max_retries` (idempotentes) o inmediato (POST), levanta `DockerSwarm::Error::Communication` con el `Excon::Error::Socket` en `cause` |
107
+ | daemon devuelve 5xx | levanta la `Error::*` correspondiente (502/503/504) sin reintento HTTP |
108
+ | fallback / cola / circuit-breaker | **ninguno** — la gema es un cliente fino, fail-fast; no encola ni degrada |
109
+ | responsabilidad del consumidor | decidir reintento con backoff, fallback o propagación; la gema solo provee el error tipado |
110
+
111
+ - **SLA del proveedor:** n/a — el daemon Docker suele ser local (socket Unix) o de infraestructura propia; no hay SLA externo que documentar.
112
+ - **Sin estado degradado:** la gema no cachea ni mantiene estado entre llamadas; si el daemon cae, cada operación falla independientemente. No hay "modo degradado" que activar/desactivar.
113
+
114
+ ## 3. Inferencias
115
+
116
+ | afirmación | confidence | a verificar |
117
+ |---|---|---|
118
+ | Versión de API = v1.41 | inferred | tomada de los `@see` de los modelos; la gema no fija `?version=` en la URL base ni negocia versión |
119
+ | `qué mandamos/esperamos` por operación | inferred | derivado del flujo de los concerns (`payload_for_docker`, `reload` tras create); el shape exacto del Spec lo fija Docker |
120
+
121
+ ## 4. Cobertura y fronteras
122
+
123
+ - **Regla de dependencia directa:** solo se documenta lo que la gema invoca directo contra el daemon. Lo que Docker orqueste por debajo (scheduling de tasks en nodos, overlay networks) es concern del daemon, no de la gema.
124
+ - **Subset, no la API completa:** `Api::ENDPOINTS` cubre el subset que la gema expone; el Docker Engine API tiene endpoints (build, exec, plugins, `POST /auth`) que la gema **no** consume → fuera de alcance. Nota: la gema **sí** manda el header `X-Registry-Auth` (credencial opaca por-request); eso es distinto del endpoint `POST /auth` (login contra un registry), que sigue sin consumirse.
125
+ - **Auth de registry privado:** soportado como credencial opaca por-request — `Service.create`/`#update` (header `X-Registry-Auth`; `#update` además `registryAuthFrom` para reusar la del spec) e `Image.pull` (header `X-Registry-Auth`). La gema no mintea ni valida la credencial: la recibe base64url del caller y la pasa tal cual (`RegistryAuth` traduce a header/query). TLS/TCP para alcanzar el daemon sigue fuera de alcance (ver abajo).
126
+ - **TLS/TCP:** el cliente no gestiona certificados; un endpoint TCP con TLS mutuo queda fuera de alcance (resultará en `Unauthorized`/`Communication`).
@@ -0,0 +1,103 @@
1
+ # Errores — docker-swarm
2
+
3
+ > meta: artefacto · RFC-020 · generado arch-structure + enriquecido arch-enrich · anclado a `8f2e1f7` · cobertura: excepciones públicas que la gema emite (`lib/docker_swarm/errors.rb` + `middleware/error_handler.rb` + `Image#raise_on_stream_error!`); política §c 16/16 enriquecida
4
+
5
+ ## 1. Resumen
6
+
7
+ La gema traduce los status HTTP no-2xx del Docker Engine API a una jerarquía de excepciones Ruby tipadas, todas bajo `DockerSwarm::Error < StandardError`. El mapeo status→excepción vive en `Middleware::ErrorHandler`; los fallos de socket se envuelven en `Communication`. No hay payload propio (no es un servicio HTTP): el "shape" es la excepción Ruby + su `message`.
8
+
9
+ ## 2. Cuerpo
10
+
11
+ ### a. Inventario de excepciones públicas
12
+
13
+ Todas heredan de `DockerSwarm::Error` (que hereda de `StandardError`). Tres formas de acceso equivalentes: `DockerSwarm::NotFound`, `DockerSwarm::Error::NotFound`, `DockerSwarm::Errors::NotFound` (alias + `Errors.const_missing`).
14
+
15
+ | excepción | jerarquía base | qué la levanta |
16
+ |---|---|---|
17
+ | `Error` | `StandardError` | base de todas; el fallback `HTTP <status>` para status no mapeado; **y** el fallo de `Image.pull` cuando el stream de progreso reporta un frame `error`/`errorDetail` (Docker lo emite con **HTTP 200** → no lo agarra `ErrorHandler`; lo eleva `Image#raise_on_stream_error!`, `models/image.rb`) |
18
+ | `Error::BadRequest` | `Error` | status 400 (payload malformado) |
19
+ | `Error::Unauthorized` | `Error` | status 401 (TLS sin credenciales) |
20
+ | `Error::Forbidden` | `Error` | status 403 (permisos insuficientes, ej. swarm op en worker) |
21
+ | `Error::NotFound` | `Error` | status 404; capturada por `find`/`destroy` → `nil` |
22
+ | `Error::NotAcceptable` | `Error` | status 406 |
23
+ | `Error::RequestTimeout` | `Error` | status 408 |
24
+ | `Error::Conflict` | `Error` | status 409 (nombre duplicado o `Version.Index` stale) |
25
+ | `Error::UnprocessableEntity` | `Error` | status 422 (raised con el `body` crudo, no el `message`) |
26
+ | `Error::TooManyRequests` | `Error` | status 429 |
27
+ | `Error::InternalServerError` | `Error` | status 500 |
28
+ | `Error::BadGateway` | `Error` | status 502 |
29
+ | `Error::ServiceUnavailable` | `Error` | status 503 |
30
+ | `Error::GatewayTimeout` | `Error` | status 504 |
31
+ | `Error::Communication` | `Error` | socket caído/inalcanzable (`Excon::Error::Socket`); `Connection` la envuelve preservando el mensaje original |
32
+
33
+ ### b. Códigos HTTP → excepción (por `Middleware::ErrorHandler`)
34
+
35
+ La gema **consume** HTTP, no lo expone. Esta tabla es el mapeo de respuesta-del-daemon → excepción-nuestra (`middleware/error_handler.rb:17-33`). El detalle de qué operación produce cada status está en el productor (Docker Engine API, ver [`docs/consumed/`](../consumed/docker-engine-api.md)).
36
+
37
+ | status | excepción | mensaje |
38
+ |---|---|---|
39
+ | 200–299 | — | pasa (no levanta) |
40
+ | 400 | `BadRequest` | `error_message(body)` |
41
+ | 401 | `Unauthorized` | `error_message(body)` |
42
+ | 403 | `Forbidden` | `error_message(body)` |
43
+ | 404 | `NotFound` | `error_message(body)` |
44
+ | 406 | `NotAcceptable` | `error_message(body)` |
45
+ | 408 | `RequestTimeout` | `error_message(body)` |
46
+ | 409 | `Conflict` | `error_message(body)` |
47
+ | 422 | `UnprocessableEntity` | `body` (crudo) |
48
+ | 429 | `TooManyRequests` | `error_message(body)` |
49
+ | 500 | `InternalServerError` | `error_message(body)` |
50
+ | 502 | `BadGateway` | `error_message(body)` |
51
+ | 503 | `ServiceUnavailable` | `error_message(body)` |
52
+ | 504 | `GatewayTimeout` | `error_message(body)` |
53
+ | otro no-2xx | `Error` | `"HTTP #{status}: #{error_msg}"` |
54
+
55
+ `error_message(body)`: si `body` es Hash → `body["message"] \|\| body["error"] \|\| body.to_json`; si no → `body.to_s` (`error_handler.rb:59-65`).
56
+
57
+ ### d. Shape del payload de error
58
+
59
+ No aplica un shape propio tipo RFC 7807: la gema es un cliente, no un servidor. El "payload" que recibe el consumidor es la **excepción Ruby** con:
60
+
61
+ - `exception.class` — el tipo tipado (tabla §a).
62
+ - `exception.message` — el `message`/`error` del body de Docker (o el body crudo en 422), o `"Docker socket error: ..."` en `Communication`.
63
+ - `exception.cause` — en `Communication`, el `Excon::Error::Socket` original queda accesible (`connection.rb:47,61`).
64
+
65
+ ### c. Política por error (retriable · backoff · idempotencia · acción)
66
+
67
+ > **Mecanismo (anclado a `connection.rb:24-33`):** el auto-retry de la gema opera **solo a nivel transporte** — reintenta `Excon::Error::Socket` y `Excon::Error::Timeout` en métodos idempotentes (`get/head/put/delete/options`), `max_retries=3`, **sin backoff** (reintento inmediato, no hay `retry_interval`). Los errores **HTTP 4xx/5xx NO se auto-reintentan**: una vez que llega una respuesta con status, `ErrorHandler` levanta y no hay retry. La columna "retriable" abajo es por tanto **recomendación al consumidor**, no comportamiento automático (salvo `Communication`).
68
+
69
+ > **Acción:** la gema **siempre loguea** el fallo (`request_failure`, nivel ERROR, `connection.rb:49`) y **propaga** (raise). No hay integración de observabilidad (Sentry/exis_ray) en la gema → `escalate`/`report` quedan al consumidor. Por eso la acción base de todas es **log + propagate**; la columna marca el matiz por error.
70
+
71
+ | excepción | retriable? (consumidor) | backoff | idempotencia requerida | acción / nota |
72
+ |---|---|---|---|---|
73
+ | `BadRequest` (400) | no | — | — | propagate — corregir payload |
74
+ | `Unauthorized` (401) | no | — | — | propagate — corregir credenciales/TLS |
75
+ | `Forbidden` (403) | no | — | — | propagate — op no permitida en este rol |
76
+ | `NotFound` (404) | no | — | — | `find`/`destroy` la absorben → `nil`; resto propagate |
77
+ | `NotAcceptable` (406) | no | — | — | propagate |
78
+ | `RequestTimeout` (408) | condicional | sí | sí (si POST) | retry solo en op idempotente; subir `read_timeout` |
79
+ | `Conflict` (409) | condicional | — | sí | si `Version.Index` stale → `reload` + reintentar `update`; si nombre duplicado → no retriable, propagate |
80
+ | `UnprocessableEntity` (422) | no | — | — | propagate — payload semánticamente inválido |
81
+ | `TooManyRequests` (429) | sí | sí | no | backoff + retry (rate limit del daemon) |
82
+ | `InternalServerError` (500) | condicional | sí | sí | retry en op idempotente; revisar payload (update sin `version` → 500) |
83
+ | `BadGateway` (502) | sí | sí | sí (si POST) | transitorio (proxy); retry seguro en op idempotente |
84
+ | `ServiceUnavailable` (503) | sí | sí | sí (si POST) | daemon reiniciando; retry con backoff |
85
+ | `GatewayTimeout` (504) | sí | sí | sí (si POST) | transitorio; retry en op idempotente |
86
+ | `Communication` (socket) | sí (auto) | no | sí (si POST) | la gema YA reintteta Socket/Timeout en métodos idempotentes (`max_retries`, sin backoff); en POST no reintenta → el caller decide |
87
+ | `Error` (pull stream, HTTP 200) | sí (consumidor) | sí | sí | `Image.pull` la eleva ante un frame `error`/`errorDetail` del stream; propagate. El pull es idempotente → el caller puede reintentar (ej. fallo transitorio de red/registry). Distinguir de un 404 **pre-stream** (imagen/registry inexistente o acceso denegado → no retriable) |
88
+
89
+ ## 3. Inferencias
90
+
91
+ | afirmación | confidence | a verificar |
92
+ |---|---|---|
93
+ | El "cuándo" de cada status (ej. 403 = swarm op en worker) refleja el comportamiento de Docker, no lógica de la gema | inferred | el mapeo es status→excepción genérico; la causa la fija el daemon. Notas tomadas de `skill/SKILL.md` |
94
+ | `UnprocessableEntity` recibe el `body` crudo (no `error_message`) a propósito (422 suele traer detalle estructurado) | declared | `error_handler.rb:25` — divergencia explícita del resto |
95
+ | §c columna "retriable" = recomendación al consumidor basada en semántica HTTP/Docker; la gema **no** auto-reintenta status HTTP (solo Socket/Timeout) | inferred | mecanismo anclado a `connection.rb`; la política por-status la confirma el humano contra el comportamiento real del daemon |
96
+ | 409 `Conflict` con `Version.Index` stale → patrón reload+retry | inferred | el `update` extrae `Version.Index` (`updatable.rb:9`); el reload-on-conflict es decisión del consumidor, no automática |
97
+
98
+ ## 4. Cobertura y fronteras
99
+
100
+ - **Solo errores públicos:** estas excepciones cruzan la frontera de la gema hacia el consumidor. No hay excepciones internas rescatadas-y-tragadas que documentar (salvo `JSON::ParserError` en `ResponseJSONParser`, que se traga y retorna el body crudo — interno, no contrato).
101
+ - **Frontera con consumed (RFC-018):** este catálogo = lo que la gema **emite**. El mapeo "error del proveedor Docker → excepción nuestra" lo referencia [`docs/consumed/docker-engine-api.md`](../consumed/docker-engine-api.md) §d, que apunta acá.
102
+ - **Política §c:** enriquecida (recomendación al consumidor); el matiz por-status lo confirma el humano contra el comportamiento real del daemon (ver §3).
103
+ - **Validación de input del caller (`ArgumentError`, stdlib):** `RegistryAuth.resolve`/`validate!` (exclusión mutua `registry_auth`/`registry_auth_from` + enum del `from`) y `Base#assign_attributes` (no-Hash) elevan `ArgumentError` ante input inválido del caller — fail-fast, antes de tocar el daemon. Es contrato público de esas firmas (documentado en [`docs/interface/interface.md`](../interface/interface.md)), **no** parte de la jerarquía `DockerSwarm::Error` → por eso no está en §a/§c.