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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +14 -6
- data/docs/behavior/behavior.md +58 -3
- data/docs/config/configuracion.md +74 -0
- data/docs/consumed/docker-engine-api.md +116 -0
- data/docs/errors/errors.md +103 -0
- data/docs/glossary/glossary.md +23 -23
- data/docs/interface/interface.md +118 -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 +13 -6
- data/lib/docker_swarm/concerns/updatable.rb +15 -4
- data/lib/docker_swarm/connection.rb +6 -6
- data/lib/docker_swarm/log_helper.rb +26 -3
- 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 +1 -0
- data/skill/SKILL.md +15 -8
- metadata +11 -4
- data/docs/config/config.md +0 -140
|
@@ -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.
|
data/lib/docker_swarm/api.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/docker_swarm/version.rb
CHANGED