Compare commits
50 commits
e4abb59e83
...
b1d1353b93
| Author | SHA1 | Date | |
|---|---|---|---|
| b1d1353b93 | |||
| 4f20a2ff06 | |||
| 57de739f80 | |||
| e857b1fb99 | |||
| 504660367e | |||
| 35d4d84004 | |||
| 1ca806852e | |||
| 7899b2f0e8 | |||
| 5660003311 | |||
| 7ac13175c6 | |||
| b4c6492936 | |||
| b7beefed3f | |||
| 5de221411e | |||
| 6555df1f4d | |||
| 26bbdfdcc4 | |||
| 313434f883 | |||
| a531a6ef66 | |||
| 8a546a8b41 | |||
| e0dc19614f | |||
| 561d885c6f | |||
| f8e1633a29 | |||
| 166c7e864c | |||
| 97d9e2872f | |||
| 030ff6dc61 | |||
| 6aa25c74c0 | |||
| 8876b3972a | |||
| 9bd11fff4c | |||
| ec1ae64e4d | |||
| ce925af579 | |||
| 2402c61455 | |||
| 4ce6728a87 | |||
| 524e83d056 | |||
| 98642bf7e7 | |||
| 33ef6b60e7 | |||
| 439fe2a2e9 | |||
| fb02331f41 | |||
| 4e034b0135 | |||
| 322ade2881 | |||
| 6ed3b3f6ed | |||
| b37ed1d349 | |||
| 7a4de13ac6 | |||
| ee560a699d | |||
| ee8e7b322d | |||
| 4bee15e684 | |||
| b18f3d737e | |||
| dc8143628a | |||
| 76af934782 | |||
| 71b0c7fdaf | |||
| 8a826b1e8f | |||
| 07d4c244d1 |
229 changed files with 31058 additions and 419 deletions
256
checkers/domain_contact.go
Normal file
256
checkers/domain_contact.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
// 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/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// redactedPatterns is the list of substrings that, when found in a contact
|
||||
// field, indicate the data is privacy-protected rather than meaningful.
|
||||
var redactedPatterns = []string{
|
||||
"redacted",
|
||||
"privacy",
|
||||
"not disclosed",
|
||||
"whoisguard",
|
||||
"withheld",
|
||||
"data protected",
|
||||
"contact privacy",
|
||||
}
|
||||
|
||||
// domainContactRule compares the registered domain contacts (registrant,
|
||||
// admin, tech) against user-supplied expected values, with redaction
|
||||
// detection for privacy-protected domains.
|
||||
type domainContactRule struct{}
|
||||
|
||||
func (r *domainContactRule) Name() string {
|
||||
return "domain_contact_check"
|
||||
}
|
||||
|
||||
func (r *domainContactRule) Description() string {
|
||||
return "Verifies that domain contacts (name/organization/email) match expected values"
|
||||
}
|
||||
|
||||
// validRoles enumerates the contact roles supported by the WHOIS observation.
|
||||
var validRoles = map[string]bool{
|
||||
"registrant": true,
|
||||
"admin": true,
|
||||
"tech": true,
|
||||
}
|
||||
|
||||
func (r *domainContactRule) ValidateOptions(opts happydns.CheckerOptions) error {
|
||||
for _, key := range []string{"expectedName", "expectedOrganization", "expectedEmail", "checkRoles"} {
|
||||
if v, ok := opts[key]; ok {
|
||||
if _, ok := v.(string); !ok {
|
||||
return fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := opts["checkRoles"].(string); ok && v != "" {
|
||||
hasOne := false
|
||||
for _, p := range strings.Split(v, ",") {
|
||||
role := strings.TrimSpace(p)
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
if !validRoles[role] {
|
||||
return fmt.Errorf("checkRoles: unknown role %q (allowed: registrant, admin, tech)", role)
|
||||
}
|
||||
hasOne = true
|
||||
}
|
||||
if !hasOne {
|
||||
return fmt.Errorf("checkRoles must contain at least one role")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *domainContactRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
|
||||
var whois WHOISData
|
||||
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusError,
|
||||
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
|
||||
Code: "contact_error",
|
||||
}
|
||||
}
|
||||
|
||||
expectedName, _ := opts["expectedName"].(string)
|
||||
expectedOrg, _ := opts["expectedOrganization"].(string)
|
||||
expectedEmail, _ := opts["expectedEmail"].(string)
|
||||
|
||||
if expectedName == "" && expectedOrg == "" && expectedEmail == "" {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusUnknown,
|
||||
Message: "No expected contact values configured",
|
||||
Code: "contact_skipped",
|
||||
}
|
||||
}
|
||||
|
||||
checkRolesStr := "registrant"
|
||||
if v, ok := opts["checkRoles"].(string); ok && v != "" {
|
||||
checkRolesStr = v
|
||||
}
|
||||
|
||||
var roles []string
|
||||
for s := range strings.SplitSeq(checkRolesStr, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
roles = append(roles, s)
|
||||
}
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusUnknown,
|
||||
Message: "No contact roles to check",
|
||||
Code: "contact_skipped",
|
||||
}
|
||||
}
|
||||
|
||||
worst := happydns.StatusOK
|
||||
var lines []string
|
||||
|
||||
for _, role := range roles {
|
||||
contact, found := whois.Contacts[role]
|
||||
if !found || contact == nil {
|
||||
lines = append(lines, fmt.Sprintf("%s: contact not found", role))
|
||||
worst = worseStatus(worst, happydns.StatusWarn)
|
||||
continue
|
||||
}
|
||||
|
||||
if isRedacted(contact) {
|
||||
lines = append(lines, fmt.Sprintf("%s: contact info is redacted/private", role))
|
||||
worst = worseStatus(worst, happydns.StatusInfo)
|
||||
continue
|
||||
}
|
||||
|
||||
var mismatches []string
|
||||
if expectedName != "" && !strings.EqualFold(expectedName, contact.Name) {
|
||||
mismatches = append(mismatches, fmt.Sprintf("name: got %q, expected %q", contact.Name, expectedName))
|
||||
}
|
||||
if expectedOrg != "" && !strings.EqualFold(expectedOrg, contact.Organization) {
|
||||
mismatches = append(mismatches, fmt.Sprintf("organization: got %q, expected %q", contact.Organization, expectedOrg))
|
||||
}
|
||||
if expectedEmail != "" && !strings.EqualFold(expectedEmail, contact.Email) {
|
||||
mismatches = append(mismatches, fmt.Sprintf("email: got %q, expected %q", contact.Email, expectedEmail))
|
||||
}
|
||||
|
||||
if len(mismatches) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("%s: %s", role, strings.Join(mismatches, ", ")))
|
||||
worst = worseStatus(worst, happydns.StatusWarn)
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("%s: contact info matches", role))
|
||||
}
|
||||
}
|
||||
|
||||
return happydns.CheckState{
|
||||
Status: worst,
|
||||
Message: strings.Join(lines, "; "),
|
||||
Code: "contact_result",
|
||||
}
|
||||
}
|
||||
|
||||
// isRedacted reports whether a contact's fields look privacy-protected.
|
||||
func isRedacted(c *happydns.ContactInfo) bool {
|
||||
for _, field := range []string{c.Name, c.Organization, c.Email} {
|
||||
lower := strings.ToLower(field)
|
||||
for _, pattern := range redactedPatterns {
|
||||
if strings.Contains(lower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// worseStatus returns the more severe of two statuses. The SDK orders
|
||||
// statuses as Unknown < OK < Info < Warn < Crit < Error, so the higher
|
||||
// numeric value is the more severe one.
|
||||
func worseStatus(a, b happydns.Status) happydns.Status {
|
||||
if b > a {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func init() {
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "domain_contact",
|
||||
Name: "Domain Contact Consistency",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
DomainOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domainName",
|
||||
Type: "string",
|
||||
AutoFill: happydns.AutoFillDomainName,
|
||||
Hide: true,
|
||||
},
|
||||
{
|
||||
Id: "expectedName",
|
||||
Type: "string",
|
||||
Label: "Expected registrant name",
|
||||
Description: "If set, the configured roles must report this exact name (case-insensitive).",
|
||||
},
|
||||
{
|
||||
Id: "expectedOrganization",
|
||||
Type: "string",
|
||||
Label: "Expected organization",
|
||||
Description: "If set, the configured roles must report this exact organization (case-insensitive).",
|
||||
},
|
||||
{
|
||||
Id: "expectedEmail",
|
||||
Type: "string",
|
||||
Label: "Expected email",
|
||||
Description: "If set, the configured roles must report this exact email (case-insensitive).",
|
||||
},
|
||||
{
|
||||
Id: "checkRoles",
|
||||
Type: "string",
|
||||
Label: "Contact roles to check",
|
||||
Description: "Comma-separated list of roles among: registrant, admin, tech.",
|
||||
Default: "registrant",
|
||||
Placeholder: "registrant",
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&domainContactRule{},
|
||||
},
|
||||
Interval: &happydns.CheckIntervalSpec{
|
||||
Min: 1 * time.Hour,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 24 * time.Hour,
|
||||
},
|
||||
})
|
||||
}
|
||||
211
checkers/domain_contact_test.go
Normal file
211
checkers/domain_contact_test.go
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// 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/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func contactsFixture() map[string]*happydns.ContactInfo {
|
||||
return map[string]*happydns.ContactInfo{
|
||||
"registrant": {
|
||||
Name: "Alice Example",
|
||||
Organization: "Example Inc",
|
||||
Email: "alice@example.com",
|
||||
},
|
||||
"admin": {
|
||||
Name: "REDACTED FOR PRIVACY",
|
||||
Organization: "REDACTED FOR PRIVACY",
|
||||
Email: "redacted@example.com",
|
||||
},
|
||||
"tech": {
|
||||
Name: "Bob Tech",
|
||||
Organization: "Example Inc",
|
||||
Email: "bob@example.com",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainContactRule_Evaluate(t *testing.T) {
|
||||
rule := &domainContactRule{}
|
||||
obs := newWhoisObs(&WHOISData{Contacts: contactsFixture()})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts happydns.CheckerOptions
|
||||
want happydns.Status
|
||||
code string
|
||||
}{
|
||||
{
|
||||
name: "no expectations",
|
||||
opts: nil,
|
||||
want: happydns.StatusUnknown,
|
||||
code: "contact_skipped",
|
||||
},
|
||||
{
|
||||
name: "registrant matches",
|
||||
opts: happydns.CheckerOptions{
|
||||
"expectedName": "Alice Example",
|
||||
"expectedEmail": "alice@example.com",
|
||||
},
|
||||
want: happydns.StatusOK,
|
||||
code: "contact_result",
|
||||
},
|
||||
{
|
||||
name: "registrant name mismatch",
|
||||
opts: happydns.CheckerOptions{
|
||||
"expectedName": "Carol Other",
|
||||
},
|
||||
want: happydns.StatusWarn,
|
||||
code: "contact_result",
|
||||
},
|
||||
{
|
||||
name: "admin role is redacted",
|
||||
opts: happydns.CheckerOptions{
|
||||
"checkRoles": "admin",
|
||||
"expectedName": "Alice Example",
|
||||
},
|
||||
want: happydns.StatusInfo,
|
||||
code: "contact_result",
|
||||
},
|
||||
{
|
||||
name: "missing role",
|
||||
opts: happydns.CheckerOptions{
|
||||
"checkRoles": "billing",
|
||||
"expectedName": "Alice Example",
|
||||
},
|
||||
want: happydns.StatusWarn,
|
||||
code: "contact_result",
|
||||
},
|
||||
{
|
||||
name: "multi-role mixed (worst wins)",
|
||||
opts: happydns.CheckerOptions{
|
||||
"checkRoles": "registrant,admin",
|
||||
"expectedName": "Alice Example",
|
||||
},
|
||||
// admin is redacted (Info) — Info is worse than OK from registrant.
|
||||
want: happydns.StatusInfo,
|
||||
code: "contact_result",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st := rule.Evaluate(context.Background(), obs, tc.opts)
|
||||
if st.Status != tc.want {
|
||||
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
|
||||
}
|
||||
if st.Code != tc.code {
|
||||
t.Errorf("code = %q, want %q", st.Code, tc.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainContactRule_EvaluateObservationError(t *testing.T) {
|
||||
rule := &domainContactRule{}
|
||||
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("nope")}
|
||||
st := rule.Evaluate(context.Background(), obs, happydns.CheckerOptions{"expectedName": "x"})
|
||||
if st.Status != happydns.StatusError || st.Code != "contact_error" {
|
||||
t.Errorf("got %v / %q", st.Status, st.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainContactRule_ValidateOptions(t *testing.T) {
|
||||
rule := &domainContactRule{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts happydns.CheckerOptions
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", nil, false},
|
||||
{"all valid", happydns.CheckerOptions{
|
||||
"expectedName": "x",
|
||||
"checkRoles": "registrant,tech",
|
||||
}, false},
|
||||
{"unknown role", happydns.CheckerOptions{"checkRoles": "billing"}, true},
|
||||
{"empty roles after split", happydns.CheckerOptions{"checkRoles": " , , "}, true},
|
||||
{"wrong type", happydns.CheckerOptions{"expectedEmail": 42}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := rule.ValidateOptions(tc.opts)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("err=%v wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRedacted(t *testing.T) {
|
||||
cases := []struct {
|
||||
c *happydns.ContactInfo
|
||||
want bool
|
||||
}{
|
||||
{&happydns.ContactInfo{Name: "REDACTED FOR PRIVACY"}, true},
|
||||
{&happydns.ContactInfo{Organization: "Contact Privacy Inc"}, true},
|
||||
{&happydns.ContactInfo{Email: "withheld@example.com"}, true},
|
||||
{&happydns.ContactInfo{Name: "Alice", Email: "alice@example.com"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isRedacted(tc.c); got != tc.want {
|
||||
t.Errorf("isRedacted(%+v) = %v, want %v", tc.c, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorseStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b, want happydns.Status
|
||||
}{
|
||||
{happydns.StatusOK, happydns.StatusInfo, happydns.StatusInfo},
|
||||
{happydns.StatusInfo, happydns.StatusWarn, happydns.StatusWarn},
|
||||
{happydns.StatusCrit, happydns.StatusWarn, happydns.StatusCrit},
|
||||
{happydns.StatusOK, happydns.StatusUnknown, happydns.StatusOK},
|
||||
{happydns.StatusError, happydns.StatusCrit, happydns.StatusError},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := worseStatus(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("worseStatus(%v,%v) = %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: WHOIS data with no contacts must not panic when a role is requested.
|
||||
func TestDomainContactRule_NilContacts(t *testing.T) {
|
||||
rule := &domainContactRule{}
|
||||
obs := newWhoisObs(&WHOISData{})
|
||||
st := rule.Evaluate(context.Background(), obs, happydns.CheckerOptions{
|
||||
"expectedName": "Alice",
|
||||
})
|
||||
if st.Status != happydns.StatusWarn {
|
||||
t.Errorf("status = %v, want Warn", st.Status)
|
||||
}
|
||||
if !strings.Contains(st.Message, "not found") {
|
||||
t.Errorf("message = %q", st.Message)
|
||||
}
|
||||
}
|
||||
237
checkers/domain_expiry.go
Normal file
237
checkers/domain_expiry.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
"git.happydns.org/happyDomain/pkg/domaininfo"
|
||||
)
|
||||
|
||||
const (
|
||||
// ObservationKeyWhois is the observation key for WHOIS / domain expiry data.
|
||||
ObservationKeyWhois happydns.ObservationKey = "whois"
|
||||
|
||||
defaultWarningDays = 30
|
||||
defaultCriticalDays = 7
|
||||
)
|
||||
|
||||
// WHOISData represents WHOIS observation data.
|
||||
type WHOISData struct {
|
||||
ExpiryDate time.Time `json:"expiryDate"`
|
||||
Registrar string `json:"registrar"`
|
||||
Contacts map[string]*happydns.ContactInfo `json:"contacts,omitempty"`
|
||||
Status []string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// whoisProvider is a placeholder WHOIS observation provider.
|
||||
type whoisProvider struct{}
|
||||
|
||||
func (p *whoisProvider) Key() happydns.ObservationKey {
|
||||
return ObservationKeyWhois
|
||||
}
|
||||
|
||||
func (p *whoisProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
domainName, _ := opts["domainName"].(string)
|
||||
if domainName == "" {
|
||||
return nil, fmt.Errorf("domainName is required")
|
||||
}
|
||||
|
||||
info, err := domaininfo.GetDomainInfo(ctx, happydns.Origin(domainName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve domain info: %w", err)
|
||||
}
|
||||
|
||||
if info.ExpirationDate == nil {
|
||||
return nil, fmt.Errorf("expiration date not available for %s", domainName)
|
||||
}
|
||||
|
||||
registrar := info.Registrar
|
||||
if registrar == "" {
|
||||
registrar = "Unknown"
|
||||
}
|
||||
|
||||
return &WHOISData{
|
||||
ExpiryDate: *info.ExpirationDate,
|
||||
Registrar: registrar,
|
||||
Contacts: info.Contacts,
|
||||
Status: info.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExtractMetrics implements happydns.CheckerMetricsReporter.
|
||||
func (p *whoisProvider) ExtractMetrics(raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, error) {
|
||||
var data WHOISData
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
daysRemaining := data.ExpiryDate.Sub(collectedAt).Hours() / 24
|
||||
return []happydns.CheckMetric{{
|
||||
Name: "domain_expiry_days_remaining",
|
||||
Value: daysRemaining,
|
||||
Unit: "days",
|
||||
Labels: map[string]string{"registrar": data.Registrar},
|
||||
Timestamp: collectedAt,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// domainExpiryRule checks whether a domain is nearing expiration.
|
||||
type domainExpiryRule struct{}
|
||||
|
||||
func (r *domainExpiryRule) Name() string {
|
||||
return "domain_expiry_check"
|
||||
}
|
||||
|
||||
func (r *domainExpiryRule) Description() string {
|
||||
return "Checks whether a domain name is nearing its expiration date"
|
||||
}
|
||||
|
||||
func (r *domainExpiryRule) ValidateOptions(opts happydns.CheckerOptions) error {
|
||||
warningDays := float64(defaultWarningDays)
|
||||
criticalDays := float64(defaultCriticalDays)
|
||||
|
||||
if v, ok := opts["warning_days"]; ok {
|
||||
d, ok := v.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("warning_days must be a number")
|
||||
}
|
||||
if d <= 0 {
|
||||
return fmt.Errorf("warning_days must be positive")
|
||||
}
|
||||
warningDays = d
|
||||
}
|
||||
if v, ok := opts["critical_days"]; ok {
|
||||
d, ok := v.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("critical_days must be a number")
|
||||
}
|
||||
if d <= 0 {
|
||||
return fmt.Errorf("critical_days must be positive")
|
||||
}
|
||||
criticalDays = d
|
||||
}
|
||||
|
||||
if criticalDays >= warningDays {
|
||||
return fmt.Errorf("critical_days (%v) must be less than warning_days (%v)", criticalDays, warningDays)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *domainExpiryRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
|
||||
var whois WHOISData
|
||||
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusError,
|
||||
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
|
||||
Code: "whois_error",
|
||||
}
|
||||
}
|
||||
|
||||
// Read thresholds from options with defaults.
|
||||
warningDays := sdk.GetIntOption(opts, "warning_days", defaultWarningDays)
|
||||
criticalDays := sdk.GetIntOption(opts, "critical_days", defaultCriticalDays)
|
||||
|
||||
daysRemaining := int(time.Until(whois.ExpiryDate).Hours() / 24)
|
||||
meta := map[string]any{"days_remaining": daysRemaining}
|
||||
|
||||
if daysRemaining <= criticalDays {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusCrit,
|
||||
Message: fmt.Sprintf("Domain expires in %d days", daysRemaining),
|
||||
Code: "expiry_critical",
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
if daysRemaining <= warningDays {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusWarn,
|
||||
Message: fmt.Sprintf("Domain expires in %d days", daysRemaining),
|
||||
Code: "expiry_warning",
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusOK,
|
||||
Message: fmt.Sprintf("Domain expires in %d days", daysRemaining),
|
||||
Code: "expiry_ok",
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(&whoisProvider{})
|
||||
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "domain_expiry",
|
||||
Name: "Domain Expiry",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
DomainOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domainName",
|
||||
Type: "string",
|
||||
AutoFill: happydns.AutoFillDomainName,
|
||||
Hide: true,
|
||||
},
|
||||
{
|
||||
Id: "warning_days",
|
||||
Type: "uint",
|
||||
Label: "Warning threshold (days)",
|
||||
Description: "Number of days before expiration to trigger a warning.",
|
||||
Default: defaultWarningDays,
|
||||
Placeholder: strconv.Itoa(defaultWarningDays),
|
||||
},
|
||||
{
|
||||
Id: "critical_days",
|
||||
Type: "uint",
|
||||
Label: "Critical threshold (days)",
|
||||
Description: "Number of days before expiration to trigger a critical alert.",
|
||||
Default: defaultCriticalDays,
|
||||
Placeholder: strconv.Itoa(defaultCriticalDays),
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&domainExpiryRule{},
|
||||
},
|
||||
Interval: &happydns.CheckIntervalSpec{
|
||||
Min: 12 * time.Hour,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 24 * time.Hour,
|
||||
},
|
||||
HasMetrics: true,
|
||||
})
|
||||
}
|
||||
136
checkers/domain_expiry_test.go
Normal file
136
checkers/domain_expiry_test.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// 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/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestDomainExpiryRule_Evaluate(t *testing.T) {
|
||||
rule := &domainExpiryRule{}
|
||||
now := time.Now()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
expiresIn time.Duration
|
||||
opts happydns.CheckerOptions
|
||||
want happydns.Status
|
||||
code string
|
||||
}{
|
||||
{"already expired", -5 * 24 * time.Hour, nil, happydns.StatusCrit, "expiry_critical"},
|
||||
{"critical default", 3 * 24 * time.Hour, nil, happydns.StatusCrit, "expiry_critical"},
|
||||
{"warning default", 15 * 24 * time.Hour, nil, happydns.StatusWarn, "expiry_warning"},
|
||||
{"ok default", 90 * 24 * time.Hour, nil, happydns.StatusOK, "expiry_ok"},
|
||||
{"ok with custom thresholds", 10 * 24 * time.Hour, happydns.CheckerOptions{
|
||||
"warning_days": float64(5),
|
||||
"critical_days": float64(2),
|
||||
}, happydns.StatusOK, "expiry_ok"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
obs := newWhoisObs(&WHOISData{
|
||||
ExpiryDate: now.Add(tc.expiresIn),
|
||||
Registrar: "Test Registrar",
|
||||
})
|
||||
st := rule.Evaluate(context.Background(), obs, tc.opts)
|
||||
if st.Status != tc.want {
|
||||
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
|
||||
}
|
||||
if st.Code != tc.code {
|
||||
t.Errorf("code = %q, want %q", st.Code, tc.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainExpiryRule_EvaluateObservationError(t *testing.T) {
|
||||
rule := &domainExpiryRule{}
|
||||
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("boom")}
|
||||
st := rule.Evaluate(context.Background(), obs, nil)
|
||||
if st.Status != happydns.StatusError {
|
||||
t.Fatalf("expected StatusError, got %v", st.Status)
|
||||
}
|
||||
if st.Code != "whois_error" {
|
||||
t.Errorf("code = %q, want whois_error", st.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainExpiryRule_ValidateOptions(t *testing.T) {
|
||||
rule := &domainExpiryRule{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts happydns.CheckerOptions
|
||||
wantErr bool
|
||||
}{
|
||||
{"defaults", nil, false},
|
||||
{"valid custom", happydns.CheckerOptions{"warning_days": 30.0, "critical_days": 7.0}, false},
|
||||
{"crit >= warn", happydns.CheckerOptions{"warning_days": 5.0, "critical_days": 5.0}, true},
|
||||
{"warn wrong type", happydns.CheckerOptions{"warning_days": "thirty"}, true},
|
||||
{"warn negative", happydns.CheckerOptions{"warning_days": -1.0}, true},
|
||||
{"crit negative", happydns.CheckerOptions{"critical_days": -3.0}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := rule.ValidateOptions(tc.opts)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("ValidateOptions err=%v, wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoisProvider_ExtractMetrics(t *testing.T) {
|
||||
p := &whoisProvider{}
|
||||
collected := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
data := WHOISData{
|
||||
ExpiryDate: collected.Add(10 * 24 * time.Hour),
|
||||
Registrar: "Acme",
|
||||
}
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metrics, err := p.ExtractMetrics(raw, collected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("expected 1 metric, got %d", len(metrics))
|
||||
}
|
||||
m := metrics[0]
|
||||
if m.Name != "domain_expiry_days_remaining" {
|
||||
t.Errorf("name = %q", m.Name)
|
||||
}
|
||||
if m.Value < 9.99 || m.Value > 10.01 {
|
||||
t.Errorf("value = %v, want ~10", m.Value)
|
||||
}
|
||||
if m.Labels["registrar"] != "Acme" {
|
||||
t.Errorf("registrar label = %q", m.Labels["registrar"])
|
||||
}
|
||||
}
|
||||
165
checkers/domain_lock.go
Normal file
165
checkers/domain_lock.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// 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/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const defaultRequiredLockStatuses = "clientTransferProhibited"
|
||||
|
||||
// domainLockRule verifies that a domain carries the expected EPP lock
|
||||
// statuses (e.g. clientTransferProhibited) as reported by RDAP/WHOIS.
|
||||
type domainLockRule struct{}
|
||||
|
||||
func (r *domainLockRule) Name() string {
|
||||
return "domain_lock_check"
|
||||
}
|
||||
|
||||
func (r *domainLockRule) Description() string {
|
||||
return "Verifies that a domain carries the expected EPP lock statuses (e.g. clientTransferProhibited)"
|
||||
}
|
||||
|
||||
func (r *domainLockRule) ValidateOptions(opts happydns.CheckerOptions) error {
|
||||
v, ok := opts["requiredStatuses"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("requiredStatuses must be a string")
|
||||
}
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("requiredStatuses must contain at least one EPP status code")
|
||||
}
|
||||
|
||||
func (r *domainLockRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
|
||||
var whois WHOISData
|
||||
if err := obs.Get(ctx, ObservationKeyWhois, &whois); err != nil {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusError,
|
||||
Message: fmt.Sprintf("Failed to get WHOIS data: %v", err),
|
||||
Code: "lock_error",
|
||||
}
|
||||
}
|
||||
|
||||
requiredStr := defaultRequiredLockStatuses
|
||||
if v, ok := opts["requiredStatuses"].(string); ok && v != "" {
|
||||
requiredStr = v
|
||||
}
|
||||
|
||||
var required []string
|
||||
for _, s := range strings.Split(requiredStr, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
required = append(required, s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(required) == 0 {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusUnknown,
|
||||
Message: "No required lock statuses configured",
|
||||
Code: "lock_skipped",
|
||||
}
|
||||
}
|
||||
|
||||
present := make(map[string]bool, len(whois.Status))
|
||||
for _, s := range whois.Status {
|
||||
present[strings.ToLower(s)] = true
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, req := range required {
|
||||
if !present[strings.ToLower(req)] {
|
||||
missing = append(missing, req)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusCrit,
|
||||
Message: fmt.Sprintf("Missing lock status: %s", strings.Join(missing, ", ")),
|
||||
Code: "lock_missing",
|
||||
Meta: map[string]any{
|
||||
"missing": missing,
|
||||
"present": whois.Status,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusOK,
|
||||
Message: fmt.Sprintf("All required statuses present: %s", strings.Join(required, ", ")),
|
||||
Code: "lock_ok",
|
||||
Meta: map[string]any{
|
||||
"required": required,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "domain_lock",
|
||||
Name: "Domain Lock Status",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
ObservationKeys: []happydns.ObservationKey{ObservationKeyWhois},
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
DomainOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domainName",
|
||||
Type: "string",
|
||||
AutoFill: happydns.AutoFillDomainName,
|
||||
Hide: true,
|
||||
},
|
||||
{
|
||||
Id: "requiredStatuses",
|
||||
Type: "string",
|
||||
Label: "Required lock statuses",
|
||||
Description: "Comma-separated list of EPP status codes that must be present on the domain (e.g. clientTransferProhibited, clientUpdateProhibited, clientDeleteProhibited).",
|
||||
Default: defaultRequiredLockStatuses,
|
||||
Placeholder: defaultRequiredLockStatuses,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&domainLockRule{},
|
||||
},
|
||||
Interval: &happydns.CheckIntervalSpec{
|
||||
Min: 1 * time.Hour,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 24 * time.Hour,
|
||||
},
|
||||
})
|
||||
}
|
||||
150
checkers/domain_lock_test.go
Normal file
150
checkers/domain_lock_test.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// 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/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestDomainLockRule_Evaluate(t *testing.T) {
|
||||
rule := &domainLockRule{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status []string
|
||||
opts happydns.CheckerOptions
|
||||
want happydns.Status
|
||||
code string
|
||||
}{
|
||||
{
|
||||
name: "default required present",
|
||||
status: []string{"clientTransferProhibited", "ok"},
|
||||
opts: nil,
|
||||
want: happydns.StatusOK,
|
||||
code: "lock_ok",
|
||||
},
|
||||
{
|
||||
name: "default required missing",
|
||||
status: []string{"ok"},
|
||||
opts: nil,
|
||||
want: happydns.StatusCrit,
|
||||
code: "lock_missing",
|
||||
},
|
||||
{
|
||||
name: "multiple required all present",
|
||||
status: []string{"clientTransferProhibited", "clientUpdateProhibited", "clientDeleteProhibited"},
|
||||
opts: happydns.CheckerOptions{
|
||||
"requiredStatuses": "clientTransferProhibited,clientUpdateProhibited,clientDeleteProhibited",
|
||||
},
|
||||
want: happydns.StatusOK,
|
||||
code: "lock_ok",
|
||||
},
|
||||
{
|
||||
name: "multiple required some missing",
|
||||
status: []string{"clientTransferProhibited"},
|
||||
opts: happydns.CheckerOptions{
|
||||
"requiredStatuses": "clientTransferProhibited,clientUpdateProhibited",
|
||||
},
|
||||
want: happydns.StatusCrit,
|
||||
code: "lock_missing",
|
||||
},
|
||||
{
|
||||
name: "case insensitive match",
|
||||
status: []string{"clienttransferprohibited"},
|
||||
opts: nil,
|
||||
want: happydns.StatusOK,
|
||||
code: "lock_ok",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
obs := newWhoisObs(&WHOISData{Status: tc.status})
|
||||
st := rule.Evaluate(context.Background(), obs, tc.opts)
|
||||
if st.Status != tc.want {
|
||||
t.Errorf("status = %v, want %v (msg=%q)", st.Status, tc.want, st.Message)
|
||||
}
|
||||
if st.Code != tc.code {
|
||||
t.Errorf("code = %q, want %q", st.Code, tc.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: WHOIS data with nil/empty status must report missing locks, not panic.
|
||||
func TestDomainLockRule_NilStatus(t *testing.T) {
|
||||
rule := &domainLockRule{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status []string
|
||||
}{
|
||||
{"nil status", nil},
|
||||
{"empty status", []string{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
obs := newWhoisObs(&WHOISData{Status: tc.status})
|
||||
st := rule.Evaluate(context.Background(), obs, nil)
|
||||
if st.Status != happydns.StatusCrit {
|
||||
t.Errorf("status = %v, want Crit", st.Status)
|
||||
}
|
||||
if st.Code != "lock_missing" {
|
||||
t.Errorf("code = %q, want lock_missing", st.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainLockRule_EvaluateObservationError(t *testing.T) {
|
||||
rule := &domainLockRule{}
|
||||
obs := &stubObservationGetter{key: ObservationKeyWhois, err: errString("nope")}
|
||||
st := rule.Evaluate(context.Background(), obs, nil)
|
||||
if st.Status != happydns.StatusError || st.Code != "lock_error" {
|
||||
t.Errorf("got %v / %q", st.Status, st.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainLockRule_ValidateOptions(t *testing.T) {
|
||||
rule := &domainLockRule{}
|
||||
cases := []struct {
|
||||
name string
|
||||
opts happydns.CheckerOptions
|
||||
wantErr bool
|
||||
}{
|
||||
{"default", nil, false},
|
||||
{"valid", happydns.CheckerOptions{"requiredStatuses": "clientTransferProhibited"}, false},
|
||||
{"empty after split", happydns.CheckerOptions{"requiredStatuses": " , , "}, true},
|
||||
{"wrong type", happydns.CheckerOptions{"requiredStatuses": 123}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := rule.ValidateOptions(tc.opts)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("err=%v wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
61
checkers/helpers_test.go
Normal file
61
checkers/helpers_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// 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/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// stubObservationGetter is a test helper that serves a single pre-built
|
||||
// observation, mimicking the SDK's mapObservationGetter (JSON round-trip).
|
||||
type stubObservationGetter struct {
|
||||
key happydns.ObservationKey
|
||||
data any
|
||||
err error
|
||||
}
|
||||
|
||||
func (g *stubObservationGetter) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
|
||||
if g.err != nil {
|
||||
return g.err
|
||||
}
|
||||
if key != g.key {
|
||||
return errNotFound
|
||||
}
|
||||
raw, err := json.Marshal(g.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
const errNotFound = errString("observation not available")
|
||||
|
||||
func newWhoisObs(d *WHOISData) *stubObservationGetter {
|
||||
return &stubObservationGetter{key: ObservationKeyWhois, data: d}
|
||||
}
|
||||
33
checkers/matrix_federation.go
Normal file
33
checkers/matrix_federation.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// 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/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
matrix "git.happydns.org/checker-matrix/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(matrix.Provider())
|
||||
// Not Externalizable checker as it already calls a HTTP API
|
||||
checker.RegisterChecker(matrix.Definition())
|
||||
}
|
||||
32
checkers/ns_restrictions.go
Normal file
32
checkers/ns_restrictions.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// 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/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
nsr "git.happydns.org/checker-ns-restrictions/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(nsr.Provider())
|
||||
checker.RegisterExternalizableChecker(nsr.Definition())
|
||||
}
|
||||
32
checkers/ping.go
Normal file
32
checkers/ping.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// 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/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
ping "git.happydns.org/checker-ping/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(ping.Provider())
|
||||
checker.RegisterExternalizableChecker(ping.Definition())
|
||||
}
|
||||
33
checkers/zonemaster.go
Normal file
33
checkers/zonemaster.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// 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/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
zonemaster "git.happydns.org/checker-zonemaster/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(zonemaster.Provider())
|
||||
// Not Externalizable checker as it already calls a HTTP API
|
||||
checker.RegisterChecker(zonemaster.Definition())
|
||||
}
|
||||
|
|
@ -26,13 +26,16 @@ import (
|
|||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/earthboundkid/versioninfo/v2"
|
||||
"github.com/fatih/color"
|
||||
|
||||
_ "git.happydns.org/happyDomain/checkers"
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/app"
|
||||
"git.happydns.org/happyDomain/internal/config"
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
_ "git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||
_ "git.happydns.org/happyDomain/internal/storage/leveldb"
|
||||
_ "git.happydns.org/happyDomain/internal/storage/oracle-nosql"
|
||||
|
|
@ -54,11 +57,19 @@ func main() {
|
|||
LastCommit: versioninfo.Revision,
|
||||
DirtyBuild: versioninfo.DirtyBuild,
|
||||
}
|
||||
v := Version
|
||||
if Version == "custom-build" {
|
||||
controller.HDVersion.Version = versioninfo.Short()
|
||||
v = versioninfo.Short()
|
||||
controller.HDVersion.Version = v
|
||||
} else {
|
||||
versioninfo.Version = Version
|
||||
}
|
||||
metrics.SetBuildInfo(
|
||||
v,
|
||||
versioninfo.Revision,
|
||||
versioninfo.LastCommit.UTC().Format(time.RFC3339),
|
||||
versioninfo.DirtyBuild,
|
||||
)
|
||||
|
||||
log.Println("This is happyDomain", versioninfo.Short())
|
||||
|
||||
|
|
|
|||
187
docs/checker-quotas.md
Normal file
187
docs/checker-quotas.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# Checker quotas and scheduling policy
|
||||
|
||||
happyDomain's checker subsystem runs scheduled DNS health checks on behalf of
|
||||
each user. To keep resource usage predictable on shared instances, the
|
||||
scheduler consults a per-user policy before every job and can throttle or
|
||||
skip executions based on the user's activity, explicit pause, or daily
|
||||
budget.
|
||||
|
||||
This document describes:
|
||||
|
||||
1. The three gates a scheduled check must pass through.
|
||||
2. How per-day budgets are counted and reset.
|
||||
3. How an administrator configures system-wide defaults.
|
||||
4. How per-user overrides work.
|
||||
5. Operational caveats (restart behaviour, manual triggers, UI indications).
|
||||
|
||||
> **Scope:** this document only covers the scheduler's user-level gate. It
|
||||
> does not cover retention (`--checker-retention-days`, see the janitor) nor
|
||||
> the per-checker `MinInterval` throttling.
|
||||
|
||||
---
|
||||
|
||||
## The three gates
|
||||
|
||||
Before each scheduled execution, the scheduler evaluates the job against a
|
||||
**user-level gate**. A job is dropped (and rescheduled for the next tick) if
|
||||
any of the following apply:
|
||||
|
||||
| Gate | Blocks when… | Source of truth |
|
||||
| --- | --- | --- |
|
||||
| Scheduling paused | `UserQuota.SchedulingPaused == true` | per-user only |
|
||||
| Inactivity pause | user has not logged in for N days | per-user, falls back to system default |
|
||||
| Daily budget | user has executed `MaxChecksPerDay` scheduled checks today | per-user, falls back to system default |
|
||||
|
||||
The first two gates ("policy layer") are cached for 5 minutes after a lookup
|
||||
so the scheduler hot path does not hit storage on every job pop. The cache
|
||||
is invalidated automatically whenever a user's quota or `LastSeen` changes
|
||||
(login, admin edit).
|
||||
|
||||
The daily budget gate is **not** cached: the counter changes on every
|
||||
successful execution and must be accurate.
|
||||
|
||||
## Daily budget
|
||||
|
||||
### How it is counted
|
||||
|
||||
- The counter is incremented **once** per successful call to
|
||||
`CreateExecution` by the scheduler. It is _not_ decremented if
|
||||
`RunExecution` later fails — a check that got as far as being recorded
|
||||
counts against the budget. This prevents users from burning capacity via
|
||||
engines that repeatedly error.
|
||||
- **Manual API triggers are counted by default**, and refused with
|
||||
`HTTP 429 Too Many Requests` once the user is over budget. This applies
|
||||
to "Run now" in the UI and to `POST
|
||||
/api/domains/.../checkers/.../executions`. The behaviour is controlled
|
||||
by `--checker-count-manual-triggers` (default `true`); set it to
|
||||
`false` to restore the legacy bypass — in that mode, manual triggers
|
||||
are neither checked nor incremented.
|
||||
|
||||
### When it resets
|
||||
|
||||
- The counter resets at **00:00 UTC** every day. This is intentionally
|
||||
independent of the user's local timezone so the behaviour is consistent
|
||||
across deployments. A user in UTC+8 will see their counter flip
|
||||
mid-afternoon local time.
|
||||
- The counter is kept **in memory only**. A process restart resets it to
|
||||
zero for every user. In a rolling-restart environment, this effectively
|
||||
grants a partial top-up; operators should size their defaults with this
|
||||
in mind.
|
||||
|
||||
### Interval-aware throttling
|
||||
|
||||
When the budget is at least **80 % consumed**, the scheduler starts skipping
|
||||
jobs whose configured interval is shorter than **4 hours**. Jobs with a
|
||||
longer interval continue to run until the hard limit is reached.
|
||||
|
||||
The goal is to prevent rare-but-important checks (for example, a daily
|
||||
DNSSEC sanity check) from being starved by frequent low-value pings (for
|
||||
example, a 1-minute probe). Put bluntly: if you are going to run out of
|
||||
budget anyway, spend the last 20 % on the checks you would most miss.
|
||||
|
||||
Constants (not currently configurable at runtime):
|
||||
|
||||
- `throttleFillRatio` = 0.8
|
||||
- `throttleShortIntervalCutoff` = 4 h
|
||||
|
||||
Both live in `internal/usecase/checker/user_gate.go`.
|
||||
|
||||
### UI signalling
|
||||
|
||||
Planned (not-yet-run) executions returned by the "upcoming checks" API are
|
||||
marked with status `ExecutionRateLimited` whenever the target user is over
|
||||
budget. This lets the frontend show the user that scheduled work is on hold
|
||||
until tomorrow, distinct from a merely-pending job.
|
||||
|
||||
Only _synthetic_ planned entries carry this status; it is never persisted
|
||||
on a real execution record.
|
||||
|
||||
## System-wide configuration
|
||||
|
||||
| CLI flag | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--checker-inactivity-pause-days` | `90` | Stop scheduling for users inactive for this many days. `0` disables the inactivity gate. |
|
||||
| `--checker-max-checks-per-day` | `0` | Cap on scheduled executions per user per day. `0` means unlimited. Counter resets at 00:00 UTC. |
|
||||
| `--checker-count-manual-triggers` | `true` | When `true`, manual triggers count against `MaxChecksPerDay` and are refused with HTTP 429 once exhausted. When `false`, manual triggers bypass the quota entirely. No effect when `MaxChecksPerDay` is `0` (unlimited). |
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
happyDomain \
|
||||
--checker-max-checks-per-day=2000 \
|
||||
--checker-inactivity-pause-days=60 \
|
||||
--checker-count-manual-triggers=true
|
||||
```
|
||||
|
||||
Values set via the CLI are read at startup. Changing them requires a
|
||||
restart of the happyDomain process.
|
||||
|
||||
## Per-user overrides
|
||||
|
||||
Administrators can override the system defaults for individual users via
|
||||
the admin API (`UserQuota`):
|
||||
|
||||
```json
|
||||
{
|
||||
"max_checks_per_day": 500,
|
||||
"inactivity_pause_days": 14,
|
||||
"scheduling_paused": false
|
||||
}
|
||||
```
|
||||
|
||||
Semantics:
|
||||
|
||||
- `max_checks_per_day`: `0` means "use the system default", any positive
|
||||
value is an override, and a **negative** value disables the daily cap
|
||||
for that user (explicit unlimited, independent of the system default).
|
||||
Changes take effect immediately — on the next scheduler tick, the
|
||||
user's budget cache is recomputed against the new limit while the
|
||||
accumulated usage counter for the current day is preserved.
|
||||
- `inactivity_pause_days`: `0` means "use the system default", any
|
||||
positive value is an override, and a **negative** value disables
|
||||
inactivity pausing for that user.
|
||||
- `scheduling_paused`: hard per-user override. Takes effect on the next
|
||||
scheduler tick (within the cache TTL of 5 minutes; an admin edit
|
||||
invalidates the cache immediately).
|
||||
|
||||
After editing a user's quota via the admin API, the gate cache is
|
||||
invalidated automatically. You should not need to restart the scheduler.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Failed executions still count.** If the checker engine is misbehaving
|
||||
for an unrelated reason, users will see their budget drained by failed
|
||||
runs. Watch the scheduler logs (`Scheduler: checker ... failed: ...`)
|
||||
and the `ExecutionFailed` status counts in the admin metrics.
|
||||
- **Process restarts clear the counters.** For deployments with a hard
|
||||
budget, avoid frequent restarts during peak hours.
|
||||
- **Manual triggers count by default.** With
|
||||
`--checker-count-manual-triggers=true` (the default), pressing
|
||||
"Run now" consumes one unit of the daily budget and returns HTTP 429
|
||||
once the budget is exhausted. Set the flag to `false` to restore the
|
||||
legacy bypass; that option is useful for self-hosted instances where
|
||||
the quota is only meant to protect against runaway scheduled load.
|
||||
- **HTTP 429 response body.** Rejected manual triggers return
|
||||
`{"errmsg": "daily check quota exhausted; try again after 00:00 UTC"}`
|
||||
alongside the 429 status. Clients should surface this to the user —
|
||||
the `ExecutionRateLimited` status (value `4`) used for planned
|
||||
executions in `?include_planned=1` can be reused for consistent
|
||||
iconography.
|
||||
- **Invalidate is cooperative.** The gate cache is invalidated on user
|
||||
update and on admin quota edit. If you modify user data through a
|
||||
backdoor (direct database write, migration, …) the cache will stay
|
||||
stale until its 5-minute TTL expires.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
There is currently no dedicated endpoint to introspect per-user usage.
|
||||
Pragmatic options, in order of effort:
|
||||
|
||||
1. Read the scheduler logs: every gated job emits a line at debug level
|
||||
(commented out by default in `scheduler.go`; enable locally when
|
||||
investigating).
|
||||
2. Query planned executions via the user-facing API with
|
||||
`?include_planned=1` and look for `ExecutionRateLimited` entries —
|
||||
this confirms a user is currently over budget.
|
||||
3. Restart the process to force a clean slate (destructive to all
|
||||
counters; use only as a last resort).
|
||||
253
docs/checker-scheduler.md
Normal file
253
docs/checker-scheduler.md
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
# Checker scheduling and execution
|
||||
|
||||
happyDomain's checker subsystem runs small health checks against DNS zones,
|
||||
domains, and services. Each check can be triggered in two ways (on a
|
||||
recurring schedule, or manually from the UI/API), and both paths share the
|
||||
same execution pipeline (observation → rule evaluation → aggregated status).
|
||||
|
||||
This document describes:
|
||||
|
||||
1. The two ways a check can be triggered (scheduled vs manual).
|
||||
2. How the execution pipeline turns an observation into a status.
|
||||
3. How options are composed across scopes and how auto-fill variables work.
|
||||
4. Where to find the per-user throttling rules.
|
||||
|
||||
> **See also:** [checker-quotas.md](./checker-quotas.md) for the per-user
|
||||
> scheduling gate (pause, inactivity, daily budget) that sits in front of
|
||||
> every scheduled and manual trigger.
|
||||
|
||||
---
|
||||
|
||||
## Key concepts
|
||||
|
||||
A few terms recur throughout this document and the checker APIs:
|
||||
|
||||
- **Checker.** The unit of monitoring logic: a named program (built-in,
|
||||
plugin, or external HTTP service) that declares a set of observations
|
||||
it can collect and a set of rules that evaluate them. A checker is
|
||||
identified by a stable `checkerId` and described by a
|
||||
`CheckerDefinition` (options, intervals, availability, rules).
|
||||
- **Observation.** The raw data produced by a single collect step: for
|
||||
example, the RTT samples of a ping probe or the response of a DNS
|
||||
query. Each observation is typed, identified by an
|
||||
`ObservationKey`, serialised to JSON, and stored in an
|
||||
`ObservationSnapshot`. A checker may expose several observation
|
||||
keys; rules request the ones they need.
|
||||
- **Rule.** A named predicate that turns observations into a
|
||||
`CheckState` (`StatusOK`, `StatusWarn`, `StatusCrit`, ...). A single
|
||||
checker typically ships several rules that look at the same
|
||||
observation from different angles (e.g. "packet loss" and "latency"
|
||||
on the same ping data). Users can enable or disable rules
|
||||
individually per target.
|
||||
- **Target.** What a check runs against: a user, a domain, a
|
||||
(zone, subdomain) pair, or a service inside a zone. The
|
||||
`CheckerAvailability` of a checker decides which target levels it
|
||||
may attach to.
|
||||
- **CheckPlan.** The per-target configuration that ties a checker to a
|
||||
specific target. It carries the user's (or admin's) overrides:
|
||||
interval, and a per-rule `enabled` map. A plan is optional: if none
|
||||
exists, the checker runs with its declared defaults.
|
||||
- **Execution.** A single run of the pipeline for a given
|
||||
(checker, target). It is created either by the scheduler (when a
|
||||
plan or default schedule is due) or by a manual trigger, and
|
||||
progresses through the lifecycle statuses `Pending → Running →
|
||||
Done | Failed`.
|
||||
- **CheckEvaluation.** The result attached to a finished execution: a
|
||||
list of rule states plus a reference to the `ObservationSnapshot`
|
||||
they were computed from. This is what the UI renders as green / amber
|
||||
/ red badges.
|
||||
|
||||
In short: a **CheckPlan** pairs a checker with a target; each run
|
||||
produces one **Execution**, which collects one or more **Observations**
|
||||
into a snapshot and feeds them to the enabled **Rules**; the rules'
|
||||
states are bundled into a **CheckEvaluation**, which becomes the
|
||||
execution's final result.
|
||||
|
||||
## How a check runs
|
||||
|
||||
### 1. Scheduled runs (automatic)
|
||||
|
||||
The scheduler maintains a priority queue of upcoming jobs and, on every
|
||||
tick, pops the jobs whose `NextRun` is due. A job that passes the
|
||||
[user-level gate](./checker-quotas.md#the-three-gates) is handed to the
|
||||
engine, executed, and re-enqueued for its next run.
|
||||
|
||||
**Interval resolution.** The effective interval for a given (checker,
|
||||
target) pair is chosen in this order:
|
||||
|
||||
1. `CheckPlan.Interval`: the per-target override stored in DB, if the
|
||||
user (or admin) set one.
|
||||
2. `CheckerDefinition.Interval.Default`: the checker's own default,
|
||||
declared at registration time.
|
||||
3. `24h`: the hardcoded fallback used when the checker did not declare
|
||||
an interval spec.
|
||||
|
||||
The result is then **clamped** to `[Interval.Min, Interval.Max]` if the
|
||||
checker declared bounds, so a user cannot set `Interval = 10s` on a
|
||||
checker whose minimum is `1m`.
|
||||
|
||||
**Spread and jitter.** To avoid thundering herds when many checks share
|
||||
the same interval, the scheduler adds:
|
||||
|
||||
- a **deterministic offset** per (checkerID, target) hashed into the
|
||||
interval window, and
|
||||
- a ~5 % **deterministic jitter** per cycle.
|
||||
|
||||
Two users who configure the same 5-minute probe on the same day will
|
||||
therefore run it on different sub-minute offsets.
|
||||
|
||||
**Applicability.** A checker is scheduled for a target only if its
|
||||
`CheckerAvailability` matches: `ApplyToDomain` enrolls every domain,
|
||||
`ApplyToZone` every published zone, and `ApplyToService` every service
|
||||
of a type listed in `LimitToServices` (or all of them, if the list is
|
||||
empty).
|
||||
|
||||
### 2. Manual runs ("Run now")
|
||||
|
||||
Users (and admins) can trigger an execution on demand:
|
||||
|
||||
```
|
||||
POST /api/domains/{domain}/checkers/{checkerId}/executions
|
||||
POST /api/domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions
|
||||
```
|
||||
|
||||
The request body is optional:
|
||||
|
||||
```json
|
||||
{
|
||||
"options": { "...": "..." },
|
||||
"enabledRules": { "ruleName": true, "otherRule": false }
|
||||
}
|
||||
```
|
||||
|
||||
- `options` are **run-time overrides** that take effect for this single
|
||||
execution. They are validated against the checker's `RunOpts`.
|
||||
- `enabledRules` temporarily selects a subset of the rules for this run;
|
||||
omit it to evaluate every rule configured for the target.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- By default, the endpoint returns **HTTP 202** with the newly created
|
||||
`Execution` and runs the pipeline asynchronously. Poll
|
||||
`GET .../executions/{id}` (or its aliases) for completion.
|
||||
- With `?sync=true`, it blocks and returns **HTTP 200** with the
|
||||
resulting `CheckEvaluation`.
|
||||
- Manual triggers are subject to the per-user daily budget and may be
|
||||
refused with **HTTP 429**. See
|
||||
[checker-quotas.md](./checker-quotas.md#daily-budget).
|
||||
|
||||
The handler is `TriggerCheck` in the checker API controller.
|
||||
|
||||
## The execution pipeline
|
||||
|
||||
Whether the trigger is scheduled or manual, a single execution goes
|
||||
through the same steps:
|
||||
|
||||
1. **Resolve options.** Merge admin → user → domain → service → run-time
|
||||
overrides into a single `CheckerOptions`, then apply
|
||||
[auto-fill](#auto-fill-variables).
|
||||
2. **Collect observations.** Each `ObservationProvider` referenced by
|
||||
the enabled rules is invoked (with caching: two rules sharing the
|
||||
same observation key only collect once). Providers return a typed
|
||||
struct that is JSON-serialised into an `ObservationSnapshot`.
|
||||
3. **Evaluate rules.** Each enabled `CheckRule` receives the snapshot
|
||||
(via an `ObservationGetter`) plus the resolved options, and returns a
|
||||
`CheckState` with one of the statuses below.
|
||||
4. **Aggregate** the individual rule states into the execution's final
|
||||
result (worst-status wins, by default) and persist both the snapshot
|
||||
and the evaluation.
|
||||
5. **Update the `Execution`** with its terminal `ExecutionStatus` and a
|
||||
link to the evaluation.
|
||||
|
||||
For the anatomy of a checker (data types, providers, rules, metrics),
|
||||
see the companion project
|
||||
[checker-dummy](https://git.happydns.org/checker-dummy), which is the
|
||||
reference walkthrough.
|
||||
|
||||
### Check statuses
|
||||
|
||||
Returned by each rule and aggregated at the execution level:
|
||||
|
||||
| Constant | Meaning |
|
||||
| ---------------- | ----------------------------------------------- |
|
||||
| `StatusOK` | Healthy. |
|
||||
| `StatusInfo` | Informational, not a problem. |
|
||||
| `StatusWarn` | Soft threshold crossed. |
|
||||
| `StatusCrit` | Hard threshold crossed. |
|
||||
| `StatusError` | The rule itself could not evaluate (e.g. bad data). |
|
||||
| `StatusUnknown` | Not enough information to decide. |
|
||||
|
||||
### Execution lifecycle statuses
|
||||
|
||||
Attached to the `Execution` record (not to individual rule states):
|
||||
|
||||
| Status | Value | Meaning |
|
||||
| ---------------------- | ----- | ----------------------------------------------------- |
|
||||
| `ExecutionPending` | `0` | Created, not yet running. |
|
||||
| `ExecutionRunning` | `1` | Pipeline is executing. |
|
||||
| `ExecutionDone` | `2` | Pipeline completed; see the linked `CheckEvaluation`. |
|
||||
| `ExecutionFailed` | `3` | Pipeline errored before producing an evaluation. |
|
||||
| `ExecutionRateLimited` | `4` | Synthetic, only on planned (not-yet-run) entries returned by `?include_planned=1`; never persisted. See [checker-quotas.md](./checker-quotas.md#ui-signalling). |
|
||||
|
||||
## Configuring a check
|
||||
|
||||
Every checker exposes a set of typed options grouped by **scope**. The
|
||||
scope determines who sets the option and for how many targets it
|
||||
applies at once:
|
||||
|
||||
| Scope | Who sets it | Applies to |
|
||||
| -------------- | ------------------ | -------------------------- |
|
||||
| `AdminOpts` | instance admin | every user on the instance |
|
||||
| `UserOpts` | end user | all their own targets |
|
||||
| `DomainOpts` | end user / auto | a single domain |
|
||||
| `ServiceOpts` | end user / auto | a single service |
|
||||
| `RunOpts` | trigger caller | a single execution |
|
||||
|
||||
At execution time the scopes are merged in order of increasing
|
||||
specificity (`admin → user → domain → service → run`), so a per-service
|
||||
value wins over a per-domain one, which wins over a per-user default,
|
||||
and so on. Admin-provided values can be locked with the `NoOverride`
|
||||
attribute to prevent lower scopes from changing them.
|
||||
|
||||
The interval itself is **not** an option; it lives on the `CheckPlan`
|
||||
record for that (checker, target) pair, as described above.
|
||||
|
||||
### Auto-fill variables
|
||||
|
||||
Some options don't need to be typed in manually: they can be resolved
|
||||
from the surrounding context of the execution. Each such option is
|
||||
declared with an `AutoFill` attribute, and the engine populates it just
|
||||
before the collect step. Supported variables:
|
||||
|
||||
| Constant | Resolves to |
|
||||
| --------------------- | ---------------------------------------------------- |
|
||||
| `AutoFillDomainName` | The target domain's FQDN. |
|
||||
| `AutoFillSubdomain` | The subdomain under which the target service lives. |
|
||||
| `AutoFillZone` | The published zone the check is running against. |
|
||||
| `AutoFillServiceType` | The service's type identifier. |
|
||||
| `AutoFillService` | The full service payload. |
|
||||
|
||||
Auto-fill runs **last** and overrides any value that may have been set
|
||||
at a lower scope: the goal is to keep these fields authoritative. Rule
|
||||
code can therefore rely on them being present and correct at
|
||||
`Evaluate()` time without re-deriving them from the observation.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Same pipeline for both triggers.** A manual run and a scheduled run
|
||||
produce an `Execution` of the same shape; the only distinction is the
|
||||
`TriggerInfo.Type` (`TriggerManual` vs `TriggerSchedule`) stored on
|
||||
the execution.
|
||||
- **User gate first.** The per-user pause / inactivity / daily-budget
|
||||
gate is evaluated before the pipeline starts; a gated scheduled job
|
||||
is dropped and the counter is not incremented. The rules are in
|
||||
[checker-quotas.md](./checker-quotas.md).
|
||||
- **Rules can be disabled per target.** `CheckPlan.Enabled` is a rule
|
||||
name → boolean map. A missing entry means "enabled", an empty map
|
||||
means "all enabled". A plan where every rule is explicitly `false`
|
||||
disables the checker entirely for that target
|
||||
(`CheckPlan.IsFullyDisabled`).
|
||||
- **Options validation.** Options are validated both when a plan is
|
||||
stored and when a manual trigger arrives (with `RunOpts` included for
|
||||
the latter). Invalid options yield HTTP 400 at trigger time;
|
||||
`TriggerCheck` does not silently drop bad input.
|
||||
BIN
docs/domain-prometheus-url.png
Normal file
BIN
docs/domain-prometheus-url.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
60
docs/metrics.md
Normal file
60
docs/metrics.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# happyDomain Metrics
|
||||
|
||||
happyDomain exposes Prometheus metrics at `GET /metrics` on the **admin
|
||||
socket only** (Unix socket or loopback). The admin socket is not
|
||||
authenticated; do not expose it to untrusted networks. The public HTTP API
|
||||
does **not** serve `/metrics`.
|
||||
|
||||
All metric names are prefixed with `happydomain_`.
|
||||
|
||||
## Exported metrics
|
||||
|
||||
| Metric | Type | Labels | Cardinality bound | Description |
|
||||
|---|---|---|---|---|
|
||||
| `happydomain_http_requests_total` | counter | `method`, `path`, `status` | HTTP methods × Gin route templates × HTTP status codes (low hundreds) | Total HTTP requests served. `path` is the Gin route template (e.g. `/api/domains/:domain`), never the raw URL, to keep cardinality bounded. |
|
||||
| `happydomain_http_request_duration_seconds` | histogram | `method`, `path` | same as above | HTTP request latency, default Prometheus buckets. |
|
||||
| `happydomain_http_requests_in_flight` | gauge | – | 1 | HTTP requests currently being served. |
|
||||
| `happydomain_scheduler_queue_depth` | gauge (func) | – | 1 | Sampled at scrape time via `RegisterSchedulerQueueDepth`. Reports 0 when no scheduler is registered. |
|
||||
| `happydomain_scheduler_active_workers` | gauge | – | 1 | Workers currently executing a check. |
|
||||
| `happydomain_scheduler_checks_total` | counter | `checker`, `status` | #checker types × {`success`, `error`} | Total scheduler check executions. Checker IDs are system-defined, never user input. |
|
||||
| `happydomain_scheduler_check_duration_seconds` | histogram | `checker` | #checker types | Check execution latency. |
|
||||
| `happydomain_provider_api_calls_total` | counter | `provider`, `operation`, `status` | #providers × #ops × {`success`, `error`} | DNS provider API calls. `provider` is the dnscontrol provider name (bounded set). |
|
||||
| `happydomain_provider_api_duration_seconds` | histogram | `provider`, `operation` | same | DNS provider API latency. |
|
||||
| `happydomain_storage_operations_total` | counter | `operation`, `entity`, `status` | ~6 ops × ~5 entities × {`success`, `error`} | Storage operations. |
|
||||
| `happydomain_storage_operation_duration_seconds` | histogram | `operation`, `entity` | same | Storage operation latency. |
|
||||
| `happydomain_storage_stats_errors_total` | counter | `entity` | #entities | Errors encountered while collecting storage stats during a scrape. Alert on a non-zero rate — silent storage failures otherwise produce gaps in the gauges below. |
|
||||
| `happydomain_registered_users` | gauge | – | 1 | Registered user accounts (sampled live at scrape time). |
|
||||
| `happydomain_domains` | gauge | – | 1 | Domains managed across all users. |
|
||||
| `happydomain_zones` | gauge | – | 1 | Zone snapshots stored. |
|
||||
| `happydomain_providers` | gauge | – | 1 | Provider configurations across all users. |
|
||||
| `happydomain_build_info` | gauge | `version`, `revision`, `dirty`, `build_date` | 1 per build | Always 1; metadata is in the labels. |
|
||||
|
||||
## Cardinality rules
|
||||
|
||||
- **Never** add a label whose value comes from user input: domain name, user
|
||||
ID, zone ID, provider URL, raw HTTP path, etc.
|
||||
- New labels MUST have a documented finite bound in the table above before
|
||||
the metric is merged.
|
||||
- Histograms inherit the cardinality of their labels — be especially careful.
|
||||
|
||||
## Security
|
||||
|
||||
`/metrics` exposes business intelligence (entity counts, provider mix,
|
||||
latency profiles) and operational shape (queue depth, worker counts). It is
|
||||
intentionally only mounted on the admin socket (`internal/app/admin.go`).
|
||||
Bind that socket to a Unix path or `127.0.0.1` only — exposing it on a
|
||||
network interface will leak this information to anyone who can reach it.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- The HTTP middleware uses `c.FullPath()` (Gin route template) to populate
|
||||
the `path` label. See `internal/metrics/http.go`.
|
||||
- The scheduler queue depth gauge is a `GaugeFunc` that calls back into the
|
||||
scheduler at scrape time, installed via
|
||||
`metrics.RegisterSchedulerQueueDepth`. The scheduler unregisters its
|
||||
accessor in `Stop()` so stopped schedulers do not leak their queue.
|
||||
- The storage stats collector runs each `Count*` query in its own goroutine
|
||||
with a `recover()` guard, so a panicking backend cannot crash the scrape.
|
||||
Failures increment `happydomain_storage_stats_errors_total{entity=…}`.
|
||||
- `happydomain_build_info` is set once at startup from `cmd/happyDomain/main.go`
|
||||
using `versioninfo.Revision`, `LastCommit`, and `DirtyBuild`.
|
||||
149
docs/plugins/checker-plugin.md
Normal file
149
docs/plugins/checker-plugin.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# Building a happyDomain Checker Plugin
|
||||
|
||||
This page documents how to ship a **checker** as an in-process Go plugin
|
||||
that happyDomain loads at startup. Checker plugins extend happyDomain with
|
||||
automated diagnostics on zones, domains, services or users.
|
||||
|
||||
If you've never built a happyDomain plugin before, read
|
||||
[`checker-dummy`](https://git.happydns.org/checker-dummy) first; it is the
|
||||
reference implementation that this page mirrors.
|
||||
|
||||
> ⚠️ **Security note.** A `.so` plugin is loaded into the happyDomain process
|
||||
> and runs with the same privileges. happyDomain refuses to load plugins from
|
||||
> a directory that is group- or world-writable; keep your plugin directory
|
||||
> owned and writable only by the happyDomain user.
|
||||
|
||||
---
|
||||
|
||||
## What a checker plugin must export
|
||||
|
||||
happyDomain's loader looks for a single exported symbol named
|
||||
`NewCheckerPlugin` with this exact signature:
|
||||
|
||||
```go
|
||||
func NewCheckerPlugin() (
|
||||
*checker.CheckerDefinition,
|
||||
checker.ObservationProvider,
|
||||
error,
|
||||
)
|
||||
```
|
||||
|
||||
where `checker` is `git.happydns.org/checker-sdk-go/checker` (see
|
||||
[Licensing](#licensing) below for why the SDK lives in a separate module).
|
||||
|
||||
- `*CheckerDefinition` describes the checker: ID, name, version, options
|
||||
documentation, rules, optional aggregator, scheduling interval, and
|
||||
whether the checker exposes HTML reports or metrics. The `ID` field is
|
||||
the persistent key: pick something stable and namespaced
|
||||
(`com.example.dnssec-freshness`, not `dnssec`).
|
||||
- `ObservationProvider` is the data-collection half of the checker. It
|
||||
exposes a `Key()` (the observation key the rules will look up) and a
|
||||
`Collect(ctx, opts)` method that returns the raw observation payload.
|
||||
happyDomain serialises the result to JSON and caches it per
|
||||
`ObservationContext`.
|
||||
- Return a non-nil `error` if your plugin cannot initialise (missing
|
||||
environment variable, broken cgo dependency, …); the host will log it and
|
||||
skip the file rather than aborting startup.
|
||||
|
||||
### Registration and collisions
|
||||
|
||||
The loader calls `RegisterExternalizableChecker` and
|
||||
`RegisterObservationProvider` from the SDK registry. Pick globally unique
|
||||
identifiers: if your checker ID or observation key collides with a built-in
|
||||
or another plugin, the duplicate is ignored.
|
||||
|
||||
The same `.so` may export both `NewCheckerPlugin` and (e.g.)
|
||||
`NewProviderPlugin`. The loader runs every known plugin loader against
|
||||
every file, so a single binary can ship a checker, a provider and a service
|
||||
at once.
|
||||
|
||||
---
|
||||
|
||||
## Minimal example
|
||||
|
||||
```go
|
||||
// Command plugin is the happyDomain plugin entrypoint for the dummy checker.
|
||||
//
|
||||
// Build with:
|
||||
// go build -buildmode=plugin -o checker-dummy.so ./plugin
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type dummyProvider struct{}
|
||||
|
||||
func (dummyProvider) Key() sdk.ObservationKey { return "dummy.observation" }
|
||||
|
||||
func (dummyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
return map[string]string{"hello": "world"}, nil
|
||||
}
|
||||
|
||||
// NewCheckerPlugin is the symbol resolved by happyDomain at startup.
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
def := &sdk.CheckerDefinition{
|
||||
ID: "com.example.dummy",
|
||||
Name: "Dummy checker",
|
||||
Version: "0.1.0",
|
||||
ObservationKeys: []sdk.ObservationKey{"dummy.observation"},
|
||||
// Add Rules / Aggregator / Options here in a real plugin.
|
||||
}
|
||||
return def, dummyProvider{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Build and deploy:
|
||||
|
||||
```bash
|
||||
go build -buildmode=plugin -o checker-dummy.so ./plugin
|
||||
sudo install -m 0644 -o happydomain checker-dummy.so /var/lib/happydomain/plugins/
|
||||
sudo systemctl restart happydomain
|
||||
```
|
||||
|
||||
happyDomain will log:
|
||||
|
||||
```
|
||||
Plugin com.example.dummy (.../checker-dummy.so) loaded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build constraints and platform support
|
||||
|
||||
Go's `plugin` package is unforgiving:
|
||||
|
||||
- The plugin **must be built with the same Go version** as happyDomain
|
||||
itself, including the same toolchain patch level.
|
||||
- It **must use the same versions of every shared dependency**. Vendor the
|
||||
exact module versions happyDomain ships, or pin them in your `go.mod`
|
||||
with `replace` directives.
|
||||
- `CGO_ENABLED=1` is required.
|
||||
- `GOOS`/`GOARCH` must match the host binary.
|
||||
|
||||
If any of these don't match, `plugin.Open` will fail with a (sometimes
|
||||
cryptic) error like *"plugin was built with a different version of package
|
||||
…"*. The host will log it and skip the file.
|
||||
|
||||
Go's `plugin` package only works on **linux**, **darwin** and **freebsd**.
|
||||
On other platforms (Windows, plan9, …) happyDomain is built without plugin
|
||||
support and `--plugins-directory` is silently ignored apart from a warning
|
||||
log line at startup.
|
||||
|
||||
---
|
||||
|
||||
## Licensing
|
||||
|
||||
Checker plugins import only `git.happydns.org/checker-sdk-go/checker`,
|
||||
which is licensed under **Apache-2.0**. This is intentional: the
|
||||
checker SDK is a small, stable public API for third-party checkers,
|
||||
deliberately split out of the AGPL-3.0 happyDomain core so that
|
||||
permissively-licensed checker plugins are possible.
|
||||
|
||||
You may therefore distribute your checker `.so` under any license compatible
|
||||
with Apache-2.0. Note that this only covers checker plugins; provider and
|
||||
service plugins still link against AGPL code and remain subject to the
|
||||
AGPL-3.0 reciprocity rules described in their respective documentation
|
||||
([provider](provider-plugin.md), [service](service-plugin.md)).
|
||||
275
docs/prometheus.md
Normal file
275
docs/prometheus.md
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
# Scraping happyDomain checker metrics with Prometheus
|
||||
|
||||
happyDomain exposes check results as time-series metrics that Prometheus can
|
||||
scrape directly. This lets you alert on DNS health checks, track trends over
|
||||
time, and correlate domain health with the rest of your infrastructure.
|
||||
|
||||
> **Scope of this document:** user-facing metrics from the checker subsystem.
|
||||
> The admin-socket `/metrics` endpoint (happyDomain internal instrumentation)
|
||||
> is covered separately in [metrics.md](metrics.md).
|
||||
|
||||
---
|
||||
|
||||
## Getting started
|
||||
|
||||
### 1. Create an API token
|
||||
|
||||
The metrics endpoints require authentication. You need a long-lived API token
|
||||
(not your login session).
|
||||
|
||||
1. Log in to happyDomain.
|
||||
2. Go to the **Account** page (top-right user menu).
|
||||
3. Scroll down to the **Security & Access** section.
|
||||
4. Click **Create API key**.
|
||||
5. Give the key a descriptive name (e.g. `prometheus-scraper`).
|
||||
6. Click **Create API key** in the modal.
|
||||
7. **Copy the secret immediately** (it is shown only once).
|
||||
|
||||

|
||||
|
||||
The secret is used as a Bearer token in every request:
|
||||
|
||||
```
|
||||
Authorization: Bearer <your-secret-here>
|
||||
```
|
||||
|
||||
### 2. Verify manually
|
||||
|
||||
Before configuring Prometheus, confirm that the endpoint is reachable and
|
||||
returns Prometheus-formatted data:
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <your-secret>" \
|
||||
-H "Accept: text/plain" \
|
||||
https://happydomain.example.com/api/checkers/metrics
|
||||
```
|
||||
|
||||
You should see lines like:
|
||||
|
||||
```
|
||||
# HELP dns_rtt_seconds unit: s
|
||||
# TYPE dns_rtt_seconds untyped
|
||||
dns_rtt_seconds{...} 0.042 1744800000000
|
||||
```
|
||||
|
||||
> **Format selection:** the API returns JSON by default. Add
|
||||
> `Accept: text/plain` (or `Accept: application/openmetrics-text`) to receive
|
||||
> Prometheus text exposition format 0.0.4. Prometheus itself sends the right
|
||||
> header automatically when you use the `params` and `headers` scrape options
|
||||
> shown below.
|
||||
|
||||
### 3. Minimal Prometheus scrape config
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: happydomain_checks
|
||||
metrics_path: /api/checkers/metrics
|
||||
scheme: https
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials: <your-secret>
|
||||
params:
|
||||
# Not needed; Prometheus sends Accept: application/openmetrics-text
|
||||
# by default and the API honours it.
|
||||
static_configs:
|
||||
- targets:
|
||||
- happydomain.example.com
|
||||
```
|
||||
|
||||
That's it. Prometheus will now scrape all check metrics for your account on
|
||||
every evaluation interval.
|
||||
|
||||
---
|
||||
|
||||
## Available endpoints
|
||||
|
||||
All endpoints are under `/api` and require `Authorization: Bearer <token>`.
|
||||
|
||||
### All user metrics
|
||||
|
||||
```
|
||||
GET /api/checkers/metrics
|
||||
```
|
||||
|
||||
Returns metrics from recent executions of **every checker across all your
|
||||
domains**. This is the broadest scrape target, useful when you want a single
|
||||
job covering everything.
|
||||
|
||||
| Query parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `limit` | `100` | Maximum number of recent executions to include. |
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Accept: text/plain" \
|
||||
"https://happydomain.example.com/api/checkers/metrics?limit=200"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Domain metrics
|
||||
|
||||
```
|
||||
GET /api/domains/{domain}/checkers/metrics
|
||||
```
|
||||
|
||||
Returns metrics for **all checkers on a single domain**, including checkers
|
||||
running on its services. `{domain}` is your domain FQDN (e.g.
|
||||
`example.com`) or its internal identifier; both are accepted.
|
||||
|
||||
| Query parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `limit` | `100` | Maximum number of recent executions to include. |
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Accept: text/plain" \
|
||||
"https://happydomain.example.com/api/domains/example.com/checkers/metrics"
|
||||
```
|
||||
|
||||
Prometheus config for per-domain scraping:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: happydomain_example_com
|
||||
metrics_path: /api/domains/example.com/checkers/metrics
|
||||
scheme: https
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials: <your-secret>
|
||||
static_configs:
|
||||
- targets:
|
||||
- happydomain.example.com
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Per-checker metrics (domain level)
|
||||
|
||||
```
|
||||
GET /api/domains/{domain}/checkers/{checkerId}/metrics
|
||||
```
|
||||
|
||||
Returns metrics for **one specific checker on a domain**. Use this when you
|
||||
want fine-grained scrape intervals or separate Prometheus jobs per checker
|
||||
type.
|
||||
|
||||
`{checkerId}` is the checker identifier (e.g. `dnssec`, `mx_reachability`).
|
||||
The easiest way to obtain the exact URL is the **Prometheus Metrics** button
|
||||
on the checker configuration page (visible when the checker produces metrics).
|
||||
|
||||
| Query parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `limit` | `100` | Maximum number of recent executions to include. |
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Accept: text/plain" \
|
||||
"https://happydomain.example.com/api/domains/example.com/checkers/dnssec/metrics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Per-checker metrics (service level)
|
||||
|
||||
```
|
||||
GET /api/domains/{domain}/zone/{zoneId}/{subdomain}/services/{serviceId}/checkers/{checkerId}/metrics
|
||||
```
|
||||
|
||||
Returns metrics for **one specific checker on a DNS service** (a structured
|
||||
record group, e.g. an MX configuration or an SPF record).
|
||||
|
||||
The URL for a service-level checker can be copied from the **Prometheus
|
||||
Metrics** button on the service's checker configuration page.
|
||||
|
||||
| Path segment | Description |
|
||||
|---|---|
|
||||
| `{domain}` | Domain FQDN or identifier |
|
||||
| `{zoneId}` | Zone snapshot identifier (opaque string) |
|
||||
| `{subdomain}` | Relative owner name within the zone (e.g. `@`, `mail`) |
|
||||
| `{serviceId}` | Service identifier (opaque string) |
|
||||
| `{checkerId}` | Checker identifier |
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Accept: text/plain" \
|
||||
"https://happydomain.example.com/api/domains/example.com/zone/<zoneId>/@/services/<serviceId>/checkers/spf_policy/metrics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Single-execution metrics
|
||||
|
||||
```
|
||||
GET /api/domains/{domain}/checkers/{checkerId}/executions/{executionId}/metrics
|
||||
GET /api/domains/{domain}/zone/{zoneId}/{subdomain}/services/{serviceId}/checkers/{checkerId}/executions/{executionId}/metrics
|
||||
```
|
||||
|
||||
Returns metrics from **one specific execution run**. This is not typically
|
||||
used for Prometheus scraping (the data is historical and does not change after
|
||||
the execution completes); it is more useful for debugging or one-off
|
||||
inspections.
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Accept: text/plain" \
|
||||
"https://happydomain.example.com/api/domains/example.com/checkers/dnssec/executions/<executionId>/metrics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-domain Prometheus configuration
|
||||
|
||||
To scrape several domains with a single Prometheus job, use
|
||||
`relabel_configs` to build the path dynamically from a label:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: happydomain_domains
|
||||
scheme: https
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials: <your-secret>
|
||||
metrics_path: /api/checkers/metrics # fallback; overridden per target
|
||||
relabel_configs:
|
||||
- source_labels: [__address__]
|
||||
regex: (.+)@(.+)
|
||||
target_label: __metrics_path__
|
||||
replacement: /api/domains/$1/checkers/metrics
|
||||
- source_labels: [__address__]
|
||||
regex: .+@(.+)
|
||||
target_label: __address__
|
||||
replacement: $1
|
||||
- source_labels: [domain]
|
||||
target_label: domain
|
||||
static_configs:
|
||||
- targets:
|
||||
- example.com@happydomain.example.com
|
||||
- otherdomain.net@happydomain.example.com
|
||||
labels:
|
||||
instance: happydomain.example.com
|
||||
```
|
||||
|
||||
Each target encodes `domain@host`; the relabel rules split them into the
|
||||
correct `__metrics_path__` and `__address__`.
|
||||
|
||||
---
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **Treat the API token like a password.** It grants read access to all check
|
||||
metrics associated with your account.
|
||||
- Use a dedicated token for each scraper. You can revoke individual tokens from
|
||||
the **Security & Access** page without affecting your login session.
|
||||
- Prefer HTTPS so the `Authorization` header is not transmitted in the clear.
|
||||
- The metrics endpoints return only aggregated numeric values; they do not
|
||||
expose DNS zone content, provider credentials, or other sensitive
|
||||
configuration.
|
||||
BIN
docs/user-create-api-key.png
Normal file
BIN
docs/user-create-api-key.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
|
|
@ -21,10 +21,11 @@
|
|||
|
||||
package main
|
||||
|
||||
//go:generate go run tools/gen_instrumented_storage.go
|
||||
//go:generate go run tools/gen_icon.go providers providers
|
||||
//go:generate go run tools/gen_icon.go services svcs
|
||||
//go:generate go run tools/gen_rr_typescript.go web/src/lib/dns_rr.ts
|
||||
//go:generate go run tools/gen_service_specs.go -o web/src/lib/services_specs.ts
|
||||
//go:generate go run tools/gen_dns_type_mapping.go -o internal/usecase/service_specs_dns_types.go
|
||||
//go:generate swag init --exclude internal/api-admin/ --generalInfo internal/api/route/route.go
|
||||
//go:generate swag init --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go
|
||||
//go:generate swag init --parseDependency --exclude internal/api-admin/ --generalInfo internal/api/route/route.go
|
||||
//go:generate swag init --parseDependency --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go
|
||||
|
|
|
|||
24
go.mod
24
go.mod
|
|
@ -5,6 +5,12 @@ go 1.25.0
|
|||
toolchain go1.26.2
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489
|
||||
git.happydns.org/checker-ns-restrictions v0.0.0-20260415205411-f1e3096f606d
|
||||
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc
|
||||
git.happydns.org/checker-sdk-go v0.5.0
|
||||
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc
|
||||
github.com/JGLTechnologies/gin-rate-limit v1.5.8
|
||||
github.com/StackExchange/dnscontrol/v4 v4.34.0
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
|
|
@ -33,6 +39,16 @@ require (
|
|||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/likexian/gokit v0.25.16 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
|
|
@ -157,6 +173,8 @@ require (
|
|||
github.com/labstack/echo/v4 v4.15.1 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/likexian/whois v1.15.7
|
||||
github.com/likexian/whois-parser v1.24.21
|
||||
github.com/luadns/luadns-go v0.3.0 // indirect
|
||||
github.com/mailgun/raymond/v2 v2.0.48 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
|
|
@ -170,6 +188,7 @@ require (
|
|||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
|
||||
github.com/nrdcg/goinwx v0.12.0 // indirect
|
||||
github.com/openrdap/rdap v0.9.1
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
|
||||
github.com/oracle/oci-go-sdk/v65 v65.111.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
|
|
@ -179,9 +198,10 @@ require (
|
|||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus-community/pro-bing v0.8.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/common v0.67.5
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
|
|
|
|||
200
go.sum
200
go.sum
|
|
@ -1,25 +1,29 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
codeberg.org/miekg/dns v0.6.67 h1:vsVNsqAOE9uYscJHIHNtoCxiEySQn/B9BEvAUYI5Zmc=
|
||||
codeberg.org/miekg/dns v0.6.67/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
|
||||
codeberg.org/miekg/dns v0.6.73 h1:4aRD1k1THw49vpe1d+W3KO16adAGN8Raxdi0WGvvbrY=
|
||||
codeberg.org/miekg/dns v0.6.73/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489 h1:pTGfGq88Dj4Y60LJLSW4FvpUubeYpNlwuxKt/2IFzdo=
|
||||
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489/go.mod h1:fQjY1yWYFucu+Ebn5uYM7ZWTJNQIgjMENI/8tqlaR98=
|
||||
git.happydns.org/checker-ns-restrictions v0.0.0-20260415205411-f1e3096f606d h1:WqNi0vYSu7ZtPC4zNTwrZM64WcceaOistkF9qMxFQvE=
|
||||
git.happydns.org/checker-ns-restrictions v0.0.0-20260415205411-f1e3096f606d/go.mod h1:Sw8SqlrTAi3ZSVQQ+5kKE8reekxvLRm7oYlUYfJD+Z4=
|
||||
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc h1:jKEOx2NDbHHxjCy1fUkcn1RgpzOKbE+bGRsF+ITNigI=
|
||||
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc/go.mod h1:wphWmslFhKcpWfJTrHdChv8DkhUP9jwis7V2jy7vOX0=
|
||||
git.happydns.org/checker-sdk-go v0.5.0 h1:wpFIK/vxanrAYf1OlewSnSCYc7KOJKdu88uUWB7HIQI=
|
||||
git.happydns.org/checker-sdk-go v0.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc h1:y5xjoqLA/WztFWhEUifOwnJ6POjl+Udw6bWjzQ2afOw=
|
||||
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc/go.mod h1:B1P23OMm82GfAtYw8vCbspc7qULsFA0u/tqR+SGAaNw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c=
|
||||
|
|
@ -36,8 +40,6 @@ github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+W
|
|||
github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1 h1:edShSHV3DV90+kt+CMaEXEzR9QF7wFrPJxVGz2blMIU=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
|
|
@ -47,21 +49,19 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
|
|||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20251202014920-1725d2651bd4 h1:DQ1+lDdBve+u+aovjh4wV6sYnvZKH0Hx8GaQOi4vYl8=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20251202014920-1725d2651bd4/go.mod h1:eauGmjfZG874MOAEPVeqg21mZCbTOLW+tFe8F7NpfnY=
|
||||
github.com/CloudyKit/jet/v6 v6.3.1 h1:6IAo5Cx21xrHVaR8zzXN5gJatKV/wO7Nf6bfCnCSbUw=
|
||||
github.com/CloudyKit/jet/v6 v6.3.1/go.mod h1:lf8ksdNsxZt7/yH/3n4vJQWA9RUq4wpaHtArHhGVMOw=
|
||||
github.com/CloudyKit/jet/v6 v6.3.2 h1:BPaX0lnXTZ9TniICiiK/0iJqzeGJ2ibvB4DjAqLMBSM=
|
||||
github.com/CloudyKit/jet/v6 v6.3.2/go.mod h1:lf8ksdNsxZt7/yH/3n4vJQWA9RUq4wpaHtArHhGVMOw=
|
||||
github.com/G-Core/gcore-dns-sdk-go v0.3.3 h1:McILJSbJ5nOcT0MI0aBYhEuufCF329YbqKwFIN0RjCI=
|
||||
github.com/G-Core/gcore-dns-sdk-go v0.3.3/go.mod h1:35t795gOfzfVanhzkFyUXEzaBuMXwETmJldPpP28MN4=
|
||||
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
|
||||
github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0=
|
||||
github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g=
|
||||
github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc=
|
||||
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
|
||||
github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk=
|
||||
github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
|
|
@ -72,12 +72,14 @@ github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY
|
|||
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
||||
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0 h1:iGVPe/gPqzpXggbbmVLWR0TyJ9UoPoqKL+kspjseZzE=
|
||||
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0/go.mod h1:76JtkiCKMwTdTOlKe9goT4Md+oWjfMouGBQgy+u1bgc=
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0 h1:7oPti0aUS+YCep8nwt5b9g4jYfCU55ZruWESL8G9K5M=
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0/go.mod h1:I8ESLVWR9C58uvGufB/AJDPhaSU4+4Oh3DLpVtgwDAk=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
|
|
@ -87,68 +89,36 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP
|
|||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3 h1:JRPXnIr0WwFsSHBmuCvT/uh0Vgys+crvwkOghbJEqi8=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3/go.mod h1:DHddp7OO4bY467WVCqWBzk5+aEWn7vqYkap7UigJzGk=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 h1:Z+/OLsb85Kpq7TVLCspskqePaf68Tdv6GfmJP4kH6i0=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5/go.mod h1:TmxGowuBYwjmHFOsEDxaZdsQE62JJzOmtiWafTi/czg=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17 h1:Fw2SIR63jhfLpFZr6955zU3g9V8ouHC/pRpmmiHmIFM=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17/go.mod h1:x9PRRtbCQ/gv1ziQPXFB7nQwQgVLQ+FSvPIkVAhRcYY=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.19 h1:I1uSW0oydwLZWp4IDjGqAJY+EoNFylgNxxcXeOSioVk=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.19/go.mod h1:1qCxun61Kq+S1e790tY+MpOKQ29DoOt2Fdx8Efgmo2g=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
|
||||
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
|
|
@ -165,20 +135,15 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF
|
|||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18 h1:RvyTDU0VmnUBd3Qm2i6irEXtCR2KRIxnRlD8l+5z/DY=
|
||||
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18/go.mod h1:a6n4wXFHbMW0iJFxHIJR4PkgG5krP52nOVCBU0m+Obw=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
|
|
@ -196,12 +161,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
|||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deepmap/oapi-codegen v1.16.3 h1:GT9G86SbQtT1r8ZB+4Cybi9VGdu1P5ieNvNdEoCSbrA=
|
||||
github.com/deepmap/oapi-codegen v1.16.3/go.mod h1:JD6ErqeX0nYnhdciLc61Konj3NBASREMlkHOgHn8WAM=
|
||||
github.com/digitalocean/godo v1.176.0 h1:P379vPO5TUre+bUHPEsdSAbl5vIrRRhP91tMIEPoWYU=
|
||||
github.com/digitalocean/godo v1.176.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/digitalocean/godo v1.184.0 h1:2B2CQhxftlf3xa24Nrzn5CBQlaQjyaWqi3XbbnJlG3w=
|
||||
github.com/digitalocean/godo v1.184.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
|
||||
github.com/dnsimple/dnsimple-go/v8 v8.1.0 h1:U4ENaNCe5aUFHLiF7lj2NNpLPzFY3YIriu/UzrdfUbg=
|
||||
github.com/dnsimple/dnsimple-go/v8 v8.1.0/go.mod h1:61MdYHRL+p2TBBUVEkxo1n4iRF6s3R9fZcvQvyt5du8=
|
||||
github.com/dnsimple/dnsimple-go/v8 v8.2.0 h1:nNgtqKrt1K1BIWIpKTCL2qCiQcfYUxzsyRGIKLYEYH0=
|
||||
github.com/dnsimple/dnsimple-go/v8 v8.2.0/go.mod h1:61MdYHRL+p2TBBUVEkxo1n4iRF6s3R9fZcvQvyt5du8=
|
||||
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
|
||||
|
|
@ -231,8 +194,6 @@ github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d
|
|||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
|
||||
github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
|
|
@ -249,56 +210,33 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-mail/mail v2.3.1+incompatible h1:UzNOn0k5lpfVtO31cK3hn6I4VEVGhe3lX8AJBAxXExM=
|
||||
github.com/go-mail/mail v2.3.1+incompatible/go.mod h1:VPWjmmNyRsWXQZHVHT3g0YbIINUkSmuKOiLIDkWbL6M=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
|
||||
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
|
||||
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
|
||||
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
|
||||
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
|
||||
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
|
|
@ -311,8 +249,6 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
|
|||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
|
|
@ -321,8 +257,6 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
|||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe h1:zn8tqiUbec4wR94o7Qj3LZCAT6uGobhEgnDRg6isG5U=
|
||||
github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXKkTfoE=
|
||||
|
|
@ -333,7 +267,6 @@ github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8
|
|||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
|
|
@ -376,12 +309,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
|||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
|
||||
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
|
|
@ -408,14 +337,10 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU
|
|||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g=
|
||||
github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.36.0 h1:HlLL/aaVXUulqe+rsjoJmrxKhPi1MflL5O9iq5QEtvo=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.36.0/go.mod h1:MnN/QJEa/RYNQiiVoJjNHPntM7Z1wlYPgJ2HA40/cDE=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.37.0 h1:PMnuOA8pL8aHLLPp6nnnCTo2Xk2tqu4dAfYsC3bWdT0=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.37.0/go.mod h1:zaDOCKmpnI86ftoCpUpaiYaw9Wew1ib1AcXTh96deYI=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 h1:J+U6+eUjIsBhefolFdZW5hQNJbkMj+7msxZrv56Cg2g=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.192 h1:xgKdmcALGqLGBrBG8stMli0k+irufCeNPenn76Y4U9o=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.192/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
|
||||
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
|
||||
|
|
@ -458,8 +383,6 @@ github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwf
|
|||
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
||||
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
|
|
@ -474,8 +397,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
|
||||
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
|
||||
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
|
|
@ -489,12 +410,16 @@ github.com/libdns/ionos v1.2.0 h1:FQ2xQTBfsjc7aMArRBBCs9l48Squt76GHXbxDsqOKgw=
|
|||
github.com/libdns/ionos v1.2.0/go.mod h1:g/JYno/+VXdujTGPBDMDeCfeLF0PJyJynsCrFu+2EFQ=
|
||||
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
|
||||
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||
github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP8=
|
||||
github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4=
|
||||
github.com/likexian/whois v1.15.7 h1:sajjDhi2bVD71AHJhjV7jLYxN92H4AWhTwxM8hmj7c0=
|
||||
github.com/likexian/whois v1.15.7/go.mod h1:kdPQtYb+7SQVftBEbCblDadUkycN7Mg1k1/Li/rwvmc=
|
||||
github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpOtTDA5OsM=
|
||||
github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4=
|
||||
github.com/luadns/luadns-go v0.3.0 h1:mN2yhFv/LnGvPw/HmvYUhXe+lc95oXUqjlYVeJeOJng=
|
||||
github.com/luadns/luadns-go v0.3.0/go.mod h1:DmPXbrGMpynq1YNDpvgww3NP5Zf4wXM5raAbGrp5L+8=
|
||||
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
|
||||
github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
|
|
@ -504,8 +429,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
|||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
|
||||
|
|
@ -545,19 +468,16 @@ github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGm
|
|||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/openrdap/rdap v0.9.1 h1:Rv6YbanbiVPsKRvOLdUmlU1AL5+2OFuEFLjFN+mQsCM=
|
||||
github.com/openrdap/rdap v0.9.1/go.mod h1:vKSiotbsENrjM/vaHXLddXbW8iQkBfa+ldEuYEjyLTQ=
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
|
||||
github.com/oracle/nosql-go-sdk v1.4.7 h1:dqVBSMulObDj0JHm1mAncTHrQg8wIiQJNC0JRNKPACg=
|
||||
github.com/oracle/nosql-go-sdk v1.4.7/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.109.0 h1:EsbFVvcV+uid9SoQnFQbTKS6FgqsM9NtoKmUIovKsbk=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.109.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.111.0 h1:eDkWg6ZN0uKwWzSekoFcQJhR+C+F/aVdTwr+lGHU9Qk=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.111.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
|
||||
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
|
||||
github.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c=
|
||||
|
|
@ -573,6 +493,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
|
||||
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
|
|
@ -580,8 +502,6 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
|||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
|
||||
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 h1:wSmWgpuccqS2IOfmYrbRiUgv+g37W5suLLLxwwniTSc=
|
||||
|
|
@ -590,6 +510,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
|||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0=
|
||||
github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
|
|
@ -635,6 +557,7 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F
|
|||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
|
|
@ -646,20 +569,14 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
|||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/tdewolff/minify/v2 v2.24.9 h1:W6A570F9N6MuZtg9mdHXD93piZZIWJaGpbAw9Narrfw=
|
||||
github.com/tdewolff/minify/v2 v2.24.9/go.mod h1:9F66jUzl/Pdf6Q5x0RXFUsI/8N1kjBb3ILg9ABSWoOI=
|
||||
github.com/tdewolff/minify/v2 v2.24.12 h1:YXJxVJmz7vxgnEv1v8J/EI4x+Uw4MMohcRFK7TFOjmk=
|
||||
github.com/tdewolff/minify/v2 v2.24.12/go.mod h1:exq1pjdrh9uAICdfVKQwqz6MsJmWmQahZuTC6pTO6ro=
|
||||
github.com/tdewolff/parse/v2 v2.8.8 h1:l3yOJ4OUKq1sKeQQxZ7P2yZ6daW/Oq4IDxL98uTOpPI=
|
||||
github.com/tdewolff/parse/v2 v2.8.8/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/parse/v2 v2.8.11 h1:SGyjEy3xEqd+W9WVzTlTQ5GkP/en4a1AZNZVJ1cvgm0=
|
||||
github.com/tdewolff/parse/v2 v2.8.11/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/transip/gotransip/v6 v6.26.1 h1:MeqIjkTBBsZwWAK6giZyMkqLmKMclVHEuTNmoBdx4MA=
|
||||
github.com/transip/gotransip/v6 v6.26.1/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=
|
||||
github.com/transip/gotransip/v6 v6.26.2 h1:pnbDXrkFevOngpi6ertLw6e57lOW+Rk3djJ9AewmJ94=
|
||||
github.com/transip/gotransip/v6 v6.26.2/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
|
|
@ -676,7 +593,6 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
|||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/vercel/terraform-provider-vercel v1.14.1 h1:ghAjFkMMzka4XuoBYdu1OXM/K7FQEj8wUd+xMPPOGrg=
|
||||
github.com/vercel/terraform-provider-vercel v1.14.1/go.mod h1:AdFCiUD0XP8XOi6tnhaCh7I0vyq2TAPmI+GcIp3+7SI=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
|
|
@ -694,6 +610,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
|
|||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
|
||||
|
|
@ -718,44 +636,32 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
|
|||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
|
||||
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
|
@ -777,8 +683,6 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL
|
|||
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
|
||||
|
|
@ -795,8 +699,6 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
|
|
@ -819,8 +721,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
|
|
@ -859,7 +759,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
|
@ -890,8 +789,6 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
|
@ -908,8 +805,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
@ -919,36 +814,27 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
|
|||
gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
|
||||
gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
|
||||
gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
|
||||
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
|
||||
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
|
||||
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
|
||||
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
|
||||
google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI=
|
||||
google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
|
|
|
|||
|
|
@ -26,11 +26,13 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/StackExchange/dnscontrol/v4/models"
|
||||
dnscontrol "github.com/StackExchange/dnscontrol/v4/pkg/providers"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -150,7 +152,11 @@ func NewDNSControlProviderAdapter(configAdapter DNSControlConfigAdapter) (ret ha
|
|||
auditor = p.RecordAuditor
|
||||
}
|
||||
|
||||
return &DNSControlAdapterNSProvider{provider, auditor}, nil
|
||||
return &DNSControlAdapterNSProvider{
|
||||
DNSServiceProvider: provider,
|
||||
RecordAuditor: auditor,
|
||||
providerName: configAdapter.DNSControlName(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DNSControlAdapterNSProvider wraps a DNSControl provider to implement the happyDomain ProviderActuator interface.
|
||||
|
|
@ -160,6 +166,8 @@ type DNSControlAdapterNSProvider struct {
|
|||
DNSServiceProvider dnscontrol.DNSServiceProvider
|
||||
// RecordAuditor validates records for provider-specific requirements
|
||||
RecordAuditor dnscontrol.RecordAuditor
|
||||
// providerName is the DNSControl provider name used for metrics labels
|
||||
providerName string
|
||||
}
|
||||
|
||||
// CanListZones checks if the provider supports listing zones (domains).
|
||||
|
|
@ -169,6 +177,26 @@ func (p *DNSControlAdapterNSProvider) CanListZones() bool {
|
|||
return ok
|
||||
}
|
||||
|
||||
// observeProviderCall starts timing a provider API call and returns a closure
|
||||
// that records the outcome. Intended use:
|
||||
//
|
||||
// defer p.observeProviderCall("op")(&err)
|
||||
//
|
||||
// The returned closure reads *err at defer-execution time, so it observes the
|
||||
// final value of the named return even if it is reassigned later in the
|
||||
// function (including from a recover() block).
|
||||
func (p *DNSControlAdapterNSProvider) observeProviderCall(operation string) func(err *error) {
|
||||
start := time.Now()
|
||||
return func(err *error) {
|
||||
status := "success"
|
||||
if err != nil && *err != nil {
|
||||
status = "error"
|
||||
}
|
||||
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, operation, status).Inc()
|
||||
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, operation).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
}
|
||||
|
||||
// CanCreateDomain checks if the provider supports creating new domains.
|
||||
// Returns true if the provider implements the ZoneCreator interface.
|
||||
func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
|
||||
|
|
@ -182,6 +210,7 @@ func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
|
|||
func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happydns.Record, err error) {
|
||||
var records models.Records
|
||||
|
||||
defer p.observeProviderCall("get_zone_records")(&err)
|
||||
defer func() {
|
||||
if a := recover(); a != nil {
|
||||
err = fmt.Errorf("%s", a)
|
||||
|
|
@ -211,6 +240,8 @@ func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happy
|
|||
// before computing corrections.
|
||||
// Returns a slice of corrections, the total number of corrections needed, and any error.
|
||||
func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []happydns.Record) (ret []*happydns.Correction, nbCorrections int, err error) {
|
||||
defer p.observeProviderCall("get_zone_corrections")(&err)
|
||||
|
||||
var dc *models.DomainConfig
|
||||
dc, err = NewDNSControlDomainConfig(strings.TrimSuffix(domain, "."), rrs)
|
||||
if err != nil {
|
||||
|
|
@ -261,23 +292,31 @@ func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []ha
|
|||
// CreateDomain creates a new zone (domain) on the provider.
|
||||
// The fqdn parameter should be a fully qualified domain name (with or without trailing dot).
|
||||
// Returns an error if the provider doesn't support domain creation or if creation fails.
|
||||
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) error {
|
||||
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) (err error) {
|
||||
defer p.observeProviderCall("create_domain")(&err)
|
||||
|
||||
zc, ok := p.DNSServiceProvider.(dnscontrol.ZoneCreator)
|
||||
if !ok {
|
||||
return fmt.Errorf("Provider doesn't support domain creation.")
|
||||
err = fmt.Errorf("Provider doesn't support domain creation.")
|
||||
return
|
||||
}
|
||||
|
||||
return zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
|
||||
err = zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
|
||||
return
|
||||
}
|
||||
|
||||
// ListZones retrieves a list of all zones (domains) managed by this provider.
|
||||
// Returns a slice of domain names or an error if the provider doesn't support listing
|
||||
// or if the operation fails.
|
||||
func (p *DNSControlAdapterNSProvider) ListZones() ([]string, error) {
|
||||
func (p *DNSControlAdapterNSProvider) ListZones() (zones []string, err error) {
|
||||
defer p.observeProviderCall("list_zones")(&err)
|
||||
|
||||
zl, ok := p.DNSServiceProvider.(dnscontrol.ZoneLister)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Provider doesn't support domain listing.")
|
||||
err = fmt.Errorf("Provider doesn't support domain listing.")
|
||||
return
|
||||
}
|
||||
|
||||
return zl.ListZones()
|
||||
zones, err = zl.ListZones()
|
||||
return
|
||||
}
|
||||
|
|
|
|||
272
internal/adapters/dnscontrol-providers_test.go
Normal file
272
internal/adapters/dnscontrol-providers_test.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// 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/>.
|
||||
|
||||
// Package adapter provides tests for DNSControlAdapterNSProvider instrumentation.
|
||||
// These tests live in the internal package so they can construct the struct
|
||||
// directly and set the unexported providerName field used in metric labels.
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnscontrolmodels "github.com/StackExchange/dnscontrol/v4/models"
|
||||
dnscontrol "github.com/StackExchange/dnscontrol/v4/pkg/providers"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
)
|
||||
|
||||
// --- mock DNSServiceProvider -------------------------------------------------
|
||||
|
||||
// mockDNSProvider implements dnscontrol.DNSServiceProvider (i.e. models.DNSProvider).
|
||||
type mockDNSProvider struct {
|
||||
getZoneRecordsErr error
|
||||
getZoneRecordsResult dnscontrolmodels.Records
|
||||
correctionsErr error
|
||||
panicOnGetZoneRecords bool
|
||||
}
|
||||
|
||||
func (m *mockDNSProvider) GetNameservers(domain string) ([]*dnscontrolmodels.Nameserver, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockDNSProvider) GetZoneRecords(domain string, meta map[string]string) (dnscontrolmodels.Records, error) {
|
||||
if m.panicOnGetZoneRecords {
|
||||
panic("simulated provider panic")
|
||||
}
|
||||
return m.getZoneRecordsResult, m.getZoneRecordsErr
|
||||
}
|
||||
|
||||
func (m *mockDNSProvider) GetZoneRecordsCorrections(dc *dnscontrolmodels.DomainConfig, existing dnscontrolmodels.Records) ([]*dnscontrolmodels.Correction, int, error) {
|
||||
return nil, 0, m.correctionsErr
|
||||
}
|
||||
|
||||
// mockZoneLister extends mockDNSProvider with ZoneLister.
|
||||
type mockZoneLister struct {
|
||||
mockDNSProvider
|
||||
listErr error
|
||||
listResult []string
|
||||
}
|
||||
|
||||
func (m *mockZoneLister) ListZones() ([]string, error) {
|
||||
return m.listResult, m.listErr
|
||||
}
|
||||
|
||||
// mockZoneCreator extends mockDNSProvider with ZoneCreator.
|
||||
type mockZoneCreator struct {
|
||||
mockDNSProvider
|
||||
ensureErr error
|
||||
}
|
||||
|
||||
func (m *mockZoneCreator) EnsureZoneExists(domain string, metadata map[string]string) error {
|
||||
return m.ensureErr
|
||||
}
|
||||
|
||||
// --- helpers -----------------------------------------------------------------
|
||||
|
||||
// noopAuditor is a RecordAuditor that approves all records.
|
||||
func noopAuditor(rcs []*dnscontrolmodels.RecordConfig) []error { return nil }
|
||||
|
||||
// newTestAdapter constructs a DNSControlAdapterNSProvider with the given
|
||||
// provider mock and a fixed providerName so metric labels are predictable.
|
||||
func newTestAdapter(provider dnscontrol.DNSServiceProvider) *DNSControlAdapterNSProvider {
|
||||
return &DNSControlAdapterNSProvider{
|
||||
DNSServiceProvider: provider,
|
||||
RecordAuditor: noopAuditor,
|
||||
providerName: "TEST_PROVIDER",
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetZoneRecords ----------------------------------------------------------
|
||||
|
||||
func TestObserveProviderCall_GetZoneRecords_Success(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockDNSProvider{})
|
||||
_, err := a.GetZoneRecords("example.com.")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "success")); got != 1 {
|
||||
t.Errorf("expected counter=1 for success, got %v", got)
|
||||
}
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "error")); got != 0 {
|
||||
t.Errorf("expected error counter=0, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_GetZoneRecords_Error(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockDNSProvider{getZoneRecordsErr: errors.New("upstream timeout")})
|
||||
_, err := a.GetZoneRecords("example.com.")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1, got %v", got)
|
||||
}
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "success")); got != 0 {
|
||||
t.Errorf("expected success counter=0, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_GetZoneRecords_Panic(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockDNSProvider{panicOnGetZoneRecords: true})
|
||||
// The recover() block in GetZoneRecords must catch the panic and return an
|
||||
// error, which the observe closure then records as "error".
|
||||
_, err := a.GetZoneRecords("example.com.")
|
||||
if err == nil {
|
||||
t.Fatal("expected panic to be recovered as an error, got nil")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_records", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1 after recovered panic, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetZoneCorrections ------------------------------------------------------
|
||||
|
||||
func TestObserveProviderCall_GetZoneCorrections_Success(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockDNSProvider{})
|
||||
_, _, err := a.GetZoneCorrections("example.com.", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_corrections", "success")); got != 1 {
|
||||
t.Errorf("expected counter=1 for success, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_GetZoneCorrections_Error(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockDNSProvider{getZoneRecordsErr: errors.New("provider down")})
|
||||
_, _, err := a.GetZoneCorrections("example.com.", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "get_zone_corrections", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CreateDomain ------------------------------------------------------------
|
||||
|
||||
func TestObserveProviderCall_CreateDomain_NotSupported(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
// mockDNSProvider does not implement ZoneCreator, so CreateDomain must fail.
|
||||
a := newTestAdapter(&mockDNSProvider{})
|
||||
err := a.CreateDomain("example.com.")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when provider does not support domain creation")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "create_domain", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_CreateDomain_Success(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockZoneCreator{})
|
||||
err := a.CreateDomain("example.com.")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "create_domain", "success")); got != 1 {
|
||||
t.Errorf("expected success counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_CreateDomain_Error(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockZoneCreator{ensureErr: errors.New("zone already exists")})
|
||||
err := a.CreateDomain("example.com.")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "create_domain", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ListZones ---------------------------------------------------------------
|
||||
|
||||
func TestObserveProviderCall_ListZones_NotSupported(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
// mockDNSProvider does not implement ZoneLister, so ListZones must fail.
|
||||
a := newTestAdapter(&mockDNSProvider{})
|
||||
_, err := a.ListZones()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when provider does not support zone listing")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "list_zones", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_ListZones_Success(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockZoneLister{listResult: []string{"example.com", "example.net"}})
|
||||
zones, err := a.ListZones()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(zones) != 2 {
|
||||
t.Errorf("expected 2 zones, got %d", len(zones))
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "list_zones", "success")); got != 1 {
|
||||
t.Errorf("expected success counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveProviderCall_ListZones_Error(t *testing.T) {
|
||||
metrics.ProviderAPICallsTotal.Reset()
|
||||
|
||||
a := newTestAdapter(&mockZoneLister{listErr: errors.New("API rate limit")})
|
||||
_, err := a.ListZones()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(metrics.ProviderAPICallsTotal.WithLabelValues("TEST_PROVIDER", "list_zones", "error")); got != 1 {
|
||||
t.Errorf("expected error counter=1, got %v", got)
|
||||
}
|
||||
}
|
||||
66
internal/api-admin/controller/check_controller.go
Normal file
66
internal/api-admin/controller/check_controller.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// 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/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
apicontroller "git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
)
|
||||
|
||||
// AdminCheckerController handles admin checker-related API endpoints.
|
||||
// It embeds CheckerController and overrides GetCheckerOptions to return a flat
|
||||
// (non-positional) map scoped to nil (global/admin) level.
|
||||
type AdminCheckerController struct {
|
||||
*apicontroller.CheckerController
|
||||
}
|
||||
|
||||
// NewAdminCheckerController creates a new AdminCheckerController.
|
||||
func NewAdminCheckerController(optionsUC *checkerUC.CheckerOptionsUsecase) *AdminCheckerController {
|
||||
return &AdminCheckerController{
|
||||
// countManualTriggers=false because admin has no budgetChecker (nil),
|
||||
// which means the flag is inert on this path — value is for clarity.
|
||||
CheckerController: apicontroller.NewCheckerController(nil, optionsUC, nil, nil, nil, nil, false),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCheckerOptions returns admin-level options (nil scope) for a checker as a flat map.
|
||||
//
|
||||
// @Summary Get admin-level checker options
|
||||
// @Tags admin,checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Success 200 {object} checker.CheckerOptions
|
||||
// @Router /checkers/{checkerId}/options [get]
|
||||
func (cc *AdminCheckerController) GetCheckerOptions(c *gin.Context) {
|
||||
checkerID := c.Param("checkerId")
|
||||
opts, err := cc.OptionsUC.GetCheckerOptions(checkerID, nil, nil, nil)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, opts)
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ func NewDomainController(
|
|||
func (dc *DomainController) ListDomains(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
if user != nil {
|
||||
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter)
|
||||
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter, nil)
|
||||
apidc.GetDomains(c)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
100
internal/api-admin/controller/scheduler_controller.go
Normal file
100
internal/api-admin/controller/scheduler_controller.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
)
|
||||
|
||||
// AdminSchedulerController handles admin scheduler API endpoints.
|
||||
type AdminSchedulerController struct {
|
||||
scheduler *checkerUC.Scheduler
|
||||
}
|
||||
|
||||
// NewAdminSchedulerController creates a new AdminSchedulerController.
|
||||
func NewAdminSchedulerController(scheduler *checkerUC.Scheduler) *AdminSchedulerController {
|
||||
return &AdminSchedulerController{scheduler: scheduler}
|
||||
}
|
||||
|
||||
// GetSchedulerStatus returns the current scheduler status.
|
||||
//
|
||||
// @Summary Get scheduler status
|
||||
// @Tags admin-scheduler
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} checkerUC.SchedulerStatus
|
||||
// @Router /scheduler [get]
|
||||
func (s *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s.scheduler.GetStatus())
|
||||
}
|
||||
|
||||
// EnableScheduler starts the scheduler and returns updated status.
|
||||
//
|
||||
// @Summary Enable the scheduler
|
||||
// @Tags admin-scheduler
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} checkerUC.SchedulerStatus
|
||||
// @Failure 500 {object} object
|
||||
// @Router /scheduler/enable [post]
|
||||
func (s *AdminSchedulerController) EnableScheduler(c *gin.Context) {
|
||||
if err := s.scheduler.SetEnabled(c.Request.Context(), true); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.scheduler.GetStatus())
|
||||
}
|
||||
|
||||
// DisableScheduler stops the scheduler and returns updated status.
|
||||
//
|
||||
// @Summary Disable the scheduler
|
||||
// @Tags admin-scheduler
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} checkerUC.SchedulerStatus
|
||||
// @Failure 500 {object} object
|
||||
// @Router /scheduler/disable [post]
|
||||
func (s *AdminSchedulerController) DisableScheduler(c *gin.Context) {
|
||||
if err := s.scheduler.SetEnabled(c.Request.Context(), false); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.scheduler.GetStatus())
|
||||
}
|
||||
|
||||
// RescheduleUpcoming rebuilds the job queue and returns the new count.
|
||||
//
|
||||
// @Summary Rebuild the scheduler queue
|
||||
// @Tags admin-scheduler
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} map[string]int
|
||||
// @Router /scheduler/reschedule-upcoming [post]
|
||||
func (s *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) {
|
||||
n := s.scheduler.RebuildQueue()
|
||||
c.JSON(http.StatusOK, gin.H{"rescheduled": n})
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ package controller
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
|
|
@ -168,7 +169,20 @@ func (uc *UserController) UpdateUser(c *gin.Context) {
|
|||
}
|
||||
uu.Id = user.Id
|
||||
|
||||
happydns.ApiResponse(c, uu, uc.store.CreateOrUpdateUser(uu))
|
||||
updated, err := uc.userService.UpdateUser(uu.Id, func(u *happydns.User) {
|
||||
// Stamp quota update time if quota fields changed.
|
||||
if uu.Quota != u.Quota {
|
||||
uu.Quota.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
u.Email = uu.Email
|
||||
u.CreatedAt = uu.CreatedAt
|
||||
u.LastSeen = uu.LastSeen
|
||||
u.Settings = uu.Settings
|
||||
u.Quota = uu.Quota
|
||||
})
|
||||
|
||||
happydns.ApiResponse(c, updated, err)
|
||||
}
|
||||
|
||||
// deleteUser removes a specific user from the database.
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ func (zc *ZoneController) DeleteZone(c *gin.Context) {
|
|||
// @Router /users/{uid}/domains/{domain}/zones/{zoneid} [get]
|
||||
// @Router /users/{uid}/providers/{pid}/domains/{domain}/zones/{zoneid} [get]
|
||||
func (zc *ZoneController) GetZone(c *gin.Context) {
|
||||
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService)
|
||||
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService, nil)
|
||||
apizc.GetZone(c)
|
||||
}
|
||||
|
||||
|
|
|
|||
51
internal/api-admin/route/checker.go
Normal file
51
internal/api-admin/route/checker.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api-admin/controller"
|
||||
)
|
||||
|
||||
func declareCheckersRoutes(router *gin.RouterGroup, dep Dependencies) {
|
||||
if dep.CheckerOptionsUC == nil {
|
||||
return
|
||||
}
|
||||
cc := controller.NewAdminCheckerController(dep.CheckerOptionsUC)
|
||||
|
||||
apiCheckersRoutes := router.Group("/checkers")
|
||||
apiCheckersRoutes.GET("", cc.ListCheckers)
|
||||
|
||||
apiCheckerRoutes := apiCheckersRoutes.Group("/:checkerId")
|
||||
apiCheckerRoutes.Use(cc.CheckerHandler)
|
||||
apiCheckerRoutes.GET("", cc.GetChecker)
|
||||
|
||||
apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options")
|
||||
apiCheckerOptionsRoutes.GET("", cc.GetCheckerOptions)
|
||||
apiCheckerOptionsRoutes.POST("", cc.AddCheckerOptions)
|
||||
apiCheckerOptionsRoutes.PUT("", cc.ChangeCheckerOptions)
|
||||
|
||||
apiCheckerOptionRoutes := apiCheckerOptionsRoutes.Group("/:optname")
|
||||
apiCheckerOptionRoutes.GET("", cc.GetCheckerOption)
|
||||
apiCheckerOptionRoutes.PUT("", cc.SetCheckerOption)
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import (
|
|||
|
||||
api "git.happydns.org/happyDomain/internal/api/route"
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -41,14 +42,18 @@ type Dependencies struct {
|
|||
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
|
||||
ZoneImporter happydns.ZoneImporterUsecase
|
||||
ZoneService happydns.ZoneServiceUsecase
|
||||
CheckerOptionsUC *checkerUC.CheckerOptionsUsecase
|
||||
CheckScheduler *checkerUC.Scheduler
|
||||
}
|
||||
|
||||
func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage, dep Dependencies) {
|
||||
apiRoutes := router.Group("/api")
|
||||
|
||||
declareBackupRoutes(cfg, apiRoutes, s)
|
||||
declareCheckersRoutes(apiRoutes, dep)
|
||||
declareDomainRoutes(apiRoutes, dep, s)
|
||||
declareProviderRoutes(apiRoutes, dep, s)
|
||||
declareSchedulerRoutes(apiRoutes, dep)
|
||||
declareSessionsRoutes(cfg, apiRoutes, s)
|
||||
declareUserAuthsRoutes(apiRoutes, dep, s)
|
||||
declareUsersRoutes(apiRoutes, dep, s)
|
||||
|
|
|
|||
41
internal/api-admin/route/scheduler.go
Normal file
41
internal/api-admin/route/scheduler.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api-admin/controller"
|
||||
)
|
||||
|
||||
func declareSchedulerRoutes(router *gin.RouterGroup, dep Dependencies) {
|
||||
if dep.CheckScheduler == nil {
|
||||
return
|
||||
}
|
||||
ctrl := controller.NewAdminSchedulerController(dep.CheckScheduler)
|
||||
|
||||
schedulerRoute := router.Group("/scheduler")
|
||||
schedulerRoute.GET("", ctrl.GetSchedulerStatus)
|
||||
schedulerRoute.POST("/enable", ctrl.EnableScheduler)
|
||||
schedulerRoute.POST("/disable", ctrl.DisableScheduler)
|
||||
schedulerRoute.POST("/reschedule-upcoming", ctrl.RescheduleUpcoming)
|
||||
}
|
||||
272
internal/api/controller/checker.go
Normal file
272
internal/api/controller/checker.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// 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/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// CheckerController handles checker-related API endpoints.
|
||||
type CheckerController struct {
|
||||
engine happydns.CheckerEngine
|
||||
OptionsUC *checkerUC.CheckerOptionsUsecase
|
||||
planUC *checkerUC.CheckPlanUsecase
|
||||
statusUC *checkerUC.CheckStatusUsecase
|
||||
plannedProvider checkerUC.PlannedJobProvider
|
||||
budgetChecker checkerUC.BudgetChecker
|
||||
countManualTriggers bool
|
||||
}
|
||||
|
||||
// NewCheckerController creates a new CheckerController.
|
||||
//
|
||||
// countManualTriggers controls whether manual (API-triggered) checker runs
|
||||
// count against the user's MaxChecksPerDay budget. When true and
|
||||
// budgetChecker is non-nil, TriggerCheck refuses the request with HTTP 429
|
||||
// once the user is over budget and increments the counter on success.
|
||||
// When false, manual triggers bypass the quota entirely (legacy behavior).
|
||||
// The value is ignored when budgetChecker is nil.
|
||||
func NewCheckerController(
|
||||
engine happydns.CheckerEngine,
|
||||
optionsUC *checkerUC.CheckerOptionsUsecase,
|
||||
planUC *checkerUC.CheckPlanUsecase,
|
||||
statusUC *checkerUC.CheckStatusUsecase,
|
||||
plannedProvider checkerUC.PlannedJobProvider,
|
||||
budgetChecker checkerUC.BudgetChecker,
|
||||
countManualTriggers bool,
|
||||
) *CheckerController {
|
||||
return &CheckerController{
|
||||
engine: engine,
|
||||
OptionsUC: optionsUC,
|
||||
planUC: planUC,
|
||||
statusUC: statusUC,
|
||||
plannedProvider: plannedProvider,
|
||||
budgetChecker: budgetChecker,
|
||||
countManualTriggers: countManualTriggers,
|
||||
}
|
||||
}
|
||||
|
||||
// StatusUC returns the CheckStatusUsecase for use by other controllers.
|
||||
func (cc *CheckerController) StatusUC() *checkerUC.CheckStatusUsecase {
|
||||
return cc.statusUC
|
||||
}
|
||||
|
||||
// targetFromContext builds a CheckTarget from middleware context values.
|
||||
func targetFromContext(c *gin.Context) happydns.CheckTarget {
|
||||
user := middleware.MyUser(c)
|
||||
target := happydns.CheckTarget{}
|
||||
if user != nil {
|
||||
target.UserId = user.Id.String()
|
||||
}
|
||||
if domain, exists := c.Get("domain"); exists {
|
||||
d := domain.(*happydns.Domain)
|
||||
target.DomainId = d.Id.String()
|
||||
}
|
||||
if sid, exists := c.Get("serviceid"); exists {
|
||||
id := sid.(happydns.Identifier)
|
||||
target.ServiceId = id.String()
|
||||
if z, zExists := c.Get("zone"); zExists {
|
||||
zone := z.(*happydns.Zone)
|
||||
if _, svc := zone.FindService(id); svc != nil {
|
||||
target.ServiceType = svc.Type
|
||||
}
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// --- Global checker routes ---
|
||||
|
||||
// ListCheckers returns all registered checker definitions.
|
||||
//
|
||||
// @Summary List available checkers
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]checker.CheckerDefinition
|
||||
// @Router /checkers [get]
|
||||
func (cc *CheckerController) ListCheckers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, checkerPkg.GetCheckers())
|
||||
}
|
||||
|
||||
// GetChecker returns a specific checker definition.
|
||||
//
|
||||
// @Summary Get a checker definition
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Success 200 {object} checker.CheckerDefinition
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /checkers/{checkerId} [get]
|
||||
func (cc *CheckerController) GetChecker(c *gin.Context) {
|
||||
def, _ := c.Get("checker")
|
||||
c.JSON(http.StatusOK, def)
|
||||
}
|
||||
|
||||
// CheckerHandler is a middleware that validates the checkerId path parameter and sets "checker" in context.
|
||||
func (cc *CheckerController) CheckerHandler(c *gin.Context) {
|
||||
checkerID := c.Param("checkerId")
|
||||
def := checkerPkg.FindChecker(checkerID)
|
||||
if def == nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Checker not found"})
|
||||
return
|
||||
}
|
||||
c.Set("checker", def)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// --- Scoped routes (domain/service) ---
|
||||
|
||||
// ListAvailableChecks lists all checkers with their latest status for a target.
|
||||
//
|
||||
// @Summary List available checks with status
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {array} happydns.CheckerStatus
|
||||
// @Router /domains/{domain}/checkers [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers [get]
|
||||
func (cc *CheckerController) ListAvailableChecks(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
|
||||
result, err := cc.statusUC.ListCheckerStatuses(target)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// TriggerCheck manually triggers a checker execution.
|
||||
// By default the check runs asynchronously and returns an Execution (HTTP 202).
|
||||
// Pass ?sync=true to block until the check completes and return a CheckEvaluation (HTTP 200).
|
||||
//
|
||||
// @Summary Trigger a manual check
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param sync query bool false "Run synchronously"
|
||||
// @Param body body happydns.CheckerRunRequest false "Run request with options and enabled rules"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.CheckEvaluation
|
||||
// @Success 202 {object} happydns.Execution
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Failure 429 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions [post]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [post]
|
||||
func (cc *CheckerController) TriggerCheck(c *gin.Context) {
|
||||
cname := c.Param("checkerId")
|
||||
|
||||
var req happydns.CheckerRunRequest
|
||||
// Body is optional; io.EOF means no body was sent, which is valid (no custom options or rules).
|
||||
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
target := targetFromContext(c)
|
||||
if err := cc.OptionsUC.ValidateOptions(cname, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), req.Options, true); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce the daily check quota on manual triggers when configured.
|
||||
// Interval=0 means "long-interval" in UserGater terms, so manual triggers
|
||||
// are only denied at the hard limit — never by interval-aware throttling.
|
||||
if cc.countManualTriggers && cc.budgetChecker != nil && !cc.budgetChecker.AllowWithInterval(target, 0) {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"errmsg": "daily check quota exhausted; try again after 00:00 UTC"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build a temporary plan from enabled rules if provided.
|
||||
var plan *happydns.CheckPlan
|
||||
if len(req.EnabledRules) > 0 {
|
||||
plan = &happydns.CheckPlan{
|
||||
CheckerID: cname,
|
||||
Target: target,
|
||||
Enabled: req.EnabledRules,
|
||||
}
|
||||
}
|
||||
|
||||
exec, err := cc.engine.CreateExecution(cname, target, plan)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
// Count the manual execution against the user's daily budget. Mirrors
|
||||
// the scheduler's onExecute semantics: increment after CreateExecution
|
||||
// succeeds, regardless of whether RunExecution later fails.
|
||||
if cc.countManualTriggers && cc.budgetChecker != nil {
|
||||
cc.budgetChecker.IncrementUsage(target)
|
||||
}
|
||||
|
||||
if c.Query("sync") == "true" {
|
||||
eval, err := cc.engine.RunExecution(c.Request.Context(), exec, plan, req.Options)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, eval)
|
||||
} else {
|
||||
go func() {
|
||||
if _, err := cc.engine.RunExecution(context.WithoutCancel(c.Request.Context()), exec, plan, req.Options); err != nil {
|
||||
log.Printf("async RunExecution error for checker %q execution %v: %v", cname, exec.Id, err)
|
||||
}
|
||||
}()
|
||||
c.JSON(http.StatusAccepted, exec)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExecutionStatus returns the status of an execution.
|
||||
//
|
||||
// @Summary Get execution status
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.Execution
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [get]
|
||||
func (cc *CheckerController) GetExecutionStatus(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
c.JSON(http.StatusOK, exec)
|
||||
}
|
||||
303
internal/api/controller/checker_metrics.go
Normal file
303
internal/api/controller/checker_metrics.go
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
// 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/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// respondWithMetrics writes metrics in the format requested by the Accept header.
|
||||
// JSON is the default (preserving the previous API contract for clients that
|
||||
// send Accept: */* or omit the header). Prometheus text exposition is only
|
||||
// returned when explicitly requested via Accept: text/plain.
|
||||
func respondWithMetrics(c *gin.Context, metrics []happydns.CheckMetric) {
|
||||
if metrics == nil {
|
||||
metrics = []happydns.CheckMetric{}
|
||||
}
|
||||
|
||||
if wantsPrometheusText(c.GetHeader("Accept")) {
|
||||
c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(renderPrometheus(metrics)))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
}
|
||||
|
||||
const maxLimit = 1000
|
||||
|
||||
// wantsPrometheusText returns true when the Accept header explicitly asks for
|
||||
// text/plain (or the Prometheus exposition media type) without also accepting
|
||||
// JSON. This keeps the JSON API the default for browsers and generic clients
|
||||
// while letting `curl -H 'Accept: text/plain'` opt into the Prometheus format.
|
||||
func wantsPrometheusText(accept string) bool {
|
||||
if accept == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(accept, "application/json") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(accept, "text/plain") ||
|
||||
strings.Contains(accept, "application/openmetrics-text")
|
||||
}
|
||||
|
||||
// escapePromLabelValue escapes a label value for the Prometheus text exposition
|
||||
// format. The spec only allows three escape sequences inside label values:
|
||||
// `\\`, `\"` and `\n`. Using fmt's %q is unsafe because it can emit \xNN or
|
||||
// \uNNNN sequences that Prometheus rejects.
|
||||
func escapePromLabelValue(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 2)
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch c := s[i]; c {
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderPrometheus formats metrics as Prometheus text exposition format
|
||||
// (version 0.0.4). It only emits constructs allowed by that format: HELP/TYPE
|
||||
// metadata and untyped samples — no OpenMetrics-only directives such as # UNIT.
|
||||
func renderPrometheus(metrics []happydns.CheckMetric) string {
|
||||
type metricMeta struct {
|
||||
unit string
|
||||
}
|
||||
seen := map[string]metricMeta{}
|
||||
var names []string
|
||||
|
||||
for _, m := range metrics {
|
||||
if _, ok := seen[m.Name]; !ok {
|
||||
seen[m.Name] = metricMeta{unit: m.Unit}
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
var b strings.Builder
|
||||
nameIdx := map[string]int{}
|
||||
for i, name := range names {
|
||||
nameIdx[name] = i
|
||||
}
|
||||
|
||||
// Sort metrics by name order, then by timestamp.
|
||||
sorted := make([]happydns.CheckMetric, len(metrics))
|
||||
copy(sorted, metrics)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
ni, nj := nameIdx[sorted[i].Name], nameIdx[sorted[j].Name]
|
||||
if ni != nj {
|
||||
return ni < nj
|
||||
}
|
||||
return sorted[i].Timestamp.Before(sorted[j].Timestamp)
|
||||
})
|
||||
|
||||
currentName := ""
|
||||
for _, m := range sorted {
|
||||
if m.Name != currentName {
|
||||
currentName = m.Name
|
||||
meta := seen[m.Name]
|
||||
if meta.unit != "" {
|
||||
// Surface the unit as a HELP comment so it stays parseable
|
||||
// under Prometheus text 0.0.4 (which has no # UNIT directive).
|
||||
fmt.Fprintf(&b, "# HELP %s unit: %s\n", m.Name, meta.unit)
|
||||
}
|
||||
fmt.Fprintf(&b, "# TYPE %s untyped\n", m.Name)
|
||||
}
|
||||
|
||||
b.WriteString(m.Name)
|
||||
if len(m.Labels) > 0 {
|
||||
b.WriteByte('{')
|
||||
first := true
|
||||
labelKeys := make([]string, 0, len(m.Labels))
|
||||
for k := range m.Labels {
|
||||
labelKeys = append(labelKeys, k)
|
||||
}
|
||||
sort.Strings(labelKeys)
|
||||
for _, k := range labelKeys {
|
||||
if !first {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
fmt.Fprintf(&b, "%s=\"%s\"", k, escapePromLabelValue(m.Labels[k]))
|
||||
first = false
|
||||
}
|
||||
b.WriteByte('}')
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, " %g", m.Value)
|
||||
if !m.Timestamp.IsZero() {
|
||||
fmt.Fprintf(&b, " %d", m.Timestamp.UnixMilli())
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func getLimitParam(c *gin.Context, defaultLimit int) int {
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
if parsed > maxLimit {
|
||||
return maxLimit
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultLimit
|
||||
}
|
||||
|
||||
// GetUserMetrics returns metrics across all checkers for the authenticated user.
|
||||
//
|
||||
// @Summary Get all user metrics
|
||||
// @Description Returns metrics from all recent executions for the authenticated user. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
|
||||
// @Tags checkers
|
||||
// @Produce json,plain
|
||||
// @Param limit query int false "Maximum number of executions to extract metrics from (default: 100)"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /checkers/metrics [get]
|
||||
func (cc *CheckerController) GetUserMetrics(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
userID := happydns.TargetIdentifier(target.UserId)
|
||||
if userID == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := getLimitParam(c, 100)
|
||||
metrics, err := cc.statusUC.GetMetricsByUser(*userID, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
|
||||
// GetDomainMetrics returns metrics for a domain and its service children.
|
||||
//
|
||||
// @Summary Get domain metrics
|
||||
// @Description Returns metrics from recent executions for a domain and all its services. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
|
||||
// @Tags checkers
|
||||
// @Produce json,plain
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param limit query int false "Maximum number of executions (default: 100)"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /domains/{domain}/checkers/metrics [get]
|
||||
func (cc *CheckerController) GetDomainMetrics(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
domainID := happydns.TargetIdentifier(target.DomainId)
|
||||
if domainID == nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Domain context required"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := getLimitParam(c, 100)
|
||||
metrics, err := cc.statusUC.GetMetricsByDomain(*domainID, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
|
||||
// GetCheckerMetrics returns metrics for a specific checker on a target.
|
||||
//
|
||||
// @Summary Get checker metrics
|
||||
// @Description Returns metrics from recent executions of a specific checker on a target. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
|
||||
// @Tags checkers
|
||||
// @Produce json,plain
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string false "Zone identifier"
|
||||
// @Param subdomain path string false "Subdomain"
|
||||
// @Param serviceid path string false "Service identifier"
|
||||
// @Param limit query int false "Maximum number of executions (default: 100)"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/metrics [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/metrics [get]
|
||||
func (cc *CheckerController) GetCheckerMetrics(c *gin.Context) {
|
||||
checkerID := c.Param("checkerId")
|
||||
target := targetFromContext(c)
|
||||
|
||||
limit := getLimitParam(c, 100)
|
||||
metrics, err := cc.statusUC.GetMetricsByChecker(checkerID, target, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
|
||||
// GetExecutionMetrics returns metrics for a single execution.
|
||||
//
|
||||
// @Summary Get execution metrics
|
||||
// @Description Returns metrics extracted from a single execution's observation snapshot. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
|
||||
// @Tags checkers
|
||||
// @Produce json,plain
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string false "Zone identifier"
|
||||
// @Param subdomain path string false "Subdomain"
|
||||
// @Param serviceid path string false "Service identifier"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/metrics [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/metrics [get]
|
||||
func (cc *CheckerController) GetExecutionMetrics(c *gin.Context) {
|
||||
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
||||
return
|
||||
}
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
exec, err := cc.statusUC.GetExecution(target, execID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
||||
return
|
||||
}
|
||||
|
||||
metrics, err := cc.statusUC.GetMetricsByExecution(target, exec.Id)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
117
internal/api/controller/checker_metrics_test.go
Normal file
117
internal/api/controller/checker_metrics_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// 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/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/common/expfmt"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestRenderPrometheus_ParsesAsValidExposition(t *testing.T) {
|
||||
// Include a label value with characters that fmt's %q would have escaped
|
||||
// as \xNN / \uNNNN — sequences which are NOT valid in Prometheus text
|
||||
// format. The output must still parse cleanly via the upstream parser.
|
||||
out := renderPrometheus([]happydns.CheckMetric{
|
||||
{
|
||||
Name: "happydomain_check_latency_seconds",
|
||||
Unit: "seconds",
|
||||
Value: 0.123,
|
||||
Timestamp: time.Unix(1700000000, 0),
|
||||
Labels: map[string]string{
|
||||
"target": "exämple.com", // non-ASCII
|
||||
"note": "line1\nline2", // newline (must become \n)
|
||||
"quoted": `he said "hi"`, // quotes
|
||||
"slash": `a\b`, // backslash
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "happydomain_check_latency_seconds",
|
||||
Value: 0.456,
|
||||
Labels: map[string]string{
|
||||
"target": "second.example",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p := expfmt.NewTextParser(model.LegacyValidation)
|
||||
if _, err := p.TextToMetricFamilies(strings.NewReader(out)); err != nil {
|
||||
t.Fatalf("renderPrometheus output is not valid Prometheus text format: %v\noutput:\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPrometheus_EscapesLabelValues(t *testing.T) {
|
||||
out := renderPrometheus([]happydns.CheckMetric{{
|
||||
Name: "x",
|
||||
Value: 1,
|
||||
Labels: map[string]string{
|
||||
"a": `\`,
|
||||
"b": `"`,
|
||||
"c": "\n",
|
||||
},
|
||||
}})
|
||||
if !strings.Contains(out, `a="\\"`) {
|
||||
t.Errorf("backslash not escaped: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `b="\""`) {
|
||||
t.Errorf("quote not escaped: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `c="\n"`) {
|
||||
t.Errorf("newline not escaped: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPrometheus_NoOpenMetricsDirectives(t *testing.T) {
|
||||
out := renderPrometheus([]happydns.CheckMetric{{
|
||||
Name: "x",
|
||||
Unit: "seconds",
|
||||
Value: 1,
|
||||
}})
|
||||
if strings.Contains(out, "# UNIT") {
|
||||
t.Errorf("output contains OpenMetrics-only # UNIT directive incompatible with text/plain;version=0.0.4: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWantsPrometheusText(t *testing.T) {
|
||||
cases := []struct {
|
||||
accept string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"*/*", false},
|
||||
{"application/json", false},
|
||||
{"application/json, text/plain", false}, // explicit JSON wins
|
||||
{"text/plain", true},
|
||||
{"text/plain; version=0.0.4", true},
|
||||
{"application/openmetrics-text; version=1.0.0", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := wantsPrometheusText(tc.accept); got != tc.want {
|
||||
t.Errorf("wantsPrometheusText(%q) = %v, want %v", tc.accept, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
223
internal/api/controller/checker_options.go
Normal file
223
internal/api/controller/checker_options.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// 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/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// GetCheckerOptions returns layered options for a checker, from least to most specific scope.
|
||||
// The scope is determined by the route context (user-only at /api/checkers, domain/service at scoped routes).
|
||||
//
|
||||
// @Summary Get checker options by scope
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {array} happydns.CheckerOptionsPositional
|
||||
// @Router /checkers/{checkerId}/options [get]
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/options [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [get]
|
||||
func (cc *CheckerController) GetCheckerOptions(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
positionals, err := cc.OptionsUC.GetCheckerOptionsPositional(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId))
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if positionals == nil {
|
||||
positionals = []*happydns.CheckerOptionsPositional{}
|
||||
}
|
||||
|
||||
// Append auto-fill resolved values so the frontend can display them.
|
||||
autoFillOpts, err := cc.OptionsUC.GetAutoFillOptions(checkerID, target)
|
||||
if err == nil && autoFillOpts != nil {
|
||||
positionals = append(positionals, &happydns.CheckerOptionsPositional{
|
||||
CheckName: checkerID,
|
||||
UserId: happydns.TargetIdentifier(target.UserId),
|
||||
DomainId: happydns.TargetIdentifier(target.DomainId),
|
||||
ServiceId: happydns.TargetIdentifier(target.ServiceId),
|
||||
Options: autoFillOpts,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, positionals)
|
||||
}
|
||||
|
||||
// AddCheckerOptions partially merges options at the current scope.
|
||||
//
|
||||
// @Summary Merge checker options
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param options body checker.CheckerOptions true "Options to merge"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} checker.CheckerOptions
|
||||
// @Router /checkers/{checkerId}/options [post]
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/options [post]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [post]
|
||||
func (cc *CheckerController) AddCheckerOptions(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
var opts happydns.CheckerOptions
|
||||
if err := c.ShouldBindJSON(&opts); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
merged, err := cc.OptionsUC.MergeCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), merged, false); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := cc.OptionsUC.AddCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, merged)
|
||||
}
|
||||
|
||||
// ChangeCheckerOptions fully replaces options at the current scope.
|
||||
//
|
||||
// @Summary Replace checker options
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param options body checker.CheckerOptions true "Options to set"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} checker.CheckerOptions
|
||||
// @Router /checkers/{checkerId}/options [put]
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/options [put]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [put]
|
||||
func (cc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
var opts happydns.CheckerOptions
|
||||
if err := c.ShouldBindJSON(&opts); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts, false); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := cc.OptionsUC.SetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, opts)
|
||||
}
|
||||
|
||||
// GetCheckerOption returns a single option value at the current scope.
|
||||
//
|
||||
// @Summary Get a single checker option
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param optname path string true "Option name"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} any
|
||||
// @Router /checkers/{checkerId}/options/{optname} [get]
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [get]
|
||||
func (cc *CheckerController) GetCheckerOption(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
optname := c.Param("optname")
|
||||
val, err := cc.OptionsUC.GetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if val == nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Option not set"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, val)
|
||||
}
|
||||
|
||||
// SetCheckerOption sets a single option value at the current scope.
|
||||
//
|
||||
// @Summary Set a single checker option
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param optname path string true "Option name"
|
||||
// @Param value body any true "Option value"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} any
|
||||
// @Router /checkers/{checkerId}/options/{optname} [put]
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [put]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [put]
|
||||
func (cc *CheckerController) SetCheckerOption(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
optname := c.Param("optname")
|
||||
var value any
|
||||
if err := c.ShouldBindJSON(&value); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
// Validate the full merged options after inserting the key.
|
||||
existing, err := cc.OptionsUC.GetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId))
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
existing[optname] = value
|
||||
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), existing, false); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := cc.OptionsUC.SetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname, value); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, value)
|
||||
}
|
||||
196
internal/api/controller/checker_plans.go
Normal file
196
internal/api/controller/checker_plans.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// 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/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// PlanHandler is a middleware that validates the planId path parameter,
|
||||
// checks target scope, and sets "plan" in context.
|
||||
func (cc *CheckerController) PlanHandler(c *gin.Context) {
|
||||
planID, err := happydns.NewIdentifierFromString(c.Param("planId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid plan ID"})
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := cc.planUC.GetCheckPlan(targetFromContext(c), planID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("plan", plan)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// ListCheckPlans returns all check plans for a domain.
|
||||
//
|
||||
// @Summary List check plans for a domain
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {array} happydns.CheckPlan
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/plans [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [get]
|
||||
func (cc *CheckerController) ListCheckPlans(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
|
||||
plans, err := cc.planUC.ListCheckPlansByTargetAndChecker(target, checkerID)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, plans)
|
||||
}
|
||||
|
||||
// CreateCheckPlan creates a new check plan.
|
||||
//
|
||||
// @Summary Create a check plan
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param plan body happydns.CheckPlan true "Check plan to create"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 201 {object} happydns.CheckPlan
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/plans [post]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [post]
|
||||
func (cc *CheckerController) CreateCheckPlan(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
|
||||
var plan happydns.CheckPlan
|
||||
if err := c.ShouldBindJSON(&plan); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
plan.Target = target
|
||||
plan.CheckerID = c.Param("checkerId")
|
||||
|
||||
if err := cc.planUC.CreateCheckPlan(&plan); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("cannot create check plan: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, plan)
|
||||
}
|
||||
|
||||
// GetCheckPlan returns a specific check plan.
|
||||
//
|
||||
// @Summary Get a check plan
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param planId path string true "Plan ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.CheckPlan
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [get]
|
||||
func (cc *CheckerController) GetCheckPlan(c *gin.Context) {
|
||||
plan := c.MustGet("plan").(*happydns.CheckPlan)
|
||||
c.JSON(http.StatusOK, plan)
|
||||
}
|
||||
|
||||
// UpdateCheckPlan updates an existing check plan.
|
||||
//
|
||||
// @Summary Update a check plan
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param planId path string true "Plan ID"
|
||||
// @Param plan body happydns.CheckPlan true "Updated check plan"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.CheckPlan
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [put]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [put]
|
||||
func (cc *CheckerController) UpdateCheckPlan(c *gin.Context) {
|
||||
existing := c.MustGet("plan").(*happydns.CheckPlan)
|
||||
|
||||
var plan happydns.CheckPlan
|
||||
if err := c.ShouldBindJSON(&plan); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
plan.Target = targetFromContext(c)
|
||||
plan.CheckerID = c.Param("checkerId")
|
||||
|
||||
updated, err := cc.planUC.UpdateCheckPlan(plan.Target, existing.Id, &plan)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("cannot update check plan: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// DeleteCheckPlan deletes a check plan.
|
||||
//
|
||||
// @Summary Delete a check plan
|
||||
// @Tags checkers
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param planId path string true "Plan ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 204
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [delete]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [delete]
|
||||
func (cc *CheckerController) DeleteCheckPlan(c *gin.Context) {
|
||||
plan := c.MustGet("plan").(*happydns.CheckPlan)
|
||||
|
||||
if err := cc.planUC.DeleteCheckPlan(targetFromContext(c), plan.Id); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
307
internal/api/controller/checker_results.go
Normal file
307
internal/api/controller/checker_results.go
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
// 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/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// ExecutionHandler is a middleware that validates the executionId path parameter,
|
||||
// checks target scope, and sets "execution" in context.
|
||||
func (cc *CheckerController) ExecutionHandler(c *gin.Context) {
|
||||
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
||||
return
|
||||
}
|
||||
|
||||
exec, err := cc.statusUC.GetExecution(targetFromContext(c), execID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("execution", exec)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// ListExecutions returns executions for a checker on a target.
|
||||
//
|
||||
// @Summary List executions for a checker
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param limit query int false "Maximum number of results"
|
||||
// @Param include_planned query bool false "Include upcoming planned executions from the scheduler"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {array} happydns.Execution
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [get]
|
||||
func (cc *CheckerController) ListExecutions(c *gin.Context) {
|
||||
cname := c.Param("checkerId")
|
||||
target := targetFromContext(c)
|
||||
|
||||
limit := getLimitParam(c, 0)
|
||||
|
||||
execs, err := cc.statusUC.ListExecutionsByChecker(cname, target, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if execs == nil {
|
||||
execs = []*happydns.Execution{}
|
||||
}
|
||||
|
||||
if c.Query("include_planned") == "true" || c.Query("include_planned") == "1" {
|
||||
planned := checkerUC.ListPlannedExecutions(cc.plannedProvider, cc.budgetChecker, cname, target)
|
||||
execs = append(planned, execs...)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, execs)
|
||||
}
|
||||
|
||||
// DeleteExecution deletes an execution record.
|
||||
//
|
||||
// @Summary Delete an execution
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [delete]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [delete]
|
||||
func (cc *CheckerController) DeleteExecution(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
|
||||
if err := cc.statusUC.DeleteExecution(targetFromContext(c), exec.Id); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteCheckerExecutions deletes all executions for a checker on a target.
|
||||
//
|
||||
// @Summary Delete all executions for a checker
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions [delete]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [delete]
|
||||
func (cc *CheckerController) DeleteCheckerExecutions(c *gin.Context) {
|
||||
cname := c.Param("checkerId")
|
||||
target := targetFromContext(c)
|
||||
|
||||
if err := cc.statusUC.DeleteExecutionsByChecker(cname, target); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetExecutionObservations returns the observation snapshot for an execution.
|
||||
//
|
||||
// @Summary Get observations for an execution
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.ObservationSnapshot
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations [get]
|
||||
func (cc *CheckerController) GetExecutionObservations(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
|
||||
snap, err := cc.statusUC.GetObservationsByExecution(targetFromContext(c), exec.Id)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, snap)
|
||||
}
|
||||
|
||||
// GetExecutionObservation returns a specific observation key from an execution's snapshot.
|
||||
//
|
||||
// @Summary Get a specific observation for an execution
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param obsKey path string true "Observation key"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} any
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get]
|
||||
func (cc *CheckerController) GetExecutionObservation(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
obsKey := c.Param("obsKey")
|
||||
|
||||
val, err := cc.statusUC.GetSnapshotByExecution(targetFromContext(c), exec.Id, obsKey)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation not available"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", val)
|
||||
}
|
||||
|
||||
// GetExecutionResults returns the evaluation (per-rule states) for an execution.
|
||||
//
|
||||
// @Summary Get results for an execution
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.CheckEvaluation
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results [get]
|
||||
func (cc *CheckerController) GetExecutionResults(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
|
||||
eval, err := cc.statusUC.GetResultsByExecution(targetFromContext(c), exec.Id)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, eval)
|
||||
}
|
||||
|
||||
// GetExecutionResult returns a specific rule's result from an execution.
|
||||
//
|
||||
// @Summary Get a specific rule result for an execution
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param ruleName path string true "Rule name"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} checker.CheckState
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get]
|
||||
func (cc *CheckerController) GetExecutionResult(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
|
||||
eval, err := cc.statusUC.GetResultsByExecution(targetFromContext(c), exec.Id)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"})
|
||||
return
|
||||
}
|
||||
|
||||
ruleName := c.Param("ruleName")
|
||||
for _, state := range eval.States {
|
||||
if state.Code == ruleName {
|
||||
c.JSON(http.StatusOK, state)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Rule result not found"})
|
||||
}
|
||||
|
||||
// GetExecutionHTMLReport returns the HTML report for a specific observation of an execution.
|
||||
//
|
||||
// @Summary Get execution observation HTML report
|
||||
// @Description Returns the full HTML document generated from an observation's data. Only available for observation providers that implement HTML reporting.
|
||||
// @Tags checkers
|
||||
// @Produce html
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param obsKey path string true "Observation key"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {string} string "HTML document"
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
|
||||
func (cc *CheckerController) GetExecutionHTMLReport(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
obsKey := c.Param("obsKey")
|
||||
|
||||
val, err := cc.statusUC.GetSnapshotByExecution(targetFromContext(c), exec.Id, obsKey)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation not available"})
|
||||
return
|
||||
}
|
||||
|
||||
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, val)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if !supported {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("observation %q does not support HTML reports", obsKey))
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
|
||||
}
|
||||
904
internal/api/controller/checker_test.go
Normal file
904
internal/api/controller/checker_test.go
Normal file
|
|
@ -0,0 +1,904 @@
|
|||
// 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/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// --- Stub types ---
|
||||
|
||||
// stubCheckerEngine implements happydns.CheckerEngine for testing.
|
||||
type stubCheckerEngine struct {
|
||||
exec *happydns.Execution
|
||||
eval *happydns.CheckEvaluation
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubCheckerEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.exec != nil {
|
||||
return s.exec, nil
|
||||
}
|
||||
id, _ := happydns.NewRandomIdentifier()
|
||||
return &happydns.Execution{
|
||||
Id: id,
|
||||
CheckerID: checkerID,
|
||||
Target: target,
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionPending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *stubCheckerEngine) RunExecution(ctx context.Context, exec *happydns.Execution, plan *happydns.CheckPlan, runOpts happydns.CheckerOptions) (*happydns.CheckEvaluation, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.eval != nil {
|
||||
return s.eval, nil
|
||||
}
|
||||
return &happydns.CheckEvaluation{
|
||||
CheckerID: exec.CheckerID,
|
||||
Target: exec.Target,
|
||||
States: []happydns.CheckState{{Status: happydns.StatusOK, Code: "ok"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// testObservationProvider is a no-op provider for tests.
|
||||
type testObservationProvider struct{}
|
||||
|
||||
func (p *testObservationProvider) Key() happydns.ObservationKey { return "test_ctrl_obs" }
|
||||
func (p *testObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
return map[string]any{"v": 1}, nil
|
||||
}
|
||||
|
||||
// testHTMLObservationProvider implements CheckerHTMLReporter for HTML report tests.
|
||||
type testHTMLObservationProvider struct{}
|
||||
|
||||
func (p *testHTMLObservationProvider) Key() happydns.ObservationKey { return "test_html_obs" }
|
||||
func (p *testHTMLObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
return map[string]any{"html": true}, nil
|
||||
}
|
||||
func (p *testHTMLObservationProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
|
||||
return "<html><body>test report</body></html>", nil
|
||||
}
|
||||
|
||||
// testCheckRule produces a fixed status.
|
||||
type testCheckRule struct {
|
||||
name string
|
||||
status happydns.Status
|
||||
}
|
||||
|
||||
func (r *testCheckRule) Name() string { return r.name }
|
||||
func (r *testCheckRule) Description() string { return "test rule: " + r.name }
|
||||
func (r *testCheckRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
|
||||
return happydns.CheckState{Status: r.status, Code: r.name}
|
||||
}
|
||||
|
||||
// registerTestChecker registers a checker for controller tests and returns its ID.
|
||||
// Uses a unique name to avoid collisions with other tests.
|
||||
var testCheckerSeq int
|
||||
|
||||
func registerTestChecker() string {
|
||||
testCheckerSeq++
|
||||
id := fmt.Sprintf("ctrl_test_checker_%d", testCheckerSeq)
|
||||
checkerPkg.RegisterObservationProvider(&testObservationProvider{})
|
||||
checkerPkg.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: id,
|
||||
Name: "Controller Test Checker",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&testCheckRule{name: "rule_a", status: happydns.StatusOK},
|
||||
},
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// newTestController creates a CheckerController with in-memory storage.
|
||||
func newTestController(engine happydns.CheckerEngine) *CheckerController {
|
||||
cc, _ := newTestControllerWithStorage(engine)
|
||||
return cc
|
||||
}
|
||||
|
||||
// newTestControllerWithStorage creates a CheckerController and returns the underlying storage.
|
||||
func newTestControllerWithStorage(engine happydns.CheckerEngine) (*CheckerController, storage.Storage) {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
planUC := checkerUC.NewCheckPlanUsecase(store)
|
||||
statusUC := checkerUC.NewCheckStatusUsecase(store, store, store, store)
|
||||
return NewCheckerController(engine, optionsUC, planUC, statusUC, nil, nil, false), store
|
||||
}
|
||||
|
||||
// newTestControllerWithBudget creates a CheckerController with a custom
|
||||
// BudgetChecker and an explicit countManualTriggers flag, for the
|
||||
// manual-trigger quota tests.
|
||||
func newTestControllerWithBudget(engine happydns.CheckerEngine, budget checkerUC.BudgetChecker, countManualTriggers bool) *CheckerController {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
planUC := checkerUC.NewCheckPlanUsecase(store)
|
||||
statusUC := checkerUC.NewCheckStatusUsecase(store, store, store, store)
|
||||
return NewCheckerController(engine, optionsUC, planUC, statusUC, nil, budget, countManualTriggers)
|
||||
}
|
||||
|
||||
// stubBudgetChecker is a minimal BudgetChecker for controller tests.
|
||||
// allow controls AllowWithInterval; increments counts IncrementUsage calls.
|
||||
type stubBudgetChecker struct {
|
||||
allow bool
|
||||
increments int
|
||||
}
|
||||
|
||||
func (s *stubBudgetChecker) RateLimiterFor(_ string) func(time.Duration) bool {
|
||||
return func(time.Duration) bool { return !s.allow }
|
||||
}
|
||||
func (s *stubBudgetChecker) AllowWithInterval(_ happydns.CheckTarget, _ time.Duration) bool {
|
||||
return s.allow
|
||||
}
|
||||
func (s *stubBudgetChecker) IncrementUsage(_ happydns.CheckTarget) { s.increments++ }
|
||||
|
||||
// countingCheckerEngine wraps stubCheckerEngine and counts CreateExecution calls.
|
||||
type countingCheckerEngine struct {
|
||||
stubCheckerEngine
|
||||
created int
|
||||
}
|
||||
|
||||
func (c *countingCheckerEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) {
|
||||
c.created++
|
||||
return c.stubCheckerEngine.CreateExecution(checkerID, target, plan)
|
||||
}
|
||||
|
||||
// --- targetFromContext tests ---
|
||||
|
||||
func TestTargetFromContext_Empty(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
if target.UserId != "" {
|
||||
t.Errorf("expected empty UserId, got %q", target.UserId)
|
||||
}
|
||||
if target.DomainId != "" {
|
||||
t.Errorf("expected empty DomainId, got %q", target.DomainId)
|
||||
}
|
||||
if target.ServiceId != "" {
|
||||
t.Errorf("expected empty ServiceId, got %q", target.ServiceId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetFromContext_WithUser(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
c.Set("LoggedUser", user)
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
if target.UserId != uid.String() {
|
||||
t.Errorf("expected UserId %q, got %q", uid.String(), target.UserId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetFromContext_WithDomain(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
domain := &happydns.Domain{Id: did}
|
||||
c.Set("domain", domain)
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
if target.DomainId != did.String() {
|
||||
t.Errorf("expected DomainId %q, got %q", did.String(), target.DomainId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetFromContext_WithService(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
sid, _ := happydns.NewRandomIdentifier()
|
||||
c.Set("serviceid", happydns.Identifier(sid))
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
if target.ServiceId != sid.String() {
|
||||
t.Errorf("expected ServiceId %q, got %q", sid.String(), target.ServiceId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetFromContext_WithServiceAndZone(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
sid, _ := happydns.NewRandomIdentifier()
|
||||
svc := &happydns.Service{
|
||||
ServiceMeta: happydns.ServiceMeta{
|
||||
Id: sid,
|
||||
Type: "svcs.TestType",
|
||||
},
|
||||
}
|
||||
zone := &happydns.Zone{
|
||||
Services: map[happydns.Subdomain][]*happydns.Service{
|
||||
"": {svc},
|
||||
},
|
||||
}
|
||||
|
||||
c.Set("serviceid", happydns.Identifier(sid))
|
||||
c.Set("zone", zone)
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
if target.ServiceType != "svcs.TestType" {
|
||||
t.Errorf("expected ServiceType %q, got %q", "svcs.TestType", target.ServiceType)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ListCheckers tests ---
|
||||
|
||||
func TestListCheckers_ReturnsRegistered(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/checkers", nil)
|
||||
|
||||
cc.ListCheckers(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := result[checkerID]; !ok {
|
||||
t.Errorf("expected checker %q in response, got keys: %v", checkerID, keysOf(result))
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// --- CheckerHandler tests ---
|
||||
|
||||
func TestCheckerHandler_NotFound(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/checkers/nonexistent", nil)
|
||||
c.Params = gin.Params{{Key: "checkerId", Value: "nonexistent_checker_xyz"}}
|
||||
|
||||
cc.CheckerHandler(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if _, ok := resp["errmsg"]; !ok {
|
||||
t.Error("expected errmsg in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerHandler_Found(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/checkers/"+checkerID, nil)
|
||||
c.Params = gin.Params{{Key: "checkerId", Value: checkerID}}
|
||||
|
||||
// CheckerHandler calls c.Next(), so we need to verify context is set.
|
||||
// Use a gin engine to test the middleware chain.
|
||||
router := gin.New()
|
||||
router.GET("/checkers/:checkerId", cc.CheckerHandler, cc.GetChecker)
|
||||
|
||||
req := httptest.NewRequest("GET", "/checkers/"+checkerID, nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var def map[string]any
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &def); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if def["id"] != checkerID {
|
||||
t.Errorf("expected checker id %q, got %v", checkerID, def["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- TriggerCheck tests ---
|
||||
|
||||
func TestTriggerCheck_Sync_Returns200(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
eval := &happydns.CheckEvaluation{
|
||||
CheckerID: checkerID,
|
||||
States: []happydns.CheckState{{Status: happydns.StatusOK, Code: "ok"}},
|
||||
}
|
||||
engine := &stubCheckerEngine{eval: eval}
|
||||
cc := newTestController(engine)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
|
||||
body, _ := json.Marshal(happydns.CheckerRunRequest{})
|
||||
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions?sync=true", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
|
||||
c.Set("LoggedUser", user)
|
||||
c.Next()
|
||||
}, cc.TriggerCheck)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if result["checkerId"] != checkerID {
|
||||
t.Errorf("expected checkerId %q, got %v", checkerID, result["checkerId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerCheck_Async_Returns202(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
engine := &stubCheckerEngine{}
|
||||
cc := newTestController(engine)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
|
||||
body, _ := json.Marshal(happydns.CheckerRunRequest{})
|
||||
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
|
||||
c.Set("LoggedUser", user)
|
||||
c.Next()
|
||||
}, cc.TriggerCheck)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerCheck_EngineError_Returns500(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
engine := &stubCheckerEngine{err: fmt.Errorf("engine failure")}
|
||||
cc := newTestController(engine)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
|
||||
body, _ := json.Marshal(happydns.CheckerRunRequest{})
|
||||
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
|
||||
c.Set("LoggedUser", user)
|
||||
c.Next()
|
||||
}, cc.TriggerCheck)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// postTrigger is a helper that wires up LoggedUser via middleware and fires
|
||||
// an async POST to TriggerCheck. Used by the budget-enforcement tests below.
|
||||
func postTrigger(cc *CheckerController, user *happydns.User, checkerID string) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(happydns.CheckerRunRequest{})
|
||||
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
|
||||
c.Set("LoggedUser", user)
|
||||
c.Next()
|
||||
}, cc.TriggerCheck)
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// When countManualTriggers=false, the quota is never consulted and the
|
||||
// counter is never incremented — legacy behavior.
|
||||
func TestTriggerCheck_BudgetBypassWhenDisabled(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
engine := &countingCheckerEngine{}
|
||||
// allow=false would normally refuse, but countManualTriggers=false bypasses.
|
||||
budget := &stubBudgetChecker{allow: false}
|
||||
cc := newTestControllerWithBudget(engine, budget, false)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
w := postTrigger(cc, &happydns.User{Id: uid}, checkerID)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202 (bypass), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if budget.increments != 0 {
|
||||
t.Errorf("expected no IncrementUsage calls when flag off, got %d", budget.increments)
|
||||
}
|
||||
if engine.created != 1 {
|
||||
t.Errorf("expected 1 CreateExecution call, got %d", engine.created)
|
||||
}
|
||||
}
|
||||
|
||||
// When countManualTriggers=true but budgetChecker is nil (e.g. checker
|
||||
// subsystem initialised without a gater), the code falls through without
|
||||
// panicking and behaves like the bypass mode.
|
||||
func TestTriggerCheck_NilBudgetCheckerIgnored(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
engine := &countingCheckerEngine{}
|
||||
cc := newTestControllerWithBudget(engine, nil, true)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
w := postTrigger(cc, &happydns.User{Id: uid}, checkerID)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202 (nil-budget fallback), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if engine.created != 1 {
|
||||
t.Errorf("expected 1 CreateExecution call, got %d", engine.created)
|
||||
}
|
||||
}
|
||||
|
||||
// Happy path with flag on and budget allowing: 202 and the usage counter is
|
||||
// bumped exactly once.
|
||||
func TestTriggerCheck_CountsOnSuccess(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
engine := &countingCheckerEngine{}
|
||||
budget := &stubBudgetChecker{allow: true}
|
||||
cc := newTestControllerWithBudget(engine, budget, true)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
w := postTrigger(cc, &happydns.User{Id: uid}, checkerID)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if budget.increments != 1 {
|
||||
t.Errorf("expected IncrementUsage called once, got %d", budget.increments)
|
||||
}
|
||||
if engine.created != 1 {
|
||||
t.Errorf("expected 1 CreateExecution call, got %d", engine.created)
|
||||
}
|
||||
}
|
||||
|
||||
// Over-budget: the request must be refused with 429 and CreateExecution
|
||||
// must NOT be called (no side-effects on a rejected request).
|
||||
func TestTriggerCheck_RefusedWhenOverBudget(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
|
||||
engine := &countingCheckerEngine{}
|
||||
budget := &stubBudgetChecker{allow: false}
|
||||
cc := newTestControllerWithBudget(engine, budget, true)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
w := postTrigger(cc, &happydns.User{Id: uid}, checkerID)
|
||||
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if engine.created != 0 {
|
||||
t.Errorf("expected no CreateExecution call on 429, got %d", engine.created)
|
||||
}
|
||||
if budget.increments != 0 {
|
||||
t.Errorf("expected no IncrementUsage call on 429, got %d", budget.increments)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetExecutionStatus tests ---
|
||||
|
||||
func TestGetExecutionStatus_ReturnsExecution(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
execID, _ := happydns.NewRandomIdentifier()
|
||||
exec := &happydns.Execution{
|
||||
Id: execID,
|
||||
CheckerID: "test",
|
||||
Status: happydns.ExecutionDone,
|
||||
Result: happydns.CheckState{Status: happydns.StatusOK, Message: "done"},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/executions/"+execID.String(), nil)
|
||||
c.Set("execution", exec)
|
||||
|
||||
cc.GetExecutionStatus(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if result["checkerId"] != "test" {
|
||||
t.Errorf("expected checkerId %q, got %v", "test", result["checkerId"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetChecker tests ---
|
||||
|
||||
func TestGetChecker_ReturnsDefinition(t *testing.T) {
|
||||
checkerID := registerTestChecker()
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
def := checkerPkg.FindChecker(checkerID)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/checkers/"+checkerID, nil)
|
||||
c.Set("checker", def)
|
||||
|
||||
cc.GetChecker(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if result["id"] != checkerID {
|
||||
t.Errorf("expected id %q, got %v", checkerID, result["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- ExecutionHandler tests ---
|
||||
|
||||
func TestExecutionHandler_InvalidID(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/executions/not-valid", nil)
|
||||
c.Params = gin.Params{{Key: "executionId", Value: "not-valid"}}
|
||||
|
||||
cc.ExecutionHandler(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionHandler_NotFound(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
fakeID, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/executions/"+fakeID.String(), nil)
|
||||
c.Params = gin.Params{{Key: "executionId", Value: fakeID.String()}}
|
||||
|
||||
cc.ExecutionHandler(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- PlanHandler tests ---
|
||||
|
||||
func TestPlanHandler_InvalidID(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plans/not-valid", nil)
|
||||
c.Params = gin.Params{{Key: "planId", Value: "not-valid"}}
|
||||
|
||||
cc.PlanHandler(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanHandler_NotFound(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
fakeID, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/plans/"+fakeID.String(), nil)
|
||||
c.Params = gin.Params{{Key: "planId", Value: fakeID.String()}}
|
||||
|
||||
cc.PlanHandler(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetExecutionHTMLReport tests ---
|
||||
|
||||
// seedExecutionWithObservations creates an execution backed by a snapshot containing the given
|
||||
// observation data. It returns the execution (with ID assigned by the store).
|
||||
func seedExecutionWithObservations(t *testing.T, store storage.Storage, target happydns.CheckTarget, data map[happydns.ObservationKey]json.RawMessage) *happydns.Execution {
|
||||
t.Helper()
|
||||
|
||||
snap := &happydns.ObservationSnapshot{
|
||||
Target: target,
|
||||
CollectedAt: time.Now(),
|
||||
Data: data,
|
||||
}
|
||||
if err := store.CreateSnapshot(snap); err != nil {
|
||||
t.Fatalf("CreateSnapshot: %v", err)
|
||||
}
|
||||
|
||||
eval := &happydns.CheckEvaluation{
|
||||
CheckerID: "html_test_checker",
|
||||
Target: target,
|
||||
SnapshotID: snap.Id,
|
||||
}
|
||||
if err := store.CreateEvaluation(eval); err != nil {
|
||||
t.Fatalf("CreateEvaluation: %v", err)
|
||||
}
|
||||
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "html_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
EvaluationID: &eval.Id,
|
||||
}
|
||||
if err := store.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution: %v", err)
|
||||
}
|
||||
return exec
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register the HTML observation provider once for tests.
|
||||
checkerPkg.RegisterObservationProvider(&testHTMLObservationProvider{})
|
||||
}
|
||||
|
||||
func TestGetExecutionHTMLReport_ObservationsNotAvailable(t *testing.T) {
|
||||
cc := newTestController(&stubCheckerEngine{})
|
||||
|
||||
// Create an execution with no evaluation/snapshot backing.
|
||||
fakeExecID, _ := happydns.NewRandomIdentifier()
|
||||
exec := &happydns.Execution{
|
||||
Id: fakeExecID,
|
||||
CheckerID: "html_test_checker",
|
||||
Status: happydns.ExecutionDone,
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/report", nil)
|
||||
c.Set("execution", exec)
|
||||
c.Params = gin.Params{{Key: "obsKey", Value: "test_html_obs"}}
|
||||
|
||||
cc.GetExecutionHTMLReport(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExecutionHTMLReport_ObservationKeyNotFound(t *testing.T) {
|
||||
cc, store := newTestControllerWithStorage(&stubCheckerEngine{})
|
||||
|
||||
target := happydns.CheckTarget{DomainId: "d1"}
|
||||
exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{
|
||||
"test_html_obs": json.RawMessage(`{"v":1}`),
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/report", nil)
|
||||
c.Set("execution", exec)
|
||||
c.Params = gin.Params{{Key: "obsKey", Value: "nonexistent_key"}}
|
||||
|
||||
cc.GetExecutionHTMLReport(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// testNoHTMLObservationProvider is a provider that does NOT implement CheckerHTMLReporter.
|
||||
type testNoHTMLObservationProvider struct{}
|
||||
|
||||
func (p *testNoHTMLObservationProvider) Key() happydns.ObservationKey { return "test_no_html_obs" }
|
||||
func (p *testNoHTMLObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
return map[string]any{"v": 1}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
checkerPkg.RegisterObservationProvider(&testNoHTMLObservationProvider{})
|
||||
}
|
||||
|
||||
func TestGetExecutionHTMLReport_ProviderDoesNotSupportHTML(t *testing.T) {
|
||||
cc, store := newTestControllerWithStorage(&stubCheckerEngine{})
|
||||
|
||||
target := happydns.CheckTarget{DomainId: "d1"}
|
||||
exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{
|
||||
"test_no_html_obs": json.RawMessage(`{"v":1}`),
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/report", nil)
|
||||
c.Set("execution", exec)
|
||||
c.Params = gin.Params{{Key: "obsKey", Value: "test_no_html_obs"}}
|
||||
|
||||
cc.GetExecutionHTMLReport(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 (unsupported), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExecutionHTMLReport_Success(t *testing.T) {
|
||||
cc, store := newTestControllerWithStorage(&stubCheckerEngine{})
|
||||
|
||||
target := happydns.CheckTarget{DomainId: "d1"}
|
||||
exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{
|
||||
"test_html_obs": json.RawMessage(`{"v":1}`),
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/report", nil)
|
||||
c.Set("execution", exec)
|
||||
c.Params = gin.Params{{Key: "obsKey", Value: "test_html_obs"}}
|
||||
|
||||
cc.GetExecutionHTMLReport(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if body != "<html><body>test report</body></html>" {
|
||||
t.Errorf("unexpected body: %s", body)
|
||||
}
|
||||
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if ct != "text/html; charset=utf-8" {
|
||||
t.Errorf("expected Content-Type text/html, got %q", ct)
|
||||
}
|
||||
|
||||
csp := w.Header().Get("Content-Security-Policy")
|
||||
if csp == "" {
|
||||
t.Error("expected Content-Security-Policy header to be set")
|
||||
}
|
||||
|
||||
xcto := w.Header().Get("X-Content-Type-Options")
|
||||
if xcto != "nosniff" {
|
||||
t.Errorf("expected X-Content-Type-Options nosniff, got %q", xcto)
|
||||
}
|
||||
}
|
||||
|
||||
// --- getLimitParam tests ---
|
||||
|
||||
func newContextWithQuery(query string) *gin.Context {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/?"+query, nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestGetLimitParam(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
defaultLimit int
|
||||
expected int
|
||||
}{
|
||||
{"empty query returns default", "", 100, 100},
|
||||
{"valid limit", "limit=50", 100, 50},
|
||||
{"zero returns default", "limit=0", 100, 100},
|
||||
{"negative returns default", "limit=-5", 100, 100},
|
||||
{"non-numeric returns default", "limit=abc", 100, 100},
|
||||
{"large value capped to maxLimit", "limit=1500", 100, 1000},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := newContextWithQuery(tt.query)
|
||||
got := getLimitParam(c, tt.defaultLimit)
|
||||
if got != tt.expected {
|
||||
t.Errorf("getLimitParam(%q, %d) = %d, want %d", tt.query, tt.defaultLimit, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import (
|
|||
"github.com/miekg/dns"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -37,13 +38,15 @@ type DomainController struct {
|
|||
domainService happydns.DomainUsecase
|
||||
remoteZoneImporter happydns.RemoteZoneImporterUsecase
|
||||
zoneImporter happydns.ZoneImporterUsecase
|
||||
checkStatusUC *checkerUC.CheckStatusUsecase
|
||||
}
|
||||
|
||||
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase) *DomainController {
|
||||
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, checkStatusUC *checkerUC.CheckStatusUsecase) *DomainController {
|
||||
return &DomainController{
|
||||
domainService: domainService,
|
||||
remoteZoneImporter: remoteZoneImporter,
|
||||
zoneImporter: zoneImporter,
|
||||
checkStatusUC: checkStatusUC,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +59,7 @@ func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporte
|
|||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {array} happydns.Domain
|
||||
// @Success 200 {array} happydns.DomainWithCheckStatus
|
||||
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Unable to retrieve user's domains"
|
||||
// @Router /domains [get]
|
||||
|
|
@ -73,7 +76,25 @@ func (dc *DomainController) GetDomains(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, domains)
|
||||
var statusByDomain map[string]*happydns.Status
|
||||
if dc.checkStatusUC != nil {
|
||||
var err error
|
||||
statusByDomain, err = dc.checkStatusUC.GetWorstDomainStatuses(user.Id)
|
||||
if err != nil {
|
||||
log.Printf("GetWorstDomainStatuses: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]*happydns.DomainWithCheckStatus, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
entry := &happydns.DomainWithCheckStatus{Domain: d}
|
||||
if statusByDomain != nil {
|
||||
entry.LastCheckStatus = statusByDomain[d.Id.String()]
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// AddDomain appends a new domain to those managed.
|
||||
|
|
|
|||
86
internal/api/controller/domain_info.go
Normal file
86
internal/api/controller/domain_info.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type DomainInfoController struct {
|
||||
diuService happydns.DomainInfoUsecase
|
||||
}
|
||||
|
||||
func NewDomainInfoController(diuService happydns.DomainInfoUsecase) *DomainInfoController {
|
||||
return &DomainInfoController{
|
||||
diuService: diuService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDomainInfo retrieves domain's administrative information.
|
||||
//
|
||||
// @Summary Get domain administrative information
|
||||
// @Schemes
|
||||
// @Description Retrieve domain's administrative information.
|
||||
// @Tags domains
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Param domain path string true "Domain name"
|
||||
// @Success 200 {object} happydns.DomainInfo
|
||||
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domaininfo/{domain} [post]
|
||||
func (dc *DomainInfoController) GetDomainInfo(c *gin.Context) {
|
||||
domain := c.Param("domain")
|
||||
if dn, ok := c.Get("domain"); ok {
|
||||
domain = dn.(*happydns.Domain).DomainName
|
||||
}
|
||||
|
||||
domain = dns.Fqdn(strings.TrimSpace(domain))
|
||||
if domain == "." {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: "empty domain name"})
|
||||
return
|
||||
}
|
||||
if _, ok := dns.IsDomainName(domain); !ok {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: "invalid domain name"})
|
||||
return
|
||||
}
|
||||
|
||||
info, err := dc.diuService.GetDomainInfo(c.Request.Context(), happydns.Origin(domain))
|
||||
if err != nil {
|
||||
if errors.Is(err, happydns.ErrDomainDoesNotExist) {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: err.Error()})
|
||||
} else {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
296
internal/api/controller/domain_test.go
Normal file
296
internal/api/controller/domain_test.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
// 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/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// --- Stub types for domain tests ---
|
||||
|
||||
// stubDomainUsecase implements happydns.DomainUsecase for testing.
|
||||
type stubDomainUsecase struct {
|
||||
domains []*happydns.Domain
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubDomainUsecase) CreateDomain(ctx context.Context, user *happydns.User, input *happydns.DomainCreationInput) (*happydns.Domain, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *stubDomainUsecase) DeleteDomain(id happydns.Identifier) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *stubDomainUsecase) ExtendsDomainWithZoneMeta(d *happydns.Domain) (*happydns.DomainWithZoneMetadata, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *stubDomainUsecase) GetUserDomain(user *happydns.User, id happydns.Identifier) (*happydns.Domain, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *stubDomainUsecase) GetUserDomainByFQDN(user *happydns.User, fqdn string) ([]*happydns.Domain, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *stubDomainUsecase) ListUserDomains(user *happydns.User) ([]*happydns.Domain, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.domains, nil
|
||||
}
|
||||
func (s *stubDomainUsecase) UpdateDomain(id happydns.Identifier, user *happydns.User, fn func(*happydns.Domain)) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// newDomainTestContext creates a gin context with a logged-in user and a recorder.
|
||||
func newDomainTestContext(user *happydns.User) (*httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/domains", nil)
|
||||
if user != nil {
|
||||
c.Set("LoggedUser", user)
|
||||
}
|
||||
return w, c
|
||||
}
|
||||
|
||||
// --- GetDomains tests ---
|
||||
|
||||
func TestGetDomains_Unauthenticated(t *testing.T) {
|
||||
dc := NewDomainController(&stubDomainUsecase{}, nil, nil, nil)
|
||||
|
||||
w, c := newDomainTestContext(nil)
|
||||
dc.GetDomains(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_ListError(t *testing.T) {
|
||||
stub := &stubDomainUsecase{err: fmt.Errorf("db failure")}
|
||||
dc := NewDomainController(stub, nil, nil, nil)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
w, c := newDomainTestContext(user)
|
||||
dc.GetDomains(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_EmptyList(t *testing.T) {
|
||||
stub := &stubDomainUsecase{domains: []*happydns.Domain{}}
|
||||
dc := NewDomainController(stub, nil, nil, nil)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
w, c := newDomainTestContext(user)
|
||||
dc.GetDomains(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var result []happydns.DomainWithCheckStatus
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0 domains, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_NilCheckStatusUC(t *testing.T) {
|
||||
did1, _ := happydns.NewRandomIdentifier()
|
||||
did2, _ := happydns.NewRandomIdentifier()
|
||||
stub := &stubDomainUsecase{
|
||||
domains: []*happydns.Domain{
|
||||
{Id: did1, DomainName: "example.com."},
|
||||
{Id: did2, DomainName: "example.org."},
|
||||
},
|
||||
}
|
||||
dc := NewDomainController(stub, nil, nil, nil)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
user := &happydns.User{Id: uid}
|
||||
w, c := newDomainTestContext(user)
|
||||
dc.GetDomains(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var result []happydns.DomainWithCheckStatus
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 domains, got %d", len(result))
|
||||
}
|
||||
|
||||
for _, d := range result {
|
||||
if d.LastCheckStatus != nil {
|
||||
t.Errorf("expected nil LastCheckStatus when checkStatusUC is nil, got %v for domain %s", *d.LastCheckStatus, d.DomainName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_WithCheckStatuses(t *testing.T) {
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did1, _ := happydns.NewRandomIdentifier()
|
||||
did2, _ := happydns.NewRandomIdentifier()
|
||||
did3, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
stub := &stubDomainUsecase{
|
||||
domains: []*happydns.Domain{
|
||||
{Id: did1, DomainName: "warn.example.com.", Owner: uid},
|
||||
{Id: did2, DomainName: "ok.example.com.", Owner: uid},
|
||||
{Id: did3, DomainName: "unchecked.example.com.", Owner: uid},
|
||||
},
|
||||
}
|
||||
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create in-memory store: %v", err)
|
||||
}
|
||||
statusUC := checkerUC.NewCheckStatusUsecase(store, store, store, store)
|
||||
|
||||
// Create executions: domain 1 has WARN, domain 2 has OK, domain 3 has none.
|
||||
for _, tc := range []struct {
|
||||
domainId happydns.Identifier
|
||||
status happydns.Status
|
||||
}{
|
||||
{did1, happydns.StatusOK},
|
||||
{did1, happydns.StatusWarn},
|
||||
{did2, happydns.StatusOK},
|
||||
} {
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "test_checker",
|
||||
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: tc.domainId.String()},
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionDone,
|
||||
Result: happydns.CheckState{Status: tc.status},
|
||||
}
|
||||
if err := store.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
dc := NewDomainController(stub, nil, nil, statusUC)
|
||||
|
||||
user := &happydns.User{Id: uid}
|
||||
w, c := newDomainTestContext(user)
|
||||
dc.GetDomains(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var result []happydns.DomainWithCheckStatus
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 domains, got %d", len(result))
|
||||
}
|
||||
|
||||
statusByDomain := make(map[string]*happydns.Status)
|
||||
for _, d := range result {
|
||||
statusByDomain[d.Id.String()] = d.LastCheckStatus
|
||||
}
|
||||
|
||||
// Domain 1: worst is WARN.
|
||||
if s := statusByDomain[did1.String()]; s == nil {
|
||||
t.Error("expected non-nil status for domain 1 (warn.example.com)")
|
||||
} else if *s != happydns.StatusWarn {
|
||||
t.Errorf("expected WARN for domain 1, got %v", *s)
|
||||
}
|
||||
|
||||
// Domain 2: worst is OK.
|
||||
if s := statusByDomain[did2.String()]; s == nil {
|
||||
t.Error("expected non-nil status for domain 2 (ok.example.com)")
|
||||
} else if *s != happydns.StatusOK {
|
||||
t.Errorf("expected OK for domain 2, got %v", *s)
|
||||
}
|
||||
|
||||
// Domain 3: no executions → nil.
|
||||
if s := statusByDomain[did3.String()]; s != nil {
|
||||
t.Errorf("expected nil status for domain 3 (unchecked.example.com), got %v", *s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_ResponseIncludesDomainFields(t *testing.T) {
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
pid, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
stub := &stubDomainUsecase{
|
||||
domains: []*happydns.Domain{
|
||||
{Id: did, DomainName: "test.example.com.", Owner: uid, ProviderId: pid, Group: "mygroup"},
|
||||
},
|
||||
}
|
||||
dc := NewDomainController(stub, nil, nil, nil)
|
||||
|
||||
user := &happydns.User{Id: uid}
|
||||
w, c := newDomainTestContext(user)
|
||||
dc.GetDomains(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var result []json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 domain, got %d", len(result))
|
||||
}
|
||||
|
||||
// Verify the JSON contains the expected domain fields (embedded from *Domain).
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(result[0], &fields); err != nil {
|
||||
t.Fatalf("failed to unmarshal domain entry: %v", err)
|
||||
}
|
||||
|
||||
for _, key := range []string{"id", "id_owner", "id_provider", "domain", "group"} {
|
||||
if _, ok := fields[key]; !ok {
|
||||
t.Errorf("expected field %q in response JSON", key)
|
||||
}
|
||||
}
|
||||
|
||||
// last_check_status should be omitted when nil (omitempty).
|
||||
if _, ok := fields["last_check_status"]; ok {
|
||||
t.Error("expected last_check_status to be omitted when nil")
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import (
|
|||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/internal/helpers"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -38,13 +39,15 @@ type ZoneController struct {
|
|||
domainService happydns.DomainUsecase
|
||||
zoneCorrectionService happydns.ZoneCorrectionApplierUsecase
|
||||
zoneService happydns.ZoneUsecase
|
||||
checkStatusUC *checkerUC.CheckStatusUsecase
|
||||
}
|
||||
|
||||
func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase) *ZoneController {
|
||||
func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase, checkStatusUC *checkerUC.CheckStatusUsecase) *ZoneController {
|
||||
return &ZoneController{
|
||||
domainService: domainService,
|
||||
zoneCorrectionService: zoneCorrectionService,
|
||||
zoneService: zoneService,
|
||||
checkStatusUC: checkStatusUC,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,14 +62,27 @@ func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.
|
|||
// @Security securitydefinitions.basic
|
||||
// @Param domainId path string true "Domain identifier"
|
||||
// @Param zoneId path string true "Zone identifier"
|
||||
// @Success 200 {object} happydns.Zone
|
||||
// @Success 200 {object} happydns.ZoneWithServicesCheckStatus
|
||||
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
|
||||
// @Router /domains/{domainId}/zone/{zoneId} [get]
|
||||
func (zc *ZoneController) GetZone(c *gin.Context) {
|
||||
zone := c.MustGet("zone").(*happydns.Zone)
|
||||
|
||||
c.JSON(http.StatusOK, zone)
|
||||
result := &happydns.ZoneWithServicesCheckStatus{Zone: zone}
|
||||
|
||||
if zc.checkStatusUC != nil {
|
||||
user := middleware.MyUser(c)
|
||||
domain := c.MustGet("domain").(*happydns.Domain)
|
||||
statusByService, err := zc.checkStatusUC.GetWorstServiceStatuses(user.Id, domain.Id)
|
||||
if err != nil {
|
||||
log.Printf("GetWorstServiceStatuses: %s", err.Error())
|
||||
} else {
|
||||
result.ServicesCheckStatus = statusByService
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetZoneSubdomain returns the services associated with a given subdomain.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/session"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -61,6 +62,12 @@ func JwtAuthMiddleware(authService happydns.AuthenticationUsecase, signingMethod
|
|||
return
|
||||
}
|
||||
|
||||
// Session IDs are handled by the session store; skip JWT parsing.
|
||||
if session.IsValidSessionID(token) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the token and retrieve claims
|
||||
claims := &UserClaims{}
|
||||
_, err := jwt.ParseWithClaims(token, claims,
|
||||
|
|
|
|||
130
internal/api/middleware/jwt_auth_middleware_test.go
Normal file
130
internal/api/middleware/jwt_auth_middleware_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// 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/>.
|
||||
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// stubAuthUsecase is a no-op implementation of happydns.AuthenticationUsecase.
|
||||
// The middleware should never reach a method of this stub when the token is a
|
||||
// session ID, and should never reach it on a malformed JWT either (it returns
|
||||
// after logging). We still assert it was not called by leaving the methods
|
||||
// panicking — if any test trips one, we know the branching logic regressed.
|
||||
type stubAuthUsecase struct{}
|
||||
|
||||
func (stubAuthUsecase) AuthenticateUserWithPassword(_ happydns.LoginRequest) (*happydns.User, error) {
|
||||
panic("AuthenticateUserWithPassword should not be called in these tests")
|
||||
}
|
||||
|
||||
func (stubAuthUsecase) CompleteAuthentication(_ happydns.UserInfo) (*happydns.User, error) {
|
||||
panic("CompleteAuthentication should not be called in these tests")
|
||||
}
|
||||
|
||||
// captureLog redirects the default logger to a buffer for the duration of fn.
|
||||
func captureLog(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
prevOut := log.Writer()
|
||||
prevFlags := log.Flags()
|
||||
log.SetOutput(&buf)
|
||||
log.SetFlags(0)
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(prevOut)
|
||||
log.SetFlags(prevFlags)
|
||||
})
|
||||
|
||||
fn()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func newRouter() *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(middleware.JwtAuthMiddleware(stubAuthUsecase{}, "HS256", []byte("test-secret")))
|
||||
r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
return r
|
||||
}
|
||||
|
||||
func Test_JwtAuthMiddleware_SessionIDTokenIsSilent(t *testing.T) {
|
||||
r := newRouter()
|
||||
|
||||
output := captureLog(t, func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sessionUC.NewSessionID())
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
})
|
||||
|
||||
if strings.Contains(output, "bad JWT claims") {
|
||||
t.Errorf("expected no %q log for a session-ID token, got:\n%s", "bad JWT claims", output)
|
||||
}
|
||||
if strings.TrimSpace(output) != "" {
|
||||
t.Errorf("expected no log output at all for a session-ID token, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_JwtAuthMiddleware_MalformedTokenStillLogs(t *testing.T) {
|
||||
r := newRouter()
|
||||
|
||||
output := captureLog(t, func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
// Contains a dot, so it can't match the session-ID shape and will be
|
||||
// routed to the JWT parser, which will fail.
|
||||
req.Header.Set("Authorization", "Bearer not.a.jwt")
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
})
|
||||
|
||||
if !strings.Contains(output, "bad JWT claims") {
|
||||
t.Errorf("expected %q log for a malformed JWT, got:\n%s", "bad JWT claims", output)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_JwtAuthMiddleware_NoAuthHeaderIsSilent(t *testing.T) {
|
||||
r := newRouter()
|
||||
|
||||
output := captureLog(t, func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
})
|
||||
|
||||
if strings.TrimSpace(output) != "" {
|
||||
t.Errorf("expected no log output without an Authorization header, got:\n%s", output)
|
||||
}
|
||||
}
|
||||
116
internal/api/route/checker.go
Normal file
116
internal/api/route/checker.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// 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/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// declareCheckerOptionsRoutes registers the options sub-routes on a checker group.
|
||||
func declareCheckerOptionsRoutes(checkerID *gin.RouterGroup, cc *controller.CheckerController) {
|
||||
checkerID.GET("/options", cc.GetCheckerOptions)
|
||||
checkerID.POST("/options", cc.AddCheckerOptions)
|
||||
checkerID.PUT("/options", cc.ChangeCheckerOptions)
|
||||
checkerID.GET("/options/:optname", cc.GetCheckerOption)
|
||||
checkerID.PUT("/options/:optname", cc.SetCheckerOption)
|
||||
}
|
||||
|
||||
// DeclareCheckerRoutes registers global checker routes under /api/checkers.
|
||||
// Returns the controller so it can be reused for scoped routes.
|
||||
func DeclareCheckerRoutes(
|
||||
apiRoutes *gin.RouterGroup,
|
||||
engine happydns.CheckerEngine,
|
||||
optionsUC *checkerUC.CheckerOptionsUsecase,
|
||||
planUC *checkerUC.CheckPlanUsecase,
|
||||
statusUC *checkerUC.CheckStatusUsecase,
|
||||
plannedProvider checkerUC.PlannedJobProvider,
|
||||
budgetChecker checkerUC.BudgetChecker,
|
||||
countManualTriggers bool,
|
||||
) *controller.CheckerController {
|
||||
cc := controller.NewCheckerController(engine, optionsUC, planUC, statusUC, plannedProvider, budgetChecker, countManualTriggers)
|
||||
|
||||
// Global: /api/checkers
|
||||
checkers := apiRoutes.Group("/checkers")
|
||||
checkers.GET("", cc.ListCheckers)
|
||||
checkers.GET("/metrics", cc.GetUserMetrics)
|
||||
|
||||
checkerID := checkers.Group("/:checkerId")
|
||||
checkerID.Use(cc.CheckerHandler)
|
||||
checkerID.GET("", cc.GetChecker)
|
||||
|
||||
declareCheckerOptionsRoutes(checkerID, cc)
|
||||
|
||||
return cc
|
||||
}
|
||||
|
||||
// DeclareScopedCheckerRoutes registers checker routes scoped to a domain or service.
|
||||
// Called for both /api/domains/:domain/checkers and .../services/:serviceid/checkers.
|
||||
func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController) {
|
||||
checkers := scopedRouter.Group("/checkers")
|
||||
checkers.GET("", cc.ListAvailableChecks)
|
||||
checkers.GET("/metrics", cc.GetDomainMetrics)
|
||||
|
||||
checkerID := checkers.Group("/:checkerId")
|
||||
checkerID.Use(cc.CheckerHandler)
|
||||
|
||||
declareCheckerOptionsRoutes(checkerID, cc)
|
||||
|
||||
// Plans (schedules).
|
||||
checkerID.GET("/plans", cc.ListCheckPlans)
|
||||
checkerID.POST("/plans", cc.CreateCheckPlan)
|
||||
|
||||
planID := checkerID.Group("/plans/:planId")
|
||||
planID.Use(cc.PlanHandler)
|
||||
planID.GET("", cc.GetCheckPlan)
|
||||
planID.PUT("", cc.UpdateCheckPlan)
|
||||
planID.DELETE("", cc.DeleteCheckPlan)
|
||||
|
||||
// Per-checker metrics.
|
||||
checkerID.GET("/metrics", cc.GetCheckerMetrics)
|
||||
|
||||
// Executions.
|
||||
executions := checkerID.Group("/executions")
|
||||
executions.GET("", cc.ListExecutions)
|
||||
executions.POST("", cc.TriggerCheck)
|
||||
executions.DELETE("", cc.DeleteCheckerExecutions)
|
||||
|
||||
executionID := executions.Group("/:executionId")
|
||||
executionID.Use(cc.ExecutionHandler)
|
||||
executionID.GET("", cc.GetExecutionStatus)
|
||||
executionID.DELETE("", cc.DeleteExecution)
|
||||
|
||||
// Metrics (under execution).
|
||||
executionID.GET("/metrics", cc.GetExecutionMetrics)
|
||||
|
||||
// Observations (under execution).
|
||||
executionID.GET("/observations", cc.GetExecutionObservations)
|
||||
executionID.GET("/observations/:obsKey", cc.GetExecutionObservation)
|
||||
executionID.GET("/observations/:obsKey/report", cc.GetExecutionHTMLReport)
|
||||
|
||||
// Results (under execution).
|
||||
executionID.GET("/results", cc.GetExecutionResults)
|
||||
executionID.GET("/results/:ruleName", cc.GetExecutionResult)
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import (
|
|||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -39,11 +40,15 @@ func DeclareDomainRoutes(
|
|||
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
|
||||
zoneServiceUC happydns.ZoneServiceUsecase,
|
||||
serviceUC happydns.ServiceUsecase,
|
||||
cc *controller.CheckerController,
|
||||
checkStatusUC *checkerUC.CheckStatusUsecase,
|
||||
domainInfoUC happydns.DomainInfoUsecase,
|
||||
) {
|
||||
dc := controller.NewDomainController(
|
||||
domainUC,
|
||||
remoteZoneImporter,
|
||||
zoneImporter,
|
||||
checkStatusUC,
|
||||
)
|
||||
|
||||
router.GET("/domains", dc.GetDomains)
|
||||
|
|
@ -56,11 +61,17 @@ func DeclareDomainRoutes(
|
|||
apiDomainsRoutes.PUT("", dc.UpdateDomain)
|
||||
apiDomainsRoutes.DELETE("", dc.DelDomain)
|
||||
|
||||
DeclareDomainInfoRoutes(apiDomainsRoutes.Group("/info"), domainInfoUC)
|
||||
DeclareDomainLogRoutes(apiDomainsRoutes, domainLogUC)
|
||||
|
||||
apiDomainsRoutes.POST("/zone", dc.ImportZone)
|
||||
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
|
||||
|
||||
// Mount domain-scoped checker routes.
|
||||
if cc != nil {
|
||||
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc)
|
||||
}
|
||||
|
||||
DeclareZoneRoutes(
|
||||
apiDomainsRoutes,
|
||||
zoneUC,
|
||||
|
|
@ -68,5 +79,6 @@ func DeclareDomainRoutes(
|
|||
zoneCorrApplier,
|
||||
zoneServiceUC,
|
||||
serviceUC,
|
||||
cc,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
37
internal/api/route/domain_info.go
Normal file
37
internal/api/route/domain_info.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func DeclareDomainInfoRoutes(router *gin.RouterGroup, domainInfoUC happydns.DomainInfoUsecase) {
|
||||
dc := controller.NewDomainInfoController(
|
||||
domainInfoUC,
|
||||
)
|
||||
|
||||
router.POST("", dc.GetDomainInfo)
|
||||
}
|
||||
|
|
@ -22,19 +22,27 @@
|
|||
package route
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
ratelimit "github.com/JGLTechnologies/gin-rate-limit"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Dependencies holds all use cases required to register the public API routes.
|
||||
// It is a plain struct — no methods, no interface — constructed once in app.go.
|
||||
// It is a plain struct - no methods, no interface - constructed once in app.go.
|
||||
type Dependencies struct {
|
||||
Authentication happydns.AuthenticationUsecase
|
||||
AuthUser happydns.AuthUserUsecase
|
||||
CaptchaVerifier happydns.CaptchaVerifier
|
||||
Domain happydns.DomainUsecase
|
||||
DomainInfo happydns.DomainInfoUsecase
|
||||
DomainLog happydns.DomainLogUsecase
|
||||
FailureTracker happydns.FailureTracker
|
||||
Provider happydns.ProviderUsecase
|
||||
|
|
@ -50,6 +58,14 @@ type Dependencies struct {
|
|||
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
|
||||
ZoneImporter happydns.ZoneImporterUsecase
|
||||
ZoneService happydns.ZoneServiceUsecase
|
||||
|
||||
CheckerEngine happydns.CheckerEngine
|
||||
CheckerOptionsUC *checkerUC.CheckerOptionsUsecase
|
||||
CheckPlanUC *checkerUC.CheckPlanUsecase
|
||||
CheckStatusUC *checkerUC.CheckStatusUsecase
|
||||
PlannedProvider checkerUC.PlannedJobProvider
|
||||
BudgetChecker checkerUC.BudgetChecker
|
||||
CountManualTriggers bool
|
||||
}
|
||||
|
||||
// @title happyDomain API
|
||||
|
|
@ -88,6 +104,22 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
|||
dep.FailureTracker,
|
||||
)
|
||||
auc := DeclareAuthUserRoutes(apiRoutes, dep.AuthUser, lc)
|
||||
|
||||
domainInfoRL := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{
|
||||
Rate: time.Minute,
|
||||
Limit: 10,
|
||||
})
|
||||
domainInfoRLMiddleware := ratelimit.RateLimiter(domainInfoRL, &ratelimit.Options{
|
||||
ErrorHandler: func(c *gin.Context, info ratelimit.Info) {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, happydns.ErrorResponse{
|
||||
Message: "Too many requests. Please try again later.",
|
||||
})
|
||||
},
|
||||
KeyFunc: func(c *gin.Context) string {
|
||||
return c.ClientIP()
|
||||
},
|
||||
})
|
||||
DeclareDomainInfoRoutes(apiRoutes.Group("/domaininfo/:domain", domainInfoRLMiddleware), dep.DomainInfo)
|
||||
DeclareProviderSpecsRoutes(apiRoutes, dep.ProviderSpecs)
|
||||
DeclareRegistrationRoutes(apiRoutes, dep.AuthUser, dep.CaptchaVerifier)
|
||||
DeclareResolverRoutes(apiRoutes, dep.Resolver)
|
||||
|
|
@ -105,6 +137,21 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
|||
}
|
||||
apiAuthRoutes.Use(middleware.AuthRequired())
|
||||
|
||||
// Initialize checker controller if checker engine is available.
|
||||
var cc *controller.CheckerController
|
||||
if dep.CheckerEngine != nil {
|
||||
cc = DeclareCheckerRoutes(
|
||||
apiAuthRoutes,
|
||||
dep.CheckerEngine,
|
||||
dep.CheckerOptionsUC,
|
||||
dep.CheckPlanUC,
|
||||
dep.CheckStatusUC,
|
||||
dep.PlannedProvider,
|
||||
dep.BudgetChecker,
|
||||
dep.CountManualTriggers,
|
||||
)
|
||||
}
|
||||
|
||||
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
|
||||
DeclareDomainRoutes(
|
||||
apiAuthRoutes,
|
||||
|
|
@ -116,6 +163,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
|||
dep.ZoneCorrectionApplier,
|
||||
dep.ZoneService,
|
||||
dep.Service,
|
||||
cc,
|
||||
dep.CheckStatusUC,
|
||||
dep.DomainInfo,
|
||||
)
|
||||
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
|
||||
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ func DeclareZoneServiceRoutes(
|
|||
zoneServiceUC happydns.ZoneServiceUsecase,
|
||||
serviceUC happydns.ServiceUsecase,
|
||||
zoneUC happydns.ZoneUsecase,
|
||||
cc *controller.CheckerController,
|
||||
) {
|
||||
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
|
||||
|
||||
|
|
@ -47,4 +48,9 @@ func DeclareZoneServiceRoutes(
|
|||
apiZonesSubdomainServiceIDRoutes.Use(middleware.ServiceIdHandler(serviceUC))
|
||||
apiZonesSubdomainServiceIDRoutes.GET("", sc.GetZoneService)
|
||||
apiZonesSubdomainServiceIDRoutes.DELETE("", sc.DeleteZoneService)
|
||||
|
||||
// Mount service-scoped checker routes.
|
||||
if cc != nil {
|
||||
DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import (
|
|||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -36,11 +37,18 @@ func DeclareZoneRoutes(
|
|||
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
|
||||
zoneServiceUC happydns.ZoneServiceUsecase,
|
||||
serviceUC happydns.ServiceUsecase,
|
||||
cc *controller.CheckerController,
|
||||
) {
|
||||
var checkStatusUC *checkerUC.CheckStatusUsecase
|
||||
if cc != nil {
|
||||
checkStatusUC = cc.StatusUC()
|
||||
}
|
||||
|
||||
zc := controller.NewZoneController(
|
||||
zoneUC,
|
||||
domainUC,
|
||||
zoneCorrApplier,
|
||||
checkStatusUC,
|
||||
)
|
||||
|
||||
apiZonesRoutes := router.Group("/zone/:zoneid")
|
||||
|
|
@ -65,6 +73,7 @@ func DeclareZoneRoutes(
|
|||
zoneServiceUC,
|
||||
serviceUC,
|
||||
zoneUC,
|
||||
cc,
|
||||
)
|
||||
|
||||
apiZonesRoutes.POST("/records", zc.AddRecords)
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
admin "git.happydns.org/happyDomain/internal/api-admin/route"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
"git.happydns.org/happyDomain/web-admin"
|
||||
|
|
@ -55,6 +57,11 @@ func NewAdmin(app *App) *Admin {
|
|||
|
||||
// Prepare usecases (admin uses unrestricted provider access)
|
||||
app.usecases.providerAdmin = providerUC.NewService(app.store, nil)
|
||||
if app.usecases.checkerOptionsUC == nil {
|
||||
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store)
|
||||
}
|
||||
|
||||
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||
|
||||
admin.DeclareRoutes(
|
||||
app.cfg,
|
||||
|
|
@ -71,6 +78,8 @@ func NewAdmin(app *App) *Admin {
|
|||
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
|
||||
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
|
||||
ZoneService: app.usecases.zoneService,
|
||||
CheckerOptionsUC: app.usecases.checkerOptionsUC,
|
||||
CheckScheduler: app.usecases.checkerScheduler,
|
||||
},
|
||||
)
|
||||
web.DeclareRoutes(app.cfg, router)
|
||||
|
|
|
|||
|
|
@ -33,11 +33,13 @@ import (
|
|||
api "git.happydns.org/happyDomain/internal/api/route"
|
||||
"git.happydns.org/happyDomain/internal/captcha"
|
||||
"git.happydns.org/happyDomain/internal/mailer"
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
"git.happydns.org/happyDomain/internal/newsletter"
|
||||
"git.happydns.org/happyDomain/internal/session"
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/internal/usecase"
|
||||
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
|
||||
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
|
||||
|
|
@ -48,6 +50,7 @@ import (
|
|||
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
|
||||
zoneServiceUC "git.happydns.org/happyDomain/internal/usecase/zone_service"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
"git.happydns.org/happyDomain/pkg/domaininfo"
|
||||
"git.happydns.org/happyDomain/web"
|
||||
)
|
||||
|
||||
|
|
@ -55,6 +58,7 @@ type Usecases struct {
|
|||
authentication happydns.AuthenticationUsecase
|
||||
authUser happydns.AuthUserUsecase
|
||||
domain happydns.DomainUsecase
|
||||
domainInfo happydns.DomainInfoUsecase
|
||||
domainLog happydns.DomainLogUsecase
|
||||
provider happydns.ProviderUsecase
|
||||
providerAdmin happydns.ProviderUsecase
|
||||
|
|
@ -69,6 +73,14 @@ type Usecases struct {
|
|||
zoneService happydns.ZoneServiceUsecase
|
||||
|
||||
orchestrator *orchestrator.Orchestrator
|
||||
|
||||
checkerEngine happydns.CheckerEngine
|
||||
checkerOptionsUC *checkerUC.CheckerOptionsUsecase
|
||||
checkerPlanUC *checkerUC.CheckPlanUsecase
|
||||
checkerStatusUC *checkerUC.CheckStatusUsecase
|
||||
checkerScheduler *checkerUC.Scheduler
|
||||
checkerJanitor *checkerUC.Janitor
|
||||
checkerUserGater *checkerUC.UserGater
|
||||
}
|
||||
|
||||
type App struct {
|
||||
|
|
@ -93,6 +105,9 @@ func NewApp(cfg *happydns.Options) *App {
|
|||
app.initStorageEngine()
|
||||
app.initNewsletter()
|
||||
app.initInsights()
|
||||
if err := app.initPlugins(); err != nil {
|
||||
log.Fatalf("Plugin initialization error: %s", err)
|
||||
}
|
||||
app.initUsecases()
|
||||
app.initCaptcha()
|
||||
app.setupRouter()
|
||||
|
|
@ -108,6 +123,9 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
|
|||
|
||||
app.initMailer()
|
||||
app.initNewsletter()
|
||||
if err := app.initPlugins(); err != nil {
|
||||
log.Fatalf("Plugin initialization error: %s", err)
|
||||
}
|
||||
app.initUsecases()
|
||||
app.initCaptcha()
|
||||
app.setupRouter()
|
||||
|
|
@ -162,6 +180,9 @@ func (app *App) initStorageEngine() {
|
|||
if err = app.store.MigrateSchema(); err != nil {
|
||||
log.Fatal("Could not migrate database: ", err)
|
||||
}
|
||||
|
||||
metrics.NewStorageStatsCollector(storage.NewStatsProvider(app.store))
|
||||
app.store = newInstrumentedStorage(app.store)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,6 +228,10 @@ func (app *App) initUsecases() {
|
|||
app.usecases.service = serviceService
|
||||
app.usecases.serviceSpecs = usecase.NewServiceSpecsUsecase()
|
||||
app.usecases.zone = zoneService
|
||||
app.usecases.domainInfo = usecase.NewDomainInfoUsecase(
|
||||
domaininfo.GetDomainRDAPInfo,
|
||||
domaininfo.GetDomainWhoisInfo,
|
||||
)
|
||||
app.usecases.domainLog = domainLogService
|
||||
|
||||
domainService := domainUC.NewService(
|
||||
|
|
@ -224,12 +249,13 @@ func (app *App) initUsecases() {
|
|||
app.store,
|
||||
)
|
||||
|
||||
app.usecases.user = userUC.NewUserUsecases(
|
||||
userService := userUC.NewUserUsecases(
|
||||
app.store,
|
||||
app.newsletter,
|
||||
authUserService,
|
||||
sessionService,
|
||||
)
|
||||
app.usecases.user = userService
|
||||
app.usecases.authentication = usecase.NewAuthenticationUsecase(app.cfg, app.store, app.usecases.user)
|
||||
app.usecases.authUser = authUserService
|
||||
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
|
||||
|
|
@ -246,6 +272,50 @@ func (app *App) initUsecases() {
|
|||
providerAdminService,
|
||||
zoneService.UpdateZoneUC,
|
||||
)
|
||||
|
||||
// Checker system.
|
||||
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store)
|
||||
app.usecases.checkerPlanUC = checkerUC.NewCheckPlanUsecase(app.store)
|
||||
app.usecases.checkerStatusUC = checkerUC.NewCheckStatusUsecase(app.store, app.store, app.store, app.store)
|
||||
app.usecases.checkerEngine = checkerUC.NewCheckerEngine(
|
||||
app.usecases.checkerOptionsUC,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
)
|
||||
// Build the user-level gate so paused or long-inactive users do not
|
||||
// get checked. The same user resolver is reused by the janitor for
|
||||
// per-user retention overrides.
|
||||
app.usecases.checkerUserGater = checkerUC.NewUserGater(app.store, app.cfg.CheckerInactivityPauseDays, app.cfg.CheckerMaxChecksPerDay)
|
||||
app.usecases.checkerScheduler = checkerUC.NewScheduler(
|
||||
app.usecases.checkerEngine,
|
||||
app.cfg.CheckerMaxConcurrency,
|
||||
app.store, app.store, app.store, app.store,
|
||||
app.usecases.checkerUserGater.AllowWithInterval,
|
||||
app.usecases.checkerUserGater.IncrementUsage,
|
||||
)
|
||||
|
||||
// Invalidate the scheduler's user gate cache whenever a user is updated
|
||||
// (e.g. login refreshing LastSeen, admin toggling SchedulingPaused).
|
||||
userService.SetOnUserChanged(func(id happydns.Identifier) {
|
||||
app.usecases.checkerUserGater.Invalidate(id.String())
|
||||
})
|
||||
|
||||
// Retention janitor.
|
||||
app.usecases.checkerJanitor = checkerUC.NewJanitor(
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
checkerUC.DefaultRetentionPolicy(app.cfg.CheckerRetentionDays),
|
||||
app.cfg.CheckerJanitorInterval,
|
||||
)
|
||||
|
||||
// Wire scheduler notifications for incremental queue updates.
|
||||
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
}
|
||||
|
||||
func (app *App) setupRouter() {
|
||||
|
|
@ -255,7 +325,7 @@ func (app *App) setupRouter() {
|
|||
|
||||
gin.ForceConsoleColor()
|
||||
app.router = gin.New()
|
||||
app.router.Use(gin.Logger(), gin.Recovery(), sessions.Sessions(
|
||||
app.router.Use(gin.Logger(), gin.Recovery(), metrics.HTTPMiddleware(), sessions.Sessions(
|
||||
session.COOKIE_NAME,
|
||||
session.NewSessionStore(app.cfg, app.store, []byte(app.cfg.JWTSecretKey)),
|
||||
))
|
||||
|
|
@ -276,6 +346,7 @@ func (app *App) setupRouter() {
|
|||
AuthUser: app.usecases.authUser,
|
||||
CaptchaVerifier: app.captchaVerifier,
|
||||
Domain: app.usecases.domain,
|
||||
DomainInfo: app.usecases.domainInfo,
|
||||
DomainLog: app.usecases.domainLog,
|
||||
FailureTracker: app.failureTracker,
|
||||
Provider: app.usecases.provider,
|
||||
|
|
@ -291,6 +362,14 @@ func (app *App) setupRouter() {
|
|||
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
|
||||
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
|
||||
ZoneService: app.usecases.zoneService,
|
||||
|
||||
CheckerEngine: app.usecases.checkerEngine,
|
||||
CheckerOptionsUC: app.usecases.checkerOptionsUC,
|
||||
CheckPlanUC: app.usecases.checkerPlanUC,
|
||||
CheckStatusUC: app.usecases.checkerStatusUC,
|
||||
PlannedProvider: app.usecases.checkerScheduler,
|
||||
BudgetChecker: app.usecases.checkerUserGater,
|
||||
CountManualTriggers: app.cfg.CheckerCountManualTriggers,
|
||||
},
|
||||
)
|
||||
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
|
||||
|
|
@ -308,6 +387,18 @@ func (app *App) Start() {
|
|||
go app.insights.Run()
|
||||
}
|
||||
|
||||
if app.usecases.checkerScheduler != nil {
|
||||
app.usecases.checkerScheduler.Start(context.Background())
|
||||
}
|
||||
|
||||
if app.usecases.checkerJanitor != nil {
|
||||
app.usecases.checkerJanitor.Start(context.Background())
|
||||
}
|
||||
|
||||
if app.usecases.checkerUserGater != nil {
|
||||
app.usecases.checkerUserGater.Start(context.Background())
|
||||
}
|
||||
|
||||
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
|
||||
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
|
|
@ -321,6 +412,18 @@ func (app *App) Stop() {
|
|||
log.Fatal("Server Shutdown:", err)
|
||||
}
|
||||
|
||||
if app.usecases.checkerScheduler != nil {
|
||||
app.usecases.checkerScheduler.Stop()
|
||||
}
|
||||
|
||||
if app.usecases.checkerJanitor != nil {
|
||||
app.usecases.checkerJanitor.Stop()
|
||||
}
|
||||
|
||||
if app.usecases.checkerUserGater != nil {
|
||||
app.usecases.checkerUserGater.Stop()
|
||||
}
|
||||
|
||||
// Close storage
|
||||
if app.store != nil {
|
||||
app.store.Close()
|
||||
|
|
|
|||
557
internal/app/instrumented_storage_generated.go
Normal file
557
internal/app/instrumented_storage_generated.go
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
// 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"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) AuthUserExists(email string) (ret bool, err error) {
|
||||
defer observe("get", "authuser")(&err)
|
||||
return s.inner.AuthUserExists(email)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearAuthUsers() (err error) {
|
||||
defer observe("delete", "authuser")(&err)
|
||||
return s.inner.ClearAuthUsers()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearCheckPlans() (err error) {
|
||||
defer observe("delete", "check_plan")(&err)
|
||||
return s.inner.ClearCheckPlans()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearCheckerConfigurations() (err error) {
|
||||
defer observe("delete", "check_config")(&err)
|
||||
return s.inner.ClearCheckerConfigurations()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearDomains() (err error) {
|
||||
defer observe("delete", "domain")(&err)
|
||||
return s.inner.ClearDomains()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearEvaluations() (err error) {
|
||||
defer observe("delete", "check_evaluation")(&err)
|
||||
return s.inner.ClearEvaluations()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearExecutions() (err error) {
|
||||
defer observe("delete", "execution")(&err)
|
||||
return s.inner.ClearExecutions()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearProviders() (err error) {
|
||||
defer observe("delete", "provider")(&err)
|
||||
return s.inner.ClearProviders()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearSessions() (err error) {
|
||||
defer observe("delete", "session")(&err)
|
||||
return s.inner.ClearSessions()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearSnapshots() (err error) {
|
||||
defer observe("delete", "observation_snapshot")(&err)
|
||||
return s.inner.ClearSnapshots()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearUsers() (err error) {
|
||||
defer observe("delete", "user")(&err)
|
||||
return s.inner.ClearUsers()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearZones() (err error) {
|
||||
defer observe("delete", "zone")(&err)
|
||||
return s.inner.ClearZones()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) Close() error { return s.inner.Close() }
|
||||
|
||||
func (s *instrumentedStorage) CountDomains() (ret int, err error) {
|
||||
defer observe("count", "domain")(&err)
|
||||
return s.inner.CountDomains()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CountProviders() (ret int, err error) {
|
||||
defer observe("count", "provider")(&err)
|
||||
return s.inner.CountProviders()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CountUsers() (ret int, err error) {
|
||||
defer observe("count", "user")(&err)
|
||||
return s.inner.CountUsers()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CountZones() (ret int, err error) {
|
||||
defer observe("count", "zone")(&err)
|
||||
return s.inner.CountZones()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateAuthUser(user *happydns.UserAuth) (err error) {
|
||||
defer observe("create", "authuser")(&err)
|
||||
return s.inner.CreateAuthUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateCheckPlan(plan *happydns.CheckPlan) (err error) {
|
||||
defer observe("create", "check_plan")(&err)
|
||||
return s.inner.CreateCheckPlan(plan)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateDomain(domain *happydns.Domain) (err error) {
|
||||
defer observe("create", "domain")(&err)
|
||||
return s.inner.CreateDomain(domain)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
|
||||
defer observe("create", "domain_log")(&err)
|
||||
return s.inner.CreateDomainLog(domain, log)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateEvaluation(eval *happydns.CheckEvaluation) (err error) {
|
||||
defer observe("create", "check_evaluation")(&err)
|
||||
return s.inner.CreateEvaluation(eval)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateExecution(exec *happydns.Execution) (err error) {
|
||||
defer observe("create", "execution")(&err)
|
||||
return s.inner.CreateExecution(exec)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateOrUpdateUser(user *happydns.User) (err error) {
|
||||
defer observe("update", "user")(&err)
|
||||
return s.inner.CreateOrUpdateUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateProvider(prvd *happydns.Provider) (err error) {
|
||||
defer observe("create", "provider")(&err)
|
||||
return s.inner.CreateProvider(prvd)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) (err error) {
|
||||
defer observe("create", "observation_snapshot")(&err)
|
||||
return s.inner.CreateSnapshot(snap)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateZone(zone *happydns.Zone) (err error) {
|
||||
defer observe("create", "zone")(&err)
|
||||
return s.inner.CreateZone(zone)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteAuthUser(user *happydns.UserAuth) (err error) {
|
||||
defer observe("delete", "authuser")(&err)
|
||||
return s.inner.DeleteAuthUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteCheckPlan(planID happydns.Identifier) (err error) {
|
||||
defer observe("delete", "check_plan")(&err)
|
||||
return s.inner.DeleteCheckPlan(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) (err error) {
|
||||
defer observe("delete", "check_config")(&err)
|
||||
return s.inner.DeleteCheckerConfiguration(checkerName, userId, domainId, serviceId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteDomain(domainid happydns.Identifier) (err error) {
|
||||
defer observe("delete", "domain")(&err)
|
||||
return s.inner.DeleteDomain(domainid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
|
||||
defer observe("delete", "domain_log")(&err)
|
||||
return s.inner.DeleteDomainLog(domain, log)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteEvaluation(evalID happydns.Identifier) (err error) {
|
||||
defer observe("delete", "check_evaluation")(&err)
|
||||
return s.inner.DeleteEvaluation(evalID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) (err error) {
|
||||
defer observe("delete", "check_evaluation")(&err)
|
||||
return s.inner.DeleteEvaluationsByChecker(checkerID, target)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteExecution(execID happydns.Identifier) (err error) {
|
||||
defer observe("delete", "execution")(&err)
|
||||
return s.inner.DeleteExecution(execID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) (err error) {
|
||||
defer observe("delete", "execution")(&err)
|
||||
return s.inner.DeleteExecutionsByChecker(checkerID, target)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteProvider(prvdid happydns.Identifier) (err error) {
|
||||
defer observe("delete", "provider")(&err)
|
||||
return s.inner.DeleteProvider(prvdid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteSession(sessionid string) (err error) {
|
||||
defer observe("delete", "session")(&err)
|
||||
return s.inner.DeleteSession(sessionid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteSnapshot(snapID happydns.Identifier) (err error) {
|
||||
defer observe("delete", "observation_snapshot")(&err)
|
||||
return s.inner.DeleteSnapshot(snapID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteUser(userid happydns.Identifier) (err error) {
|
||||
defer observe("delete", "user")(&err)
|
||||
return s.inner.DeleteUser(userid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteZone(zoneid happydns.Identifier) (err error) {
|
||||
defer observe("delete", "zone")(&err)
|
||||
return s.inner.DeleteZone(zoneid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetAuthUser(id happydns.Identifier) (ret *happydns.UserAuth, err error) {
|
||||
defer observe("get", "authuser")(&err)
|
||||
return s.inner.GetAuthUser(id)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetAuthUserByEmail(email string) (ret *happydns.UserAuth, err error) {
|
||||
defer observe("get", "authuser")(&err)
|
||||
return s.inner.GetAuthUserByEmail(email)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (ret *happydns.ObservationCacheEntry, err error) {
|
||||
defer observe("get", "observation_cache")(&err)
|
||||
return s.inner.GetCachedObservation(target, key)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetCheckPlan(planID happydns.Identifier) (ret *happydns.CheckPlan, err error) {
|
||||
defer observe("get", "check_plan")(&err)
|
||||
return s.inner.GetCheckPlan(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) (ret []*happydns.CheckerOptionsPositional, err error) {
|
||||
defer observe("get", "check_config")(&err)
|
||||
return s.inner.GetCheckerConfiguration(checkerName, userId, domainId, serviceId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetDomain(domainid happydns.Identifier) (ret *happydns.Domain, err error) {
|
||||
defer observe("get", "domain")(&err)
|
||||
return s.inner.GetDomain(domainid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetDomainByDN(user *happydns.User, fqdn string) (ret []*happydns.Domain, err error) {
|
||||
defer observe("get", "domain")(&err)
|
||||
return s.inner.GetDomainByDN(user, fqdn)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetEvaluation(evalID happydns.Identifier) (ret *happydns.CheckEvaluation, err error) {
|
||||
defer observe("get", "check_evaluation")(&err)
|
||||
return s.inner.GetEvaluation(evalID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetExecution(execID happydns.Identifier) (ret *happydns.Execution, err error) {
|
||||
defer observe("get", "execution")(&err)
|
||||
return s.inner.GetExecution(execID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetLastSchedulerRun() (ret time.Time, err error) {
|
||||
defer observe("get", "scheduler_state")(&err)
|
||||
return s.inner.GetLastSchedulerRun()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetLatestEvaluation(planID happydns.Identifier) (ret *happydns.CheckEvaluation, err error) {
|
||||
defer observe("get", "check_evaluation")(&err)
|
||||
return s.inner.GetLatestEvaluation(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetProvider(prvdid happydns.Identifier) (ret *happydns.ProviderMessage, err error) {
|
||||
defer observe("get", "provider")(&err)
|
||||
return s.inner.GetProvider(prvdid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetSession(sessionid string) (ret *happydns.Session, err error) {
|
||||
defer observe("get", "session")(&err)
|
||||
return s.inner.GetSession(sessionid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetSnapshot(snapID happydns.Identifier) (ret *happydns.ObservationSnapshot, err error) {
|
||||
defer observe("get", "observation_snapshot")(&err)
|
||||
return s.inner.GetSnapshot(snapID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetUser(userid happydns.Identifier) (ret *happydns.User, err error) {
|
||||
defer observe("get", "user")(&err)
|
||||
return s.inner.GetUser(userid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetUserByEmail(email string) (ret *happydns.User, err error) {
|
||||
defer observe("get", "user")(&err)
|
||||
return s.inner.GetUserByEmail(email)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetZone(zoneid happydns.Identifier) (ret *happydns.ZoneMessage, err error) {
|
||||
defer observe("get", "zone")(&err)
|
||||
return s.inner.GetZone(zoneid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetZoneMeta(zoneid happydns.Identifier) (ret *happydns.ZoneMeta, err error) {
|
||||
defer observe("get", "zone")(&err)
|
||||
return s.inner.GetZoneMeta(zoneid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) InsightsRun() (err error) {
|
||||
defer observe("run", "insight")(&err)
|
||||
return s.inner.InsightsRun()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) LastInsightsRun() (ret *time.Time, ret2 happydns.Identifier, err error) {
|
||||
defer observe("get", "insight")(&err)
|
||||
return s.inner.LastInsightsRun()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllAuthUsers() (ret happydns.Iterator[happydns.UserAuth], err error) {
|
||||
defer observe("list", "authuser")(&err)
|
||||
return s.inner.ListAllAuthUsers()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllCachedObservations() (ret happydns.Iterator[happydns.ObservationCacheEntry], err error) {
|
||||
defer observe("list", "observation_cache")(&err)
|
||||
return s.inner.ListAllCachedObservations()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllCheckPlans() (ret happydns.Iterator[happydns.CheckPlan], err error) {
|
||||
defer observe("list", "check_plan")(&err)
|
||||
return s.inner.ListAllCheckPlans()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllCheckerConfigurations() (ret happydns.Iterator[happydns.CheckerOptionsPositional], err error) {
|
||||
defer observe("list", "check_config")(&err)
|
||||
return s.inner.ListAllCheckerConfigurations()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllDomainLogs() (ret happydns.Iterator[happydns.DomainLogWithDomainId], err error) {
|
||||
defer observe("list", "domain_log")(&err)
|
||||
return s.inner.ListAllDomainLogs()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllDomains() (ret happydns.Iterator[happydns.Domain], err error) {
|
||||
defer observe("list", "domain")(&err)
|
||||
return s.inner.ListAllDomains()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllEvaluations() (ret happydns.Iterator[happydns.CheckEvaluation], err error) {
|
||||
defer observe("list", "check_evaluation")(&err)
|
||||
return s.inner.ListAllEvaluations()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllExecutions() (ret happydns.Iterator[happydns.Execution], err error) {
|
||||
defer observe("list", "execution")(&err)
|
||||
return s.inner.ListAllExecutions()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllProviders() (ret happydns.Iterator[happydns.ProviderMessage], err error) {
|
||||
defer observe("list", "provider")(&err)
|
||||
return s.inner.ListAllProviders()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllSessions() (ret happydns.Iterator[happydns.Session], err error) {
|
||||
defer observe("list", "session")(&err)
|
||||
return s.inner.ListAllSessions()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllSnapshots() (ret happydns.Iterator[happydns.ObservationSnapshot], err error) {
|
||||
defer observe("list", "observation_snapshot")(&err)
|
||||
return s.inner.ListAllSnapshots()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllUsers() (ret happydns.Iterator[happydns.User], err error) {
|
||||
defer observe("list", "user")(&err)
|
||||
return s.inner.ListAllUsers()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAllZones() (ret happydns.Iterator[happydns.ZoneMessage], err error) {
|
||||
defer observe("list", "zone")(&err)
|
||||
return s.inner.ListAllZones()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAuthUserSessions(user *happydns.UserAuth) (ret []*happydns.Session, err error) {
|
||||
defer observe("list", "session")(&err)
|
||||
return s.inner.ListAuthUserSessions(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListCheckPlansByChecker(checkerID string) (ret []*happydns.CheckPlan, err error) {
|
||||
defer observe("list", "check_plan")(&err)
|
||||
return s.inner.ListCheckPlansByChecker(checkerID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListCheckPlansByTarget(target happydns.CheckTarget) (ret []*happydns.CheckPlan, err error) {
|
||||
defer observe("list", "check_plan")(&err)
|
||||
return s.inner.ListCheckPlansByTarget(target)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListCheckPlansByUser(userId happydns.Identifier) (ret []*happydns.CheckPlan, err error) {
|
||||
defer observe("list", "check_plan")(&err)
|
||||
return s.inner.ListCheckPlansByUser(userId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListCheckerConfiguration(checkerName string) (ret []*happydns.CheckerOptionsPositional, err error) {
|
||||
defer observe("list", "check_config")(&err)
|
||||
return s.inner.ListCheckerConfiguration(checkerName)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListDomainLogs(domain *happydns.Domain) (ret []*happydns.DomainLog, err error) {
|
||||
defer observe("list", "domain_log")(&err)
|
||||
return s.inner.ListDomainLogs(domain)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListDomains(user *happydns.User) (ret []*happydns.Domain, err error) {
|
||||
defer observe("list", "domain")(&err)
|
||||
return s.inner.ListDomains(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) (ret []*happydns.CheckEvaluation, err error) {
|
||||
defer observe("list", "check_evaluation")(&err)
|
||||
return s.inner.ListEvaluationsByChecker(checkerID, target, limit)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListEvaluationsByPlan(planID happydns.Identifier) (ret []*happydns.CheckEvaluation, err error) {
|
||||
defer observe("list", "check_evaluation")(&err)
|
||||
return s.inner.ListEvaluationsByPlan(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int, filter func(*happydns.Execution) bool) (ret []*happydns.Execution, err error) {
|
||||
defer observe("list", "execution")(&err)
|
||||
return s.inner.ListExecutionsByChecker(checkerID, target, limit, filter)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) (ret []*happydns.Execution, err error) {
|
||||
defer observe("list", "execution")(&err)
|
||||
return s.inner.ListExecutionsByDomain(domainId, limit, filter)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListExecutionsByPlan(planID happydns.Identifier) (ret []*happydns.Execution, err error) {
|
||||
defer observe("list", "execution")(&err)
|
||||
return s.inner.ListExecutionsByPlan(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListExecutionsByUser(userId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) (ret []*happydns.Execution, err error) {
|
||||
defer observe("list", "execution")(&err)
|
||||
return s.inner.ListExecutionsByUser(userId, limit, filter)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.ProviderMessages, err error) {
|
||||
defer observe("list", "provider")(&err)
|
||||
return s.inner.ListProviders(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListUserSessions(userid happydns.Identifier) (ret []*happydns.Session, err error) {
|
||||
defer observe("list", "session")(&err)
|
||||
return s.inner.ListUserSessions(userid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) MigrateSchema() error { return s.inner.MigrateSchema() }
|
||||
|
||||
func (s *instrumentedStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) (err error) {
|
||||
defer observe("put", "observation_cache")(&err)
|
||||
return s.inner.PutCachedObservation(target, key, entry)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) SchemaVersion() int { return s.inner.SchemaVersion() }
|
||||
|
||||
func (s *instrumentedStorage) SetLastSchedulerRun(t time.Time) (err error) {
|
||||
defer observe("set", "scheduler_state")(&err)
|
||||
return s.inner.SetLastSchedulerRun(t)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) TidyCheckPlanIndexes() (err error) {
|
||||
defer observe("tidy", "check_plan")(&err)
|
||||
return s.inner.TidyCheckPlanIndexes()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) TidyEvaluationIndexes() (err error) {
|
||||
defer observe("tidy", "check_evaluation")(&err)
|
||||
return s.inner.TidyEvaluationIndexes()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) TidyExecutionIndexes() (err error) {
|
||||
defer observe("tidy", "execution")(&err)
|
||||
return s.inner.TidyExecutionIndexes()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateAuthUser(user *happydns.UserAuth) (err error) {
|
||||
defer observe("update", "authuser")(&err)
|
||||
return s.inner.UpdateAuthUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateCheckPlan(plan *happydns.CheckPlan) (err error) {
|
||||
defer observe("update", "check_plan")(&err)
|
||||
return s.inner.UpdateCheckPlan(plan)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier, opts happydns.CheckerOptions) (err error) {
|
||||
defer observe("update", "check_config")(&err)
|
||||
return s.inner.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, opts)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateDomain(domain *happydns.Domain) (err error) {
|
||||
defer observe("update", "domain")(&err)
|
||||
return s.inner.UpdateDomain(domain)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
|
||||
defer observe("update", "domain_log")(&err)
|
||||
return s.inner.UpdateDomainLog(domain, log)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateExecution(exec *happydns.Execution) (err error) {
|
||||
defer observe("update", "execution")(&err)
|
||||
return s.inner.UpdateExecution(exec)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateProvider(prvd *happydns.Provider) (err error) {
|
||||
defer observe("update", "provider")(&err)
|
||||
return s.inner.UpdateProvider(prvd)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateSession(session *happydns.Session) (err error) {
|
||||
defer observe("update", "session")(&err)
|
||||
return s.inner.UpdateSession(session)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateZone(zone *happydns.Zone) (err error) {
|
||||
defer observe("update", "zone")(&err)
|
||||
return s.inner.UpdateZone(zone)
|
||||
}
|
||||
238
internal/app/plugins.go
Normal file
238
internal/app/plugins.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
// 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 linux || darwin || freebsd
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"plugin"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
// pluginSymbols is the minimal subset of *plugin.Plugin used by the loaders.
|
||||
// It exists so that loaders can be unit-tested with a fake instead of
|
||||
// requiring a real .so file built via `go build -buildmode=plugin`.
|
||||
type pluginSymbols interface {
|
||||
Lookup(symName string) (plugin.Symbol, error)
|
||||
}
|
||||
|
||||
// pluginLoader attempts to find and register one specific kind of plugin
|
||||
// symbol from an already-opened .so file.
|
||||
//
|
||||
// It returns (true, nil) when the symbol was found and registration
|
||||
// succeeded, (true, err) when the symbol was found but something went wrong,
|
||||
// and (false, nil) when the symbol simply isn't present in that file (which
|
||||
// is not considered an error: a single .so may implement only a subset of
|
||||
// the known plugin types).
|
||||
type pluginLoader func(p pluginSymbols, fname string) (found bool, err error)
|
||||
|
||||
// safeCall invokes fn while recovering from any panic raised by plugin code.
|
||||
// A panicking factory must not take the whole server down at startup; the
|
||||
// recovered value is converted to an error so the caller can log/skip the
|
||||
// offending plugin like any other failure.
|
||||
func safeCall(symbol string, fname string, fn func() error) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("plugin %q panicked in %s: %v", fname, symbol, r)
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
|
||||
// pluginLoaders is the authoritative list of plugin types that happyDomain
|
||||
// knows about. To support a new plugin type, add a single entry here.
|
||||
var pluginLoaders = []pluginLoader{
|
||||
loadCheckerPlugin,
|
||||
}
|
||||
|
||||
// loadCheckerPlugin handles the NewCheckerPlugin symbol exported by checkers
|
||||
// built against checker-sdk-go (see ../../checker-dummy/README.md).
|
||||
func loadCheckerPlugin(p pluginSymbols, fname string) (bool, error) {
|
||||
sym, err := p.Lookup("NewCheckerPlugin")
|
||||
if err != nil {
|
||||
// Symbol not present in this .so, not an error.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
factory, ok := sym.(func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error))
|
||||
if !ok {
|
||||
return true, fmt.Errorf("symbol NewCheckerPlugin has unexpected type %T", sym)
|
||||
}
|
||||
|
||||
var (
|
||||
def *sdk.CheckerDefinition
|
||||
provider sdk.ObservationProvider
|
||||
)
|
||||
if err := safeCall("NewCheckerPlugin", fname, func() error {
|
||||
var ferr error
|
||||
def, provider, ferr = factory()
|
||||
return ferr
|
||||
}); err != nil {
|
||||
return true, err
|
||||
}
|
||||
if def == nil {
|
||||
return true, fmt.Errorf("NewCheckerPlugin returned a nil CheckerDefinition")
|
||||
}
|
||||
if provider == nil {
|
||||
return true, fmt.Errorf("NewCheckerPlugin returned a nil ObservationProvider")
|
||||
}
|
||||
|
||||
checker.RegisterObservationProvider(provider)
|
||||
checker.RegisterExternalizableChecker(def)
|
||||
log.Printf("Plugin %s (%s) loaded", def.ID, fname)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// checkPluginDirectoryPermissions refuses to load plugins from a directory
|
||||
// that any non-owner can write to. Loading a .so file is arbitrary code
|
||||
// execution as the happyDomain process, so a world- or group-writable
|
||||
// plugin directory is treated as a fatal misconfiguration: any local user
|
||||
// (or any process sharing the group) able to drop a file there could take
|
||||
// over the server. Operators who genuinely need shared deployment should
|
||||
// stage plugins elsewhere and rsync them into a directory owned and
|
||||
// writable only by the happyDomain user.
|
||||
func checkPluginDirectoryPermissions(directory string) error {
|
||||
// Use Lstat to detect symlinks: a symlink could be silently redirected
|
||||
// to an attacker-controlled directory, bypassing the permission check
|
||||
// on the original path.
|
||||
linfo, err := os.Lstat(directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to stat plugins directory %q: %s", directory, err)
|
||||
}
|
||||
if linfo.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("plugins directory %q is a symbolic link; refusing to follow it", directory)
|
||||
}
|
||||
if !linfo.IsDir() {
|
||||
return fmt.Errorf("plugins path %q is not a directory", directory)
|
||||
}
|
||||
mode := linfo.Mode().Perm()
|
||||
if mode&0o022 != 0 {
|
||||
return fmt.Errorf("plugins directory %q is group- or world-writable (mode %#o); refusing to load plugins from it", directory, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPluginFilePermissions refuses to load a .so file that is group- or
|
||||
// world-writable. Even inside a properly locked-down directory, a writable
|
||||
// plugin binary could be replaced by a malicious actor sharing the group.
|
||||
// Symlinks are followed: the permission check applies to the resolved target,
|
||||
// which allows the common pattern of symlinking to versioned binaries
|
||||
// (e.g. checker-foo.so -> checker-foo-v1.2.so) for atomic upgrades.
|
||||
// The directory-level symlink ban already prevents attackers from redirecting
|
||||
// the scan root itself.
|
||||
func checkPluginFilePermissions(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to stat plugin file %q: %s", path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("plugin %q is not a regular file (or resolves to a non-regular file)", path)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
if mode&0o022 != 0 {
|
||||
return fmt.Errorf("plugin file %q is group- or world-writable (mode %#o)", path, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// initPlugins scans each directory listed in cfg.PluginsDirectories and loads
|
||||
// every .so file found as a Go plugin. A directory that cannot be read is a
|
||||
// fatal configuration error; individual plugin failures are logged and
|
||||
// skipped so that one bad .so does not prevent the others from loading.
|
||||
func (a *App) initPlugins() error {
|
||||
for _, directory := range a.cfg.PluginsDirectories {
|
||||
if err := checkPluginDirectoryPermissions(directory); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read plugins directory %q: %s", directory, err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only attempt to load shared-object files.
|
||||
if filepath.Ext(file.Name()) != ".so" {
|
||||
continue
|
||||
}
|
||||
|
||||
fname := filepath.Join(directory, file.Name())
|
||||
|
||||
if err := checkPluginFilePermissions(fname); err != nil {
|
||||
log.Printf("Skipping plugin %q: %s", fname, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := loadPlugin(fname); err != nil {
|
||||
log.Printf("Unable to load plugin %q: %s", fname, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPlugin opens the .so file at fname and runs every registered
|
||||
// pluginLoader against it. A loader that does not find its symbol is silently
|
||||
// skipped. If no loader recognises any symbol in the file a warning is
|
||||
// logged, because the file might be a valid plugin for a future version of
|
||||
// happyDomain. Loader errors for one plugin kind do not prevent the other
|
||||
// kinds in the same .so from being attempted: a single .so is allowed to
|
||||
// expose more than one plugin type, and a failure to register (e.g.) the
|
||||
// service half should not silently drop the checker half. All loader errors
|
||||
// encountered are joined and returned together.
|
||||
func loadPlugin(fname string) error {
|
||||
p, err := plugin.Open(fname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
anyFound bool
|
||||
errs []error
|
||||
)
|
||||
for _, loader := range pluginLoaders {
|
||||
found, err := loader(p, fname)
|
||||
if found {
|
||||
anyFound = true
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !anyFound && len(errs) == 0 {
|
||||
log.Printf("Warning: plugin %q exports no recognised symbols", fname)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
143
internal/app/plugins_checker_test.go
Normal file
143
internal/app/plugins_checker_test.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// 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 linux || darwin || freebsd
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"plugin"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// dummyCheckerProvider is a minimal ObservationProvider used by the tests
|
||||
// below. It is intentionally trivial: the loader tests only care that
|
||||
// registration succeeds, not what the provider actually collects.
|
||||
type dummyCheckerProvider struct {
|
||||
key happydns.ObservationKey
|
||||
}
|
||||
|
||||
func (d *dummyCheckerProvider) Key() happydns.ObservationKey { return d.key }
|
||||
func (d *dummyCheckerProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newDummyCheckerFactory(id string) func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
return func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
def := &sdk.CheckerDefinition{
|
||||
ID: id,
|
||||
Name: "Dummy checker",
|
||||
}
|
||||
return def, &dummyCheckerProvider{key: happydns.ObservationKey("dummy-" + id)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCheckerPlugin_SymbolMissing(t *testing.T) {
|
||||
found, err := loadCheckerPlugin(&fakeSymbols{}, "missing.so")
|
||||
if found || err != nil {
|
||||
t.Fatalf("expected (false, nil) when symbol is absent, got (%v, %v)", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCheckerPlugin_WrongSymbolType(t *testing.T) {
|
||||
fs := &fakeSymbols{syms: map[string]plugin.Symbol{
|
||||
"NewCheckerPlugin": 42, // not a function
|
||||
}}
|
||||
found, err := loadCheckerPlugin(fs, "wrongtype.so")
|
||||
if !found || err == nil || !strings.Contains(err.Error(), "unexpected type") {
|
||||
t.Fatalf("expected wrong-type error, got (%v, %v)", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCheckerPlugin_FactoryError(t *testing.T) {
|
||||
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
return nil, nil, errors.New("boom")
|
||||
}
|
||||
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
|
||||
|
||||
found, err := loadCheckerPlugin(fs, "factoryerr.so")
|
||||
if !found || err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("expected factory error to propagate, got (%v, %v)", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCheckerPlugin_NilDefinition(t *testing.T) {
|
||||
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
return nil, &dummyCheckerProvider{key: "k"}, nil
|
||||
}
|
||||
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
|
||||
|
||||
found, err := loadCheckerPlugin(fs, "nildef.so")
|
||||
if !found || err == nil || !strings.Contains(err.Error(), "nil CheckerDefinition") {
|
||||
t.Fatalf("expected nil-definition error, got (%v, %v)", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCheckerPlugin_NilProvider(t *testing.T) {
|
||||
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
return &sdk.CheckerDefinition{ID: "x"}, nil, nil
|
||||
}
|
||||
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
|
||||
|
||||
found, err := loadCheckerPlugin(fs, "nilprov.so")
|
||||
if !found || err == nil || !strings.Contains(err.Error(), "nil ObservationProvider") {
|
||||
t.Fatalf("expected nil-provider error, got (%v, %v)", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCheckerPlugin_FactoryPanics(t *testing.T) {
|
||||
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
panic("kaboom")
|
||||
}
|
||||
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
|
||||
|
||||
found, err := loadCheckerPlugin(fs, "panic.so")
|
||||
if !found || err == nil {
|
||||
t.Fatalf("expected panic to be converted to error, got (%v, %v)", found, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "panicked") || !strings.Contains(err.Error(), "kaboom") {
|
||||
t.Errorf("expected wrapped panic error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCheckerPlugin_Success(t *testing.T) {
|
||||
factory := newDummyCheckerFactory("dummy-success")
|
||||
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
|
||||
|
||||
found, err := loadCheckerPlugin(fs, "first.so")
|
||||
if !found || err != nil {
|
||||
t.Fatalf("expected success, got (%v, %v)", found, err)
|
||||
}
|
||||
|
||||
if got := checker.FindChecker("dummy-success"); got == nil {
|
||||
t.Errorf("expected checker %q to be registered", "dummy-success")
|
||||
}
|
||||
if got := sdk.FindObservationProvider(happydns.ObservationKey("dummy-dummy-success")); got == nil {
|
||||
t.Errorf("expected observation provider %q to be registered", "dummy-dummy-success")
|
||||
}
|
||||
}
|
||||
37
internal/app/plugins_stub.go
Normal file
37
internal/app/plugins_stub.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// 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 !linux && !darwin && !freebsd
|
||||
|
||||
package app
|
||||
|
||||
import "log"
|
||||
|
||||
// initPlugins is a no-op on platforms where Go's plugin package is not
|
||||
// supported (Windows, plan9, …). If the operator configured plugin
|
||||
// directories anyway we log a clear warning rather than silently ignoring
|
||||
// them, so the misconfiguration is visible at startup.
|
||||
func (a *App) initPlugins() error {
|
||||
if len(a.cfg.PluginsDirectories) > 0 {
|
||||
log.Printf("Warning: plugin loading is not supported on this platform; ignoring %d configured plugin directories", len(a.cfg.PluginsDirectories))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
172
internal/app/plugins_test.go
Normal file
172
internal/app/plugins_test.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
// 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 linux || darwin || freebsd
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"plugin"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeSymbols is a pluginSymbols implementation backed by a static map. It
|
||||
// lets the loader tests exercise their behaviour without having to compile a
|
||||
// real .so file via `go build -buildmode=plugin`.
|
||||
type fakeSymbols struct {
|
||||
syms map[string]plugin.Symbol
|
||||
}
|
||||
|
||||
func (f *fakeSymbols) Lookup(name string) (plugin.Symbol, error) {
|
||||
if s, ok := f.syms[name]; ok {
|
||||
return s, nil
|
||||
}
|
||||
return nil, fmt.Errorf("symbol %q not found", name)
|
||||
}
|
||||
|
||||
// TestLoadPlugin_NoRecognisedSymbols verifies that when a .so file exports
|
||||
// none of the known plugin symbols, every loader returns (false, nil), i.e.
|
||||
// the file is silently skipped rather than reported as an error. loadPlugin
|
||||
// itself logs a warning in that situation; we exercise the inner loop here
|
||||
// because the outer call requires plugin.Open and a real .so file.
|
||||
func TestLoadPlugin_NoRecognisedSymbols(t *testing.T) {
|
||||
fs := &fakeSymbols{}
|
||||
for _, loader := range pluginLoaders {
|
||||
found, err := loader(fs, "empty.so")
|
||||
if found || err != nil {
|
||||
t.Fatalf("loader returned (%v, %v) for empty symbol set, expected (false, nil)", found, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPluginDirectoryPermissions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// A freshly-created TempDir is owner-only on every platform we run on,
|
||||
// so this must be accepted.
|
||||
if err := os.Chmod(dir, 0o750); err != nil {
|
||||
t.Fatalf("chmod 0750: %v", err)
|
||||
}
|
||||
if err := checkPluginDirectoryPermissions(dir); err != nil {
|
||||
t.Errorf("expected 0750 directory to be accepted, got %v", err)
|
||||
}
|
||||
|
||||
// World-writable: must be refused.
|
||||
if err := os.Chmod(dir, 0o777); err != nil {
|
||||
t.Fatalf("chmod 0777: %v", err)
|
||||
}
|
||||
if err := checkPluginDirectoryPermissions(dir); err == nil {
|
||||
t.Errorf("expected 0777 directory to be refused")
|
||||
}
|
||||
|
||||
// Group-writable: must also be refused.
|
||||
if err := os.Chmod(dir, 0o770); err != nil {
|
||||
t.Fatalf("chmod 0770: %v", err)
|
||||
}
|
||||
if err := checkPluginDirectoryPermissions(dir); err == nil {
|
||||
t.Errorf("expected 0770 directory to be refused")
|
||||
}
|
||||
|
||||
// Restore permissions so t.TempDir cleanup can remove the directory.
|
||||
_ = os.Chmod(dir, 0o700)
|
||||
|
||||
// Non-existent path: must be refused.
|
||||
if err := checkPluginDirectoryPermissions(filepath.Join(dir, "does-not-exist")); err == nil {
|
||||
t.Errorf("expected missing directory to be refused")
|
||||
}
|
||||
|
||||
// Symlink to a valid directory: must be refused.
|
||||
target := t.TempDir()
|
||||
link := filepath.Join(dir, "symlink-plugins")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
if err := checkPluginDirectoryPermissions(link); err == nil {
|
||||
t.Errorf("expected symlink directory to be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPluginFilePermissions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "test.so")
|
||||
if err := os.WriteFile(f, []byte("fake"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
// Owner-writable, not group/world-writable: accepted.
|
||||
if err := checkPluginFilePermissions(f); err != nil {
|
||||
t.Errorf("expected 0644 file to be accepted, got %v", err)
|
||||
}
|
||||
|
||||
// Group-writable: refused.
|
||||
if err := os.Chmod(f, 0o664); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if err := checkPluginFilePermissions(f); err == nil {
|
||||
t.Errorf("expected 0664 file to be refused")
|
||||
}
|
||||
|
||||
// World-writable: refused.
|
||||
if err := os.Chmod(f, 0o646); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if err := checkPluginFilePermissions(f); err == nil {
|
||||
t.Errorf("expected 0646 file to be refused")
|
||||
}
|
||||
|
||||
// Non-existent: refused.
|
||||
if err := checkPluginFilePermissions(filepath.Join(dir, "nope.so")); err == nil {
|
||||
t.Errorf("expected missing file to be refused")
|
||||
}
|
||||
|
||||
// Symlink to a safe regular file: accepted (we follow the link and
|
||||
// check the target's permissions, not the link itself).
|
||||
regular := filepath.Join(dir, "real.so")
|
||||
if err := os.WriteFile(regular, []byte("real"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "link.so")
|
||||
if err := os.Symlink(regular, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
if err := checkPluginFilePermissions(link); err != nil {
|
||||
t.Errorf("expected symlink to safe file to be accepted, got %v", err)
|
||||
}
|
||||
|
||||
// Symlink to a writable target: refused.
|
||||
writable := filepath.Join(dir, "writable.so")
|
||||
if err := os.WriteFile(writable, []byte("bad"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.Chmod(writable, 0o666); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
linkBad := filepath.Join(dir, "link-bad.so")
|
||||
if err := os.Symlink(writable, linkBad); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
if err := checkPluginFilePermissions(linkBad); err == nil {
|
||||
t.Errorf("expected symlink to writable file to be refused")
|
||||
}
|
||||
}
|
||||
51
internal/checker/aggregator.go
Normal file
51
internal/checker/aggregator.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// WorstStatusAggregator aggregates check states by taking the worst status.
|
||||
type WorstStatusAggregator struct{}
|
||||
|
||||
func (a WorstStatusAggregator) Aggregate(states []happydns.CheckState) happydns.CheckState {
|
||||
if len(states) == 0 {
|
||||
return happydns.CheckState{Status: happydns.StatusUnknown}
|
||||
}
|
||||
worst := states[0].Status
|
||||
var messages []string
|
||||
for _, s := range states {
|
||||
if s.Status > worst {
|
||||
worst = s.Status
|
||||
}
|
||||
if s.Message != "" {
|
||||
messages = append(messages, s.Message)
|
||||
}
|
||||
}
|
||||
return happydns.CheckState{
|
||||
Status: worst,
|
||||
Message: strings.Join(messages, "; "),
|
||||
}
|
||||
}
|
||||
117
internal/checker/aggregator_test.go
Normal file
117
internal/checker/aggregator_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestWorstStatusAggregator_Empty(t *testing.T) {
|
||||
agg := WorstStatusAggregator{}
|
||||
got := agg.Aggregate(nil)
|
||||
if got.Status != happydns.StatusUnknown {
|
||||
t.Errorf("Aggregate(nil) status = %v, want StatusUnknown", got.Status)
|
||||
}
|
||||
if got.Message != "" {
|
||||
t.Errorf("Aggregate(nil) message = %q, want empty", got.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorstStatusAggregator_Single(t *testing.T) {
|
||||
agg := WorstStatusAggregator{}
|
||||
got := agg.Aggregate([]happydns.CheckState{
|
||||
{Status: happydns.StatusOK, Message: "all good"},
|
||||
})
|
||||
if got.Status != happydns.StatusOK {
|
||||
t.Errorf("status = %v, want StatusOK", got.Status)
|
||||
}
|
||||
if got.Message != "all good" {
|
||||
t.Errorf("message = %q, want %q", got.Message, "all good")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorstStatusAggregator_PicksWorst(t *testing.T) {
|
||||
agg := WorstStatusAggregator{}
|
||||
tests := []struct {
|
||||
name string
|
||||
states []happydns.CheckState
|
||||
wantStat happydns.Status
|
||||
}{
|
||||
{
|
||||
name: "ok and warn",
|
||||
states: []happydns.CheckState{
|
||||
{Status: happydns.StatusOK},
|
||||
{Status: happydns.StatusWarn},
|
||||
},
|
||||
wantStat: happydns.StatusWarn,
|
||||
},
|
||||
{
|
||||
name: "crit among ok and warn",
|
||||
states: []happydns.CheckState{
|
||||
{Status: happydns.StatusOK},
|
||||
{Status: happydns.StatusCrit},
|
||||
{Status: happydns.StatusWarn},
|
||||
},
|
||||
wantStat: happydns.StatusCrit,
|
||||
},
|
||||
{
|
||||
name: "error is worst",
|
||||
states: []happydns.CheckState{
|
||||
{Status: happydns.StatusCrit},
|
||||
{Status: happydns.StatusError},
|
||||
{Status: happydns.StatusOK},
|
||||
},
|
||||
wantStat: happydns.StatusError,
|
||||
},
|
||||
{
|
||||
name: "info and ok",
|
||||
states: []happydns.CheckState{
|
||||
{Status: happydns.StatusInfo},
|
||||
{Status: happydns.StatusOK},
|
||||
},
|
||||
wantStat: happydns.StatusInfo,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := agg.Aggregate(tt.states)
|
||||
if got.Status != tt.wantStat {
|
||||
t.Errorf("status = %v, want %v", got.Status, tt.wantStat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorstStatusAggregator_ConcatenatesMessages(t *testing.T) {
|
||||
agg := WorstStatusAggregator{}
|
||||
got := agg.Aggregate([]happydns.CheckState{
|
||||
{Status: happydns.StatusOK, Message: "check A passed"},
|
||||
{Status: happydns.StatusWarn, Message: ""},
|
||||
{Status: happydns.StatusCrit, Message: "check C failed"},
|
||||
})
|
||||
want := "check A passed; check C failed"
|
||||
if got.Message != want {
|
||||
t.Errorf("message = %q, want %q", got.Message, want)
|
||||
}
|
||||
}
|
||||
323
internal/checker/observation.go
Normal file
323
internal/checker/observation.go
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
// observation.go implements the observation subsystem, which is the data
|
||||
// collection layer for the checker framework. An observation represents a
|
||||
// piece of raw data gathered about a check target (e.g. DNS records, HTTP
|
||||
// headers, TLS certificate details). Observations are identified by an
|
||||
// ObservationKey and collected on demand by registered ObservationProviders.
|
||||
//
|
||||
// The ObservationContext provides lazy-loading, cached, thread-safe access to
|
||||
// observations: the first checker that requests a given observation triggers
|
||||
// its collection, and subsequent checkers reuse the cached result. This
|
||||
// design decouples data collection from evaluation: checkers declare which
|
||||
// observations they need, and the context ensures each is collected at most
|
||||
// once per check run. Observations can also be persisted as snapshots and
|
||||
// reused across runs when freshness requirements allow.
|
||||
//
|
||||
// Observation providers may optionally implement reporting interfaces
|
||||
// (CheckerHTMLReporter, CheckerMetricsReporter) to produce human-readable
|
||||
// reports or extract time-series metrics from collected data.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// ObservationCacheLookup resolves a cached observation for a target+key.
|
||||
// Returns the raw data and collection time, or an error if not cached.
|
||||
type ObservationCacheLookup func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error)
|
||||
|
||||
// ObservationContext provides lazy-loading, cached, thread-safe access to observation data.
|
||||
// Collected data is serialized to json.RawMessage immediately after collection.
|
||||
//
|
||||
// Concurrency model: the outer mu protects only the cache/errors/inflight
|
||||
// maps and is held for short critical sections. Provider collection runs
|
||||
// *without* mu held, so two calls to Get for *different* keys can collect
|
||||
// concurrently. Two calls for the *same* key are deduplicated: the first
|
||||
// installs an inflight channel, runs the collection, then closes the
|
||||
// channel; the others wait on it and read the cached result afterwards.
|
||||
type ObservationContext struct {
|
||||
target happydns.CheckTarget
|
||||
opts happydns.CheckerOptions
|
||||
cache map[happydns.ObservationKey]json.RawMessage
|
||||
errors map[happydns.ObservationKey]error
|
||||
inflight map[happydns.ObservationKey]chan struct{}
|
||||
mu sync.Mutex
|
||||
cacheLookup ObservationCacheLookup // nil = no DB cache
|
||||
freshness time.Duration // 0 = always collect
|
||||
providerOverride map[happydns.ObservationKey]happydns.ObservationProvider
|
||||
}
|
||||
|
||||
// NewObservationContext creates a new ObservationContext for the given target and options.
|
||||
// cacheLookup and freshness enable cross-checker observation reuse from stored snapshots.
|
||||
// Pass nil and 0 to disable DB-based caching.
|
||||
func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions, cacheLookup ObservationCacheLookup, freshness time.Duration) *ObservationContext {
|
||||
return &ObservationContext{
|
||||
target: target,
|
||||
opts: opts,
|
||||
cache: make(map[happydns.ObservationKey]json.RawMessage),
|
||||
errors: make(map[happydns.ObservationKey]error),
|
||||
inflight: make(map[happydns.ObservationKey]chan struct{}),
|
||||
cacheLookup: cacheLookup,
|
||||
freshness: freshness,
|
||||
}
|
||||
}
|
||||
|
||||
// SetProviderOverride registers a per-context provider that takes precedence
|
||||
// over the global registry for the given observation key. This is used to
|
||||
// substitute local providers with HTTP-backed ones when an endpoint is configured.
|
||||
func (oc *ObservationContext) SetProviderOverride(key happydns.ObservationKey, p happydns.ObservationProvider) {
|
||||
oc.mu.Lock()
|
||||
defer oc.mu.Unlock()
|
||||
if oc.providerOverride == nil {
|
||||
oc.providerOverride = make(map[happydns.ObservationKey]happydns.ObservationProvider)
|
||||
}
|
||||
oc.providerOverride[key] = p
|
||||
}
|
||||
|
||||
// getProvider returns the observation provider for the given key, checking
|
||||
// per-context overrides first, then falling back to the global registry.
|
||||
// Safe to call without holding oc.mu - it acquires the lock internally.
|
||||
func (oc *ObservationContext) getProvider(key happydns.ObservationKey) happydns.ObservationProvider {
|
||||
oc.mu.Lock()
|
||||
override := oc.providerOverride
|
||||
oc.mu.Unlock()
|
||||
if override != nil {
|
||||
if p, ok := override[key]; ok {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return sdk.FindObservationProvider(key)
|
||||
}
|
||||
|
||||
// Get collects observation data for the given key (lazily) and unmarshals it into dest.
|
||||
// Thread-safe: concurrent calls for the same key are deduplicated; concurrent
|
||||
// calls for different keys collect in parallel.
|
||||
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
|
||||
for {
|
||||
oc.mu.Lock()
|
||||
if raw, ok := oc.cache[key]; ok {
|
||||
oc.mu.Unlock()
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
if err, ok := oc.errors[key]; ok {
|
||||
oc.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
if ch, ok := oc.inflight[key]; ok {
|
||||
// Another goroutine is already collecting this key. Release
|
||||
// the lock, wait for it to finish, then re-check the cache.
|
||||
oc.mu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// We are the leader for this key. Install the inflight channel
|
||||
// before releasing the lock so concurrent callers wait on us.
|
||||
ch := make(chan struct{})
|
||||
oc.inflight[key] = ch
|
||||
oc.mu.Unlock()
|
||||
|
||||
raw, collectErr := oc.collect(ctx, key)
|
||||
|
||||
// Collection errors are cached for the lifetime of this
|
||||
// ObservationContext (i.e. a single execution run). This is
|
||||
// intentional: within one run the same transient failure would
|
||||
// keep recurring, and retrying would slow down the pipeline.
|
||||
// A new execution creates a fresh context, giving the provider
|
||||
// another chance.
|
||||
oc.mu.Lock()
|
||||
if collectErr != nil {
|
||||
oc.errors[key] = collectErr
|
||||
} else {
|
||||
oc.cache[key] = raw
|
||||
}
|
||||
delete(oc.inflight, key)
|
||||
close(ch)
|
||||
oc.mu.Unlock()
|
||||
|
||||
if collectErr != nil {
|
||||
return collectErr
|
||||
}
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
}
|
||||
|
||||
// collect runs the DB-cache lookup and provider collection for a single key
|
||||
// without holding oc.mu, so collections for different keys can run in
|
||||
// parallel. Callers are responsible for installing the result into the cache
|
||||
// or errors map and signalling waiters.
|
||||
func (oc *ObservationContext) collect(ctx context.Context, key happydns.ObservationKey) (json.RawMessage, error) {
|
||||
if oc.cacheLookup != nil && oc.freshness > 0 {
|
||||
if raw, collectedAt, err := oc.cacheLookup(oc.target, key); err == nil {
|
||||
if time.Since(collectedAt) < oc.freshness {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider := oc.getProvider(key)
|
||||
if provider == nil {
|
||||
return nil, fmt.Errorf("no observation provider registered for key %q", key)
|
||||
}
|
||||
|
||||
val, err := provider.Collect(ctx, oc.opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("observation %q: marshal failed: %w", key, err)
|
||||
}
|
||||
return json.RawMessage(raw), nil
|
||||
}
|
||||
|
||||
// Data returns all cached observation data as pre-serialized JSON.
|
||||
func (oc *ObservationContext) Data() map[happydns.ObservationKey]json.RawMessage {
|
||||
oc.mu.Lock()
|
||||
defer oc.mu.Unlock()
|
||||
|
||||
data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache))
|
||||
for k, v := range oc.cache {
|
||||
data[k] = v
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// Provider registration is startup-only (see comments on the registries in
|
||||
// internal/service/registry.go and internal/provider/registry.go), so the
|
||||
// "any provider implements X reporter" question has a fixed answer for the
|
||||
// process lifetime. We compute it once on first call and cache it.
|
||||
var (
|
||||
htmlReporterOnce sync.Once
|
||||
htmlReporterCached bool
|
||||
metricsReporterOnce sync.Once
|
||||
metricsReporterCached bool
|
||||
)
|
||||
|
||||
// HasHTMLReporter returns true if any registered observation provider implements CheckerHTMLReporter.
|
||||
func HasHTMLReporter() bool {
|
||||
htmlReporterOnce.Do(func() {
|
||||
for _, p := range sdk.GetObservationProviders() {
|
||||
if _, ok := p.(happydns.CheckerHTMLReporter); ok {
|
||||
htmlReporterCached = true
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return htmlReporterCached
|
||||
}
|
||||
|
||||
// GetHTMLReport renders an HTML report for the given observation key and raw JSON data.
|
||||
// Returns (html, true, nil) if the provider supports HTML reports, or ("", false, nil) if not.
|
||||
func GetHTMLReport(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
|
||||
return getHTMLReport(sdk.FindObservationProvider(key), key, raw)
|
||||
}
|
||||
|
||||
// GetHTMLReportCtx is like GetHTMLReport but resolves the provider through
|
||||
// the ObservationContext, respecting per-context overrides.
|
||||
func (oc *ObservationContext) GetHTMLReportCtx(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
|
||||
return getHTMLReport(oc.getProvider(key), key, raw)
|
||||
}
|
||||
|
||||
func getHTMLReport(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
|
||||
if provider == nil {
|
||||
return "", false, fmt.Errorf("no observation provider registered for key %q", key)
|
||||
}
|
||||
|
||||
hr, ok := provider.(happydns.CheckerHTMLReporter)
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
html, err := hr.GetHTMLReport(raw)
|
||||
return html, true, err
|
||||
}
|
||||
|
||||
// HasMetricsReporter returns true if any registered observation provider implements CheckerMetricsReporter.
|
||||
func HasMetricsReporter() bool {
|
||||
metricsReporterOnce.Do(func() {
|
||||
for _, p := range sdk.GetObservationProviders() {
|
||||
if _, ok := p.(happydns.CheckerMetricsReporter); ok {
|
||||
metricsReporterCached = true
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return metricsReporterCached
|
||||
}
|
||||
|
||||
// GetMetrics extracts metrics for the given observation key and raw JSON data.
|
||||
// Returns (metrics, true, nil) if the provider supports metrics, or (nil, false, nil) if not.
|
||||
func GetMetrics(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
|
||||
return getMetrics(sdk.FindObservationProvider(key), key, raw, collectedAt)
|
||||
}
|
||||
|
||||
// GetMetricsCtx is like GetMetrics but resolves the provider through
|
||||
// the ObservationContext, respecting per-context overrides.
|
||||
func (oc *ObservationContext) GetMetricsCtx(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
|
||||
return getMetrics(oc.getProvider(key), key, raw, collectedAt)
|
||||
}
|
||||
|
||||
func getMetrics(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
|
||||
if provider == nil {
|
||||
return nil, false, fmt.Errorf("no observation provider registered for key %q", key)
|
||||
}
|
||||
|
||||
mr, ok := provider.(happydns.CheckerMetricsReporter)
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
metrics, err := mr.ExtractMetrics(raw, collectedAt)
|
||||
return metrics, true, err
|
||||
}
|
||||
|
||||
// GetAllMetrics extracts metrics from all observation keys in a snapshot.
|
||||
func GetAllMetrics(snap *happydns.ObservationSnapshot) ([]happydns.CheckMetric, error) {
|
||||
var allMetrics []happydns.CheckMetric
|
||||
var errs []error
|
||||
for key, raw := range snap.Data {
|
||||
metrics, supported, err := GetMetrics(key, raw, snap.CollectedAt)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("observation %q: %w", key, err))
|
||||
continue
|
||||
}
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
allMetrics = append(allMetrics, metrics...)
|
||||
}
|
||||
return allMetrics, errors.Join(errs...)
|
||||
}
|
||||
168
internal/checker/observation_test.go
Normal file
168
internal/checker/observation_test.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// blockingProvider is an ObservationProvider whose Collect blocks on the
|
||||
// release channel until the test signals it. It records how many concurrent
|
||||
// Collect calls are in flight at any moment.
|
||||
type blockingProvider struct {
|
||||
key happydns.ObservationKey
|
||||
release chan struct{}
|
||||
calls int32
|
||||
}
|
||||
|
||||
func (b *blockingProvider) Key() happydns.ObservationKey { return b.key }
|
||||
|
||||
func (b *blockingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
|
||||
atomic.AddInt32(&b.calls, 1)
|
||||
defer atomic.AddInt32(&b.calls, -1)
|
||||
select {
|
||||
case <-b.release:
|
||||
return map[string]string{string(b.key): "ok"}, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// TestObservationContext_ConcurrentDifferentKeys verifies that two Get calls
|
||||
// for distinct observation keys can run their Collect concurrently, i.e.
|
||||
// the per-context lock is not held across provider.Collect.
|
||||
func TestObservationContext_ConcurrentDifferentKeys(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
|
||||
pa := &blockingProvider{key: happydns.ObservationKey("test-a"), release: release}
|
||||
pb := &blockingProvider{key: happydns.ObservationKey("test-b"), release: release}
|
||||
|
||||
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
|
||||
oc.SetProviderOverride(pa.key, pa)
|
||||
oc.SetProviderOverride(pb.key, pb)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, 2)
|
||||
for i, key := range []happydns.ObservationKey{pa.key, pb.key} {
|
||||
wg.Add(1)
|
||||
go func(idx int, k happydns.ObservationKey) {
|
||||
defer wg.Done()
|
||||
var dst map[string]string
|
||||
results[idx] = oc.Get(ctx, k, &dst)
|
||||
}(i, key)
|
||||
}
|
||||
|
||||
// Wait until both providers are blocked inside Collect simultaneously.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt32(&pa.calls) == 1 && atomic.LoadInt32(&pb.calls) == 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if a, b := atomic.LoadInt32(&pa.calls), atomic.LoadInt32(&pb.calls); a != 1 || b != 1 {
|
||||
t.Fatalf("expected both providers to be collecting in parallel, got a=%d b=%d", a, b)
|
||||
}
|
||||
|
||||
// Release both Collects and wait for the Get calls to return.
|
||||
release <- struct{}{}
|
||||
release <- struct{}{}
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range results {
|
||||
if err != nil {
|
||||
t.Errorf("Get %d returned error: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestObservationContext_DedupesSameKey verifies that concurrent Get calls
|
||||
// for the *same* key only invoke provider.Collect once.
|
||||
func TestObservationContext_DedupesSameKey(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
|
||||
var collectCount int32
|
||||
prov := &countingProvider{
|
||||
key: happydns.ObservationKey("test-dedup"),
|
||||
release: release,
|
||||
count: &collectCount,
|
||||
}
|
||||
|
||||
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
|
||||
oc.SetProviderOverride(prov.key, prov)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const N = 8
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(N)
|
||||
for i := 0; i < N; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var dst map[string]string
|
||||
if err := oc.Get(ctx, prov.key, &dst); err != nil {
|
||||
t.Errorf("Get error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for at least one collect to be in flight, then release it.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&collectCount) == 0 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&collectCount); got != 1 {
|
||||
t.Errorf("expected exactly 1 Collect call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type countingProvider struct {
|
||||
key happydns.ObservationKey
|
||||
release chan struct{}
|
||||
count *int32
|
||||
}
|
||||
|
||||
func (c *countingProvider) Key() happydns.ObservationKey { return c.key }
|
||||
|
||||
func (c *countingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
|
||||
atomic.AddInt32(c.count, 1)
|
||||
select {
|
||||
case <-c.release:
|
||||
return map[string]string{"k": "v"}, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
120
internal/checker/provider_http.go
Normal file
120
internal/checker/provider_http.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// httpClient is a shared client with a sensible timeout for remote checker
|
||||
// endpoints. The per-request context can shorten this further.
|
||||
var httpClient = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// maxErrorBodySize is the maximum number of bytes read from an error response
|
||||
// body to include in the error message.
|
||||
const maxErrorBodySize = 4096
|
||||
|
||||
// maxResponseBodySize is the maximum number of bytes read from a successful
|
||||
// response body. This prevents a misbehaving endpoint from causing OOM.
|
||||
const maxResponseBodySize = 10 << 20 // 10 MiB
|
||||
|
||||
// HTTPObservationProvider is an ObservationProvider that delegates data
|
||||
// collection to a remote HTTP endpoint via POST /collect.
|
||||
type HTTPObservationProvider struct {
|
||||
observationKey happydns.ObservationKey
|
||||
endpoint string // base URL without trailing slash
|
||||
}
|
||||
|
||||
// NewHTTPObservationProvider creates a new HTTP-backed observation provider.
|
||||
// endpoint is the base URL of the remote checker (e.g. "http://checker-ping:8080").
|
||||
func NewHTTPObservationProvider(key happydns.ObservationKey, endpoint string) *HTTPObservationProvider {
|
||||
return &HTTPObservationProvider{
|
||||
observationKey: key,
|
||||
endpoint: strings.TrimSuffix(endpoint, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
// Key returns the observation key this provider handles.
|
||||
func (p *HTTPObservationProvider) Key() happydns.ObservationKey {
|
||||
return p.observationKey
|
||||
}
|
||||
|
||||
// Collect sends the observation request to the remote endpoint and returns
|
||||
// the raw JSON data. The returned value is a json.RawMessage which
|
||||
// ObservationContext.Get() will marshal without double-encoding.
|
||||
func (p *HTTPObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
reqBody := happydns.ExternalCollectRequest{
|
||||
Key: p.observationKey,
|
||||
Options: opts,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP provider %s: failed to marshal request: %w", p.observationKey, err)
|
||||
}
|
||||
|
||||
url := p.endpoint + "/collect"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP provider %s: failed to create request: %w", p.observationKey, err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP provider %s: request failed: %w", p.observationKey, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
|
||||
return nil, fmt.Errorf("HTTP provider %s: endpoint returned status %d: %s", p.observationKey, resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result happydns.ExternalCollectResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBodySize)).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("HTTP provider %s: failed to decode response: %w", p.observationKey, err)
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return nil, fmt.Errorf("HTTP provider %s: remote error: %s", p.observationKey, result.Error)
|
||||
}
|
||||
|
||||
if result.Data == nil {
|
||||
return nil, fmt.Errorf("HTTP provider %s: remote returned empty data", p.observationKey)
|
||||
}
|
||||
|
||||
// Return json.RawMessage directly - it implements json.Marshaler,
|
||||
// so ObservationContext.Get() won't double-encode it.
|
||||
return result.Data, nil
|
||||
}
|
||||
240
internal/checker/provider_http_test.go
Normal file
240
internal/checker/provider_http_test.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestHTTPObservationProvider_Key(t *testing.T) {
|
||||
p := NewHTTPObservationProvider("my_key", "http://example.com")
|
||||
if got := p.Key(); got != "my_key" {
|
||||
t.Errorf("Key() = %q, want %q", got, "my_key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPObservationProvider_TrailingSlashTrimmed(t *testing.T) {
|
||||
p := NewHTTPObservationProvider("k", "http://example.com/")
|
||||
if p.endpoint != "http://example.com" {
|
||||
t.Errorf("endpoint = %q, want trailing slash trimmed", p.endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPObservationProvider_CollectSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/collect" {
|
||||
t.Errorf("expected /collect, got %s", r.URL.Path)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %q", ct)
|
||||
}
|
||||
|
||||
// Verify request body is well-formed.
|
||||
var req happydns.ExternalCollectRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
if req.Key != "test_obs" {
|
||||
t.Errorf("request Key = %q, want %q", req.Key, "test_obs")
|
||||
}
|
||||
if v, ok := req.Options["foo"]; !ok || v != "bar" {
|
||||
t.Errorf("request Options[foo] = %v, want %q", v, "bar")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
|
||||
Data: json.RawMessage(`{"value":42}`),
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPObservationProvider("test_obs", srv.URL)
|
||||
opts := happydns.CheckerOptions{"foo": "bar"}
|
||||
|
||||
result, err := p.Collect(context.Background(), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() returned error: %v", err)
|
||||
}
|
||||
|
||||
raw, ok := result.(json.RawMessage)
|
||||
if !ok {
|
||||
t.Fatalf("expected json.RawMessage, got %T", result)
|
||||
}
|
||||
|
||||
var data map[string]int
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
t.Fatalf("failed to unmarshal result: %v", err)
|
||||
}
|
||||
if data["value"] != 42 {
|
||||
t.Errorf("value = %d, want 42", data["value"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPObservationProvider_CollectRemoteError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
|
||||
Error: "something went wrong",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPObservationProvider("k", srv.URL)
|
||||
_, err := p.Collect(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for remote error response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "something went wrong") {
|
||||
t.Errorf("error = %q, want it to contain remote error message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPObservationProvider_CollectEmptyData(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPObservationProvider("k", srv.URL)
|
||||
_, err := p.Collect(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty data response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty data") {
|
||||
t.Errorf("error = %q, want it to mention empty data", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPObservationProvider_CollectNon200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal failure", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPObservationProvider("k", srv.URL)
|
||||
_, err := p.Collect(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-200 status")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("error = %q, want it to contain status code 500", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "internal failure") {
|
||||
t.Errorf("error = %q, want it to contain response body excerpt", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPObservationProvider_CollectInvalidJSON(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, "not json")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPObservationProvider("k", srv.URL)
|
||||
_, err := p.Collect(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "decode") {
|
||||
t.Errorf("error = %q, want it to mention decode failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPObservationProvider_CollectContextCancelled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Block until the request context is cancelled.
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewHTTPObservationProvider("k", srv.URL)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
_, err := p.Collect(ctx, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPObservationProvider_CollectConnectionRefused(t *testing.T) {
|
||||
// Use a server that is immediately closed to simulate connection refused.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
endpoint := srv.URL
|
||||
srv.Close()
|
||||
|
||||
p := NewHTTPObservationProvider("k", endpoint)
|
||||
_, err := p.Collect(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for connection refused")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request failed") {
|
||||
t.Errorf("error = %q, want it to mention request failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPObservationProvider_IntegrationWithObservationContext(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
|
||||
Data: json.RawMessage(`{"temp":23.5}`),
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
key := happydns.ObservationKey("http_test_obs")
|
||||
p := NewHTTPObservationProvider(key, srv.URL)
|
||||
|
||||
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
|
||||
oc.SetProviderOverride(key, p)
|
||||
|
||||
var dest map[string]float64
|
||||
if err := oc.Get(context.Background(), key, &dest); err != nil {
|
||||
t.Fatalf("ObservationContext.Get() returned error: %v", err)
|
||||
}
|
||||
if dest["temp"] != 23.5 {
|
||||
t.Errorf("temp = %v, want 23.5", dest["temp"])
|
||||
}
|
||||
|
||||
// Second call should use the cached value, not hit the server again.
|
||||
var dest2 map[string]float64
|
||||
if err := oc.Get(context.Background(), key, &dest2); err != nil {
|
||||
t.Fatalf("second Get() returned error: %v", err)
|
||||
}
|
||||
if dest2["temp"] != 23.5 {
|
||||
t.Errorf("cached temp = %v, want 23.5", dest2["temp"])
|
||||
}
|
||||
}
|
||||
60
internal/checker/registry.go
Normal file
60
internal/checker/registry.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// The checker definition registry lives in the Apache-2.0 licensed
|
||||
// checker-sdk-go module, so external plugins can register themselves
|
||||
// without depending on AGPL code. These wrappers preserve the existing
|
||||
// happyDomain call sites.
|
||||
|
||||
// RegisterChecker registers a checker definition globally.
|
||||
func RegisterChecker(c *happydns.CheckerDefinition) {
|
||||
sdk.RegisterChecker(c)
|
||||
}
|
||||
|
||||
// RegisterExternalizableChecker registers a checker that supports being
|
||||
// delegated to a remote HTTP endpoint. It appends an "endpoint" AdminOpt
|
||||
// so the administrator can optionally configure a remote URL.
|
||||
// When the endpoint is left empty, the checker runs locally as usual.
|
||||
func RegisterExternalizableChecker(c *happydns.CheckerDefinition) {
|
||||
sdk.RegisterExternalizableChecker(c)
|
||||
}
|
||||
|
||||
// RegisterObservationProvider registers an observation provider globally.
|
||||
func RegisterObservationProvider(p happydns.ObservationProvider) {
|
||||
sdk.RegisterObservationProvider(p)
|
||||
}
|
||||
|
||||
// GetCheckers returns all registered checker definitions.
|
||||
func GetCheckers() map[string]*happydns.CheckerDefinition {
|
||||
return sdk.GetCheckers()
|
||||
}
|
||||
|
||||
// FindChecker returns the checker definition with the given ID, or nil.
|
||||
func FindChecker(id string) *happydns.CheckerDefinition {
|
||||
return sdk.FindChecker(id)
|
||||
}
|
||||
|
|
@ -24,6 +24,8 @@ package config // import "git.happydns.org/happyDomain/config"
|
|||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
|
|
@ -45,6 +47,12 @@ func declareFlags(o *happydns.Options) {
|
|||
flag.Var(&JWTSecretKey{&o.JWTSecretKey}, "jwt-secret-key", "Secret key used to verify JWT authentication tokens (a random secret is used if undefined)")
|
||||
flag.Var(&URL{&o.ExternalAuth}, "external-auth", "Base URL to use for login and registration (use embedded forms if left empty)")
|
||||
flag.BoolVar(&o.OptOutInsights, "opt-out-insights", false, "Disable the anonymous usage statistics report. If you care about this project and don't participate in discussions, don't opt-out.")
|
||||
flag.IntVar(&o.CheckerMaxConcurrency, "checker-max-concurrency", runtime.NumCPU(), "Maximum number of checker jobs that can run simultaneously")
|
||||
flag.IntVar(&o.CheckerRetentionDays, "checker-retention-days", 365, "System-wide default retention horizon for check execution history (overridable per user)")
|
||||
flag.DurationVar(&o.CheckerJanitorInterval, "checker-janitor-interval", 6*time.Hour, "How often the checker retention janitor runs")
|
||||
flag.IntVar(&o.CheckerInactivityPauseDays, "checker-inactivity-pause-days", 90, "Pause checks for users that haven't logged in for this many days (0 disables, overridable per user)")
|
||||
flag.IntVar(&o.CheckerMaxChecksPerDay, "checker-max-checks-per-day", 0, "System-wide default cap on scheduled checker executions per user per day; counter resets at 00:00 UTC and is in-memory only (0 = unlimited, overridable per user; see docs/checker-quotas.md)")
|
||||
flag.BoolVar(&o.CheckerCountManualTriggers, "checker-count-manual-triggers", true, "When true (default), manual checker triggers count against UserQuota.MaxChecksPerDay and are refused with HTTP 429 once exhausted; when false, manual triggers bypass the quota entirely (see docs/checker-quotas.md)")
|
||||
|
||||
flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server")
|
||||
flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user")
|
||||
|
|
@ -60,6 +68,8 @@ func declareFlags(o *happydns.Options) {
|
|||
flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (altcha, hcaptcha, recaptchav2, turnstile, or empty to disable)")
|
||||
flag.IntVar(&o.CaptchaLoginThreshold, "captcha-login-threshold", 3, "Number of failed login attempts before captcha is required (0 = always require when provider configured)")
|
||||
|
||||
flag.Var(&stringSlice{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing checker plugins (.so files); may be repeated")
|
||||
|
||||
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,8 +25,27 @@ import (
|
|||
"encoding/base64"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// stringSlice is a flag.Value that accumulates string values across repeated
|
||||
// invocations of the same flag (e.g. -plugins-directory a -plugins-directory b).
|
||||
type stringSlice struct {
|
||||
Values *[]string
|
||||
}
|
||||
|
||||
func (s *stringSlice) String() string {
|
||||
if s.Values == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(*s.Values, ",")
|
||||
}
|
||||
|
||||
func (s *stringSlice) Set(value string) error {
|
||||
*s.Values = append(*s.Values, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type JWTSecretKey struct {
|
||||
Secret *[]byte
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,13 @@ func ValidateStructValues(data any) error {
|
|||
}
|
||||
|
||||
v := reflect.Indirect(reflect.ValueOf(data))
|
||||
if !v.IsValid() {
|
||||
return nil
|
||||
}
|
||||
t := v.Type()
|
||||
if t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
sf := t.Field(i)
|
||||
|
|
@ -127,6 +133,87 @@ func ValidateStructValues(data any) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ValidateMapValues validates a map[string]any against a slice of Field definitions.
|
||||
// It checks required fields, choices constraints, basic type compatibility,
|
||||
// and rejects unknown keys not declared in any field definition.
|
||||
func ValidateMapValues(opts map[string]any, fields []happydns.Field) error {
|
||||
known := make(map[string]*happydns.Field, len(fields))
|
||||
for i := range fields {
|
||||
known[fields[i].Id] = &fields[i]
|
||||
}
|
||||
|
||||
// Reject unknown keys.
|
||||
for k := range opts {
|
||||
if _, ok := known[k]; !ok {
|
||||
return fmt.Errorf("unknown option %q", k)
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
v, exists := opts[f.Id]
|
||||
|
||||
label := f.Label
|
||||
if label == "" {
|
||||
label = f.Id
|
||||
}
|
||||
|
||||
// Required check.
|
||||
if f.Required {
|
||||
if !exists || v == nil {
|
||||
return fmt.Errorf("field %q is required", label)
|
||||
}
|
||||
if s, ok := v.(string); ok && s == "" {
|
||||
return fmt.Errorf("field %q is required", label)
|
||||
}
|
||||
}
|
||||
|
||||
if !exists || v == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Choices check.
|
||||
if len(f.Choices) > 0 {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %q: expected a string value for choices field", label)
|
||||
}
|
||||
if s != "" && !slices.Contains(f.Choices, s) {
|
||||
return fmt.Errorf("field %q: value %q is not a valid choice (valid: %v)", label, s, f.Choices)
|
||||
}
|
||||
}
|
||||
|
||||
// Basic type check.
|
||||
if f.Type != "" {
|
||||
if err := checkMapValueType(f.Type, v, label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkMapValueType performs a basic type compatibility check between a Field.Type
|
||||
// string and the actual value from a map[string]any (JSON-decoded).
|
||||
func checkMapValueType(fieldType string, value any, label string) error {
|
||||
switch {
|
||||
case strings.HasPrefix(fieldType, "string"):
|
||||
if _, ok := value.(string); !ok {
|
||||
return fmt.Errorf("field %q: expected string, got %T", label, value)
|
||||
}
|
||||
case strings.HasPrefix(fieldType, "int") || strings.HasPrefix(fieldType, "uint") || strings.HasPrefix(fieldType, "float"):
|
||||
// JSON numbers decode as float64.
|
||||
if _, ok := value.(float64); !ok {
|
||||
return fmt.Errorf("field %q: expected number, got %T", label, value)
|
||||
}
|
||||
case fieldType == "bool":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return fmt.Errorf("field %q: expected bool, got %T", label, value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenStructFields generates corresponding SourceFields of the given Source.
|
||||
func GenStructFields(data any) (fields []*happydns.Field) {
|
||||
if data != nil {
|
||||
|
|
|
|||
201
internal/forms/field_test.go
Normal file
201
internal/forms/field_test.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// 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/>.
|
||||
|
||||
package forms
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestValidateMapValues_Required(t *testing.T) {
|
||||
fields := []happydns.Field{
|
||||
{Id: "name", Type: "string", Required: true, Label: "Name"},
|
||||
}
|
||||
|
||||
// Missing required field.
|
||||
if err := ValidateMapValues(map[string]any{}, fields); err == nil {
|
||||
t.Fatal("expected error for missing required field")
|
||||
}
|
||||
|
||||
// Nil value.
|
||||
if err := ValidateMapValues(map[string]any{"name": nil}, fields); err == nil {
|
||||
t.Fatal("expected error for nil required field")
|
||||
}
|
||||
|
||||
// Empty string value.
|
||||
if err := ValidateMapValues(map[string]any{"name": ""}, fields); err == nil {
|
||||
t.Fatal("expected error for empty string required field")
|
||||
}
|
||||
|
||||
// Valid value.
|
||||
if err := ValidateMapValues(map[string]any{"name": "hello"}, fields); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMapValues_Choices(t *testing.T) {
|
||||
fields := []happydns.Field{
|
||||
{Id: "color", Type: "string", Choices: []string{"red", "green", "blue"}},
|
||||
}
|
||||
|
||||
if err := ValidateMapValues(map[string]any{"color": "red"}, fields); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if err := ValidateMapValues(map[string]any{"color": "yellow"}, fields); err == nil {
|
||||
t.Fatal("expected error for invalid choice")
|
||||
}
|
||||
|
||||
// Empty string is allowed (field not required).
|
||||
if err := ValidateMapValues(map[string]any{"color": ""}, fields); err != nil {
|
||||
t.Fatalf("unexpected error for empty choice: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMapValues_TypeCheck(t *testing.T) {
|
||||
fields := []happydns.Field{
|
||||
{Id: "count", Type: "int"},
|
||||
{Id: "label", Type: "string"},
|
||||
{Id: "enabled", Type: "bool"},
|
||||
}
|
||||
|
||||
// Valid types.
|
||||
if err := ValidateMapValues(map[string]any{"count": float64(5), "label": "test", "enabled": true}, fields); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Wrong type for int field.
|
||||
if err := ValidateMapValues(map[string]any{"count": "notanumber"}, fields); err == nil {
|
||||
t.Fatal("expected error for wrong type on int field")
|
||||
}
|
||||
|
||||
// Wrong type for string field.
|
||||
if err := ValidateMapValues(map[string]any{"label": float64(42)}, fields); err == nil {
|
||||
t.Fatal("expected error for wrong type on string field")
|
||||
}
|
||||
|
||||
// Wrong type for bool field.
|
||||
if err := ValidateMapValues(map[string]any{"enabled": "yes"}, fields); err == nil {
|
||||
t.Fatal("expected error for wrong type on bool field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMapValues_UnknownKeys(t *testing.T) {
|
||||
fields := []happydns.Field{
|
||||
{Id: "name", Type: "string"},
|
||||
}
|
||||
|
||||
if err := ValidateMapValues(map[string]any{"name": "ok", "unknown": "bad"}, fields); err == nil {
|
||||
t.Fatal("expected error for unknown key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMapValues_EmptyFieldsAndOpts(t *testing.T) {
|
||||
// No fields defined, empty options: valid.
|
||||
if err := ValidateMapValues(map[string]any{}, nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// No fields defined, but has options: rejected as unknown.
|
||||
if err := ValidateMapValues(map[string]any{"x": 1}, nil); err == nil {
|
||||
t.Fatal("expected error for unknown key with no fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMapValues_ChoicesNonString(t *testing.T) {
|
||||
fields := []happydns.Field{
|
||||
{Id: "mode", Type: "string", Choices: []string{"a", "b"}},
|
||||
}
|
||||
|
||||
// Non-string value on a choices field.
|
||||
if err := ValidateMapValues(map[string]any{"mode": float64(1)}, fields); err == nil {
|
||||
t.Fatal("expected error for non-string choices value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMapValues_RequiredNonString(t *testing.T) {
|
||||
fields := []happydns.Field{
|
||||
{Id: "count", Type: "int", Required: true, Label: "Count"},
|
||||
}
|
||||
|
||||
// Missing required int field.
|
||||
if err := ValidateMapValues(map[string]any{}, fields); err == nil {
|
||||
t.Fatal("expected error for missing required int field")
|
||||
}
|
||||
|
||||
// Nil value for required int field.
|
||||
if err := ValidateMapValues(map[string]any{"count": nil}, fields); err == nil {
|
||||
t.Fatal("expected error for nil required int field")
|
||||
}
|
||||
|
||||
// Zero value passes (not treated as empty for non-string types).
|
||||
if err := ValidateMapValues(map[string]any{"count": float64(0)}, fields); err != nil {
|
||||
t.Fatalf("unexpected error for zero-value required int: %v", err)
|
||||
}
|
||||
|
||||
// Valid non-zero value.
|
||||
if err := ValidateMapValues(map[string]any{"count": float64(5)}, fields); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMapValues_ChoicesWithTypeCheck(t *testing.T) {
|
||||
fields := []happydns.Field{
|
||||
{Id: "color", Type: "string", Choices: []string{"red", "green", "blue"}},
|
||||
}
|
||||
|
||||
// Valid choice passes both choices and type check.
|
||||
if err := ValidateMapValues(map[string]any{"color": "red"}, fields); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Invalid choice fails at choices check (type is correct).
|
||||
if err := ValidateMapValues(map[string]any{"color": "yellow"}, fields); err == nil {
|
||||
t.Fatal("expected error for invalid choice with type+choices field")
|
||||
}
|
||||
|
||||
// Wrong type fails at choices check before reaching type check.
|
||||
if err := ValidateMapValues(map[string]any{"color": float64(42)}, fields); err == nil {
|
||||
t.Fatal("expected error for non-string value on choices+type field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStructValues_NilPointer(t *testing.T) {
|
||||
type S struct {
|
||||
Name string `happydomain:"required"`
|
||||
}
|
||||
// Typed nil pointer must not panic.
|
||||
if err := ValidateStructValues((*S)(nil)); err != nil {
|
||||
t.Fatalf("expected nil error for typed nil pointer, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStructValues_NonStruct(t *testing.T) {
|
||||
// Non-struct values must not panic.
|
||||
if err := ValidateStructValues("hello"); err != nil {
|
||||
t.Fatalf("expected nil error for non-struct, got %v", err)
|
||||
}
|
||||
if err := ValidateStructValues(42); err != nil {
|
||||
t.Fatalf("expected nil error for non-struct, got %v", err)
|
||||
}
|
||||
}
|
||||
173
internal/metrics/collector.go
Normal file
173
internal/metrics/collector.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// 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/>.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// StatsProvider is the minimal interface required by StorageStatsCollector to
|
||||
// count business entities. It is implemented by
|
||||
// internal/storage.StatsProvider, which delegates to the backend's native
|
||||
// Count* methods so each scrape runs O(prefix scan) rather than O(full decode).
|
||||
type StatsProvider interface {
|
||||
CountUsers() (int, error)
|
||||
CountDomains() (int, error)
|
||||
CountZones() (int, error)
|
||||
CountProviders() (int, error)
|
||||
}
|
||||
|
||||
// StorageStatsCollector is a Prometheus Collector that queries storage at each
|
||||
// scrape to report accurate business-entity counts.
|
||||
type StorageStatsCollector struct {
|
||||
provider StatsProvider
|
||||
|
||||
usersDesc *prometheus.Desc
|
||||
domainsDesc *prometheus.Desc
|
||||
zonesDesc *prometheus.Desc
|
||||
providersDesc *prometheus.Desc
|
||||
|
||||
// statsErrorsTotal counts failed Count* calls during a Prometheus scrape
|
||||
// so silent storage failures remain visible (and alertable) instead of
|
||||
// producing gaps in the gauge series.
|
||||
statsErrorsTotal *prometheus.CounterVec
|
||||
}
|
||||
|
||||
// NewStorageStatsCollector creates a new collector backed by the given
|
||||
// StatsProvider and registers it (and its companion error counter) with the
|
||||
// default Prometheus registry. Re-registration is tolerated, so calling this
|
||||
// twice — for instance from tests — does not panic.
|
||||
func NewStorageStatsCollector(p StatsProvider) *StorageStatsCollector {
|
||||
c := &StorageStatsCollector{
|
||||
provider: p,
|
||||
statsErrorsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "happydomain_storage_stats_errors_total",
|
||||
Help: "Total number of errors encountered while collecting storage stats for the /metrics endpoint.",
|
||||
}, []string{"entity"}),
|
||||
usersDesc: prometheus.NewDesc(
|
||||
"happydomain_registered_users",
|
||||
"Current number of registered user accounts.",
|
||||
nil, nil,
|
||||
),
|
||||
domainsDesc: prometheus.NewDesc(
|
||||
"happydomain_domains",
|
||||
"Current number of domains managed across all users.",
|
||||
nil, nil,
|
||||
),
|
||||
zonesDesc: prometheus.NewDesc(
|
||||
"happydomain_zones",
|
||||
"Current number of zone snapshots stored.",
|
||||
nil, nil,
|
||||
),
|
||||
providersDesc: prometheus.NewDesc(
|
||||
"happydomain_providers",
|
||||
"Current number of provider configurations across all users.",
|
||||
nil, nil,
|
||||
),
|
||||
}
|
||||
|
||||
registerOrLog(c)
|
||||
registerOrLog(c.statsErrorsTotal)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// registerOrLog registers a collector with the default registry, tolerating
|
||||
// "already registered" so test setups and repeated app initialisations are safe.
|
||||
func registerOrLog(c prometheus.Collector) {
|
||||
if err := prometheus.Register(c); err != nil {
|
||||
var are prometheus.AlreadyRegisteredError
|
||||
if errors.As(err, &are) {
|
||||
return
|
||||
}
|
||||
log.Printf("metrics: failed to register collector: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Describe implements prometheus.Collector.
|
||||
func (c *StorageStatsCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||
ch <- c.usersDesc
|
||||
ch <- c.domainsDesc
|
||||
ch <- c.zonesDesc
|
||||
ch <- c.providersDesc
|
||||
}
|
||||
|
||||
// Collect implements prometheus.Collector. It queries storage live so the
|
||||
// values always reflect the actual database state. Each backend call runs in
|
||||
// its own goroutine to keep the scrape latency bounded by the slowest count
|
||||
// rather than their sum.
|
||||
func (c *StorageStatsCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
type job struct {
|
||||
entity string
|
||||
desc *prometheus.Desc
|
||||
fn func() (int, error)
|
||||
}
|
||||
jobs := []job{
|
||||
{"user", c.usersDesc, c.provider.CountUsers},
|
||||
{"domain", c.domainsDesc, c.provider.CountDomains},
|
||||
{"zone", c.zonesDesc, c.provider.CountZones},
|
||||
{"provider", c.providersDesc, c.provider.CountProviders},
|
||||
}
|
||||
|
||||
type result struct {
|
||||
desc *prometheus.Desc
|
||||
val float64
|
||||
ok bool
|
||||
}
|
||||
results := make([]result, len(jobs))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, j := range jobs {
|
||||
wg.Add(1)
|
||||
go func(i int, j job) {
|
||||
defer wg.Done()
|
||||
// A panic inside a backend Count* implementation must not
|
||||
// crash the scrape goroutine: convert it into a stats error
|
||||
// so the failure is visible via happydomain_storage_stats_errors_total
|
||||
// instead of producing an unrecoverable process crash.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
c.statsErrorsTotal.WithLabelValues(j.entity).Inc()
|
||||
log.Printf("metrics: panic while collecting %s count: %v", j.entity, r)
|
||||
}
|
||||
}()
|
||||
n, err := j.fn()
|
||||
if err != nil {
|
||||
c.statsErrorsTotal.WithLabelValues(j.entity).Inc()
|
||||
return
|
||||
}
|
||||
results[i] = result{desc: j.desc, val: float64(n), ok: true}
|
||||
}(i, j)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, r := range results {
|
||||
if !r.ok {
|
||||
continue
|
||||
}
|
||||
ch <- prometheus.MustNewConstMetric(r.desc, prometheus.GaugeValue, r.val)
|
||||
}
|
||||
}
|
||||
54
internal/metrics/http.go
Normal file
54
internal/metrics/http.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// 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/>.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HTTPMiddleware returns a Gin middleware that records HTTP request metrics.
|
||||
// It uses c.FullPath() to get the route pattern (e.g. /api/domains/:domain)
|
||||
// rather than the actual URL, avoiding high-cardinality labels.
|
||||
func HTTPMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
HTTPRequestsInFlight.Inc()
|
||||
|
||||
c.Next()
|
||||
|
||||
HTTPRequestsInFlight.Dec()
|
||||
|
||||
path := c.FullPath()
|
||||
if path == "" {
|
||||
path = "unknown"
|
||||
}
|
||||
method := c.Request.Method
|
||||
status := strconv.Itoa(c.Writer.Status())
|
||||
duration := time.Since(start).Seconds()
|
||||
|
||||
HTTPRequestsTotal.WithLabelValues(method, path, status).Inc()
|
||||
HTTPRequestDuration.WithLabelValues(method, path).Observe(duration)
|
||||
}
|
||||
}
|
||||
137
internal/metrics/metrics.go
Normal file
137
internal/metrics/metrics.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// 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/>.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
// HTTP metrics
|
||||
HTTPRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "happydomain_http_requests_total",
|
||||
Help: "Total number of HTTP requests.",
|
||||
}, []string{"method", "path", "status"})
|
||||
|
||||
HTTPRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "happydomain_http_request_duration_seconds",
|
||||
Help: "Duration of HTTP requests in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"method", "path"})
|
||||
|
||||
HTTPRequestsInFlight = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "happydomain_http_requests_in_flight",
|
||||
Help: "Current number of HTTP requests being served.",
|
||||
})
|
||||
|
||||
// Scheduler metrics
|
||||
//
|
||||
// schedulerQueueDepthFn is consulted at scrape time by the GaugeFunc
|
||||
// registered below. The scheduler installs its accessor via
|
||||
// RegisterSchedulerQueueDepth at construction, which avoids sprinkling
|
||||
// gauge.Set calls across every queue mutation site.
|
||||
schedulerQueueDepthFn atomic.Pointer[func() float64]
|
||||
|
||||
// SchedulerQueueDepth is kept as a package-level var (rather than the
|
||||
// blank identifier) so it is discoverable via grep alongside the other
|
||||
// metric vars and easy to reference from tests.
|
||||
SchedulerQueueDepth = promauto.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "happydomain_scheduler_queue_depth",
|
||||
Help: "Number of items currently in the check scheduler queue.",
|
||||
}, func() float64 {
|
||||
if fn := schedulerQueueDepthFn.Load(); fn != nil {
|
||||
return (*fn)()
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
SchedulerActiveWorkers = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "happydomain_scheduler_active_workers",
|
||||
Help: "Number of check scheduler workers currently executing a check.",
|
||||
})
|
||||
|
||||
SchedulerChecksTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "happydomain_scheduler_checks_total",
|
||||
Help: "Total number of checks executed by the scheduler.",
|
||||
}, []string{"checker", "status"})
|
||||
|
||||
SchedulerCheckDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "happydomain_scheduler_check_duration_seconds",
|
||||
Help: "Duration of individual check executions in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"checker"})
|
||||
|
||||
// DNS provider API metrics
|
||||
ProviderAPICallsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "happydomain_provider_api_calls_total",
|
||||
Help: "Total number of DNS provider API calls.",
|
||||
}, []string{"provider", "operation", "status"})
|
||||
|
||||
ProviderAPIDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "happydomain_provider_api_duration_seconds",
|
||||
Help: "Duration of DNS provider API calls in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"provider", "operation"})
|
||||
|
||||
// Storage metrics
|
||||
StorageOperationsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "happydomain_storage_operations_total",
|
||||
Help: "Total number of storage operations.",
|
||||
}, []string{"operation", "entity", "status"})
|
||||
|
||||
StorageOperationDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "happydomain_storage_operation_duration_seconds",
|
||||
Help: "Duration of storage operations in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"operation", "entity"})
|
||||
|
||||
// Build info. Always 1; the metadata is carried in the labels so that
|
||||
// dashboards and alerts can group/diff across deployments.
|
||||
BuildInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "happydomain_build_info",
|
||||
Help: "Build information about the running happyDomain instance. Always 1; metadata is in the labels.",
|
||||
}, []string{"version", "revision", "dirty", "build_date"})
|
||||
)
|
||||
|
||||
// SetBuildInfo records the application build metadata in the build info
|
||||
// metric. Call this once during application startup. buildDate should be
|
||||
// formatted as RFC3339 (UTC) and may be empty if unknown.
|
||||
func SetBuildInfo(version, revision, buildDate string, dirty bool) {
|
||||
BuildInfo.WithLabelValues(version, revision, strconv.FormatBool(dirty), buildDate).Set(1)
|
||||
}
|
||||
|
||||
// RegisterSchedulerQueueDepth installs the accessor used at scrape time to
|
||||
// report the current scheduler queue depth. The function is invoked from the
|
||||
// Prometheus scrape goroutine, so it must be safe to call concurrently with
|
||||
// queue mutations and must not block for long. Passing nil unregisters the
|
||||
// accessor (the gauge will then report 0).
|
||||
func RegisterSchedulerQueueDepth(fn func() float64) {
|
||||
if fn == nil {
|
||||
schedulerQueueDepthFn.Store(nil)
|
||||
return
|
||||
}
|
||||
schedulerQueueDepthFn.Store(&fn)
|
||||
}
|
||||
272
internal/metrics/metrics_test.go
Normal file
272
internal/metrics/metrics_test.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// 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/>.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/prometheus/common/expfmt"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// --- HTTPMiddleware -------------------------------------------------------
|
||||
|
||||
func TestHTTPMiddleware_RecordsRouteTemplateNotRawPath(t *testing.T) {
|
||||
// Reset to keep assertions independent from any other test in the package.
|
||||
HTTPRequestsTotal.Reset()
|
||||
HTTPRequestDuration.Reset()
|
||||
|
||||
r := gin.New()
|
||||
r.Use(HTTPMiddleware())
|
||||
r.GET("/api/domains/:domain", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/domains/example.com", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// Route template — not the raw URL — must be used as the path label,
|
||||
// otherwise cardinality explodes with one series per domain name.
|
||||
if got := testutil.ToFloat64(HTTPRequestsTotal.WithLabelValues("GET", "/api/domains/:domain", "200")); got != 1 {
|
||||
t.Fatalf("expected 1 request recorded for route template, got %v", got)
|
||||
}
|
||||
if got := testutil.CollectAndCount(HTTPRequestsTotal); got != 1 {
|
||||
t.Fatalf("expected exactly one series, got %d (cardinality leak?)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMiddleware_UnmatchedRouteUsesUnknownLabel(t *testing.T) {
|
||||
HTTPRequestsTotal.Reset()
|
||||
HTTPRequestDuration.Reset()
|
||||
|
||||
r := gin.New()
|
||||
r.Use(HTTPMiddleware())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/no/such/route", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if got := testutil.ToFloat64(HTTPRequestsTotal.WithLabelValues("GET", "unknown", "404")); got != 1 {
|
||||
t.Fatalf("expected 1 request recorded under 'unknown' path, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMiddleware_InFlightBalanced(t *testing.T) {
|
||||
HTTPRequestsInFlight.Set(0)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(HTTPMiddleware())
|
||||
r.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
for range 5 {
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(HTTPRequestsInFlight); got != 0 {
|
||||
t.Fatalf("in-flight gauge should return to 0 after requests complete, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- StorageStatsCollector ------------------------------------------------
|
||||
|
||||
type fakeStatsProvider struct {
|
||||
users, domains, zones, providers int
|
||||
usersErr, domainsErr error
|
||||
zonesPanic bool
|
||||
}
|
||||
|
||||
func (f *fakeStatsProvider) CountUsers() (int, error) { return f.users, f.usersErr }
|
||||
func (f *fakeStatsProvider) CountDomains() (int, error) { return f.domains, f.domainsErr }
|
||||
func (f *fakeStatsProvider) CountZones() (int, error) {
|
||||
if f.zonesPanic {
|
||||
panic("boom")
|
||||
}
|
||||
return f.zones, nil
|
||||
}
|
||||
func (f *fakeStatsProvider) CountProviders() (int, error) { return f.providers, nil }
|
||||
|
||||
// collectorFor builds a StorageStatsCollector against a private registry so
|
||||
// that tests can run in parallel without sharing state with the default
|
||||
// registry or with each other.
|
||||
func collectorFor(p StatsProvider) *StorageStatsCollector {
|
||||
return &StorageStatsCollector{
|
||||
provider: p,
|
||||
statsErrorsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "happydomain_storage_stats_errors_total",
|
||||
Help: "test",
|
||||
}, []string{"entity"}),
|
||||
usersDesc: prometheus.NewDesc(
|
||||
"happydomain_registered_users", "users", nil, nil),
|
||||
domainsDesc: prometheus.NewDesc(
|
||||
"happydomain_domains", "domains", nil, nil),
|
||||
zonesDesc: prometheus.NewDesc(
|
||||
"happydomain_zones", "zones", nil, nil),
|
||||
providersDesc: prometheus.NewDesc(
|
||||
"happydomain_providers", "providers", nil, nil),
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageStatsCollector_HappyPath(t *testing.T) {
|
||||
c := collectorFor(&fakeStatsProvider{users: 3, domains: 7, zones: 11, providers: 2})
|
||||
|
||||
if got := testutil.CollectAndCount(c); got != 4 {
|
||||
t.Fatalf("expected 4 metrics, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageStatsCollector_ErrorSkipsMetricAndIncrementsErrorCounter(t *testing.T) {
|
||||
c := collectorFor(&fakeStatsProvider{
|
||||
users: 3,
|
||||
domainsErr: errors.New("db down"),
|
||||
zones: 1, providers: 1,
|
||||
})
|
||||
|
||||
// 4 jobs, 1 errors out → 3 metrics emitted.
|
||||
if got := testutil.CollectAndCount(c); got != 3 {
|
||||
t.Fatalf("expected 3 metrics when one count fails, got %d", got)
|
||||
}
|
||||
if got := testutil.ToFloat64(c.statsErrorsTotal.WithLabelValues("domain")); got != 1 {
|
||||
t.Fatalf("expected stats error counter for 'domain' to be 1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageStatsCollector_PanicIsRecovered(t *testing.T) {
|
||||
c := collectorFor(&fakeStatsProvider{users: 1, domains: 1, providers: 1, zonesPanic: true})
|
||||
|
||||
// Must not crash the test process; panicking job is dropped, others succeed.
|
||||
got := testutil.CollectAndCount(c)
|
||||
if got != 3 {
|
||||
t.Fatalf("expected 3 metrics when zones panics, got %d", got)
|
||||
}
|
||||
if v := testutil.ToFloat64(c.statsErrorsTotal.WithLabelValues("zone")); v != 1 {
|
||||
t.Fatalf("expected zone stats error counter to be 1, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scheduler queue depth gauge -----------------------------------------
|
||||
|
||||
func TestRegisterSchedulerQueueDepth(t *testing.T) {
|
||||
t.Cleanup(func() { RegisterSchedulerQueueDepth(nil) })
|
||||
|
||||
RegisterSchedulerQueueDepth(func() float64 { return 42 })
|
||||
|
||||
// The gauge func is registered against the default registry by promauto.
|
||||
// Gather and look for our specific metric.
|
||||
mfs, err := prometheus.DefaultGatherer.Gather()
|
||||
if err != nil {
|
||||
t.Fatalf("gather: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, mf := range mfs {
|
||||
if mf.GetName() != "happydomain_scheduler_queue_depth" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if v := mf.GetMetric()[0].GetGauge().GetValue(); v != 42 {
|
||||
t.Fatalf("expected queue depth 42, got %v", v)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("happydomain_scheduler_queue_depth not registered")
|
||||
}
|
||||
|
||||
// nil clears the accessor → gauge falls back to 0.
|
||||
RegisterSchedulerQueueDepth(nil)
|
||||
mfs, _ = prometheus.DefaultGatherer.Gather()
|
||||
for _, mf := range mfs {
|
||||
if mf.GetName() != "happydomain_scheduler_queue_depth" {
|
||||
continue
|
||||
}
|
||||
if v := mf.GetMetric()[0].GetGauge().GetValue(); v != 0 {
|
||||
t.Fatalf("expected queue depth 0 after clearing accessor, got %v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- SetBuildInfo --------------------------------------------------------
|
||||
|
||||
func TestSetBuildInfo(t *testing.T) {
|
||||
BuildInfo.Reset()
|
||||
SetBuildInfo("1.2.3-test", "abcdef0", "2026-04-08T00:00:00Z", true)
|
||||
|
||||
if got := testutil.ToFloat64(BuildInfo.WithLabelValues("1.2.3-test", "abcdef0", "true", "2026-04-08T00:00:00Z")); got != 1 {
|
||||
t.Fatalf("expected build_info{...}=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- /metrics endpoint exposition format ---------------------------------
|
||||
|
||||
// TestMetricsEndpointParses guards against the whole exposition pipeline
|
||||
// emitting something that an actual Prometheus scraper would reject.
|
||||
func TestMetricsEndpointParses(t *testing.T) {
|
||||
// Drive at least one observation through every metric family touched by
|
||||
// instrumentation so the endpoint isn't trivially empty.
|
||||
HTTPRequestsTotal.WithLabelValues("GET", "/x", "200").Inc()
|
||||
StorageOperationsTotal.WithLabelValues("get", "user", "success").Inc()
|
||||
SchedulerChecksTotal.WithLabelValues("dns", "success").Inc()
|
||||
ProviderAPICallsTotal.WithLabelValues("dummy", "list", "success").Inc()
|
||||
SetBuildInfo("test", "deadbee", "2026-04-08T00:00:00Z", false)
|
||||
|
||||
srv := httptest.NewServer(promhttp.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET /metrics: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
parser := expfmt.NewTextParser(model.LegacyValidation)
|
||||
mfs, err := parser.TextToMetricFamilies(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid prometheus exposition format: %v", err)
|
||||
}
|
||||
|
||||
// Sanity-check a few of the metrics we expect to find.
|
||||
for _, name := range []string{
|
||||
"happydomain_http_requests_total",
|
||||
"happydomain_storage_operations_total",
|
||||
"happydomain_scheduler_checks_total",
|
||||
"happydomain_provider_api_calls_total",
|
||||
"happydomain_build_info",
|
||||
} {
|
||||
if _, ok := mfs[name]; !ok {
|
||||
t.Errorf("expected metric %q in /metrics output", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -75,11 +75,11 @@ func (s *SessionStore) New(r *http.Request, name string) (*sessions.Session, err
|
|||
|
||||
if _, ok := r.Header["Authorization"]; ok && len(r.Header["Authorization"]) > 0 {
|
||||
if flds := strings.Fields(r.Header["Authorization"][0]); len(flds) == 2 && flds[0] == "Bearer" {
|
||||
if isValidSessionID(flds[1]) {
|
||||
if IsValidSessionID(flds[1]) {
|
||||
session.ID = flds[1]
|
||||
}
|
||||
} else if user, _, ok := r.BasicAuth(); ok {
|
||||
if isValidSessionID(user) {
|
||||
if IsValidSessionID(user) {
|
||||
session.ID = user
|
||||
}
|
||||
}
|
||||
|
|
@ -242,10 +242,10 @@ func (s *SessionStore) save(session *sessions.Session, ua string) error {
|
|||
return s.storage.UpdateSession(mysession)
|
||||
}
|
||||
|
||||
// isValidSessionID returns true if s looks like a session ID generated by
|
||||
// NewSessionID: base32 standard alphabet ([A-Z2-7]), exactly 103 characters.
|
||||
func isValidSessionID(s string) bool {
|
||||
if len(s) != 103 {
|
||||
// IsValidSessionID returns true if s looks like a session ID generated by
|
||||
// NewSessionID: base32 standard alphabet ([A-Z2-7]), exactly SessionIDLen characters.
|
||||
func IsValidSessionID(s string) bool {
|
||||
if len(s) != sessionUC.SessionIDLen {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
|
|
|
|||
70
internal/session/sessions_test.go
Normal file
70
internal/session/sessions_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// 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/>.
|
||||
|
||||
package session_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/session"
|
||||
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
|
||||
)
|
||||
|
||||
func Test_IsValidSessionID_RoundTrip(t *testing.T) {
|
||||
// A freshly-generated session ID must always be considered valid.
|
||||
for range 32 {
|
||||
id := sessionUC.NewSessionID()
|
||||
if !session.IsValidSessionID(id) {
|
||||
t.Fatalf("NewSessionID() produced %q which IsValidSessionID rejected", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Test_IsValidSessionID_Rejects(t *testing.T) {
|
||||
valid := sessionUC.NewSessionID()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"one char short", valid[:len(valid)-1]},
|
||||
{"one char long", valid + "A"},
|
||||
{"all lowercase", strings.ToLower(valid)},
|
||||
{"with base32 padding", strings.Repeat("A", sessionUC.SessionIDLen-1) + "="},
|
||||
{"digit 0 (not in base32 alphabet)", strings.Repeat("A", sessionUC.SessionIDLen-1) + "0"},
|
||||
{"digit 1 (not in base32 alphabet)", strings.Repeat("A", sessionUC.SessionIDLen-1) + "1"},
|
||||
{"digit 8 (not in base32 alphabet)", strings.Repeat("A", sessionUC.SessionIDLen-1) + "8"},
|
||||
{"digit 9 (not in base32 alphabet)", strings.Repeat("A", sessionUC.SessionIDLen-1) + "9"},
|
||||
{"embedded space", strings.Repeat("A", sessionUC.SessionIDLen-1) + " "},
|
||||
{"non-ASCII", strings.Repeat("A", sessionUC.SessionIDLen-1) + "é"},
|
||||
{"looks like a JWT", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.sig"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if session.IsValidSessionID(tc.in) {
|
||||
t.Errorf("IsValidSessionID(%q) = true, want false", tc.in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ package storage // import "git.happydns.org/happyDomain/internal/storage"
|
|||
|
||||
import (
|
||||
"git.happydns.org/happyDomain/internal/usecase/authuser"
|
||||
"git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/internal/usecase/domain"
|
||||
"git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||
"git.happydns.org/happyDomain/internal/usecase/insight"
|
||||
|
|
@ -40,6 +41,13 @@ type ProviderAndDomainStorage interface {
|
|||
|
||||
type Storage interface {
|
||||
authuser.AuthUserStorage
|
||||
checker.CheckPlanStorage
|
||||
checker.CheckerOptionsStorage
|
||||
checker.CheckEvaluationStorage
|
||||
checker.ExecutionStorage
|
||||
checker.ObservationCacheStorage
|
||||
checker.ObservationSnapshotStorage
|
||||
checker.SchedulerStateStorage
|
||||
domain.DomainStorage
|
||||
domainlog.DomainLogStorage
|
||||
insight.InsightStorage
|
||||
|
|
|
|||
227
internal/storage/kvtpl/check_evaluation.go
Normal file
227
internal/storage/kvtpl/check_evaluation.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func (s *KVStorage) ListEvaluationsByPlan(planID happydns.Identifier) ([]*happydns.CheckEvaluation, error) {
|
||||
return listByIndex(s, fmt.Sprintf("chckeval-plan|%s|", planID.String()), s.GetEvaluation)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListAllEvaluations() (happydns.Iterator[happydns.CheckEvaluation], error) {
|
||||
iter := s.db.Search("chckeval|")
|
||||
return NewKVIterator[happydns.CheckEvaluation](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetEvaluation(evalID happydns.Identifier) (*happydns.CheckEvaluation, error) {
|
||||
eval := &happydns.CheckEvaluation{}
|
||||
err := s.db.Get(fmt.Sprintf("chckeval|%s", evalID.String()), eval)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrCheckEvaluationNotFound
|
||||
}
|
||||
return eval, err
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetLatestEvaluation(planID happydns.Identifier) (*happydns.CheckEvaluation, error) {
|
||||
evals, err := s.ListEvaluationsByPlan(planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(evals) == 0 {
|
||||
return nil, happydns.ErrCheckEvaluationNotFound
|
||||
}
|
||||
|
||||
latest := evals[0]
|
||||
for _, e := range evals[1:] {
|
||||
if e.EvaluatedAt.After(latest.EvaluatedAt) {
|
||||
latest = e
|
||||
}
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.CheckEvaluation, error) {
|
||||
return listByIndexSorted(
|
||||
s,
|
||||
fmt.Sprintf("chckeval-chkr|%s|%s|", checkerID, target.String()),
|
||||
s.GetEvaluation,
|
||||
func(a, b *happydns.CheckEvaluation) bool { return a.EvaluatedAt.After(b.EvaluatedAt) },
|
||||
limit,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *KVStorage) CreateEvaluation(eval *happydns.CheckEvaluation) error {
|
||||
key, id, err := s.db.FindIdentifierKey("chckeval|")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eval.Id = id
|
||||
|
||||
// Store the primary record.
|
||||
if err := s.db.Put(key, eval); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store secondary index by plan if applicable.
|
||||
if eval.PlanID != nil {
|
||||
indexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String())
|
||||
if err := s.db.Put(indexKey, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Store secondary index by checker+target.
|
||||
checkerIndexKey := fmt.Sprintf("chckeval-chkr|%s|%s|%s", eval.CheckerID, eval.Target.String(), eval.Id.String())
|
||||
if err := s.db.Put(checkerIndexKey, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteEvaluation(evalID happydns.Identifier) error {
|
||||
// Load first to find plan ID for index cleanup.
|
||||
eval, err := s.GetEvaluation(evalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if eval.PlanID != nil {
|
||||
indexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String())
|
||||
if err := s.db.Delete(indexKey); err != nil {
|
||||
log.Printf("DeleteEvaluation: failed to delete plan index %s: %v\n", indexKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up checker+target index.
|
||||
checkerIndexKey := fmt.Sprintf("chckeval-chkr|%s|%s|%s", eval.CheckerID, eval.Target.String(), eval.Id.String())
|
||||
if err := s.db.Delete(checkerIndexKey); err != nil {
|
||||
log.Printf("DeleteEvaluation: failed to delete checker index %s: %v\n", checkerIndexKey, err)
|
||||
}
|
||||
|
||||
return s.db.Delete(fmt.Sprintf("chckeval|%s", evalID.String()))
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) error {
|
||||
prefix := fmt.Sprintf("chckeval-chkr|%s|%s|", checkerID, target.String())
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
for iter.Next() {
|
||||
evalId, err := lastKeySegment(iter.Key())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
eval, err := s.GetEvaluation(evalId)
|
||||
if err != nil {
|
||||
// Primary record already gone; just clean up this index entry
|
||||
// and attempt to clean up the plan index (best-effort scan).
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.deleteEvalPlanIndexByEvalID(evalId)
|
||||
continue
|
||||
}
|
||||
|
||||
// Delete plan index if applicable.
|
||||
if eval.PlanID != nil {
|
||||
planIndexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String())
|
||||
if err := s.db.Delete(planIndexKey); err != nil {
|
||||
log.Printf("DeleteEvaluationsByChecker: failed to delete plan index %s: %v\n", planIndexKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete primary record.
|
||||
if err := s.db.Delete(fmt.Sprintf("chckeval|%s", eval.Id.String())); err != nil {
|
||||
log.Printf("DeleteEvaluationsByChecker: failed to delete primary record %s: %v\n", eval.Id.String(), err)
|
||||
}
|
||||
|
||||
// Delete this checker index entry.
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteEvalPlanIndexByEvalID scans plan indexes to remove any entry for the
|
||||
// given evaluation ID. Used when the primary record is already gone and we
|
||||
// don't know which plan it belonged to.
|
||||
func (s *KVStorage) deleteEvalPlanIndexByEvalID(evalId happydns.Identifier) {
|
||||
suffix := "|" + evalId.String()
|
||||
iter := s.db.Search("chckeval-plan|")
|
||||
defer iter.Release()
|
||||
for iter.Next() {
|
||||
if strings.HasSuffix(iter.Key(), suffix) {
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
log.Printf("deleteEvalPlanIndexByEvalID: failed to delete %s: %v\n", iter.Key(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KVStorage) evalExists(id happydns.Identifier) bool {
|
||||
_, err := s.GetEvaluation(id)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) TidyEvaluationIndexes() error {
|
||||
// Tidy chckeval-plan|{planId}|{evalId} indexes.
|
||||
s.tidyTwoPartIndex("chckeval-plan|", "evaluation plan", func(id happydns.Identifier) bool {
|
||||
_, err := s.GetCheckPlan(id)
|
||||
return err == nil
|
||||
}, s.evalExists)
|
||||
|
||||
// Tidy chckeval-chkr|{checkerID}|{target}|{evalId} indexes.
|
||||
s.tidyLastSegmentIndex("chckeval-chkr|", "evaluation checker", s.evalExists)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) ClearEvaluations() error {
|
||||
// Delete secondary indexes (chckeval-plan|..., chckeval-chkr|...).
|
||||
if err := s.clearByPrefix("chckeval-"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete primary records (chckeval|...).
|
||||
iter, err := s.ListAllEvaluations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
for iter.Next() {
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
222
internal/storage/kvtpl/check_plan.go
Normal file
222
internal/storage/kvtpl/check_plan.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func planTargetIndexKey(target happydns.CheckTarget, planId string) string {
|
||||
return fmt.Sprintf("chckpln-tgt|%s|%s", target.String(), planId)
|
||||
}
|
||||
|
||||
func planCheckerIndexKey(checkerID string, planId string) string {
|
||||
return fmt.Sprintf("chckpln-chkr|%s|%s", checkerID, planId)
|
||||
}
|
||||
|
||||
func planUserIndexKey(userId string, planId string) string {
|
||||
return fmt.Sprintf("chckpln-user|%s|%s", userId, planId)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) {
|
||||
iter := s.db.Search("chckpln|")
|
||||
return NewKVIterator[happydns.CheckPlan](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) {
|
||||
return listByIndex(s, fmt.Sprintf("chckpln-tgt|%s|", target.String()), s.GetCheckPlan)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) {
|
||||
return listByIndex(s, fmt.Sprintf("chckpln-chkr|%s|", checkerID), s.GetCheckPlan)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) {
|
||||
return listByIndex(s, fmt.Sprintf("chckpln-user|%s|", userId.String()), s.GetCheckPlan)
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) {
|
||||
plan := &happydns.CheckPlan{}
|
||||
err := s.db.Get(fmt.Sprintf("chckpln|%s", planID.String()), plan)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrCheckPlanNotFound
|
||||
}
|
||||
return plan, err
|
||||
}
|
||||
|
||||
func (s *KVStorage) CreateCheckPlan(plan *happydns.CheckPlan) error {
|
||||
key, id, err := s.db.FindIdentifierKey("chckpln|")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plan.Id = id
|
||||
|
||||
if err := s.db.Put(key, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.putCheckPlanIndexes(plan)
|
||||
}
|
||||
|
||||
func (s *KVStorage) UpdateCheckPlan(plan *happydns.CheckPlan) error {
|
||||
old, err := s.GetCheckPlan(plan.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.Put(fmt.Sprintf("chckpln|%s", plan.Id.String()), plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up stale target index if target changed.
|
||||
oldTargetKey := planTargetIndexKey(old.Target, old.Id.String())
|
||||
newTargetKey := planTargetIndexKey(plan.Target, plan.Id.String())
|
||||
if oldTargetKey != newTargetKey {
|
||||
if err := s.db.Delete(oldTargetKey); err != nil {
|
||||
log.Printf("UpdateCheckPlan: failed to delete stale target index %s: %v\n", oldTargetKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale checker index if checker changed.
|
||||
oldCheckerKey := planCheckerIndexKey(old.CheckerID, old.Id.String())
|
||||
newCheckerKey := planCheckerIndexKey(plan.CheckerID, plan.Id.String())
|
||||
if oldCheckerKey != newCheckerKey {
|
||||
if err := s.db.Delete(oldCheckerKey); err != nil {
|
||||
log.Printf("UpdateCheckPlan: failed to delete stale checker index %s: %v\n", oldCheckerKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale user index if user changed.
|
||||
if old.Target.UserId != "" && old.Target.UserId != plan.Target.UserId {
|
||||
if err := s.db.Delete(planUserIndexKey(old.Target.UserId, old.Id.String())); err != nil {
|
||||
log.Printf("UpdateCheckPlan: failed to delete stale user index for user %s: %v\n", old.Target.UserId, err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.putCheckPlanIndexes(plan)
|
||||
}
|
||||
|
||||
func (s *KVStorage) putCheckPlanIndexes(plan *happydns.CheckPlan) error {
|
||||
if err := s.db.Put(planTargetIndexKey(plan.Target, plan.Id.String()), true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.Put(planCheckerIndexKey(plan.CheckerID, plan.Id.String()), true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if plan.Target.UserId != "" {
|
||||
if err := s.db.Put(planUserIndexKey(plan.Target.UserId, plan.Id.String()), true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteCheckPlan(planID happydns.Identifier) error {
|
||||
plan, err := s.GetCheckPlan(planID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.Delete(planTargetIndexKey(plan.Target, planID.String())); err != nil {
|
||||
log.Printf("DeleteCheckPlan: failed to delete target index: %v\n", err)
|
||||
}
|
||||
|
||||
if err := s.db.Delete(planCheckerIndexKey(plan.CheckerID, planID.String())); err != nil {
|
||||
log.Printf("DeleteCheckPlan: failed to delete checker index: %v\n", err)
|
||||
}
|
||||
|
||||
if plan.Target.UserId != "" {
|
||||
if err := s.db.Delete(planUserIndexKey(plan.Target.UserId, planID.String())); err != nil {
|
||||
log.Printf("DeleteCheckPlan: failed to delete user index for user %s: %v\n", plan.Target.UserId, err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.db.Delete(fmt.Sprintf("chckpln|%s", planID.String()))
|
||||
}
|
||||
|
||||
// deleteCheckPlanSecondaryIndexesByPlanID scans all plan index prefixes to
|
||||
// remove any entry for the given plan ID. Used when the primary record is
|
||||
// already gone and we don't know which target/checker/user it belonged to.
|
||||
func (s *KVStorage) deleteCheckPlanSecondaryIndexesByPlanID(planId happydns.Identifier) {
|
||||
suffix := "|" + planId.String()
|
||||
for _, prefix := range []string{"chckpln-tgt|", "chckpln-chkr|", "chckpln-user|"} {
|
||||
iter := s.db.Search(prefix)
|
||||
for iter.Next() {
|
||||
if strings.HasSuffix(iter.Key(), suffix) {
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
log.Printf("deleteCheckPlanSecondaryIndexesByPlanID: failed to delete %s: %v\n", iter.Key(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
iter.Release()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KVStorage) checkPlanExists(id happydns.Identifier) bool {
|
||||
_, err := s.GetCheckPlan(id)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) TidyCheckPlanIndexes() error {
|
||||
// Tidy chckpln-tgt|{target}|{planId} indexes.
|
||||
s.tidyLastSegmentIndex("chckpln-tgt|", "plan target", s.checkPlanExists)
|
||||
|
||||
// Tidy chckpln-chkr|{checkerID}|{planId} indexes.
|
||||
s.tidyLastSegmentIndex("chckpln-chkr|", "plan checker", s.checkPlanExists)
|
||||
|
||||
// Tidy chckpln-user|{userId}|{planId} indexes.
|
||||
s.tidyTwoPartIndex("chckpln-user|", "plan user", func(id happydns.Identifier) bool {
|
||||
_, err := s.GetUser(id)
|
||||
return err == nil
|
||||
}, s.checkPlanExists)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) ClearCheckPlans() error {
|
||||
// Delete secondary indexes.
|
||||
if err := s.clearByPrefix("chckpln-"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete primary records.
|
||||
iter, err := s.ListAllCheckPlans()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
175
internal/storage/kvtpl/checker_options.go
Normal file
175
internal/storage/kvtpl/checker_options.go
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// checkerOptionsKey builds the positional KV key for checker options.
|
||||
// Format: chckrcfg|{checkerName}|{userId}|{domainId}|{serviceId}
|
||||
func checkerOptionsKey(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) string {
|
||||
return fmt.Sprintf("chckrcfg|%s|%s|%s|%s", checkerName,
|
||||
happydns.FormatIdentifier(userId), happydns.FormatIdentifier(domainId), happydns.FormatIdentifier(serviceId))
|
||||
}
|
||||
|
||||
// parseCheckerOptionsKey extracts the positional components from a KV key.
|
||||
func parseCheckerOptionsKey(key string) (checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
|
||||
trimmed := strings.TrimPrefix(key, "chckrcfg|")
|
||||
parts := strings.SplitN(trimmed, "|", 4)
|
||||
if len(parts) < 4 {
|
||||
return trimmed, nil, nil, nil
|
||||
}
|
||||
|
||||
checkerName = parts[0]
|
||||
if parts[1] != "" {
|
||||
if id, err := happydns.NewIdentifierFromString(parts[1]); err == nil {
|
||||
userId = &id
|
||||
}
|
||||
}
|
||||
if parts[2] != "" {
|
||||
if id, err := happydns.NewIdentifierFromString(parts[2]); err == nil {
|
||||
domainId = &id
|
||||
}
|
||||
}
|
||||
if parts[3] != "" {
|
||||
if id, err := happydns.NewIdentifierFromString(parts[3]); err == nil {
|
||||
serviceId = &id
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptionsPositional], error) {
|
||||
iter := s.db.Search("chckrcfg|")
|
||||
return &checkerOptionsIterator{KVIterator: NewKVIterator[happydns.CheckerOptions](s.db, iter)}, nil
|
||||
}
|
||||
|
||||
// checkerOptionsIterator wraps KVIterator[CheckerOptions] and enriches each
|
||||
// item with positional fields parsed from the storage key.
|
||||
type checkerOptionsIterator struct {
|
||||
*KVIterator[happydns.CheckerOptions]
|
||||
}
|
||||
|
||||
func (it *checkerOptionsIterator) Item() *happydns.CheckerOptionsPositional {
|
||||
opts := it.KVIterator.Item()
|
||||
if opts == nil {
|
||||
return nil
|
||||
}
|
||||
cn, uid, did, sid := parseCheckerOptionsKey(it.Key())
|
||||
return &happydns.CheckerOptionsPositional{
|
||||
CheckName: cn,
|
||||
UserId: uid,
|
||||
DomainId: did,
|
||||
ServiceId: sid,
|
||||
Options: *opts,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListCheckerConfiguration(checkerName string) ([]*happydns.CheckerOptionsPositional, error) {
|
||||
prefix := fmt.Sprintf("chckrcfg|%s|", checkerName)
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var results []*happydns.CheckerOptionsPositional
|
||||
for iter.Next() {
|
||||
var opts happydns.CheckerOptions
|
||||
if err := s.db.DecodeData(iter.Value(), &opts); err != nil {
|
||||
log.Printf("ListCheckerConfiguration: error decoding checker config at key %q: %s", iter.Key(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
cn, uid, did, sid := parseCheckerOptionsKey(iter.Key())
|
||||
results = append(results, &happydns.CheckerOptionsPositional{
|
||||
CheckName: cn,
|
||||
UserId: uid,
|
||||
DomainId: did,
|
||||
ServiceId: sid,
|
||||
Options: opts,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error) {
|
||||
var results []*happydns.CheckerOptionsPositional
|
||||
|
||||
// Try each scope level from admin up to the requested specificity.
|
||||
scopes := []struct {
|
||||
uid, did, sid *happydns.Identifier
|
||||
}{
|
||||
{nil, nil, nil},
|
||||
{userId, nil, nil},
|
||||
{userId, domainId, nil},
|
||||
{userId, domainId, serviceId},
|
||||
}
|
||||
|
||||
for _, sc := range scopes {
|
||||
// Skip levels that require identifiers not provided.
|
||||
if (sc.uid != nil && userId == nil) || (sc.did != nil && domainId == nil) || (sc.sid != nil && serviceId == nil) {
|
||||
continue
|
||||
}
|
||||
|
||||
key := checkerOptionsKey(checkerName, sc.uid, sc.did, sc.sid)
|
||||
var opts happydns.CheckerOptions
|
||||
if err := s.db.Get(key, &opts); err == nil {
|
||||
results = append(results, &happydns.CheckerOptionsPositional{
|
||||
CheckName: checkerName,
|
||||
UserId: sc.uid,
|
||||
DomainId: sc.did,
|
||||
ServiceId: sc.sid,
|
||||
Options: opts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) UpdateCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier, opts happydns.CheckerOptions) error {
|
||||
key := checkerOptionsKey(checkerName, userId, domainId, serviceId)
|
||||
return s.db.Put(key, opts)
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) error {
|
||||
key := checkerOptionsKey(checkerName, userId, domainId, serviceId)
|
||||
return s.db.Delete(key)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ClearCheckerConfigurations() error {
|
||||
iter, err := s.ListAllCheckerConfigurations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -34,6 +34,10 @@ func (s *KVStorage) ListAllDomains() (happydns.Iterator[happydns.Domain], error)
|
|||
return NewKVIterator[happydns.Domain](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) CountDomains() (int, error) {
|
||||
return s.countByPrefix("domain-")
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListDomains(u *happydns.User) (domains []*happydns.Domain, err error) {
|
||||
iter := s.db.Search("domain-")
|
||||
defer iter.Release()
|
||||
|
|
|
|||
355
internal/storage/kvtpl/execution.go
Normal file
355
internal/storage/kvtpl/execution.go
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func executionUserIndexKey(userId string, execId string) string {
|
||||
return fmt.Sprintf("chckexec-user|%s|%s", userId, execId)
|
||||
}
|
||||
|
||||
func executionDomainIndexKey(domainId string, execId string) string {
|
||||
return fmt.Sprintf("chckexec-domain|%s|%s", domainId, execId)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error) {
|
||||
return listByIndex(s, fmt.Sprintf("chckexec-plan|%s|", planID.String()), s.GetExecution)
|
||||
}
|
||||
|
||||
// listRecentExecutions scans a prefix, decodes executions, sorts by most
|
||||
// recent first, applies an optional filter predicate, and then applies a limit.
|
||||
func (s *KVStorage) listRecentExecutions(prefix string, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
|
||||
return listByIndexSorted(
|
||||
s,
|
||||
prefix,
|
||||
s.GetExecution,
|
||||
func(a, b *happydns.Execution) bool { return a.StartedAt.After(b.StartedAt) },
|
||||
limit,
|
||||
filter,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
|
||||
return s.listRecentExecutions(fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String()), limit, filter)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListExecutionsByUser(userId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
|
||||
return s.listRecentExecutions(fmt.Sprintf("chckexec-user|%s|", userId.String()), limit, filter)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
|
||||
return s.listRecentExecutions(fmt.Sprintf("chckexec-domain|%s|", domainId.String()), limit, filter)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListAllExecutions() (happydns.Iterator[happydns.Execution], error) {
|
||||
iter := s.db.Search("chckexec|")
|
||||
return NewKVIterator[happydns.Execution](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetExecution(execID happydns.Identifier) (*happydns.Execution, error) {
|
||||
exec := &happydns.Execution{}
|
||||
err := s.db.Get(fmt.Sprintf("chckexec|%s", execID.String()), exec)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrExecutionNotFound
|
||||
}
|
||||
return exec, err
|
||||
}
|
||||
|
||||
func (s *KVStorage) CreateExecution(exec *happydns.Execution) error {
|
||||
key, id, err := s.db.FindIdentifierKey("chckexec|")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exec.Id = id
|
||||
|
||||
if err := s.db.Put(key, exec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Secondary index by plan.
|
||||
if exec.PlanID != nil {
|
||||
indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
|
||||
if err := s.db.Put(indexKey, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary index by checker+target.
|
||||
checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), exec.Id.String())
|
||||
if err := s.db.Put(checkerIndexKey, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Secondary index by user.
|
||||
if exec.Target.UserId != "" {
|
||||
if err := s.db.Put(executionUserIndexKey(exec.Target.UserId, exec.Id.String()), true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary index by domain.
|
||||
if exec.Target.DomainId != "" {
|
||||
if err := s.db.Put(executionDomainIndexKey(exec.Target.DomainId, exec.Id.String()), true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) UpdateExecution(exec *happydns.Execution) error {
|
||||
// Load the old record so we can detect changed index keys.
|
||||
old, err := s.GetExecution(exec.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.Put(fmt.Sprintf("chckexec|%s", exec.Id.String()), exec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up stale plan index if PlanID changed.
|
||||
if old.PlanID != nil {
|
||||
oldPlanKey := fmt.Sprintf("chckexec-plan|%s|%s", old.PlanID.String(), exec.Id.String())
|
||||
newPlanKey := ""
|
||||
if exec.PlanID != nil {
|
||||
newPlanKey = fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
|
||||
}
|
||||
if oldPlanKey != newPlanKey {
|
||||
if err := s.db.Delete(oldPlanKey); err != nil {
|
||||
log.Printf("UpdateExecution: failed to delete stale plan index %s: %v\n", oldPlanKey, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update secondary index by plan if applicable.
|
||||
if exec.PlanID != nil {
|
||||
indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
|
||||
if err := s.db.Put(indexKey, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale checker+target index if CheckerID or Target changed.
|
||||
oldCheckerKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", old.CheckerID, old.Target.String(), exec.Id.String())
|
||||
newCheckerKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), exec.Id.String())
|
||||
if oldCheckerKey != newCheckerKey {
|
||||
if err := s.db.Delete(oldCheckerKey); err != nil {
|
||||
log.Printf("UpdateExecution: failed to delete stale checker index %s: %v\n", oldCheckerKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update secondary index by checker+target.
|
||||
if err := s.db.Put(newCheckerKey, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up stale user index if UserId changed.
|
||||
if old.Target.UserId != "" && old.Target.UserId != exec.Target.UserId {
|
||||
if err := s.db.Delete(executionUserIndexKey(old.Target.UserId, exec.Id.String())); err != nil {
|
||||
log.Printf("UpdateExecution: failed to delete stale user index for user %s: %v\n", old.Target.UserId, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update secondary index by user.
|
||||
if exec.Target.UserId != "" {
|
||||
if err := s.db.Put(executionUserIndexKey(exec.Target.UserId, exec.Id.String()), true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale domain index if DomainId changed.
|
||||
if old.Target.DomainId != "" && old.Target.DomainId != exec.Target.DomainId {
|
||||
if err := s.db.Delete(executionDomainIndexKey(old.Target.DomainId, exec.Id.String())); err != nil {
|
||||
log.Printf("UpdateExecution: failed to delete stale domain index for domain %s: %v\n", old.Target.DomainId, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update secondary index by domain.
|
||||
if exec.Target.DomainId != "" {
|
||||
if err := s.db.Put(executionDomainIndexKey(exec.Target.DomainId, exec.Id.String()), true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteExecution(execID happydns.Identifier) error {
|
||||
exec, err := s.GetExecution(execID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exec.PlanID != nil {
|
||||
indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), execID.String())
|
||||
if err := s.db.Delete(indexKey); err != nil {
|
||||
log.Printf("DeleteExecution: failed to delete plan index %s: %v\n", indexKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), execID.String())
|
||||
if err := s.db.Delete(checkerIndexKey); err != nil {
|
||||
log.Printf("DeleteExecution: failed to delete checker index %s: %v\n", checkerIndexKey, err)
|
||||
}
|
||||
|
||||
if exec.Target.UserId != "" {
|
||||
if err := s.db.Delete(executionUserIndexKey(exec.Target.UserId, execID.String())); err != nil {
|
||||
log.Printf("DeleteExecution: failed to delete user index for user %s: %v\n", exec.Target.UserId, err)
|
||||
}
|
||||
}
|
||||
|
||||
if exec.Target.DomainId != "" {
|
||||
if err := s.db.Delete(executionDomainIndexKey(exec.Target.DomainId, execID.String())); err != nil {
|
||||
log.Printf("DeleteExecution: failed to delete domain index for domain %s: %v\n", exec.Target.DomainId, err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.db.Delete(fmt.Sprintf("chckexec|%s", execID.String()))
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error {
|
||||
prefix := fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String())
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
for iter.Next() {
|
||||
execId, err := lastKeySegment(iter.Key())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exec, err := s.GetExecution(execId)
|
||||
if err != nil {
|
||||
// Primary record already gone; just clean up this index entry
|
||||
// and attempt to clean up other indexes (best-effort scan).
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.deleteExecSecondaryIndexesByExecID(execId)
|
||||
continue
|
||||
}
|
||||
|
||||
if exec.PlanID != nil {
|
||||
planIndexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
|
||||
if err := s.db.Delete(planIndexKey); err != nil {
|
||||
log.Printf("DeleteExecutionsByChecker: failed to delete plan index %s: %v\n", planIndexKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
if exec.Target.UserId != "" {
|
||||
if err := s.db.Delete(executionUserIndexKey(exec.Target.UserId, exec.Id.String())); err != nil {
|
||||
log.Printf("DeleteExecutionsByChecker: failed to delete user index for user %s: %v\n", exec.Target.UserId, err)
|
||||
}
|
||||
}
|
||||
|
||||
if exec.Target.DomainId != "" {
|
||||
if err := s.db.Delete(executionDomainIndexKey(exec.Target.DomainId, exec.Id.String())); err != nil {
|
||||
log.Printf("DeleteExecutionsByChecker: failed to delete domain index for domain %s: %v\n", exec.Target.DomainId, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.Delete(fmt.Sprintf("chckexec|%s", exec.Id.String())); err != nil {
|
||||
log.Printf("DeleteExecutionsByChecker: failed to delete primary record %s: %v\n", exec.Id.String(), err)
|
||||
}
|
||||
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteExecSecondaryIndexesByExecID scans plan, user and domain indexes to
|
||||
// remove any entry for the given execution ID. Used when the primary record is
|
||||
// already gone and we don't know which plan/user/domain it belonged to.
|
||||
func (s *KVStorage) deleteExecSecondaryIndexesByExecID(execId happydns.Identifier) {
|
||||
suffix := "|" + execId.String()
|
||||
for _, prefix := range []string{"chckexec-plan|", "chckexec-user|", "chckexec-domain|"} {
|
||||
iter := s.db.Search(prefix)
|
||||
for iter.Next() {
|
||||
if strings.HasSuffix(iter.Key(), suffix) {
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
log.Printf("deleteExecSecondaryIndexesByExecID: failed to delete %s: %v\n", iter.Key(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
iter.Release()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KVStorage) execExists(id happydns.Identifier) bool {
|
||||
_, err := s.GetExecution(id)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) TidyExecutionIndexes() error {
|
||||
// Tidy chckexec-plan|{planId}|{execId} indexes.
|
||||
s.tidyTwoPartIndex("chckexec-plan|", "execution plan", func(id happydns.Identifier) bool {
|
||||
_, err := s.GetCheckPlan(id)
|
||||
return err == nil
|
||||
}, s.execExists)
|
||||
|
||||
// Tidy chckexec-chkr|{checkerID}|{target}|{execId} indexes.
|
||||
s.tidyLastSegmentIndex("chckexec-chkr|", "execution checker", s.execExists)
|
||||
|
||||
// Tidy chckexec-user|{userId}|{execId} indexes.
|
||||
s.tidyTwoPartIndex("chckexec-user|", "execution user", func(id happydns.Identifier) bool {
|
||||
_, err := s.GetUser(id)
|
||||
return err == nil
|
||||
}, s.execExists)
|
||||
|
||||
// Tidy chckexec-domain|{domainId}|{execId} indexes.
|
||||
s.tidyTwoPartIndex("chckexec-domain|", "execution domain", func(id happydns.Identifier) bool {
|
||||
_, err := s.GetDomain(id)
|
||||
return err == nil
|
||||
}, s.execExists)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) ClearExecutions() error {
|
||||
// Delete secondary indexes (chckexec-plan|..., chckexec-chkr|..., chckexec-user|..., chckexec-domain|...).
|
||||
if err := s.clearByPrefix("chckexec-"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete primary records (chckexec|...).
|
||||
iter, err := s.ListAllExecutions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
for iter.Next() {
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
50
internal/storage/kvtpl/observation_cache.go
Normal file
50
internal/storage/kvtpl/observation_cache.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// 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/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func obsCacheKey(target happydns.CheckTarget, key happydns.ObservationKey) string {
|
||||
return fmt.Sprintf("obscache|%s-%s", target.String(), key)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListAllCachedObservations() (happydns.Iterator[happydns.ObservationCacheEntry], error) {
|
||||
iter := s.db.Search("obscache|")
|
||||
return NewKVIterator[happydns.ObservationCacheEntry](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error) {
|
||||
entry := &happydns.ObservationCacheEntry{}
|
||||
err := s.db.Get(obsCacheKey(target, key), entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error {
|
||||
return s.db.Put(obsCacheKey(target, key), entry)
|
||||
}
|
||||
71
internal/storage/kvtpl/observation_snapshot.go
Normal file
71
internal/storage/kvtpl/observation_snapshot.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func (s *KVStorage) ListAllSnapshots() (happydns.Iterator[happydns.ObservationSnapshot], error) {
|
||||
iter := s.db.Search("chcksnap|")
|
||||
return NewKVIterator[happydns.ObservationSnapshot](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetSnapshot(snapID happydns.Identifier) (*happydns.ObservationSnapshot, error) {
|
||||
snap := &happydns.ObservationSnapshot{}
|
||||
err := s.db.Get(fmt.Sprintf("chcksnap|%s", snapID.String()), snap)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrSnapshotNotFound
|
||||
}
|
||||
return snap, err
|
||||
}
|
||||
|
||||
func (s *KVStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) error {
|
||||
key, id, err := s.db.FindIdentifierKey("chcksnap|")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snap.Id = id
|
||||
return s.db.Put(key, snap)
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteSnapshot(snapID happydns.Identifier) error {
|
||||
return s.db.Delete(fmt.Sprintf("chcksnap|%s", snapID.String()))
|
||||
}
|
||||
|
||||
func (s *KVStorage) ClearSnapshots() error {
|
||||
iter, err := s.ListAllSnapshots()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -34,6 +34,10 @@ func (s *KVStorage) ListAllProviders() (happydns.Iterator[happydns.ProviderMessa
|
|||
return NewKVIterator[happydns.ProviderMessage](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) CountProviders() (int, error) {
|
||||
return s.countByPrefix("provider-")
|
||||
}
|
||||
|
||||
func (s *KVStorage) getProviderMeta(id happydns.Identifier) (*happydns.ProviderMessage, error) {
|
||||
srcMsg := &happydns.ProviderMessage{}
|
||||
err := s.db.Get(id.String(), srcMsg)
|
||||
|
|
|
|||
44
internal/storage/kvtpl/scheduler_state.go
Normal file
44
internal/storage/kvtpl/scheduler_state.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const schedulerLastRunKey = "scheduler-lastrun"
|
||||
|
||||
func (s *KVStorage) GetLastSchedulerRun() (time.Time, error) {
|
||||
var t time.Time
|
||||
err := s.db.Get(schedulerLastRunKey, &t)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (s *KVStorage) SetLastSchedulerRun(t time.Time) error {
|
||||
return s.db.Put(schedulerLastRunKey, t)
|
||||
}
|
||||
|
|
@ -22,7 +22,13 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type KVStorage struct {
|
||||
|
|
@ -38,3 +44,159 @@ func NewKVDatabase(impl storage.KVStorage) (storage.Storage, error) {
|
|||
func (s *KVStorage) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// lastKeySegment extracts the identifier after the last "|" in a KV key.
|
||||
func lastKeySegment(key string) (happydns.Identifier, error) {
|
||||
i := strings.LastIndex(key, "|")
|
||||
if i < 0 {
|
||||
return happydns.Identifier{}, fmt.Errorf("key %q has no pipe separator", key)
|
||||
}
|
||||
return happydns.NewIdentifierFromString(key[i+1:])
|
||||
}
|
||||
|
||||
// listByIndex scans a secondary index prefix, resolves each entity by its
|
||||
// last key segment, and returns the collected results.
|
||||
func listByIndex[T any](s *KVStorage, prefix string, getEntity func(happydns.Identifier) (*T, error)) ([]*T, error) {
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var results []*T
|
||||
for iter.Next() {
|
||||
id, err := lastKeySegment(iter.Key())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entity, err := getEntity(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, entity)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// listByIndexSorted is like listByIndex but sorts results, applies an optional
|
||||
// filter predicate, and then applies a limit. The limit counts only items that
|
||||
// pass the filter; passing nil for filter disables filtering.
|
||||
func listByIndexSorted[T any](s *KVStorage, prefix string, getEntity func(happydns.Identifier) (*T, error), less func(*T, *T) bool, limit int, filter func(*T) bool) ([]*T, error) {
|
||||
results, err := listByIndex(s, prefix, getEntity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return less(results[i], results[j])
|
||||
})
|
||||
|
||||
if filter == nil {
|
||||
if limit > 0 && len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
filtered := results[:0]
|
||||
for _, r := range results {
|
||||
if filter(r) {
|
||||
filtered = append(filtered, r)
|
||||
if limit > 0 && len(filtered) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// tidyTwoPartIndex removes stale secondary index entries of the form
|
||||
// prefix{ownerId}|{entityId}. If validateOwner is non-nil, entries whose
|
||||
// owner ID fails validation are also removed.
|
||||
func (s *KVStorage) tidyTwoPartIndex(prefix, label string, validateOwner func(happydns.Identifier) bool, entityExists func(happydns.Identifier) bool) {
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
for iter.Next() {
|
||||
key := iter.Key()
|
||||
rest := strings.TrimPrefix(key, prefix)
|
||||
parts := strings.SplitN(rest, "|", 2)
|
||||
if len(parts) != 2 {
|
||||
_ = s.db.Delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
ownerId, err := happydns.NewIdentifierFromString(parts[0])
|
||||
if err != nil {
|
||||
_ = s.db.Delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
entityId, err := happydns.NewIdentifierFromString(parts[1])
|
||||
if err != nil {
|
||||
_ = s.db.Delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
if validateOwner != nil && !validateOwner(ownerId) {
|
||||
log.Printf("Deleting stale %s index (%s %s not found): %s\n", label, label, parts[0], key)
|
||||
_ = s.db.Delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
if !entityExists(entityId) {
|
||||
log.Printf("Deleting stale %s index (entity %s not found): %s\n", label, parts[1], key)
|
||||
_ = s.db.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tidyLastSegmentIndex removes stale index entries where the entity ID is the
|
||||
// last "|"-separated segment. Used for multi-part indexes like
|
||||
// prefix{checkerID}|{target}|{entityId}.
|
||||
func (s *KVStorage) tidyLastSegmentIndex(prefix, label string, entityExists func(happydns.Identifier) bool) {
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
for iter.Next() {
|
||||
key := iter.Key()
|
||||
lastPipe := strings.LastIndex(key, "|")
|
||||
if lastPipe < 0 {
|
||||
_ = s.db.Delete(key)
|
||||
continue
|
||||
}
|
||||
idStr := key[lastPipe+1:]
|
||||
|
||||
id, err := happydns.NewIdentifierFromString(idStr)
|
||||
if err != nil {
|
||||
_ = s.db.Delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
if !entityExists(id) {
|
||||
log.Printf("Deleting stale %s index (entity %s not found): %s\n", label, idStr, key)
|
||||
_ = s.db.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearByPrefix deletes all KV entries matching the given prefix.
|
||||
func (s *KVStorage) clearByPrefix(prefix string) error {
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
for iter.Next() {
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// countByPrefix counts the number of keys matching the given prefix without
|
||||
// decoding their values. It is the foundation of the Count* methods exposed
|
||||
// to observability code.
|
||||
func (s *KVStorage) countByPrefix(prefix string) (int, error) {
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
n := 0
|
||||
for iter.Next() {
|
||||
n++
|
||||
}
|
||||
return n, iter.Err()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ func (s *KVStorage) ListAllUsers() (happydns.Iterator[happydns.User], error) {
|
|||
return NewKVIterator[happydns.User](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) CountUsers() (int, error) {
|
||||
return s.countByPrefix("user-")
|
||||
}
|
||||
|
||||
func (s *KVStorage) getUser(key string) (*happydns.User, error) {
|
||||
u := &happydns.User{}
|
||||
err := s.db.Get(key, &u)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ func (s *KVStorage) ListAllZones() (happydns.Iterator[happydns.ZoneMessage], err
|
|||
return NewKVIterator[happydns.ZoneMessage](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) CountZones() (int, error) {
|
||||
return s.countByPrefix("domain.zone-")
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error) {
|
||||
z := &happydns.ZoneMessage{}
|
||||
err := s.db.Get(fmt.Sprintf("domain.zone-%s", id.String()), &z)
|
||||
|
|
|
|||
40
internal/storage/stats_provider.go
Normal file
40
internal/storage/stats_provider.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// 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/>.
|
||||
|
||||
package storage
|
||||
|
||||
// StatsProvider implements metrics.StatsProvider using a Storage. It delegates
|
||||
// to the storage backend's native Count* methods so each Prometheus scrape
|
||||
// runs at most one cheap key-prefix scan per entity instead of decoding every
|
||||
// record.
|
||||
type StatsProvider struct {
|
||||
store Storage
|
||||
}
|
||||
|
||||
// NewStatsProvider creates a StatsProvider backed by the given Storage.
|
||||
func NewStatsProvider(s Storage) *StatsProvider {
|
||||
return &StatsProvider{store: s}
|
||||
}
|
||||
|
||||
func (p *StatsProvider) CountUsers() (int, error) { return p.store.CountUsers() }
|
||||
func (p *StatsProvider) CountDomains() (int, error) { return p.store.CountDomains() }
|
||||
func (p *StatsProvider) CountZones() (int, error) { return p.store.CountZones() }
|
||||
func (p *StatsProvider) CountProviders() (int, error) { return p.store.CountProviders() }
|
||||
|
|
@ -61,12 +61,13 @@ func (lu *loginUsecase) CompleteAuthentication(uinfo happydns.UserInfo) (*happyd
|
|||
return nil, fmt.Errorf("unable to create user account: %w", err)
|
||||
}
|
||||
} else if (uinfo.GetEmail() != "" && user.Email != uinfo.GetEmail()) || time.Since(user.LastSeen) > time.Hour*12 {
|
||||
if uinfo.GetEmail() != "" {
|
||||
user.Email = uinfo.GetEmail()
|
||||
}
|
||||
user.LastSeen = time.Now()
|
||||
|
||||
err = lu.store.CreateOrUpdateUser(user)
|
||||
email := uinfo.GetEmail()
|
||||
user, err = lu.userService.UpdateUser(user.Id, func(u *happydns.User) {
|
||||
if email != "" {
|
||||
u.Email = email
|
||||
}
|
||||
u.LastSeen = time.Now()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("has a correct JWT, user has been found, but an error occured when trying to update the user's information: %w", err)
|
||||
}
|
||||
|
|
|
|||
135
internal/usecase/checker/check_plan_usecase.go
Normal file
135
internal/usecase/checker/check_plan_usecase.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// targetMatchesResource verifies that every non-empty field in scope
|
||||
// matches the corresponding field in resource. Returns false if any
|
||||
// scope-specified field does not match, indicating the resource belongs
|
||||
// to a different user/domain/service than the caller's scope.
|
||||
func targetMatchesResource(scope, resource happydns.CheckTarget) bool {
|
||||
if scope.UserId != "" && scope.UserId != resource.UserId {
|
||||
return false
|
||||
}
|
||||
if scope.DomainId != "" && scope.DomainId != resource.DomainId {
|
||||
return false
|
||||
}
|
||||
if scope.ServiceId != "" && scope.ServiceId != resource.ServiceId {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CheckPlanUsecase handles business logic for check plans.
|
||||
type CheckPlanUsecase struct {
|
||||
store CheckPlanStorage
|
||||
}
|
||||
|
||||
// NewCheckPlanUsecase creates a new CheckPlanUsecase.
|
||||
func NewCheckPlanUsecase(store CheckPlanStorage) *CheckPlanUsecase {
|
||||
return &CheckPlanUsecase{store: store}
|
||||
}
|
||||
|
||||
// ListCheckPlansByTarget returns all check plans matching the given target.
|
||||
func (u *CheckPlanUsecase) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) {
|
||||
return u.store.ListCheckPlansByTarget(target)
|
||||
}
|
||||
|
||||
// ListCheckPlansByTargetAndChecker returns all check plans matching both the
|
||||
// given target and the given checkerID, filtering in a single pass to avoid
|
||||
// fetching then discarding unrelated plans.
|
||||
func (u *CheckPlanUsecase) ListCheckPlansByTargetAndChecker(target happydns.CheckTarget, checkerID string) ([]*happydns.CheckPlan, error) {
|
||||
plans, err := u.store.ListCheckPlansByTarget(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := plans[:0]
|
||||
for _, p := range plans {
|
||||
if p.CheckerID == checkerID {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// CreateCheckPlan validates that the checker exists and persists the plan.
|
||||
func (u *CheckPlanUsecase) CreateCheckPlan(plan *happydns.CheckPlan) error {
|
||||
if checkerPkg.FindChecker(plan.CheckerID) == nil {
|
||||
return fmt.Errorf("checker %q not found", plan.CheckerID)
|
||||
}
|
||||
return u.store.CreateCheckPlan(plan)
|
||||
}
|
||||
|
||||
// GetCheckPlan retrieves a check plan by ID and verifies it belongs to the given scope.
|
||||
func (u *CheckPlanUsecase) GetCheckPlan(scope happydns.CheckTarget, planID happydns.Identifier) (*happydns.CheckPlan, error) {
|
||||
plan, err := u.store.GetCheckPlan(planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !targetMatchesResource(scope, plan.Target) {
|
||||
return nil, happydns.ErrCheckPlanNotFound
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// UpdateCheckPlan fetches the existing plan, verifies scope ownership,
|
||||
// validates the checker exists, preserves Id and Target (immutable),
|
||||
// and persists the merged result.
|
||||
func (u *CheckPlanUsecase) UpdateCheckPlan(scope happydns.CheckTarget, planID happydns.Identifier, updated *happydns.CheckPlan) (*happydns.CheckPlan, error) {
|
||||
existing, err := u.store.GetCheckPlan(planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !targetMatchesResource(scope, existing.Target) {
|
||||
return nil, happydns.ErrCheckPlanNotFound
|
||||
}
|
||||
|
||||
if checkerPkg.FindChecker(updated.CheckerID) == nil {
|
||||
return nil, fmt.Errorf("checker %q not found", updated.CheckerID)
|
||||
}
|
||||
|
||||
updated.Id = existing.Id
|
||||
updated.Target = existing.Target
|
||||
|
||||
if err := u.store.UpdateCheckPlan(updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// DeleteCheckPlan deletes a check plan by ID after verifying scope ownership.
|
||||
func (u *CheckPlanUsecase) DeleteCheckPlan(scope happydns.CheckTarget, planID happydns.Identifier) error {
|
||||
plan, err := u.store.GetCheckPlan(planID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !targetMatchesResource(scope, plan.Target) {
|
||||
return happydns.ErrCheckPlanNotFound
|
||||
}
|
||||
return u.store.DeleteCheckPlan(planID)
|
||||
}
|
||||
387
internal/usecase/checker/check_plan_usecase_test.go
Normal file
387
internal/usecase/checker/check_plan_usecase_test.go
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
// 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/>.
|
||||
|
||||
package checker_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func setupPlanUC(t *testing.T) (*checkerUC.CheckPlanUsecase, *planStore) {
|
||||
t.Helper()
|
||||
// Register a checker so CreateCheckPlan validation passes.
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "plan_test_checker",
|
||||
Name: "Plan Test Checker",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&testCheckRule{name: "rule_a", status: happydns.StatusOK},
|
||||
},
|
||||
})
|
||||
|
||||
store := newPlanStore()
|
||||
uc := checkerUC.NewCheckPlanUsecase(store)
|
||||
return uc, store
|
||||
}
|
||||
|
||||
func TestCheckPlanUsecase_CreateAndGet(t *testing.T) {
|
||||
uc, _ := setupPlanUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
plan := &happydns.CheckPlan{
|
||||
CheckerID: "plan_test_checker",
|
||||
Target: target,
|
||||
}
|
||||
|
||||
if err := uc.CreateCheckPlan(plan); err != nil {
|
||||
t.Fatalf("CreateCheckPlan() error: %v", err)
|
||||
}
|
||||
|
||||
if plan.Id.IsEmpty() {
|
||||
t.Fatal("expected plan to get an ID assigned")
|
||||
}
|
||||
|
||||
got, err := uc.GetCheckPlan(target, plan.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCheckPlan() error: %v", err)
|
||||
}
|
||||
if got.CheckerID != "plan_test_checker" {
|
||||
t.Errorf("expected CheckerID plan_test_checker, got %s", got.CheckerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPlanUsecase_CreateUnknownChecker(t *testing.T) {
|
||||
uc, _ := setupPlanUC(t)
|
||||
|
||||
plan := &happydns.CheckPlan{
|
||||
CheckerID: "nonexistent_checker",
|
||||
}
|
||||
|
||||
if err := uc.CreateCheckPlan(plan); err == nil {
|
||||
t.Fatal("expected error for unknown checker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPlanUsecase_ListByTarget(t *testing.T) {
|
||||
uc, _ := setupPlanUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
plan := &happydns.CheckPlan{
|
||||
CheckerID: "plan_test_checker",
|
||||
Target: target,
|
||||
}
|
||||
if err := uc.CreateCheckPlan(plan); err != nil {
|
||||
t.Fatalf("CreateCheckPlan() error: %v", err)
|
||||
}
|
||||
|
||||
plans, err := uc.ListCheckPlansByTarget(target)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCheckPlansByTarget() error: %v", err)
|
||||
}
|
||||
if len(plans) != 1 {
|
||||
t.Errorf("expected 1 plan, got %d", len(plans))
|
||||
}
|
||||
|
||||
// Different target should return empty.
|
||||
uid2, _ := happydns.NewRandomIdentifier()
|
||||
other := happydns.CheckTarget{UserId: uid2.String()}
|
||||
plans2, err := uc.ListCheckPlansByTarget(other)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCheckPlansByTarget() error: %v", err)
|
||||
}
|
||||
if len(plans2) != 0 {
|
||||
t.Errorf("expected 0 plans for different target, got %d", len(plans2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPlanUsecase_ListByTargetAndChecker(t *testing.T) {
|
||||
uc, _ := setupPlanUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
// Create a plan for plan_test_checker.
|
||||
plan := &happydns.CheckPlan{
|
||||
CheckerID: "plan_test_checker",
|
||||
Target: target,
|
||||
}
|
||||
if err := uc.CreateCheckPlan(plan); err != nil {
|
||||
t.Fatalf("CreateCheckPlan() error: %v", err)
|
||||
}
|
||||
|
||||
// Query for the matching checker - should return the plan.
|
||||
plans, err := uc.ListCheckPlansByTargetAndChecker(target, "plan_test_checker")
|
||||
if err != nil {
|
||||
t.Fatalf("ListCheckPlansByTargetAndChecker() error: %v", err)
|
||||
}
|
||||
if len(plans) != 1 {
|
||||
t.Errorf("expected 1 plan, got %d", len(plans))
|
||||
}
|
||||
|
||||
// Query for a different checker on the same target - should return nothing.
|
||||
plans2, err := uc.ListCheckPlansByTargetAndChecker(target, "other_checker")
|
||||
if err != nil {
|
||||
t.Fatalf("ListCheckPlansByTargetAndChecker() error: %v", err)
|
||||
}
|
||||
if len(plans2) != 0 {
|
||||
t.Errorf("expected 0 plans for different checker, got %d", len(plans2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPlanUsecase_UpdatePreservesIdAndTarget(t *testing.T) {
|
||||
uc, _ := setupPlanUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
plan := &happydns.CheckPlan{
|
||||
CheckerID: "plan_test_checker",
|
||||
Target: target,
|
||||
}
|
||||
if err := uc.CreateCheckPlan(plan); err != nil {
|
||||
t.Fatalf("CreateCheckPlan() error: %v", err)
|
||||
}
|
||||
|
||||
origID := plan.Id
|
||||
|
||||
// Update with different target and ID; they should be preserved.
|
||||
uid2, _ := happydns.NewRandomIdentifier()
|
||||
fakeID, _ := happydns.NewRandomIdentifier()
|
||||
updated := &happydns.CheckPlan{
|
||||
Id: fakeID,
|
||||
CheckerID: "plan_test_checker",
|
||||
Target: happydns.CheckTarget{UserId: uid2.String()},
|
||||
Enabled: map[string]bool{"rule_a": false},
|
||||
}
|
||||
|
||||
result, err := uc.UpdateCheckPlan(target, origID, updated)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateCheckPlan() error: %v", err)
|
||||
}
|
||||
|
||||
if !result.Id.Equals(origID) {
|
||||
t.Errorf("expected Id to be preserved as %s, got %s", origID, result.Id)
|
||||
}
|
||||
if result.Target.String() != target.String() {
|
||||
t.Errorf("expected Target to be preserved")
|
||||
}
|
||||
if result.Enabled["rule_a"] != false {
|
||||
t.Errorf("expected Enabled to be updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPlanUsecase_UpdateScopeMismatch(t *testing.T) {
|
||||
uc, _ := setupPlanUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
uid2, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
plan := &happydns.CheckPlan{
|
||||
CheckerID: "plan_test_checker",
|
||||
Target: target,
|
||||
}
|
||||
if err := uc.CreateCheckPlan(plan); err != nil {
|
||||
t.Fatalf("CreateCheckPlan() error: %v", err)
|
||||
}
|
||||
|
||||
// Update with a different user scope should fail.
|
||||
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
|
||||
_, err := uc.UpdateCheckPlan(wrongScope, plan.Id, &happydns.CheckPlan{
|
||||
CheckerID: "plan_test_checker",
|
||||
Enabled: map[string]bool{"rule_a": false},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when scope doesn't match plan target")
|
||||
}
|
||||
|
||||
// Verify the original plan is unchanged.
|
||||
got, err := uc.GetCheckPlan(target, plan.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCheckPlan() error: %v", err)
|
||||
}
|
||||
if got.Enabled != nil {
|
||||
t.Errorf("expected original plan to be unchanged, got Enabled=%v", got.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPlanUsecase_GetScopeMismatch(t *testing.T) {
|
||||
uc, _ := setupPlanUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
uid2, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
plan := &happydns.CheckPlan{
|
||||
CheckerID: "plan_test_checker",
|
||||
Target: target,
|
||||
}
|
||||
if err := uc.CreateCheckPlan(plan); err != nil {
|
||||
t.Fatalf("CreateCheckPlan() error: %v", err)
|
||||
}
|
||||
|
||||
// Get with a different user scope should fail.
|
||||
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
|
||||
_, err := uc.GetCheckPlan(wrongScope, plan.Id)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when scope doesn't match plan target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPlanUsecase_DeleteScopeMismatch(t *testing.T) {
|
||||
uc, _ := setupPlanUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
uid2, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
plan := &happydns.CheckPlan{
|
||||
CheckerID: "plan_test_checker",
|
||||
Target: target,
|
||||
}
|
||||
if err := uc.CreateCheckPlan(plan); err != nil {
|
||||
t.Fatalf("CreateCheckPlan() error: %v", err)
|
||||
}
|
||||
|
||||
// Delete with a different user scope should fail.
|
||||
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
|
||||
if err := uc.DeleteCheckPlan(wrongScope, plan.Id); err == nil {
|
||||
t.Fatal("expected error when scope doesn't match plan target")
|
||||
}
|
||||
|
||||
// Verify the plan still exists.
|
||||
_, err := uc.GetCheckPlan(target, plan.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("plan should still exist after failed delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPlanUsecase_UpdateNotFound(t *testing.T) {
|
||||
uc, _ := setupPlanUC(t)
|
||||
|
||||
fakeID, _ := happydns.NewRandomIdentifier()
|
||||
_, err := uc.UpdateCheckPlan(happydns.CheckTarget{}, fakeID, &happydns.CheckPlan{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent plan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPlanUsecase_Delete(t *testing.T) {
|
||||
uc, _ := setupPlanUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String()}
|
||||
|
||||
plan := &happydns.CheckPlan{
|
||||
CheckerID: "plan_test_checker",
|
||||
Target: target,
|
||||
}
|
||||
if err := uc.CreateCheckPlan(plan); err != nil {
|
||||
t.Fatalf("CreateCheckPlan() error: %v", err)
|
||||
}
|
||||
|
||||
if err := uc.DeleteCheckPlan(target, plan.Id); err != nil {
|
||||
t.Fatalf("DeleteCheckPlan() error: %v", err)
|
||||
}
|
||||
|
||||
_, err := uc.GetCheckPlan(target, plan.Id)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after deletion")
|
||||
}
|
||||
}
|
||||
|
||||
// --- planStore: minimal in-memory CheckPlanStorage ---
|
||||
|
||||
type planStore struct {
|
||||
plans map[string]*happydns.CheckPlan
|
||||
}
|
||||
|
||||
func newPlanStore() *planStore {
|
||||
return &planStore{plans: make(map[string]*happydns.CheckPlan)}
|
||||
}
|
||||
|
||||
func (s *planStore) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *planStore) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) {
|
||||
var result []*happydns.CheckPlan
|
||||
for _, p := range s.plans {
|
||||
if p.Target.String() == target.String() {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *planStore) ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *planStore) ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *planStore) GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) {
|
||||
p, ok := s.plans[planID.String()]
|
||||
if !ok {
|
||||
return nil, happydns.ErrCheckPlanNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *planStore) CreateCheckPlan(plan *happydns.CheckPlan) error {
|
||||
id, _ := happydns.NewRandomIdentifier()
|
||||
plan.Id = id
|
||||
s.plans[plan.Id.String()] = plan
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *planStore) UpdateCheckPlan(plan *happydns.CheckPlan) error {
|
||||
s.plans[plan.Id.String()] = plan
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *planStore) DeleteCheckPlan(planID happydns.Identifier) error {
|
||||
delete(s.plans, planID.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *planStore) TidyCheckPlanIndexes() error { return nil }
|
||||
|
||||
func (s *planStore) ClearCheckPlans() error {
|
||||
s.plans = make(map[string]*happydns.CheckPlan)
|
||||
return nil
|
||||
}
|
||||
403
internal/usecase/checker/check_status_usecase.go
Normal file
403
internal/usecase/checker/check_status_usecase.go
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// worstStatusMaxExecs is the maximum number of executions fetched when
|
||||
// computing worst-status aggregations. It prevents unbounded memory usage
|
||||
// on long-lived accounts while being generous enough for any realistic
|
||||
// scenario.
|
||||
const worstStatusMaxExecs = 10000
|
||||
|
||||
// CheckStatusUsecase handles aggregation of checker statuses and evaluation/execution queries.
|
||||
type CheckStatusUsecase struct {
|
||||
planStore CheckPlanStorage
|
||||
evalStore CheckEvaluationStorage
|
||||
execStore ExecutionStorage
|
||||
snapStore ObservationSnapshotStorage
|
||||
}
|
||||
|
||||
// NewCheckStatusUsecase creates a new CheckStatusUsecase.
|
||||
func NewCheckStatusUsecase(planStore CheckPlanStorage, evalStore CheckEvaluationStorage, execStore ExecutionStorage, snapStore ObservationSnapshotStorage) *CheckStatusUsecase {
|
||||
return &CheckStatusUsecase{
|
||||
planStore: planStore,
|
||||
evalStore: evalStore,
|
||||
execStore: execStore,
|
||||
snapStore: snapStore,
|
||||
}
|
||||
}
|
||||
|
||||
// BudgetChecker exposes the per-user daily quota to the API layer.
|
||||
//
|
||||
// RateLimiterFor hands out a snapshot predicate that reports whether a
|
||||
// scheduled execution of a given interval would be denied by the user's
|
||||
// daily budget — either because the hard MaxChecksPerDay cap is reached,
|
||||
// or because interval-aware throttling is skipping short-interval jobs to
|
||||
// protect rarer ones. The snapshot is resolved once per user so callers
|
||||
// can evaluate many intervals without repeated lookups.
|
||||
//
|
||||
// AllowWithInterval and IncrementUsage let the manual-trigger endpoint
|
||||
// enforce the same quota as the scheduler: check before creating the
|
||||
// execution, increment on success. All three are implemented by
|
||||
// *UserGater.
|
||||
type BudgetChecker interface {
|
||||
RateLimiterFor(userID string) func(time.Duration) bool
|
||||
AllowWithInterval(target happydns.CheckTarget, interval time.Duration) bool
|
||||
IncrementUsage(target happydns.CheckTarget)
|
||||
}
|
||||
|
||||
// ListPlannedExecutions returns synthetic Execution records for upcoming scheduled jobs.
|
||||
// Returns nil if provider is nil. When budgetChecker is non-nil, each planned
|
||||
// execution is marked with status ExecutionRateLimited instead of
|
||||
// ExecutionPending if a job of that interval would be denied by the user's
|
||||
// daily budget at the moment of the call.
|
||||
func ListPlannedExecutions(provider PlannedJobProvider, budgetChecker BudgetChecker, checkerID string, target happydns.CheckTarget) []*happydns.Execution {
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
jobs := provider.GetPlannedJobsForChecker(checkerID, target)
|
||||
result := make([]*happydns.Execution, 0, len(jobs))
|
||||
|
||||
// Resolve the user's rate-limit predicate once, so the loop below does
|
||||
// not reacquire the UserGater's budget lock for every planned job.
|
||||
isLimited := func(time.Duration) bool { return false }
|
||||
if budgetChecker != nil {
|
||||
isLimited = budgetChecker.RateLimiterFor(target.UserId)
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
status := happydns.ExecutionPending
|
||||
if isLimited(job.Interval) {
|
||||
status = happydns.ExecutionRateLimited
|
||||
}
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: job.CheckerID,
|
||||
PlanID: job.PlanID,
|
||||
Target: job.Target,
|
||||
Trigger: happydns.TriggerInfo{Type: happydns.TriggerSchedule},
|
||||
StartedAt: job.NextRun,
|
||||
Status: status,
|
||||
}
|
||||
result = append(result, exec)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ListCheckerStatuses aggregates checkers, plans, and latest evaluations into a status list.
|
||||
func (u *CheckStatusUsecase) ListCheckerStatuses(target happydns.CheckTarget) ([]happydns.CheckerStatus, error) {
|
||||
checkers := checkerPkg.GetCheckers()
|
||||
plans, err := u.planStore.ListCheckPlansByTarget(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
planByChecker := make(map[string]*happydns.CheckPlan)
|
||||
for _, p := range plans {
|
||||
planByChecker[p.CheckerID] = p
|
||||
}
|
||||
|
||||
var result []happydns.CheckerStatus
|
||||
for _, def := range checkers {
|
||||
switch target.Scope() {
|
||||
case happydns.CheckScopeDomain:
|
||||
if !def.Availability.ApplyToDomain {
|
||||
continue
|
||||
}
|
||||
case happydns.CheckScopeService:
|
||||
if !def.Availability.ApplyToService {
|
||||
continue
|
||||
}
|
||||
if len(def.Availability.LimitToServices) > 0 && target.ServiceType != "" {
|
||||
if !slices.Contains(def.Availability.LimitToServices, target.ServiceType) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status := happydns.CheckerStatus{
|
||||
CheckerDefinition: def,
|
||||
Plan: planByChecker[def.ID],
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
enabledRules := make(map[string]bool, len(def.Rules))
|
||||
for _, rule := range def.Rules {
|
||||
enabledRules[rule.Name()] = true
|
||||
}
|
||||
if status.Plan != nil {
|
||||
status.Enabled = !status.Plan.IsFullyDisabled()
|
||||
for ruleName := range enabledRules {
|
||||
enabledRules[ruleName] = status.Plan.IsRuleEnabled(ruleName)
|
||||
}
|
||||
}
|
||||
status.EnabledRules = enabledRules
|
||||
|
||||
execs, err := u.execStore.ListExecutionsByChecker(def.ID, target, 1, nil)
|
||||
if err != nil {
|
||||
log.Printf("ListCheckerStatuses: failed to fetch latest execution for checker %s: %v", def.ID, err)
|
||||
} else if len(execs) > 0 {
|
||||
status.LatestExecution = execs[0]
|
||||
}
|
||||
|
||||
result = append(result, status)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
result = []happydns.CheckerStatus{}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetExecution returns a specific execution by ID after verifying scope ownership.
|
||||
func (u *CheckStatusUsecase) GetExecution(scope happydns.CheckTarget, execID happydns.Identifier) (*happydns.Execution, error) {
|
||||
exec, err := u.execStore.GetExecution(execID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !targetMatchesResource(scope, exec.Target) {
|
||||
return nil, happydns.ErrExecutionNotFound
|
||||
}
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
// ListExecutionsByChecker returns executions for a checker on a target, up to limit.
|
||||
func (u *CheckStatusUsecase) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) {
|
||||
return u.execStore.ListExecutionsByChecker(checkerID, target, limit, nil)
|
||||
}
|
||||
|
||||
// GetObservationsByExecution returns the observation snapshot for an execution after verifying scope.
|
||||
func (u *CheckStatusUsecase) GetObservationsByExecution(scope happydns.CheckTarget, execID happydns.Identifier) (*happydns.ObservationSnapshot, error) {
|
||||
exec, err := u.execStore.GetExecution(execID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !targetMatchesResource(scope, exec.Target) {
|
||||
return nil, happydns.ErrExecutionNotFound
|
||||
}
|
||||
return u.snapshotForExecution(exec)
|
||||
}
|
||||
|
||||
// DeleteExecution deletes an execution record by ID after verifying scope ownership.
|
||||
func (u *CheckStatusUsecase) DeleteExecution(scope happydns.CheckTarget, execID happydns.Identifier) error {
|
||||
exec, err := u.execStore.GetExecution(execID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !targetMatchesResource(scope, exec.Target) {
|
||||
return happydns.ErrExecutionNotFound
|
||||
}
|
||||
return u.execStore.DeleteExecution(execID)
|
||||
}
|
||||
|
||||
// DeleteExecutionsByChecker deletes all executions for a checker on a target.
|
||||
func (u *CheckStatusUsecase) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error {
|
||||
return u.execStore.DeleteExecutionsByChecker(checkerID, target)
|
||||
}
|
||||
|
||||
// worstStatuses groups executions by a key extracted via keyFn, keeps only
|
||||
// the latest execution per (key, checker) pair, and returns the worst status
|
||||
// per key.
|
||||
func worstStatuses(execs []*happydns.Execution, keyFn func(*happydns.Execution) string) map[string]*happydns.Status {
|
||||
type groupKey struct {
|
||||
key string
|
||||
checker string
|
||||
}
|
||||
latest := map[groupKey]*happydns.Execution{}
|
||||
for _, exec := range execs {
|
||||
k := keyFn(exec)
|
||||
if k == "" || exec.Status != happydns.ExecutionDone {
|
||||
continue
|
||||
}
|
||||
gk := groupKey{key: k, checker: exec.CheckerID}
|
||||
if prev, ok := latest[gk]; !ok || exec.StartedAt.After(prev.StartedAt) {
|
||||
latest[gk] = exec
|
||||
}
|
||||
}
|
||||
|
||||
worst := map[string]*happydns.Status{}
|
||||
for gk, exec := range latest {
|
||||
s := exec.Result.Status
|
||||
if s == happydns.StatusUnknown {
|
||||
continue
|
||||
}
|
||||
if prev, ok := worst[gk.key]; !ok || s > *prev {
|
||||
worst[gk.key] = &s
|
||||
}
|
||||
}
|
||||
|
||||
if len(worst) == 0 {
|
||||
return nil
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
// GetWorstDomainStatuses fetches all executions for a user and returns the worst
|
||||
// (most critical) status per domain. It keeps only the latest execution per
|
||||
// (domain, checker) pair and reports the worst status among them.
|
||||
func (u *CheckStatusUsecase) GetWorstDomainStatuses(userId happydns.Identifier) (map[string]*happydns.Status, error) {
|
||||
execs, err := u.execStore.ListExecutionsByUser(userId, worstStatusMaxExecs, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return worstStatuses(execs, func(e *happydns.Execution) string {
|
||||
return e.Target.DomainId
|
||||
}), nil
|
||||
}
|
||||
|
||||
// GetWorstServiceStatuses returns the worst check status for each service in the zone.
|
||||
// It fetches all executions for the domain in a single query, then aggregates
|
||||
// the worst status per service in memory.
|
||||
func (u *CheckStatusUsecase) GetWorstServiceStatuses(userId happydns.Identifier, domainId happydns.Identifier) (map[string]*happydns.Status, error) {
|
||||
execs, err := u.execStore.ListExecutionsByDomain(domainId, worstStatusMaxExecs, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return worstStatuses(execs, func(e *happydns.Execution) string {
|
||||
return e.Target.ServiceId
|
||||
}), nil
|
||||
}
|
||||
|
||||
// GetResultsByExecution returns the evaluation (with per-rule states) for an execution after verifying scope.
|
||||
func (u *CheckStatusUsecase) GetResultsByExecution(scope happydns.CheckTarget, execID happydns.Identifier) (*happydns.CheckEvaluation, error) {
|
||||
exec, err := u.execStore.GetExecution(execID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !targetMatchesResource(scope, exec.Target) {
|
||||
return nil, happydns.ErrExecutionNotFound
|
||||
}
|
||||
if exec.EvaluationID == nil {
|
||||
return nil, happydns.ErrCheckEvaluationNotFound
|
||||
}
|
||||
return u.evalStore.GetEvaluation(*exec.EvaluationID)
|
||||
}
|
||||
|
||||
// snapshotForExecution returns the observation snapshot associated with an execution.
|
||||
func (u *CheckStatusUsecase) snapshotForExecution(exec *happydns.Execution) (*happydns.ObservationSnapshot, error) {
|
||||
if exec.EvaluationID == nil {
|
||||
return nil, happydns.ErrCheckEvaluationNotFound
|
||||
}
|
||||
|
||||
eval, err := u.evalStore.GetEvaluation(*exec.EvaluationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u.snapStore.GetSnapshot(eval.SnapshotID)
|
||||
}
|
||||
|
||||
// extractMetricsFromExecution extracts metrics from a single execution's snapshot.
|
||||
func (u *CheckStatusUsecase) extractMetricsFromExecution(exec *happydns.Execution) ([]happydns.CheckMetric, error) {
|
||||
if exec.Status != happydns.ExecutionDone || exec.EvaluationID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
snap, err := u.snapshotForExecution(exec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return checkerPkg.GetAllMetrics(snap)
|
||||
}
|
||||
|
||||
// extractMetricsFromExecutions extracts metrics from a list of executions.
|
||||
func (u *CheckStatusUsecase) extractMetricsFromExecutions(execs []*happydns.Execution) ([]happydns.CheckMetric, error) {
|
||||
var allMetrics []happydns.CheckMetric
|
||||
for _, exec := range execs {
|
||||
metrics, err := u.extractMetricsFromExecution(exec)
|
||||
if err != nil {
|
||||
log.Printf("extractMetricsFromExecutions: exec %s: %v", exec.Id.String(), err)
|
||||
continue
|
||||
}
|
||||
allMetrics = append(allMetrics, metrics...)
|
||||
}
|
||||
return allMetrics, nil
|
||||
}
|
||||
|
||||
// doneExecution is a filter predicate for ListExecutionsBy* that keeps only
|
||||
// executions that have completed successfully and can produce metrics.
|
||||
func doneExecution(e *happydns.Execution) bool {
|
||||
return e.Status == happydns.ExecutionDone && e.EvaluationID != nil
|
||||
}
|
||||
|
||||
// GetMetricsByExecution extracts metrics from a single execution's snapshot after verifying scope.
|
||||
func (u *CheckStatusUsecase) GetMetricsByExecution(scope happydns.CheckTarget, execID happydns.Identifier) ([]happydns.CheckMetric, error) {
|
||||
exec, err := u.execStore.GetExecution(execID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !targetMatchesResource(scope, exec.Target) {
|
||||
return nil, happydns.ErrExecutionNotFound
|
||||
}
|
||||
return u.extractMetricsFromExecution(exec)
|
||||
}
|
||||
|
||||
// GetMetricsByChecker extracts metrics from recent executions of a checker on a target.
|
||||
func (u *CheckStatusUsecase) GetMetricsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]happydns.CheckMetric, error) {
|
||||
execs, err := u.execStore.ListExecutionsByChecker(checkerID, target, limit, doneExecution)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u.extractMetricsFromExecutions(execs)
|
||||
}
|
||||
|
||||
// GetMetricsByUser extracts metrics from recent executions for a user across all checkers.
|
||||
func (u *CheckStatusUsecase) GetMetricsByUser(userId happydns.Identifier, limit int) ([]happydns.CheckMetric, error) {
|
||||
execs, err := u.execStore.ListExecutionsByUser(userId, limit, doneExecution)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u.extractMetricsFromExecutions(execs)
|
||||
}
|
||||
|
||||
// GetMetricsByDomain extracts metrics from recent executions for a domain (including services).
|
||||
func (u *CheckStatusUsecase) GetMetricsByDomain(domainId happydns.Identifier, limit int) ([]happydns.CheckMetric, error) {
|
||||
execs, err := u.execStore.ListExecutionsByDomain(domainId, limit, doneExecution)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u.extractMetricsFromExecutions(execs)
|
||||
}
|
||||
|
||||
// GetSnapshotByExecution returns the raw observation data for a single key from an execution after verifying scope.
|
||||
func (u *CheckStatusUsecase) GetSnapshotByExecution(scope happydns.CheckTarget, execID happydns.Identifier, obsKey string) (json.RawMessage, error) {
|
||||
snap, err := u.GetObservationsByExecution(scope, execID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, ok := snap.Data[obsKey]
|
||||
if !ok {
|
||||
return nil, happydns.ErrSnapshotNotFound
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
992
internal/usecase/checker/check_status_usecase_test.go
Normal file
992
internal/usecase/checker/check_status_usecase_test.go
Normal file
|
|
@ -0,0 +1,992 @@
|
|||
// 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/>.
|
||||
|
||||
package checker_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func setupStatusUC(t *testing.T) (*checkerUC.CheckStatusUsecase, *planStore, storage.Storage) {
|
||||
t.Helper()
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "status_test_checker",
|
||||
Name: "Status Test Checker",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&testCheckRule{name: "rule_x", status: happydns.StatusOK},
|
||||
&testCheckRule{name: "rule_y", status: happydns.StatusWarn},
|
||||
},
|
||||
})
|
||||
|
||||
ps := newPlanStore()
|
||||
ms, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("Instantiate() returned error: %v", err)
|
||||
}
|
||||
uc := checkerUC.NewCheckStatusUsecase(ps, ms, ms, ms)
|
||||
return uc, ps, ms
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_ListCheckerStatuses(t *testing.T) {
|
||||
uc, _, _ := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
statuses, err := uc.ListCheckerStatuses(target)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCheckerStatuses() error: %v", err)
|
||||
}
|
||||
|
||||
if len(statuses) == 0 {
|
||||
t.Fatal("expected at least one checker status")
|
||||
}
|
||||
|
||||
// All should be enabled by default (no plans).
|
||||
for _, s := range statuses {
|
||||
if !s.Enabled {
|
||||
t.Errorf("expected checker %s to be enabled by default", s.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_ListCheckerStatuses_WithPlan(t *testing.T) {
|
||||
uc, ps, _ := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
// Create a plan that fully disables the checker.
|
||||
plan := &happydns.CheckPlan{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Enabled: map[string]bool{"rule_x": false, "rule_y": false},
|
||||
}
|
||||
if err := ps.CreateCheckPlan(plan); err != nil {
|
||||
t.Fatalf("CreateCheckPlan() error: %v", err)
|
||||
}
|
||||
|
||||
statuses, err := uc.ListCheckerStatuses(target)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCheckerStatuses() error: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, s := range statuses {
|
||||
if s.ID == "status_test_checker" {
|
||||
found = true
|
||||
if s.Enabled {
|
||||
t.Error("expected status_test_checker to be disabled when all rules are off")
|
||||
}
|
||||
if s.Plan == nil {
|
||||
t.Error("expected Plan to be set")
|
||||
}
|
||||
if s.EnabledRules["rule_x"] {
|
||||
t.Error("expected rule_x to be disabled")
|
||||
}
|
||||
if s.EnabledRules["rule_y"] {
|
||||
t.Error("expected rule_y to be disabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("status_test_checker not found in statuses")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_ListCheckerStatuses_WithEvaluation(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
// Create an execution for the checker.
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionDone,
|
||||
Result: happydns.CheckState{Status: happydns.StatusOK, Message: "all good"},
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
statuses, err := uc.ListCheckerStatuses(target)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCheckerStatuses() error: %v", err)
|
||||
}
|
||||
|
||||
for _, s := range statuses {
|
||||
if s.ID == "status_test_checker" {
|
||||
if s.LatestExecution == nil {
|
||||
t.Error("expected LatestExecution to be set")
|
||||
} else if s.LatestExecution.Result.Status != happydns.StatusOK {
|
||||
t.Errorf("expected latest execution result status OK, got %s", s.LatestExecution.Result.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetExecution(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
exec := &happydns.Execution{
|
||||
Status: happydns.ExecutionDone,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
got, err := uc.GetExecution(happydns.CheckTarget{}, exec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetExecution() error: %v", err)
|
||||
}
|
||||
if got.Status != happydns.ExecutionDone {
|
||||
t.Errorf("expected status Done, got %d", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetExecutionNotFound(t *testing.T) {
|
||||
uc, _, _ := setupStatusUC(t)
|
||||
|
||||
fakeID, _ := happydns.NewRandomIdentifier()
|
||||
_, err := uc.GetExecution(happydns.CheckTarget{}, fakeID)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent execution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetExecution_ScopeMismatch(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
uid2, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
// Access with a different user scope should fail.
|
||||
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
|
||||
_, err := uc.GetExecution(wrongScope, exec.Id)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when scope doesn't match execution target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_DeleteExecution(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
if err := uc.DeleteExecution(target, exec.Id); err != nil {
|
||||
t.Fatalf("DeleteExecution() error: %v", err)
|
||||
}
|
||||
|
||||
_, err := uc.GetExecution(target, exec.Id)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after deletion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_DeleteExecution_ScopeMismatch(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
uid2, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
// Delete with wrong scope should fail.
|
||||
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
|
||||
if err := uc.DeleteExecution(wrongScope, exec.Id); err == nil {
|
||||
t.Fatal("expected error when scope doesn't match")
|
||||
}
|
||||
|
||||
// Original should still exist.
|
||||
_, err := uc.GetExecution(target, exec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("execution should still exist after failed delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_DeleteExecutionsByChecker(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := uc.DeleteExecutionsByChecker("status_test_checker", target); err != nil {
|
||||
t.Fatalf("DeleteExecutionsByChecker() error: %v", err)
|
||||
}
|
||||
|
||||
execs, err := uc.ListExecutionsByChecker("status_test_checker", target, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListExecutionsByChecker() error: %v", err)
|
||||
}
|
||||
if len(execs) != 0 {
|
||||
t.Errorf("expected 0 executions after bulk delete, got %d", len(execs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_ListExecutionsByChecker(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionDone,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
execs, err := uc.ListExecutionsByChecker("status_test_checker", target, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("ListExecutionsByChecker() error: %v", err)
|
||||
}
|
||||
if len(execs) > 3 {
|
||||
t.Errorf("expected at most 3 executions with limit, got %d", len(execs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetWorstDomainStatuses(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did1, _ := happydns.NewRandomIdentifier()
|
||||
did2, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
// Domain 1: one OK and one WARN execution.
|
||||
for _, status := range []happydns.Status{happydns.StatusOK, happydns.StatusWarn} {
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did1.String()},
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionDone,
|
||||
Result: happydns.CheckState{Status: status},
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Domain 2: only OK.
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did2.String()},
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionDone,
|
||||
Result: happydns.CheckState{Status: happydns.StatusOK},
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
worst, err := uc.GetWorstDomainStatuses(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorstDomainStatuses() error: %v", err)
|
||||
}
|
||||
|
||||
// Domain 1 should have worst status WARN.
|
||||
if s, ok := worst[did1.String()]; !ok {
|
||||
t.Error("expected domain 1 in results")
|
||||
} else if *s != happydns.StatusWarn {
|
||||
t.Errorf("expected worst status WARN for domain 1, got %v", *s)
|
||||
}
|
||||
|
||||
// Domain 2 should have worst status OK.
|
||||
if s, ok := worst[did2.String()]; !ok {
|
||||
t.Error("expected domain 2 in results")
|
||||
} else if *s != happydns.StatusOK {
|
||||
t.Errorf("expected worst status OK for domain 2, got %v", *s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetWorstServiceStatuses(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
sid1, _ := happydns.NewRandomIdentifier()
|
||||
sid2, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
// Service 1: CRIT execution.
|
||||
exec1 := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid1.String()},
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionDone,
|
||||
Result: happydns.CheckState{Status: happydns.StatusCrit},
|
||||
}
|
||||
if err := ms.CreateExecution(exec1); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
// Service 2: OK execution.
|
||||
exec2 := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid2.String()},
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionDone,
|
||||
Result: happydns.CheckState{Status: happydns.StatusOK},
|
||||
}
|
||||
if err := ms.CreateExecution(exec2); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
worst, err := uc.GetWorstServiceStatuses(uid, did)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorstServiceStatuses() error: %v", err)
|
||||
}
|
||||
|
||||
if s, ok := worst[sid1.String()]; !ok {
|
||||
t.Error("expected service 1 in results")
|
||||
} else if *s != happydns.StatusCrit {
|
||||
t.Errorf("expected CRIT for service 1, got %v", *s)
|
||||
}
|
||||
|
||||
if s, ok := worst[sid2.String()]; !ok {
|
||||
t.Error("expected service 2 in results")
|
||||
} else if *s != happydns.StatusOK {
|
||||
t.Errorf("expected OK for service 2, got %v", *s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetWorstServiceStatuses_Empty(t *testing.T) {
|
||||
uc, _, _ := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
result, err := uc.GetWorstServiceStatuses(uid, did)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorstServiceStatuses() error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("expected nil for empty results, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetResultsByExecution(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
// Create evaluation.
|
||||
eval := &happydns.CheckEvaluation{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
States: []happydns.CheckState{{Status: happydns.StatusOK, Code: "test"}},
|
||||
}
|
||||
if err := ms.CreateEvaluation(eval); err != nil {
|
||||
t.Fatalf("CreateEvaluation() error: %v", err)
|
||||
}
|
||||
|
||||
// Create execution referencing the evaluation.
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
EvaluationID: &eval.Id,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
got, err := uc.GetResultsByExecution(target, exec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetResultsByExecution() error: %v", err)
|
||||
}
|
||||
if len(got.States) != 1 {
|
||||
t.Errorf("expected 1 state, got %d", len(got.States))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetResultsByExecution_NoEvaluation(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
target := happydns.CheckTarget{}
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionPending,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
_, err := uc.GetResultsByExecution(target, exec.Id)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for execution without evaluation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_ListPlannedExecutions(t *testing.T) {
|
||||
// Test with nil provider.
|
||||
result := checkerUC.ListPlannedExecutions(nil, nil, "checker", happydns.CheckTarget{})
|
||||
if result != nil {
|
||||
t.Errorf("expected nil for nil provider, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// fakePlannedProvider is a stub PlannedJobProvider that returns a fixed list
|
||||
// of scheduler jobs, used to test ListPlannedExecutions independently of the
|
||||
// scheduler.
|
||||
type fakePlannedProvider struct {
|
||||
jobs []*checkerUC.SchedulerJob
|
||||
}
|
||||
|
||||
func (f *fakePlannedProvider) GetPlannedJobsForChecker(checkerID string, target happydns.CheckTarget) []*checkerUC.SchedulerJob {
|
||||
return f.jobs
|
||||
}
|
||||
|
||||
// fakeBudgetChecker is a stub BudgetChecker. Its verdict can be set as a
|
||||
// blanket value (limited=true denies every call) or selectively denied for
|
||||
// intervals shorter than denyBelow, mirroring how UserGater throttles
|
||||
// short-interval jobs while still allowing longer ones.
|
||||
type fakeBudgetChecker struct {
|
||||
limited bool
|
||||
denyBelow time.Duration
|
||||
calls int // number of RateLimiterFor invocations, for fanout assertions
|
||||
}
|
||||
|
||||
func (f *fakeBudgetChecker) RateLimiterFor(userID string) func(time.Duration) bool {
|
||||
f.calls++
|
||||
return func(interval time.Duration) bool {
|
||||
if f.limited {
|
||||
return true
|
||||
}
|
||||
if f.denyBelow > 0 && interval > 0 && interval < f.denyBelow {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AllowWithInterval / IncrementUsage are present so fakeBudgetChecker
|
||||
// satisfies BudgetChecker; ListPlannedExecutions never calls them, so the
|
||||
// bodies are intentionally minimal.
|
||||
func (f *fakeBudgetChecker) AllowWithInterval(_ happydns.CheckTarget, _ time.Duration) bool {
|
||||
return !f.limited
|
||||
}
|
||||
func (f *fakeBudgetChecker) IncrementUsage(_ happydns.CheckTarget) {}
|
||||
|
||||
func TestCheckStatusUsecase_ListPlannedExecutions_StatusPending(t *testing.T) {
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
now := time.Now()
|
||||
provider := &fakePlannedProvider{jobs: []*checkerUC.SchedulerJob{
|
||||
{CheckerID: "c1", Target: target, Interval: time.Hour, NextRun: now.Add(time.Hour)},
|
||||
{CheckerID: "c1", Target: target, Interval: 2 * time.Hour, NextRun: now.Add(2 * time.Hour)},
|
||||
}}
|
||||
|
||||
// Nil budget checker -> all entries should be pending.
|
||||
result := checkerUC.ListPlannedExecutions(provider, nil, "c1", target)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 planned executions, got %d", len(result))
|
||||
}
|
||||
for i, exec := range result {
|
||||
if exec.Status != happydns.ExecutionPending {
|
||||
t.Errorf("result[%d].Status = %v; want ExecutionPending", i, exec.Status)
|
||||
}
|
||||
if exec.Trigger.Type != happydns.TriggerSchedule {
|
||||
t.Errorf("result[%d].Trigger.Type = %v; want TriggerSchedule", i, exec.Trigger.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Budget checker reporting "not rate-limited" -> still pending.
|
||||
result = checkerUC.ListPlannedExecutions(provider, &fakeBudgetChecker{}, "c1", target)
|
||||
for i, exec := range result {
|
||||
if exec.Status != happydns.ExecutionPending {
|
||||
t.Errorf("result[%d].Status = %v; want ExecutionPending when budget OK", i, exec.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_ListPlannedExecutions_StatusRateLimited(t *testing.T) {
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
now := time.Now()
|
||||
provider := &fakePlannedProvider{jobs: []*checkerUC.SchedulerJob{
|
||||
{CheckerID: "c1", Target: target, Interval: time.Hour, NextRun: now.Add(time.Hour)},
|
||||
{CheckerID: "c1", Target: target, Interval: 2 * time.Hour, NextRun: now.Add(2 * time.Hour)},
|
||||
}}
|
||||
|
||||
result := checkerUC.ListPlannedExecutions(provider, &fakeBudgetChecker{limited: true}, "c1", target)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 planned executions, got %d", len(result))
|
||||
}
|
||||
for i, exec := range result {
|
||||
if exec.Status != happydns.ExecutionRateLimited {
|
||||
t.Errorf("result[%d].Status = %v; want ExecutionRateLimited", i, exec.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_ListPlannedExecutions_MixedByInterval(t *testing.T) {
|
||||
// When throttling is interval-aware, ListPlannedExecutions should flag
|
||||
// short-interval jobs as rate-limited while leaving longer ones pending.
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
now := time.Now()
|
||||
provider := &fakePlannedProvider{jobs: []*checkerUC.SchedulerJob{
|
||||
{CheckerID: "c1", Target: target, Interval: time.Minute, NextRun: now.Add(time.Minute)},
|
||||
{CheckerID: "c1", Target: target, Interval: 6 * time.Hour, NextRun: now.Add(6 * time.Hour)},
|
||||
}}
|
||||
|
||||
// Throttle anything shorter than 4h (mirrors UserGater's cutoff).
|
||||
result := checkerUC.ListPlannedExecutions(provider, &fakeBudgetChecker{denyBelow: 4 * time.Hour}, "c1", target)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 planned executions, got %d", len(result))
|
||||
}
|
||||
if result[0].Status != happydns.ExecutionRateLimited {
|
||||
t.Errorf("result[0].Status = %v; want ExecutionRateLimited for 1-minute interval", result[0].Status)
|
||||
}
|
||||
if result[1].Status != happydns.ExecutionPending {
|
||||
t.Errorf("result[1].Status = %v; want ExecutionPending for 6-hour interval", result[1].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_ListPlannedExecutions_EmptyJobs(t *testing.T) {
|
||||
// Even when rate-limited, an empty provider should produce an empty,
|
||||
// non-nil result (matching the prior behaviour of always returning a
|
||||
// slice when provider is non-nil).
|
||||
provider := &fakePlannedProvider{jobs: nil}
|
||||
result := checkerUC.ListPlannedExecutions(provider, &fakeBudgetChecker{limited: true}, "c1", happydns.CheckTarget{})
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil (empty) slice, got nil")
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0 planned executions, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_ListPlannedExecutions_SnapshotsBudgetOnce(t *testing.T) {
|
||||
// RateLimiterFor must be invoked exactly once per call regardless of
|
||||
// how many planned jobs are returned — this is the whole point of the
|
||||
// closure-based snapshot API (one budget lookup amortised over N jobs).
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
now := time.Now()
|
||||
provider := &fakePlannedProvider{jobs: []*checkerUC.SchedulerJob{
|
||||
{CheckerID: "c1", Target: target, Interval: time.Minute, NextRun: now},
|
||||
{CheckerID: "c1", Target: target, Interval: time.Hour, NextRun: now},
|
||||
{CheckerID: "c1", Target: target, Interval: 6 * time.Hour, NextRun: now},
|
||||
{CheckerID: "c1", Target: target, Interval: 24 * time.Hour, NextRun: now},
|
||||
}}
|
||||
|
||||
bc := &fakeBudgetChecker{denyBelow: 4 * time.Hour}
|
||||
_ = checkerUC.ListPlannedExecutions(provider, bc, "c1", target)
|
||||
if bc.calls != 1 {
|
||||
t.Errorf("RateLimiterFor called %d times; want 1 (one snapshot per call)", bc.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetObservationsByExecution(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
// Create snapshot.
|
||||
snap := &happydns.ObservationSnapshot{
|
||||
Target: target,
|
||||
CollectedAt: time.Now(),
|
||||
}
|
||||
if err := ms.CreateSnapshot(snap); err != nil {
|
||||
t.Fatalf("CreateSnapshot() error: %v", err)
|
||||
}
|
||||
|
||||
// Create evaluation referencing the snapshot.
|
||||
eval := &happydns.CheckEvaluation{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
SnapshotID: snap.Id,
|
||||
}
|
||||
if err := ms.CreateEvaluation(eval); err != nil {
|
||||
t.Fatalf("CreateEvaluation() error: %v", err)
|
||||
}
|
||||
|
||||
// Create execution referencing the evaluation.
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
EvaluationID: &eval.Id,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
got, err := uc.GetObservationsByExecution(target, exec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetObservationsByExecution() error: %v", err)
|
||||
}
|
||||
if !got.Id.Equals(snap.Id) {
|
||||
t.Errorf("expected snapshot ID %s, got %s", snap.Id, got.Id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetObservationsByExecution_ScopeMismatch(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
uid2, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
|
||||
_, err := uc.GetObservationsByExecution(wrongScope, exec.Id)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when scope doesn't match")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Metrics extraction tests ---
|
||||
|
||||
func TestCheckStatusUsecase_ExtractMetricsFromExecution_NilEvaluation(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
EvaluationID: nil,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
metrics, err := uc.GetMetricsByExecution(target, exec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetricsByExecution() error: %v", err)
|
||||
}
|
||||
if len(metrics) != 0 {
|
||||
t.Errorf("expected empty metrics for nil evaluation, got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_ExtractMetricsFromExecution_NotDone(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionPending,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
metrics, err := uc.GetMetricsByExecution(target, exec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetricsByExecution() error: %v", err)
|
||||
}
|
||||
if len(metrics) != 0 {
|
||||
t.Errorf("expected empty metrics for pending execution, got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetMetricsByChecker_Empty(t *testing.T) {
|
||||
uc, _, _ := setupStatusUC(t)
|
||||
|
||||
target := happydns.CheckTarget{UserId: "nonexistent", DomainId: "d1"}
|
||||
|
||||
metrics, err := uc.GetMetricsByChecker("status_test_checker", target, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetricsByChecker() error: %v", err)
|
||||
}
|
||||
if len(metrics) != 0 {
|
||||
t.Errorf("expected empty metrics for checker with no executions, got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetMetricsByUser(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionDone,
|
||||
Result: happydns.CheckState{Status: happydns.StatusOK},
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
metrics, err := uc.GetMetricsByUser(uid, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetricsByUser() error: %v", err)
|
||||
}
|
||||
// Without observation providers registered in tests, metrics will be empty,
|
||||
// but the call must succeed without error.
|
||||
_ = metrics
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetMetricsByDomain(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionDone,
|
||||
Result: happydns.CheckState{Status: happydns.StatusOK},
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
metrics, err := uc.GetMetricsByDomain(did, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetricsByDomain() error: %v", err)
|
||||
}
|
||||
_ = metrics
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetMetricsByUser_LimitApplied(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionDone,
|
||||
Result: happydns.CheckState{Status: happydns.StatusOK},
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Call with limit=2; underlying list should be limited.
|
||||
metrics, err := uc.GetMetricsByUser(uid, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetricsByUser(limit=2) error: %v", err)
|
||||
}
|
||||
_ = metrics
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetSnapshotByExecution(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
// Create snapshot with observation data.
|
||||
snap := &happydns.ObservationSnapshot{
|
||||
Target: target,
|
||||
CollectedAt: time.Now(),
|
||||
Data: map[happydns.ObservationKey]json.RawMessage{
|
||||
"dns_records": json.RawMessage(`{"records":["A 1.2.3.4"]}`),
|
||||
},
|
||||
}
|
||||
if err := ms.CreateSnapshot(snap); err != nil {
|
||||
t.Fatalf("CreateSnapshot() error: %v", err)
|
||||
}
|
||||
|
||||
eval := &happydns.CheckEvaluation{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
SnapshotID: snap.Id,
|
||||
}
|
||||
if err := ms.CreateEvaluation(eval); err != nil {
|
||||
t.Fatalf("CreateEvaluation() error: %v", err)
|
||||
}
|
||||
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
EvaluationID: &eval.Id,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
raw, err := uc.GetSnapshotByExecution(target, exec.Id, "dns_records")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSnapshotByExecution() error: %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal observation data: %v", err)
|
||||
}
|
||||
if _, ok := parsed["records"]; !ok {
|
||||
t.Error("expected 'records' key in observation data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetSnapshotByExecution_KeyNotFound(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
snap := &happydns.ObservationSnapshot{
|
||||
Target: target,
|
||||
CollectedAt: time.Now(),
|
||||
Data: map[happydns.ObservationKey]json.RawMessage{},
|
||||
}
|
||||
if err := ms.CreateSnapshot(snap); err != nil {
|
||||
t.Fatalf("CreateSnapshot() error: %v", err)
|
||||
}
|
||||
|
||||
eval := &happydns.CheckEvaluation{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
SnapshotID: snap.Id,
|
||||
}
|
||||
if err := ms.CreateEvaluation(eval); err != nil {
|
||||
t.Fatalf("CreateEvaluation() error: %v", err)
|
||||
}
|
||||
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
EvaluationID: &eval.Id,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
_, err := uc.GetSnapshotByExecution(target, exec.Id, "nonexistent_key")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent observation key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusUsecase_GetSnapshotByExecution_ScopeMismatch(t *testing.T) {
|
||||
uc, _, ms := setupStatusUC(t)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
uid2, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: "status_test_checker",
|
||||
Target: target,
|
||||
Status: happydns.ExecutionDone,
|
||||
}
|
||||
if err := ms.CreateExecution(exec); err != nil {
|
||||
t.Fatalf("CreateExecution() error: %v", err)
|
||||
}
|
||||
|
||||
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
|
||||
_, err := uc.GetSnapshotByExecution(wrongScope, exec.Id, "any_key")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when scope doesn't match")
|
||||
}
|
||||
}
|
||||
241
internal/usecase/checker/checker_engine.go
Normal file
241
internal/usecase/checker/checker_engine.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// checkerEngine implements the happydns.CheckerEngine interface.
|
||||
type checkerEngine struct {
|
||||
optionsUC *CheckerOptionsUsecase
|
||||
evalStore CheckEvaluationStorage
|
||||
execStore ExecutionStorage
|
||||
snapStore ObservationSnapshotStorage
|
||||
cacheStore ObservationCacheStorage
|
||||
}
|
||||
|
||||
// NewCheckerEngine creates a new CheckerEngine implementation.
|
||||
func NewCheckerEngine(
|
||||
optionsUC *CheckerOptionsUsecase,
|
||||
evalStore CheckEvaluationStorage,
|
||||
execStore ExecutionStorage,
|
||||
snapStore ObservationSnapshotStorage,
|
||||
cacheStore ObservationCacheStorage,
|
||||
) happydns.CheckerEngine {
|
||||
return &checkerEngine{
|
||||
optionsUC: optionsUC,
|
||||
evalStore: evalStore,
|
||||
execStore: execStore,
|
||||
snapStore: snapStore,
|
||||
cacheStore: cacheStore,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateExecution validates the checker and creates a pending Execution record.
|
||||
func (e *checkerEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) {
|
||||
if checkerPkg.FindChecker(checkerID) == nil {
|
||||
return nil, fmt.Errorf("%w: %s", happydns.ErrCheckerNotFound, checkerID)
|
||||
}
|
||||
|
||||
// Determine trigger info.
|
||||
trigger := happydns.TriggerInfo{Type: happydns.TriggerManual}
|
||||
var planID *happydns.Identifier
|
||||
if plan != nil {
|
||||
planID = &plan.Id
|
||||
trigger.PlanID = planID
|
||||
trigger.Type = happydns.TriggerSchedule
|
||||
}
|
||||
|
||||
// Create execution record.
|
||||
exec := &happydns.Execution{
|
||||
CheckerID: checkerID,
|
||||
PlanID: planID,
|
||||
Target: target,
|
||||
Trigger: trigger,
|
||||
StartedAt: time.Now(),
|
||||
Status: happydns.ExecutionPending,
|
||||
}
|
||||
if err := e.execStore.CreateExecution(exec); err != nil {
|
||||
return nil, fmt.Errorf("creating execution: %w", err)
|
||||
}
|
||||
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
// RunExecution takes an existing execution and runs the checker pipeline.
|
||||
func (e *checkerEngine) RunExecution(ctx context.Context, exec *happydns.Execution, plan *happydns.CheckPlan, runOpts happydns.CheckerOptions) (*happydns.CheckEvaluation, error) {
|
||||
log.Printf("CheckerEngine: running checker %s on %s", exec.CheckerID, exec.Target.String())
|
||||
|
||||
def := checkerPkg.FindChecker(exec.CheckerID)
|
||||
if def == nil {
|
||||
endTime := time.Now()
|
||||
exec.Status = happydns.ExecutionFailed
|
||||
exec.EndedAt = &endTime
|
||||
exec.Error = fmt.Sprintf("checker not found: %s", exec.CheckerID)
|
||||
if err := e.execStore.UpdateExecution(exec); err != nil {
|
||||
log.Printf("CheckerEngine: failed to update execution: %v", err)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", happydns.ErrCheckerNotFound, exec.CheckerID)
|
||||
}
|
||||
|
||||
// Mark as running.
|
||||
exec.Status = happydns.ExecutionRunning
|
||||
if err := e.execStore.UpdateExecution(exec); err != nil {
|
||||
log.Printf("CheckerEngine: failed to update execution: %v", err)
|
||||
}
|
||||
|
||||
// Run the pipeline and handle failure.
|
||||
result, eval, err := e.runPipeline(ctx, def, exec.Target, plan, exec.PlanID, runOpts)
|
||||
if err != nil {
|
||||
log.Printf("CheckerEngine: checker %s on %s failed: %v", exec.CheckerID, exec.Target.String(), err)
|
||||
endTime := time.Now()
|
||||
exec.Status = happydns.ExecutionFailed
|
||||
exec.EndedAt = &endTime
|
||||
exec.Error = err.Error()
|
||||
if err := e.execStore.UpdateExecution(exec); err != nil {
|
||||
log.Printf("CheckerEngine: failed to update execution: %v", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Mark as done.
|
||||
endTime := time.Now()
|
||||
exec.Status = happydns.ExecutionDone
|
||||
exec.EndedAt = &endTime
|
||||
exec.Result = result
|
||||
exec.EvaluationID = &eval.Id
|
||||
if err := e.execStore.UpdateExecution(exec); err != nil {
|
||||
log.Printf("CheckerEngine: failed to update execution: %v", err)
|
||||
}
|
||||
|
||||
return eval, nil
|
||||
}
|
||||
|
||||
func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDefinition, target happydns.CheckTarget, plan *happydns.CheckPlan, planID *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckState, *happydns.CheckEvaluation, error) {
|
||||
// Resolve options (stored + run + auto-fill).
|
||||
mergedOpts, err := e.optionsUC.BuildMergedCheckerOptionsWithAutoFill(def.ID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), runOpts)
|
||||
if err != nil {
|
||||
return happydns.CheckState{}, nil, fmt.Errorf("resolving options: %w", err)
|
||||
}
|
||||
|
||||
// Build observation cache lookup for cross-checker reuse.
|
||||
var cacheLookup checkerPkg.ObservationCacheLookup
|
||||
if e.cacheStore != nil {
|
||||
cacheLookup = func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error) {
|
||||
entry, err := e.cacheStore.GetCachedObservation(target, key)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
snap, err := e.snapStore.GetSnapshot(entry.SnapshotID)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
raw, ok := snap.Data[key]
|
||||
if !ok {
|
||||
return nil, time.Time{}, fmt.Errorf("observation %q not in snapshot", key)
|
||||
}
|
||||
return raw, entry.CollectedAt, nil
|
||||
}
|
||||
}
|
||||
|
||||
var freshness time.Duration
|
||||
if plan != nil && plan.Interval != nil {
|
||||
freshness = *plan.Interval
|
||||
} else if plan != nil && def.Interval != nil {
|
||||
freshness = def.Interval.Default
|
||||
}
|
||||
|
||||
// Create observation context for lazy data collection.
|
||||
obsCtx := checkerPkg.NewObservationContext(target, mergedOpts, cacheLookup, freshness)
|
||||
|
||||
// If an endpoint is configured, override observation providers with HTTP transport.
|
||||
if endpoint, ok := mergedOpts["endpoint"].(string); ok && endpoint != "" {
|
||||
for _, key := range def.ObservationKeys {
|
||||
obsCtx.SetProviderOverride(key, checkerPkg.NewHTTPObservationProvider(key, endpoint))
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate all rules, skipping disabled ones.
|
||||
states := make([]happydns.CheckState, 0, len(def.Rules))
|
||||
for _, rule := range def.Rules {
|
||||
if plan != nil && !plan.IsRuleEnabled(rule.Name()) {
|
||||
continue
|
||||
}
|
||||
state := rule.Evaluate(ctx, obsCtx, mergedOpts)
|
||||
if state.Code == "" {
|
||||
state.Code = rule.Name()
|
||||
}
|
||||
states = append(states, state)
|
||||
}
|
||||
|
||||
// Aggregate results.
|
||||
aggregator := def.Aggregator
|
||||
if aggregator == nil {
|
||||
aggregator = checkerPkg.WorstStatusAggregator{}
|
||||
}
|
||||
result := aggregator.Aggregate(states)
|
||||
|
||||
// Persist observation snapshot.
|
||||
snap := &happydns.ObservationSnapshot{
|
||||
Target: target,
|
||||
CollectedAt: time.Now(),
|
||||
Data: obsCtx.Data(),
|
||||
}
|
||||
if err := e.snapStore.CreateSnapshot(snap); err != nil {
|
||||
return happydns.CheckState{}, nil, fmt.Errorf("creating snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Update observation cache pointers for cross-checker reuse.
|
||||
if e.cacheStore != nil {
|
||||
for key := range snap.Data {
|
||||
if err := e.cacheStore.PutCachedObservation(target, key, &happydns.ObservationCacheEntry{
|
||||
SnapshotID: snap.Id,
|
||||
CollectedAt: snap.CollectedAt,
|
||||
}); err != nil {
|
||||
log.Printf("warning: failed to cache observation %q for target %s: %v", key, target.String(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist evaluation.
|
||||
eval := &happydns.CheckEvaluation{
|
||||
PlanID: planID,
|
||||
CheckerID: def.ID,
|
||||
Target: target,
|
||||
SnapshotID: snap.Id,
|
||||
EvaluatedAt: time.Now(),
|
||||
States: states,
|
||||
}
|
||||
if err := e.evalStore.CreateEvaluation(eval); err != nil {
|
||||
return happydns.CheckState{}, nil, fmt.Errorf("creating evaluation: %w", err)
|
||||
}
|
||||
|
||||
return result, eval, nil
|
||||
}
|
||||
586
internal/usecase/checker/checker_engine_test.go
Normal file
586
internal/usecase/checker/checker_engine_test.go
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package checker_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// testObservationProvider returns static test data.
|
||||
type testObservationProvider struct{}
|
||||
|
||||
func (p *testObservationProvider) Key() happydns.ObservationKey {
|
||||
return "test_obs"
|
||||
}
|
||||
|
||||
func (p *testObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
return map[string]any{"value": 42}, nil
|
||||
}
|
||||
|
||||
// testCheckRule produces a state based on observations.
|
||||
type testCheckRule struct {
|
||||
name string
|
||||
status happydns.Status
|
||||
}
|
||||
|
||||
func (r *testCheckRule) Name() string { return r.name }
|
||||
func (r *testCheckRule) Description() string { return "test rule: " + r.name }
|
||||
|
||||
func (r *testCheckRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
|
||||
var data map[string]any
|
||||
if err := obs.Get(ctx, "test_obs", &data); err != nil {
|
||||
return happydns.CheckState{Status: happydns.StatusError, Message: err.Error()}
|
||||
}
|
||||
return happydns.CheckState{Status: r.status, Message: r.name + " passed", Code: r.name}
|
||||
}
|
||||
|
||||
func TestCheckerEngine_RunOK(t *testing.T) {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("Instantiate() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Register test provider and checker.
|
||||
checker.RegisterObservationProvider(&testObservationProvider{})
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "test_checker",
|
||||
Name: "Test Checker",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&testCheckRule{name: "rule_ok", status: happydns.StatusOK},
|
||||
},
|
||||
})
|
||||
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
exec, err := engine.CreateExecution("test_checker", target, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateExecution() returned error: %v", err)
|
||||
}
|
||||
|
||||
eval, err := engine.RunExecution(context.Background(), exec, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RunExecution() returned error: %v", err)
|
||||
}
|
||||
|
||||
if eval == nil {
|
||||
t.Fatal("RunExecution() returned nil evaluation")
|
||||
}
|
||||
|
||||
if exec.Result.Status != happydns.StatusOK {
|
||||
t.Errorf("expected status OK, got %s", exec.Result.Status)
|
||||
}
|
||||
|
||||
if len(eval.States) != 1 {
|
||||
t.Errorf("expected 1 state, got %d", len(eval.States))
|
||||
}
|
||||
|
||||
// Verify execution was persisted.
|
||||
execs, err := store.ListExecutionsByChecker("test_checker", target, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ListExecutionsByChecker() returned error: %v", err)
|
||||
}
|
||||
if len(execs) != 1 {
|
||||
t.Errorf("expected 1 execution, got %d", len(execs))
|
||||
}
|
||||
|
||||
// Verify the execution ended as Done.
|
||||
for _, ex := range execs {
|
||||
if ex.Status != happydns.ExecutionDone {
|
||||
t.Errorf("expected execution status Done, got %d", ex.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerEngine_RunWarn(t *testing.T) {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("Instantiate() returned error: %v", err)
|
||||
}
|
||||
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "test_checker_warn",
|
||||
Name: "Test Checker Warn",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&testCheckRule{name: "rule_ok", status: happydns.StatusOK},
|
||||
&testCheckRule{name: "rule_warn", status: happydns.StatusWarn},
|
||||
},
|
||||
})
|
||||
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
exec, err := engine.CreateExecution("test_checker_warn", target, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateExecution() returned error: %v", err)
|
||||
}
|
||||
eval, err := engine.RunExecution(context.Background(), exec, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RunExecution() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Worst status aggregation: WARN should win over OK.
|
||||
if exec.Result.Status != happydns.StatusWarn {
|
||||
t.Errorf("expected aggregated status WARN, got %s", exec.Result.Status)
|
||||
}
|
||||
|
||||
if len(eval.States) != 2 {
|
||||
t.Errorf("expected 2 states, got %d", len(eval.States))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerEngine_RunPerRuleDisable(t *testing.T) {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("Instantiate() returned error: %v", err)
|
||||
}
|
||||
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "test_checker_per_rule",
|
||||
Name: "Test Checker Per Rule",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&testCheckRule{name: "rule_a", status: happydns.StatusOK},
|
||||
&testCheckRule{name: "rule_b", status: happydns.StatusWarn},
|
||||
&testCheckRule{name: "rule_c", status: happydns.StatusCrit},
|
||||
},
|
||||
})
|
||||
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
// Disable rule_b and rule_c, only rule_a should run.
|
||||
plan := &happydns.CheckPlan{
|
||||
CheckerID: "test_checker_per_rule",
|
||||
Target: target,
|
||||
Enabled: map[string]bool{
|
||||
"rule_a": true,
|
||||
"rule_b": false,
|
||||
"rule_c": false,
|
||||
},
|
||||
}
|
||||
|
||||
exec, err := engine.CreateExecution("test_checker_per_rule", target, plan)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateExecution() returned error: %v", err)
|
||||
}
|
||||
eval, err := engine.RunExecution(context.Background(), exec, plan, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RunExecution() returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(eval.States) != 1 {
|
||||
t.Fatalf("expected 1 state (only rule_a), got %d", len(eval.States))
|
||||
}
|
||||
|
||||
if exec.Result.Status != happydns.StatusOK {
|
||||
t.Errorf("expected status OK (only rule_a active), got %s", exec.Result.Status)
|
||||
}
|
||||
|
||||
if eval.States[0].Code != "rule_a" {
|
||||
t.Errorf("expected rule_a state, got code %s", eval.States[0].Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPlan_IsFullyDisabled(t *testing.T) {
|
||||
// Nil map = not disabled.
|
||||
p := &happydns.CheckPlan{}
|
||||
if p.IsFullyDisabled() {
|
||||
t.Error("nil map should not be fully disabled")
|
||||
}
|
||||
|
||||
// All false = disabled.
|
||||
p.Enabled = map[string]bool{"a": false, "b": false}
|
||||
if !p.IsFullyDisabled() {
|
||||
t.Error("all-false map should be fully disabled")
|
||||
}
|
||||
|
||||
// Mixed = not disabled.
|
||||
p.Enabled = map[string]bool{"a": true, "b": false}
|
||||
if p.IsFullyDisabled() {
|
||||
t.Error("mixed map should not be fully disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPlan_IsRuleEnabled(t *testing.T) {
|
||||
// Nil map = all enabled.
|
||||
p := &happydns.CheckPlan{}
|
||||
if !p.IsRuleEnabled("any") {
|
||||
t.Error("nil map should enable all rules")
|
||||
}
|
||||
|
||||
// Missing key = enabled.
|
||||
p.Enabled = map[string]bool{"a": false}
|
||||
if !p.IsRuleEnabled("b") {
|
||||
t.Error("missing key should be enabled")
|
||||
}
|
||||
|
||||
// Explicit false = disabled.
|
||||
if p.IsRuleEnabled("a") {
|
||||
t.Error("explicit false should be disabled")
|
||||
}
|
||||
|
||||
// Explicit true = enabled.
|
||||
p.Enabled["c"] = true
|
||||
if !p.IsRuleEnabled("c") {
|
||||
t.Error("explicit true should be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerEngine_RunNotFound(t *testing.T) {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("Instantiate() returned error: %v", err)
|
||||
}
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String()}
|
||||
|
||||
_, err = engine.CreateExecution("nonexistent_checker", target, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent checker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerEngine_RunWithScheduledTrigger(t *testing.T) {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("Instantiate() returned error: %v", err)
|
||||
}
|
||||
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "test_checker_sched",
|
||||
Name: "Test Checker Scheduled",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&testCheckRule{name: "rule_sched", status: happydns.StatusOK},
|
||||
},
|
||||
})
|
||||
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
planID, _ := happydns.NewRandomIdentifier()
|
||||
plan := &happydns.CheckPlan{
|
||||
Id: planID,
|
||||
CheckerID: "test_checker_sched",
|
||||
Target: target,
|
||||
}
|
||||
|
||||
exec, err := engine.CreateExecution("test_checker_sched", target, plan)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateExecution() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the trigger is set to Schedule when plan is provided.
|
||||
if exec.Trigger.Type != happydns.TriggerSchedule {
|
||||
t.Errorf("expected TriggerSchedule, got %v", exec.Trigger.Type)
|
||||
}
|
||||
if exec.PlanID == nil || !exec.PlanID.Equals(planID) {
|
||||
t.Errorf("expected PlanID %s, got %v", planID, exec.PlanID)
|
||||
}
|
||||
|
||||
eval, err := engine.RunExecution(context.Background(), exec, plan, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RunExecution() returned error: %v", err)
|
||||
}
|
||||
if eval == nil {
|
||||
t.Fatal("expected non-nil evaluation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerEngine_RunExecution_CheckerDisappeared(t *testing.T) {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("Instantiate() returned error: %v", err)
|
||||
}
|
||||
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "test_checker_disappear",
|
||||
Name: "Test Checker Disappear",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&testCheckRule{name: "rule_d", status: happydns.StatusOK},
|
||||
},
|
||||
})
|
||||
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String()}
|
||||
|
||||
exec, err := engine.CreateExecution("test_checker_disappear", target, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateExecution() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Simulate the checker being unregistered between Create and Run
|
||||
// by using a fake checker ID on the execution.
|
||||
exec.CheckerID = "vanished_checker"
|
||||
|
||||
_, err = engine.RunExecution(context.Background(), exec, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when checker has disappeared")
|
||||
}
|
||||
|
||||
// The execution should be marked as failed.
|
||||
persisted, err := store.GetExecution(exec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetExecution() returned error: %v", err)
|
||||
}
|
||||
if persisted.Status != happydns.ExecutionFailed {
|
||||
t.Errorf("expected execution status Failed, got %d", persisted.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerEngine_RunPopulatesObservationCache(t *testing.T) {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("Instantiate() returned error: %v", err)
|
||||
}
|
||||
|
||||
checker.RegisterObservationProvider(&testObservationProvider{})
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: "test_checker_cache",
|
||||
Name: "Test Checker Cache",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
Rules: []happydns.CheckRule{
|
||||
&testCheckRule{name: "rule_cache", status: happydns.StatusOK},
|
||||
},
|
||||
})
|
||||
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
exec, err := engine.CreateExecution("test_checker_cache", target, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateExecution() returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err = engine.RunExecution(context.Background(), exec, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RunExecution() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Verify observation cache was populated for the "test_obs" key.
|
||||
entry, err := store.GetCachedObservation(target, "test_obs")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCachedObservation() returned error: %v", err)
|
||||
}
|
||||
if entry.SnapshotID.IsEmpty() {
|
||||
t.Error("expected non-empty snapshot ID in cache entry")
|
||||
}
|
||||
if entry.CollectedAt.IsZero() {
|
||||
t.Error("expected non-zero CollectedAt in cache entry")
|
||||
}
|
||||
|
||||
// Verify the cached snapshot actually exists and contains the data.
|
||||
snap, err := store.GetSnapshot(entry.SnapshotID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSnapshot() returned error: %v", err)
|
||||
}
|
||||
if _, ok := snap.Data["test_obs"]; !ok {
|
||||
t.Error("expected 'test_obs' key in snapshot data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerEngine_RunWithEndpointOverride(t *testing.T) {
|
||||
// Start a fake remote checker that responds to POST /collect.
|
||||
var gotRequest happydns.ExternalCollectRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/collect" {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
|
||||
Data: json.RawMessage(`{"value":99}`),
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("Instantiate() returned error: %v", err)
|
||||
}
|
||||
|
||||
const checkerID = "test_checker_endpoint"
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: checkerID,
|
||||
Name: "Test Checker Endpoint",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
ObservationKeys: []happydns.ObservationKey{"test_obs"},
|
||||
Rules: []happydns.CheckRule{
|
||||
&testCheckRule{name: "rule_endpoint", status: happydns.StatusOK},
|
||||
},
|
||||
})
|
||||
|
||||
// Store admin-level configuration with the endpoint pointing to our test server.
|
||||
if err := store.UpdateCheckerConfiguration(checkerID, nil, nil, nil, happydns.CheckerOptions{
|
||||
"endpoint": srv.URL,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateCheckerConfiguration() returned error: %v", err)
|
||||
}
|
||||
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
exec, err := engine.CreateExecution(checkerID, target, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateExecution() returned error: %v", err)
|
||||
}
|
||||
|
||||
eval, err := engine.RunExecution(context.Background(), exec, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RunExecution() returned error: %v", err)
|
||||
}
|
||||
|
||||
if eval == nil {
|
||||
t.Fatal("RunExecution() returned nil evaluation")
|
||||
}
|
||||
|
||||
// The engine should have delegated to the HTTP endpoint.
|
||||
if gotRequest.Key != "test_obs" {
|
||||
t.Errorf("remote received Key = %q, want %q", gotRequest.Key, "test_obs")
|
||||
}
|
||||
|
||||
if exec.Result.Status != happydns.StatusOK {
|
||||
t.Errorf("expected status OK, got %s", exec.Result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerEngine_RunWithEndpointOverride_RemoteFailure(t *testing.T) {
|
||||
// Start a remote checker that always returns an error.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
|
||||
Error: "remote collector is down",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("Instantiate() returned error: %v", err)
|
||||
}
|
||||
|
||||
const checkerID = "test_checker_endpoint_fail"
|
||||
checker.RegisterChecker(&happydns.CheckerDefinition{
|
||||
ID: checkerID,
|
||||
Name: "Test Checker Endpoint Fail",
|
||||
Availability: happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
ObservationKeys: []happydns.ObservationKey{"test_obs"},
|
||||
Rules: []happydns.CheckRule{
|
||||
&testCheckRule{name: "rule_endpoint_fail", status: happydns.StatusOK},
|
||||
},
|
||||
})
|
||||
|
||||
if err := store.UpdateCheckerConfiguration(checkerID, nil, nil, nil, happydns.CheckerOptions{
|
||||
"endpoint": srv.URL,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateCheckerConfiguration() returned error: %v", err)
|
||||
}
|
||||
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
exec, err := engine.CreateExecution(checkerID, target, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateExecution() returned error: %v", err)
|
||||
}
|
||||
|
||||
eval, err := engine.RunExecution(context.Background(), exec, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RunExecution() returned error: %v", err)
|
||||
}
|
||||
|
||||
// The rule should report an error state because observation collection failed.
|
||||
if exec.Result.Status != happydns.StatusError {
|
||||
t.Errorf("expected status Error, got %s", exec.Result.Status)
|
||||
}
|
||||
|
||||
if len(eval.States) != 1 {
|
||||
t.Fatalf("expected 1 state, got %d", len(eval.States))
|
||||
}
|
||||
}
|
||||
642
internal/usecase/checker/checker_options_usecase.go
Normal file
642
internal/usecase/checker/checker_options_usecase.go
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"sync"
|
||||
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/internal/forms"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// fieldMetaCache caches the result of computeFieldMeta per CheckerDefinition.
|
||||
// Checker definitions are immutable after init-time registration, so the cache
|
||||
// never needs invalidation.
|
||||
var fieldMetaCache sync.Map // *happydns.CheckerDefinition -> checkerFieldMeta
|
||||
|
||||
// isEmptyValue returns true if v is nil or an empty string.
|
||||
func isEmptyValue(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
if s, ok := v.(string); ok && s == "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// identifiersEqual returns true when both identifiers are nil or point to the same value.
|
||||
func identifiersEqual(a, b *happydns.Identifier) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return a.Equals(*b)
|
||||
}
|
||||
|
||||
// getScopedOptions returns options stored exactly at the requested scope level,
|
||||
// without merging parent scopes.
|
||||
func (u *CheckerOptionsUsecase) getScopedOptions(
|
||||
checkerName string,
|
||||
userId *happydns.Identifier,
|
||||
domainId *happydns.Identifier,
|
||||
serviceId *happydns.Identifier,
|
||||
) (happydns.CheckerOptions, error) {
|
||||
positionals, err := u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId)
|
||||
if err != nil {
|
||||
return make(happydns.CheckerOptions), err
|
||||
}
|
||||
for _, p := range positionals {
|
||||
if identifiersEqual(p.UserId, userId) && identifiersEqual(p.DomainId, domainId) && identifiersEqual(p.ServiceId, serviceId) {
|
||||
if p.Options != nil {
|
||||
return p.Options, nil
|
||||
}
|
||||
return make(happydns.CheckerOptions), nil
|
||||
}
|
||||
}
|
||||
return make(happydns.CheckerOptions), nil
|
||||
}
|
||||
|
||||
// CheckerOptionsUsecase handles the resolution and persistence of checker options.
|
||||
type CheckerOptionsUsecase struct {
|
||||
store CheckerOptionsStorage
|
||||
autoFillStore CheckAutoFillStorage
|
||||
}
|
||||
|
||||
// NewCheckerOptionsUsecase creates a new CheckerOptionsUsecase.
|
||||
func NewCheckerOptionsUsecase(store CheckerOptionsStorage, autoFillStore CheckAutoFillStorage) *CheckerOptionsUsecase {
|
||||
return &CheckerOptionsUsecase{store: store, autoFillStore: autoFillStore}
|
||||
}
|
||||
|
||||
// GetCheckerOptionsPositional returns the raw positional options from all scope levels,
|
||||
// ordered from least to most specific (admin < user < domain < service).
|
||||
func (u *CheckerOptionsUsecase) GetCheckerOptionsPositional(
|
||||
checkerName string,
|
||||
userId *happydns.Identifier,
|
||||
domainId *happydns.Identifier,
|
||||
serviceId *happydns.Identifier,
|
||||
) ([]*happydns.CheckerOptionsPositional, error) {
|
||||
return u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId)
|
||||
}
|
||||
|
||||
// GetAutoFillOptions resolves auto-fill values for a checker and target,
|
||||
// returning only the auto-filled key/value pairs.
|
||||
func (u *CheckerOptionsUsecase) GetAutoFillOptions(
|
||||
checkerName string,
|
||||
target happydns.CheckTarget,
|
||||
) (happydns.CheckerOptions, error) {
|
||||
result, err := u.resolveAutoFill(checkerName, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetCheckerOptions retrieves and merges options from all applicable levels
|
||||
// (admin < user < domain < service), returning the merged result.
|
||||
func (u *CheckerOptionsUsecase) GetCheckerOptions(
|
||||
checkerName string,
|
||||
userId *happydns.Identifier,
|
||||
domainId *happydns.Identifier,
|
||||
serviceId *happydns.Identifier,
|
||||
) (happydns.CheckerOptions, error) {
|
||||
positionals, err := u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine which fields are NoOverride.
|
||||
var noOverrideIds map[string]bool
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
noOverrideIds = computeFieldMeta(def).noOverrideIds
|
||||
}
|
||||
|
||||
merged := make(happydns.CheckerOptions)
|
||||
// positionals are returned in order of increasing specificity.
|
||||
for _, p := range positionals {
|
||||
for k, v := range p.Options {
|
||||
// If the key is NoOverride and already set by a less specific scope, skip it.
|
||||
if noOverrideIds[k] {
|
||||
if _, exists := merged[k]; exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// BuildMergedCheckerOptions merges stored options with runtime overrides.
|
||||
// RunOpts are applied last and win over all stored levels.
|
||||
func BuildMergedCheckerOptions(storedOpts happydns.CheckerOptions, runOpts happydns.CheckerOptions) happydns.CheckerOptions {
|
||||
result := make(happydns.CheckerOptions)
|
||||
maps.Copy(result, storedOpts)
|
||||
maps.Copy(result, runOpts)
|
||||
return result
|
||||
}
|
||||
|
||||
// SetCheckerOptions persists options at the given positional level (full replace).
|
||||
// Keys with nil or empty-string values are excluded from the stored map.
|
||||
// Auto-fill keys are also stripped since they are system-provided at runtime.
|
||||
func (u *CheckerOptionsUsecase) SetCheckerOptions(
|
||||
checkerName string,
|
||||
userId *happydns.Identifier,
|
||||
domainId *happydns.Identifier,
|
||||
serviceId *happydns.Identifier,
|
||||
opts happydns.CheckerOptions,
|
||||
) error {
|
||||
// Determine which field IDs are auto-filled or NoOverride for this checker.
|
||||
var autoFillIds map[string]string
|
||||
var noOverrideScopes map[string]happydns.CheckScopeType
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
meta := computeFieldMeta(def)
|
||||
autoFillIds = meta.autoFillIds
|
||||
noOverrideScopes = meta.noOverrideScopes
|
||||
}
|
||||
|
||||
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
|
||||
|
||||
filtered := make(happydns.CheckerOptions, len(opts))
|
||||
for k, v := range opts {
|
||||
if isEmptyValue(v) || autoFillIds[k] != "" {
|
||||
continue
|
||||
}
|
||||
// Defense-in-depth: strip NoOverride fields at scopes below their definition.
|
||||
if defScope, ok := noOverrideScopes[k]; ok && currentScope > defScope {
|
||||
continue
|
||||
}
|
||||
filtered[k] = v
|
||||
}
|
||||
return u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, filtered)
|
||||
}
|
||||
|
||||
// MergeCheckerOptions computes the result of merging newOpts into the existing
|
||||
// options at the given scope level WITHOUT persisting it. This allows callers to
|
||||
// validate the merged result before committing it to storage.
|
||||
// Keys with nil or empty-string values are removed from the merged map.
|
||||
func (u *CheckerOptionsUsecase) MergeCheckerOptions(
|
||||
checkerName string,
|
||||
userId *happydns.Identifier,
|
||||
domainId *happydns.Identifier,
|
||||
serviceId *happydns.Identifier,
|
||||
newOpts happydns.CheckerOptions,
|
||||
) (happydns.CheckerOptions, error) {
|
||||
existing, err := u.getScopedOptions(checkerName, userId, domainId, serviceId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine NoOverride scopes for defense-in-depth stripping.
|
||||
var noOverrideScopes map[string]happydns.CheckScopeType
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
noOverrideScopes = computeFieldMeta(def).noOverrideScopes
|
||||
}
|
||||
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
|
||||
|
||||
for k, v := range newOpts {
|
||||
// Defense-in-depth: skip NoOverride fields at scopes below their definition.
|
||||
if defScope, ok := noOverrideScopes[k]; ok && currentScope > defScope {
|
||||
continue
|
||||
}
|
||||
if isEmptyValue(v) {
|
||||
delete(existing, k)
|
||||
} else {
|
||||
existing[k] = v
|
||||
}
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// AddCheckerOptions merges new options into existing ones at the given scope level
|
||||
// and persists the result. Keys with nil or empty-string values are deleted from the
|
||||
// scope rather than stored.
|
||||
func (u *CheckerOptionsUsecase) AddCheckerOptions(
|
||||
checkerName string,
|
||||
userId *happydns.Identifier,
|
||||
domainId *happydns.Identifier,
|
||||
serviceId *happydns.Identifier,
|
||||
newOpts happydns.CheckerOptions,
|
||||
) (happydns.CheckerOptions, error) {
|
||||
merged, err := u.MergeCheckerOptions(checkerName, userId, domainId, serviceId, newOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// GetCheckerOption returns a single option value from the merged options.
|
||||
func (u *CheckerOptionsUsecase) GetCheckerOption(
|
||||
checkerName string,
|
||||
userId *happydns.Identifier,
|
||||
domainId *happydns.Identifier,
|
||||
serviceId *happydns.Identifier,
|
||||
optName string,
|
||||
) (any, error) {
|
||||
opts, err := u.GetCheckerOptions(checkerName, userId, domainId, serviceId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return opts[optName], nil
|
||||
}
|
||||
|
||||
// scopeFromIdentifiers determines the CheckScopeType based on which identifiers are set.
|
||||
func scopeFromIdentifiers(userId, domainId, serviceId *happydns.Identifier) happydns.CheckScopeType {
|
||||
if serviceId != nil {
|
||||
return happydns.CheckScopeService
|
||||
}
|
||||
if domainId != nil {
|
||||
return happydns.CheckScopeDomain
|
||||
}
|
||||
if userId != nil {
|
||||
return happydns.CheckScopeUser
|
||||
}
|
||||
return happydns.CheckScopeAdmin
|
||||
}
|
||||
|
||||
// collectFieldsForScope returns the fields from a CheckerOptionsDocumentation
|
||||
// that are valid at the given scope level. RunOpts are never included for
|
||||
// persisted scopes.
|
||||
func collectFieldsForScope(doc happydns.CheckerOptionsDocumentation, scope happydns.CheckScopeType) []happydns.CheckerOptionDocumentation {
|
||||
var fields []happydns.CheckerOptionDocumentation
|
||||
switch scope {
|
||||
case happydns.CheckScopeAdmin:
|
||||
fields = append(fields, doc.AdminOpts...)
|
||||
case happydns.CheckScopeUser:
|
||||
fields = append(fields, doc.UserOpts...)
|
||||
case happydns.CheckScopeDomain, happydns.CheckScopeZone:
|
||||
fields = append(fields, doc.DomainOpts...)
|
||||
case happydns.CheckScopeService:
|
||||
fields = append(fields, doc.ServiceOpts...)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// ValidateOptions validates checker options against the checker's field definitions
|
||||
// for the given scope level, and any OptionsValidator interface implemented by rules.
|
||||
// When withRunOpts is true, RunOpts fields are also included so that required run-time
|
||||
// options are enforced (used at trigger time). For persisted scopes, pass false.
|
||||
func (u *CheckerOptionsUsecase) ValidateOptions(
|
||||
checkerName string,
|
||||
userId *happydns.Identifier,
|
||||
domainId *happydns.Identifier,
|
||||
serviceId *happydns.Identifier,
|
||||
opts happydns.CheckerOptions,
|
||||
withRunOpts bool,
|
||||
) error {
|
||||
def := checkerPkg.FindChecker(checkerName)
|
||||
if def == nil {
|
||||
return fmt.Errorf("checker %q not found", checkerName)
|
||||
}
|
||||
|
||||
scope := scopeFromIdentifiers(userId, domainId, serviceId)
|
||||
|
||||
// Collect fields for this scope from the checker definition.
|
||||
// When withRunOpts is true (trigger time), also include all persisted-scope
|
||||
// fields so that options already stored at a different scope level (e.g.
|
||||
// admin-level options merged into the final opts map) are not rejected as
|
||||
// unknown.
|
||||
var allFields []happydns.CheckerOptionDocumentation
|
||||
if withRunOpts {
|
||||
allFields = append(allFields, def.Options.AdminOpts...)
|
||||
allFields = append(allFields, def.Options.UserOpts...)
|
||||
allFields = append(allFields, def.Options.DomainOpts...)
|
||||
allFields = append(allFields, def.Options.ServiceOpts...)
|
||||
allFields = append(allFields, def.Options.RunOpts...)
|
||||
} else {
|
||||
allFields = collectFieldsForScope(def.Options, scope)
|
||||
}
|
||||
|
||||
// Collect fields from rules that declare their own options at this scope.
|
||||
for _, rule := range def.Rules {
|
||||
if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok {
|
||||
ruleDoc := rwo.Options()
|
||||
if withRunOpts {
|
||||
allFields = append(allFields, ruleDoc.AdminOpts...)
|
||||
allFields = append(allFields, ruleDoc.UserOpts...)
|
||||
allFields = append(allFields, ruleDoc.DomainOpts...)
|
||||
allFields = append(allFields, ruleDoc.ServiceOpts...)
|
||||
allFields = append(allFields, ruleDoc.RunOpts...)
|
||||
} else {
|
||||
allFields = append(allFields, collectFieldsForScope(ruleDoc, scope)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out auto-fill fields: they are system-provided at runtime
|
||||
// and should not be validated against user input.
|
||||
autoFillIds := computeFieldMeta(def).autoFillIds
|
||||
var validatableFields []happydns.CheckerOptionDocumentation
|
||||
for _, f := range allFields {
|
||||
if _, isAutoFill := autoFillIds[f.Id]; !isAutoFill {
|
||||
validatableFields = append(validatableFields, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate against field definitions. ValidateMapValues lives in the
|
||||
// forms package and works with happydns.Field; CheckerOptionDocumentation
|
||||
// is structurally identical so an element-wise conversion is enough.
|
||||
if len(validatableFields) > 0 {
|
||||
asFields := make([]happydns.Field, len(validatableFields))
|
||||
for i, opt := range validatableFields {
|
||||
asFields[i] = happydns.FieldFromCheckerOption(opt)
|
||||
}
|
||||
if err := forms.ValidateMapValues(opts, asFields); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any rule implements OptionsValidator.
|
||||
for _, rule := range def.Rules {
|
||||
if v, ok := rule.(happydns.OptionsValidator); ok {
|
||||
if err := v.ValidateOptions(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCheckerOption sets a single option value at the given scope level.
|
||||
// If value is nil or empty string, the key is deleted from the scope.
|
||||
func (u *CheckerOptionsUsecase) SetCheckerOption(
|
||||
checkerName string,
|
||||
userId *happydns.Identifier,
|
||||
domainId *happydns.Identifier,
|
||||
serviceId *happydns.Identifier,
|
||||
optName string,
|
||||
value any,
|
||||
) error {
|
||||
// Defense-in-depth: reject NoOverride fields at scopes below their definition.
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
meta := computeFieldMeta(def)
|
||||
if defScope, ok := meta.noOverrideScopes[optName]; ok {
|
||||
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
|
||||
if currentScope > defScope {
|
||||
return fmt.Errorf("option %q cannot be overridden at this scope level", optName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := u.getScopedOptions(checkerName, userId, domainId, serviceId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isEmptyValue(value) {
|
||||
delete(existing, optName)
|
||||
} else {
|
||||
existing[optName] = value
|
||||
}
|
||||
return u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, existing)
|
||||
}
|
||||
|
||||
// checkerFieldMeta holds pre-computed field metadata for a checker definition,
|
||||
// avoiding repeated scans of the same option groups and rules.
|
||||
type checkerFieldMeta struct {
|
||||
autoFillIds map[string]string
|
||||
noOverrideIds map[string]bool
|
||||
noOverrideScopes map[string]happydns.CheckScopeType
|
||||
}
|
||||
|
||||
// computeFieldMeta returns cached field metadata for a checker definition.
|
||||
// The result is computed once per definition and cached for the process lifetime.
|
||||
func computeFieldMeta(def *happydns.CheckerDefinition) checkerFieldMeta {
|
||||
if cached, ok := fieldMetaCache.Load(def); ok {
|
||||
return cached.(checkerFieldMeta)
|
||||
}
|
||||
meta := buildFieldMeta(def)
|
||||
fieldMetaCache.Store(def, meta)
|
||||
return meta
|
||||
}
|
||||
|
||||
// buildFieldMeta scans all option groups and rules of a checker definition
|
||||
// and returns the consolidated field metadata.
|
||||
func buildFieldMeta(def *happydns.CheckerDefinition) checkerFieldMeta {
|
||||
meta := checkerFieldMeta{
|
||||
autoFillIds: make(map[string]string),
|
||||
noOverrideIds: make(map[string]bool),
|
||||
noOverrideScopes: make(map[string]happydns.CheckScopeType),
|
||||
}
|
||||
|
||||
scanDoc := func(doc happydns.CheckerOptionsDocumentation) {
|
||||
type scopedGroup struct {
|
||||
fields []happydns.CheckerOptionDocumentation
|
||||
scope happydns.CheckScopeType
|
||||
}
|
||||
groups := []scopedGroup{
|
||||
{doc.AdminOpts, happydns.CheckScopeAdmin},
|
||||
{doc.UserOpts, happydns.CheckScopeUser},
|
||||
{doc.DomainOpts, happydns.CheckScopeDomain},
|
||||
{doc.ServiceOpts, happydns.CheckScopeService},
|
||||
{doc.RunOpts, happydns.CheckScopeService}, // RunOpts have no distinct scope; use Service as ceiling.
|
||||
}
|
||||
for _, g := range groups {
|
||||
for _, f := range g.fields {
|
||||
if f.AutoFill != "" {
|
||||
meta.autoFillIds[f.Id] = f.AutoFill
|
||||
}
|
||||
if f.NoOverride {
|
||||
meta.noOverrideIds[f.Id] = true
|
||||
meta.noOverrideScopes[f.Id] = g.scope
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanDoc(def.Options)
|
||||
for _, rule := range def.Rules {
|
||||
if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok {
|
||||
scanDoc(rwo.Options())
|
||||
}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// buildAutoFillContext loads domain/zone data from storage and builds a map
|
||||
// of auto-fill key to resolved value.
|
||||
func (u *CheckerOptionsUsecase) buildAutoFillContext(
|
||||
target happydns.CheckTarget,
|
||||
) (map[string]any, error) {
|
||||
ctx := make(map[string]any)
|
||||
if u.autoFillStore == nil {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
domainId := happydns.TargetIdentifier(target.DomainId)
|
||||
if domainId == nil {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
domain, err := u.autoFillStore.GetDomain(*domainId)
|
||||
if err != nil {
|
||||
return ctx, fmt.Errorf("loading domain for auto-fill: %w", err)
|
||||
}
|
||||
|
||||
ctx[happydns.AutoFillDomainName] = domain.DomainName
|
||||
|
||||
// Load the WIP zone ([0]) for auto-fill context, so the user can
|
||||
// configure checkers for services they are currently working on.
|
||||
if len(domain.ZoneHistory) == 0 {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
zone, err := u.autoFillStore.GetZone(domain.ZoneHistory[0])
|
||||
if err != nil {
|
||||
return ctx, fmt.Errorf("loading zone for auto-fill: %w", err)
|
||||
}
|
||||
ctx[happydns.AutoFillZone] = zone
|
||||
|
||||
// Resolve service if target has a ServiceId.
|
||||
// Search WIP first, then latest published, then older history.
|
||||
if serviceId := happydns.TargetIdentifier(target.ServiceId); serviceId != nil {
|
||||
for i := 0; i < len(domain.ZoneHistory); i++ {
|
||||
z := zone
|
||||
if i > 0 {
|
||||
z, err = u.autoFillStore.GetZone(domain.ZoneHistory[i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for subdomain, services := range z.Services {
|
||||
for _, svc := range services {
|
||||
if svc.Id.Equals(*serviceId) {
|
||||
ctx[happydns.AutoFillSubdomain] = string(subdomain)
|
||||
ctx[happydns.AutoFillServiceType] = svc.Type
|
||||
ctx[happydns.AutoFillService] = svc
|
||||
return ctx, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// resolveAutoFill looks up the checker definition, scans its fields for AutoFill
|
||||
// attributes, builds the execution context from storage, and returns a map of
|
||||
// field ID to resolved value. Returns an empty map (not nil) when there is
|
||||
// nothing to fill.
|
||||
func (u *CheckerOptionsUsecase) resolveAutoFill(
|
||||
checkerName string,
|
||||
target happydns.CheckTarget,
|
||||
) (happydns.CheckerOptions, error) {
|
||||
def := checkerPkg.FindChecker(checkerName)
|
||||
if def == nil {
|
||||
return make(happydns.CheckerOptions), nil
|
||||
}
|
||||
|
||||
autoFillFields := computeFieldMeta(def).autoFillIds
|
||||
if len(autoFillFields) == 0 {
|
||||
return make(happydns.CheckerOptions), nil
|
||||
}
|
||||
|
||||
ctx, err := u.buildAutoFillContext(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(happydns.CheckerOptions, len(autoFillFields))
|
||||
for fieldId, autoFillKey := range autoFillFields {
|
||||
if val, ok := ctx[autoFillKey]; ok {
|
||||
result[fieldId] = val
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BuildMergedCheckerOptionsWithAutoFill merges stored options, runtime overrides,
|
||||
// and auto-fill values. Auto-fill values are applied last and always win.
|
||||
func (u *CheckerOptionsUsecase) BuildMergedCheckerOptionsWithAutoFill(
|
||||
checkerName string,
|
||||
userId *happydns.Identifier,
|
||||
domainId *happydns.Identifier,
|
||||
serviceId *happydns.Identifier,
|
||||
runOpts happydns.CheckerOptions,
|
||||
) (happydns.CheckerOptions, error) {
|
||||
positionals, err := u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
def := checkerPkg.FindChecker(checkerName)
|
||||
|
||||
// Merge stored options from least to most specific, respecting NoOverride.
|
||||
var meta checkerFieldMeta
|
||||
if def != nil {
|
||||
meta = computeFieldMeta(def)
|
||||
}
|
||||
|
||||
storedOpts := make(happydns.CheckerOptions)
|
||||
for _, p := range positionals {
|
||||
for k, v := range p.Options {
|
||||
if meta.noOverrideIds[k] {
|
||||
if _, exists := storedOpts[k]; exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
storedOpts[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Apply runtime overrides on top.
|
||||
merged := BuildMergedCheckerOptions(storedOpts, runOpts)
|
||||
|
||||
// Restore NoOverride fields from storedOpts so that runOpts cannot override them.
|
||||
for id := range meta.noOverrideIds {
|
||||
if v, ok := storedOpts[id]; ok {
|
||||
merged[id] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve auto-fill values (always win).
|
||||
if def != nil && len(meta.autoFillIds) > 0 {
|
||||
target := happydns.CheckTarget{
|
||||
UserId: happydns.FormatIdentifier(userId),
|
||||
DomainId: happydns.FormatIdentifier(domainId),
|
||||
ServiceId: happydns.FormatIdentifier(serviceId),
|
||||
}
|
||||
ctx, err := u.buildAutoFillContext(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for fieldId, autoFillKey := range meta.autoFillIds {
|
||||
if val, ok := ctx[autoFillKey]; ok {
|
||||
merged[fieldId] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
1654
internal/usecase/checker/checker_options_usecase_test.go
Normal file
1654
internal/usecase/checker/checker_options_usecase_test.go
Normal file
File diff suppressed because it is too large
Load diff
23
internal/usecase/checker/doc.go
Normal file
23
internal/usecase/checker/doc.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
// Package checker provides the usecase layer for the checker/monitoring system.
|
||||
package checker // import "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
241
internal/usecase/checker/janitor.go
Normal file
241
internal/usecase/checker/janitor.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// JanitorUserResolver resolves a user from a CheckTarget so the janitor can
|
||||
// honour per-user retention overrides stored in UserQuota.
|
||||
type JanitorUserResolver interface {
|
||||
GetUser(id happydns.Identifier) (*happydns.User, error)
|
||||
}
|
||||
|
||||
// Janitor periodically prunes old check executions and evaluations according
|
||||
// to the tiered RetentionPolicy. It is the long-tail enforcement counterpart
|
||||
// of the cheap hard cap applied at execution-creation time.
|
||||
type Janitor struct {
|
||||
planStore CheckPlanStorage
|
||||
execStore ExecutionStorage
|
||||
evalStore CheckEvaluationStorage
|
||||
snapStore ObservationSnapshotStorage
|
||||
userResolver JanitorUserResolver
|
||||
defaultPolicy RetentionPolicy
|
||||
interval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewJanitor builds a Janitor that runs every `interval`. The defaultPolicy
|
||||
// is applied to executions of users that did not customize their retention
|
||||
// horizon via UserQuota. evalStore and snapStore may be nil if evaluation
|
||||
// pruning is not desired.
|
||||
func NewJanitor(planStore CheckPlanStorage, execStore ExecutionStorage, evalStore CheckEvaluationStorage, snapStore ObservationSnapshotStorage, userResolver JanitorUserResolver, defaultPolicy RetentionPolicy, interval time.Duration) *Janitor {
|
||||
if interval <= 0 {
|
||||
interval = 6 * time.Hour
|
||||
}
|
||||
return &Janitor{
|
||||
planStore: planStore,
|
||||
execStore: execStore,
|
||||
evalStore: evalStore,
|
||||
snapStore: snapStore,
|
||||
userResolver: userResolver,
|
||||
defaultPolicy: defaultPolicy,
|
||||
interval: interval,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the janitor loop in a goroutine. It runs an immediate sweep
|
||||
// once the loop is up.
|
||||
func (j *Janitor) Start(ctx context.Context) {
|
||||
j.mu.Lock()
|
||||
if j.running {
|
||||
j.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
j.cancel = cancel
|
||||
j.done = make(chan struct{})
|
||||
j.running = true
|
||||
j.mu.Unlock()
|
||||
|
||||
go j.loop(ctx)
|
||||
}
|
||||
|
||||
// Stop halts the janitor and waits for the current sweep to finish.
|
||||
func (j *Janitor) Stop() {
|
||||
j.mu.Lock()
|
||||
cancel := j.cancel
|
||||
done := j.done
|
||||
j.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
j.mu.Lock()
|
||||
j.running = false
|
||||
j.mu.Unlock()
|
||||
}
|
||||
|
||||
func (j *Janitor) loop(ctx context.Context) {
|
||||
defer close(j.done)
|
||||
|
||||
// Run immediately, then on the configured interval.
|
||||
j.RunOnce(ctx)
|
||||
|
||||
ticker := time.NewTicker(j.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
j.RunOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunOnce performs a single sweep over all check plans, applying the per-user
|
||||
// retention policy to both executions and evaluations. Returns the total
|
||||
// number of records deleted (executions + evaluations).
|
||||
func (j *Janitor) RunOnce(ctx context.Context) int {
|
||||
iter, err := j.planStore.ListAllCheckPlans()
|
||||
if err != nil {
|
||||
log.Printf("Janitor: failed to list check plans: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
now := time.Now()
|
||||
deleted := 0
|
||||
|
||||
// Cache user policies to avoid resolving the same user repeatedly.
|
||||
policyByUser := map[string]RetentionPolicy{}
|
||||
|
||||
for iter.Next() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return deleted
|
||||
default:
|
||||
}
|
||||
|
||||
plan := iter.Item()
|
||||
if plan == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
policy := j.policyForTarget(plan.Target, policyByUser)
|
||||
hardCutoff := now.AddDate(0, 0, -policy.RetentionDays)
|
||||
|
||||
// Prune executions using the tiered retention policy.
|
||||
execs, err := j.execStore.ListExecutionsByPlan(plan.Id)
|
||||
if err != nil {
|
||||
log.Printf("Janitor: failed to list executions for plan %s: %v", plan.Id.String(), err)
|
||||
} else if len(execs) > 0 {
|
||||
// All executions share the same (CheckerID, Target) since they come
|
||||
// from a single plan, so Decide's internal grouping is a no-op here.
|
||||
_, drop := policy.Decide(execs, now)
|
||||
|
||||
for _, id := range drop {
|
||||
if err := j.execStore.DeleteExecution(id); err != nil {
|
||||
log.Printf("Janitor: failed to delete execution %s: %v", id.String(), err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
|
||||
// Prune evaluations older than the hard cutoff.
|
||||
if j.evalStore != nil {
|
||||
deleted += j.pruneEvaluations(plan.Id, hardCutoff)
|
||||
}
|
||||
}
|
||||
|
||||
if err := iter.Err(); err != nil {
|
||||
log.Printf("Janitor: iterator error while walking check plans: %v", err)
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
log.Printf("Janitor: pruned %d records", deleted)
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
// pruneEvaluations deletes evaluations for the given plan that are older than
|
||||
// the cutoff, along with their associated snapshots.
|
||||
func (j *Janitor) pruneEvaluations(planID happydns.Identifier, cutoff time.Time) int {
|
||||
evals, err := j.evalStore.ListEvaluationsByPlan(planID)
|
||||
if err != nil {
|
||||
log.Printf("Janitor: failed to list evaluations for plan %s: %v", planID.String(), err)
|
||||
return 0
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, eval := range evals {
|
||||
if eval.EvaluatedAt.Before(cutoff) {
|
||||
// Delete the associated snapshot first.
|
||||
if j.snapStore != nil && !eval.SnapshotID.IsEmpty() {
|
||||
if err := j.snapStore.DeleteSnapshot(eval.SnapshotID); err != nil {
|
||||
log.Printf("Janitor: failed to delete snapshot %s: %v", eval.SnapshotID.String(), err)
|
||||
}
|
||||
}
|
||||
if err := j.evalStore.DeleteEvaluation(eval.Id); err != nil {
|
||||
log.Printf("Janitor: failed to delete evaluation %s: %v", eval.Id.String(), err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
func (j *Janitor) policyForTarget(target happydns.CheckTarget, cache map[string]RetentionPolicy) RetentionPolicy {
|
||||
uid := target.UserId
|
||||
if uid == "" || j.userResolver == nil {
|
||||
return j.defaultPolicy
|
||||
}
|
||||
if p, ok := cache[uid]; ok {
|
||||
return p
|
||||
}
|
||||
policy := j.defaultPolicy
|
||||
id, err := happydns.NewIdentifierFromString(uid)
|
||||
if err == nil {
|
||||
if user, err := j.userResolver.GetUser(id); err == nil && user != nil {
|
||||
if user.Quota.RetentionDays > 0 {
|
||||
policy = DefaultRetentionPolicy(user.Quota.RetentionDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
cache[uid] = policy
|
||||
return policy
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue