docker-swarm 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +1 -1
- data/docs/behavior/behavior.md +50 -10
- data/docs/consumed/docker-engine-api.md +16 -6
- data/docs/glossary/glossary.md +3 -3
- data/docs/interface/interface.md +8 -5
- data/docs/release/release.md +2 -2
- data/docs/test/testing.md +3 -3
- 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/version.rb +1 -1
- data/lib/docker_swarm.rb +1 -0
- data/skill/SKILL.md +13 -7
- 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: 8e27b0beec0476baca5222a41e38aa7b11998641c39cc15eeb16390343e8364b
|
|
4
|
+
data.tar.gz: 61faa29cc088ea4286fb864b0b2237bfd104059236e4b748168344148931ab9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 01b0508429e58f4878e8aa5bba874e67d57e87bf691c31ebc144da22cbeb86d89dc438f268ab85374009949e28296546747be6e0b03b718ddaa4a2df6f1e521b
|
|
7
|
+
data.tar.gz: bc02c9d23be1bacc5a8f7bee2e105cf0fdc90a75fa96d4257c41467bff9b7369a031c6c21644ac3cfc84159910bd10657371b2b9407d4c9bb270cf081d52af23
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.9.0] — 2026-08-03
|
|
6
|
+
|
|
7
|
+
### Nuevas funcionalidades
|
|
8
|
+
- `Container` pasa a incluir `Concerns::Creatable`: la gema ya puede **crear** containers, no solo operar los existentes. El nombre viaja por **query string** (`POST /containers/create?name=`), que es donde el Engine lo espera — en el body lo descarta en silencio y responde `201`, dejando el container con nombre aleatorio. `Concerns::Creatable` gana `create_query_params` (default `[]`, override por modelo; `Container` declara `%w[name]`) y `query_params_for_docker`; `save` los manda en la URL y los excluye del payload. Implementa ADR-025 cláusula 1 — @gedera
|
|
9
|
+
|
|
10
|
+
### Breaking changes
|
|
11
|
+
- **`logs` devuelve texto demultiplexado.** Sin TTY el Engine enmarca cada fragmento con 8 bytes de cabecera (tipo de stream · relleno · tamaño big-endian); el nuevo `Middleware::LogStreamDemuxer` los saca, así que `Loggable#logs` entrega texto limpio en `Container`, `Service` y `Task`. **Cambia el valor de retorno** para cualquier consumidor que hoy reciba el body crudo. Un stream sin framing (TTY) pasa intacto. Implementa ADR-025 cláusula 3 — @gedera
|
|
12
|
+
- El dispatch **no** se decide solo por `Content-Type`: `application/vnd.docker.multiplexed-stream` existe desde la **API v1.42**, y antes un stream multiplexado viajaba igual como `application/vnd.docker.raw-stream`. Como la gema no fija `?version=`, un Engine que tope en v1.41 devuelve `raw-stream` **con** framing. Ante `raw-stream` el middleware valida la **forma del frame** y solo demultiplexa si la cadena cierra de punta a punta; ante cualquier inconsistencia devuelve el body intacto. Registrado en **ADR-027**, que corrige un dato de apoyo del §Alternativas de ADR-025 **sin** superseder su Decisión.
|
|
13
|
+
|
|
14
|
+
### Seguridad
|
|
15
|
+
- **`LogHelper.sanitize` redacta los secretos que viajan como `"CLAVE=VALOR"` en `Env`** (#24). Antes redactaba solo por **clave de hash**: un String caía al `else` y pasaba intacto, y el `Env` de un `ContainerSpec` es un **array de strings** `"CLAVE=VALOR"` donde el nombre del secreto vive *dentro* del elemento. Como `"Env"` tampoco matchea `SENSITIVE_KEYS`, el valor de todo secreto pasado por variable de entorno **se logueaba entero en `request_success` — camino feliz, nivel INFO** — en cada create/update de un service. Se agrega `redact_kv_string` y una rama `when String`: si la parte izquierda matchea `SENSITIVE_KEYS` se reemplaza el valor y **se conserva el nombre** (saber qué secreto apareció es diagnóstico útil; su valor no). El regex usa `[^=]+` a la izquierda para no partir en un `=` del valor (base64, URLs) y `/m` para valores multilínea. Afecta a **todas** las versiones anteriores — @Pslp
|
|
16
|
+
- **Hueco declarado, no cubierto por este fix:** `private_key` **no está** en `SENSITIVE_KEYS`, así que un PEM con ese nombre sigue saliendo en claro (hay un spec que lo fija como comportamiento conocido). Ampliar la lista va aparte: cambia la redacción para todos los consumidores.
|
|
17
|
+
|
|
18
|
+
### Otros cambios
|
|
19
|
+
- `spec.homepage` del gemspec pasa a `https://github.com/sequre/docker-swarm` (#27): el repo se transfirió de la cuenta personal `gedera` a la org `sequre`. De ahí derivan los cuatro metadata URIs (`source_code_uri`, `changelog_uri`, `bug_tracker_uri`, `documentation_uri`), así que desde esta versión apuntan a la ubicación nueva. Las versiones ya publicadas conservan la URL anterior — las salva el redirect de GitHub — @gedera
|
|
20
|
+
|
|
5
21
|
## [0.8.0] — 2026-07-22
|
|
6
22
|
|
|
7
23
|
### Nuevas funcionalidades
|
data/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Documentación normada (RFC-001) por capa:
|
|
|
43
43
|
|---|---|---|
|
|
44
44
|
| Datos | — | `n/a` (gema sin DB) |
|
|
45
45
|
| Glosario | [`docs/glossary/glossary.md`](docs/glossary/glossary.md) | completo (primitivas + arquitectura interna) |
|
|
46
|
-
| Comportamiento | [`docs/behavior/behavior.md`](docs/behavior/behavior.md) | backfill on-demand (
|
|
46
|
+
| Comportamiento | [`docs/behavior/behavior.md`](docs/behavior/behavior.md) | backfill on-demand + incremental (11 flujos) |
|
|
47
47
|
| Configuración | [`docs/config/configuracion.md`](docs/config/configuracion.md) | inventario base (7 opciones, sin env vars) |
|
|
48
48
|
| Interfaz | [`docs/interface/interface.md`](docs/interface/interface.md) | API Ruby pública (11 modelos + Base + concerns) |
|
|
49
49
|
| Topología | [`docs/topology/topology.md`](docs/topology/topology.md) | 3 deps runtime + grafo de contexto |
|
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.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 (
|
|
11
|
+
### Documentados (11)
|
|
12
12
|
|
|
13
13
|
1. `Service.create` + reload
|
|
14
14
|
2. `Service.update` con `Version.Index`
|
|
@@ -20,12 +20,12 @@ Flujos de ejecución load-bearing de `docker-swarm`: cómo se materializan en ru
|
|
|
20
20
|
8. `Loggable#logs` streaming
|
|
21
21
|
9. Auth de registry privado (`RegistryAuth.resolve` → `X-Registry-Auth` / `registryAuthFrom`)
|
|
22
22
|
10. `Image.pull` síncrono (stream NDJSON → error tipado → resultado explícito)
|
|
23
|
+
11. `Container.create` con nombre por query string (`create_query_params`)
|
|
23
24
|
|
|
24
25
|
### No documentados (ausencia ≠ inexistencia, RFC-007)
|
|
25
26
|
|
|
26
27
|
- Reconexión / reapertura de socket Unix (Excon nativo, fuera de nuestra superficie).
|
|
27
28
|
- Flujo de configuración / boot (`DockerSwarm.configure`) — trivial, sin secuencia de interés.
|
|
28
|
-
- `Container.create` — **no implementado** en F1 (intencional, ver glossary).
|
|
29
29
|
|
|
30
30
|
## 3. Flujos
|
|
31
31
|
|
|
@@ -212,26 +212,38 @@ Mismo patrón para `stop`. POST sin body → no se reintenta automáticamente (
|
|
|
212
212
|
|
|
213
213
|
### 3.8 `Loggable#logs` streaming
|
|
214
214
|
|
|
215
|
-
Obtención de logs
|
|
215
|
+
Obtención de logs para Service/Task/Container, ya demultiplexados.
|
|
216
216
|
|
|
217
217
|
```mermaid
|
|
218
218
|
sequenceDiagram
|
|
219
219
|
actor Caller
|
|
220
220
|
participant Model as Service/Task/Container
|
|
221
221
|
participant Api
|
|
222
|
+
participant Demux as LogStreamDemuxer
|
|
222
223
|
participant Docker
|
|
223
224
|
|
|
224
225
|
Caller->>Model: model.logs(stdout: 1, stderr: 1, follow: 0)
|
|
225
226
|
Model->>Api: request(:logs, id:, query: { stdout:, stderr:, follow: })
|
|
226
227
|
Api->>Docker: GET /services/abc/logs?stdout=1&stderr=1
|
|
227
|
-
Docker-->>
|
|
228
|
-
|
|
228
|
+
Docker-->>Demux: 200 stream + Content-Type
|
|
229
|
+
alt cadena de frames cierra de punta a punta
|
|
230
|
+
Demux->>Demux: saca 8 bytes de cabecera por frame, concatena en orden
|
|
231
|
+
else body sin framing (TTY) o inconsistente
|
|
232
|
+
Demux->>Demux: deja el body intacto
|
|
233
|
+
end
|
|
234
|
+
Demux-->>Api: texto limpio
|
|
235
|
+
Api-->>Model: body
|
|
229
236
|
Model-->>Caller: String
|
|
230
237
|
```
|
|
231
238
|
|
|
232
239
|
**Notas load-bearing:**
|
|
233
|
-
- El body se
|
|
234
|
-
- `
|
|
240
|
+
- El body no se parsea como JSON (`ResponseJSONParser` lo respeta porque el Content-Type no es `application/json`).
|
|
241
|
+
- **El demux vive en un middleware, no en `Loggable`:** `Connection#request` devuelve `response.body` y descarta los headers, así que aguas abajo ya no hay `Content-Type` con el que decidir (ADR-025 cláusula 3).
|
|
242
|
+
- **`raw-stream` no implica TTY.** Ese `Content-Type` era el único que existía antes de la API v1.42, y la gema no fija `?version=` → un Engine 20.10 devuelve `raw-stream` con framing. El middleware decide por la forma del frame, no por el header (detalle y cita del changelog en [`docs/consumed/docker-engine-api.md`](../consumed/docker-engine-api.md) §b).
|
|
243
|
+
- **Desviación de ADR-025, acotada.** La **Decisión** normativa (`ADR-025:130-131` — *"un middleware que decide por `Content-Type`"*) **se cumple**: el middleware corta si el header falta o no es uno de los dos. Lo que la implementación contradice es el **rationale de §Alternativas** (`ADR-025:106-107`), que da por sentado que `raw-stream` implica TTY. Ese dato de apoyo es falso para Engines que topan en la API v1.41. Asentar la corrección en el reino queda **pendiente**.
|
|
244
|
+
- **El demux limpia los frames, no separa señal de ruido.** `stdout` y `stderr` siguen intercalados en un solo String: quien necesite un dato puntual tiene que delimitarlo en origen.
|
|
245
|
+
- **Un frame partido entre chunks no se reensambla:** el demux es todo-o-nada, así que devuelve el body **intacto** en vez de texto a medias. El comportamiento está definido y cubierto por spec — los cuatro casos que pide `ADR-025:196-198` (cadena de frames, varios en un chunk, tamaño/cola truncados, TTY sin framing) están en `spec/docker/swarm/middleware/log_stream_demuxer_spec.rb`.
|
|
246
|
+
- `follow: 1` mantiene la conexión abierta — el caller debe manejar el stream/timeout. `[inferred]` Con `follow` el body no llega completo, así que el demux no aplica por diseño; no está ejercitado por spec ni contemplado en ADR-025 — es extrapolación de esta capa, no una limitación declarada por la ADR.
|
|
235
247
|
|
|
236
248
|
### 3.9 Auth de registry privado (`X-Registry-Auth` / `registryAuthFrom`)
|
|
237
249
|
|
|
@@ -287,12 +299,40 @@ sequenceDiagram
|
|
|
287
299
|
- El digest sale del frame `Digest: sha256:...` (el stream de pull **no** trae campo `aux` — verificado contra Docker 29.5.3), escaneando desde el final.
|
|
288
300
|
- Es un `POST` → **no** entra en la política de retries (ver flujo 3.5).
|
|
289
301
|
|
|
302
|
+
### 3.11 `Container.create` con nombre por query string
|
|
303
|
+
|
|
304
|
+
El Engine toma el nombre del container por query string. En el body lo **descarta en silencio** y responde `201` igual: el container nace con nombre aleatorio, y una lógica de adopción por nombre determinista no lo encuentra → el reintento duplica en vez de adoptar (ADR-025 cláusula 1).
|
|
305
|
+
|
|
306
|
+
```mermaid
|
|
307
|
+
sequenceDiagram
|
|
308
|
+
actor Caller
|
|
309
|
+
participant Container as DockerSwarm::Container
|
|
310
|
+
participant Api
|
|
311
|
+
participant Docker
|
|
312
|
+
|
|
313
|
+
Caller->>Container: Container.create(name: "acs-seed-helper", Image:, Cmd:)
|
|
314
|
+
Container->>Container: valid?
|
|
315
|
+
Container->>Container: query_params_for_docker → { name: "acs-seed-helper" }
|
|
316
|
+
Container->>Container: payload_for_docker.except("name")
|
|
317
|
+
Container->>Api: request(:create, query_params:, payload:)
|
|
318
|
+
Api->>Docker: POST /containers/create?name=acs-seed-helper
|
|
319
|
+
Docker-->>Api: 201 { Id: "abc" }
|
|
320
|
+
Api-->>Container: { Id: "abc" }
|
|
321
|
+
Container->>Container: self.ID = "abc" → reload
|
|
322
|
+
Container-->>Caller: instancia hidratada
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
**Notas load-bearing:**
|
|
326
|
+
- El split lo declara el modelo con `.create_query_params` (`%w[name]` en `Container`); el default del concern es `[]`, así que los demás modelos Creatable no cambian de comportamiento.
|
|
327
|
+
- **El atributo se excluye del payload**, no se duplica: mandarlo en los dos lados no da error pero deja el body con una clave que Docker ignora.
|
|
328
|
+
- Hereda el flujo §3.1: `valid?` antes del POST, `reload` después, y **sin retry** por ser `POST` (§3.5). Si el `create` falla por `Communication`, el caller decide — con nombre determinista puede adoptar el existente en el reintento.
|
|
329
|
+
|
|
290
330
|
## 4. Cobertura y fronteras
|
|
291
331
|
|
|
292
|
-
- **Cobertura (RFC-007 backfill on-demand + incremental):** 8 flujos load-bearing en el backfill inicial + 2 agregados con el soporte de auth de registry privado (auth de registry privado, `Image.pull` síncrono) =
|
|
332
|
+
- **Cobertura (RFC-007 backfill on-demand + incremental):** 8 flujos load-bearing en el backfill inicial + 2 agregados con el soporte de auth de registry privado (auth de registry privado, `Image.pull` síncrono) + 1 con el `create` de containers = 11. Esta gema es chica; el backfill completo era factible y se hizo, y a partir de ahí se acreta por PR.
|
|
293
333
|
- **Frontera con glosario:** términos (Service, Spec, Version.Index, etc.) viven en [`docs/glossary/glossary.md`](../glossary/glossary.md). Esta capa documenta secuencias, no significado.
|
|
294
334
|
- **Frontera con configuración:** `DockerSwarm.configure` es boot, no flujo de negocio. No se diagrama.
|
|
295
335
|
- **No localizable / fuera de alcance:**
|
|
296
336
|
- Lógica interna de Excon (retry timing, socket pool) — vive en Excon, no se inventa diagrama.
|
|
297
|
-
-
|
|
337
|
+
- Nada pendiente por este motivo. (Hasta el 2026-08-03 esta línea decía que la gema no implementaba `X-Registry-Auth` y citaba `Image.create`; las dos cosas quedaron obsoletas — la auth de registry está documentada en §3.9 e `Image.create` fue retirado.)
|
|
298
338
|
- **Cadencia incremental a partir de acá:** sólo se diagrama un flujo cuando un PR lo toca o agrega. No barrido retroactivo de legacy.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Dependencias consumidas — docker-swarm
|
|
2
2
|
|
|
3
|
-
> meta: artefacto · RFC-018 · generado arch-structure + enriquecido arch-enrich · anclado a `
|
|
3
|
+
> meta: artefacto · RFC-018 · generado arch-structure + enriquecido arch-enrich · anclado a `v0.9.0` · cobertura: superficie del Docker Engine API consumida por la gema (`api.rb` ENDPOINTS + `connection.rb`); §c/§e enriquecidas 1/1
|
|
4
4
|
|
|
5
5
|
## 1. Resumen
|
|
6
6
|
|
|
@@ -19,7 +19,7 @@ La gema consume **una** dependencia externa: el **Docker Engine API** (HTTP REST
|
|
|
19
19
|
| transporte | HTTP/REST sobre Unix socket (`unix:///var/run/docker.sock`, default) o TCP (`http://host:2375`) |
|
|
20
20
|
| cliente nuestro | `DockerSwarm::Connection` (Excon) + `DockerSwarm::Api` (`api.rb`) |
|
|
21
21
|
| auth | ninguna por default (socket local). TCP/TLS → fuera de alcance del cliente (no inyecta credenciales; 401 si el daemon las exige) |
|
|
22
|
-
| versión de API | v1.41 (referencia en los `@see` de los modelos; no se negocia explícitamente) |
|
|
22
|
+
| versión de API | v1.41 (referencia en los `@see` de los modelos; no se negocia explícitamente). **La gema no fija `?version=`** → el daemon sirve su versión **máxima**, así que la versión efectiva la decide cada host. Piso relevante: un Engine 20.10 topa en **v1.41**. Techo del parque: `unknown` (no derivable de este repo). Consecuencia en los logs: ver §b |
|
|
23
23
|
| ancla | doc oficial: <https://docs.docker.com/engine/api/v1.41/> |
|
|
24
24
|
|
|
25
25
|
#### b. Operaciones consumidas
|
|
@@ -38,22 +38,22 @@ Subset que la gema invoca, derivado de `Api::ENDPOINTS` (`api.rb:5-72`). `destin
|
|
|
38
38
|
| nodes | update | `POST nodes/%<id>s/update` | `?version=` + payload / — |
|
|
39
39
|
| nodes | destroy | `DELETE nodes/%<id>s` | — / — |
|
|
40
40
|
| tasks | index / show | `GET tasks`, `GET tasks/%<id>s` | `?filters=` / array \| Hash |
|
|
41
|
-
| tasks | logs | `GET tasks/%<id>s/logs` | `?stdout/stderr/...` / stream
|
|
41
|
+
| tasks | logs | `GET tasks/%<id>s/logs` | `?stdout/stderr/...` / stream multiplexado (demux en el cliente) |
|
|
42
42
|
| services | index / show | `GET services`, `GET services/%<id>s` | `?filters=` / array \| Hash |
|
|
43
43
|
| services | create | `POST services/create` | payload (Spec aplanado) + header `X-Registry-Auth` (opcional, registry privado) / `{ID}` |
|
|
44
44
|
| services | update | `POST services/%<id>s/update` | `?version=` (+ `?registryAuthFrom=` opcional: `spec`\|`previous-spec`) + payload + header `X-Registry-Auth` (opcional; excluyente con `registryAuthFrom`) / — |
|
|
45
45
|
| services | destroy | `DELETE services/%<id>s` | — / — |
|
|
46
|
-
| services | logs | `GET services/%<id>s/logs` | `?stdout/stderr/...` / stream
|
|
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,16 @@ Subset que la gema invoca, derivado de `Api::ENDPOINTS` (`api.rb:5-72`). `destin
|
|
|
61
61
|
|
|
62
62
|
Serialización: request body no-String → JSON (`Content-Type: application/json`) vía `Middleware::RequestEncoder`; response `application/json` → `HashWithIndifferentAccess` recursivo vía `Middleware::ResponseJSONParser`.
|
|
63
63
|
|
|
64
|
+
**Streams de logs (`containers`/`services`/`tasks` → `logs`).** Sin TTY el Engine multiplexa: 8 bytes de cabecera por frame (1 tipo de stream · 3 de relleno en cero · 4 de tamaño big-endian). `Middleware::LogStreamDemuxer` los saca, así que `Loggable#logs` entrega texto limpio.
|
|
65
|
+
|
|
66
|
+
El `Content-Type` **no alcanza** para decidir. `application/vnd.docker.multiplexed-stream` existe **desde la API v1.42**; su entrada de changelog dice, textual:
|
|
67
|
+
|
|
68
|
+
> `GET /containers/{id}/attach`, `GET /exec/{id}/start`, `GET /containers/{id}/logs` `GET /services/{id}/logs` and `GET /tasks/{id}/logs` now set Content-Type header to `application/vnd.docker.multiplexed-stream` when a multiplexed stdout/stderr stream is sent to client, `application/vnd.docker.raw-stream` otherwise.
|
|
69
|
+
|
|
70
|
+
Antes de v1.42 ese valor no existía y un stream multiplexado viajaba igual como `application/vnd.docker.raw-stream`. Como la gema no fija `?version=`, un Engine 20.10 (tope v1.41) devuelve `raw-stream` **con** framing. Por eso el middleware, ante `raw-stream`, decide por la **forma del frame** —recorre el body entero y solo demultiplexa si la cadena de frames cierra de punta a punta— en vez de confiar en el header. Ante cualquier inconsistencia deja el body intacto.
|
|
71
|
+
|
|
72
|
+
> Anclaje: <https://docs.docker.com/reference/api/engine/version-history/> (entrada de v1.42).
|
|
73
|
+
|
|
64
74
|
#### d. Errores del proveedor → excepción nuestra
|
|
65
75
|
|
|
66
76
|
El daemon responde con status HTTP; `Middleware::ErrorHandler` los mapea a la jerarquía `DockerSwarm::Error`. La tabla completa status→excepción está en [`docs/errors/errors.md`](../errors/errors.md) §b (este artefacto **referencia**, no la redefine).
|
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.9.0` · cobertura: API Ruby pública de la gema (`lib/docker_swarm/**`); símbolos internos marcados en §4
|
|
4
4
|
|
|
5
5
|
## 1. Resumen
|
|
6
6
|
|
|
@@ -63,11 +63,13 @@ Inventario completo de opciones: [`docs/config/configuracion.md`](../config/conf
|
|
|
63
63
|
| símbolo | tipo | nota |
|
|
64
64
|
|---|---|---|
|
|
65
65
|
| `Concerns::Creatable.create(attributes = {}, **opts)` | método de clase (mixin) | `new` + `save`; retorna la instancia. `opts` reservado `registry_auth:` (→ header `X-Registry-Auth`); el resto se pliega como atributos |
|
|
66
|
-
| `Concerns::Creatable#save(registry_auth: nil)` | método de instancia | `false` si `!valid?`; `update` si `persisted?`; si no `POST create` + `reload`. `registry_auth` viaja como header, nunca en el payload |
|
|
66
|
+
| `Concerns::Creatable#save(registry_auth: nil)` | método de instancia | `false` si `!valid?`; `update` si `persisted?`; si no `POST create` + `reload`. `registry_auth` viaja como header, nunca en el payload. Los `create_query_params` del modelo viajan por query string y se **excluyen** del payload |
|
|
67
|
+
| `Concerns::Creatable.create_query_params` | método de clase (mixin) | `[]` por default; override por modelo. Atributos que el Engine toma por query string en el `create` y **descarta en silencio** si van en el body |
|
|
68
|
+
| `Concerns::Creatable#query_params_for_docker` | método de instancia | los `create_query_params` seteados en esta instancia, con claves símbolo; `{}` si el modelo no declara ninguno |
|
|
67
69
|
| `Concerns::Updatable#update(new_attributes = {}, **opts)` | método de instancia | extrae `Version.Index`; `false` si `!valid?`; `POST update` con `?version=`. `opts` reservado `registry_auth:` (header) / `registry_auth_from:` (query `registryAuthFrom`, `spec`\|`previous-spec`, excluyente con `registry_auth`); el resto se pliega como atributos |
|
|
68
70
|
| `Concerns::Deletable.destroy(id)` | método de clase (mixin) | `DELETE destroy`; `nil` si `Errors::NotFound` |
|
|
69
71
|
| `Concerns::Deletable#destroy` | método de instancia | delega en `.destroy(self.ID)` |
|
|
70
|
-
| `Concerns::Loggable#logs(query_params = { stdout: 1, stderr: 1 })` | método de instancia | `GET logs`; retorna el stream
|
|
72
|
+
| `Concerns::Loggable#logs(query_params = { stdout: 1, stderr: 1 })` | método de instancia | `GET logs`; retorna **texto ya demultiplexado** (sin los 8 bytes de cabecera por frame) — el demux lo hace `Middleware::LogStreamDemuxer`, el consumidor no ve el framing. Un stream de TTY (sin framing) pasa intacto |
|
|
71
73
|
| `Concerns::Inspectable#inspect` | método de instancia | render legible (ID/Name/Version/Spec) |
|
|
72
74
|
|
|
73
75
|
### Modelos (`models/*.rb`)
|
|
@@ -77,7 +79,7 @@ Inventario completo de opciones: [`docs/config/configuracion.md`](../config/conf
|
|
|
77
79
|
| `DockerSwarm::Service` | clase < Base | Creatable, Updatable, Deletable, Loggable; `#restart` (incrementa `TaskTemplate.ForceUpdate`); `create`/`update` aceptan `registry_auth:` (+ `update`: `registry_auth_from:`) para auth de registry privado |
|
|
78
80
|
| `DockerSwarm::Node` | clase < Base | Updatable, Deletable (sin `create`: los nodos se unen fuera de la gema) |
|
|
79
81
|
| `DockerSwarm::Task` | clase < Base | Loggable (read-only; generadas por el orquestador) |
|
|
80
|
-
| `DockerSwarm::Container` | clase < Base | Deletable, Loggable; `#start`, `#stop` (
|
|
82
|
+
| `DockerSwarm::Container` | clase < Base | Creatable, Deletable, Loggable; `#start`, `#stop`; `.create_query_params == %w[name]` (el Engine toma el nombre por query string — en el body lo descarta en silencio y el container nace con nombre aleatorio). El `create` **no** es gap intencional desde ADR-025 cláusula 1 |
|
|
81
83
|
| `DockerSwarm::Image` | clase < Base | Deletable + `.pull(image_reference, registry_auth: nil)`. **NO** es Creatable (`Image.create` retirado sin alias). `.pull` = pull explícito síncrono: consume el stream NDJSON hasta EOF, eleva `DockerSwarm::Error` ante frame `error`/`errorDetail`, retorna `{ status: :pulled, image_ref:, digest? }` (sin `find` posterior) |
|
|
82
84
|
| `DockerSwarm::Network` | clase < Base | Creatable, Updatable, Deletable |
|
|
83
85
|
| `DockerSwarm::Volume` | clase < Base | Creatable, Deletable; `.root_key = "Volumes"` (respuesta wrapped) |
|
|
@@ -99,7 +101,8 @@ Inventario completo de opciones: [`docs/config/configuracion.md`](../config/conf
|
|
|
99
101
|
| `DockerSwarm::RegistryAuth.resolve(registry_auth:, registry_auth_from:)` | método de módulo | traduce las opciones de auth a `[headers, query_params]` (`X-Registry-Auth` / `registryAuthFrom`); valida exclusión mutua + enum antes de la request. Usado por `Image.pull` / `#save` / `#update`; la credencial nunca toca payload ni estado del modelo |
|
|
100
102
|
| `DockerSwarm::RegistryAuth::{HEADER, QUERY, FROM_VALUES}` | constantes | `"X-Registry-Auth"` · `:registryAuthFrom` · `%w[spec previous-spec]` |
|
|
101
103
|
| `DockerSwarm::Error` + subclases + aliases + `DockerSwarm::Errors` | clases/módulo | jerarquía de errores — detalle en [`docs/errors/errors.md`](../errors/errors.md) |
|
|
102
|
-
| `DockerSwarm::Middleware::{RequestEncoder, ResponseJSONParser, ErrorHandler}` | clases | middlewares Excon; públicos por require pero de uso interno (ver §4) |
|
|
104
|
+
| `DockerSwarm::Middleware::{RequestEncoder, LogStreamDemuxer, ResponseJSONParser, ErrorHandler}` | clases | middlewares Excon; públicos por require pero de uso interno (ver §4) |
|
|
105
|
+
| `DockerSwarm::Middleware::LogStreamDemuxer::{MULTIPLEXED_CONTENT_TYPE, RAW_CONTENT_TYPE, HEADER_SIZE, STREAM_TYPES}` | constantes | `"application/vnd.docker.multiplexed-stream"` · `"application/vnd.docker.raw-stream"` · `8` · `[0, 1, 2]` |
|
|
103
106
|
|
|
104
107
|
## 3. Inferencias
|
|
105
108
|
|
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.9.0` · cobertura: §a estructura completa (versión · changelog · build-trigger · patrón); §b enrich completa (deploy · rollback · ambientes · dueño)
|
|
4
4
|
|
|
5
5
|
## 1. Resumen
|
|
6
6
|
|
|
@@ -12,7 +12,7 @@ Gema Ruby publicada en RubyGems. Release por **tag `v*`**: el push del tag dispa
|
|
|
12
12
|
|
|
13
13
|
| campo | valor | fuente |
|
|
14
14
|
|---|---|---|
|
|
15
|
-
| versión actual | `0.
|
|
15
|
+
| versión actual | `0.9.0` | `lib/docker_swarm/version.rb` (`DockerSwarm::VERSION`) |
|
|
16
16
|
| esquema de versión | SemVer (`MAJOR.MINOR.PATCH`) | `CHANGELOG.md` (breaking/mejoras/correcciones por bump) |
|
|
17
17
|
| artefacto liberado | gema `docker-swarm` a RubyGems | `docker-swarm.gemspec` (`spec.name`), `.github/workflows/release.yml` |
|
|
18
18
|
| changelog | `CHANGELOG.md`, formato Keep a Changelog, entradas fechadas por versión | `CHANGELOG.md` |
|
data/docs/test/testing.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Test — docker-swarm
|
|
2
2
|
|
|
3
|
-
> meta: artefacto · RFC-013 · generado arch-structure + enriquecido arch-enrich · anclado a `
|
|
3
|
+
> meta: artefacto · RFC-013 · generado arch-structure + enriquecido arch-enrich · anclado a `v0.9.0` · cobertura: estructura de la suite (`spec/`, `.github/workflows/main.yml`); §e enriquecida, §f enriquecida, §g `unknown` (sin incidentes registrados), §h enriquecida
|
|
4
4
|
|
|
5
5
|
## 1. Resumen
|
|
6
6
|
|
|
@@ -15,7 +15,7 @@ Framework: **RSpec** (`~> 3.0`). `verify_partial_doubles = true` (mocks estricto
|
|
|
15
15
|
| subdirectorio | propósito | nivel | helper |
|
|
16
16
|
|---|---|---|---|
|
|
17
17
|
| `spec/docker/swarm/*_spec.rb` | api, configuration, connection, log_helper, registry_auth | unit | `spec_helper` |
|
|
18
|
-
| `spec/docker/swarm/middleware/*_spec.rb` | error_handler, request_encoder, response_json_parser | unit | `spec_helper` |
|
|
18
|
+
| `spec/docker/swarm/middleware/*_spec.rb` | error_handler, log_stream_demuxer, request_encoder, response_json_parser | unit | `spec_helper` |
|
|
19
19
|
| `spec/docker/swarm/models/*_spec.rb` | base, container, image, network, node, service, task + `shared_crud_spec` | unit | `spec_helper` |
|
|
20
20
|
| `spec/integration/*_spec.rb` | containers, infra, security, services, system | integration | `integration_helper` |
|
|
21
21
|
|
|
@@ -54,7 +54,7 @@ Ninguna. No hay `SimpleCov`/`.simplecov` ni umbral declarado en el repo (verific
|
|
|
54
54
|
- CRUD genérico de `config`, `secret`, `volume`: vía `shared_crud_spec` (`it_behaves_like "a crud resource"`) — no tienen spec dedicado pero **sí** están cubiertos (create/find/destroy). `image` salió del CRUD genérico (su `create` era un pull) → tiene spec propio (abajo).
|
|
55
55
|
- `image`: `image_spec` (dedicado) — `Image.pull` (stream NDJSON, extracción de digest del frame `Digest:`, error tipado ante `error`/`errorDetail`, forma polimórfica del body) + `Deletable` y listado.
|
|
56
56
|
- Auth de registry privado: `registry_auth_spec` (helper `RegistryAuth`: exclusión mutua `registry_auth`/`registry_auth_from`, enum del `from`, traducción a header/query) + bloque registry-auth en `service_spec` (create/update, no-exposición de la credencial en logs).
|
|
57
|
-
- Infra de transporte: `api_spec`, `connection_spec`, `configuration_spec`, `log_helper_spec`, los
|
|
57
|
+
- Infra de transporte: `api_spec`, `connection_spec`, `configuration_spec`, `log_helper_spec`, los 4 middleware specs.
|
|
58
58
|
- `swarm`, `system` (singletons): `swarm_spec`, `system_spec`.
|
|
59
59
|
|
|
60
60
|
**Cubierto (integration, daemon real):** lifecycle de containers, services, infra (networks/volumes), system (info/version/up/df), security (config/secret create+find+destroy).
|
|
@@ -15,6 +15,15 @@ module DockerSwarm
|
|
|
15
15
|
resource.save(registry_auth: registry_auth)
|
|
16
16
|
resource
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
# Atributos que el Engine toma por **query string** en el +create+, no en el body.
|
|
20
|
+
# Un atributo declarado acá viaja en la URL y se excluye del payload: mandarlo en
|
|
21
|
+
# el body no le da error a Docker, lo **descarta en silencio**.
|
|
22
|
+
# Override en el modelo que lo necesite (p. ej. +name+ en {DockerSwarm::Container}).
|
|
23
|
+
# @return [Array<String>] nombres de atributo
|
|
24
|
+
def create_query_params
|
|
25
|
+
[].freeze
|
|
26
|
+
end
|
|
18
27
|
end
|
|
19
28
|
|
|
20
29
|
# @param registry_auth [String, nil] credencial opaca para el header X-Registry-Auth
|
|
@@ -25,7 +34,8 @@ module DockerSwarm
|
|
|
25
34
|
headers, = RegistryAuth.resolve(registry_auth: registry_auth)
|
|
26
35
|
response = Api.request(
|
|
27
36
|
action: self.class.routes[:create],
|
|
28
|
-
|
|
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
|
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
|
---
|
|
@@ -58,7 +61,7 @@ Defaults son razonables: en local sin TLS, no necesitás bloque `configure`.
|
|
|
58
61
|
| `Service` | `all(filters)`, `find(id)`, `where(filters)`, `create(attrs)` | `update(attrs)`, `restart`, `destroy`, `logs(query)`, `reload`, `persisted?`, `id` | CRUD completo + force-recreate de tasks; `create`/`update` aceptan `registry_auth:` (+ `update`: `registry_auth_from:`) para auth de registry privado |
|
|
59
62
|
| `Node` | `all(filters)`, `find(id)`, `where(filters)` | `update(attrs)`, `destroy` | No `create` (los nodos se unen fuera de la gema) |
|
|
60
63
|
| `Task` | `all(filters)`, `find(id)`, `where(filters)` | `logs(query)`, `reload` | Read-only (generados por orquestador) |
|
|
61
|
-
| `Container` | `all(filters)`, `find(id)`, `where(filters)` | `start`, `stop`, `destroy`, `logs(query)` |
|
|
64
|
+
| `Container` | `all(filters)`, `find(id)`, `where(filters)`, `create(attrs)` | `start`, `stop`, `destroy`, `logs(query)` | `create` manda `name` por query string (`create_query_params`) — en el body Docker lo descarta en silencio |
|
|
62
65
|
| `Image` | `all(filters)`, `find(id)`, `pull(image_reference, registry_auth:)` | `destroy` | **No `create`** (retirado; `Image` ya no es Creatable). `pull` = pull explícito síncrono → `{status, image_ref, digest?}`; **soporta `X-Registry-Auth`** para registries privados |
|
|
63
66
|
| `Network` | `all(filters)`, `find(id)`, `create(attrs)` | `update(attrs)`, `destroy` | CRUD completo |
|
|
64
67
|
| `Volume` | `all(filters)`, `find(id)`, `create(attrs)` | `destroy` | No `update` (Docker no lo soporta). Respuesta wrapped vía `root_key = "Volumes"` |
|
|
@@ -95,7 +98,7 @@ service.restart
|
|
|
95
98
|
# Destroy graceful (nil si 404)
|
|
96
99
|
service.destroy
|
|
97
100
|
|
|
98
|
-
# Logs
|
|
101
|
+
# Logs (ya demultiplexados: sin cabeceras de frame)
|
|
99
102
|
service.logs(stdout: 1, stderr: 1)
|
|
100
103
|
|
|
101
104
|
# Health check
|
|
@@ -130,7 +133,8 @@ Todas heredan de `DockerSwarm::Error`. Tres formas de acceso equivalentes: `Dock
|
|
|
130
133
|
- **Retries automáticos sólo en métodos seguros** (GET/HEAD/PUT/DELETE/OPTIONS). POST/PATCH **no** reintentan para evitar duplicados — si el socket se cae durante un `create`, el caller decide qué hacer. Ver §3.5 de `docs/behavior/behavior.md`.
|
|
131
134
|
- **`Spec` se mergea con `deep_merge` en updates**, no se reemplaza. Pasale sólo los campos que cambian: `service.update(Mode: {...})`, no `service.update(Spec: {...completo})`.
|
|
132
135
|
- **`assign_attributes` muta antes de validar.** Si `update` falla por `valid?` o por el API, la instancia local quedó mutada. Hacé `reload` si necesitás estado limpio.
|
|
133
|
-
- **`Container.create`
|
|
136
|
+
- **`Container.create` manda el nombre por query string.** Docker **descarta en silencio** un `name:` en el body y responde `201`: el container nace con nombre aleatorio y un reintento duplica en vez de adoptar. La gema lo resuelve sola vía `Container.create_query_params == %w[name]` — pero si armás el request por afuera (`DockerSwarm.request`), el `?name=` es tuyo. Ver ADR-025 cláusula 1 y §3.11 de `docs/behavior/behavior.md`.
|
|
137
|
+
- **`logs` devuelve texto ya demultiplexado.** Sin TTY el Engine enmarca cada fragmento con 8 bytes de cabecera; `Middleware::LogStreamDemuxer` los saca en `Container`, `Service` y `Task`. Dos consecuencias: **`stdout` y `stderr` vienen intercalados** en un solo String (si necesitás un dato puntual, delimitalo en origen desde el `Cmd`), y **un frame partido entre chunks no se reensambla** — el demux es todo-o-nada, así que ante cualquier inconsistencia te devuelve el body intacto en vez de texto a medias. Con `follow: 1` el body no llega completo, así que no esperes demux ahí.
|
|
134
138
|
- **`Image.create` retirado (breaking).** Ya no existe (`Image` dejó de ser Creatable; el `create` estaba roto y sin consumidores). Usá `Image.pull(image_reference, registry_auth:)`.
|
|
135
139
|
- **Auth de registry privado soportado** vía credencial opaca base64url en `registry_auth:` — `Image.pull(ref, registry_auth:)`, `Service.create(..., registry_auth:)` y `Service#update(..., registry_auth:` / `registry_auth_from:)`. Viaja por header `X-Registry-Auth` (o query `registryAuthFrom`: `spec`\|`previous-spec`, excluyentes); la gema no la mintea ni decodifica. Ver flujos 3.9/3.10 de `docs/behavior/behavior.md`.
|
|
136
140
|
- **`destroy` es graceful con 404** (retorna `nil`), no con 409. Si el recurso está en uso, `Conflict` se propaga.
|
|
@@ -173,8 +177,10 @@ Logs salen en formato KV (`component=docker_swarm.connection event=request_succe
|
|
|
173
177
|
- [`docs/config/configuracion.md`](docs/config/configuracion.md) — inventario de configuración runtime (7 opciones del bloque `configure`, sin env vars, ninguna secreta). El bloque de arriba es el resumen; shape/defaults/consumidores en el detalle.
|
|
174
178
|
- [`docs/topology/topology.md`](docs/topology/topology.md) — dependencias runtime (3) + grafo de contexto.
|
|
175
179
|
- [`docs/test/testing.md`](docs/test/testing.md) — estructura de la suite RSpec (unit + integration) y comandos de corrida.
|
|
180
|
+
- [`docs/release/release.md`](docs/release/release.md) — canal de publicación (tag `v*` → RubyGems) + deploy/rollback/ambientes.
|
|
176
181
|
- `docs/data/` — `n/a` (gema sin DB).
|
|
177
182
|
- `docs/api/` (operaciones), `docs/events/` — `n/a` (la gema no expone superficie HTTP/CLI/eventos propia; su superficie pública es la interfaz Ruby).
|
|
183
|
+
- `docs/security/`, `docs/multi-tenancy/`, `docs/data-lifecycle/` — `n/a` (sin authn/authz propios —la frontera auth-hacia-Docker está en `docs/consumed` §a—; gema stateless sin scope de tenant; sin persistencia/PII/retención).
|
|
178
184
|
|
|
179
185
|
## Versionado del contrato
|
|
180
186
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: docker-swarm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gabriel
|
|
@@ -113,6 +113,7 @@ files:
|
|
|
113
113
|
- lib/docker_swarm/errors.rb
|
|
114
114
|
- lib/docker_swarm/log_helper.rb
|
|
115
115
|
- lib/docker_swarm/middleware/error_handler.rb
|
|
116
|
+
- lib/docker_swarm/middleware/log_stream_demuxer.rb
|
|
116
117
|
- lib/docker_swarm/middleware/request_encoder.rb
|
|
117
118
|
- lib/docker_swarm/middleware/response_json_parser.rb
|
|
118
119
|
- lib/docker_swarm/models/config.rb
|
|
@@ -129,15 +130,15 @@ files:
|
|
|
129
130
|
- lib/docker_swarm/registry_auth.rb
|
|
130
131
|
- lib/docker_swarm/version.rb
|
|
131
132
|
- skill/SKILL.md
|
|
132
|
-
homepage: https://github.com/
|
|
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.9.0/CHANGELOG.md
|
|
140
|
+
bug_tracker_uri: https://github.com/sequre/docker-swarm/issues
|
|
141
|
+
documentation_uri: https://github.com/sequre/docker-swarm/blob/v0.9.0/skill/SKILL.md
|
|
141
142
|
rubygems_mfa_required: 'true'
|
|
142
143
|
rdoc_options: []
|
|
143
144
|
require_paths:
|