docker-swarm 0.7.1 → 0.8.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.
@@ -0,0 +1,118 @@
1
+ # Interfaz — docker-swarm
2
+
3
+ > meta: artefacto · RFC-004 · generado arch-structure · anclado a `29856f1` · cobertura: API Ruby pública de la gema (`lib/docker_swarm/**`); símbolos internos marcados en §4
4
+
5
+ ## 1. Resumen
6
+
7
+ API Ruby pública de la gema. Entrypoint `DockerSwarm` (config + cliente HTTP). 11 modelos `ActiveModel`-compatibles heredan de `DockerSwarm::Base` y mezclan concerns (`Creatable`/`Updatable`/`Deletable`/`Loggable`). Atributos vía accessors dinámicos PascalCase (`method_missing`). Jerarquía de errores en `DockerSwarm::Error` (detalle en [`docs/errors/errors.md`](../errors/errors.md)).
8
+
9
+ ## 2. Cuerpo
10
+
11
+ Proyección RBS-conceptual: `símbolo · tipo · nota` (raíz → profundidad → alfabético). Firmas aplanadas; el wire-schema de los atributos PascalCase no es estático (ver §4).
12
+
13
+ ### Módulo raíz `DockerSwarm`
14
+
15
+ | símbolo | tipo | nota |
16
+ |---|---|---|
17
+ | `DockerSwarm` | módulo | namespace raíz |
18
+ | `DockerSwarm::VERSION` | constante | `"0.8.0"` (`version.rb`) |
19
+ | `DockerSwarm.configuration` | attr (r/w) | instancia de `Configuration`; lazy-init en `configure`/`connection` |
20
+ | `DockerSwarm.configure { \|config\| ... }` | método de módulo | crea/yields `Configuration`; aplica `log_level` al logger; resetea la conexión memoizada |
21
+ | `DockerSwarm.connection` | método de módulo | `Connection` memoizada (auto-`configure` si falta) |
22
+ | `DockerSwarm.request(options = {})` | método de módulo | delega en `connection.request`; entrypoint de bajo nivel (escape hatch para llamadas crudas) |
23
+
24
+ ### `DockerSwarm::Configuration` (`configuration.rb`)
25
+
26
+ | símbolo | tipo | nota |
27
+ |---|---|---|
28
+ | `#socket_path` | attr (r/w) | default `"unix:///var/run/docker.sock"` |
29
+ | `#logger` | attr (r/w) | default `Logger.new($stdout)` |
30
+ | `#log_level` | attr (r/w) | default `Logger::INFO` |
31
+ | `#read_timeout` / `#read_timeout=` | attr (r) + setter | default `60.0`; setter castea `to_f` |
32
+ | `#write_timeout` / `#write_timeout=` | attr (r) + setter | default `60.0`; setter castea `to_f` |
33
+ | `#connect_timeout` / `#connect_timeout=` | attr (r) + setter | default `10.0`; setter castea `to_f` |
34
+ | `#max_retries` / `#max_retries=` | attr (r) + setter | default `3`; setter castea `to_i` |
35
+
36
+ Inventario completo de opciones: [`docs/config/configuracion.md`](../config/configuracion.md).
37
+
38
+ ### `DockerSwarm::Base` (`base.rb`) — base de los modelos
39
+
40
+ `include ActiveModel::Model`, `include Concerns::Inspectable`.
41
+
42
+ | símbolo | tipo | nota |
43
+ |---|---|---|
44
+ | `.resource_name` | método de clase | `name.demodulize.downcase.pluralize`; override en `Swarm`/`System` |
45
+ | `.routes` | método de clase | `Api::ENDPOINTS[resource_name.to_sym]` |
46
+ | `.root_key` | método de clase | `nil` por default; override `"Volumes"` en `Volume` |
47
+ | `.defined_attributes` | método de clase | `Set` de accessors ya definidos (interno; cache de `method_missing`) |
48
+ | `.all(filters = {})` | método de clase | `GET index`; mapea a instancias; aplica `root_key`; `[]` si vacío |
49
+ | `.find(id)` | método de clase | `GET show`; `nil` si `Errors::NotFound` |
50
+ | `.where(filters)` | método de clase | alias de `all` |
51
+ | `#initialize(attributes = {})` | método de instancia | `assign_attributes` si presente |
52
+ | `#assign_attributes(new_attributes)` | método de instancia | normaliza `Id`→`ID`; `deep_merge` del campo `Spec`; `ArgumentError` si no es Hash |
53
+ | `#attributes` | método de instancia | `instance_values` sin internos de ActiveModel |
54
+ | `#serializable_hash` / `#as_json` | método de instancia | == `attributes` |
55
+ | `#payload_for_docker` | método de instancia | descarta `ID/Version/CreatedAt/UpdatedAt`; aplana `Spec` al root |
56
+ | `#persisted?` | método de instancia | `ID` presente |
57
+ | `#id` | método de instancia | == `self.ID` |
58
+ | `#reload` | método de instancia | re-`find` por `id` y re-asigna |
59
+ | `#method_missing` / `#respond_to_missing?` | método de instancia | accessors dinámicos PascalCase (atributos del recurso Docker) |
60
+
61
+ ### Concerns (`concerns/*.rb`)
62
+
63
+ | símbolo | tipo | nota |
64
+ |---|---|---|
65
+ | `Concerns::Creatable.create(attributes = {}, **opts)` | método de clase (mixin) | `new` + `save`; retorna la instancia. `opts` reservado `registry_auth:` (→ header `X-Registry-Auth`); el resto se pliega como atributos |
66
+ | `Concerns::Creatable#save(registry_auth: nil)` | método de instancia | `false` si `!valid?`; `update` si `persisted?`; si no `POST create` + `reload`. `registry_auth` viaja como header, nunca en el payload |
67
+ | `Concerns::Updatable#update(new_attributes = {}, **opts)` | método de instancia | extrae `Version.Index`; `false` si `!valid?`; `POST update` con `?version=`. `opts` reservado `registry_auth:` (header) / `registry_auth_from:` (query `registryAuthFrom`, `spec`\|`previous-spec`, excluyente con `registry_auth`); el resto se pliega como atributos |
68
+ | `Concerns::Deletable.destroy(id)` | método de clase (mixin) | `DELETE destroy`; `nil` si `Errors::NotFound` |
69
+ | `Concerns::Deletable#destroy` | método de instancia | delega en `.destroy(self.ID)` |
70
+ | `Concerns::Loggable#logs(query_params = { stdout: 1, stderr: 1 })` | método de instancia | `GET logs`; retorna el stream raw |
71
+ | `Concerns::Inspectable#inspect` | método de instancia | render legible (ID/Name/Version/Spec) |
72
+
73
+ ### Modelos (`models/*.rb`)
74
+
75
+ | símbolo | tipo | concerns + métodos propios |
76
+ |---|---|---|
77
+ | `DockerSwarm::Service` | clase < Base | Creatable, Updatable, Deletable, Loggable; `#restart` (incrementa `TaskTemplate.ForceUpdate`); `create`/`update` aceptan `registry_auth:` (+ `update`: `registry_auth_from:`) para auth de registry privado |
78
+ | `DockerSwarm::Node` | clase < Base | Updatable, Deletable (sin `create`: los nodos se unen fuera de la gema) |
79
+ | `DockerSwarm::Task` | clase < Base | Loggable (read-only; generadas por el orquestador) |
80
+ | `DockerSwarm::Container` | clase < Base | Deletable, Loggable; `#start`, `#stop` (sin `create`: gap intencional) |
81
+ | `DockerSwarm::Image` | clase < Base | Deletable + `.pull(image_reference, registry_auth: nil)`. **NO** es Creatable (`Image.create` retirado sin alias). `.pull` = pull explícito síncrono: consume el stream NDJSON hasta EOF, eleva `DockerSwarm::Error` ante frame `error`/`errorDetail`, retorna `{ status: :pulled, image_ref:, digest? }` (sin `find` posterior) |
82
+ | `DockerSwarm::Network` | clase < Base | Creatable, Updatable, Deletable |
83
+ | `DockerSwarm::Volume` | clase < Base | Creatable, Deletable; `.root_key = "Volumes"` (respuesta wrapped) |
84
+ | `DockerSwarm::Config` | clase < Base | Creatable, Deletable (sin `update`: recrear) |
85
+ | `DockerSwarm::Secret` | clase < Base | Creatable, Deletable (sin `update`); `Data` filtrado en logs |
86
+ | `DockerSwarm::Swarm` | clase < Base | `.resource_name = "swarm"`; `.show` (singleton, info del cluster) |
87
+ | `DockerSwarm::System` | clase < Base | `.resource_name = "system"`; `.info`, `.version`, `.up`, `.df` (singleton) |
88
+
89
+ ### Superficie de bajo nivel / soporte
90
+
91
+ | símbolo | tipo | nota |
92
+ |---|---|---|
93
+ | `DockerSwarm::Api::ENDPOINTS` | constante (frozen Hash) | mapa `recurso → {operación → {method, path}}` |
94
+ | `DockerSwarm::Api.request(action:, arguments: {}, query_params: {}, payload: nil)` | método de clase | formatea el `path` y delega en `DockerSwarm.request` |
95
+ | `DockerSwarm::Connection.new(socket_path, logger)` | clase | cliente Excon (Unix socket o TCP); `#request`, `#socket_path`, `#logger` |
96
+ | `DockerSwarm::Connection::IDEMPOTENT_METHODS` | constante | `%i[get head put delete options]` (los únicos con retry) |
97
+ | `DockerSwarm::LogHelper.format_kv(payload)` | método de módulo | formatea KV + masking de claves sensibles |
98
+ | `DockerSwarm::LogHelper::SENSITIVE_KEYS` | constante (Regexp) | `password\|pass\|...\|\bdata\b` |
99
+ | `DockerSwarm::RegistryAuth.resolve(registry_auth:, registry_auth_from:)` | método de módulo | traduce las opciones de auth a `[headers, query_params]` (`X-Registry-Auth` / `registryAuthFrom`); valida exclusión mutua + enum antes de la request. Usado por `Image.pull` / `#save` / `#update`; la credencial nunca toca payload ni estado del modelo |
100
+ | `DockerSwarm::RegistryAuth::{HEADER, QUERY, FROM_VALUES}` | constantes | `"X-Registry-Auth"` · `:registryAuthFrom` · `%w[spec previous-spec]` |
101
+ | `DockerSwarm::Error` + subclases + aliases + `DockerSwarm::Errors` | clases/módulo | jerarquía de errores — detalle en [`docs/errors/errors.md`](../errors/errors.md) |
102
+ | `DockerSwarm::Middleware::{RequestEncoder, ResponseJSONParser, ErrorHandler}` | clases | middlewares Excon; públicos por require pero de uso interno (ver §4) |
103
+
104
+ ## 3. Inferencias
105
+
106
+ | afirmación | confidence | a verificar |
107
+ |---|---|---|
108
+ | `Connection`, `Api` y los `Middleware::*` son de uso **interno** (un consumidor normal usa los modelos, no estas clases) | inferred | son `public` en Ruby; no hay marca `@api private`. `DockerSwarm.request`/`Api` son el escape-hatch documentado en `skill/SKILL.md` |
109
+ | `.defined_attributes` es interno (cache de `method_missing`), no superficie de consumo | inferred | público pero sin uso externo plausible |
110
+ | Los atributos PascalCase (`service.Spec`, `service.Version`, …) son la superficie real de datos, pero su set depende de la respuesta del Docker Engine API | declared | `method_missing` define accessors on-demand; el shape lo fija Docker, no la gema |
111
+
112
+ ## 4. Cobertura y fronteras
113
+
114
+ - **Wire-schema de atributos:** los modelos no declaran atributos estáticos — se materializan dinámicamente desde el JSON de Docker (`method_missing`). El shape de `Spec`/`TaskTemplate`/etc. es el del Docker Engine API v1.41, **fuera de este repo** (doc oficial Docker). `unspecified` acá a propósito.
115
+ - **`interface` vs `operaciones` (RFC-003):** esta gema NO expone superficie HTTP/CLI/eventos propia → `docs/api/operaciones` es `n/a`. Su superficie pública ES esta interfaz Ruby. Lo que la gema *consume* (Docker Engine API) vive en [`docs/consumed/`](../consumed/docker-engine-api.md).
116
+ - **Errores:** la jerarquía `DockerSwarm::Error` se cataloga en [`docs/errors/errors.md`](../errors/errors.md); acá solo se referencia.
117
+ - **Símbolos internos:** `Connection`, `Api`, `Middleware::*`, `Base.defined_attributes` se listan por completitud pero no son API de consumo recomendada; un cambio en ellos no rompe el contrato del consumidor típico (que usa los modelos).
118
+ - **Significado de negocio** de cada modelo/término → [`docs/glossary/glossary.md`](../glossary/glossary.md); secuencias → [`docs/behavior/behavior.md`](../behavior/behavior.md).
@@ -0,0 +1,54 @@
1
+ # Release — docker-swarm
2
+
3
+ > meta: artefacto · RFC-014 · generado arch-structure + enriquecido arch-enrich · anclado a `cccfe63` · cobertura: §a estructura completa (versión · changelog · build-trigger · patrón); §b enrich completa (deploy · rollback · ambientes · dueño)
4
+
5
+ ## 1. Resumen
6
+
7
+ Gema Ruby publicada en RubyGems. Release por **tag `v*`**: el push del tag dispara el workflow `Publish to RubyGems` que hace `gem build` + `gem push`. Sin deploy de infraestructura (gema, no servicio): no hay `Dockerfile`, ni branches `production`/`staging`, ni ambientes.
8
+
9
+ ## 2. Cuerpo
10
+
11
+ ### §a Estructura (verificable del código)
12
+
13
+ | campo | valor | fuente |
14
+ |---|---|---|
15
+ | versión actual | `0.7.2` | `lib/docker_swarm/version.rb` (`DockerSwarm::VERSION`) |
16
+ | esquema de versión | SemVer (`MAJOR.MINOR.PATCH`) | `CHANGELOG.md` (breaking/mejoras/correcciones por bump) |
17
+ | artefacto liberado | gema `docker-swarm` a RubyGems | `docker-swarm.gemspec` (`spec.name`), `.github/workflows/release.yml` |
18
+ | changelog | `CHANGELOG.md`, formato Keep a Changelog, entradas fechadas por versión | `CHANGELOG.md` |
19
+ | licencia | MIT | `docker-swarm.gemspec` (`spec.license`) |
20
+ | build-trigger | push de tag `v*` | `.github/workflows/release.yml` (`on.push.tags: ['v*']`) |
21
+ | **patrón de trigger (RFC-014)** | **patrón 1** — publish per-repo-visible en workflow (tag → RubyGems) | `.github/workflows/release.yml` |
22
+ | runner de release | `ubuntu-latest`, Ruby `3.4.4` | `.github/workflows/release.yml` |
23
+ | pasos de publish | `gem build *.gemspec` + `gem push *.gem` | `.github/workflows/release.yml` |
24
+ | credencial de publish | `secrets.RUBYGEMS_API_KEY` (env `RUBYGEMS_API_KEY`) | `.github/workflows/release.yml` |
25
+ | MFA de publish | requerida (`rubygems_mfa_required = "true"`) | `docker-swarm.gemspec` (`spec.metadata`) |
26
+ | Ruby mínimo del release | `>= 3.2.0` | `docker-swarm.gemspec` (`required_ruby_version`) |
27
+ | contenido empaquetado | `lib`, `exe`, `skill`, `docs` + `README.md`, `CHANGELOG.md`, `LICENSE` | `docker-swarm.gemspec` (`spec.files`). Empaquetar `skill/` + `docs/` es **intencional**: la gema shippea su skill version-locked (RFC-008) y los artefactos de arquitectura para consumidores/agentes |
28
+ | CI (no gatea el release) | workflow `Ruby` (`main.yml`) corre en push a `main` / PR: `rspec` (sin `type:integration`) + `rubocop`. **Independiente** de `release.yml` — el tag `v*` publica sin exigir que el CI haya pasado | `.github/workflows/main.yml` |
29
+ | dependencias runtime | `activesupport >= 6.0`, `activemodel >= 6.0`, `excon >= 0.80` | `docker-swarm.gemspec` |
30
+
31
+ ### §b Deploy · rollback · ambientes · dueño (enrich)
32
+
33
+ | dimensión | valor | nota |
34
+ |---|---|---|
35
+ | deploy | Publicación a RubyGems por `gem push` al pushear el tag `v*`. Los consumidores la reciben vía Bundler (`gem 'docker-swarm'`) tras la propagación del índice de RubyGems (~minutos) | "Deploy" para una gema = disponibilidad en RubyGems; no hay infraestructura que desplegar |
36
+ | rollback | **Bump correctivo (preferido):** publicar un patch nuevo (`X.Y.Z+1`) con el fix. RubyGems **prohíbe** re-pushear el mismo número de versión (restricción técnica). `gem yank -v X.Y.Z` — RubyGems lo permite en cualquier momento (no hay restricción técnica), pero es **política del equipo** reservarlo a casos graves (secreto filtrado, gema rota/ininstalable) | Distinguir: el re-push prohibido es técnico de RubyGems; el yank restringido es decisión del equipo (un yank rompe a quien fijó esa versión → se prefiere avanzar) |
37
+ | ambientes | No aplica: artefacto único publicado en RubyGems, sin staging/producción. El único "ambiente" es la versión instalada por cada consumidor | La matriz de compatibilidad la fija `required_ruby_version` (`>= 3.2.0`), no un ambiente de deploy |
38
+ | dueño del release | Gabriel (mantenedor) — taggea `v*` y publica; @Pablo contribuye fixes | Derivado del historial de `CHANGELOG.md` y confirmado por el equipo |
39
+
40
+ ## 3. Inferencias
41
+
42
+ | afirmación | confidence | a verificar |
43
+ |---|---|---|
44
+ | Patrón de trigger = 1 (publish per-repo-visible por tag), no patrón 3 (branch `production`/`staging`) | declared | `release.yml` sólo escucha `tags: v*`; no existen branches `production`/`staging` (`git branch -a`) |
45
+ | Esquema de versión = SemVer | inferred | derivado del uso en `CHANGELOG.md` (0.7.0 marcó breaking changes); no hay política SemVer declarada explícita en el repo |
46
+ | No hay deploy de infra | declared | ausencia de `Dockerfile`, `docker-compose.yml`, `helm/`, branches de ambiente — es gema, no servicio |
47
+ | El gem se buildea bajo Ruby `3.4.4` aunque declara floor `>= 3.2.0` | declared | runner fijo en `release.yml` (`3.4.4`) vs `required_ruby_version` del gemspec (`>= 3.2.0`); no es defecto — el build no acopla el floor de compatibilidad |
48
+
49
+ ## 4. Cobertura y fronteras
50
+
51
+ - **Estructura (§a) y enrich (§b) completas** al ancla `cccfe63`; significado de §b aportado por el mantenedor.
52
+ - **Proceso operativo del release** (quién taggea, checklist, bump de versión) lo ejecuta la skill `gem-release`; este artefacto documenta el contrato, no el paso-a-paso de la skill.
53
+ - **Trade-off conocido:** el release (`release.yml`, tag `v*`) **no exige** que el CI (`main.yml`) haya pasado — un tag publica aunque los tests fallen. Hoy el gate es de facto (el dev corre la suite antes de taggear), no forzado por el pipeline. Endurecerlo (exigir CI verde antes de publicar) es una mejora de proceso pendiente, no documentada como decisión formal.
54
+ - **Fuera de alcance:** la config runtime de la gema vive en `docs/config/configuracion.md`; la suite y su gate CI en `docs/test/testing.md` (este artefacto sólo referencia el gate pre-release, no lo redefine).
@@ -0,0 +1,100 @@
1
+ # Test — docker-swarm
2
+
3
+ > meta: artefacto · RFC-013 · generado arch-structure + enriquecido arch-enrich · anclado a `8f2e1f7` · cobertura: estructura de la suite (`spec/`, `.github/workflows/main.yml`); §e enriquecida, §f enriquecida, §g `unknown` (sin incidentes registrados), §h enriquecida
4
+
5
+ ## 1. Resumen
6
+
7
+ Suite RSpec en dos niveles: **unit** (mockean `DockerSwarm::Api`/`Excon`, sin daemon) e **integration** (`type: :integration`, requieren un Docker daemon real). CI corre solo unit + RuboCop; integration es local/opt-in. Sin herramienta de coverage configurada.
8
+
9
+ ## 2. Cuerpo
10
+
11
+ ### a. Suites, frameworks y niveles
12
+
13
+ Framework: **RSpec** (`~> 3.0`). `verify_partial_doubles = true` (mocks estrictos).
14
+
15
+ | subdirectorio | propósito | nivel | helper |
16
+ |---|---|---|---|
17
+ | `spec/docker/swarm/*_spec.rb` | api, configuration, connection, log_helper, registry_auth | unit | `spec_helper` |
18
+ | `spec/docker/swarm/middleware/*_spec.rb` | error_handler, request_encoder, response_json_parser | unit | `spec_helper` |
19
+ | `spec/docker/swarm/models/*_spec.rb` | base, container, image, network, node, service, task + `shared_crud_spec` | unit | `spec_helper` |
20
+ | `spec/integration/*_spec.rb` | containers, infra, security, services, system | integration | `integration_helper` |
21
+
22
+ Tag de nivel: las integration declaran `RSpec.describe "...", type: :integration` (`spec/integration/*_spec.rb:5`). Las unit no llevan tag → se filtran con `~type:integration`.
23
+
24
+ `shared_crud_spec.rb` = shared examples de CRUD reusados por los specs de modelo.
25
+
26
+ ### b. Comando de corrida
27
+
28
+ | contexto | comando | qué corre |
29
+ |---|---|---|
30
+ | unit (local) | `bundle exec rspec --tag ~type:integration` | todo menos integration |
31
+ | integration (local) | `bundle exec rspec` | incluye integration (requiere Docker socket; override `DOCKER_URL`) |
32
+ | lint | `bundle exec rubocop` | rubocop-rails-omakase |
33
+ | CI (`.github/workflows/main.yml`) | `bundle exec rspec --tag ~type:integration` + `bundle exec rubocop` | unit + lint, Ruby 3.4.4 |
34
+
35
+ CI **no** corre integration (no hay daemon en el runner). Se dispara en push a `main` y en `pull_request`.
36
+
37
+ ### c. Fixtures / Factories
38
+
39
+ - **Sin fixtures YAML ni FactoryBot.** Los datos de prueba se construyen inline en cada spec.
40
+ - **Unit:** mockean `DockerSwarm::Api.request` (o `Excon`) con `and_return`/`and_raise` (stubs de respuesta Docker). `spec_helper` configura `socket_path = "unix:///tmp/docker.sock"` y logger a `/dev/null`.
41
+ - **Integration:** crean recursos reales contra el daemon; helper `random_name(prefix)` (`integration_helper.rb:26`) genera nombres únicos con `SecureRandom.hex(4)` para evitar colisiones.
42
+
43
+ ### d. Configuración de coverage
44
+
45
+ Ninguna. No hay `SimpleCov`/`.simplecov` ni umbral declarado en el repo (verificado: sin `SimpleCov` en `spec/` ni en `Gemfile.lock`).
46
+
47
+ ### e. Gaps de cobertura (narrado)
48
+
49
+ > Cobertura declarada — qué flujos de negocio están ejercitados y cuáles no. No es el % de líneas (no hay SimpleCov, §d).
50
+
51
+ **Cubierto (unit, con mocks):**
52
+ - 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
+ - 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
+ - `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
+ - Auth de registry privado: `registry_auth_spec` (helper `RegistryAuth`: exclusión mutua `registry_auth`/`registry_auth_from`, enum del `from`, traducción a header/query) + bloque registry-auth en `service_spec` (create/update, no-exposición de la credencial en logs).
57
+ - Infra de transporte: `api_spec`, `connection_spec`, `configuration_spec`, `log_helper_spec`, los 3 middleware specs.
58
+ - `swarm`, `system` (singletons): `swarm_spec`, `system_spec`.
59
+
60
+ **Cubierto (integration, daemon real):** lifecycle de containers, services, infra (networks/volumes), system (info/version/up/df), security (config/secret create+find+destroy).
61
+
62
+ **Gaps declarados:**
63
+ - `error_handler_spec` ejercita **un subset** de los 14 status; 400/401/403/406/408/409/422/502/503/504 y el fallback genérico no tienen aserción dedicada (gap de contrato §f).
64
+ - `Service#restart`, `Loggable#logs` — cobertura unit no confirmada por spec dedicado; verificar.
65
+ - Path de error `Communication` (socket caído) y la política de retry idempotente: no confirmado que haya spec dedicado en `connection_spec` (verificar).
66
+
67
+ ### f. Contract-assessment (¿los tests ejercitan los contratos públicos?)
68
+
69
+ | contrato | RFC | cubierto | nota |
70
+ |---|---|---|---|
71
+ | Interfaz Ruby pública | RFC-004 | parcial | model specs + `shared_crud` ejercitan `all/find/create/update/destroy/logs`; falta aserción sistemática de toda la superficie |
72
+ | Errores públicos | RFC-020 | parcial | `error_handler_spec` cubre el mapeo status→excepción pero **solo 4 de 14 status** → contrato de errores incompletamente verificado |
73
+ | Docker Engine API consumida | RFC-018 | sí (integration) | los specs `type: :integration` ejercitan el contrato real contra el daemon; unit lo mockea |
74
+
75
+ ### g. Link a incidente
76
+
77
+ `unknown` — **por ausencia de fuente, no por verificación de que no existan.** No hay un tracker de incidentes vinculado a este repo ni `refs incidente/PR` en los specs o en el historial localizable; por tanto no se puede afirmar ni que haya ni que no haya tests nacidos de un incidente. No es "cero incidentes verificado". Se completará si/cuando un test de regresión se ate explícitamente al bug que previene.
78
+
79
+ ### h. PII / datos sensibles en fixtures
80
+
81
+ **Sin PII real ni secretos reales.** Clasificación (no valores):
82
+
83
+ - Nombres de recursos: generados con `random_name(prefix)` → `SecureRandom.hex(4)` (`integration_helper.rb:26`). Sintéticos.
84
+ - `Config`/`Secret` Data en integration: literales de prueba base64 (`Base64.strict_encode64("hello world")`, `"top secret"` — `security_spec.rb`). **No** son credenciales reales; son strings de test.
85
+ - Unit specs: payloads inline mockeados, sin datos reales.
86
+
87
+ No cruza RFC-026 (no hay PII de personas ni secretos productivos en fixtures).
88
+
89
+ ## 3. Inferencias
90
+
91
+ | afirmación | confidence | a verificar |
92
+ |---|---|---|
93
+ | Los specs de modelo unit mockean `Api`/`Excon` (no tocan socket) | inferred | `spec_helper` apunta a `/tmp/docker.sock` inexistente → necesariamente stubean; patrón confirmado en `skill/SKILL.md` |
94
+ | Integration requiere daemon Swarm activo | declared | `integration_helper` usa el socket real (`DOCKER_URL` o default) |
95
+
96
+ ## 4. Cobertura y fronteras
97
+
98
+ - **Contenido de cada test case** individual queda en el código, no acá.
99
+ - **Niveles:** solo unit e integration; no hay system/e2e ni matriz de versiones (`Appraisals`) — Ruby único (3.4.4) en CI.
100
+ - **Coverage real (%)** no medible desde el repo (sin tool); §d declara la ausencia, no un número.
@@ -0,0 +1,55 @@
1
+ # Topología — docker-swarm
2
+
3
+ > meta: artefacto · RFC-006 · generado arch-structure · anclado a `15bcd21` · cobertura: dependencias runtime (`.gemspec` + `Gemfile.lock`) y mapa de contexto de la gema
4
+
5
+ ## 1. Resumen
6
+
7
+ Gema cliente sin servidor propio. Tres dependencias runtime (`activesupport`, `activemodel`, `excon`). Se ubica entre el código Ruby consumidor y el Docker Engine API (vía socket Unix o TCP). No tiene base de datos, colas ni servicios adyacentes propios.
8
+
9
+ ## 2. Cuerpo
10
+
11
+ ### a. Dependencias
12
+
13
+ Runtime declaradas en `docker-swarm.gemspec`; versiones resueltas en `Gemfile.lock`.
14
+
15
+ | nombre | versión (constraint) | resuelta | rol |
16
+ |---|---|---|---|
17
+ | `activesupport` | `>= 6.0` | 8.1.3 | core-ext (`HashWithIndifferentAccess`, `deep_merge`, `blank?`, `demodulize`, `pluralize`) |
18
+ | `activemodel` | `>= 6.0` | 8.1.3 | `ActiveModel::Model` (validaciones, API de atributos) en `Base` |
19
+ | `excon` | `>= 0.80` | 1.5.0 | cliente HTTP con soporte nativo de Unix socket + stack de middlewares |
20
+
21
+ Desarrollo / test (no se empaquetan): `rake ~> 13.0`, `rspec ~> 3.0`, `pry`, `rubocop-rails-omakase`.
22
+
23
+ ### b. Grafo de contexto
24
+
25
+ ```mermaid
26
+ flowchart LR
27
+ Consumer["App Ruby consumidora"] -->|usa modelos| Gem["docker-swarm (gema)"]
28
+ Gem -->|ActiveModel / core-ext| AS["activesupport + activemodel"]
29
+ Gem -->|HTTP via Excon| Daemon["Docker Engine API (dockerd)"]
30
+ Daemon -.->|unix:///var/run/docker.sock o TCP| Gem
31
+ ```
32
+
33
+ La gema es el centro: arriba la consume una app Ruby; abajo habla con el daemon Docker por Excon. `activesupport`/`activemodel` son librerías embebidas, no servicios.
34
+
35
+ ### c. Modos de ejecución
36
+
37
+ No aplica: es una librería embebida en el proceso del consumidor (sin web/worker/cron propios). El único "modo" es el transporte hacia el daemon:
38
+
39
+ | transporte | configuración | default |
40
+ |---|---|---|
41
+ | Unix socket | `socket_path = "unix:///var/run/docker.sock"` | sí |
42
+ | TCP | `socket_path = "http://host:2375"` | no |
43
+
44
+ ## 3. Inferencias
45
+
46
+ | afirmación | confidence | a verificar |
47
+ |---|---|---|
48
+ | `activesupport`/`activemodel` 8.1.3 son las resueltas hoy, pero el constraint `>= 6.0` admite Rails 6/7/8 | declared | `Gemfile.lock` fija 8.1.3; el `.gemspec` no pone techo |
49
+ | La gema no abre puertos ni corre procesos propios | declared | sin `config/`, sin `bin/` server, sin Railtie/Engine |
50
+
51
+ ## 4. Cobertura y fronteras
52
+
53
+ - **Dependencias transitivas:** las de `activesupport` (concurrent-ruby, i18n, tzinfo, etc.) y `excon` (logger) están en `Gemfile.lock` pero no son edges del repo → fuera del grafo de contexto.
54
+ - **El daemon Docker** es la única dependencia externa de runtime real; su contrato consumido se detalla en [`docs/consumed/docker-engine-api.md`](../consumed/docker-engine-api.md).
55
+ - **Arquitectura upstream** (cómo está desplegado el cluster Swarm que la gema administra) es del operador, no del repo → fuera de alcance.
@@ -66,19 +66,23 @@ module DockerSwarm
66
66
  images: {
67
67
  index: { method: :get, path: "images/json" },
68
68
  show: { method: :get, path: "images/%<id>s/json" },
69
- create: { method: :post, path: "images/create?fromImage=%<id>s" },
69
+ pull: { method: :post, path: "images/create" },
70
70
  destroy: { method: :delete, path: "images/%<id>s" }
71
71
  }
72
72
  }.freeze
73
73
 
74
- def self.request(action:, arguments: {}, query_params: {}, payload: nil)
74
+ def self.request(action:, arguments: {}, query_params: {}, payload: nil, headers: {})
75
75
  path = format(action[:path], arguments)
76
- DockerSwarm.request(
76
+ options = {
77
77
  method: action[:method],
78
78
  path: path,
79
79
  query: query_params,
80
80
  body: payload
81
- )
81
+ }
82
+ # Solo forwardeamos headers cuando hay: sin ellos, la request queda
83
+ # idéntica a la actual (no pisamos los headers por defecto de Excon).
84
+ options[:headers] = headers if headers && !headers.empty?
85
+ DockerSwarm.request(**options)
82
86
  end
83
87
  end
84
88
  end
@@ -6,20 +6,27 @@ module DockerSwarm
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  class_methods do
9
- def create(attributes = {})
10
- resource = new(attributes)
11
- resource.save
9
+ # @param attributes [Hash] atributos Docker (hash posicional braceado)
10
+ # @param opts [Hash] keywords: atributos sueltos históricos + opción reservada
11
+ # +registry_auth+ (credencial para X-Registry-Auth). El resto se pliega como atributos.
12
+ def create(attributes = {}, **opts)
13
+ registry_auth = opts.delete(:registry_auth)
14
+ resource = new(attributes.merge(opts))
15
+ resource.save(registry_auth: registry_auth)
12
16
  resource
13
17
  end
14
18
  end
15
19
 
16
- def save
20
+ # @param registry_auth [String, nil] credencial opaca para el header X-Registry-Auth
21
+ def save(registry_auth: nil)
17
22
  return false unless valid?
18
- return update if persisted?
23
+ return update(registry_auth: registry_auth) if persisted?
19
24
 
25
+ headers, = RegistryAuth.resolve(registry_auth: registry_auth)
20
26
  response = Api.request(
21
27
  action: self.class.routes[:create],
22
- payload: payload_for_docker
28
+ payload: payload_for_docker,
29
+ headers: headers
23
30
  )
24
31
 
25
32
  self.ID = response["ID"] || response["Id"] || response["Name"]
@@ -5,16 +5,27 @@ module DockerSwarm
5
5
  module Updatable
6
6
  extend ActiveSupport::Concern
7
7
 
8
- def update(new_attributes = {})
8
+ # @param new_attributes [Hash] atributos Docker (hash posicional braceado)
9
+ # @param opts [Hash] keywords: atributos sueltos históricos + opciones reservadas
10
+ # +registry_auth+ (header X-Registry-Auth) / +registry_auth_from+ (query registryAuthFrom,
11
+ # excluyente con registry_auth). El resto se pliega como atributos.
12
+ def update(new_attributes = {}, **opts)
13
+ registry_auth = opts.delete(:registry_auth)
14
+ registry_auth_from = opts.delete(:registry_auth_from)
15
+ # Valida (excluyente + enum) y resuelve los canales ANTES de mutar el objeto.
16
+ headers, auth_query = RegistryAuth.resolve(registry_auth: registry_auth, registry_auth_from: registry_auth_from)
17
+
9
18
  current_version = self.Version&.dig("Index")
10
- assign_attributes(new_attributes) if new_attributes.present?
19
+ attributes = new_attributes.merge(opts)
20
+ assign_attributes(attributes) if attributes.present?
11
21
  return false unless valid?
12
22
 
13
23
  Api.request(
14
24
  action: self.class.routes[:update],
15
25
  arguments: { id: self.ID },
16
- query_params: { version: current_version },
17
- payload: payload_for_docker
26
+ query_params: { version: current_version }.merge(auth_query),
27
+ payload: payload_for_docker,
28
+ headers: headers
18
29
  )
19
30
 
20
31
  true
@@ -102,14 +102,14 @@ module DockerSwarm
102
102
  end
103
103
 
104
104
  def client
105
- debug_enabled = logger&.level == Logger::DEBUG
106
-
105
+ # NO habilitamos el debug de Excon ni le pasamos el logger. El instrumentor
106
+ # de Excon redacta solo Authorization/Proxy-Authorization, NUNCA headers de
107
+ # autenticación custom (p. ej. X-Registry-Auth) → filtraría esa credencial.
108
+ # Nuestro #log_event ya loguea request/response con redacción recursiva
109
+ # (LogHelper.sanitize). Para wire-debug explícito y consciente del riesgo queda
110
+ # EXCON_DEBUG (mecanismo nativo de Excon, off por defecto).
107
111
  options = {
108
112
  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
113
  retry_limit: 0
114
114
  }
115
115
 
@@ -5,15 +5,38 @@ 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
+ # Redacta recursivamente los valores cuya CLAVE es sensible, a cualquier
13
+ # profundidad (hashes y arrays anidados). No muta la entrada: devuelve copias.
14
+ #
15
+ # Un header sensible puede viajar anidado (`headers: { "X-Registry-Auth" => "<cred>" }`)
16
+ # y el match por clave de primer nivel no lo alcanzaba — el hash interno se
17
+ # interpolaba entero.
18
+ #
19
+ # @param value [Object] hash, array o escalar
20
+ # @return [Object] copia con los valores sensibles reemplazados por [FILTERED]
21
+ def self.sanitize(value)
22
+ case value
23
+ when Hash
24
+ value.each_with_object({}) do |(k, v), acc|
25
+ acc[k] = k.to_s.match?(SENSITIVE_KEYS) ? FILTERED : sanitize(v)
26
+ end
27
+ when Array
28
+ value.map { |v| sanitize(v) }
29
+ else
30
+ value
31
+ end
32
+ end
9
33
 
10
34
  # Formats a hash into a KV structured string with sensitive data masking
11
35
  # @param payload [Hash] The data to format
12
36
  # @return [String] KV formatted string
13
37
  def self.format_kv(payload)
14
- payload.map do |k, v|
15
- val = k.to_s =~ SENSITIVE_KEYS ? "[FILTERED]" : v
16
- "#{k}=#{val}"
38
+ sanitize(payload).map do |k, v|
39
+ "#{k}=#{v}"
17
40
  end.join(" ")
18
41
  rescue
19
42
  "event=logging_error"
@@ -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.1"
4
+ VERSION = "0.8.0"
5
5
  end