Introduce a DomainAvailabilityWatch entity (model, storage, usecase and REST endpoints) letting a user track a domain they do not own and get notified the moment it becomes available for registration. A dedicated domain_availability checker reads WHOIS/RDAP via pkg/domaininfo and inverts the status (OK while registered, Crit once free) so the existing dispatcher fires exactly once on the transition. The scheduler enumerates watches and enqueues the check, carrying the watch id in CheckTarget.DomainId; autofill and notification payloads fall back to the watch store to resolve the name. Watches are included in per-user backup/restore. The web UI adds an availability watchlist page and navigation entry.
417 lines
12 KiB
Go
417 lines
12 KiB
Go
// This file is part of the happyDomain (R) project.
|
|
// Copyright (c) 2020-2026 happyDomain
|
|
// Authors: Pierre-Olivier Mercier, et al.
|
|
//
|
|
// This program is offered under a commercial and under the AGPL license.
|
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
//
|
|
// For AGPL licensing:
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
//go:build ignore
|
|
|
|
// gen_instrumented_storage generates internal/app/instrumented_storage_generated.go,
|
|
// a metrics-instrumented wrapper for every method of storage.Storage.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"go/format"
|
|
"go/types"
|
|
"log"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"golang.org/x/tools/go/packages"
|
|
)
|
|
|
|
// entityMap maps each embedded interface type name to the Prometheus entity label.
|
|
var entityMap = map[string]string{
|
|
"AuthUserStorage": "authuser",
|
|
"CheckPlanStorage": "check_plan",
|
|
"CheckerOptionsStorage": "check_config",
|
|
"CheckEvaluationStorage": "check_evaluation",
|
|
"ExecutionStorage": "execution",
|
|
"DiscoveryEntryStorage": "discovery_entry",
|
|
"DiscoveryObservationStorage": "discovery_observation",
|
|
"ObservationCacheStorage": "observation_cache",
|
|
"ObservationSnapshotStorage": "observation_snapshot",
|
|
"SchedulerStateStorage": "scheduler_state",
|
|
"DomainStorage": "domain",
|
|
"DomainAvailabilityWatchStorage": "domain_availability_watch",
|
|
"DomainLogStorage": "domain_log",
|
|
"InsightStorage": "insight",
|
|
"NotificationChannelStorage": "notification_channel",
|
|
"NotificationPreferenceStorage": "notification_preference",
|
|
"NotificationStateStorage": "notification_state",
|
|
"NotificationRecordStorage": "notification_record",
|
|
"ProviderStorage": "provider",
|
|
"SessionStorage": "session",
|
|
"UserStorage": "user",
|
|
"ZoneStorage": "zone",
|
|
}
|
|
|
|
// operationOverrides maps method names that don't follow the prefix convention.
|
|
var operationOverrides = map[string]string{
|
|
"AuthUserExists": "get",
|
|
"InsightsRun": "run",
|
|
"LastInsightsRun": "get",
|
|
"CreateOrUpdateUser": "update",
|
|
"ReplaceDiscoveryEntries": "update",
|
|
}
|
|
|
|
// skipMethods lists methods that should be passed through without instrumentation.
|
|
var skipMethods = map[string]bool{
|
|
"SchemaVersion": true,
|
|
"MigrateSchema": true,
|
|
"Close": true,
|
|
}
|
|
|
|
func main() {
|
|
cfg := &packages.Config{
|
|
Mode: packages.NeedTypes | packages.NeedImports | packages.NeedDeps,
|
|
}
|
|
pkgs, err := packages.Load(cfg, "git.happydns.org/happyDomain/internal/storage")
|
|
if err != nil {
|
|
log.Fatalf("loading storage package: %v", err)
|
|
}
|
|
if len(pkgs) == 0 {
|
|
log.Fatal("no packages loaded")
|
|
}
|
|
if len(pkgs[0].Errors) > 0 {
|
|
for _, e := range pkgs[0].Errors {
|
|
log.Println(e)
|
|
}
|
|
log.Fatal("package has errors")
|
|
}
|
|
|
|
storageObj := pkgs[0].Types.Scope().Lookup("Storage")
|
|
if storageObj == nil {
|
|
log.Fatal("Storage type not found")
|
|
}
|
|
storageIface := storageObj.Type().Underlying().(*types.Interface)
|
|
|
|
// Build method → entity mapping by walking embedded interfaces.
|
|
methodEntity := map[string]string{}
|
|
for i := 0; i < storageIface.NumEmbeddeds(); i++ {
|
|
embedded := storageIface.EmbeddedType(i)
|
|
named, ok := embedded.(*types.Named)
|
|
if !ok {
|
|
continue
|
|
}
|
|
ifaceName := named.Obj().Name()
|
|
entity, ok := entityMap[ifaceName]
|
|
if !ok {
|
|
log.Fatalf("unknown embedded interface %q — add it to entityMap", ifaceName)
|
|
}
|
|
iface := named.Underlying().(*types.Interface)
|
|
for j := 0; j < iface.NumMethods(); j++ {
|
|
methodEntity[iface.Method(j).Name()] = entity
|
|
}
|
|
}
|
|
|
|
// Track imports needed by the generated code.
|
|
imports := map[string]string{} // path → alias (empty = no alias)
|
|
|
|
// qualifier returns the package qualifier for types.TypeString. It also
|
|
// records each referenced package so we can emit the right imports.
|
|
qualifier := func(pkg *types.Package) string {
|
|
path := pkg.Path()
|
|
name := pkg.Name()
|
|
switch path {
|
|
case "git.happydns.org/happyDomain/model":
|
|
imports[path] = "happydns"
|
|
return "happydns"
|
|
default:
|
|
imports[path] = ""
|
|
return name
|
|
}
|
|
}
|
|
|
|
type methodInfo struct {
|
|
Name string
|
|
Entity string
|
|
Operation string
|
|
Skip bool // passthrough without observe
|
|
Sig *types.Signature
|
|
}
|
|
|
|
var methods []methodInfo
|
|
for i := 0; i < storageIface.NumMethods(); i++ {
|
|
m := storageIface.Method(i)
|
|
name := m.Name()
|
|
sig := m.Type().(*types.Signature)
|
|
|
|
if skipMethods[name] {
|
|
methods = append(methods, methodInfo{Name: name, Skip: true, Sig: sig})
|
|
continue
|
|
}
|
|
|
|
entity, ok := methodEntity[name]
|
|
if !ok {
|
|
log.Fatalf("method %q has no entity mapping (not in any embedded interface?)", name)
|
|
}
|
|
|
|
op := operationOverrides[name]
|
|
if op == "" {
|
|
op = inferOperation(name)
|
|
}
|
|
|
|
methods = append(methods, methodInfo{
|
|
Name: name,
|
|
Entity: entity,
|
|
Operation: op,
|
|
Sig: sig,
|
|
})
|
|
}
|
|
|
|
// Pre-resolve all type strings so the qualifier captures all needed imports.
|
|
type renderedMethod struct {
|
|
info methodInfo
|
|
params string // "p1 T1, p2 T2"
|
|
results string // "(r1 R1, r2 R2)"
|
|
callArgs string // "p1, p2"
|
|
retNames []string
|
|
hasErr bool
|
|
}
|
|
var rendered []renderedMethod
|
|
for _, m := range methods {
|
|
rm := renderedMethod{info: m}
|
|
rm.params = renderParams(m.Sig.Params(), qualifier)
|
|
rm.results = renderResults(m.Sig.Results(), qualifier, m.Skip)
|
|
rm.callArgs = renderCallArgs(m.Sig.Params())
|
|
rm.retNames, rm.hasErr = resultNames(m.Sig.Results(), m.Skip)
|
|
rendered = append(rendered, rm)
|
|
}
|
|
|
|
// Generate code. Imports are written after the qualifier has been called
|
|
// for every method signature, so the imports map is fully populated.
|
|
var buf bytes.Buffer
|
|
buf.WriteString(`// Code generated by go run tools/gen_instrumented_storage.go; DO NOT EDIT.
|
|
|
|
package app
|
|
|
|
import (
|
|
"time"
|
|
|
|
"git.happydns.org/happyDomain/internal/metrics"
|
|
"git.happydns.org/happyDomain/internal/storage"
|
|
`)
|
|
extraImports := map[string]string{}
|
|
for path, alias := range imports {
|
|
switch path {
|
|
case "time", "git.happydns.org/happyDomain/internal/metrics", "git.happydns.org/happyDomain/internal/storage":
|
|
continue
|
|
default:
|
|
extraImports[path] = alias
|
|
}
|
|
}
|
|
paths := make([]string, 0, len(extraImports))
|
|
for p := range extraImports {
|
|
paths = append(paths, p)
|
|
}
|
|
sort.Strings(paths)
|
|
for _, p := range paths {
|
|
alias := extraImports[p]
|
|
if alias != "" {
|
|
fmt.Fprintf(&buf, "\t%s %q\n", alias, p)
|
|
} else {
|
|
fmt.Fprintf(&buf, "\t%q\n", p)
|
|
}
|
|
}
|
|
buf.WriteString(")\n\n")
|
|
|
|
buf.WriteString(`// instrumentedStorage wraps a storage.Storage to record Prometheus metrics for
|
|
// every operation.
|
|
type instrumentedStorage struct {
|
|
inner storage.Storage
|
|
}
|
|
|
|
// newInstrumentedStorage wraps the given Storage with metrics instrumentation.
|
|
func newInstrumentedStorage(s storage.Storage) storage.Storage {
|
|
return &instrumentedStorage{inner: s}
|
|
}
|
|
|
|
// observe starts a timer and returns a closure that, when called with a
|
|
// pointer to the named return error, records the operation outcome. Use as:
|
|
//
|
|
// defer observe("get", "user")(&err)
|
|
//
|
|
// The closure reads *err at defer-execution time, so it captures the final
|
|
// value of the named return.
|
|
func observe(operation, entity string) func(err *error) {
|
|
start := time.Now()
|
|
return func(err *error) {
|
|
status := "success"
|
|
if *err != nil {
|
|
status = "error"
|
|
}
|
|
metrics.StorageOperationsTotal.WithLabelValues(operation, entity, status).Inc()
|
|
metrics.StorageOperationDuration.WithLabelValues(operation, entity).Observe(time.Since(start).Seconds())
|
|
}
|
|
}
|
|
|
|
`)
|
|
|
|
// Write method implementations.
|
|
for _, rm := range rendered {
|
|
m := rm.info
|
|
if m.Skip {
|
|
// Passthrough: one-liner.
|
|
retType := types.TypeString(m.Sig.Results().At(0).Type(), qualifier)
|
|
if m.Sig.Results().Len() == 0 {
|
|
fmt.Fprintf(&buf, "func (s *instrumentedStorage) %s(%s) { s.inner.%s(%s) }\n\n",
|
|
m.Name, rm.params, m.Name, rm.callArgs)
|
|
} else if m.Sig.Results().Len() == 1 {
|
|
fmt.Fprintf(&buf, "func (s *instrumentedStorage) %s(%s) %s { return s.inner.%s(%s) }\n\n",
|
|
m.Name, rm.params, retType, m.Name, rm.callArgs)
|
|
} else {
|
|
fmt.Fprintf(&buf, "func (s *instrumentedStorage) %s(%s) %s { return s.inner.%s(%s) }\n\n",
|
|
m.Name, rm.params, rm.results, m.Name, rm.callArgs)
|
|
}
|
|
continue
|
|
}
|
|
|
|
fmt.Fprintf(&buf, "func (s *instrumentedStorage) %s(%s) %s {\n",
|
|
m.Name, rm.params, rm.results)
|
|
fmt.Fprintf(&buf, "\tdefer observe(%q, %q)(&err)\n", m.Operation, m.Entity)
|
|
fmt.Fprintf(&buf, "\treturn s.inner.%s(%s)\n", m.Name, rm.callArgs)
|
|
buf.WriteString("}\n\n")
|
|
}
|
|
|
|
// Format the generated code.
|
|
formatted, err := format.Source(buf.Bytes())
|
|
if err != nil {
|
|
// Write unformatted for debugging.
|
|
os.WriteFile("internal/app/instrumented_storage_generated.go", buf.Bytes(), 0644)
|
|
log.Fatalf("gofmt failed: %v", err)
|
|
}
|
|
|
|
if err := os.WriteFile("internal/app/instrumented_storage_generated.go", formatted, 0644); err != nil {
|
|
log.Fatalf("writing output: %v", err)
|
|
}
|
|
log.Println("wrote internal/app/instrumented_storage_generated.go")
|
|
}
|
|
|
|
// inferOperation derives the Prometheus operation label from a method name
|
|
// using prefix matching.
|
|
func inferOperation(name string) string {
|
|
prefixes := []struct {
|
|
prefix string
|
|
op string
|
|
}{
|
|
{"ListAll", "list"},
|
|
{"List", "list"},
|
|
{"Get", "get"},
|
|
{"Find", "get"},
|
|
{"Count", "count"},
|
|
{"Create", "create"},
|
|
{"Update", "update"},
|
|
{"Restore", "restore"},
|
|
{"Delete", "delete"},
|
|
{"Clear", "delete"},
|
|
{"Set", "set"},
|
|
{"Put", "put"},
|
|
{"Tidy", "tidy"},
|
|
}
|
|
for _, p := range prefixes {
|
|
if strings.HasPrefix(name, p.prefix) {
|
|
return p.op
|
|
}
|
|
}
|
|
log.Fatalf("cannot infer operation for method %q — add it to operationOverrides", name)
|
|
return ""
|
|
}
|
|
|
|
// renderParams renders the parameter list of a signature as "(name Type, ...)" for use in
|
|
// a method declaration.
|
|
func renderParams(params *types.Tuple, qual types.Qualifier) string {
|
|
if params.Len() == 0 {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
for i := 0; i < params.Len(); i++ {
|
|
p := params.At(i)
|
|
parts = append(parts, fmt.Sprintf("%s %s", p.Name(), types.TypeString(p.Type(), qual)))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// renderResults renders the result list with named returns. For instrumented
|
|
// methods, the last result (error) is always named "err". Other results get
|
|
// placeholder names (ret, ret2, ...) to enable `defer observe(...)(&err)`.
|
|
func renderResults(results *types.Tuple, qual types.Qualifier, skip bool) string {
|
|
if results.Len() == 0 {
|
|
return ""
|
|
}
|
|
if skip {
|
|
// For skipped methods, use the raw type list.
|
|
if results.Len() == 1 {
|
|
return types.TypeString(results.At(0).Type(), qual)
|
|
}
|
|
var parts []string
|
|
for i := 0; i < results.Len(); i++ {
|
|
parts = append(parts, types.TypeString(results.At(i).Type(), qual))
|
|
}
|
|
return "(" + strings.Join(parts, ", ") + ")"
|
|
}
|
|
|
|
names, _ := resultNames(results, skip)
|
|
var parts []string
|
|
for i := 0; i < results.Len(); i++ {
|
|
parts = append(parts, fmt.Sprintf("%s %s", names[i], types.TypeString(results.At(i).Type(), qual)))
|
|
}
|
|
return "(" + strings.Join(parts, ", ") + ")"
|
|
}
|
|
|
|
// resultNames returns synthetic names for each result variable. The last
|
|
// error-typed result is always "err"; others get "ret", "ret2", etc.
|
|
func resultNames(results *types.Tuple, skip bool) ([]string, bool) {
|
|
names := make([]string, results.Len())
|
|
hasErr := false
|
|
retIdx := 0
|
|
for i := 0; i < results.Len(); i++ {
|
|
r := results.At(i)
|
|
if i == results.Len()-1 && r.Type().String() == "error" {
|
|
names[i] = "err"
|
|
hasErr = true
|
|
} else {
|
|
// Use the original name if present, otherwise generate one.
|
|
if r.Name() != "" && r.Name() != "_" {
|
|
names[i] = r.Name()
|
|
} else if retIdx == 0 {
|
|
names[i] = "ret"
|
|
retIdx++
|
|
} else {
|
|
names[i] = fmt.Sprintf("ret%d", retIdx+1)
|
|
retIdx++
|
|
}
|
|
}
|
|
}
|
|
return names, hasErr
|
|
}
|
|
|
|
// renderCallArgs renders just the argument names for forwarding to s.inner.
|
|
func renderCallArgs(params *types.Tuple) string {
|
|
if params.Len() == 0 {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
for i := 0; i < params.Len(); i++ {
|
|
parts = append(parts, params.At(i).Name())
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|