Implement auto-fill variables for test plugin option fields

Add an AutoFill attribute to the Field struct that marks option fields
as automatically resolved by the software based on test context, rather
than requiring user input. Auto-fill always overrides any user-provided
value at execution time.
This commit is contained in:
nemunaire 2026-02-12 12:52:40 +07:00
commit 3bf9cc189b
12 changed files with 260 additions and 114 deletions

View file

@ -23,8 +23,6 @@ package controller
import (
"fmt"
"log"
"maps"
"net/http"
"github.com/gin-gonic/gin"
@ -230,44 +228,12 @@ func (tc *TestResultController) TriggerTest(c *gin.Context) {
serviceID = &targetID
}
mergedOptions := make(happydns.PluginOptions)
// Fill opts with default plugin options
plugin, err := tc.testPluginUC.GetTestPlugin(pluginName)
if err != nil {
log.Printf("Warning: unable to get plugin %q for default options: %v", pluginName, err)
} else {
availableOpts := plugin.AvailableOptions()
// Collect all option documentation from different scopes
allOpts := []happydns.PluginOptionDocumentation{}
allOpts = append(allOpts, availableOpts.RunOpts...)
allOpts = append(allOpts, availableOpts.ServiceOpts...)
allOpts = append(allOpts, availableOpts.DomainOpts...)
allOpts = append(allOpts, availableOpts.UserOpts...)
allOpts = append(allOpts, availableOpts.AdminOpts...)
// Fill defaults
for _, opt := range allOpts {
if opt.Default != nil {
mergedOptions[opt.Id] = opt.Default
}
}
}
// Get merged options from upper levels
baseOptions, err := tc.testPluginUC.GetTestPluginOptions(pluginName, &user.Id, domainID, serviceID)
mergedOptions, err := tc.testPluginUC.BuildMergedTestPluginOptions(pluginName, &user.Id, domainID, serviceID, options.Options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Merge request options on top of base options (request options override)
if baseOptions != nil {
maps.Copy(mergedOptions, *baseOptions)
}
maps.Copy(mergedOptions, options.Options)
// Trigger the test via scheduler (returns error if scheduler is disabled)
executionID, err := tc.testScheduler.TriggerOnDemandTest(pluginName, tc.scope, targetID, user.Id, mergedOptions)
if err != nil {
@ -306,7 +272,7 @@ func (tc *TestResultController) GetTestPluginOptions(c *gin.Context) {
serviceID = &targetID
}
opts, err := tc.testPluginUC.GetTestPluginOptions(pluginName, &user.Id, domainID, serviceID)
opts, err := tc.testPluginUC.GetStoredTestPluginOptionsNoDefault(pluginName, &user.Id, domainID, serviceID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return

View file

@ -239,7 +239,7 @@ func (app *App) initUsecases() {
app.usecases.authUser = authUserService
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
app.usecases.session = sessionService
app.usecases.testPlugin = pluginUC.NewTestPluginUsecase(app.cfg, app.plugins, app.store)
app.usecases.testPlugin = pluginUC.NewTestPluginUsecase(app.cfg, app.plugins, app.store, app.store)
app.usecases.testResult = testresultUC.NewTestResultUsecase(app.store, app.cfg)
app.usecases.testSchedule = testresultUC.NewTestScheduleUsecase(app.store, app.cfg, app.store, app.usecases.testPlugin)

View file

@ -562,11 +562,26 @@ func (w *worker) executeTest(item *queueItem) {
return
}
// Merge options: global defaults < user opts < domain/service opts < schedule opts
mergedOptions, err := w.scheduler.scheduleUsecase.PrepareTestOptions(schedule)
if err != nil {
// Non-fatal: PrepareTestOptions already falls back to schedule-only options
log.Printf("Worker %d: warning, could not prepare plugin options for %s: %v\n", w.id, schedule.PluginName, err)
// For scheduled tests: merge plugin defaults < stored (user/domain/service) opts < schedule opts < auto-fill.
// For on-demand tests the caller has already merged all options, so use them directly.
var mergedOptions happydns.PluginOptions
if item.execution.ScheduleId != nil {
var domainId, serviceId *happydns.Identifier
switch schedule.TargetType {
case happydns.TestScopeDomain:
domainId = &schedule.TargetId
case happydns.TestScopeService:
serviceId = &schedule.TargetId
}
var mergeErr error
mergedOptions, mergeErr = w.scheduler.pluginUsecase.BuildMergedTestPluginOptions(schedule.PluginName, &schedule.OwnerId, domainId, serviceId, schedule.Options)
if mergeErr != nil {
// Non-fatal: fall back to schedule-only options
log.Printf("Worker %d: warning, could not prepare plugin options for %s: %v\n", w.id, schedule.PluginName, mergeErr)
mergedOptions = schedule.Options
}
} else {
mergedOptions = schedule.Options
}
// Prepare metadata
@ -651,3 +666,4 @@ func (w *worker) executeTest(item *queueItem) {
log.Printf("Worker %d: Completed test %s for target %s (status: %d, duration: %v)\n",
w.id, schedule.PluginName, schedule.TargetId.String(), result.Status, duration)
}

View file

@ -44,3 +44,19 @@ type PluginStorage interface {
// ClearPluginConfigurations deletes all Providers present in the database.
ClearPluginConfigurations() error
}
// PluginAutoFillStorage provides the domain/zone/user lookups needed to
// resolve auto-fill variables for test plugin options.
type PluginAutoFillStorage interface {
// GetDomain retrieves the Domain with the given identifier.
GetDomain(domainid happydns.Identifier) (*happydns.Domain, error)
// GetUser retrieves the User with the given identifier.
GetUser(userid happydns.Identifier) (*happydns.User, error)
// ListDomains retrieves all Domains associated to the given User.
ListDomains(user *happydns.User) ([]*happydns.Domain, error)
// GetZone retrieves the full Zone (including Services and metadata) for the given identifier.
GetZone(zoneid happydns.Identifier) (*happydns.ZoneMessage, error)
}

View file

@ -23,6 +23,7 @@ package plugin
import (
"fmt"
"log"
"maps"
"sort"
@ -30,16 +31,18 @@ import (
)
type testPluginUsecase struct {
config *happydns.Options
manager happydns.PluginManager
store PluginStorage
config *happydns.Options
manager happydns.PluginManager
store PluginStorage
autoFillStore PluginAutoFillStorage
}
func NewTestPluginUsecase(cfg *happydns.Options, manager happydns.PluginManager, store PluginStorage) happydns.TestPluginUsecase {
func NewTestPluginUsecase(cfg *happydns.Options, manager happydns.PluginManager, store PluginStorage, autoFillStore PluginAutoFillStorage) happydns.TestPluginUsecase {
return &testPluginUsecase{
config: cfg,
manager: manager,
store: store,
config: cfg,
manager: manager,
store: store,
autoFillStore: autoFillStore,
}
}
@ -115,6 +118,156 @@ func (tu *testPluginUsecase) ListTestPlugins() ([]happydns.TestPlugin, error) {
return tu.manager.GetTestPlugins(), nil
}
// GetStoredTestPluginOptionsNoDefault returns the stored options (user/domain/service scopes)
// with auto-fill variables applied, but without plugin-defined defaults or run-time overrides.
func (tu *testPluginUsecase) GetStoredTestPluginOptionsNoDefault(pname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (happydns.PluginOptions, error) {
stored, err := tu.GetTestPluginOptions(pname, userid, domainid, serviceid)
if err != nil {
return nil, err
}
var opts happydns.PluginOptions
if stored != nil {
opts = *stored
} else {
opts = make(happydns.PluginOptions)
}
plugin, err := tu.GetTestPlugin(pname)
if err != nil {
return opts, nil
}
return tu.applyAutoFill(plugin, userid, domainid, serviceid, opts), nil
}
// BuildMergedTestPluginOptions merges plugin options from all sources in priority order:
// plugin defaults < stored (user/domain/service) options < runOpts < auto-fill variables.
func (tu *testPluginUsecase) BuildMergedTestPluginOptions(pname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, runOpts happydns.PluginOptions) (happydns.PluginOptions, error) {
merged := make(happydns.PluginOptions)
// 1. Fill plugin defaults.
plugin, err := tu.GetTestPlugin(pname)
if err != nil {
log.Printf("Warning: unable to get plugin %q for default options: %v", pname, err)
} else {
availableOpts := plugin.AvailableOptions()
allOpts := []happydns.PluginOptionDocumentation{}
allOpts = append(allOpts, availableOpts.RunOpts...)
allOpts = append(allOpts, availableOpts.ServiceOpts...)
allOpts = append(allOpts, availableOpts.DomainOpts...)
allOpts = append(allOpts, availableOpts.UserOpts...)
allOpts = append(allOpts, availableOpts.AdminOpts...)
for _, opt := range allOpts {
if opt.Default != nil {
merged[opt.Id] = opt.Default
}
}
}
// 2. Override with stored options (user/domain/service scopes).
baseOptions, err := tu.GetTestPluginOptions(pname, userid, domainid, serviceid)
if err != nil {
return merged, fmt.Errorf("could not fetch stored plugin options for %s: %w", pname, err)
}
if baseOptions != nil {
maps.Copy(merged, *baseOptions)
}
// 3. Override with caller-supplied run options.
maps.Copy(merged, runOpts)
// 4. Inject auto-fill variables (always win over any user-supplied value).
if plugin != nil {
merged = tu.applyAutoFill(plugin, userid, domainid, serviceid, merged)
}
return merged, nil
}
// applyAutoFill resolves auto-fill fields declared by the plugin and injects
// the context-resolved values into a copy of opts.
func (tu *testPluginUsecase) applyAutoFill(
plugin happydns.TestPlugin,
userid *happydns.Identifier,
domainid *happydns.Identifier,
serviceid *happydns.Identifier,
opts happydns.PluginOptions,
) happydns.PluginOptions {
allOpts := plugin.AvailableOptions()
// Collect which auto-fill keys are needed.
needed := make(map[string]string) // autoFill constant → field id
for _, groups := range [][]happydns.PluginOptionDocumentation{
allOpts.RunOpts, allOpts.DomainOpts, allOpts.ServiceOpts,
allOpts.UserOpts, allOpts.AdminOpts,
} {
for _, opt := range groups {
if opt.AutoFill != "" {
needed[opt.AutoFill] = opt.Id
}
}
}
if len(needed) == 0 || tu.autoFillStore == nil {
return opts
}
autoFillCtx := tu.buildAutoFillContext(userid, domainid, serviceid)
result := maps.Clone(opts)
for autoFillKey, fieldId := range needed {
if val, ok := autoFillCtx[autoFillKey]; ok {
result[fieldId] = val
}
}
return result
}
// buildAutoFillContext resolves the available auto-fill values for the given
// user/domain/service identifiers.
func (tu *testPluginUsecase) buildAutoFillContext(userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) map[string]string {
ctx := make(map[string]string)
if domainid != nil {
if domain, err := tu.autoFillStore.GetDomain(*domainid); err == nil {
ctx[happydns.AutoFillDomainName] = domain.DomainName
}
} else if serviceid != nil && userid != nil {
// To resolve service context we need to find which domain/zone owns the service.
user, err := tu.autoFillStore.GetUser(*userid)
if err != nil {
return ctx
}
domains, err := tu.autoFillStore.ListDomains(user)
if err != nil {
return ctx
}
for _, domain := range domains {
if len(domain.ZoneHistory) == 0 {
continue
}
// The first element in ZoneHistory is the current (most recent) zone.
zoneMsg, err := tu.autoFillStore.GetZone(domain.ZoneHistory[0])
if err != nil {
continue
}
for subdomain, svcs := range zoneMsg.Services {
for _, svc := range svcs {
if svc.Id.Equals(*serviceid) {
ctx[happydns.AutoFillDomainName] = domain.DomainName
ctx[happydns.AutoFillSubdomain] = string(subdomain)
ctx[happydns.AutoFillServiceType] = svc.Type
return ctx
}
}
}
}
}
return ctx
}
func (tu *testPluginUsecase) SetTestPluginOptions(pname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.PluginOptions) error {
// filter opts that correspond to the level set
plugin, err := tu.GetTestPlugin(pname)

View file

@ -223,39 +223,6 @@ func (u *TestScheduleUsecase) getDefaultInterval(targetType happydns.TestScopeTy
}
}
// MergePluginOptions merges plugin options from different scopes
// Priority: schedule options > domain options > user options > global options
func (u *TestScheduleUsecase) MergePluginOptions(
globalOpts happydns.PluginOptions,
userOpts happydns.PluginOptions,
domainOpts happydns.PluginOptions,
scheduleOpts happydns.PluginOptions,
) happydns.PluginOptions {
merged := make(happydns.PluginOptions)
// Start with global options
for k, v := range globalOpts {
merged[k] = v
}
// Override with user options
for k, v := range userOpts {
merged[k] = v
}
// Override with domain options
for k, v := range domainOpts {
merged[k] = v
}
// Override with schedule options (highest priority)
for k, v := range scheduleOpts {
merged[k] = v
}
return merged
}
// ValidateScheduleOwnership checks if a user owns a schedule
func (u *TestScheduleUsecase) ValidateScheduleOwnership(scheduleId happydns.Identifier, userId happydns.Identifier) error {
schedule, err := u.storage.GetTestSchedule(scheduleId)
@ -450,30 +417,3 @@ func (u *TestScheduleUsecase) DiscoverAndEnsureSchedules() error {
return errors.Join(errs...)
}
// PrepareTestOptions fetches and merges plugin options for a scheduled test execution.
// It combines stored options (global/user/domain/service scopes) with the
// schedule-specific overrides, returning the final merged options.
func (u *TestScheduleUsecase) PrepareTestOptions(schedule *happydns.TestSchedule) (happydns.PluginOptions, error) {
if u.pluginUsecase == nil {
return schedule.Options, nil
}
var domainId, serviceId *happydns.Identifier
switch schedule.TargetType {
case happydns.TestScopeDomain:
domainId = &schedule.TargetId
case happydns.TestScopeService:
serviceId = &schedule.TargetId
}
baseOptions, err := u.pluginUsecase.GetTestPluginOptions(schedule.PluginName, &schedule.OwnerId, domainId, serviceId)
if err != nil {
// Non-fatal: fall back to schedule-only options and surface as a warning
return schedule.Options, fmt.Errorf("could not fetch plugin options for %s: %w", schedule.PluginName, err)
}
if baseOptions != nil {
return u.MergePluginOptions(nil, nil, *baseOptions, schedule.Options), nil
}
return schedule.Options, nil
}

View file

@ -104,6 +104,11 @@ type Field struct {
// Description stores an helpfull sentence describing the field.
Description string `json:"description,omitempty"`
// AutoFill indicates the field value is automatically resolved by the
// software based on test context. When set, the value should not be
// entered by the user.
AutoFill string `json:"autoFill,omitempty"`
}
type FormState struct {

View file

@ -21,6 +21,22 @@
package happydns
// Auto-fill variable identifiers for plugin option fields.
const (
// AutoFillDomainName fills the option with the fully qualified domain name
// of the domain being tested (e.g. "example.com.").
AutoFillDomainName = "domain_name"
// AutoFillSubdomain fills the option with the subdomain relative to the zone
// (e.g. "www" for "www.example.com." in zone "example.com."). Only
// applicable for service-scoped tests.
AutoFillSubdomain = "subdomain"
// AutoFillServiceType fills the option with the service type identifier
// (e.g. "abstract.MatrixIM"). Only applicable for service-scoped tests.
AutoFillServiceType = "service_type"
)
const (
PluginResultStatusKO PluginResultStatus = iota
PluginResultStatusWarn
@ -93,6 +109,8 @@ type PluginManager interface {
}
type TestPluginUsecase interface {
BuildMergedTestPluginOptions(string, *Identifier, *Identifier, *Identifier, PluginOptions) (PluginOptions, error)
GetStoredTestPluginOptionsNoDefault(string, *Identifier, *Identifier, *Identifier) (PluginOptions, error)
GetTestPlugin(string) (TestPlugin, error)
GetTestPluginOptions(string, *Identifier, *Identifier, *Identifier) (*PluginOptions, error)
ListTestPlugins() ([]TestPlugin, error)

View file

@ -26,6 +26,18 @@
import { t } from "$lib/translations";
const AUTO_FILL_KEYS: Record<string, string> = {
domain_name: "plugins.tests.auto-fill.domain_name",
subdomain: "plugins.tests.auto-fill.subdomain",
service_type: "plugins.tests.auto-fill.service_type",
};
function getAutoFillLabel(autoFill: string): string {
const tKey = AUTO_FILL_KEYS[autoFill];
if (tKey) return $t(tKey);
return $t("plugins.tests.auto-fill.generic", { key: autoFill });
}
interface OptionDef {
id?: string;
label?: string;
@ -34,6 +46,7 @@
placeholder?: string;
description?: string;
required?: boolean;
autoFill?: string;
}
interface OptionGroup {
@ -63,10 +76,16 @@
{optDoc.label || optDoc.id}:
</dt>
<dd class="col-sm-8">
{#if optDoc.default}
<span class="text-muted d-block">{optDoc.default}</span>
{:else if optDoc.placeholder}
<em class="text-muted d-block">{optDoc.placeholder}</em>
{#if optDoc.autoFill}
<span class="badge bg-info me-1"
>{getAutoFillLabel(optDoc.autoFill)}</span
>
{:else}
{#if optDoc.default}
<span class="text-muted d-block">{optDoc.default}</span>
{:else if optDoc.placeholder}
<em class="text-muted d-block">{optDoc.placeholder}</em>
{/if}
{/if}
{#if optDoc.description}
<small class="text-muted d-block">{optDoc.description}</small>
@ -76,7 +95,7 @@
type: optDoc.type || "string",
})}</small
>
{#if optDoc.required}
{#if optDoc.required && !optDoc.autoFill}
<small class="text-danger ms-2"
>{$t("plugins.tests.option-groups.required")}</small
>

View file

@ -718,6 +718,12 @@
"type": "Type: {{type}}",
"required": "Required"
},
"auto-fill": {
"domain_name": "auto-filled: domain name",
"subdomain": "auto-filled: subdomain",
"service_type": "auto-filled: service type",
"generic": "auto-filled: {{key}}"
},
"messages": {
"options-updated": "Plugin options updated successfully",
"options-cleaned": "Orphaned options removed successfully",

View file

@ -547,6 +547,12 @@
"type": "Type : {{type}}",
"required": "Requis"
},
"auto-fill": {
"domain_name": "rempli automatiquement : nom de domaine",
"subdomain": "rempli automatiquement : sous-domaine",
"service_type": "rempli automatiquement : type de service",
"generic": "rempli automatiquement : {{key}}"
},
"messages": {
"options-updated": "Options du plugin mises à jour avec succès",
"options-cleaned": "Options orphelines supprimées avec succès",

View file

@ -31,6 +31,7 @@ export class Field {
required? = $state<boolean>();
secret? = $state<boolean>();
textarea? = $state<boolean>();
autoFill? = $state<string>();
}
export class CustomForm {