Aggiunge registry nodi stream e provisioning Hetzner/lab per lo scale-out.

Prepara l'architettura multi-nodo (MediaMTX+ffmpeg) con assignment URL per sessione, admin di provision/drain e provider Cloud/DNS astratti verso mltv-stream.net.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-09 19:52:47 +02:00
co-authored by Cursor
parent 45bdda7c95
commit c3878cdc6d
48 changed files with 1980 additions and 25 deletions
@@ -0,0 +1,21 @@
# frozen_string_literal: true
module Streams
module DnsProviders
class Error < StandardError; end
class Base
def upsert_a(name, ip)
raise NotImplementedError
end
def delete_a(name)
raise NotImplementedError
end
def resolve(name)
raise NotImplementedError
end
end
end
end
@@ -0,0 +1,105 @@
# frozen_string_literal: true
require "faraday"
require "cgi"
module Streams
module DnsProviders
# Hetzner Cloud DNS (Console) — zone mltv-stream.net.
#
# ENV:
# HCLOUD_TOKEN (stesso del Cloud)
# STREAM_DNS_ZONE (default mltv-stream.net)
# STREAM_DNS_TTL (default 60)
class Hetzner < Base
API = "https://api.hetzner.cloud/v1"
def initialize(token: ENV.fetch("HCLOUD_TOKEN"), zone: nil, conn: nil)
@token = token
@zone = zone || ENV.fetch("STREAM_DNS_ZONE", "mltv-stream.net")
@ttl = ENV.fetch("STREAM_DNS_TTL", "60").to_i
@conn = conn
end
def upsert_a(name, ip)
rr_name = relative_name(name)
delete_a(name)
post("/zones/#{CGI.escape(@zone)}/rrsets", {
name: rr_name,
type: "A",
ttl: @ttl,
records: [{ value: ip.to_s, comment: "matchlivetv stream-node" }],
labels: { "matchlivetv" => "true", "role" => "stream-node" }
})
true
end
def delete_a(name)
rr_name = relative_name(name)
encoded = CGI.escape(rr_name)
response = conn.delete("/zones/#{CGI.escape(@zone)}/rrsets/#{encoded}/A") do |req|
req.headers.update(auth_headers)
end
return true if response.status == 404 || response.status == 204 || response.success?
raise Error, "Hetzner DNS API #{response.status}: #{response.body.inspect}"
end
def resolve(name)
rr_name = relative_name(name)
data = get("/zones/#{CGI.escape(@zone)}/rrsets", name: rr_name, type: "A")
rrset = Array(data["rrsets"]).first
Array(rrset&.dig("records")).first&.dig("value")
rescue Error
nil
end
private
def relative_name(name)
host = name.to_s.strip.downcase.delete_suffix(".")
suffix = ".#{@zone}"
return "@" if host == @zone
return host.delete_suffix(suffix) if host.end_with?(suffix)
host
end
def conn
@conn ||= Faraday.new(url: API) do |f|
f.request :json
f.response :json, content_type: /\bjson$/
f.adapter Faraday.default_adapter
end
end
def auth_headers
{ "Authorization" => "Bearer #{@token}" }
end
def get(path, params = {})
response = conn.get(path) do |req|
req.headers.update(auth_headers)
req.params.update(params)
end
unwrap!(response)
end
def post(path, body)
response = conn.post(path) do |req|
req.headers.update(auth_headers)
req.body = body
end
unwrap!(response)
end
def unwrap!(response)
unless response.success?
raise Error, "Hetzner DNS API #{response.status}: #{response.body.inspect}"
end
response.body.is_a?(Hash) ? response.body : {}
end
end
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
module Streams
module DnsProviders
# DNS lab in Redis (e dump hosts). Nessuna chiamata al registrar.
class Lab < Base
REDIS_KEY = "stream_dns:a_records"
def initialize(redis: nil)
@redis = redis
end
def upsert_a(name, ip)
host = normalize(name)
redis.hset(REDIS_KEY, host, ip.to_s)
true
end
def delete_a(name)
redis.hdel(REDIS_KEY, normalize(name))
true
end
def resolve(name)
redis.hget(REDIS_KEY, normalize(name))
end
def all_records
redis.hgetall(REDIS_KEY)
end
def hosts_file_snippet
all_records.sort.map { |host, ip| "#{ip}\t#{host}" }.join("\n")
end
private
def normalize(name)
name.to_s.strip.downcase.delete_suffix(".")
end
def redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
end
end
end