Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions examples/kv/src/main.cr
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,11 @@ end
transport.start

# HTTP server
raft_handler = Raft::HTTP::Handler(KVCommand).new(meta_node, transport, raft_advertise_address)
raft_status_handler = Raft::HTTP::StatusHandler(KVCommand).new(meta_node, transport, raft_advertise_address)
raft_admin_handler = Raft::HTTP::AdminHandler(KVCommand).new(meta_node, transport)
kv_handler = KVHttpHandler.new(meta_node, meta_sm, nodes, value_machines)

server = ::HTTP::Server.new([kv_handler, raft_handler]) do |context|
server = ::HTTP::Server.new([kv_handler, raft_status_handler, raft_admin_handler]) do |context|
context.response.status_code = 404
context.response.print "Not found"
end
Expand Down
5 changes: 3 additions & 2 deletions examples/queue/src/main.cr
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,11 @@ end

transport.start

raft_handler = Raft::HTTP::Handler(QueueCommand).new(meta_node, transport, raft_advertise_address)
raft_status_handler = Raft::HTTP::StatusHandler(QueueCommand).new(meta_node, transport, raft_advertise_address)
raft_admin_handler = Raft::HTTP::AdminHandler(QueueCommand).new(meta_node, transport)
queue_handler = QueueHttpHandler.new(meta_node, meta_sm, nodes, state_machines, transport)

server = ::HTTP::Server.new([queue_handler, raft_handler]) do |context|
server = ::HTTP::Server.new([queue_handler, raft_status_handler, raft_admin_handler]) do |context|
context.response.status_code = 404
context.response.print "Not found"
end
Expand Down
124 changes: 124 additions & 0 deletions spec/raft/http/admin_handler_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
require "../../spec_helper"
require "http/server"
require "http/client"

private def make_node(dir : String, peers : Array(Raft::NodeID) = [2_u64, 3_u64]) : Raft::Node(TestData)
Dir.mkdir_p(dir)
config = Raft::Config.new
config.data_dir = dir
config.election_timeout_min_ticks = 100_u32
config.election_timeout_max_ticks = 100_u32
Raft::Node(TestData).new(id: 1_u64, peers: peers, config: config, state_machine: TestStateMachine.new)
end

describe Raft::HTTP::AdminHandler do
it "bootstraps a single-node cluster via POST" do
dir = File.tempname("raft_admin")
node = make_node(dir, peers: [] of Raft::NodeID)

handler = Raft::HTTP::AdminHandler(TestData).new(node)
server = ::HTTP::Server.new([handler])
address = server.bind_tcp("127.0.0.1", 0)
spawn server.listen

response = ::HTTP::Client.post("http://127.0.0.1:#{address.port}/raft/admin/bootstrap")
response.status_code.should eq 200
response.body.should contain("\"status\":\"bootstrapped\"")
node.role.should eq Raft::Role::Leader

server.close
node.close
FileUtils.rm_rf(dir)
end

it "returns 404 for unknown admin actions" do
dir = File.tempname("raft_admin")
node = make_node(dir)

handler = Raft::HTTP::AdminHandler(TestData).new(node)
server = ::HTTP::Server.new([handler])
address = server.bind_tcp("127.0.0.1", 0)
spawn server.listen

response = ::HTTP::Client.post("http://127.0.0.1:#{address.port}/raft/admin/nonexistent")
response.status_code.should eq 404

server.close
node.close
FileUtils.rm_rf(dir)
end

it "does not respond to GET /raft/status (falls through)" do
dir = File.tempname("raft_admin")
node = make_node(dir)

handler = Raft::HTTP::AdminHandler(TestData).new(node)
server = ::HTTP::Server.new([handler])
address = server.bind_tcp("127.0.0.1", 0)
spawn server.listen

response = ::HTTP::Client.get("http://127.0.0.1:#{address.port}/raft/status")
response.status_code.should eq 404

server.close
node.close
FileUtils.rm_rf(dir)
end

{% if flag?(:raft_debug) %}
it "pauses and resumes node via debug admin endpoints" do
dir = File.tempname("raft_admin")
node = make_node(dir)

handler = Raft::HTTP::AdminHandler(TestData).new(node)
server = ::HTTP::Server.new([handler])
address = server.bind_tcp("127.0.0.1", 0)
spawn server.listen

response = ::HTTP::Client.post("http://127.0.0.1:#{address.port}/raft/admin/pause")
response.status_code.should eq 200
node.paused.should be_true

response = ::HTTP::Client.post("http://127.0.0.1:#{address.port}/raft/admin/resume")
response.status_code.should eq 200
node.paused.should be_false

server.close
node.close
FileUtils.rm_rf(dir)
end
{% end %}
end

describe "Raft::HTTP::StatusHandler + AdminHandler chained" do
it "serves both route sets when both handlers are mounted together" do
dir = File.tempname("raft_chain")
Dir.mkdir_p(dir)
config = Raft::Config.new
config.data_dir = dir
config.election_timeout_min_ticks = 100_u32
config.election_timeout_max_ticks = 100_u32
node = Raft::Node(TestData).new(id: 1_u64, peers: [] of Raft::NodeID, config: config, state_machine: TestStateMachine.new)

status_handler = Raft::HTTP::StatusHandler(TestData).new(node)
admin_handler = Raft::HTTP::AdminHandler(TestData).new(node)
server = ::HTTP::Server.new([status_handler, admin_handler])
address = server.bind_tcp("127.0.0.1", 0)
spawn server.listen

# Status path hits StatusHandler.
status_response = ::HTTP::Client.get("http://127.0.0.1:#{address.port}/raft/status")
status_response.status_code.should eq 200
status_response.body.should contain("\"id\":1")

# Admin path falls through StatusHandler, lands on AdminHandler.
bootstrap_response = ::HTTP::Client.post("http://127.0.0.1:#{address.port}/raft/admin/bootstrap")
bootstrap_response.status_code.should eq 200
bootstrap_response.body.should contain("\"status\":\"bootstrapped\"")
node.role.should eq Raft::Role::Leader

server.close
node.close
FileUtils.rm_rf(dir)
end
end
92 changes: 0 additions & 92 deletions spec/raft/http/handler_spec.cr

This file was deleted.

89 changes: 89 additions & 0 deletions spec/raft/http/status_handler_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
require "../../spec_helper"
require "http/server"
require "http/client"

private def make_node(dir : String) : Raft::Node(TestData)
Dir.mkdir_p(dir)
config = Raft::Config.new
config.data_dir = dir
config.election_timeout_min_ticks = 100_u32
config.election_timeout_max_ticks = 100_u32
Raft::Node(TestData).new(id: 1_u64, peers: [2_u64, 3_u64], config: config, state_machine: TestStateMachine.new)
end

private def make_node_with_metrics(dir : String) : Raft::Node(TestData)
Dir.mkdir_p(dir)
config = Raft::Config.new
config.data_dir = dir
config.election_timeout_min_ticks = 100_u32
config.election_timeout_max_ticks = 100_u32
metrics = Raft::Metrics.new(node_id: 1_u64)
Raft::Node(TestData).new(id: 1_u64, peers: [2_u64, 3_u64], config: config, state_machine: TestStateMachine.new, metrics: metrics)
end

describe Raft::HTTP::StatusHandler do
it "returns node status as JSON" do
dir = File.tempname("raft_status")
node = make_node(dir)

handler = Raft::HTTP::StatusHandler(TestData).new(node)
server = ::HTTP::Server.new([handler])
address = server.bind_tcp("127.0.0.1", 0)
spawn server.listen

response = ::HTTP::Client.get("http://127.0.0.1:#{address.port}/raft/status")
response.status_code.should eq 200
body = response.body
body.should contain("\"id\":1")
body.should contain("\"role\":\"follower\"")

server.close
node.close
FileUtils.rm_rf(dir)
end

it "returns metrics in prometheus format" do
dir = File.tempname("raft_status")
node = make_node_with_metrics(dir)

handler = Raft::HTTP::StatusHandler(TestData).new(node)
server = ::HTTP::Server.new([handler])
address = server.bind_tcp("127.0.0.1", 0)
spawn server.listen

response = ::HTTP::Client.get("http://127.0.0.1:#{address.port}/raft/metrics")
response.status_code.should eq 200
response.body.should contain("raft_node_term")
# Fresh follower with no leader yet: is_leader=0, leader_id=0 (nil → 0).
response.body.should match /raft_node_is_leader\{[^}]*\} 0\n/
response.body.should match /raft_node_leader_id\{[^}]*\} 0\n/

server.close
node.close
FileUtils.rm_rf(dir)
end

it "does not respond to POST /raft/admin/* (falls through to next handler)" do
# StatusHandler alone — admin posts should fall through to the bare chain's
# 404. This is the property that lets the metrics port stay safe from
# cluster-mutating requests.
dir = File.tempname("raft_status")
node = make_node(dir)

handler = Raft::HTTP::StatusHandler(TestData).new(node)
server = ::HTTP::Server.new([handler])
address = server.bind_tcp("127.0.0.1", 0)
spawn server.listen

response = ::HTTP::Client.post("http://127.0.0.1:#{address.port}/raft/admin/bootstrap")
response.status_code.should eq 404

# Node was not mutated — still a follower with the initial peer set.
node.role.should eq Raft::Role::Follower
node.current_term.should eq 0_u64

server.close
node.close
FileUtils.rm_rf(dir)
end
end
3 changes: 2 additions & 1 deletion src/raft.cr
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ require "./raft/transport/memory_transport"
require "./raft/transport/tcp_transport"
require "./raft/node"
require "./raft/server"
require "./raft/http/handler"
require "./raft/http/status_handler"
require "./raft/http/admin_handler"
Loading