Files
2026-07-13 12:32:21 +08:00

302 lines
8.5 KiB
Lua

--- This module provides a mock for Kong Control Plane RPC
-- @module spec.helpers.rpc_mock.cp
local helpers = require("spec.helpers")
local dp_mock = require("spec.helpers.rpc_mock.dp")
local default_cert = require("spec.helpers.rpc_mock.default").default_cert
local _M = {}
local _MT = { __index = _M, }
--- this function starts a mocked Kong CP with the given configuration
-- @tparam[opts={}] table opts the configuration options. Fields not mentioned here will be used as Kong configuration, and by default
-- the control plane will use the default_cert
-- @tparam[opts=false] boolean opts.attaching set to true to attach to an existing control plane (instead of starting one)
-- @tparam[opts=true] boolean opts.interception whether to enable the default interception handlers
-- @tparam[opts={}] table opts.mocks handlers for mocked RPCs
-- @tparam[opts={}] table opts.prehooks handlers for prehooks
-- @tparam[opts={}] table opts.posthooks handlers for posthooks
-- @usage local cp = cp_mock.new()
function _M.new(opts)
opts = opts or {}
opts.prefix = opts.prefix or "servroot_rpc_tap"
opts.role = "control_plane"
opts.plugins = opts.plugins or "bundled"
opts.plugins = opts.plugins .. ",rpc-debug"
opts.cluster_listen = opts.cluster_listen or "127.0.0.1:8005"
opts.mocks = opts.mocks or {}
opts.prehooks = opts.prehooks or {}
opts.posthooks = opts.posthooks or {}
opts.cluster_rpc = "on"
opts.cluster_rpc_sync = opts.cluster_rpc_sync or "on"
if opts.interception == nil then
opts.interception = true
end
for k, v in pairs(default_cert) do
if opts[k] == nil then
opts[k] = v
end
end
return setmetatable(opts, _MT)
end
--- start the mocked control plane
-- throws an error if failed to start
function _M.start(self)
if not self.attaching then
assert(helpers.start_kong(self))
end
self.debugger_dp = dp_mock.new({
cluster_control_plane = self.cluster_listen,
})
-- install default interception handlers
if self.interception then
self:enable_inception()
end
-- attached control plane will call this method when a hooked/mocked RPC is called.
-- this RPC handles both prehook and mock, and response to the control plane:
-- 1. if the RPC is mocked, return the mock result;
-- 2. if the RPC has a prehook, manipulate the args and returns them, and tell if a posthook is present and pending call
self.debugger_dp.callbacks:register("kong.rpc.debug.call_handler", function(proxy_id, proxy_payload)
local method, node_id, payload, call_seq =
proxy_payload.method, proxy_payload.node_id, proxy_payload.payload, proxy_payload.call_seq
local mock = self.mocks[method]
if mock then
local res, err = mock(node_id, payload, proxy_id, self)
return {
mock = true,
result = res,
error = err,
}
end
local prehook = self.prehooks[method] or self.prehooks["*"]
local posthook = self.posthooks[method] or self.posthooks["*"]
local result = {
prehook = prehook and true,
posthook = posthook and true,
}
if prehook then
local res, err = prehook(node_id, payload, proxy_id, self, method, call_seq)
if not res then
return nil, err
end
result.args = res
end
return result
end)
self.debugger_dp.callbacks:register("kong.rpc.debug.call_handler_post", function(proxy_id, proxy_payload)
local method, node_id, payload, call_seq =
proxy_payload.method, proxy_payload.node_id, proxy_payload.payload, proxy_payload.call_seq
local cb = self.posthooks[method] or self.posthooks["*"]
if not cb then
return nil, "no callback registered for method: " .. method
end
local res, err = cb(node_id, payload, proxy_id, self, method, call_seq)
return {
result = res,
error = err,
}
end)
self.debugger_dp:start()
self.debugger_dp:wait_until_connected()
return self:attach_debugger()
end
--- register mocked/hocked RPCs to the control plane
function _M:attach_debugger()
return self.debugger_dp:call("control_plane", "kong.rpc.debug.register")
end
--- let CP make a call to a node
-- @tparam string node_id the node ID to call
-- @tparam string method the RPC method to call
-- @tparam any payload the payload to send
function _M:call(node_id, method, payload)
local res, err = self.debugger_dp:call("control_plane", "kong.rpc.debug.call", {
method = method,
args = payload,
node_id = node_id,
})
if err then
return nil, "debugger error: " .. err
end
return res.result, res.error
end
--- get the node IDs connected to the control plane
-- @treturn table a table of node IDs
function _M:get_node_ids()
return self.debugger_dp:call("control_plane", "kong.rpc.debug.lua_code", [[
local node_ids = {}
for node_id, _ in pairs(kong.rpc.clients) do
if type(node_id) == "string" then
node_ids[node_id] = true
end
end
return node_ids
]])
end
--- wait until at least one node is connected to the control plane
-- throws when timeout
-- @tparam string node_id the node ID to wait for
-- @tparam[opt=15] number timeout the timeout in seconds
function _M:wait_for_node(node_id, timeout)
return helpers.wait_until(function()
local list, err = self:get_node_ids()
if not list then
return nil, err
end
return list[node_id]
end, timeout)
end
--- register a mock for an RPC
-- @param api_name the RPC name
-- @param cb the callback to be called when the RPC is called
-- the callback should return the result and error
function _M:mock(api_name, cb)
self.mocks[api_name] = cb
end
--- unregister a mock for an RPC
-- @param api_name the RPC name
function _M:unmock(api_name)
self.mocks[api_name] = nil
end
--- register a prehook for an RPC
-- @tparam string api_name the RPC name
-- @tparam function cb the callback to be called before the RPC is called
-- the callback should return the manipulated payload
-- in form of { arg1, arg2, ... }
function _M:prehook(api_name, cb)
self.prehooks[api_name] = cb
end
--- register a posthook for an RPC
-- @tparam string api_name the RPC name
-- @tparam function cb the callback to be called after the RPC is called
-- the callback should return the manipulated payload
-- in form of result, error (multiple return values)
function _M:posthook(api_name, cb)
self.posthooks[api_name] = cb
end
local function get_records(server)
local records = server.records
if not records then
records = {}
server.records = records
end
return records
end
local function record_has_response(record)
return record.response and true
end
--- wait until a call is made to the control plane. Only available if the control plane is started with interception enabled
-- @tparam[opt] function cond optional condition to wait for. Default is to wait until the call has a response
-- the record is in the form of { request = payload, response = payload, node_id = node_id, proxy_id = proxy_id, method = method }
-- and history can be accessed via `records` field of the object
-- @tparam[opt=15] number timeout the timeout in seconds
function _M:wait_for_a_call(cond, timeout)
cond = cond or record_has_response
local result
helpers.wait_until(function()
local records = get_records(self)
for _, record in pairs(records) do
if cond(record) then
result = record
return record
end
end
end, timeout)
return result
end
local function default_inception_prehook(node_id, payload, proxy_id, server, method, call_seq)
local records = get_records(server)
records[call_seq] = {
request = payload,
node_id = node_id,
proxy_id = proxy_id,
method = method,
}
return payload
end
local function default_inception_posthook(node_id, payload, proxy_id, server, method, call_seq)
local records = get_records(server)
local record = records[call_seq]
if not record then
print("no record found for call_seq: ", call_seq)
record = {
node_id = node_id,
proxy_id = proxy_id,
method = method,
}
records[call_seq] = record
end
record.response = payload
return payload.result, payload.error
end
--- enable the default interception handlers
function _M:enable_inception()
self.prehooks["*"] = default_inception_prehook
self.posthooks["*"] = default_inception_posthook
end
--- stop the mocked control plane
-- parameters are passed to `helpers.stop_kong`
function _M:stop(...)
if not self.attaching then
helpers.stop_kong(self.prefix, ...)
end
self.debugger_dp:stop()
end
return _M