docker-swarm 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97102c26aff73c5426373e1cfbfb542fd2cf4e82404a3b1de4833b8e82d5d66a
4
- data.tar.gz: 3a9288b1dc9eae6d4849503de616561c94af264ec467e9450c2a4e2863e4706f
3
+ metadata.gz: b46f33638fb88f8c9fca4a85747f67e266525933e4b22d38bb47e2ce1556a552
4
+ data.tar.gz: b080b742324d91eb1570af605b05539da67284fb76631faae04583f2577edad3
5
5
  SHA512:
6
- metadata.gz: fb9eea1d60221c643681e50ea18a4a9830967e46ef226543c62df85c26102c9fd60b26a6f77f35f12c6e5ff86ca15ec280175ca656c266587e7737414837f91c
7
- data.tar.gz: 0b223dd63b9c69a14a24c07b3be527876d9535c1114f53a507152fd2d28cbf126a387b0b72ddbba18290aa621c49369adaf83f2e80ca68f3d2dad8def9c749bf
6
+ metadata.gz: 17f03f903b19273e822feb4a389ff9b862d034607f032c3b6112838b1ddf5f38927dabad4001214f6ad9ce6a06f8661939b38fb2fa3dda8a0b1975246da199fa
7
+ data.tar.gz: 44edbe59ffa431b2cf78735ca138c2fd4c41d0d57dda543509d9de8a417343c525d1a3493d549fa4e2ebfc63948491a2994917df493683c25f8c33e2f795eadf
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/docker-swarm.svg)](https://badge.fury.io/rb/docker-swarm)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- `docker-swarm` es un ORM ligero y cliente API robusto para interactuar con Docker Swarm desde Ruby. Diseñado para sentirse familiar a los desarrolladores de Rails, utiliza `ActiveModel` para ofrecer una interfaz limpia y potente.
6
+ `docker-swarm` es un ORM ligero y cliente API robusto para interactuar con Docker Swarm desde Ruby. Diseñado para sentirse familiar a los desarrolladores de Rails, utiliza `ActiveModel` para ofrecer una interfaz limpia y potente con estándares de observabilidad de Wispro.
7
7
 
8
8
  ## 🚀 Inicio Rápido
9
9
 
@@ -13,42 +13,37 @@ require 'docker_swarm'
13
13
  # Configurar (opcional, usa defaults)
14
14
  DockerSwarm.configure do |config|
15
15
  config.socket_path = "unix:///var/run/docker.sock"
16
+ config.log_level = Logger::INFO
17
+ config.read_timeout = 30
18
+ config.max_retries = 3
16
19
  end
17
20
 
18
- # Listar servicios
21
+ # Listar servicios (con soporte para Indifferent Access en arrays)
19
22
  services = DockerSwarm::Service.all
20
- services.each { |s| puts "#{s.ID}: #{s.Spec['Name']}" }
23
+ services.each { |s| puts "#{s.ID}: #{s.Spec[:Name]}" }
21
24
 
22
25
  # Crear un nuevo servicio
23
26
  service = DockerSwarm::Service.create(
27
+ Name: "my-webapp",
24
28
  Spec: {
25
- Name: "my-webapp",
26
29
  TaskTemplate: {
27
30
  ContainerSpec: { Image: "nginx:latest" }
28
31
  }
29
32
  }
30
33
  )
31
34
 
32
- # Obtener logs
35
+ # Obtener logs (stdout y stderr habilitados por defecto)
33
36
  puts service.logs
34
37
  ```
35
38
 
36
- ## 📖 Documentación Completa
37
-
38
- Para profundizar en el uso de la gema, consulta las siguientes guías:
39
-
40
- 1. **[Guía de Configuración](docs/configuration.md)**: Cómo configurar el socket, logger y opciones globales.
41
- 2. **[Uso del ORM (Modelos)](docs/models.md)**: Todo sobre el ciclo de vida de los recursos (`Service`, `Node`, `Task`, etc.).
42
- 3. **[Cliente de API (Bajo Nivel)](docs/api.md)**: Cómo realizar peticiones personalizadas directamente a la API de Docker.
43
- 4. **[Manejo de Errores](docs/errors.md)**: Jerarquía de excepciones y mapeo de errores de Docker.
44
- 5. **[Pruebas y Mocking](docs/testing.md)**: Guía para testear tu aplicación sin depender de un socket de Docker real.
45
-
46
39
  ## 🛠 Características Clave
47
40
 
41
+ - **Observabilidad Wispro**: Logging estructurado (KV) con `component`, `event`, `source` y `duration_ms` usando reloj monotónico.
42
+ - **Seguridad**: Enmascaramiento automático de secretos (`password`, `token`, etc.) en los logs.
43
+ - **Deep Indifferent Access**: Acceso a atributos mediante símbolos o strings, incluso en resultados de listados (`.all`).
44
+ - **Resiliencia**: Gestión inteligente de timeouts (`read`, `write`, `connect`) y reintentos automáticos para errores de red.
48
45
  - **Mapeo PascalCase**: Mantiene la fidelidad con los atributos de Docker (e.g., `s.ID`, `s.Spec`) evitando transformaciones costosas.
49
46
  - **ActiveModel Ready**: Soporta validaciones, serialización JSON y comportamientos estándar de modelos Ruby.
50
- - **Surgical Updates**: Actualizaciones precisas enviando solo el índice de versión y el payload necesario.
51
- - **Excon Stack**: Basado en `Excon` con middlewares para encoding de peticiones, parseo de respuestas y gestión de errores.
52
47
 
53
48
  ## 🤝 Contribuir
54
49
 
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "docker_swarm"
@@ -13,8 +13,19 @@ module DockerSwarm
13
13
  Api::ENDPOINTS[resource_name.to_sym]
14
14
  end
15
15
 
16
+ # Key in the JSON response that contains the array of items.
17
+ # Override in subclasses if the API returns a wrapped object (e.g., Volumes).
18
+ # @return [String, nil]
19
+ def root_key
20
+ nil
21
+ end
22
+
16
23
  def all(filters = {})
17
- _fetch_all(filters).map { |data| new(data) }
24
+ response = _fetch_all(filters)
25
+ return [] if response.blank?
26
+
27
+ data = root_key && response.is_a?(Hash) ? response[root_key] : response
28
+ Array(data).map { |item| new(item) }
18
29
  end
19
30
 
20
31
  def find(id)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module DockerSwarm
6
+ class Configuration
7
+ attr_accessor :socket_path, :logger, :log_level,
8
+ :read_timeout, :write_timeout, :connect_timeout,
9
+ :max_retries
10
+
11
+ def initialize
12
+ @socket_path = "unix:///var/run/docker.sock"
13
+ @logger = Logger.new($stdout)
14
+ @log_level = Logger::INFO
15
+ @read_timeout = 60
16
+ @write_timeout = 60
17
+ @connect_timeout = 10
18
+ @max_retries = 3
19
+ end
20
+ end
21
+ end
@@ -10,29 +10,120 @@ module DockerSwarm
10
10
  end
11
11
 
12
12
  def request(options = {})
13
- normalized_path = socket_path.sub(/^unix:\/\//, "")
14
- client(normalized_path).request(options).body
13
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+
15
+ log_event("request_started",
16
+ level: :debug,
17
+ data: { method: options[:method], path: options[:path] })
18
+
19
+ # Combinar opciones por defecto de la gema con las de la petición
20
+ request_options = {
21
+ idempotent: true,
22
+ retry_errors: [Excon::Error::Socket, Excon::Error::Timeout],
23
+ read_timeout: DockerSwarm.configuration.read_timeout,
24
+ write_timeout: DockerSwarm.configuration.write_timeout,
25
+ connect_timeout: DockerSwarm.configuration.connect_timeout,
26
+ retries: DockerSwarm.configuration.max_retries
27
+ }.merge(options)
28
+
29
+ response = client.request(request_options)
30
+
31
+ log_event("request_success",
32
+ data: {
33
+ method: request_options[:method],
34
+ path: request_options[:path],
35
+ status: response.status,
36
+ duration_ms: calculate_duration(start_time)
37
+ })
38
+
39
+ response.body
15
40
  rescue => e
16
- if e.is_a?(Excon::Error::Socket)
17
- raise Errors::Communication, "Docker socket error: #{e.message}"
41
+ # Excon suele envolver excepciones de middleware en Excon::Error::Socket.
42
+ # Intentamos recuperar la causa original si es una de nuestras excepciones.
43
+ actual_error = (e.respond_to?(:cause) && e.cause&.class&.name&.include?("DockerSwarm::Error")) ? e.cause : e
44
+
45
+ log_event("request_failure",
46
+ level: :error,
47
+ data: {
48
+ method: options[:method],
49
+ path: options[:path],
50
+ error: actual_error.class.name,
51
+ message: actual_error.message,
52
+ duration_ms: calculate_duration(start_time)
53
+ })
54
+
55
+ if actual_error.class.name.include?("DockerSwarm::Error")
56
+ raise actual_error
57
+ elsif actual_error.is_a?(Excon::Error::Socket)
58
+ raise ::DockerSwarm::Error::Communication, "Docker socket error: #{actual_error.message}"
18
59
  else
19
- raise e
60
+ raise actual_error
20
61
  end
21
62
  end
63
+
22
64
  private
23
65
 
24
- def client(path)
25
- @client ||= Excon.new(
26
- "unix:///",
27
- socket: path,
28
- middlewares: [
29
- Excon::Middleware::ResponseParser,
30
- Excon::Middleware::RedirectFollower,
31
- Middleware::RequestEncoder,
32
- Middleware::ResponseJSONParser,
33
- Middleware::ErrorHandler
34
- ]
35
- )
66
+ def log_event(event, level: :info, data: {})
67
+ return unless logger
68
+ # Respetar el nivel de log antes de procesar nada
69
+ return if level == :debug && logger.level > Logger::DEBUG
70
+
71
+ log_block = proc do
72
+ begin
73
+ payload = {
74
+ component: "docker_swarm.connection",
75
+ event: event,
76
+ source: "http"
77
+ }.merge(data)
78
+
79
+ payload.map do |k, v|
80
+ val = k.to_s =~ /password|token|api_key|auth|secret/i ? "[FILTERED]" : v
81
+ "#{k}=#{val}"
82
+ end.join(" ")
83
+ rescue
84
+ "component=docker_swarm.connection event=logging_error"
85
+ end
86
+ end
87
+
88
+ if level == :debug
89
+ logger.debug(&log_block)
90
+ else
91
+ logger.send(level, log_block.call)
92
+ end
93
+ end
94
+
95
+ def calculate_duration(start_time)
96
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
97
+ end
98
+
99
+ def common_middlewares
100
+ # Usamos los middlewares por defecto de Excon para garantizar que todas las llaves internas
101
+ # (como :retries_remaining o :instrumentor_name) se inicialicen correctamente.
102
+ Excon.defaults[:middlewares] + [
103
+ Excon::Middleware::RedirectFollower,
104
+ Middleware::RequestEncoder,
105
+ Middleware::ResponseJSONParser,
106
+ Middleware::ErrorHandler
107
+ ]
108
+ end
109
+
110
+ def client
111
+ debug_enabled = logger&.level == Logger::DEBUG
112
+
113
+ options = {
114
+ middlewares: common_middlewares,
115
+ logger: logger,
116
+ debug_request: debug_enabled,
117
+ debug_response: debug_enabled,
118
+ # Si debug_enabled es true, Excon usará su lógica interna de debug con el logger proporcionado
119
+ retry_limit: 0
120
+ }
121
+
122
+ @client ||= if socket_path.start_with?("unix://")
123
+ Excon.new("unix:///", options.merge(socket: socket_path.sub(/^unix:\/\//, "")))
124
+ else
125
+ Excon.new(socket_path, options)
126
+ end
36
127
  end
37
128
  end
38
129
  end
@@ -3,7 +3,7 @@
3
3
  module DockerSwarm
4
4
  class Error < StandardError; end
5
5
 
6
- module Errors
6
+ class Error
7
7
  class BadRequest < Error; end
8
8
  class Unauthorized < Error; end
9
9
  class Forbidden < Error; end
@@ -19,4 +19,31 @@ module DockerSwarm
19
19
  class TooManyRequests < Error; end
20
20
  class Communication < Error; end
21
21
  end
22
+
23
+ # Aliases para permitir acceso directo DockerSwarm::Conflict
24
+ BadRequest = Error::BadRequest
25
+ Unauthorized = Error::Unauthorized
26
+ Forbidden = Error::Forbidden
27
+ NotFound = Error::NotFound
28
+ NotAcceptable = Error::NotAcceptable
29
+ RequestTimeout = Error::RequestTimeout
30
+ Conflict = Error::Conflict
31
+ UnprocessableEntity = Error::UnprocessableEntity
32
+ InternalServerError = Error::InternalServerError
33
+ BadGateway = Error::BadGateway
34
+ ServiceUnavailable = Error::ServiceUnavailable
35
+ GatewayTimeout = Error::GatewayTimeout
36
+ TooManyRequests = Error::TooManyRequests
37
+ Communication = Error::Communication
38
+
39
+ # Módulo Errors para compatibilidad adicional
40
+ module Errors
41
+ def self.const_missing(name)
42
+ if ::DockerSwarm::Error.const_defined?(name)
43
+ ::DockerSwarm::Error.const_get(name)
44
+ else
45
+ super
46
+ end
47
+ end
48
+ end
22
49
  end
@@ -9,31 +9,58 @@ module DockerSwarm
9
9
  status = env[:response][:status]
10
10
  body = env[:response][:body]
11
11
 
12
+ return @stack.response_call(env) if (200..299).include?(status)
13
+
14
+ error_msg = error_message(body)
15
+ log_business_error(env, status, error_msg)
16
+
12
17
  case status
13
- when 200..299
14
- # Continuar normalmente
15
- when 400 then raise Errors::BadRequest, error_message(body)
16
- when 401 then raise Errors::Unauthorized, error_message(body)
17
- when 403 then raise Errors::Forbidden, error_message(body)
18
- when 404 then raise Errors::NotFound, error_message(body)
19
- when 406 then raise Errors::NotAcceptable, error_message(body)
20
- when 408 then raise Errors::RequestTimeout, error_message(body)
21
- when 409 then raise Errors::Conflict, error_message(body)
22
- when 422 then raise Errors::UnprocessableEntity, body
23
- when 429 then raise Errors::TooManyRequests, error_message(body)
24
- when 500 then raise Errors::InternalServerError, error_message(body)
25
- when 502 then raise Errors::BadGateway, error_message(body)
26
- when 503 then raise Errors::ServiceUnavailable, error_message(body)
27
- when 504 then raise Errors::GatewayTimeout, error_message(body)
18
+ when 400 then raise ::DockerSwarm::BadRequest, error_msg
19
+ when 401 then raise ::DockerSwarm::Unauthorized, error_msg
20
+ when 403 then raise ::DockerSwarm::Forbidden, error_msg
21
+ when 404 then raise ::DockerSwarm::NotFound, error_msg
22
+ when 406 then raise ::DockerSwarm::NotAcceptable, error_msg
23
+ when 408 then raise ::DockerSwarm::RequestTimeout, error_msg
24
+ when 409 then raise ::DockerSwarm::Conflict, error_msg
25
+ when 422 then raise ::DockerSwarm::UnprocessableEntity, body
26
+ when 429 then raise ::DockerSwarm::TooManyRequests, error_msg
27
+ when 500 then raise ::DockerSwarm::InternalServerError, error_msg
28
+ when 502 then raise ::DockerSwarm::BadGateway, error_msg
29
+ when 503 then raise ::DockerSwarm::ServiceUnavailable, error_msg
30
+ when 504 then raise ::DockerSwarm::GatewayTimeout, error_msg
28
31
  else
29
- raise Errors::Error, "HTTP #{status}: #{error_message(body)}"
32
+ raise ::DockerSwarm::Error, "HTTP #{status}: #{error_msg}"
30
33
  end
31
-
32
- @stack.response_call(env)
33
34
  end
34
35
 
35
36
  private
36
37
 
38
+ def log_business_error(env, status, message)
39
+ logger = env[:logger]
40
+ return unless logger
41
+
42
+ begin
43
+ payload = {
44
+ component: "docker_swarm.middleware.error_handler",
45
+ event: "business_error",
46
+ source: "http",
47
+ status: status,
48
+ message: message,
49
+ method: env[:method],
50
+ path: env[:path]
51
+ }
52
+
53
+ kv_string = payload.map do |k, v|
54
+ val = k.to_s =~ /password|token|api_key|auth|secret/i ? "[FILTERED]" : v
55
+ "#{k}=#{val}"
56
+ end.join(" ")
57
+
58
+ logger.error(kv_string)
59
+ rescue
60
+ # Resilience: logging should not crash the app
61
+ end
62
+ end
63
+
37
64
  def error_message(body)
38
65
  if body.is_a?(Hash)
39
66
  body["message"] || body["error"] || body.to_json
@@ -4,12 +4,28 @@ module DockerSwarm
4
4
  module Middleware
5
5
  class RequestEncoder < Excon::Middleware::Base
6
6
  def request_call(env)
7
- if env[:body] && env[:body].is_a?(Hash)
8
- env[:body] = env[:body].to_json
9
- env[:headers]["Content-Type"] ||= "application/json"
7
+ if env[:body] && !env[:body].is_a?(String)
8
+ content_type = (env[:headers]["Content-Type"] || env[:headers][:content_type]).to_s
9
+ env[:body] = serialize_body(env[:body], content_type)
10
+
11
+ if content_type.blank? || content_type.include?("application/json")
12
+ env[:headers]["Content-Type"] ||= "application/json"
13
+ end
10
14
  end
11
15
  @stack.request_call(env)
12
16
  end
17
+
18
+ private
19
+
20
+ def serialize_body(body, content_type)
21
+ if content_type.include?("application/x-www-form-urlencoded")
22
+ URI.encode_www_form(body)
23
+ elsif content_type.include?("multipart/form-data")
24
+ body
25
+ else
26
+ body.to_json
27
+ end
28
+ end
13
29
  end
14
30
  end
15
31
  end
@@ -9,17 +9,29 @@ module DockerSwarm
9
9
  headers = env[:response][:headers] || {}
10
10
 
11
11
  if body && !body.empty? && headers["Content-Type"]&.include?("application/json")
12
- begin
13
- parsed = JSON.parse(body)
14
- env[:response][:body] = parsed.is_a?(Hash) ? parsed.with_indifferent_access : parsed
15
- rescue JSON::ParserError
16
- # Keep original body if parsing fails
17
- end
12
+ env[:response][:body] = parse_json(body)
18
13
  end
19
14
  end
20
15
 
21
16
  @stack.response_call(env)
22
17
  end
18
+
19
+ private
20
+
21
+ def parse_json(body)
22
+ result = body.is_a?(String) ? JSON.parse(body) : body
23
+
24
+ case result
25
+ when Hash
26
+ result.with_indifferent_access
27
+ when Array
28
+ result.map { |item| item.is_a?(Hash) ? item.with_indifferent_access : item }
29
+ else
30
+ result
31
+ end
32
+ rescue JSON::ParserError
33
+ body
34
+ end
23
35
  end
24
36
  end
25
37
  end
@@ -8,20 +8,11 @@ module DockerSwarm
8
8
  include Concerns::Updatable
9
9
  include Concerns::Deletable
10
10
 
11
- validate :validate_name_presence
12
-
13
11
  # Fetches logs for the service
14
12
  # @param query_params [Hash] Query parameters for the logs endpoint (stdout, stderr, follow, etc.)
15
13
  # @return [String] The raw log stream
16
14
  def logs(query_params = { stdout: 1, stderr: 1 })
17
15
  Api.request(action: self.class.routes[:logs], arguments: { id: self.ID }, query_params: query_params)
18
16
  end
19
-
20
- private
21
-
22
- def validate_name_presence
23
- name = attributes["Name"] || (respond_to?(:Name) ? Name : nil)
24
- errors.add(:Name, "can't be blank") if name.blank?
25
- end
26
17
  end
27
18
  end
@@ -5,12 +5,8 @@ module DockerSwarm
5
5
  include Concerns::Creatable
6
6
  include Concerns::Deletable
7
7
 
8
- def self.all(filters = {})
9
- response = _fetch_all(filters)
10
- return [] if response.blank?
11
-
12
- data = response.is_a?(Hash) ? response["Volumes"] : response
13
- Array(data).map { |item| new(item) }
8
+ def self.root_key
9
+ "Volumes"
14
10
  end
15
11
  end
16
12
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DockerSwarm
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/docker_swarm.rb CHANGED
@@ -7,6 +7,34 @@ require "excon"
7
7
  require "json"
8
8
  require "logger"
9
9
 
10
+ module DockerSwarm
11
+ class << self
12
+ attr_accessor :configuration
13
+
14
+ def configure
15
+ @configuration ||= Configuration.new
16
+ yield(@configuration) if block_given?
17
+
18
+ if @configuration.logger.respond_to?(:level=)
19
+ @configuration.logger.level = @configuration.log_level
20
+ end
21
+
22
+ @connection = nil
23
+ end
24
+
25
+ def connection
26
+ configure unless @configuration
27
+ @connection ||= Connection.new(@configuration.socket_path, @configuration.logger)
28
+ end
29
+
30
+ def request(options = {})
31
+ connection.request(options)
32
+ end
33
+ end
34
+ end
35
+
36
+ # Primero cargamos la configuración porque la clase DockerSwarm la usa arriba
37
+ require_relative "docker_swarm/configuration"
10
38
  require_relative "docker_swarm/version"
11
39
  require_relative "docker_swarm/errors"
12
40
  require_relative "docker_swarm/middleware/request_encoder"
@@ -31,32 +59,3 @@ require_relative "docker_swarm/models/network"
31
59
  require_relative "docker_swarm/models/config"
32
60
  require_relative "docker_swarm/models/secret"
33
61
  require_relative "docker_swarm/models/volume"
34
-
35
- module DockerSwarm
36
- class << self
37
- attr_accessor :configuration
38
-
39
- def configure
40
- self.configuration ||= Configuration.new
41
- yield(configuration) if block_given?
42
- end
43
-
44
- def connection
45
- configure unless configuration
46
- @connection ||= Connection.new(configuration.socket_path, configuration.logger)
47
- end
48
-
49
- def request(options = {})
50
- connection.request(options)
51
- end
52
- end
53
-
54
- class Configuration
55
- attr_accessor :socket_path, :logger
56
-
57
- def initialize
58
- @socket_path = "unix:///var/run/docker.sock"
59
- @logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
60
- end
61
- end
62
- end
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel
@@ -89,12 +89,14 @@ extensions: []
89
89
  extra_rdoc_files: []
90
90
  files:
91
91
  - README.md
92
+ - lib/docker-swarm.rb
92
93
  - lib/docker_swarm.rb
93
94
  - lib/docker_swarm/api.rb
94
95
  - lib/docker_swarm/base.rb
95
96
  - lib/docker_swarm/concerns/creatable.rb
96
97
  - lib/docker_swarm/concerns/deletable.rb
97
98
  - lib/docker_swarm/concerns/updatable.rb
99
+ - lib/docker_swarm/configuration.rb
98
100
  - lib/docker_swarm/connection.rb
99
101
  - lib/docker_swarm/errors.rb
100
102
  - lib/docker_swarm/middleware/error_handler.rb