Skip to content

feat(container): update, restart y stats - #41

Merged
gedera merged 6 commits into
mainfrom
feat/container-update-restart-stats
Aug 18, 2026
Merged

feat(container): update, restart y stats#41
gedera merged 6 commits into
mainfrom
feat/container-update-restart-stats

Conversation

@gedera

@gedera gedera commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Causa raíz

DockerSwarm::Container no implementa update, restart ni stats, y la ausencia no se notalib/docker_swarm/base.rb:158-160:

elsif !method_str.end_with?("?")
  _define_dynamic_accessor(method_str)
  nil

Cualquier método desconocido que no termine en ? define un accessor y devuelve nil, sin NoMethodError. Medido:

c.start    respond_to?=true   → implementado
c.stop     respond_to?=true   → implementado
c.logs     respond_to?=true   → implementado

c.update   respond_to?=true   → devolvió nil   (NO levantó)
c.restart  respond_to?=true   → devolvió nil   (NO levantó)
c.stats    respond_to?=true   → devolvió nil   (NO levantó)

respond_to? también miente, así que un consumidor defensivo que chequee antes de llamar recibe lo mismo. Aguas abajo, el containers_controller del BCM serializa ese nil y responde 200 OK con cuerpo nulo (sequre/box_cluster_manager#43).

Cuatro decisiones, y ninguna es copiar al hermano

① No se incluye Concerns::Updatable. Está escrito para services de Swarm: manda query_params: { version: current_version } (updatable.rb:26) para el control de concurrencia optimista. El POST /containers/{id}/update no tiene version — es otro endpoint, con otra semántica (límites de recursos). Incluirlo mandaría un query param que el Engine ignora en silencio.

update absorbe kwargs a propósito, y esto es lo menos obvio del PR. Concerns::Creatable#save:32 hace:

return update(registry_auth: registry_auth) if persisted?

Con la firma natural update(new_attributes = {}), ese kwarg se convierte en Hash posicional en Ruby 3 — medido:

def update(new_attributes = {})          →  update(registry_auth: nil)  da  {registry_auth: nil}
def update(new_attributes = {}, **opts)  →  lo separa:  [{}, {registry_auth: nil}]

O sea que save sobre un container persistido postearía {"registry_auth": null} al Engine, sin error. Basura silenciosa: el mismo tipo de bug que este PR viene a cerrar, introducido al cerrarlo. Los dos de registry se descartan porque son cosa de Service.

Y update devuelve el cuerpo, no un booleano. Docker responde {"Warnings": [...]} y ahí avisa cuando un límite no se pudo aplicar; colapsarlo a true se comería justamente esa señal.

restart no copia a Service#restart. El hermano simula el restart incrementando ForceUpdate (service.rb:40-43) porque los services de Swarm no tienen endpoint de restart. Los containers sí. Copiarlo sería arrastrar un workaround que acá no hace falta.

stats fuerza stream: false, y por eso el método vuelve. Medido contra Engine 29.7.2:

GET /containers/{id}/stats                  exit 28 (timeout) · 6 objetos en 6s · la conexión NO cierra
GET /containers/{id}/stats?stream=false     exit 0 · 1 objeto · 1s · cierra sola

En una llamada RPC el default cuelga — y el modo de falla no es un error, es una espera. Mismo patrón que ADR-027: el default de Docker no es el que sirve.

El parámetro se mergea en vez de reemplazarse, a diferencia de Loggable#logs. Ahí un caller que pasa su propio hash sólo cambia qué streams lee; acá lo dejaría colgado. Se puede pisar a propósito (stats(stream: true)), pero no por accidente.

Evidencia — contra un Engine real, con efecto semántico

No alcanza con el 200: se verificó que hicieran lo que prometen.

Método HTTP Query enviada Devolvió Efecto verificado
stats 200 {stream: false} Hash con memory_stats.usage, en 1.0s no colgó
restart 204 {t: 1} true StartedAt 13:36:06.844 → 13:36:08.255
update 200 {} {"Warnings" => nil} Memory 67108864 → 134217728
ruby -c        Syntax OK  (los 2 archivos)
rubocop        2 files inspected, no offenses detected

Qué NO prueba

  • La suite de la gema todavía no corrió, ni hay tests nuevos: este PR sale con la solución sola para revisar la forma. Van en el commit siguiente.
  • Un solo Engine (29.7.2, arm64-darwin). El comportamiento de stats sin stream podría diferir en otras versiones; lo que sí es estable es que el default del spec es stream=true.
  • update se probó con Memory/MemorySwap. No se ejercitaron los demás campos (NanoCpus, RestartPolicy, …) ni el caso donde el Engine devuelve warnings.

Alcance

  • lib/docker_swarm/api.rb — 3 rutas nuevas en containers:.
  • lib/docker_swarm/models/container.rb — los 3 métodos.

Qué NO hace: no toca Base, ni el method_missing que es la causa de fondo del silencio (eso es superficie compartida por los 11 modelos y merece su propia decisión); no trae tests ni incremento de doc todavía; y no incluye el release ni los bumps, que son #40.

Corrección de un dato del issue de origen: decía que Api::ENDPOINTS[:containers] declaraba 5 rutas. Son 7 — omitía index y show. No cambia el trabajo.

Closes #39
Part of #30

`DockerSwarm::Container` no implementaba las tres, y el `method_missing` de
`Base` hacia que la ausencia NO se notara: cualquier metodo desconocido que no
termine en `?` define un accessor y devuelve nil (base.rb:158-160). Peor,
`respond_to?` tambien devuelve true, asi que un consumidor defensivo que
chequee antes de llamar recibe la misma mentira. Aguas abajo el BCM serializa
ese nil y responde 200 OK con cuerpo nulo.

Tres decisiones, ninguna copiada del hermano:

1. NO se incluye Concerns::Updatable. Ese concern es para services de Swarm:
   manda ?version= para concurrencia optimista (updatable.rb:26). El update de
   un container no tiene version — otro endpoint, otra semantica — asi que
   incluirlo mandaria un query param que el Engine ignora en silencio.

2. `update` absorbe kwargs a proposito. Creatable#save llama
   `update(registry_auth:)` cuando el objeto esta persistido; con la firma
   simple ese kwarg se vuelve Hash posicional en Ruby 3 (medido) y terminaria
   posteado como atributo: {"registry_auth": null} hacia el Engine, sin error.
   Los dos de registry se descartan: son cosa de Service.

   Y devuelve el cuerpo, no un booleano: Docker responde {"Warnings": [...]} y
   ahi avisa cuando un limite no se pudo aplicar. Colapsarlo a true se comeria
   la senal.

3. `restart` NO copia a Service#restart. El hermano simula con ForceUpdate
   PORQUE los services no tienen endpoint de restart; los containers si.

4. `stats` fuerza stream: false, y por eso el metodo vuelve. El endpoint
   streamea por default. Medido contra Engine 29.7.2: sin el parametro, 6
   objetos en 6s y la conexion NO cierra (exit 28 de curl); con stream=false,
   un objeto y cierra en 1s. En RPC el default cuelga, y el modo de falla no es
   un error sino una espera.

   El parametro se MERGEA en vez de reemplazarse — a diferencia de
   Loggable#logs, donde un caller que pasa su hash solo cambia que streams lee;
   aca lo dejaria colgado.

Verificado contra un Engine real, con efecto semantico y no solo el 200:

  stats    200 · query {stream: false} · Hash con memory_stats.usage en 1.0s
  restart  204 · query {t: 1} · true · StartedAt se movio
  update   200 · {"Warnings" => nil} · Memory 67108864 -> 134217728

Sin tests todavia: van despues de la revision de forma.

Closes #39
Part of #30
@gedera gedera added the bug Something isn't working label Aug 17, 2026
@gedera gedera self-assigned this Aug 17, 2026
Los 4 CONFIRMED por la verificacion adversarial, y los 4 son de este PR: al
hacer que `update` tolerara el kwarg de `Creatable#save` cree un agujero nuevo.

F1 (3/5, high) — `save` sobre un container persistido posteaba {} y perdia los
cambios locales EN SILENCIO. Creatable#save:32 llama `update(registry_auth:)`
sin atributos; tras el except el payload quedaba vacio y el Engine responde 200
sin aplicar nada — medido: body {} -> {"Warnings":null}, Memory sin cambiar.
Antes devolvia nil por method_missing, asi que no es regresion; pero tuve la
oportunidad de arreglarlo y en cambio lo hice PARECER que funcionaba, que es
peor. Ahora un payload vacio levanta ArgumentError con un mensaje que explica y
redirige a `update("Memory" => ...)`.

El save generico NO se soporta a proposito: POST /containers/{id}/update no es
"guardar el objeto", es un endpoint angosto de limites de recursos. Derivar el
payload de los atributos locales exigiria una whitelist de campos que driftea
contra la API. Mejor decirlo que fingirlo.

F2 (3/5) — `save` devolvia el Hash del Engine en vez de un booleano, rompiendo
su contrato. Se resuelve por F1: ese camino ahora levanta.

F3 (2/5) — `update` dejaba el estado local stale. Se agrega `reload` tras el
200, y NO `assign_attributes` como hace Concerns::Updatable: el payload es
plano (Memory) y el objeto lo tiene anidado (HostConfig.Memory), asi que
asignarlo crearia un atributo fantasma en vez de actualizar el real. `reload`
trae la forma correcta y es el mismo cierre que usa `save` al crear.

F4 (1/5, security) — el except filtraba solo `opts`, asi que una credencial
pasada POSICIONALMENTE ({registry_auth: "...", Memory: ...}) viajaba en el
payload y salia en el log (body=...). Ahora el except va sobre el MERGE, y
cubre las claves como Symbol y como String. Es la misma fuga que #24 arreglo
por el otro lado.

Verificado contra Engine 29.7.2:

  save sobre persistido       -> ArgumentError (antes: 200 OK silencioso)
  credencial posicional       -> filtrada, no viajo
  update camino feliz         -> HostConfig.Memory 67108864 -> 134217728 sin reload manual
  rubocop                     -> no offenses

Part of #39
@gedera

gedera commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Review multi-modelo — 5 revisaron, 4 findings, los 4 CONFIRMED y los 4 aplicados

claude/sonnet · codex · opencode-bigpickle · opencode-glm · agy. Con la evidencia contra el Engine adjunta y cinco archivos que el PR no toca incluidos como lectura (base.rb, creatable.rb, updatable.rb, loggable.rb, service.rb) — sin ellos las cuatro decisiones del PR son irrevisables.

Los cuatro hallazgos son de este PR, y el grave muestra algo incómodo: al hacer que update tolerara el kwarg de save, abrí un agujero nuevo.

Finding Acuerdo Verify Qué se hizo
F1 🔴 save sobre persistido postea {} y pierde los cambios locales en silencio 3/5 levanta ArgumentError
F2 🟠 save devolvía el Hash del Engine, rompiendo su contrato booleano 3/5 se resuelve por F1
F3 🟠 update dejaba el estado local stale 2/5 reload tras el 200
F4 🟡 el except filtraba sólo optsuna credencial posicional viajaba al payload y al log 1/5 el except va sobre el merge

F1 — el que más duele, porque lo introduje al arreglar otra cosa

Creatable#save:32 llama update(registry_auth:) sin atributos. Tras el except el payload quedaba en {}, y el Engine acepta el no-op. Verificado, porque dos revisores lo marcaron como supuesto:

POST /containers/{id}/update  con body {}
  → http=200 · {"Warnings":null} · Memory sin cambiar (67108864 antes y después)

Así que c.Memory = X; c.save perdía el cambio sin un solo error. Antes de este PR devolvía nil por method_missingno es regresión, pero tuve la oportunidad de cerrarlo y en cambio lo hice parecer que funcionaba, que es peor.

El save genérico no se soporta, a propósito. POST /containers/{id}/update no es "guardar el objeto": es un endpoint angosto de límites de recursos. Derivar el payload de los atributos locales —la otra opción que propuso un revisor— exigiría una whitelist de campos que driftea contra la API. Mejor decirlo que fingirlo, y el mensaje del error redirige a update("Memory" => …).

(Verificado antes de elegir: nadie llama .save sobre containers — ni los consumidores ni los specs de la gema. Hacerlo fallar fuerte no rompe a nadie.)

F3 — donde me aparté de lo que proponían los revisores

Sugerían assign_attributes, que es lo que hace Concerns::Updatable. Acá sería incorrecto: el payload de update 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. Se usa reload, que trae la forma correcta del Engine y es el mismo cierre que ya usa save al crear.

Evidencia de los cuatro, contra Engine 29.7.2

save sobre persistido    → ArgumentError con el mensaje que redirige   (antes: 200 OK silencioso)
credencial posicional    → filtrada por el except del merge, no viajó
update camino feliz      → HostConfig.Memory 67108864 → 134217728, sin reload manual
                           (el GET del reload se ve en el log, justo tras el POST)
rubocop                  → no offenses

Lo que quedó abierto y declarado

  • Base#method_missing sigue intacto. Es la causa de fondo del silencio y la comparten los 11 modelos: Network, Volume y Secret también incluyen Creatable sin Updatable, así que su save sobre persistido cae en el mismo nil. Merece decisión propia, no un arreglo de contrabando en este PR.
  • Un supuesto que ningún revisor pudo cerrar: cómo serializa Api.request las claves String en query_params. Si las pasa tal cual, stats("stream" => true) duplicaría el parámetro junto al default. No lo verifiqué.
  • Sigue sin tests y sin incremento de doc — van después del OK humano.

@gedera
gedera marked this pull request as ready for review August 17, 2026 13:57
Comment thread lib/docker_swarm/models/container.rb Outdated
Comment on lines +94 to +96
"Container#update necesita al menos un atributo. Un payload vacío recibe 200 OK " \
"del Engine y NO aplica nada. Si venís de `save`: los containers no soportan el " \
"save genérico — usá `update(\"Memory\" => …)` con los límites explícitos."

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

por standard esto? no deberia de estar en ingles?

Review de @gedera en el PR 41. Medida la convencion del repo antes de cambiar:
de los 5 mensajes de error de la gema, CUATRO estan en ingles

  "assign_attributes expects a Hash, got #{new_attributes.class}"
  "Docker socket error: #{actual_error.message}"
  "HTTP #{status}: #{error_msg}"
  "image pull failed: #{detail}"

y uno en espanol ("registry_auth y registry_auth_from son mutuamente
excluyentes", 0.8.0). Copie al outlier.

La distincion que queda clara: prosa explicativa en espanol —los comentarios de
container.rb, updatable.rb y el CHANGELOG lo son— pero SUPERFICIE PUBLICA en
ingles, y un mensaje de excepcion es superficie publica: lo lee quien consume
la gema, no quien la mantiene.

No se toca el outlier de 0.8.0: es preexistente y de otro cambio.
@gedera

gedera commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Medí la convención antes de cambiar, porque no la tenía clara: de los 5 mensajes de error de la gema, cuatro están en inglés (assign_attributes expects a Hash…, Docker socket error:…, HTTP #{status}:…, image pull failed:…) y uno en españolregistry_auth y registry_auth_from son mutuamente excluyentes, que entró en 0.8.0. Copié al outlier.

Tenés razón: corregido a inglés en afeec1b.

La distinción que me queda, y la aplico de acá en adelante: prosa explicativa en español —los comentarios de container.rb, updatable.rb y el CHANGELOG lo son— pero superficie pública en inglés. Un mensaje de excepción es superficie pública: lo lee quien consume la gema, no quien la mantiene.

Verificado cómo sale:

Container#update requires at least one attribute. An empty payload gets a 200 OK from the
Engine and applies nothing. Coming from `save`? Containers do not support the generic save:
use `update("Memory" => ...)` with explicit resource limits.

No toqué el outlier de 0.8.0 — es preexistente y de otro cambio.

gedera added 3 commits August 17, 2026 20:33
…stats

Los unitarios pinean lo que MANDAMOS; los de integracion, lo que el Engine HACE.
Hay una cosa que un unitario no puede cubrir por definicion: que `stats` VUELVA.
Mockeando `Api.request` se verifica que pasamos stream: false, no que eso evite
el cuelgue.

Denegacion probada en DOS capas:

  contra main (sin metodos ni rutas)          27 examples, 13 failures
  contra 7f56c96 (metodos SIN los 4 findings)  27 examples,  4 failures
    :214 reload del estado local            -> F3
    :223 payload vacio levanta              -> F1
    :229 save sobre persistido levanta      -> F1/F2
    :248 registry_auth como clave String    -> F4

O sea que cada hallazgo del MVF tiene un test que lo deniega, uno a uno: si
alguien revierte cualquiera de los cuatro, se pone rojo.

Los de integracion destaparon el issue #38 en la practica: el `after` hacia
`destroy(force: true)` y `Deletable#destroy` NO ACEPTA ARGUMENTOS, asi que el
ArgumentError se lo comia el rescue, el container quedaba vivo y el ejemplo
siguiente moria con 409 Conflict — con el error real tapado por el conflicto de
nombre. Se cierra con `stop` + `destroy` y con nombre unico POR EJEMPLO, no por
corrida, para que una fuga no encadene.

  217 examples, 0 failures  (unitarios)
    8 examples, 0 failures  (integracion, contra Engine 29.7.2)
  rubocop: 56 files, no offenses

Part of #39
Cuatro artefactos quedaron MINTIENDO despues del cambio — no uno:

  interface        decia "Creatable, Deletable, Loggable; #start, #stop"
  consumed         listaba 6 endpoints de containers, ahora son 9
  glossary         "la gema expone create/start/stop/destroy/logs"
  skill (x2)       el indice de capacidades y la tabla de superficie
  test             la cobertura nueva, unit + integration

En interface y skill no alcanza con listar los metodos: van las tres decisiones
que un consumidor necesita saber y no puede deducir de la firma —que #update NO
usa Concerns::Updatable y por que, que devuelve el cuerpo y no un booleano, y
que #stats fuerza stream: false porque con el default la llamada no vuelve— mas
la que rompe expectativas: `save` sobre un container persistido LEVANTA, no se
soporta el save generico.

En test se declara lo que solo la integracion puede cubrir: que `stats` VUELVA.
Mockeando Api.request se verifica que mandamos stream: false, no que eso evite
el cuelgue; de ahi el Timeout.timeout(15) del spec de integracion.

NO se re-ancla ningun artefacto. Los cuatro apuntan a tags de version (v0.10.0,
v0.9.0) y ya venian stale contra 0.11.0 — pero anclar a v0.12.0 seria declarar
una release que todavia no paso. El re-anclaje va con #40, que es la release, y
queda dicho en cada refresh.

arch-lint: 14 -> 11 warns, exit 0.
217 examples, 0 failures.

Part of #39
… de consumed

INCR-001 behavior — faltaba la capa, y el argumento de critias es bueno: la
cadencia la declara el propio §4 ("solo se diagrama un flujo cuando un PR lo
toca o agrega") y el precedente in-repo pone la vara BAJA — el flujo 3.7 es
start/stop, dos llamadas de un paso. `Container#update` es mas complejo que
eso: merge -> except -> chequeo de vacio -> raise -> POST -> reload -> devolver
cuerpo, con la bifurcacion save-sobre-persistido que pasa de 200 no-op
silencioso a ArgumentError. Ese "silencioso -> ruidoso" es exactamente lo que
la capa ya diagrama en 3.4 y 3.11.

Se agrega el flujo 3.13 con las dos ramas, y `stats` en las notas (misma forma
del problema, otra cara: no falla, CUELGA).

El conteo 12 -> 13 se movio en los CINCO lugares donde vive: behavior §2
(indice), §2 (titulo "Documentados"), la meta, §4 (el derivado "= 12"),
README.md y AGENTS.md. Si se mueve uno solo, STRUCT-003 rompe en el proximo
barrido.

INCR-001 errors — NO va una fila en §a, y eso ya estaba normado por el propio
artefacto: los ArgumentError de stdlib son "contrato publico de esas firmas, no
parte de la jerarquia DockerSwarm::Error". Lo que faltaba es la otra mitad: esa
misma clausula ENUMERA los sitios y ahora hay un tercero. Una linea.

INCR-003 consumed §c — la frase del ?version= se escribio para Service#update y
quedo CONTRADICIENDO a la fila que este mismo PR agrego a §b ("sin ?version=,
eso es de services"). Un agente que leyera §c concluiria que un replay de
Container#update da 409, cuando re-aplica el mismo limite. Se califica el
sujeto.

Observacion B — §b decia `?stream=false` OBLIGATORIO y no lo es: el codigo
mergea y hay un unitario que asserta que se puede pisar. Pasa a "por default
(pisable)". interface y SKILL ya decian "fuerza", que si es correcto.

Observacion C — §e de test tenia la linea vieja "container (start/stop)" tres
bullets arriba del bullet nuevo: el lector encontraba primero la lista
incompleta.

Y un arreglo propio: el comentario `%%` que puse INLINE en el mermaid va en su
propia linea o se renderiza como texto. Pasa a Note, que es lo que usa el resto
del archivo (0 usos de %% en las otras 12 secuencias).

arch-lint: 11 -> 10 warns, exit 0.  221 examples, 0 failures.

Part of #39
@gedera
gedera merged commit f384af6 into main Aug 18, 2026
1 check passed
@gedera
gedera deleted the feat/container-update-restart-stats branch August 18, 2026 13:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

B11a · Container: update/restart/stats faltan y la ausencia devuelve nil en silencio

1 participant