From 559284f0b2f4e10f4bb6e36b19294ab92a171585 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Mon, 10 Aug 2026 12:20:36 +0200 Subject: [PATCH] Corregge il provisioning Hetzner: Faraday, cloud-init e default cpx12. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sistemati path API /v1, AppArmor/auth MediaMTX sul nodo e defaults nbg1 così lo smoke Cloud è ripetibile. Co-authored-by: Cursor --- .../streams/cloud_providers/hetzner.rb | 19 ++-- .../services/streams/dns_providers/hetzner.rb | 9 +- .../spec/services/streams/autoscaler_spec.rb | 9 +- .../streams/cloud_providers/hetzner_spec.rb | 18 ++-- .../streams/dns_providers/hetzner_spec.rb | 8 +- .../services/streams/node_provisioner_spec.rb | 2 + .../services/streams/node_registry_spec.rb | 6 ++ docs/infrastructure/STREAMING_AUTOSCALE.md | 6 +- infra/.env.production.example | 6 +- infra/stream-node/cloud-init.yaml | 96 ++++++++++++------- 10 files changed, 113 insertions(+), 66 deletions(-) diff --git a/backend/app/services/streams/cloud_providers/hetzner.rb b/backend/app/services/streams/cloud_providers/hetzner.rb index bde3b53..6963201 100644 --- a/backend/app/services/streams/cloud_providers/hetzner.rb +++ b/backend/app/services/streams/cloud_providers/hetzner.rb @@ -15,7 +15,8 @@ module Streams # HCLOUD_NETWORK_ID (opzionale, private network / WireGuard prep) # HCLOUD_USER_DATA_FILE (opzionale, cloud-init path) class Hetzner < Base - API = "https://api.hetzner.cloud/v1" + # Trailing slash obbligatorio: path assoluti tipo "/servers" altrimenti droppano /v1. + API = "https://api.hetzner.cloud/v1/" def initialize(token: ENV.fetch("HCLOUD_TOKEN"), conn: nil) @token = token @@ -25,12 +26,12 @@ module Streams def create_node(name:, labels: {}) body = { name: name, - server_type: ENV.fetch("HCLOUD_SERVER_TYPE", "cpx21"), + server_type: ENV.fetch("HCLOUD_SERVER_TYPE", "cpx12"), image: ENV.fetch("HCLOUD_IMAGE", "debian-12"), - location: ENV.fetch("HCLOUD_LOCATION", "fsn1"), + location: ENV.fetch("HCLOUD_LOCATION", "nbg1"), start_after_create: true, labels: default_labels.merge(stringify_labels(labels)), - ssh_keys: [ENV.fetch("HCLOUD_SSH_KEY", "matchlivetv-stream")], + ssh_keys: [ENV.fetch("HCLOUD_SSH_KEY", "matchlivetv-stream-hetzner")], public_net: { enable_ipv4: true, enable_ipv6: false @@ -41,7 +42,7 @@ module Streams user_data = cloud_init_user_data body[:user_data] = user_data if user_data.present? - data = post("/servers", body) + data = post("servers", body) server = data["server"] || {} action = data["action"] wait_action!(action) if action @@ -51,7 +52,7 @@ module Streams end def destroy_node(instance_id) - delete("/servers/#{instance_id}") + delete("servers/#{instance_id}") true end @@ -61,14 +62,14 @@ module Streams params[:label_selector] = label_selector if label_selector.present? params[:label_selector] ||= "matchlivetv=true,role=stream-node" - data = get("/servers", params) + data = get("servers", params) Array(data["servers"]).map { |s| instance_from_server(s) } end def wait_until_running(instance_id, timeout: 180) deadline = Time.now + timeout loop do - data = get("/servers/#{instance_id}") + data = get("servers/#{instance_id}") server = data["server"] status = server["status"] if status == "running" @@ -169,7 +170,7 @@ module Streams deadline = Time.now + timeout id = action["id"] loop do - data = get("/actions/#{id}") + data = get("actions/#{id}") status = data.dig("action", "status") return if status == "success" raise Error, "Hetzner action #{id} failed: #{data.inspect}" if status == "error" diff --git a/backend/app/services/streams/dns_providers/hetzner.rb b/backend/app/services/streams/dns_providers/hetzner.rb index 9a5a673..f345b7f 100644 --- a/backend/app/services/streams/dns_providers/hetzner.rb +++ b/backend/app/services/streams/dns_providers/hetzner.rb @@ -12,7 +12,8 @@ module Streams # STREAM_DNS_ZONE (default mltv-stream.net) # STREAM_DNS_TTL (default 60) class Hetzner < Base - API = "https://api.hetzner.cloud/v1" + # Trailing slash obbligatorio: path assoluti altrimenti droppano /v1. + API = "https://api.hetzner.cloud/v1/" def initialize(token: ENV.fetch("HCLOUD_TOKEN"), zone: nil, conn: nil) @token = token @@ -24,7 +25,7 @@ module Streams def upsert_a(name, ip) rr_name = relative_name(name) delete_a(name) - post("/zones/#{CGI.escape(@zone)}/rrsets", { + post("zones/#{CGI.escape(@zone)}/rrsets", { name: rr_name, type: "A", ttl: @ttl, @@ -37,7 +38,7 @@ module Streams 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| + 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? @@ -47,7 +48,7 @@ module Streams def resolve(name) rr_name = relative_name(name) - data = get("/zones/#{CGI.escape(@zone)}/rrsets", name: rr_name, type: "A") + 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 diff --git a/backend/spec/services/streams/autoscaler_spec.rb b/backend/spec/services/streams/autoscaler_spec.rb index 551e858..a40408a 100644 --- a/backend/spec/services/streams/autoscaler_spec.rb +++ b/backend/spec/services/streams/autoscaler_spec.rb @@ -17,6 +17,8 @@ RSpec.describe Streams::Autoscaler do redis.del(Streams::Autoscaler::LOCK_KEY) redis.del(Streams::Autoscaler::KILL_SWITCH_KEY) redis.del(Streams::DnsProviders::Lab::REDIS_KEY) + StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current) + StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all end it "is a no-op when disabled" do @@ -153,7 +155,10 @@ RSpec.describe Streams::Autoscaler do "MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935", "HLS_PUBLIC_URL" => "https://home.example/hls" ) do + StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current) + StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all home = Streams::NodeRegistry.ensure_home_from_env! + home.update!(max_publishers: 1) user = User.create!(email: "budget@example.com", name: "B", password: "Password123", role: "coach") club = Club.create!(name: "Budget", sport: "volleyball") team = club.teams.create!(name: "T", sport: "volleyball", slug: "budget-t") @@ -165,7 +170,9 @@ RSpec.describe Streams::Autoscaler do result = described_class.reconcile!(provisioner: provisioner) expect(result.actions).to include(:blocked_capacity) - expect(described_class.metrics[:within_budget]).to eq(true) + # 0 overflow nodes → stima 0€ ≤ budget; il blocco è sul nodo successivo (+1) + expect(described_class.within_budget?(0)).to eq(true) + expect(described_class.within_budget?(1)).to eq(false) end end end diff --git a/backend/spec/services/streams/cloud_providers/hetzner_spec.rb b/backend/spec/services/streams/cloud_providers/hetzner_spec.rb index 53c7d52..f24046c 100644 --- a/backend/spec/services/streams/cloud_providers/hetzner_spec.rb +++ b/backend/spec/services/streams/cloud_providers/hetzner_spec.rb @@ -5,7 +5,7 @@ require "rails_helper" RSpec.describe Streams::CloudProviders::Hetzner do let(:stubs) { Faraday::Adapter::Test::Stubs.new } let(:conn) do - Faraday.new(url: "https://api.hetzner.cloud/v1") do |f| + Faraday.new(url: Streams::CloudProviders::Hetzner::API) do |f| f.request :json f.response :json f.adapter :test, stubs @@ -25,15 +25,15 @@ RSpec.describe Streams::CloudProviders::Hetzner do it "creates a server and waits until running" do with_env( - "HCLOUD_LOCATION" => "fsn1", - "HCLOUD_SERVER_TYPE" => "cpx21", + "HCLOUD_LOCATION" => "nbg1", + "HCLOUD_SERVER_TYPE" => "cpx12", "HCLOUD_IMAGE" => "debian-12", - "HCLOUD_SSH_KEY" => "matchlivetv-stream" + "HCLOUD_SSH_KEY" => "matchlivetv-stream-hetzner" ) do - stubs.post("/servers") do |env| + stubs.post("https://api.hetzner.cloud/v1/servers") do |env| body = JSON.parse(env.body) expect(body["name"]).to eq("mltv-stream-ingest-01") - expect(body["location"]).to eq("fsn1") + expect(body["location"]).to eq("nbg1") [ 201, { "Content-Type" => "application/json" }, @@ -49,10 +49,10 @@ RSpec.describe Streams::CloudProviders::Hetzner do } ] end - stubs.get("/actions/7") do + stubs.get("https://api.hetzner.cloud/v1/actions/7") do [200, { "Content-Type" => "application/json" }, { "action" => { "id" => 7, "status" => "success" } }] end - stubs.get("/servers/42") do + stubs.get("https://api.hetzner.cloud/v1/servers/42") do [ 200, { "Content-Type" => "application/json" }, @@ -76,7 +76,7 @@ RSpec.describe Streams::CloudProviders::Hetzner do end it "destroys a server" do - stubs.delete("/servers/42") { [204, {}, ""] } + stubs.delete("https://api.hetzner.cloud/v1/servers/42") { [204, {}, ""] } expect(provider.destroy_node("42")).to eq(true) end end diff --git a/backend/spec/services/streams/dns_providers/hetzner_spec.rb b/backend/spec/services/streams/dns_providers/hetzner_spec.rb index 7b7c710..6b61818 100644 --- a/backend/spec/services/streams/dns_providers/hetzner_spec.rb +++ b/backend/spec/services/streams/dns_providers/hetzner_spec.rb @@ -5,7 +5,7 @@ require "rails_helper" RSpec.describe Streams::DnsProviders::Hetzner do let(:stubs) { Faraday::Adapter::Test::Stubs.new } let(:conn) do - Faraday.new(url: "https://api.hetzner.cloud/v1") do |f| + Faraday.new(url: Streams::DnsProviders::Hetzner::API) do |f| f.request :json f.response :json f.adapter :test, stubs @@ -16,8 +16,8 @@ RSpec.describe Streams::DnsProviders::Hetzner do after { stubs.verify_stubbed_calls } it "upserts an A record (delete then create)" do - stubs.delete("/zones/mltv-stream.net/rrsets/ingest-01/A") { [404, {}, ""] } - stubs.post("/zones/mltv-stream.net/rrsets") do |env| + stubs.delete("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets/ingest-01/A") { [404, {}, ""] } + stubs.post("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets") do |env| body = JSON.parse(env.body) expect(body["name"]).to eq("ingest-01") expect(body["type"]).to eq("A") @@ -29,7 +29,7 @@ RSpec.describe Streams::DnsProviders::Hetzner do end it "deletes an A record" do - stubs.delete("/zones/mltv-stream.net/rrsets/ingest-01/A") { [204, {}, ""] } + stubs.delete("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets/ingest-01/A") { [204, {}, ""] } expect(provider.delete_a("ingest-01.mltv-stream.net")).to eq(true) end end diff --git a/backend/spec/services/streams/node_provisioner_spec.rb b/backend/spec/services/streams/node_provisioner_spec.rb index 09df63a..c62f3d6 100644 --- a/backend/spec/services/streams/node_provisioner_spec.rb +++ b/backend/spec/services/streams/node_provisioner_spec.rb @@ -15,6 +15,8 @@ RSpec.describe Streams::NodeProvisioner do before do redis.del(Streams::DnsProviders::Lab::REDIS_KEY) + StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current) + StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all end it "provisions and decommissions a simulated lab node" do diff --git a/backend/spec/services/streams/node_registry_spec.rb b/backend/spec/services/streams/node_registry_spec.rb index 17041b9..42e804f 100644 --- a/backend/spec/services/streams/node_registry_spec.rb +++ b/backend/spec/services/streams/node_registry_spec.rb @@ -22,6 +22,12 @@ RSpec.describe Streams::NodeRegistry do } end + before do + StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current) + StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all + StreamNode.find_by(slug: Streams::NodeRegistry::HOME_SLUG)&.update!(max_publishers: 2, status: "ready") + end + it "creates home node from ENV" do with_env(home_env) do node = described_class.ensure_home_from_env! diff --git a/docs/infrastructure/STREAMING_AUTOSCALE.md b/docs/infrastructure/STREAMING_AUTOSCALE.md index 3dcfedf..2385777 100644 --- a/docs/infrastructure/STREAMING_AUTOSCALE.md +++ b/docs/infrastructure/STREAMING_AUTOSCALE.md @@ -353,10 +353,10 @@ Finché WireGuard/Tailscale non è pronto, `api_base_url` punta comunque alla pr ```bash HCLOUD_TOKEN=... -HCLOUD_LOCATION=fsn1 -HCLOUD_SERVER_TYPE=cpx21 +HCLOUD_LOCATION=nbg1 +HCLOUD_SERVER_TYPE=cpx12 HCLOUD_IMAGE=debian-12 -HCLOUD_SSH_KEY=matchlivetv-stream +HCLOUD_SSH_KEY=matchlivetv-stream-hetzner HCLOUD_NETWORK_ID= # opzionale HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml diff --git a/infra/.env.production.example b/infra/.env.production.example index 92c9ca6..783258f 100644 --- a/infra/.env.production.example +++ b/infra/.env.production.example @@ -108,10 +108,10 @@ SENTRY_TRACES_SAMPLE_RATE=0.1 # --- Stream autoscale (Hetzner Cloud + DNS mltv-stream.net) --- # Token progetto matchlivetv-stream (Read & Write). Non committare il valore reale. HCLOUD_TOKEN= -HCLOUD_LOCATION=fsn1 -HCLOUD_SERVER_TYPE=cpx21 +HCLOUD_LOCATION=nbg1 +HCLOUD_SERVER_TYPE=cpx12 HCLOUD_IMAGE=debian-12 -HCLOUD_SSH_KEY=matchlivetv-stream +HCLOUD_SSH_KEY=matchlivetv-stream-hetzner HCLOUD_NETWORK_ID= HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml STREAM_DNS_ZONE=mltv-stream.net diff --git a/infra/stream-node/cloud-init.yaml b/infra/stream-node/cloud-init.yaml index e51f098..7be27af 100644 --- a/infra/stream-node/cloud-init.yaml +++ b/infra/stream-node/cloud-init.yaml @@ -1,41 +1,71 @@ #cloud-config -# Cloud-init minimale per nodo stream Hetzner (MediaMTX + ffmpeg). -# Uso in .env produzione: -# HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml -# -# Per boot più veloci: creare uno snapshot dopo il primo setup e usare HCLOUD_IMAGE=. +# Nodo stream Hetzner: MediaMTX + ffmpeg. +# HCLOUD_USER_DATA_FILE=.../infra/stream-node/cloud-init.yaml package_update: true packages: - docker.io - ffmpeg - curl + - apparmor + +write_files: + - path: /opt/stream-node/mediamtx.yml + permissions: "0644" + content: | + logLevel: info + # Allinea a infra/mediamtx.yml: API raggiungibile anche da IP non-localhost + # (in prod restringere via WireGuard / firewall Hetzner). + authInternalUsers: + - user: any + pass: + ips: [] + permissions: + - action: api + - action: metrics + - action: publish + - action: read + - action: playback + api: yes + apiAddress: :9997 + rtmp: yes + rtmpAddress: :1935 + hls: yes + hlsAddress: :8888 + paths: + all_others: + - path: /opt/stream-node/bootstrap.sh + permissions: "0755" + content: | + #!/bin/bash + set -euo pipefail + mkdir -p /recordings /slates + systemctl enable --now docker + # aspetta il socket docker + for i in $(seq 1 60); do + if docker info >/dev/null 2>&1; then break; fi + sleep 2 + done + docker pull bluenviron/mediamtx:latest + docker rm -f mediamtx 2>/dev/null || true + # Debian cloud image: docker.io senza apparmor_parser → serve unconfined o pkg apparmor + docker run -d --name mediamtx --restart unless-stopped --network host \ + --security-opt apparmor=unconfined \ + -v /opt/stream-node/mediamtx.yml:/mediamtx.yml:ro \ + -v /recordings:/recordings \ + -v /slates:/slates:ro \ + bluenviron/mediamtx:latest /mediamtx.yml + # smoke locale + for i in $(seq 1 30); do + if curl -fsS http://127.0.0.1:9997/v3/paths/list >/dev/null; then + echo "mediamtx ready" | tee /opt/stream-node/ready + exit 0 + fi + sleep 2 + done + echo "mediamtx NOT ready" | tee /opt/stream-node/ready + docker logs mediamtx || true + exit 1 + runcmd: - - systemctl enable --now docker - - mkdir -p /opt/stream-node /recordings /slates - - | - cat >/opt/stream-node/docker-compose.yml <<'EOF' - services: - mediamtx: - image: bluenviron/mediamtx:latest - network_mode: host - restart: unless-stopped - volumes: - - ./mediamtx.yml:/mediamtx.yml:ro - - /recordings:/recordings - - /slates:/slates:ro - command: /mediamtx.yml - EOF - - | - cat >/opt/stream-node/mediamtx.yml <<'EOF' - logLevel: info - api: yes - apiAddress: :9997 - rtmp: yes - rtmpAddress: :1935 - hls: yes - hlsAddress: :8888 - paths: - all_others: - EOF - - cd /opt/stream-node && docker compose up -d + - [bash, /opt/stream-node/bootstrap.sh]