Implement auto-fill variables for checker 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 cc75779fbd
12 changed files with 277 additions and 116 deletions

View file

@ -23,8 +23,6 @@ package controller
import (
"fmt"
"log"
"maps"
"net/http"
"github.com/gin-gonic/gin"
@ -157,55 +155,8 @@ func (tc *CheckResultController) TriggerCheck(c *gin.Context) {
return
}
// Merge options with upper levels (user, domain, service)
var domainID, serviceID *happydns.Identifier
switch tc.scope {
case happydns.CheckScopeDomain:
domainID = &targetID
case happydns.CheckScopeService:
serviceID = &targetID
}
mergedOptions := make(happydns.CheckerOptions)
// Fill opts with default plugin options
checker, err := tc.checkerUC.GetChecker(checkName)
if err != nil {
log.Printf("Warning: unable to get plugin %q for default options: %v", checkName, err)
} else {
options := checker.Options()
// Collect all option documentation from different scopes
allOpts := []happydns.CheckerOptionDocumentation{}
allOpts = append(allOpts, options.RunOpts...)
allOpts = append(allOpts, options.ServiceOpts...)
allOpts = append(allOpts, options.DomainOpts...)
allOpts = append(allOpts, options.UserOpts...)
allOpts = append(allOpts, options.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.checkerUC.GetCheckerOptions(checkName, &user.Id, domainID, serviceID)
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 check via scheduler (returns error if scheduler is disabled)
executionID, err := tc.checkScheduler.TriggerOnDemandCheck(checkName, tc.scope, targetID, user.Id, mergedOptions)
// Trigger the test via scheduler (returns error if scheduler is disabled)
executionID, err := tc.checkScheduler.TriggerOnDemandCheck(checkName, tc.scope, targetID, user.Id, options.Options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return

View file

@ -264,7 +264,7 @@ func (app *App) initUsecases() {
app.usecases.authUser = authUserService
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
app.usecases.session = sessionService
app.usecases.checker = checkUC.NewCheckerUsecase(app.cfg, app.store)
app.usecases.checker = checkUC.NewCheckerUsecase(app.cfg, app.store, app.store)
app.usecases.checkerSchedule = checkresultUC.NewCheckScheduleUsecase(app.store, app.cfg, app.store, app.usecases.checker)
app.usecases.checkResult = checkresultUC.NewCheckResultUsecase(app.store, app.cfg, app.usecases.checker, app.usecases.checkerSchedule)

View file

@ -495,9 +495,9 @@ func (w *worker) executeCheck(item *queueItem) {
// Always update schedule NextRun after execution, whether it succeeds or fails.
// This prevents the schedule from being re-queued on the next tick if the test fails.
if item.execution.ScheduleId != nil {
if execution.ScheduleId != nil {
defer func() {
if err := w.scheduler.scheduleUsecase.UpdateScheduleAfterRun(*item.execution.ScheduleId); err != nil {
if err := w.scheduler.scheduleUsecase.UpdateScheduleAfterRun(*execution.ScheduleId); err != nil {
log.Printf("Worker %d: Error updating schedule after run: %v\n", w.id, err)
}
}()
@ -536,11 +536,20 @@ func (w *worker) executeCheck(item *queueItem) {
return
}
// Merge options: global defaults < user opts < domain/service opts < schedule opts
mergedOptions, err := w.scheduler.scheduleUsecase.PrepareCheckOptions(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.CheckerName, err)
var domainId, serviceId *happydns.Identifier
switch schedule.TargetType {
case happydns.CheckScopeDomain:
domainId = &schedule.TargetId
case happydns.CheckScopeService:
serviceId = &schedule.TargetId
}
// Merge options: global defaults < user opts < domain/service opts < schedule/on-demand opts < auto-fill
mergedOptions, mergeErr := w.scheduler.checkerUsecase.BuildMergedCheckerOptions(schedule.CheckerName, &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 checker options for %s: %v\n", w.id, schedule.CheckerName, mergeErr)
mergedOptions = schedule.Options
}
// Prepare metadata

View file

@ -44,3 +44,19 @@ type CheckerStorage interface {
// ClearCheckerConfigurations deletes all Providers present in the database.
ClearCheckerConfigurations() error
}
// CheckAutoFillStorage provides the domain/zone/user lookups needed to
// resolve auto-fill variables for test check options.
type CheckAutoFillStorage 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

@ -24,6 +24,7 @@ package check
import (
"cmp"
"fmt"
"log"
"maps"
"slices"
@ -33,14 +34,16 @@ import (
)
type checkerUsecase struct {
config *happydns.Options
store CheckerStorage
config *happydns.Options
store CheckerStorage
autoFillStore CheckAutoFillStorage
}
func NewCheckerUsecase(cfg *happydns.Options, store CheckerStorage) happydns.CheckerUsecase {
func NewCheckerUsecase(cfg *happydns.Options, store CheckerStorage, autoFillStore CheckAutoFillStorage) happydns.CheckerUsecase {
return &checkerUsecase{
config: cfg,
store: store,
config: cfg,
store: store,
autoFillStore: autoFillStore,
}
}
@ -120,6 +123,168 @@ func (tu *checkerUsecase) ListCheckers() (*map[string]happydns.Checker, error) {
return checks.GetCheckers(), nil
}
// GetStoredCheckerOptionsNoDefault returns the stored options (user/domain/service scopes)
// with auto-fill variables applied, but without checker-defined defaults or run-time overrides.
func (tu *checkerUsecase) GetStoredCheckerOptionsNoDefault(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (happydns.CheckerOptions, error) {
stored, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
if err != nil {
return nil, err
}
var opts happydns.CheckerOptions
if stored != nil {
opts = *stored
} else {
opts = make(happydns.CheckerOptions)
}
checker, err := tu.GetChecker(cname)
if err != nil {
return opts, nil
}
return tu.applyAutoFill(checker, userid, domainid, serviceid, opts), nil
}
// BuildMergedCheckerOptions merges checker options from all sources in priority order:
// checker defaults < stored (user/domain/service) options < runOpts < auto-fill variables.
func (tu *checkerUsecase) BuildMergedCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckerOptions, error) {
merged := make(happydns.CheckerOptions)
// 1. Fill checker defaults.
checker, err := tu.GetChecker(cname)
if err != nil {
log.Printf("Warning: unable to get checker %q for default options: %v", cname, err)
} else {
opts := checker.Options()
allOpts := []happydns.CheckerOptionDocumentation{}
allOpts = append(allOpts, opts.RunOpts...)
allOpts = append(allOpts, opts.ServiceOpts...)
allOpts = append(allOpts, opts.DomainOpts...)
allOpts = append(allOpts, opts.UserOpts...)
allOpts = append(allOpts, opts.AdminOpts...)
for _, opt := range allOpts {
if opt.Default != nil {
merged[opt.Id] = opt.Default
} else if opt.Placeholder != "" {
merged[opt.Id] = opt.Placeholder
}
}
}
// 2. Override with stored options (user/domain/service scopes).
baseOptions, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
if err != nil {
return merged, fmt.Errorf("could not fetch stored checker options for %s: %w", cname, err)
}
if baseOptions != nil {
copyNonEmpty(merged, *baseOptions)
}
// 3. Override with caller-supplied run options.
copyNonEmpty(merged, runOpts)
// 4. Inject auto-fill variables (always win over any user-supplied value).
if checker != nil {
merged = tu.applyAutoFill(checker, userid, domainid, serviceid, merged)
}
return merged, nil
}
// applyAutoFill resolves auto-fill fields declared by the checker and injects
// the context-resolved values into a copy of opts.
func (tu *checkerUsecase) applyAutoFill(
checker happydns.Checker,
userid *happydns.Identifier,
domainid *happydns.Identifier,
serviceid *happydns.Identifier,
opts happydns.CheckerOptions,
) happydns.CheckerOptions {
// Collect which auto-fill keys are needed.
needed := make(map[string]string) // autoFill constant → field id
options := checker.Options()
for _, groups := range [][]happydns.CheckerOptionDocumentation{
options.RunOpts, options.DomainOpts, options.ServiceOpts,
options.UserOpts, options.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 *checkerUsecase) buildAutoFillContext(userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) map[string]any {
ctx := make(map[string]any)
if domainid != nil {
if domain, err := tu.autoFillStore.GetDomain(*domainid); err == nil {
ctx[happydns.AutoFillDomainName] = domain.DomainName
if len(domain.ZoneHistory) > 0 {
// The first element in ZoneHistory is the current (most recent) zone.
zoneMsg, err := tu.autoFillStore.GetZone(domain.ZoneHistory[0])
if err == nil {
ctx[happydns.AutoFillZone] = zoneMsg
}
}
}
} 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.AutoFillZone] = zoneMsg
ctx[happydns.AutoFillService] = svc
ctx[happydns.AutoFillServiceType] = svc.Type
return ctx
}
}
}
}
}
return ctx
}
func (tu *checkerUsecase) SetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
// filter opts that correspond to the level set
checker, err := tu.GetChecker(cname)
@ -129,34 +294,30 @@ func (tu *checkerUsecase) SetCheckerOptions(cname string, userid *happydns.Ident
options := checker.Options()
var optNames []string
var relevantOpts []happydns.CheckerOptionDocumentation
if serviceid != nil {
for _, opt := range options.ServiceOpts {
optNames = append(optNames, opt.Id)
}
relevantOpts = options.ServiceOpts
} else if domainid != nil {
for _, opt := range options.DomainOpts {
optNames = append(optNames, opt.Id)
}
relevantOpts = options.DomainOpts
} else if userid != nil {
for _, opt := range options.UserOpts {
optNames = append(optNames, opt.Id)
}
relevantOpts = options.UserOpts
} else {
for _, opt := range options.AdminOpts {
optNames = append(optNames, opt.Id)
}
relevantOpts = options.AdminOpts
}
allowed := make(map[string]struct{}, len(relevantOpts))
for _, opt := range relevantOpts {
allowed[opt.Id] = struct{}{}
}
// Filter opts to only include keys that are in optNames
filteredOpts := make(happydns.CheckerOptions)
for _, optName := range optNames {
if val, exists := opts[optName]; exists && val != "" {
filteredOpts[optName] = val
for id := range allowed {
if val, exists := opts[id]; exists && val != "" {
filteredOpts[id] = val
}
}
return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, opts)
return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, filteredOpts)
}
func (tu *checkerUsecase) OverwriteSomeCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
@ -221,7 +382,7 @@ func (tu *checkerUsecase) ValidateCheckerOptions(cname string, opts happydns.Che
return fmt.Errorf("unknown option %q for checker %q", name, cname)
}
if doc.Type == "" {
if doc.AutoFill != "" || doc.Type == "" {
continue
}

View file

@ -456,31 +456,3 @@ func (u *CheckScheduleUsecase) DiscoverAndEnsureSchedules() error {
return errors.Join(errs...)
}
// PrepareCheckOptions fetches and merges plugin options for a scheduled check execution.
// It combines stored options (global/user/domain/service scopes) with the
// schedule-specific overrides, returning the final merged options.
func (u *CheckScheduleUsecase) PrepareCheckOptions(schedule *happydns.CheckerSchedule) (happydns.CheckerOptions, error) {
if u.checkerUsecase == nil {
return schedule.Options, nil
}
var domainId, serviceId *happydns.Identifier
switch schedule.TargetType {
case happydns.CheckScopeDomain:
domainId = &schedule.TargetId
case happydns.CheckScopeService:
serviceId = &schedule.TargetId
}
baseOptions, err := u.checkerUsecase.GetCheckerOptions(schedule.CheckerName, &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.CheckerName, err)
}
if baseOptions != nil {
return u.MergeCheckOptions(nil, nil, *baseOptions, schedule.Options), nil
}
return schedule.Options, nil
}

View file

@ -144,7 +144,4 @@ type CheckerScheduleUsecase interface {
// RescheduleOverdueTests reschedules overdue tests to run soon, spread over a
// short window to avoid scheduler famine after a suspend or server restart.
RescheduleOverdueChecks() (int, error)
// PrepareCheckOptions fetches and merges plugin options for a scheduled check execution.
PrepareCheckOptions(schedule *CheckerSchedule) (CheckerOptions, error)
}

View file

@ -26,6 +26,30 @@ import (
"time"
)
// Auto-fill variable identifiers for checker 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"
// AutoFillZone fills the option with the zone object. Only applicable
// for domain-scoped and service-scoped tests.
AutoFillZone = "zone"
// AutoFillServiceType fills the option with the service type identifier
// (e.g. "abstract.MatrixIM"). Only applicable for service-scoped tests.
AutoFillServiceType = "service_type"
// AutoFillService fills the option with the service object. Only applicable
// for service-scoped tests.
AutoFillService = "service"
)
const (
CheckResultStatusUnknown CheckResultStatus = iota
CheckResultStatusCritical
@ -110,6 +134,8 @@ type CheckerStatus struct {
}
type CheckerUsecase interface {
BuildMergedCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) (CheckerOptions, error)
GetStoredCheckerOptionsNoDefault(string, *Identifier, *Identifier, *Identifier) (CheckerOptions, error)
GetChecker(string) (Checker, error)
GetCheckerOptions(string, *Identifier, *Identifier, *Identifier) (*CheckerOptions, error)
GetCheckerResponse(Checker) CheckerResponse

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

@ -26,6 +26,18 @@
import { t } from "$lib/translations";
const AUTO_FILL_KEYS: Record<string, string> = {
domain_name: "checkers.auto-fill.domain_name",
subdomain: "checkers.auto-fill.subdomain",
service_type: "checkers.auto-fill.service_type",
};
function getAutoFillLabel(autoFill: string): string {
const tKey = AUTO_FILL_KEYS[autoFill];
if (tKey) return $t(tKey);
return $t("checkers.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,7 +76,11 @@
{optDoc.label || optDoc.id}:
</dt>
<dd class="col-sm-8">
{#if optDoc.default}
{#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>
@ -76,7 +93,7 @@
type: optDoc.type || "string",
})}
</small>
{#if optDoc.required}
{#if optDoc.required && !optDoc.autoFill}
<small class="text-danger ms-2">
{$t("checkers.option-groups.required")}
</small>

View file

@ -768,6 +768,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": "Checker options updated successfully",
"options-cleaned": "Orphaned options removed successfully",

View file

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