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:
@@ -0,0 +1,82 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
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|
|
||||
f.request :json
|
||||
f.response :json
|
||||
f.adapter :test, stubs
|
||||
end
|
||||
end
|
||||
let(:provider) { described_class.new(token: "test-token", conn: conn) }
|
||||
|
||||
def with_env(vars)
|
||||
previous = vars.keys.index_with { |k| ENV[k] }
|
||||
vars.each { |k, v| ENV[k] = v }
|
||||
yield
|
||||
ensure
|
||||
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||
end
|
||||
|
||||
after { stubs.verify_stubbed_calls }
|
||||
|
||||
it "creates a server and waits until running" do
|
||||
with_env(
|
||||
"HCLOUD_LOCATION" => "fsn1",
|
||||
"HCLOUD_SERVER_TYPE" => "cpx21",
|
||||
"HCLOUD_IMAGE" => "debian-12",
|
||||
"HCLOUD_SSH_KEY" => "matchlivetv-stream"
|
||||
) do
|
||||
stubs.post("/servers") do |env|
|
||||
body = JSON.parse(env.body)
|
||||
expect(body["name"]).to eq("mltv-stream-ingest-01")
|
||||
expect(body["location"]).to eq("fsn1")
|
||||
[
|
||||
201,
|
||||
{ "Content-Type" => "application/json" },
|
||||
{
|
||||
"server" => {
|
||||
"id" => 42,
|
||||
"name" => "mltv-stream-ingest-01",
|
||||
"status" => "initializing",
|
||||
"public_net" => { "ipv4" => { "ip" => "49.13.1.2" } },
|
||||
"private_net" => []
|
||||
},
|
||||
"action" => { "id" => 7, "status" => "running" }
|
||||
}
|
||||
]
|
||||
end
|
||||
stubs.get("/actions/7") do
|
||||
[200, { "Content-Type" => "application/json" }, { "action" => { "id" => 7, "status" => "success" } }]
|
||||
end
|
||||
stubs.get("/servers/42") do
|
||||
[
|
||||
200,
|
||||
{ "Content-Type" => "application/json" },
|
||||
{
|
||||
"server" => {
|
||||
"id" => 42,
|
||||
"name" => "mltv-stream-ingest-01",
|
||||
"status" => "running",
|
||||
"public_net" => { "ipv4" => { "ip" => "49.13.1.2" } },
|
||||
"private_net" => []
|
||||
}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
instance = provider.create_node(name: "mltv-stream-ingest-01", labels: { role: "stream-node" })
|
||||
expect(instance.id).to eq("42")
|
||||
expect(instance.public_ip).to eq("49.13.1.2")
|
||||
expect(instance.status).to eq("running")
|
||||
end
|
||||
end
|
||||
|
||||
it "destroys a server" do
|
||||
stubs.delete("/servers/42") { [204, {}, ""] }
|
||||
expect(provider.destroy_node("42")).to eq(true)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
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|
|
||||
f.request :json
|
||||
f.response :json
|
||||
f.adapter :test, stubs
|
||||
end
|
||||
end
|
||||
let(:provider) { described_class.new(token: "test-token", zone: "mltv-stream.net", conn: conn) }
|
||||
|
||||
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|
|
||||
body = JSON.parse(env.body)
|
||||
expect(body["name"]).to eq("ingest-01")
|
||||
expect(body["type"]).to eq("A")
|
||||
expect(body["records"].first["value"]).to eq("49.13.1.2")
|
||||
[201, { "Content-Type" => "application/json" }, { "rrset" => { "name" => "ingest-01" } }]
|
||||
end
|
||||
|
||||
expect(provider.upsert_a("ingest-01.mltv-stream.net", "49.13.1.2")).to eq(true)
|
||||
end
|
||||
|
||||
it "deletes an A record" do
|
||||
stubs.delete("/zones/mltv-stream.net/rrsets/ingest-01/A") { [204, {}, ""] }
|
||||
expect(provider.delete_a("ingest-01.mltv-stream.net")).to eq(true)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Streams::NodeProvisioner cloud" do
|
||||
it "registers a cloud node using injected providers" do
|
||||
cloud = instance_double(
|
||||
Streams::CloudProviders::Hetzner,
|
||||
create_node: Streams::CloudProviders::Instance.new(
|
||||
id: "99",
|
||||
name: "mltv-stream-ingest-01",
|
||||
public_ip: "49.13.9.9",
|
||||
private_ip: "10.0.0.9",
|
||||
status: "running",
|
||||
raw: {}
|
||||
)
|
||||
)
|
||||
dns = instance_double(Streams::DnsProviders::Hetzner)
|
||||
allow(dns).to receive(:upsert_a)
|
||||
|
||||
ENV["MEDIAMTX_API_URL"] = "http://mtx-home:9997"
|
||||
ENV["MEDIAMTX_RTMP_URL"] = "rtmp://home.example:1935"
|
||||
ENV["HLS_PUBLIC_URL"] = "https://home.example/hls"
|
||||
ENV["STREAM_CLOUD_DNS_SUFFIX"] = "mltv-stream.net"
|
||||
ENV["STREAM_CLOUD_MAX_PUBLISHERS"] = "4"
|
||||
|
||||
node = Streams::NodeProvisioner.new(cloud: cloud, dns: dns).provision_cloud!
|
||||
expect(node.slug).to eq("ingest-01")
|
||||
expect(node.role).to eq("cloud")
|
||||
expect(node.provider).to eq("hetzner")
|
||||
expect(node.hostname).to eq("ingest-01.mltv-stream.net")
|
||||
expect(node.rtmp_base_url).to eq("rtmp://ingest-01.mltv-stream.net:1935")
|
||||
expect(node.api_base_url).to eq("http://10.0.0.9:9997")
|
||||
expect(dns).to have_received(:upsert_a).with("ingest-01.mltv-stream.net", "49.13.9.9")
|
||||
ensure
|
||||
%w[
|
||||
MEDIAMTX_API_URL MEDIAMTX_RTMP_URL HLS_PUBLIC_URL
|
||||
STREAM_CLOUD_DNS_SUFFIX STREAM_CLOUD_MAX_PUBLISHERS
|
||||
].each { |k| ENV.delete(k) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Streams::NodeProvisioner do
|
||||
def with_env(vars)
|
||||
previous = vars.keys.index_with { |k| ENV[k] }
|
||||
vars.each { |k, v| ENV[k] = v }
|
||||
yield
|
||||
ensure
|
||||
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||
end
|
||||
|
||||
let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) }
|
||||
|
||||
before do
|
||||
redis.del(Streams::DnsProviders::Lab::REDIS_KEY)
|
||||
end
|
||||
|
||||
it "provisions and decommissions a simulated lab node" do
|
||||
with_env(
|
||||
"STREAM_CLOUD_PROVIDER" => "local_lab",
|
||||
"STREAM_DNS_PROVIDER" => "lab",
|
||||
"STREAM_LAB_DNS_SUFFIX" => "lab.mltv-stream.net",
|
||||
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
|
||||
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
|
||||
"STREAM_LAB_MAX_PUBLISHERS" => "2"
|
||||
) do
|
||||
provisioner = described_class.new
|
||||
node = provisioner.provision_lab!
|
||||
|
||||
expect(node.slug).to eq("ingest-lab-01")
|
||||
expect(node.role).to eq("lab")
|
||||
expect(node.status).to eq("ready")
|
||||
expect(node.hostname).to eq("ingest-lab-01.lab.mltv-stream.net")
|
||||
expect(node.api_base_url).to eq("http://mtx-home:9997")
|
||||
expect(Streams::DnsProviders::Lab.new.resolve(node.hostname)).to eq("127.0.0.1")
|
||||
|
||||
provisioner.decommission!(node)
|
||||
expect(StreamNode.find_by(slug: "ingest-lab-01")).to be_nil
|
||||
expect(Streams::DnsProviders::Lab.new.resolve("ingest-lab-01.lab.mltv-stream.net")).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
it "refuses to decommission a busy node" do
|
||||
with_env(
|
||||
"STREAM_CLOUD_PROVIDER" => "local_lab",
|
||||
"STREAM_DNS_PROVIDER" => "lab",
|
||||
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
|
||||
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls"
|
||||
) do
|
||||
node = described_class.new.provision_lab!
|
||||
user = User.create!(email: "lab@example.com", name: "L", password: "Password123", role: "coach")
|
||||
club = Club.create!(name: "LabClub", sport: "volleyball")
|
||||
team = club.teams.create!(name: "T", sport: "volleyball", slug: "lab-t")
|
||||
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: node)
|
||||
|
||||
expect { described_class.new.decommission!(node) }.to raise_error(Streams::NodeProvisioner::BusyError)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Streams::NodeRegistry do
|
||||
def with_env(vars)
|
||||
previous = vars.keys.index_with { |k| ENV[k] }
|
||||
vars.each { |k, v| ENV[k] = v }
|
||||
yield
|
||||
ensure
|
||||
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||
end
|
||||
|
||||
let(:home_env) do
|
||||
{
|
||||
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||
"MEDIAMTX_RTMP_URL" => "rtmp://ingest-home.example:1935",
|
||||
"HLS_PUBLIC_URL" => "https://ingest-home.example/hls",
|
||||
"MEDIAMTX_INTERNAL_RTMP_URL" => "rtmp://mtx-home:1935",
|
||||
"MEDIAMTX_HLS_URL" => "http://mtx-home:8888",
|
||||
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "2"
|
||||
}
|
||||
end
|
||||
|
||||
it "creates home node from ENV" do
|
||||
with_env(home_env) do
|
||||
node = described_class.ensure_home_from_env!
|
||||
expect(node.slug).to eq("home")
|
||||
expect(node.rtmp_base_url).to eq("rtmp://ingest-home.example:1935")
|
||||
expect(node.api_base_url).to eq("http://mtx-home:9997")
|
||||
expect(node.max_publishers).to eq(2)
|
||||
expect(node.status).to eq("ready")
|
||||
end
|
||||
end
|
||||
|
||||
it "allocates the least loaded ready node" do
|
||||
with_env(home_env) do
|
||||
home = described_class.ensure_home_from_env!
|
||||
cloud = StreamNode.create!(
|
||||
slug: "ingest-01",
|
||||
hostname: "ingest-01.mltv-stream.net",
|
||||
role: "cloud",
|
||||
status: "ready",
|
||||
provider: "hetzner",
|
||||
rtmp_base_url: "rtmp://ingest-01.mltv-stream.net:1935",
|
||||
hls_base_url: "https://ingest-01.mltv-stream.net/hls",
|
||||
api_base_url: "http://10.0.0.2:9997",
|
||||
max_publishers: 2,
|
||||
max_relays: 2
|
||||
)
|
||||
|
||||
user = User.create!(email: "n@example.com", name: "N", password: "Password123", role: "coach")
|
||||
club = Club.create!(name: "C", sport: "volleyball")
|
||||
team = club.teams.create!(name: "T", sport: "volleyball", slug: "t-node")
|
||||
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||
|
||||
StreamSession.create!(
|
||||
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home
|
||||
)
|
||||
|
||||
expect(described_class.allocate!).to eq(cloud)
|
||||
end
|
||||
end
|
||||
|
||||
it "raises when no capacity remains" do
|
||||
with_env(home_env.merge("STREAM_NODE_HOME_MAX_PUBLISHERS" => "1")) do
|
||||
home = described_class.ensure_home_from_env!
|
||||
user = User.create!(email: "full@example.com", name: "F", password: "Password123", role: "coach")
|
||||
club = Club.create!(name: "Full", sport: "volleyball")
|
||||
team = club.teams.create!(name: "T", sport: "volleyball", slug: "t-full")
|
||||
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||
StreamSession.create!(
|
||||
match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home
|
||||
)
|
||||
|
||||
expect { described_class.allocate! }.to raise_error(Streams::NodeRegistry::NoCapacityError)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user