docker-swarm 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -96,20 +96,29 @@ 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
  ]
102
103
  end
103
104
 
104
105
  def client
105
- debug_enabled = logger&.level == Logger::DEBUG
106
-
106
+ # NO habilitamos el debug de Excon ni le pasamos el logger. El instrumentor
107
+ # de Excon redacta solo Authorization/Proxy-Authorization, NUNCA headers de
108
+ # autenticación custom (p. ej. X-Registry-Auth) → filtraría esa credencial.
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).
107
120
  options = {
108
121
  middlewares: common_middlewares,
109
- logger: logger,
110
- debug_request: debug_enabled,
111
- debug_response: debug_enabled,
112
- # Si debug_enabled es true, Excon usará su lógica interna de debug con el logger proporcionado
113
122
  retry_limit: 0
114
123
  }
115
124
 
@@ -5,15 +5,72 @@ module DockerSwarm
5
5
  module LogHelper
6
6
  # `data` se matchea con \b para que `Data` (Secret/Config) se filtre
7
7
  # pero `metadata` u otras claves no caigan en falso positivo.
8
+ # `auth` cubre headers de autenticación (`X-Registry-Auth`, `Authorization`), case-insensitive.
8
9
  SENSITIVE_KEYS = /password|pass|passwd|secret|token|api_key|auth|\bdata\b/i.freeze
10
+ FILTERED = "[FILTERED]"
11
+
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:
23
+ #
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.
33
+ #
34
+ # @param value [Object] hash, array o escalar
35
+ # @return [Object] copia con los valores sensibles reemplazados por [FILTERED]
36
+ def self.sanitize(value)
37
+ case value
38
+ when Hash
39
+ value.each_with_object({}) do |(k, v), acc|
40
+ acc[k] = k.to_s.match?(SENSITIVE_KEYS) ? FILTERED : sanitize(v)
41
+ end
42
+ when Array
43
+ value.map { |v| sanitize(v) }
44
+ when String
45
+ redact_kv_string(value)
46
+ else
47
+ value
48
+ end
49
+ end
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
9
67
 
10
68
  # Formats a hash into a KV structured string with sensitive data masking
11
69
  # @param payload [Hash] The data to format
12
70
  # @return [String] KV formatted string
13
71
  def self.format_kv(payload)
14
- payload.map do |k, v|
15
- val = k.to_s =~ SENSITIVE_KEYS ? "[FILTERED]" : v
16
- "#{k}=#{val}"
72
+ sanitize(payload).map do |k, v|
73
+ "#{k}=#{v}"
17
74
  end.join(" ")
18
75
  rescue
19
76
  "event=logging_error"
@@ -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
@@ -3,8 +3,92 @@
3
3
  module DockerSwarm
4
4
  # Represents a Docker Image
5
5
  # @see https://docs.docker.com/engine/api/v1.41/#tag/Image
6
+ #
7
+ # No incluye Creatable: el "create" del Docker API sobre imágenes es un PULL
8
+ # (stream de progreso), no la construcción de un recurso CRUD. Se expone como
9
+ # `.pull` con contrato propio.
6
10
  class Image < Base
7
- include Concerns::Creatable
8
11
  include Concerns::Deletable
12
+
13
+ # Docker emite el digest del pull en un frame de status "Digest: sha256:..."
14
+ # (verificado empíricamente contra Docker 29.5.3; el stream de pull NO trae campo `aux`).
15
+ DIGEST_STATUS = /\bDigest:\s*(sha256:[0-9a-f]+)/
16
+
17
+ class << self
18
+ # Pull explícito de una imagen (POST /images/create).
19
+ #
20
+ # Operación SÍNCRONA: consume el stream NDJSON de progreso hasta EOF, eleva
21
+ # error tipado ante un frame `error`/`errorDetail` (que Docker manda CON HTTP 200),
22
+ # y solo tras terminación limpia devuelve un resultado explícito construido desde
23
+ # el stream — sin un `find` posterior que reintroduciría el problema referencia-vs-ID.
24
+ #
25
+ # @param image_reference [String] referencia completa (registry/repo:tag o @sha256:...)
26
+ # @param registry_auth [String, nil] credencial opaca base64url → header X-Registry-Auth
27
+ # @return [Hash] { status: :pulled, image_ref: String, digest: String (si Docker lo emite) }
28
+ # @raise [DockerSwarm::Error] si el stream reporta error/errorDetail
29
+ def pull(image_reference, registry_auth: nil)
30
+ headers, = RegistryAuth.resolve(registry_auth: registry_auth)
31
+
32
+ body = Api.request(
33
+ action: routes[:pull],
34
+ query_params: { fromImage: image_reference },
35
+ headers: headers
36
+ )
37
+
38
+ frames = parse_progress_stream(body)
39
+ raise_on_stream_error!(frames)
40
+ pull_result(image_reference, frames)
41
+ end
42
+
43
+ private
44
+
45
+ # El middleware entrega el stream como String (multi-frame NDJSON: el JSON.parse
46
+ # global falló y devolvió el cuerpo crudo) o como Hash (un único objeto JSON, p. ej.
47
+ # algunos errores). Normalizamos a una lista de frames-Hash en ambos casos.
48
+ def parse_progress_stream(body)
49
+ case body
50
+ when Hash
51
+ [ body ]
52
+ when String
53
+ body.each_line.filter_map { |line| parse_frame(line) }
54
+ else
55
+ []
56
+ end
57
+ end
58
+
59
+ def parse_frame(line)
60
+ line = line.strip
61
+ return if line.empty?
62
+
63
+ JSON.parse(line)
64
+ rescue JSON::ParserError
65
+ nil
66
+ end
67
+
68
+ def raise_on_stream_error!(frames)
69
+ error_frame = frames.find { |frame| frame["error"] || frame["errorDetail"] }
70
+ return unless error_frame
71
+
72
+ detail = error_frame.dig("errorDetail", "message") || error_frame["error"]
73
+ raise DockerSwarm::Error, "image pull failed: #{detail}"
74
+ end
75
+
76
+ def pull_result(image_reference, frames)
77
+ digest = extract_digest(frames)
78
+
79
+ result = { status: :pulled, image_ref: image_reference }
80
+ result[:digest] = digest if digest
81
+ result
82
+ end
83
+
84
+ # Escaneamos desde el final para quedarnos con el frame "Digest:" más reciente.
85
+ def extract_digest(frames)
86
+ frames.reverse_each do |frame|
87
+ match = DIGEST_STATUS.match(frame["status"].to_s)
88
+ return match[1] if match
89
+ end
90
+ nil
91
+ end
92
+ end
9
93
  end
10
94
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockerSwarm
4
+ # Traduce las opciones de autenticación de registry privado a los canales de
5
+ # transporte de la Docker Engine API, sin tocar el payload ni el estado del modelo:
6
+ #
7
+ # - +registry_auth+ -> header +X-Registry-Auth+ (credencial opaca base64url).
8
+ # - +registry_auth_from+ -> query +registryAuthFrom+ (+spec+ | +previous-spec+),
9
+ # fuente de credencial a reusar en un update cuando el header NO está presente.
10
+ #
11
+ # Son mutuamente excluyentes: Docker define +registryAuthFrom+ como la fuente a usar
12
+ # solo si +X-Registry-Auth+ no viaja. Si el caller pasa ambos, se corta con un error
13
+ # local claro antes de la request, en vez de derivar la ambigüedad al Engine.
14
+ #
15
+ # @see https://docs.docker.com/engine/api/v1.41/#tag/Service/operation/ServiceUpdate
16
+ module RegistryAuth
17
+ HEADER = "X-Registry-Auth"
18
+ QUERY = :registryAuthFrom
19
+ FROM_VALUES = %w[spec previous-spec].freeze
20
+
21
+ module_function
22
+
23
+ # @param registry_auth [String, nil] credencial opaca para el header X-Registry-Auth
24
+ # @param registry_auth_from [String, nil] "spec" | "previous-spec"; excluyente con registry_auth
25
+ # @return [Array(Hash, Hash)] par [headers, query_params] a mergear en la request
26
+ # (cada uno vacío cuando su opción no vino)
27
+ # @raise [ArgumentError] si vienen ambos juntos o si registry_auth_from es inválido
28
+ def resolve(registry_auth: nil, registry_auth_from: nil)
29
+ validate!(registry_auth, registry_auth_from)
30
+
31
+ headers = registry_auth ? { HEADER => registry_auth } : {}
32
+ query = registry_auth_from ? { QUERY => registry_auth_from } : {}
33
+
34
+ [ headers, query ]
35
+ end
36
+
37
+ def validate!(registry_auth, registry_auth_from)
38
+ if registry_auth && registry_auth_from
39
+ raise ArgumentError, "registry_auth y registry_auth_from son mutuamente excluyentes: pasá uno u otro"
40
+ end
41
+
42
+ return if registry_auth_from.nil? || FROM_VALUES.include?(registry_auth_from)
43
+
44
+ raise ArgumentError,
45
+ "registry_auth_from inválido: #{registry_auth_from.inspect} (válidos: #{FROM_VALUES.join(', ')})"
46
+ end
47
+ end
48
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DockerSwarm
4
- VERSION = "0.7.2"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/docker_swarm.rb CHANGED
@@ -40,10 +40,12 @@ 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"
46
47
  require_relative "docker_swarm/api"
48
+ require_relative "docker_swarm/registry_auth"
47
49
 
48
50
  # Concerns deben cargarse antes que Base si Base los incluye
49
51
  require_relative "docker_swarm/concerns/creatable"
data/skill/SKILL.md CHANGED
@@ -9,16 +9,19 @@ description: >-
9
9
  cuando el caller necesita orquestar Docker desde Ruby — listar/crear/
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
- labels, o capturar errores tipados de Docker (Conflict/NotFound/
13
- Communication). NO activar para builds de imágenes (no implementado), pull
14
- con auth de registry privado (no implementado), o flujos que no son
15
- Swarm (Docker Compose, raw containers).
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
+ También cubre containers standalone (no Swarm): crear/correr/limpiar un
15
+ helper container efímero para operar datos on-host. NO activar para builds
16
+ de imágenes (no implementado) ni para Docker Compose (no parsea
17
+ `docker-compose.yml`).
16
18
  triggers:
17
19
  - "DockerSwarm::"
18
20
  - "docker-swarm gem"
19
21
  - "Docker Engine API desde Ruby"
20
22
  - "Service.create / Service.update / Service.restart"
21
- - "Container.start / Container.stop"
23
+ - "Container.create / Container.start / Container.stop"
24
+ - "helper container efímero"
22
25
  - "logs de un servicio Docker"
23
26
  - "Version.Index"
24
27
  ---
@@ -55,11 +58,11 @@ Defaults son razonables: en local sin TLS, no necesitás bloque `configure`.
55
58
 
56
59
  | Modelo | Class methods | Instance methods | Notas |
57
60
  |---|---|---|---|
58
- | `Service` | `all(filters)`, `find(id)`, `where(filters)`, `create(attrs)` | `update(attrs)`, `restart`, `destroy`, `logs(query)`, `reload`, `persisted?`, `id` | CRUD completo + force-recreate de tasks |
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)` | **No `create`** (gap conocido, fuera F1) |
62
- | `Image` | `all(filters)`, `find(id)`, `create(attrs)` | `destroy` | `create` = pull. **No soporta `X-Registry-Auth`** (registries privados con auth no funcionan) |
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 |
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"` |
65
68
  | `Config` | `all(filters)`, `find(id)`, `create(attrs)` | `destroy` | No `update` — recrear |
@@ -95,7 +98,7 @@ service.restart
95
98
  # Destroy graceful (nil si 404)
96
99
  service.destroy
97
100
 
98
- # Logs raw
101
+ # Logs (ya demultiplexados: sin cabeceras de frame)
99
102
  service.logs(stdout: 1, stderr: 1)
100
103
 
101
104
  # Health check
@@ -130,8 +133,10 @@ 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` no existe** en la gema (gap intencional F1). Si necesitás crear containers standalone, usá `DockerSwarm.request(method: :post, path: "containers/create", ...)` directo.
134
- - **Pull con registry privado no soportado.** `Image.create` no inyecta header `X-Registry-Auth`. Para registries privados, fallback a `DockerSwarm.request` con headers manuales.
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í.
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:)`.
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`.
135
140
  - **`destroy` es graceful con 404** (retorna `nil`), no con 409. Si el recurso está en uso, `Conflict` se propaga.
136
141
  - **Logs sensibles enmascarados** automáticamente: keys matching `password|pass|passwd|secret|token|api_key|auth|\bdata\b` → `[FILTERED]`. `\bdata\b` evita filtrar `metadata`/`database`.
137
142
 
@@ -164,11 +169,18 @@ Logs salen en formato KV (`component=docker_swarm.connection event=request_succe
164
169
 
165
170
  ## Índice de artefactos
166
171
 
172
+ - [`docs/interface/interface.md`](docs/interface/interface.md) — API Ruby pública (11 modelos + `Base` + concerns + `Api`/`Connection`). La tabla de símbolos de arriba es el resumen; el detalle por símbolo acá.
173
+ - [`docs/errors/errors.md`](docs/errors/errors.md) — jerarquía de excepciones + mapeo status HTTP → excepción. La tabla de errores de arriba es el resumen.
174
+ - [`docs/consumed/docker-engine-api.md`](docs/consumed/docker-engine-api.md) — superficie del Docker Engine API consumida (`Api::ENDPOINTS`) + política de retry estructural.
167
175
  - [`docs/glossary/glossary.md`](docs/glossary/glossary.md) — definición de términos (primitivas Docker + conceptos internos).
168
176
  - [`docs/behavior/behavior.md`](docs/behavior/behavior.md) — secuencias load-bearing (create+reload, update+Version, retry-policy, error-mapping, etc.).
169
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.
178
+ - [`docs/topology/topology.md`](docs/topology/topology.md) — dependencias runtime (3) + grafo de contexto.
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.
170
181
  - `docs/data/` — `n/a` (gema sin DB).
171
- - `docs/api/`, `docs/interface/`, `docs/topology/` — F2 declaradas, **no implementadas**. El contrato resumido de arriba reside **transitoriamente** acá (RFC-008 §2 coexistencia transitoria con destino pendiente).
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).
172
184
 
173
185
  ## Versionado del contrato
174
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.7.2
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel
@@ -92,7 +92,13 @@ files:
92
92
  - README.md
93
93
  - docs/behavior/behavior.md
94
94
  - docs/config/configuracion.md
95
+ - docs/consumed/docker-engine-api.md
96
+ - docs/errors/errors.md
95
97
  - docs/glossary/glossary.md
98
+ - docs/interface/interface.md
99
+ - docs/release/release.md
100
+ - docs/test/testing.md
101
+ - docs/topology/topology.md
96
102
  - lib/docker-swarm.rb
97
103
  - lib/docker_swarm.rb
98
104
  - lib/docker_swarm/api.rb
@@ -107,6 +113,7 @@ files:
107
113
  - lib/docker_swarm/errors.rb
108
114
  - lib/docker_swarm/log_helper.rb
109
115
  - lib/docker_swarm/middleware/error_handler.rb
116
+ - lib/docker_swarm/middleware/log_stream_demuxer.rb
110
117
  - lib/docker_swarm/middleware/request_encoder.rb
111
118
  - lib/docker_swarm/middleware/response_json_parser.rb
112
119
  - lib/docker_swarm/models/config.rb
@@ -120,17 +127,18 @@ files:
120
127
  - lib/docker_swarm/models/system.rb
121
128
  - lib/docker_swarm/models/task.rb
122
129
  - lib/docker_swarm/models/volume.rb
130
+ - lib/docker_swarm/registry_auth.rb
123
131
  - lib/docker_swarm/version.rb
124
132
  - skill/SKILL.md
125
- homepage: https://github.com/gedera/docker-swarm
133
+ homepage: https://github.com/sequre/docker-swarm
126
134
  licenses:
127
135
  - MIT
128
136
  metadata:
129
- homepage_uri: https://github.com/gedera/docker-swarm
130
- source_code_uri: https://github.com/gedera/docker-swarm
131
- changelog_uri: https://github.com/gedera/docker-swarm/blob/v0.7.2/CHANGELOG.md
132
- bug_tracker_uri: https://github.com/gedera/docker-swarm/issues
133
- documentation_uri: https://github.com/gedera/docker-swarm/blob/v0.7.2/skill/SKILL.md
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
134
142
  rubygems_mfa_required: 'true'
135
143
  rdoc_options: []
136
144
  require_paths: