]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Project] Rework mixins and documentation part
authorVsevolod Stakhov <vsevolod@rspamd.com>
Tue, 18 Nov 2025 19:52:08 +0000 (19:52 +0000)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Tue, 18 Nov 2025 19:52:08 +0000 (19:52 +0000)
lualib/lua_redis.lua
lualib/lua_shape/registry.lua
lualib/rspamadm/confighelp.lua
lualib/rspamadm/confighelp_plugins.lua [new file with mode: 0644]
src/rspamadm/confighelp.c

index 0025399e06f98aad7f7c3031aafccd5163ddd754..ddcb84c94b683f44a1a3faf7966e9f5a24367465 100644 (file)
@@ -18,6 +18,7 @@ local logger = require "rspamd_logger"
 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 = {}
 
@@ -70,53 +71,143 @@ local common_schema = T.table({
   }):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())
@@ -124,42 +215,39 @@ local enrich_schema = function(external)
         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" })
         }
       })
     }
index 88eb57c82b92e74a9c92c89b5af45f264ee06f5d..39ae83d7902926ac1e894dbaf88643b6e4503847 100644 (file)
@@ -54,6 +54,14 @@ function Registry:define(id, schema)
     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)
 
@@ -84,9 +92,9 @@ function Registry: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
@@ -146,7 +154,7 @@ function Registry:resolve_schema(schema)
       -- 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
@@ -157,7 +165,7 @@ function Registry:resolve_schema(schema)
     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
@@ -182,7 +190,7 @@ function Registry:resolve_schema(schema)
     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
@@ -193,7 +201,7 @@ function Registry:resolve_schema(schema)
     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
index 38b26b6fc9a0912ede67ee84e980e33fdb3aecb9..36161c10f20e015b37a2fc36a711d98bcf137e04 100644 (file)
@@ -5,6 +5,7 @@ local known_attrs = {
   type = 1,
   required = 1,
   default = 1,
+  mixins = 1,
 }
 local argparse = require "argparse"
 local ansicolors = require "ansicolors"
@@ -93,6 +94,14 @@ local function print_help(key, value, tabs)
     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))
diff --git a/lualib/rspamadm/confighelp_plugins.lua b/lualib/rspamadm/confighelp_plugins.lua
new file mode 100644 (file)
index 0000000..d5d822e
--- /dev/null
@@ -0,0 +1,147 @@
+--[[
+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
index 196324d1307528fcb5e009d6050f8202cb1a8f71..04c8845a162400aad3214ebe25abfedc82ee8603 100644 (file)
@@ -14,6 +14,7 @@
  * limitations under the License.
  */
 #include <ucl.h>
+#include <string.h>
 #include "config.h"
 #include "rspamadm.h"
 #include "cfg_file.h"
@@ -36,6 +37,10 @@ static void rspamadm_confighelp(int argc, char **argv,
 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,
@@ -189,6 +194,107 @@ rspamadm_confighelp_search_word(const ucl_object_t *obj, const char *str)
        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)
 {
@@ -201,6 +307,7 @@ 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");
@@ -266,13 +373,26 @@ rspamadm_confighelp(int argc, char **argv, const struct rspamadm_command *cmd)
                                                                                                                          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) {
@@ -295,6 +415,10 @@ rspamadm_confighelp(int argc, char **argv, const struct rspamadm_command *cmd)
                rspamadm_confighelp_show(cfg, argc, argv, NULL, cfg->doc_strings);
        }
 
+       if (plugins_doc) {
+               ucl_object_unref(plugins_doc);
+       }
+
        rspamd_config_free(cfg);
 
        exit(ret);