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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +14 -7
- data/docs/behavior/behavior.md +106 -11
- data/docs/consumed/docker-engine-api.md +126 -0
- data/docs/errors/errors.md +103 -0
- data/docs/glossary/glossary.md +25 -25
- data/docs/interface/interface.md +121 -0
- data/docs/release/release.md +54 -0
- data/docs/test/testing.md +100 -0
- data/docs/topology/topology.md +55 -0
- data/lib/docker_swarm/api.rb +8 -4
- data/lib/docker_swarm/concerns/creatable.rb +32 -6
- data/lib/docker_swarm/concerns/updatable.rb +15 -4
- data/lib/docker_swarm/connection.rb +15 -6
- data/lib/docker_swarm/log_helper.rb +60 -3
- data/lib/docker_swarm/middleware/log_stream_demuxer.rb +89 -0
- data/lib/docker_swarm/models/container.rb +10 -0
- data/lib/docker_swarm/models/image.rb +85 -1
- data/lib/docker_swarm/registry_auth.rb +48 -0
- data/lib/docker_swarm/version.rb +1 -1
- data/lib/docker_swarm.rb +2 -0
- data/skill/SKILL.md +24 -12
- metadata +15 -7
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/docker_swarm/version.rb
CHANGED
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,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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)` |
|
|
62
|
-
| `Image` | `all(filters)`, `find(id)`, `
|
|
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
|
|
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`
|
|
134
|
-
-
|
|
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
|
|
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.
|
|
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/
|
|
133
|
+
homepage: https://github.com/sequre/docker-swarm
|
|
126
134
|
licenses:
|
|
127
135
|
- MIT
|
|
128
136
|
metadata:
|
|
129
|
-
homepage_uri: https://github.com/
|
|
130
|
-
source_code_uri: https://github.com/
|
|
131
|
-
changelog_uri: https://github.com/
|
|
132
|
-
bug_tracker_uri: https://github.com/
|
|
133
|
-
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
|
|
134
142
|
rubygems_mfa_required: 'true'
|
|
135
143
|
rdoc_options: []
|
|
136
144
|
require_paths:
|