Corregge il provisioning Hetzner: Faraday, cloud-init e default cpx12.

Sistemati path API /v1, AppArmor/auth MediaMTX sul nodo e defaults nbg1 così lo smoke Cloud è ripetibile.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-10 12:20:36 +02:00
co-authored by Cursor
parent e5fc925bae
commit 559284f0b2
10 changed files with 113 additions and 66 deletions
@@ -15,7 +15,8 @@ module Streams
# HCLOUD_NETWORK_ID (opzionale, private network / WireGuard prep) # HCLOUD_NETWORK_ID (opzionale, private network / WireGuard prep)
# HCLOUD_USER_DATA_FILE (opzionale, cloud-init path) # HCLOUD_USER_DATA_FILE (opzionale, cloud-init path)
class Hetzner < Base 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) def initialize(token: ENV.fetch("HCLOUD_TOKEN"), conn: nil)
@token = token @token = token
@@ -25,12 +26,12 @@ module Streams
def create_node(name:, labels: {}) def create_node(name:, labels: {})
body = { body = {
name: name, 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"), image: ENV.fetch("HCLOUD_IMAGE", "debian-12"),
location: ENV.fetch("HCLOUD_LOCATION", "fsn1"), location: ENV.fetch("HCLOUD_LOCATION", "nbg1"),
start_after_create: true, start_after_create: true,
labels: default_labels.merge(stringify_labels(labels)), 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: { public_net: {
enable_ipv4: true, enable_ipv4: true,
enable_ipv6: false enable_ipv6: false
@@ -41,7 +42,7 @@ module Streams
user_data = cloud_init_user_data user_data = cloud_init_user_data
body[:user_data] = user_data if user_data.present? body[:user_data] = user_data if user_data.present?
data = post("/servers", body) data = post("servers", body)
server = data["server"] || {} server = data["server"] || {}
action = data["action"] action = data["action"]
wait_action!(action) if action wait_action!(action) if action
@@ -51,7 +52,7 @@ module Streams
end end
def destroy_node(instance_id) def destroy_node(instance_id)
delete("/servers/#{instance_id}") delete("servers/#{instance_id}")
true true
end end
@@ -61,14 +62,14 @@ module Streams
params[:label_selector] = label_selector if label_selector.present? params[:label_selector] = label_selector if label_selector.present?
params[:label_selector] ||= "matchlivetv=true,role=stream-node" 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) } Array(data["servers"]).map { |s| instance_from_server(s) }
end end
def wait_until_running(instance_id, timeout: 180) def wait_until_running(instance_id, timeout: 180)
deadline = Time.now + timeout deadline = Time.now + timeout
loop do loop do
data = get("/servers/#{instance_id}") data = get("servers/#{instance_id}")
server = data["server"] server = data["server"]
status = server["status"] status = server["status"]
if status == "running" if status == "running"
@@ -169,7 +170,7 @@ module Streams
deadline = Time.now + timeout deadline = Time.now + timeout
id = action["id"] id = action["id"]
loop do loop do
data = get("/actions/#{id}") data = get("actions/#{id}")
status = data.dig("action", "status") status = data.dig("action", "status")
return if status == "success" return if status == "success"
raise Error, "Hetzner action #{id} failed: #{data.inspect}" if status == "error" raise Error, "Hetzner action #{id} failed: #{data.inspect}" if status == "error"
@@ -12,7 +12,8 @@ module Streams
# STREAM_DNS_ZONE (default mltv-stream.net) # STREAM_DNS_ZONE (default mltv-stream.net)
# STREAM_DNS_TTL (default 60) # STREAM_DNS_TTL (default 60)
class Hetzner < Base 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) def initialize(token: ENV.fetch("HCLOUD_TOKEN"), zone: nil, conn: nil)
@token = token @token = token
@@ -24,7 +25,7 @@ module Streams
def upsert_a(name, ip) def upsert_a(name, ip)
rr_name = relative_name(name) rr_name = relative_name(name)
delete_a(name) delete_a(name)
post("/zones/#{CGI.escape(@zone)}/rrsets", { post("zones/#{CGI.escape(@zone)}/rrsets", {
name: rr_name, name: rr_name,
type: "A", type: "A",
ttl: @ttl, ttl: @ttl,
@@ -37,7 +38,7 @@ module Streams
def delete_a(name) def delete_a(name)
rr_name = relative_name(name) rr_name = relative_name(name)
encoded = CGI.escape(rr_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) req.headers.update(auth_headers)
end end
return true if response.status == 404 || response.status == 204 || response.success? return true if response.status == 404 || response.status == 204 || response.success?
@@ -47,7 +48,7 @@ module Streams
def resolve(name) def resolve(name)
rr_name = relative_name(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 rrset = Array(data["rrsets"]).first
Array(rrset&.dig("records")).first&.dig("value") Array(rrset&.dig("records")).first&.dig("value")
rescue Error rescue Error
@@ -17,6 +17,8 @@ RSpec.describe Streams::Autoscaler do
redis.del(Streams::Autoscaler::LOCK_KEY) redis.del(Streams::Autoscaler::LOCK_KEY)
redis.del(Streams::Autoscaler::KILL_SWITCH_KEY) redis.del(Streams::Autoscaler::KILL_SWITCH_KEY)
redis.del(Streams::DnsProviders::Lab::REDIS_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 end
it "is a no-op when disabled" do it "is a no-op when disabled" do
@@ -153,7 +155,10 @@ RSpec.describe Streams::Autoscaler do
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935", "MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
"HLS_PUBLIC_URL" => "https://home.example/hls" "HLS_PUBLIC_URL" => "https://home.example/hls"
) do ) 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 = Streams::NodeRegistry.ensure_home_from_env!
home.update!(max_publishers: 1)
user = User.create!(email: "budget@example.com", name: "B", password: "Password123", role: "coach") user = User.create!(email: "budget@example.com", name: "B", password: "Password123", role: "coach")
club = Club.create!(name: "Budget", sport: "volleyball") club = Club.create!(name: "Budget", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "budget-t") 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) result = described_class.reconcile!(provisioner: provisioner)
expect(result.actions).to include(:blocked_capacity) 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 end
end end
@@ -5,7 +5,7 @@ require "rails_helper"
RSpec.describe Streams::CloudProviders::Hetzner do RSpec.describe Streams::CloudProviders::Hetzner do
let(:stubs) { Faraday::Adapter::Test::Stubs.new } let(:stubs) { Faraday::Adapter::Test::Stubs.new }
let(:conn) do 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.request :json
f.response :json f.response :json
f.adapter :test, stubs f.adapter :test, stubs
@@ -25,15 +25,15 @@ RSpec.describe Streams::CloudProviders::Hetzner do
it "creates a server and waits until running" do it "creates a server and waits until running" do
with_env( with_env(
"HCLOUD_LOCATION" => "fsn1", "HCLOUD_LOCATION" => "nbg1",
"HCLOUD_SERVER_TYPE" => "cpx21", "HCLOUD_SERVER_TYPE" => "cpx12",
"HCLOUD_IMAGE" => "debian-12", "HCLOUD_IMAGE" => "debian-12",
"HCLOUD_SSH_KEY" => "matchlivetv-stream" "HCLOUD_SSH_KEY" => "matchlivetv-stream-hetzner"
) do ) do
stubs.post("/servers") do |env| stubs.post("https://api.hetzner.cloud/v1/servers") do |env|
body = JSON.parse(env.body) body = JSON.parse(env.body)
expect(body["name"]).to eq("mltv-stream-ingest-01") expect(body["name"]).to eq("mltv-stream-ingest-01")
expect(body["location"]).to eq("fsn1") expect(body["location"]).to eq("nbg1")
[ [
201, 201,
{ "Content-Type" => "application/json" }, { "Content-Type" => "application/json" },
@@ -49,10 +49,10 @@ RSpec.describe Streams::CloudProviders::Hetzner do
} }
] ]
end 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" } }] [200, { "Content-Type" => "application/json" }, { "action" => { "id" => 7, "status" => "success" } }]
end end
stubs.get("/servers/42") do stubs.get("https://api.hetzner.cloud/v1/servers/42") do
[ [
200, 200,
{ "Content-Type" => "application/json" }, { "Content-Type" => "application/json" },
@@ -76,7 +76,7 @@ RSpec.describe Streams::CloudProviders::Hetzner do
end end
it "destroys a server" do 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) expect(provider.destroy_node("42")).to eq(true)
end end
end end
@@ -5,7 +5,7 @@ require "rails_helper"
RSpec.describe Streams::DnsProviders::Hetzner do RSpec.describe Streams::DnsProviders::Hetzner do
let(:stubs) { Faraday::Adapter::Test::Stubs.new } let(:stubs) { Faraday::Adapter::Test::Stubs.new }
let(:conn) do 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.request :json
f.response :json f.response :json
f.adapter :test, stubs f.adapter :test, stubs
@@ -16,8 +16,8 @@ RSpec.describe Streams::DnsProviders::Hetzner do
after { stubs.verify_stubbed_calls } after { stubs.verify_stubbed_calls }
it "upserts an A record (delete then create)" do it "upserts an A record (delete then create)" do
stubs.delete("/zones/mltv-stream.net/rrsets/ingest-01/A") { [404, {}, ""] } stubs.delete("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets/ingest-01/A") { [404, {}, ""] }
stubs.post("/zones/mltv-stream.net/rrsets") do |env| stubs.post("https://api.hetzner.cloud/v1/zones/mltv-stream.net/rrsets") do |env|
body = JSON.parse(env.body) body = JSON.parse(env.body)
expect(body["name"]).to eq("ingest-01") expect(body["name"]).to eq("ingest-01")
expect(body["type"]).to eq("A") expect(body["type"]).to eq("A")
@@ -29,7 +29,7 @@ RSpec.describe Streams::DnsProviders::Hetzner do
end end
it "deletes an A record" do 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) expect(provider.delete_a("ingest-01.mltv-stream.net")).to eq(true)
end end
end end
@@ -15,6 +15,8 @@ RSpec.describe Streams::NodeProvisioner do
before do before do
redis.del(Streams::DnsProviders::Lab::REDIS_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 end
it "provisions and decommissions a simulated lab node" do it "provisions and decommissions a simulated lab node" do
@@ -22,6 +22,12 @@ RSpec.describe Streams::NodeRegistry do
} }
end 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 it "creates home node from ENV" do
with_env(home_env) do with_env(home_env) do
node = described_class.ensure_home_from_env! node = described_class.ensure_home_from_env!
+3 -3
View File
@@ -353,10 +353,10 @@ Finché WireGuard/Tailscale non è pronto, `api_base_url` punta comunque alla pr
```bash ```bash
HCLOUD_TOKEN=... HCLOUD_TOKEN=...
HCLOUD_LOCATION=fsn1 HCLOUD_LOCATION=nbg1
HCLOUD_SERVER_TYPE=cpx21 HCLOUD_SERVER_TYPE=cpx12
HCLOUD_IMAGE=debian-12 HCLOUD_IMAGE=debian-12
HCLOUD_SSH_KEY=matchlivetv-stream HCLOUD_SSH_KEY=matchlivetv-stream-hetzner
HCLOUD_NETWORK_ID= # opzionale HCLOUD_NETWORK_ID= # opzionale
HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml
+3 -3
View File
@@ -108,10 +108,10 @@ SENTRY_TRACES_SAMPLE_RATE=0.1
# --- Stream autoscale (Hetzner Cloud + DNS mltv-stream.net) --- # --- Stream autoscale (Hetzner Cloud + DNS mltv-stream.net) ---
# Token progetto matchlivetv-stream (Read & Write). Non committare il valore reale. # Token progetto matchlivetv-stream (Read & Write). Non committare il valore reale.
HCLOUD_TOKEN= HCLOUD_TOKEN=
HCLOUD_LOCATION=fsn1 HCLOUD_LOCATION=nbg1
HCLOUD_SERVER_TYPE=cpx21 HCLOUD_SERVER_TYPE=cpx12
HCLOUD_IMAGE=debian-12 HCLOUD_IMAGE=debian-12
HCLOUD_SSH_KEY=matchlivetv-stream HCLOUD_SSH_KEY=matchlivetv-stream-hetzner
HCLOUD_NETWORK_ID= HCLOUD_NETWORK_ID=
HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml
STREAM_DNS_ZONE=mltv-stream.net STREAM_DNS_ZONE=mltv-stream.net
+55 -25
View File
@@ -1,34 +1,31 @@
#cloud-config #cloud-config
# Cloud-init minimale per nodo stream Hetzner (MediaMTX + ffmpeg). # Nodo stream Hetzner: MediaMTX + ffmpeg.
# Uso in .env produzione: # HCLOUD_USER_DATA_FILE=.../infra/stream-node/cloud-init.yaml
# 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=<snapshot>.
package_update: true package_update: true
packages: packages:
- docker.io - docker.io
- ffmpeg - ffmpeg
- curl - curl
runcmd: - apparmor
- systemctl enable --now docker
- mkdir -p /opt/stream-node /recordings /slates write_files:
- | - path: /opt/stream-node/mediamtx.yml
cat >/opt/stream-node/docker-compose.yml <<'EOF' permissions: "0644"
services: content: |
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 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 api: yes
apiAddress: :9997 apiAddress: :9997
rtmp: yes rtmp: yes
@@ -37,5 +34,38 @@ runcmd:
hlsAddress: :8888 hlsAddress: :8888
paths: paths:
all_others: all_others:
EOF - path: /opt/stream-node/bootstrap.sh
- cd /opt/stream-node && docker compose up -d 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:
- [bash, /opt/stream-node/bootstrap.sh]