From 4fd0ec1e4c138efe348b51e49c856bd6826846eb Mon Sep 17 00:00:00 2001 From: David Silva Date: Thu, 6 Nov 2025 21:56:16 +0000 Subject: [PATCH] Add ruby equivalent of node.js app --- .../ruby-app-node-equivalent/.dockerignore | 52 +++++ .../222/ruby-app-node-equivalent/Dockerfile | 17 ++ lessons/222/ruby-app-node-equivalent/Gemfile | 9 + .../222/ruby-app-node-equivalent/Gemfile.lock | 33 +++ .../222/ruby-app-node-equivalent/README.md | 200 ++++++++++++++++++ lessons/222/ruby-app-node-equivalent/app.rb | 157 ++++++++++++++ .../222/ruby-app-node-equivalent/config.rb | 6 + .../222/ruby-app-node-equivalent/config.yaml | 9 + lessons/222/ruby-app-node-equivalent/db.rb | 23 ++ .../222/ruby-app-node-equivalent/devices.rb | 13 ++ .../222/ruby-app-node-equivalent/metrics.rb | 51 +++++ 11 files changed, 570 insertions(+) create mode 100644 lessons/222/ruby-app-node-equivalent/.dockerignore create mode 100644 lessons/222/ruby-app-node-equivalent/Dockerfile create mode 100644 lessons/222/ruby-app-node-equivalent/Gemfile create mode 100644 lessons/222/ruby-app-node-equivalent/Gemfile.lock create mode 100644 lessons/222/ruby-app-node-equivalent/README.md create mode 100644 lessons/222/ruby-app-node-equivalent/app.rb create mode 100644 lessons/222/ruby-app-node-equivalent/config.rb create mode 100644 lessons/222/ruby-app-node-equivalent/config.yaml create mode 100644 lessons/222/ruby-app-node-equivalent/db.rb create mode 100644 lessons/222/ruby-app-node-equivalent/devices.rb create mode 100644 lessons/222/ruby-app-node-equivalent/metrics.rb diff --git a/lessons/222/ruby-app-node-equivalent/.dockerignore b/lessons/222/ruby-app-node-equivalent/.dockerignore new file mode 100644 index 000000000..3d2986503 --- /dev/null +++ b/lessons/222/ruby-app-node-equivalent/.dockerignore @@ -0,0 +1,52 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +server/*.spec.js +kubernetes + +# Don't copy default config +config.yaml + +# Ruby specific +*.gem +*.rbc +.bundle +.config +coverage +InstalledFiles +lib/bundler/man +pkg +rdoc +spec/reports +test/tmp +test/version_tmp +tmp +.bundle/ +vendor/bundle +Gemfile.lock + diff --git a/lessons/222/ruby-app-node-equivalent/Dockerfile b/lessons/222/ruby-app-node-equivalent/Dockerfile new file mode 100644 index 000000000..bb9ae8b4d --- /dev/null +++ b/lessons/222/ruby-app-node-equivalent/Dockerfile @@ -0,0 +1,17 @@ +FROM ruby:3.3-bookworm AS build + +COPY . /app + +WORKDIR /app + +RUN bundle install + +FROM ruby:3.3-slim-bookworm + +COPY --from=build /app /app +COPY --from=build /usr/local/bundle /usr/local/bundle + +WORKDIR /app + +CMD ["ruby", "app.rb"] + diff --git a/lessons/222/ruby-app-node-equivalent/Gemfile b/lessons/222/ruby-app-node-equivalent/Gemfile new file mode 100644 index 000000000..f1e621c31 --- /dev/null +++ b/lessons/222/ruby-app-node-equivalent/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'pg' +gem 'prometheus-client' +gem 'rack' +gem 'yaml' + diff --git a/lessons/222/ruby-app-node-equivalent/Gemfile.lock b/lessons/222/ruby-app-node-equivalent/Gemfile.lock new file mode 100644 index 000000000..6ce969160 --- /dev/null +++ b/lessons/222/ruby-app-node-equivalent/Gemfile.lock @@ -0,0 +1,33 @@ +GEM + remote: https://rubygems.org/ + specs: + base64 (0.3.0) + pg (1.6.2) + pg (1.6.2-aarch64-linux) + pg (1.6.2-aarch64-linux-musl) + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-darwin) + pg (1.6.2-x86_64-linux) + pg (1.6.2-x86_64-linux-musl) + prometheus-client (4.2.5) + base64 + rack (3.2.4) + yaml (0.4.0) + +PLATFORMS + aarch64-linux + aarch64-linux-musl + arm64-darwin + ruby + x86_64-darwin + x86_64-linux + x86_64-linux-musl + +DEPENDENCIES + pg + prometheus-client + rack + yaml + +BUNDLED WITH + 2.7.2 diff --git a/lessons/222/ruby-app-node-equivalent/README.md b/lessons/222/ruby-app-node-equivalent/README.md new file mode 100644 index 000000000..94473b0a7 --- /dev/null +++ b/lessons/222/ruby-app-node-equivalent/README.md @@ -0,0 +1,200 @@ +# Ruby Device Management API + +A Ruby-based HTTP server application for managing IoT devices with PostgreSQL database integration and Prometheus metrics. This is a Ruby equivalent of the Node.js application in the `node-app` folder. + +## Overview + +This application provides a RESTful API for managing device records, including: +- Device listing and creation +- PostgreSQL database integration +- Prometheus metrics for monitoring +- Health check endpoint + +## Features + +- **HTTP Server**: Built with Rack and WEBrick +- **Database**: PostgreSQL integration using the `pg` gem +- **Metrics**: Prometheus histogram metrics for tracking database operation duration +- **RESTful API**: Endpoints for device management +- **Docker Support**: Multi-stage Dockerfile for containerized deployment + +## Prerequisites + +- Ruby 3.3 or higher +- PostgreSQL database +- Bundler gem (usually comes with Ruby) + +## Installation + +1. Install dependencies: +```bash +bundle install +``` + +## Configuration + +The application uses a `config.yaml` file for configuration. Create or modify `config.yaml`: + +```yaml +--- +appPort: 8080 +db: + user: node + password: devops123 + host: postgresql.antonputra.pvt + database: mydb + maxConnections: 75 +``` + +**Note**: Update the database connection details to match your PostgreSQL setup. + +## Running the Application + +### Local Development + +1. Ensure PostgreSQL is running and accessible +2. Make sure the database and `node_device` table exist (see migration notes below) +3. Start the server: +```bash +ruby app.rb +``` + +The server will start on `http://0.0.0.0:8080` (or the port specified in `config.yaml`). + +### Using Docker + +1. Build the Docker image: +```bash +docker build -t ruby-app-node-equivalent . +``` + +2. Run the container: +```bash +docker run -p 8080:8080 ruby-app-node-equivalent +``` + +## API Endpoints + +### GET /api/devices + +Returns a list of sample devices. + +**Response:** +```json +[ + { + "id": 1, + "uuid": "9add349c-c35c-4d32-ab0f-53da1ba40a2a", + "mac": "5F-33-CC-1F-43-82", + "firmware": "2.1.6", + "created_at": "2024-05-28T15:21:51.137Z", + "updated_at": "2024-05-28T15:21:51.137Z" + }, + ... +] +``` + +### POST /api/devices + +Creates a new device in the database. + +**Request Body:** +```json +{ + "mac": "AA-BB-CC-DD-EE-FF", + "firmware": "1.0.0" +} +``` + +**Response (201 Created):** +```json +{ + "id": 4, + "uuid": "generated-uuid", + "mac": "AA-BB-CC-DD-EE-FF", + "firmware": "1.0.0", + "created_at": "2024-11-08T12:34:56.789Z", + "updated_at": "2024-11-08T12:34:56.789Z" +} +``` + +**Error Response (400 Bad Request):** +```json +{ + "message": "Error message" +} +``` + +### GET /metrics + +Returns Prometheus metrics in text format. This endpoint exposes the `myapp_request_duration_seconds` histogram metric that tracks database operation durations. + +### GET /healthz + +Health check endpoint. Returns `OK` if the server is running. + +**Response:** +``` +OK +``` + +## Database Schema + +The application expects a PostgreSQL table named `node_device` with the following structure: + +```sql +CREATE TABLE node_device ( + id SERIAL PRIMARY KEY, + uuid VARCHAR(255) NOT NULL, + mac VARCHAR(255) NOT NULL, + firmware VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +``` + +## Project Structure + +``` +ruby-app-node-equivalent/ +├── app.rb # Main HTTP server application +├── config.rb # Configuration loader +├── config.yaml # Configuration file +├── db.rb # PostgreSQL database connection +├── devices.rb # Device database operations +├── metrics.rb # Prometheus metrics setup +├── Gemfile # Ruby dependencies +├── Dockerfile # Docker build configuration +├── .dockerignore # Docker ignore patterns +└── README.md # This file +``` + +## Dependencies + +- **pg**: PostgreSQL database adapter +- **prometheus-client**: Prometheus metrics client library +- **rack**: Web server interface +- **yaml**: YAML configuration parsing (built-in) + +## Metrics + +The application tracks database operation duration using a Prometheus histogram metric: + +- **Metric Name**: `myapp_request_duration_seconds` +- **Type**: Histogram +- **Labels**: `op` (operation type, e.g., "db") +- **Buckets**: Custom buckets optimized for low-latency operations (0.00001 to 17.5 seconds) + +## Error Handling + +- Database errors are caught and returned as 400 Bad Request with an error message +- Invalid JSON in POST requests will result in a 400 error +- All errors are logged to stdout + +## Notes + +- The server uses a 60-second keep-alive timeout +- UUIDs are automatically generated using Ruby's `SecureRandom.uuid` +- Timestamps are generated in ISO 8601 format with millisecond precision +- The application uses a single PostgreSQL connection (connection pooling can be added if needed) + diff --git a/lessons/222/ruby-app-node-equivalent/app.rb b/lessons/222/ruby-app-node-equivalent/app.rb new file mode 100644 index 000000000..5caed5020 --- /dev/null +++ b/lessons/222/ruby-app-node-equivalent/app.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'rack' +require 'webrick' +require 'webrick/httpservlet' +require 'stringio' +require 'json' +require 'securerandom' +require 'prometheus/client' +require 'prometheus/client/formats/text' +require_relative 'config' +require_relative 'metrics' +require_relative 'devices' + +# Timeout in milliseconds (60 seconds) +class App + def call(env) + req = Rack::Request.new(env) + + case req.path + when '/' + [200, { 'Content-Type' => 'text/plain' }, ['Hello, World!']] + when '/metrics' + registry = Prometheus::Client.registry + metrics_text = Prometheus::Client::Formats::Text.marshal(registry) + [200, { 'Content-Type' => 'text/plain; version=0.0.4; charset=utf-8' }, [metrics_text]] + + when '/healthz' + [200, { 'Content-Type' => 'text/plain' }, ['OK']] + + when '/api/devices' + if req.get? + devices = [ + { + id: 1, + uuid: '9add349c-c35c-4d32-ab0f-53da1ba40a2a', + mac: '5F-33-CC-1F-43-82', + firmware: '2.1.6', + created_at: '2024-05-28T15:21:51.137Z', + updated_at: '2024-05-28T15:21:51.137Z' + }, + { + id: 2, + uuid: 'd2293412-36eb-46e7-9231-af7e9249fffe', + mac: 'E7-34-96-33-0C-4C', + firmware: '1.0.3', + created_at: '2024-01-28T15:20:51.137Z', + updated_at: '2024-01-28T15:20:51.137Z' + }, + { + id: 3, + uuid: 'eee58ca8-ca51-47a5-ab48-163fd0e44b77', + mac: '68-93-9B-B5-33-B9', + firmware: '4.3.1', + created_at: '2024-08-28T15:18:21.137Z', + updated_at: '2024-08-28T15:18:21.137Z' + } + ] + + [200, { 'Content-Type' => 'application/json' }, [JSON.generate(devices)]] + elsif req.post? + body = req.body.read + device = JSON.parse(body) + + datetime = Time.now.utc.iso8601(3) + + device['uuid'] = SecureRandom.uuid + device['created_at'] = datetime + device['updated_at'] = datetime + + start_time = Time.now + begin + record = save_device( + uuid: device['uuid'], + mac: device['mac'], + firmware: device['firmware'], + created_at: device['created_at'], + updated_at: device['updated_at'] + ) + duration = Time.now - start_time + HISTOGRAM.observe({ op: 'db' }, duration) + + device['id'] = record[0]['id'].to_i + + [201, { 'Content-Type' => 'application/json' }, [JSON.generate(device)]] + rescue StandardError => e + puts e.message + puts e.backtrace.join("\n") + + [400, { 'Content-Type' => 'application/json' }, [JSON.generate({ message: e.message })]] + end + else + [404, { 'Content-Type' => 'text/plain' }, ['Not Found']] + end + + else + [404, { 'Content-Type' => 'text/plain' }, ['Not Found']] + end + end +end + +app = App.new + +# Create a Rack adapter for WEBrick +class RackAdapter < WEBrick::HTTPServlet::AbstractServlet + def initialize(server, app) + super(server) + @app = app + end + + def service(req, res) + body_content = req.body || '' + input = StringIO.new(body_content) + input.set_encoding(Encoding::BINARY) + + env = { + 'REQUEST_METHOD' => req.request_method, + 'SCRIPT_NAME' => '', + 'PATH_INFO' => req.path, + 'QUERY_STRING' => req.query_string || '', + 'SERVER_NAME' => req.host, + 'SERVER_PORT' => req.port.to_s, + 'rack.version' => Rack::VERSION, + 'rack.url_scheme' => (req.request_uri.scheme rescue 'http'), + 'rack.input' => input, + 'rack.errors' => $stderr, + 'rack.multithread' => false, + 'rack.multiprocess' => false, + 'rack.run_once' => false, + 'CONTENT_LENGTH' => body_content.bytesize.to_s, + 'CONTENT_TYPE' => req.content_type || '' + } + + req.header.each do |key, values| + env["HTTP_#{key.upcase.tr('-', '_')}"] = values.join(', ') + end + + status, headers, body = @app.call(env) + + res.status = status + headers.each { |key, value| res[key] = value } + body.each { |chunk| res.body << chunk.to_s } + end +end + +server = WEBrick::HTTPServer.new( + BindAddress: '0.0.0.0', + Port: CONFIG['appPort'] +) + +server.mount('/', RackAdapter, app) + +puts "Ruby is listening on http://0.0.0.0:#{CONFIG['appPort']} ..." + +trap('INT') { server.shutdown } +server.start + diff --git a/lessons/222/ruby-app-node-equivalent/config.rb b/lessons/222/ruby-app-node-equivalent/config.rb new file mode 100644 index 000000000..db4396185 --- /dev/null +++ b/lessons/222/ruby-app-node-equivalent/config.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require 'yaml' + +CONFIG = YAML.load_file('config.yaml') + diff --git a/lessons/222/ruby-app-node-equivalent/config.yaml b/lessons/222/ruby-app-node-equivalent/config.yaml new file mode 100644 index 000000000..020a4e3f2 --- /dev/null +++ b/lessons/222/ruby-app-node-equivalent/config.yaml @@ -0,0 +1,9 @@ +--- +appPort: 8080 +db: + user: node + password: devops123 + host: postgresql.antonputra.pvt + database: mydb + maxConnections: 75 + diff --git a/lessons/222/ruby-app-node-equivalent/db.rb b/lessons/222/ruby-app-node-equivalent/db.rb new file mode 100644 index 000000000..1e586f7a2 --- /dev/null +++ b/lessons/222/ruby-app-node-equivalent/db.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'pg' +require_relative 'config' + +# Creates a connection pool to connect to Postgres. +# Connection is established lazily to avoid errors on startup if DB is not available +module DB + def self.connection + @connection ||= PG.connect( + host: CONFIG['db']['host'], + dbname: CONFIG['db']['database'], + user: CONFIG['db']['user'], + password: CONFIG['db']['password'], + port: 5432 + ) + end + + def self.exec_params(*args) + connection.exec_params(*args) + end +end + diff --git a/lessons/222/ruby-app-node-equivalent/devices.rb b/lessons/222/ruby-app-node-equivalent/devices.rb new file mode 100644 index 000000000..e0e5a43d8 --- /dev/null +++ b/lessons/222/ruby-app-node-equivalent/devices.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative 'db' + +# Inserts a Device into the Postgres database. +def save_device(uuid:, mac:, firmware:, created_at:, updated_at:) + result = DB.exec_params( + 'INSERT INTO "node_device" ("uuid", "mac", "firmware", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id"', + [uuid, mac, firmware, created_at, updated_at] + ) + result +end + diff --git a/lessons/222/ruby-app-node-equivalent/metrics.rb b/lessons/222/ruby-app-node-equivalent/metrics.rb new file mode 100644 index 000000000..f61a3967f --- /dev/null +++ b/lessons/222/ruby-app-node-equivalent/metrics.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'prometheus/client' + +# Unlike a Summary, buckets must be defined based on the expected application latency to capture as many distributions as possible. +# More buckets mean more load on your monitoring system, so adapt to your app!!! +BUCKETS = [ + 0.00001, 0.000015, 0.00002, 0.000025, 0.00003, 0.000035, 0.00004, 0.000045, + 0.00005, 0.000055, 0.00006, 0.000065, 0.00007, 0.000075, 0.00008, 0.000085, + 0.00009, 0.000095, 0.0001, 0.000101, 0.000102, 0.000103, 0.000104, 0.000105, + 0.000106, 0.000107, 0.000108, 0.000109, 0.00011, 0.000111, 0.000112, 0.000113, + 0.000114, 0.000115, 0.000116, 0.000117, 0.000118, 0.000119, 0.00012, 0.000121, + 0.000122, 0.000123, 0.000124, 0.000125, 0.000126, 0.000127, 0.000128, + 0.000129, 0.00013, 0.000131, 0.000132, 0.000133, 0.000134, 0.000135, 0.000136, + 0.000137, 0.000138, 0.000139, 0.00014, 0.000141, 0.000142, 0.000143, 0.000144, + 0.000145, 0.000146, 0.000147, 0.000148, 0.000149, 0.00015, 0.000151, 0.000152, + 0.000153, 0.000154, 0.000155, 0.000156, 0.000157, 0.000158, 0.000159, 0.00016, + 0.000161, 0.000162, 0.000163, 0.000164, 0.000165, 0.000166, 0.000167, + 0.000168, 0.000169, 0.00017, 0.000171, 0.000172, 0.000173, 0.000174, 0.000175, + 0.000176, 0.000177, 0.000178, 0.000179, 0.00018, 0.000181, 0.000182, 0.000183, + 0.000184, 0.000185, 0.000186, 0.000187, 0.000188, 0.000189, 0.00019, 0.000191, + 0.000192, 0.000193, 0.000194, 0.000195, 0.000196, 0.000197, 0.000198, + 0.000199, 0.0002, 0.00021, 0.00022, 0.00023, 0.00024, 0.00025, 0.00026, + 0.00027, 0.00028, 0.00029, 0.0003, 0.00031, 0.00032, 0.00033, 0.00034, + 0.00035, 0.00036, 0.00037, 0.00038, 0.00039, 0.0004, 0.00041, 0.00042, + 0.00043, 0.00044, 0.00045, 0.00046, 0.00047, 0.00048, 0.00049, 0.0005, + 0.00051, 0.00052, 0.00053, 0.00054, 0.00055, 0.00056, 0.00057, 0.00058, + 0.00059, 0.0006, 0.00061, 0.00062, 0.00063, 0.00064, 0.00065, 0.00066, + 0.00067, 0.00068, 0.00069, 0.0007, 0.00071, 0.00072, 0.00073, 0.00074, + 0.00075, 0.00076, 0.00077, 0.00078, 0.00079, 0.0008, 0.00081, 0.00082, + 0.00083, 0.00084, 0.00085, 0.00086, 0.00087, 0.00088, 0.00089, 0.0009, + 0.00091, 0.00092, 0.00093, 0.00094, 0.00095, 0.00096, 0.00097, 0.00098, + 0.00099, 0.001, 0.0015, 0.002, 0.0025, 0.003, 0.0035, 0.004, 0.0045, 0.005, + 0.0055, 0.006, 0.0065, 0.007, 0.0075, 0.008, 0.0085, 0.009, 0.0095, 0.01, + 0.015, 0.02, 0.025, 0.03, 0.035, 0.04, 0.045, 0.05, 0.055, 0.06, 0.065, 0.07, + 0.075, 0.08, 0.085, 0.09, 0.095, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, + 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0, 1.5, 2.0, 2.5, + 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0, 6.5, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0, + 10.5, 11.0, 11.5, 12.0, 12.5, 13.0, 13.5, 14.0, 14.5, 15.0, 15.5, 16.0, 16.5, + 17.0, 17.5 +].freeze + +# A metric to record the duration of requests, +# such as database queries or requests to the S3 object store. +HISTOGRAM = Prometheus::Client.registry.histogram( + :myapp_request_duration_seconds, + docstring: 'Duration of the request.', + buckets: BUCKETS, + labels: [:op] +) +