docker-swarm 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b67b8f60093fc00f4305221d2f4619ebda26d59979db28cffa12947b1aa2b51
4
- data.tar.gz: 6a220462c6f427c9ed883651d92c7e54f7eb058925d4eb1e3b6f61764c9db545
3
+ metadata.gz: 4c0c5309bd00011e9ccffc6887da8771c9dac05d8bcacd08105fa2a83e05fc1a
4
+ data.tar.gz: 3d931bd5d1282db9ff5b8714361176720b09c83c2753f3875caab70194b7cc7b
5
5
  SHA512:
6
- metadata.gz: 3a9189e2987390ed20f32ca0288d18ee96df35b637f0b675ef8d5ff0e9bddbe5743b8145afbc57dedf1e921e45a5f7031c4b9cd4c91992dfb1120a637aef9ad9
7
- data.tar.gz: aa102d0979a97aed02dab90a0dacf03c89a81001750072957e9ec2ac5a6a8e12aa1ba9b4c08442c00a83af74bfb0291e7723e0b3123b4ae3cacee44fa58d4562
6
+ metadata.gz: 1a77a34705c2774f62b025cb7091dd2bb3930dfac5b43ef3afec90cf93d37c819666320e30fdd93f600e44d7e91ee8a9bac3e687152ff754d3c79d4363a908ee
7
+ data.tar.gz: d7b1bb712c6a2920b98651eb43a3d885f18bc31c5a0a24ff58adce45b55d3792a9a536e4087e7c4a1dab397558ec35b791cb6773acf8a4f1a2d56a7f3e3c592c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.12.0] — 2026-08-18
8
+
9
+ ### Nuevas funcionalidades
10
+ - **`Container` gana `#update`, `#restart` y `#stats`** (#39). Las tres faltaban, y `Base#method_missing` hacía que la ausencia **no se notara**: devolvía `nil` sin levantar y `respond_to?` decía `true`, así que un consumidor no podía distinguir *"se hizo"* de *"no existe la implementación"*. Aguas abajo eso salía como `200 OK` con cuerpo nulo (`sequre/box_cluster_manager#43`) — @gedera
11
+ - **`#stats` fuerza `stream: false`, y eso es lo que hace que el método vuelva.** El endpoint streamea por default: medido contra un Engine **29.7.2**, sin ese parámetro emite un objeto por segundo y **la conexión no cierra**. En una llamada RPC cuelga, y el modo de falla no es un error sino una espera. El parámetro se **mergea** en vez de reemplazarse —a diferencia de `Loggable#logs`—, así que un caller que pasa su propio hash cambia qué mide, no se queda esperando; `stats(stream: true)` sigue disponible a propósito.
12
+ - **`#restart(timeout: nil)`** pega al endpoint real. **No** copia a `Service#restart`, que simula el reinicio incrementando `ForceUpdate` *porque los services de Swarm no tienen endpoint de restart*; los containers sí. Sin `timeout` no se manda `?t=`, que **no** es lo mismo que mandar `0`.
13
+ - **`#update` NO usa `Concerns::Updatable`.** Ese concern manda `?version=<Version.Index>` para la concurrencia optimista de *services*; el update de un container es otro endpoint —límites de recursos— que no tiene ese parámetro y lo **ignoraría en silencio**. Devuelve el **cuerpo** del Engine (`Warnings`), no un booleano: ahí avisa cuando un límite no se pudo aplicar. Y hace `reload` en vez de `assign_attributes`, porque el payload es plano (`Memory`) y el objeto lo tiene anidado (`HostConfig.Memory`) — asignarlo crearía un atributo fantasma.
14
+ - `Api::ENDPOINTS[:containers]` pasa de 7 a 10 rutas.
15
+
16
+ ### Cambios de comportamiento
17
+ - **`save` sobre un `Container` ya persistido ahora levanta `ArgumentError`; antes devolvía `nil`** (#39). `Concerns::Creatable#save` llama `update(registry_auth:)` **sin atributos** cuando el objeto está persistido, así que el payload quedaba vacío — y el Engine **acepta el body vacío respondiendo `200 OK` sin aplicar nada** (medido). O sea que `c.Memory = X; c.save` perdía el cambio sin un solo error. **El `save` genérico no se soporta a propósito:** `POST /containers/{id}/update` no es *"guardar el objeto"*, y derivar el payload de los atributos locales exigiría una whitelist de campos que driftea contra la API. El mensaje del error redirige a `update("Memory" => …)`.
18
+ - **Alcance verificado antes de tomar la decisión:** ningún consumidor del fleet ni ningún spec de la gema llama `.save` sobre un container. Para el resto de los modelos no cambia nada — @gedera
19
+
20
+ ### Seguridad
21
+ - **`Container#update` descarta `registry_auth`/`registry_auth_from` sobre el hash ya mergeado**, no sólo sobre los kwargs (#39). Una credencial pasada en el hash **posicional** habría viajado en el payload al Engine y aparecido en el log de `request_success` (`body=…`). Es la misma clase de fuga que corrigió #24 por el otro lado, en el camino nuevo — @gedera
22
+
23
+ ### Documentación
24
+ - Seis capas movidas con la superficie nueva: `interface`, `consumed` (3 endpoints), `test`, `glossary`, `behavior` (**flujo 3.13**: el payload vacío que el Engine acepta, con la rama `save` → `ArgumentError`) y `errors` §4 (el tercer sitio que eleva `ArgumentError`). Los compuestos (`README.md`, `skill/SKILL.md`, el stanza de `AGENTS.md`) reindexados, con el conteo de flujos **12 → 13** — @gedera
25
+ - `docs/consumed/` §c dejaba de ser cierta como generalización: la frase del `?version=` se escribió para `Service#update` y ahora hay un `update` que **no** lo lleva. Sujeto calificado — @gedera
26
+ - `docs/release/` §a: el campo `versión actual` venía **stale en `0.10.0`** con la gema ya en `0.11.0`. Corregido, y declarado que a ese campo lo invalida **todo** release por construcción — @gedera
27
+
28
+
7
29
  ## [0.11.0] — 2026-08-07
8
30
 
9
31
  ### Correcciones
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 + incremental (12 flujos) |
46
+ | Comportamiento | [`docs/behavior/behavior.md`](docs/behavior/behavior.md) | backfill on-demand + incremental (13 flujos) |
47
47
  | Configuración | [`docs/config/configuracion.md`](docs/config/configuracion.md) | inventario base (7 opciones, sin env vars) |
48
48
  | Interfaz | [`docs/interface/interface.md`](docs/interface/interface.md) | API Ruby pública (11 modelos + Base + concerns) |
49
49
  | Topología | [`docs/topology/topology.md`](docs/topology/topology.md) | 3 deps runtime + grafo de contexto |
@@ -1,6 +1,6 @@
1
1
  # Comportamiento — docker-swarm
2
2
 
3
- > meta: artefacto · RFC-007 · generado dev-enrich · anclado a `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)
3
+ > meta: artefacto · RFC-007 · generado dev-enrich · anclado a `v0.10.0` · cobertura: 13 flujos load-bearing (8 backfill inicial + 5 nuevos: auth de registry privado, `Image.pull` síncrono, `Container.create`, partición query params/filters del listado, `Container#update`/`#stats`) · refresh #39 (flujo 3.13: el payload vacío que el Engine acepta con 200, y el cuelgue de `stats`; el ancla sigue en `v0.10.0` — el re-anclaje va con la release 0.12.0, #40)
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 (12)
11
+ ### Documentados (13)
12
12
 
13
13
  1. `Service.create` + reload
14
14
  2. `Service.update` con `Version.Index`
@@ -22,6 +22,7 @@ Flujos de ejecución load-bearing de `docker-swarm`: cómo se materializan en ru
22
22
  10. `Image.pull` síncrono (stream NDJSON → error tipado → resultado explícito)
23
23
  11. `Container.create` con nombre por query string (`create_query_params`)
24
24
  12. `Model.where` — partición query params propios vs. `?filters=` (`index_query_params`)
25
+ 13. `Container#update` — el payload vacío que el Engine acepta (y `#stats`, que cuelga en vez de fallar)
25
26
 
26
27
  ### No documentados (ausencia ≠ inexistencia, RFC-007)
27
28
 
@@ -355,9 +356,48 @@ flowchart TD
355
356
  - **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
357
  - Sin filtros no hay query: `all` manda `query_params: {}` — el listado por default no cambió.
357
358
 
359
+ ### 3.13 `Container#update` — el payload vacío que el Engine acepta
360
+
361
+ `POST /containers/{id}/update` **no es "guardar el objeto"**: es un endpoint angosto de límites de recursos, y **acepta un body vacío respondiendo `200 OK` sin aplicar nada** (medido contra Engine 29.7.2: body `{}` → `{"Warnings":null}`, `Memory` sin cambiar).
362
+
363
+ Eso importa porque `Concerns::Creatable#save` **llama a `update`** cuando el objeto ya está persistido, y sin atributos. La cadena completa deja el payload en `{}`, así que un `save` se llevaría los cambios locales sin un solo error — el mismo modo de falla silencioso de §3.11, por la otra puerta.
364
+
365
+ ```mermaid
366
+ sequenceDiagram
367
+ actor Caller
368
+ participant Container as DockerSwarm::Container
369
+ participant Api
370
+ participant Docker
371
+
372
+ alt save sobre un objeto persistido
373
+ Caller->>Container: c.Memory = 128.MB; c.save
374
+ Container->>Container: Creatable#save → persisted? → update(registry_auth: nil)
375
+ Container->>Container: {}.merge({registry_auth: nil}).except(:registry_auth, …) → {}
376
+ Container-->>Caller: ArgumentError ("requires at least one attribute")
377
+ Note over Container,Docker: NO se emite request: el 200 no-op nunca ocurre
378
+ else update con límites explícitos
379
+ Caller->>Container: c.update("Memory" => 134217728)
380
+ Container->>Container: merge + except → payload no vacío
381
+ Note over Container,Api: sin ?version= — eso es de Service#update
382
+ Container->>Api: request(:update, payload:)
383
+ Api->>Docker: POST /containers/{id}/update
384
+ Docker-->>Api: 200 { Warnings: … }
385
+ Api-->>Container: { Warnings: … }
386
+ Container->>Container: reload
387
+ Container-->>Caller: { Warnings: … }
388
+ end
389
+ ```
390
+
391
+ **Notas load-bearing:**
392
+ - **No usa `Concerns::Updatable`.** Ese concern manda `?version=<Version.Index>` para la concurrencia optimista de *services*; el update de un container no tiene ese parámetro y el Engine lo **ignora en silencio**. Ver la calificación del sujeto en `docs/consumed/docker-engine-api.md` §c.
393
+ - **Devuelve el cuerpo, no un booleano.** El Engine avisa por `Warnings` cuando un límite no se pudo aplicar; colapsarlo a `true` se comería la señal. Es una asimetría deliberada con `#start`/`#stop` (§3.7), que sí devuelven `true`.
394
+ - **`reload`, y no `assign_attributes`** como hace `Updatable`: el payload es **plano** (`Memory`) y el objeto lo tiene **anidado** (`HostConfig.Memory`), así que asignarlo crearía un atributo fantasma en vez de actualizar el real.
395
+ - **El `except` va sobre el merge**, no sólo sobre los kwargs: una credencial pasada en el hash **posicional** llegaría al payload y de ahí al log (`body=…`).
396
+ - **`#stats` comparte la forma del problema**, con otra cara: su default (`stream=true`) no falla, **cuelga** — la conexión queda abierta emitiendo un objeto por segundo. Por eso el método fuerza `stream: false` **mergeando** (no reemplazando como `Loggable#logs` en §3.8): un caller que pasa su propio hash cambia qué mide, no se queda esperando para siempre.
397
+
358
398
  ## 4. Cobertura y fronteras
359
399
 
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.
400
+ - **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 + 1 con `Container#update`/`#stats` (#39) = 13. Esta gema es chica; el backfill completo era factible y se hizo, y a partir de ahí se acreta por PR.
361
401
  - **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.
362
402
  - **Frontera con configuración:** `DockerSwarm.configure` es boot, no flujo de negocio. No se diagrama.
363
403
  - **No localizable / fuera de alcance:**
@@ -1,6 +1,7 @@
1
1
  # Dependencias consumidas — docker-swarm
2
2
 
3
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
+ > · refresh #39 (3 endpoints nuevos de containers: `update`/`restart`/`stats`; el ancla sigue en `v0.10.0` — el re-anclaje va con la release 0.12.0, #40)
4
5
 
5
6
  ## 1. Resumen
6
7
 
@@ -54,6 +55,9 @@ Subset que la gema invoca, derivado de `Api::ENDPOINTS` (`api.rb:5-72`). `destin
54
55
  | containers | start / stop | `POST containers/%<id>s/start`, `POST containers/%<id>s/stop` | — / — |
55
56
  | containers | destroy | `DELETE containers/%<id>s` | — / — |
56
57
  | containers | logs | `GET containers/%<id>s/logs` | `?stdout/stderr/...` / stream multiplexado (demux en el cliente) |
58
+ | containers | update | `POST containers/%<id>s/update` | payload de límites de recursos, **sin `?version=`** (eso es de services) / `{Warnings}` |
59
+ | containers | restart | `POST containers/%<id>s/restart` | `?t=` opcional (segundos antes de matar) / — |
60
+ | containers | stats | `GET containers/%<id>s/stats` | **`?stream=false` por default** (pisable explícitamente con `stats(stream: true)`; con el default del Engine la llamada **no vuelve**) / Hash de métricas |
57
61
  | images | index | `GET images/json` | — / array |
58
62
  | images | show | `GET images/%<id>s/json` | — / Hash |
59
63
  | images | pull | `POST images/create?fromImage=<ref>` | header `X-Registry-Auth` (opcional, registry privado) / stream NDJSON de progreso |
@@ -123,7 +127,7 @@ El daemon responde con status HTTP; `Middleware::ErrorHandler` los mapea a la je
123
127
 
124
128
  - `GET`/`DELETE`/`PUT` son seguros de reintentar: re-listar, re-borrar (404 → `nil` graceful) o re-actualizar produce el mismo estado final → la gema los reintenta automáticamente ante caída de socket.
125
129
  - `POST create` (services/networks/volumes/configs/secrets/containers) **no** se reintenta: un replay tras fallo parcial podría crear un recurso duplicado (el daemon no deduplica por nombre en todos los recursos). El caller decide qué hacer si un `create` falla por `Communication`.
126
- - `POST update`/`start`/`stop`/`restart` tampoco se reintentan (son POST); `update` además acarrea `?version=` un replay con versión vieja daría 409 `Conflict`, no un duplicado.
130
+ - `POST update`/`start`/`stop`/`restart` tampoco se reintentan (son POST). ⚠️ **El `?version=` es de `Service#update`, NO de containers**: ahí un replay con versión vieja daría 409 `Conflict` en vez de un duplicado. `Container#update` **no lo lleva** (#39) — es otro endpoint, de límites de recursos: un replay re-aplica el mismo límite, así que es idempotente en efecto. `GET stats` cae bajo la regla genérica de arriba (`IDEMPOTENT_METHODS`).
127
131
  - **Sin backoff** es aceptable acá: el socket Unix local rara vez está transitoriamente saturado; ante un daemon caído, 3 reintentos inmediatos fallan rápido y se propaga `Communication`.
128
132
 
129
133
  #### e. Degradación (si la dependencia cae)
@@ -100,4 +100,4 @@ No aplica un shape propio tipo RFC 7807: la gema es un cliente, no un servidor.
100
100
  - **Solo errores públicos:** estas excepciones cruzan la frontera de la gema hacia el consumidor. No hay excepciones internas rescatadas-y-tragadas que documentar (salvo `JSON::ParserError` en `ResponseJSONParser`, que se traga y retorna el body crudo — interno, no contrato).
101
101
  - **Frontera con consumed (RFC-018):** este catálogo = lo que la gema **emite**. El mapeo "error del proveedor Docker → excepción nuestra" lo referencia [`docs/consumed/docker-engine-api.md`](../consumed/docker-engine-api.md) §d, que apunta acá.
102
102
  - **Política §c:** enriquecida (recomendación al consumidor); el matiz por-status lo confirma el humano contra el comportamiento real del daemon (ver §3).
103
- - **Validación de input del caller (`ArgumentError`, stdlib):** `RegistryAuth.resolve`/`validate!` (exclusión mutua `registry_auth`/`registry_auth_from` + enum del `from`) y `Base#assign_attributes` (no-Hash) elevan `ArgumentError` ante input inválido del caller — fail-fast, antes de tocar el daemon. Es contrato público de esas firmas (documentado en [`docs/interface/interface.md`](../interface/interface.md)), **no** parte de la jerarquía `DockerSwarm::Error` → por eso no está en §a/§c.
103
+ - **Validación de input del caller (`ArgumentError`, stdlib):** `RegistryAuth.resolve`/`validate!` (exclusión mutua `registry_auth`/`registry_auth_from` + enum del `from`), `Base#assign_attributes` (no-Hash) y `Container#update` (payload vacío tras descartar los `registry_auth`: el Engine lo aceptaría con `200 OK` sin aplicar nada, #39) elevan `ArgumentError` ante input inválido del caller — fail-fast, antes de tocar el daemon. Es contrato público de esas firmas (documentado en [`docs/interface/interface.md`](../interface/interface.md)), **no** parte de la jerarquía `DockerSwarm::Error` → por eso no está en §a/§c.
@@ -1,6 +1,7 @@
1
1
  # Glosario — docker-swarm
2
2
 
3
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
+ > · refresh #39 (la superficie que expone la gema para Container; el ancla sigue en `v0.9.0` — el re-anclaje va con la release 0.12.0, #40)
4
5
 
5
6
  ## 1. Resumen
6
7
 
@@ -25,7 +26,7 @@ Unidad de ejecución de un Service en un Node específico. Read-only: las tasks
25
26
 
26
27
  ## Container
27
28
 
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
+ Container Docker standalone (no Swarm). La gema expone create/start/stop/restart/stats/update/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
30
  **Binding:** [`DockerSwarm::Container`](../../lib/docker_swarm/models/container.rb)
30
31
 
31
32
  ## Image
@@ -1,6 +1,7 @@
1
1
  # Interfaz — docker-swarm
2
2
 
3
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
+ > · refresh #39 (superficie de `Container`: `#restart`/`#stats`/`#update`; el ancla sigue en `v0.10.0` — el re-anclaje va con la release 0.12.0, #40)
4
5
 
5
6
  ## 1. Resumen
6
7
 
@@ -80,7 +81,7 @@ Inventario completo de opciones: [`docs/config/configuracion.md`](../config/conf
80
81
  | `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` |
81
82
  | `DockerSwarm::Node` | clase < Base | Updatable, Deletable (sin `create`: los nodos se unen fuera de la gema) |
82
83
  | `DockerSwarm::Task` | clase < Base | Loggable (read-only; generadas por el orquestador) |
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; `.index_query_params == %i[all limit size]` — `since`/`before` son **filtros** de `/containers/json`, no query params (#35) |
84
+ | `DockerSwarm::Container` | clase < Base | Creatable, Deletable, Loggable; `#start`, `#stop`, `#restart(timeout: nil)`, `#stats(query_params = {})`, `#update(attrs = {}, **opts)` (#39). **`#update` NO usa `Concerns::Updatable`**: ese concern manda `?version=` para la concurrencia optimista de *services*, y el update de un container es otro endpoint (límites de recursos) sin ese param. Devuelve el cuerpo del Engine (`Warnings`), **no** un booleano, y `reload`ea; **levanta `ArgumentError` con payload vacío**, así que `save` sobre un container persistido falla fuerte en vez de postear `{}` y perder los cambios en silencio. **`#stats` fuerza `stream: false`** — con el default el endpoint streamea y la llamada nunca vuelve; `.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; `.index_query_params == %i[all limit size]` — `since`/`before` son **filtros** de `/containers/json`, no query params (#35) |
84
85
  | `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); `.index_query_params == %i[all digests]` — `since`/`before` son **filtros** de `/images/json`, no query params (#35) |
85
86
  | `DockerSwarm::Network` | clase < Base | Creatable, Updatable, Deletable |
86
87
  | `DockerSwarm::Volume` | clase < Base | Creatable, Deletable; `.root_key = "Volumes"` (respuesta wrapped) |
@@ -1,6 +1,6 @@
1
1
  # Release — docker-swarm
2
2
 
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)
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) · refresh v0.12.0 (#40 — el campo `versión actual` venía **stale en `0.10.0`** con la gema ya en `0.11.0`: el release anterior no lo movió. Corregido acá, y de paso queda dicho que a este campo lo invalida **todo** release por construcción, no sólo los que tocan esta capa)
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.10.0` | `lib/docker_swarm/version.rb` (`DockerSwarm::VERSION`) |
15
+ | versión actual | `0.12.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,7 @@
1
1
  # Test — docker-swarm
2
2
 
3
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
+ > · refresh #39 (cobertura de `update`/`restart`/`stats`, unit + integration; el ancla sigue en `v0.10.0` — el re-anclaje va con la release 0.12.0, #40)
4
5
 
5
6
  ## 1. Resumen
6
7
 
@@ -50,11 +51,12 @@ Ninguna. No hay `SimpleCov`/`.simplecov` ni umbral declarado en el repo (verific
50
51
 
51
52
  **Cubierto (unit, con mocks):**
52
53
  - Mapeo de errores HTTP → excepción: `error_handler_spec` (subset de status verificado: 200, 404, 429, 500 — no los 14).
53
- - Modelos con métodos propios: `service` (incl. lógica de update/version), `node`, `task`, `container` (start/stop), `network`, `base` (accessors dinámicos, `assign_attributes`/`Spec` merge).
54
+ - Modelos con métodos propios: `service` (incl. lógica de update/version), `node`, `task`, `container` (start/stop/restart/stats/update), `network`, `base` (accessors dinámicos, `assign_attributes`/`Spec` merge).
54
55
  - 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
56
  - `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
57
  - 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
58
  - 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; y #35: los tres query params de `ContainerList`, `since` ruteado a filtros, `size` a la URL, `force` ausente), `image_spec` (#35: `%i[all digests]`, `since` a filtros, `digests` a la URL). 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?`.
59
+ - Superficie de `Container` que faltaba (#39): `container_spec` cubre `restart` (pega al endpoint real, **no** simula con `ForceUpdate` como `Service`; `t` sólo si le pasan `timeout`), `stats` (**fuerza `stream: false`** — si ese ejemplo se cae, el método deja de volver; y **mergea** en vez de reemplazar, para que un caller no se cuelgue sin querer) y `update` (sin `?version=`; devuelve el cuerpo y no un booleano; `reload`ea; **levanta con payload vacío**, que es lo que hace que `save` sobre un persistido falle fuerte en vez de postear `{}`; descarta `registry_auth` tanto como kwarg como clave String del hash posicional). Integration: `containers_spec` corre los tres contra el daemon — **`stats` bajo `Timeout.timeout(15)`, que es lo único que un unitario no puede cubrir** (mockeando `Api.request` se verifica que mandamos `stream: false`, no que eso evite el cuelgue), `restart` comparando `StartedAt`, y `update` verificando el límite en el objeto local **y** en el Engine.
58
60
  - Infra de transporte: `api_spec`, `connection_spec`, `configuration_spec`, `log_helper_spec`, los 4 middleware specs.
59
61
  - `swarm`, `system` (singletons): `swarm_spec`, `system_spec`.
60
62
 
@@ -61,7 +61,10 @@ module DockerSwarm
61
61
  start: { method: :post, path: "containers/%<id>s/start" },
62
62
  stop: { method: :post, path: "containers/%<id>s/stop" },
63
63
  destroy: { method: :delete, path: "containers/%<id>s" },
64
- logs: { method: :get, path: "containers/%<id>s/logs" }
64
+ logs: { method: :get, path: "containers/%<id>s/logs" },
65
+ update: { method: :post, path: "containers/%<id>s/update" },
66
+ restart: { method: :post, path: "containers/%<id>s/restart" },
67
+ stats: { method: :get, path: "containers/%<id>s/stats" }
65
68
  },
66
69
  images: {
67
70
  index: { method: :get, path: "images/json" },
@@ -45,5 +45,114 @@ module DockerSwarm
45
45
  Api.request(action: self.class.routes[:stop], arguments: { id: self.ID })
46
46
  true
47
47
  end
48
+
49
+ # Actualiza los límites de recursos del container (+POST /containers/{id}/update+).
50
+ #
51
+ # ⚠️ **NO usa {Concerns::Updatable}, y no es un olvido.** Ese concern está escrito para
52
+ # services de Swarm: manda +?version=<Version.Index>+ para el control de concurrencia
53
+ # optimista y serializa con +payload_for_docker+. El update de un **container** no tiene
54
+ # +version+ — es otro endpoint con otra semántica (cpu, memoria, reinicio) — así que
55
+ # incluirlo mandaría un query param que el Engine **ignora en silencio**.
56
+ #
57
+ # 🚦 **La firma absorbe kwargs a propósito.** {Concerns::Creatable#save} llama
58
+ # +update(registry_auth:)+ cuando el objeto ya está persistido; con una firma
59
+ # +update(new_attributes = {})+ ese kwarg se convierte en Hash posicional (Ruby 3) y
60
+ # terminaría **posteado como atributo**: +{"registry_auth": null}+ hacia el Engine, sin
61
+ # error. Los dos de registry se descartan acá porque son cosa de +Service+ (header
62
+ # +X-Registry-Auth+ / query +registryAuthFrom+); un container no autentica en su update.
63
+ #
64
+ # Devuelve el cuerpo de la respuesta —Docker responde +{"Warnings": [...]}+— y **no un
65
+ # booleano**: el Engine avisa por ahí cuando un límite no se pudo aplicar. Colapsarlo a
66
+ # +true+ se comería justamente la señal.
67
+ #
68
+ # 🚦 **Un payload vacío levanta, y por eso `save` sobre un container persistido NO funciona
69
+ # — falla fuerte.** {Concerns::Creatable#save} llama +update(registry_auth:)+ **sin
70
+ # atributos**, así que el payload queda en +{}+ y el Engine responde **+200 OK+ sin aplicar
71
+ # nada** (medido: body +{}+ → +{"Warnings":null}+, +Memory+ sin cambiar). Dejarlo pasar
72
+ # convertiría a +save+ en una promesa vacía: el caller asigna atributos, llama +save+, y
73
+ # los pierde sin un solo error.
74
+ #
75
+ # No se soporta el +save+ genérico a propósito: +POST /containers/{id}/update+ **no es
76
+ # "guardar el objeto"**, es un endpoint angosto de límites de recursos. Derivar el payload
77
+ # de los atributos locales exigiría una whitelist de los campos que Docker acepta, que
78
+ # driftea contra la API. Mejor decirlo que fingirlo.
79
+ #
80
+ # @param new_attributes [Hash] límites de recursos en el shape de Docker (+Memory+,
81
+ # +NanoCpus+, +RestartPolicy+, …)
82
+ # @param opts [Hash] atributos sueltos; +registry_auth+ y +registry_auth_from+ se descartan
83
+ # @raise [ArgumentError] si no queda ningún atributo para mandar
84
+ # @return [Hash] cuerpo de la respuesta del Engine (+Warnings+)
85
+ def update(new_attributes = {}, **opts)
86
+ # El `except` va sobre el MERGE, no sólo sobre `opts`: un caller que pase
87
+ # `{registry_auth: "…", Memory: …}` como Hash **posicional** metería la credencial en el
88
+ # payload, y de ahí al log (`body=…`) — la misma fuga que arregló #24 por el otro lado.
89
+ payload = new_attributes.merge(opts).except(:registry_auth, :registry_auth_from,
90
+ "registry_auth", "registry_auth_from")
91
+
92
+ if payload.empty?
93
+ raise ArgumentError,
94
+ "Container#update requires at least one attribute. An empty payload gets a 200 OK " \
95
+ "from the Engine and applies nothing. Coming from `save`? Containers do not support " \
96
+ "the generic save: use `update(\"Memory\" => ...)` with explicit resource limits."
97
+ end
98
+
99
+ response = Api.request(
100
+ action: self.class.routes[:update],
101
+ arguments: { id: self.ID },
102
+ payload: payload
103
+ )
104
+
105
+ # El estado local queda stale si no se recarga: el payload es **plano** (`Memory`) pero el
106
+ # objeto lo tiene anidado (`HostConfig.Memory`), así que un `assign_attributes` —lo que
107
+ # hace {Concerns::Updatable}— crearía un atributo fantasma en vez de actualizar el real.
108
+ # `reload` trae la forma correcta del Engine; es el mismo cierre que usa `save` al crear.
109
+ reload
110
+ response
111
+ end
112
+
113
+ # Reinicia el container (+POST /containers/{id}/restart+).
114
+ #
115
+ # ⚠️ **No se parece a {Service#restart}, y está bien.** El hermano simula el restart
116
+ # incrementando +ForceUpdate+ **porque los services de Swarm no tienen endpoint de
117
+ # restart**. Los containers sí lo tienen, así que copiar ese workaround sería arrastrar
118
+ # una vuelta que acá no hace falta.
119
+ #
120
+ # @param timeout [Integer, nil] segundos a esperar antes de matar el proceso (+t+ de
121
+ # Docker). +nil+ deja el default del Engine, que **no** es lo mismo que mandar +0+
122
+ # (eso mataría sin gracia).
123
+ # @return [Boolean] true si el Engine aceptó el reinicio
124
+ def restart(timeout: nil)
125
+ Api.request(
126
+ action: self.class.routes[:restart],
127
+ arguments: { id: self.ID },
128
+ query_params: timeout.nil? ? {} : { t: timeout }
129
+ )
130
+ true
131
+ end
132
+
133
+ # Métricas de uso del container (+GET /containers/{id}/stats+).
134
+ #
135
+ # 🚦 **`stream: false` NO es un default cómodo: es lo que hace que el método vuelva.**
136
+ # El endpoint streamea por default —+stream=true+— y la conexión queda abierta emitiendo
137
+ # un objeto por segundo. Medido contra un Engine **29.7.2**: sin el parámetro, 6 objetos
138
+ # en 6 s y la conexión **no cierra** (+exit 28+ de curl); con +stream=false+, un objeto y
139
+ # cierra en 1 s. En una llamada RPC el default **cuelga** — y el modo de falla no es un
140
+ # error, es una espera. Mismo patrón que ADR-027: el default de Docker no es el que sirve.
141
+ #
142
+ # Por eso el parámetro se **mergea** en vez de reemplazarse, que es como lo hace
143
+ # {Concerns::Loggable#logs}: ahí un caller que pasa su propio hash sólo cambia qué streams
144
+ # lee; acá lo dejaría colgado. Se puede pisar a propósito (+stats(stream: true)+), pero no
145
+ # por accidente.
146
+ #
147
+ # @param query_params [Hash] parámetros extra (+one-shot+, …). +stream+ va en +false+
148
+ # salvo que se lo pise explícitamente.
149
+ # @return [Hash] snapshot de métricas parseado
150
+ def stats(query_params = {})
151
+ Api.request(
152
+ action: self.class.routes[:stats],
153
+ arguments: { id: self.ID },
154
+ query_params: { stream: false }.merge(query_params)
155
+ )
156
+ end
48
157
  end
49
158
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DockerSwarm
4
- VERSION = "0.11.0"
4
+ VERSION = "0.12.0"
5
5
  end
data/skill/SKILL.md CHANGED
@@ -20,7 +20,7 @@ triggers:
20
20
  - "docker-swarm gem"
21
21
  - "Docker Engine API desde Ruby"
22
22
  - "Service.create / Service.update / Service.restart"
23
- - "Container.create / Container.start / Container.stop"
23
+ - "Container.create / Container.start / Container.stop / Container.restart / Container.stats / Container.update"
24
24
  - "helper container efímero"
25
25
  - "logs de un servicio Docker"
26
26
  - "Version.Index"
@@ -61,7 +61,7 @@ Defaults son razonables: en local sin TLS, no necesitás bloque `configure`.
61
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`) |
62
62
  | `Node` | `all(filters)`, `find(id)`, `where(filters)` | `update(attrs)`, `destroy` | No `create` (los nodos se unen fuera de la gema) |
63
63
  | `Task` | `all(filters)`, `find(id)`, `where(filters)` | `logs(query)`, `reload` | Read-only (generados por orquestador) |
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 |
64
+ | `Container` | `all(filters)`, `find(id)`, `where(filters)`, `create(attrs)` | `start`, `stop`, `restart(timeout:)`, `stats(params)`, `update(attrs)`, `destroy`, `logs(query)` | `create` manda `name` por query string (`create_query_params`) — en el body Docker lo descarta en silencio. **`stats` fuerza `stream: false`**: con el default la llamada no vuelve. **`update` levanta con payload vacío**, así que `save` sobre un container persistido NO funciona: no se soporta el save genérico (#39) |
65
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 |
66
66
  | `Network` | `all(filters)`, `find(id)`, `create(attrs)` | `update(attrs)`, `destroy` | CRUD completo |
67
67
  | `Volume` | `all(filters)`, `find(id)`, `create(attrs)` | `destroy` | No `update` (Docker no lo soporta). Respuesta wrapped vía `root_key = "Volumes"` |
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.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel
@@ -136,9 +136,9 @@ licenses:
136
136
  metadata:
137
137
  homepage_uri: https://github.com/sequre/docker-swarm
138
138
  source_code_uri: https://github.com/sequre/docker-swarm
139
- changelog_uri: https://github.com/sequre/docker-swarm/blob/v0.11.0/CHANGELOG.md
139
+ changelog_uri: https://github.com/sequre/docker-swarm/blob/v0.12.0/CHANGELOG.md
140
140
  bug_tracker_uri: https://github.com/sequre/docker-swarm/issues
141
- documentation_uri: https://github.com/sequre/docker-swarm/blob/v0.11.0/skill/SKILL.md
141
+ documentation_uri: https://github.com/sequre/docker-swarm/blob/v0.12.0/skill/SKILL.md
142
142
  rubygems_mfa_required: 'true'
143
143
  rdoc_options: []
144
144
  require_paths: