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