local lutil = require "lua_util"
local rspamd_util = require "rspamd_util"
local T = require "lua_shape.core"
+local PluginSchema = require "lua_shape.plugin_schema"
local exports = {}
}):optional():doc({ summary = "Redis server version (6 or 7)" }),
}, { open = true })
+local function extend_fields(...)
+ local res = {}
+ for i = 1, select('#', ...) do
+ local tbl = select(i, ...)
+ if tbl then
+ for k, v in pairs(tbl) do
+ res[k] = v
+ end
+ end
+ end
+ return res
+end
+
+-- Register common schema as redis_common first
+PluginSchema.register("mixins.redis_common", common_schema)
+
+-- Create full redis mixin schema for documentation
+local redis_mixin_schema = T.one_of({
+ {
+ name = "common_only",
+ schema = T.table({}, {
+ open = true,
+ mixins = { T.mixin(common_schema, { as = "redis_common" }) }
+ }):doc({ summary = "Redis with no explicit servers (uses localhost)" })
+ },
+ {
+ name = "read_servers",
+ schema = T.table({
+ read_servers = T.one_of({
+ T.string():doc({ summary = "Single Redis server" }),
+ T.array(T.string()):doc({ summary = "Multiple Redis servers" })
+ }):doc({ summary = "Read servers list" })
+ }, {
+ open = true,
+ mixins = { T.mixin(common_schema, { as = "redis_common" }) }
+ }):doc({ summary = "Redis with read-only servers" })
+ },
+ {
+ name = "write_servers",
+ schema = T.table({
+ write_servers = T.one_of({
+ T.string():doc({ summary = "Single Redis server" }),
+ T.array(T.string()):doc({ summary = "Multiple Redis servers" })
+ }):doc({ summary = "Write servers list" })
+ }, {
+ open = true,
+ mixins = { T.mixin(common_schema, { as = "redis_common" }) }
+ }):doc({ summary = "Redis with write-only servers" })
+ },
+ {
+ name = "rw_servers",
+ schema = T.table({
+ read_servers = T.one_of({
+ T.string():doc({ summary = "Single Redis server" }),
+ T.array(T.string()):doc({ summary = "Multiple Redis servers" })
+ }):doc({ summary = "Read servers list" }),
+ write_servers = T.one_of({
+ T.string():doc({ summary = "Single Redis server" }),
+ T.array(T.string()):doc({ summary = "Multiple Redis servers" })
+ }):doc({ summary = "Write servers list" })
+ }, {
+ open = true,
+ mixins = { T.mixin(common_schema, { as = "redis_common" }) }
+ }):doc({ summary = "Redis with separate read and write servers" })
+ },
+ {
+ name = "servers",
+ schema = T.table({
+ servers = T.one_of({
+ T.string():doc({ summary = "Single Redis server" }),
+ T.array(T.string()):doc({ summary = "Multiple Redis servers" })
+ }):doc({ summary = "Redis servers list" })
+ }, {
+ open = true,
+ mixins = { T.mixin(common_schema, { as = "redis_common" }) }
+ }):doc({ summary = "Redis with server list" })
+ },
+ {
+ name = "server_legacy",
+ schema = T.table({
+ server = T.one_of({
+ T.string():doc({ summary = "Single Redis server" }),
+ T.array(T.string()):doc({ summary = "Multiple Redis servers" })
+ }):doc({ summary = "Redis server (legacy)" })
+ }, {
+ open = true,
+ mixins = { T.mixin(common_schema, { as = "redis_common" }) }
+ }):doc({ summary = "Redis with legacy server parameter" })
+ }
+}):doc({ summary = "Redis connection configuration" })
+
+PluginSchema.register("mixins.redis", redis_mixin_schema)
+
local enrich_schema = function(external)
- local external_schema = T.table(external, { open = true })
+ local external_fields = external or {}
return T.one_of({
{
name = "common_only",
- schema = T.table({}, {
+ schema = T.table(extend_fields(external_fields), {
open = true,
mixins = {
- T.mixin(common_schema, { as = "redis_common" }),
- T.mixin(external_schema, { as = "external" })
+ T.mixin(common_schema, { as = "redis" })
}
})
},
{
name = "read_servers",
- schema = T.table({
+ schema = T.table(extend_fields(external_fields, {
read_servers = T.one_of({
T.string(),
T.array(T.string())
- }),
- }, {
+ })
+ }), {
open = true,
mixins = {
- T.mixin(common_schema, { as = "redis_common" }),
- T.mixin(external_schema, { as = "external" })
+ T.mixin(common_schema, { as = "redis" })
}
})
},
{
name = "write_servers",
- schema = T.table({
+ schema = T.table(extend_fields(external_fields, {
write_servers = T.one_of({
T.string(),
T.array(T.string())
- }),
- }, {
+ })
+ }), {
open = true,
mixins = {
- T.mixin(common_schema, { as = "redis_common" }),
- T.mixin(external_schema, { as = "external" })
+ T.mixin(common_schema, { as = "redis" })
}
})
},
{
name = "rw_servers",
- schema = T.table({
+ schema = T.table(extend_fields(external_fields, {
read_servers = T.one_of({
T.string(),
T.array(T.string())
write_servers = T.one_of({
T.string(),
T.array(T.string())
- }),
- }, {
+ })
+ }), {
open = true,
mixins = {
- T.mixin(common_schema, { as = "redis_common" }),
- T.mixin(external_schema, { as = "external" })
+ T.mixin(common_schema, { as = "redis" })
}
})
},
{
name = "servers",
- schema = T.table({
+ schema = T.table(extend_fields(external_fields, {
servers = T.one_of({
T.string(),
T.array(T.string())
- }),
- }, {
+ })
+ }), {
open = true,
mixins = {
- T.mixin(common_schema, { as = "redis_common" }),
- T.mixin(external_schema, { as = "external" })
+ T.mixin(common_schema, { as = "redis" })
}
})
},
{
name = "server_legacy",
- schema = T.table({
+ schema = T.table(extend_fields(external_fields, {
server = T.one_of({
T.string(),
T.array(T.string())
- }),
- }, {
+ })
+ }), {
open = true,
mixins = {
- T.mixin(common_schema, { as = "redis_common" }),
- T.mixin(external_schema, { as = "external" })
+ T.mixin(common_schema, { as = "redis" })
}
})
}
error("Schema already defined: " .. id)
end
+ -- Set schema_id in opts if not already set
+ if not schema.opts then
+ schema.opts = {}
+ end
+ if not schema.opts.schema_id then
+ schema.opts.schema_id = id
+ end
+
-- Resolve mixins if this is a table schema
local resolved = self:resolve_schema(schema)
local tag = schema.tag
-- If already resolved, return from cache
- local cache_key = tostring(schema)
- if self.resolved_cache[cache_key] then
- return self.resolved_cache[cache_key]
+ -- Use the schema table itself as key (works with weak tables)
+ if self.resolved_cache[schema] then
+ return self.resolved_cache[schema]
end
-- Handle reference nodes
-- Create new table schema with merged fields
local resolved = shallowcopy(schema)
resolved.fields = merged_fields
- self.resolved_cache[cache_key] = resolved
+ self.resolved_cache[schema] = resolved
return resolved
end
end
if resolved_item ~= schema.item_schema then
local resolved = shallowcopy(schema)
resolved.item_schema = resolved_item
- self.resolved_cache[cache_key] = resolved
+ self.resolved_cache[schema] = resolved
return resolved
end
end
if changed then
local resolved = shallowcopy(schema)
resolved.variants = resolved_variants
- self.resolved_cache[cache_key] = resolved
+ self.resolved_cache[schema] = resolved
return resolved
end
end
if resolved_inner ~= schema.inner then
local resolved = shallowcopy(schema)
resolved.inner = resolved_inner
- self.resolved_cache[cache_key] = resolved
+ self.resolved_cache[schema] = resolved
return resolved
end
end
type = 1,
required = 1,
default = 1,
+ mixins = 1,
}
local argparse = require "argparse"
local ansicolors = require "ansicolors"
if value['default'] then
print(string.format('%s\tDefault: %s', tabs, value['default']))
end
+ if value['mixins'] then
+ local mixin_names = {}
+ for _, mixin in ipairs(value['mixins']) do
+ table.insert(mixin_names, mixin.name or mixin.schema_id or 'unknown')
+ end
+ print(string.format('%s\tMixins: %s', tabs, table.concat(mixin_names, ', ')))
+ print(string.format('%s\t(Use `rspamadm confighelp <mixin>` for details)', tabs))
+ end
if not opts['no-examples'] and value['example'] then
local nv = string.match(value['example'], '^%s*(.*[^%s])%s*$') or value.example
print(string.format('%s\tExample:\n%s', tabs, nv))
--- /dev/null
+--[[
+Copyright (c) 2025, Vsevolod Stakhov <vsevolod@rspamd.com>
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+]]--
+
+-- Convert plugin schemas to UCL format for confighelp
+
+local function convert_schema(node)
+ if not node then return {} end
+
+ local res = {}
+ if node.summary then res.data = node.summary end
+ if node.description and not res.data then res.data = node.description end
+ if node.examples and node.examples[1] then res.example = node.examples[1] end
+ if node.default ~= nil then res.default = node.default end
+ if node.optional ~= nil then res.required = not node.optional end
+
+ if node.type == 'scalar' then
+ res.type = node.kind or 'string'
+ elseif node.type == 'table' then
+ res.type = 'object'
+ else
+ res.type = node.type
+ end
+
+ if node.type == 'table' then
+ local mixins = {}
+ local mixin_field_names = {}
+ for _, group in ipairs(node.mixin_groups or {}) do
+ local entry = { name = group.mixin_name or group.schema_id, schema_id = group.schema_id }
+ table.insert(mixins, entry)
+ for _, mixin_field in ipairs(group.fields or {}) do
+ mixin_field_names[mixin_field.name] = true
+ end
+ end
+ if #mixins > 0 then res.mixins = mixins end
+
+ for _, field in ipairs(node.fields or {}) do
+ if not mixin_field_names[field.name] then
+ local child = convert_schema(field.schema)
+ child.required = not field.optional
+ if field.schema and field.schema.summary and not child.data then
+ child.data = field.schema.summary
+ end
+ res[field.name] = child
+ end
+ end
+
+ if node.extra_schema then
+ local extra_child = convert_schema(node.extra_schema)
+ if not extra_child.data then extra_child.data = 'Entry schema' end
+ res['entry'] = extra_child
+ end
+
+ elseif node.type == 'array' and node.item_schema then
+ res.item = convert_schema(node.item_schema)
+
+ elseif node.type == 'one_of' then
+ local variants = {}
+ local common_fields = {}
+ local idx = 1
+
+ -- Extract fields common to all variants
+ local first_variant_fields = nil
+ local all_have_common = true
+ for _, variant in ipairs(node.variants or {}) do
+ if variant.type == 'table' and variant.fields then
+ local field_map = {}
+ for _, field in ipairs(variant.fields) do
+ field_map[field.name] = field
+ end
+ if first_variant_fields == nil then
+ first_variant_fields = field_map
+ else
+ -- Check which fields are common
+ for fname, _ in pairs(first_variant_fields) do
+ if not field_map[fname] then
+ first_variant_fields[fname] = nil
+ end
+ end
+ end
+ else
+ all_have_common = false
+ end
+ end
+
+ -- Convert common fields
+ if first_variant_fields and all_have_common then
+ for fname, field in pairs(first_variant_fields) do
+ local child = convert_schema(field.schema)
+ child.required = not field.optional
+ if field.schema and field.schema.summary and not child.data then
+ child.data = field.schema.summary
+ end
+ common_fields[fname] = true
+ res[fname] = child
+ end
+ end
+
+ -- Convert variants, excluding common fields
+ for _, variant in ipairs(node.variants or {}) do
+ local vname = variant.name or ('variant_' .. tostring(idx))
+ local converted_variant = convert_schema(variant)
+
+ -- Remove common fields from variant
+ for fname, _ in pairs(common_fields) do
+ converted_variant[fname] = nil
+ end
+
+ variants[vname] = converted_variant
+ idx = idx + 1
+ end
+ res.options = variants
+ end
+
+ return res
+end
+
+return function()
+ local Registry = require 'lua_shape.registry'
+ local docs = require 'lua_shape.docs'
+ local ucl = require 'ucl'
+
+ local reg = Registry.global()
+ if not reg then return nil end
+
+ local exported = docs.for_registry(reg)
+ if not exported or not exported.schemas then return nil end
+
+ local converted = {}
+ for id, schema in pairs(exported.schemas) do
+ converted[id] = convert_schema(schema)
+ end
+
+ return ucl.to_format({ schemas = converted }, 'json-compact')
+end
* limitations under the License.
*/
#include <ucl.h>
+#include <string.h>
#include "config.h"
#include "rspamadm.h"
#include "cfg_file.h"
static const char *rspamadm_confighelp_help(gboolean full_help,
const struct rspamadm_command *cmd);
+static ucl_object_t *rspamadm_confighelp_load_plugins_doc(struct rspamd_config *cfg);
+static const ucl_object_t *rspamadm_confighelp_lookup_plugin_doc(ucl_object_t *plugins_doc,
+ const char *key);
+
struct rspamadm_command confighelp_command = {
.name = "confighelp",
.flags = 0,
return res;
}
+static ucl_object_t *
+rspamadm_confighelp_load_plugins_doc(struct rspamd_config *cfg)
+{
+ lua_State *L = cfg->lua_state;
+ struct ucl_parser *parser;
+ ucl_object_t *doc = NULL;
+ const char *json;
+ size_t len;
+
+ /* Load the confighelp_plugins module */
+ lua_getglobal(L, "require");
+ lua_pushstring(L, "rspamadm.confighelp_plugins");
+
+ if (lua_pcall(L, 1, 1, 0) != LUA_OK) {
+ rspamd_fprintf(stderr, "cannot load confighelp_plugins module: %s\n",
+ lua_tostring(L, -1));
+ lua_pop(L, 1);
+ return NULL;
+ }
+
+ /* Module should return a function, call it */
+ if (!lua_isfunction(L, -1)) {
+ rspamd_fprintf(stderr, "confighelp_plugins module should return a function\n");
+ lua_pop(L, 1);
+ return NULL;
+ }
+
+ if (lua_pcall(L, 0, 1, 0) != LUA_OK) {
+ rspamd_fprintf(stderr, "cannot execute confighelp_plugins function: %s\n",
+ lua_tostring(L, -1));
+ lua_pop(L, 1);
+ return NULL;
+ }
+
+ /* Check result */
+ if (lua_isnil(L, -1)) {
+ lua_pop(L, 1);
+ return NULL;
+ }
+
+ json = lua_tolstring(L, -1, &len);
+ if (json == NULL) {
+ lua_pop(L, 1);
+ return NULL;
+ }
+
+ /* Parse JSON result */
+ parser = ucl_parser_new(0);
+ if (parser == NULL) {
+ lua_pop(L, 1);
+ return NULL;
+ }
+
+ if (!ucl_parser_add_chunk(parser, json, len)) {
+ rspamd_fprintf(stderr, "cannot parse plugin registry docs: %s\n",
+ ucl_parser_get_error(parser));
+ ucl_parser_free(parser);
+ lua_pop(L, 1);
+ return NULL;
+ }
+
+ doc = ucl_parser_get_object(parser);
+ ucl_parser_free(parser);
+ lua_pop(L, 1);
+
+ return doc;
+}
+
+static const ucl_object_t *
+rspamadm_confighelp_lookup_plugin_doc(ucl_object_t *plugins_doc, const char *key)
+{
+ const ucl_object_t *schemas, *elt;
+
+ if (plugins_doc == NULL || key == NULL) {
+ return NULL;
+ }
+
+ schemas = ucl_object_lookup(plugins_doc, "schemas");
+ if (schemas == NULL) {
+ return NULL;
+ }
+
+ elt = ucl_object_lookup(schemas, key);
+ if (elt == NULL) {
+ const char *prefixes[] = {"plugins.", "mixins."};
+ gsize i;
+ for (i = 0; i < G_N_ELEMENTS(prefixes) && elt == NULL; i++) {
+ const char *pref = prefixes[i];
+ size_t plen = strlen(pref);
+ if (strncmp(key, pref, plen) == 0) {
+ continue;
+ }
+ gchar *tmp = g_strdup_printf("%s%s", pref, key);
+ elt = ucl_object_lookup(schemas, tmp);
+ g_free(tmp);
+ }
+ }
+
+ return elt;
+}
+
__attribute__((noreturn)) static void
rspamadm_confighelp(int argc, char **argv, const struct rspamadm_command *cmd)
{
worker_t **pworker;
struct module_ctx *mod_ctx;
int i, ret = 0, processed_args = 0;
+ ucl_object_t *plugins_doc = NULL;
context = g_option_context_new(
"confighelp - displays help for the configuration options");
argv[i]);
}
else {
- doc_obj = ucl_object_typed_new(UCL_OBJECT);
+ doc_obj = NULL;
elt = ucl_object_lookup_path(cfg->doc_strings, argv[i]);
if (elt) {
+ doc_obj = ucl_object_typed_new(UCL_OBJECT);
ucl_object_insert_key(doc_obj, ucl_object_ref(elt),
argv[i], 0, false);
}
+ else {
+ const ucl_object_t *plugin_doc = NULL;
+ if (plugins_doc == NULL) {
+ plugins_doc = rspamadm_confighelp_load_plugins_doc(cfg);
+ }
+ plugin_doc = rspamadm_confighelp_lookup_plugin_doc(plugins_doc, argv[i]);
+ if (plugin_doc) {
+ doc_obj = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(doc_obj, ucl_object_ref(plugin_doc),
+ argv[i], 0, false);
+ }
+ }
}
if (doc_obj != NULL) {
rspamadm_confighelp_show(cfg, argc, argv, NULL, cfg->doc_strings);
}
+ if (plugins_doc) {
+ ucl_object_unref(plugins_doc);
+ }
+
rspamd_config_free(cfg);
exit(ret);