From 8160adcdca88344ee2afc67f40f4a8d404ae25cb Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 7 Apr 2026 20:37:44 +0700 Subject: [PATCH] Initial commit --- LICENSE | 202 ++++++++++++++++++++++++++ NOTICE | 10 ++ README.md | 35 +++++ checker/options.go | 91 ++++++++++++ checker/registry.go | 78 ++++++++++ checker/server.go | 224 ++++++++++++++++++++++++++++ checker/types.go | 347 ++++++++++++++++++++++++++++++++++++++++++++ go.mod | 3 + 8 files changed, 990 insertions(+) create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 README.md create mode 100644 checker/options.go create mode 100644 checker/registry.go create mode 100644 checker/server.go create mode 100644 checker/types.go create mode 100644 go.mod diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c16b505 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020-2026 The happyDomain Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..8dcfcf2 --- /dev/null +++ b/NOTICE @@ -0,0 +1,10 @@ +checker-sdk-go +Copyright 2020-2026 The happyDomain Authors + +This product includes software developed as part of the happyDomain project +(https://happydomain.org). + +Portions of this code were originally written for the happyDomain server +(licensed under AGPL-3.0 and a commercial license) and are made available +here under the Apache License, Version 2.0 to enable a permissively licensed +ecosystem of checker plugins. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed5f3f6 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# checker-sdk-go + +Public Go SDK for writing [happyDomain](https://happydomain.org) checker plugins. + +This module provides the stable types, helpers, and HTTP server scaffolding +that all checker plugins need, independent of the happyDomain server itself. + +## Why a separate module? + +The happyDomain server is licensed under AGPL-3.0 (with a commercial option). +If checkers had to import the server's internal packages, every checker, even +trivial ones, would inherit those licensing constraints. + +This SDK is released under the **Apache License 2.0**, so: + +- You can write a checker plugin under any license you want (MIT, Apache, + proprietary, AGPL, whatever fits your needs). +- A plugin built against this SDK is *not* a derivative work of happyDomain. +- happyDomain itself depends on this SDK (as an Apache-licensed dependency, + which is compatible with AGPL). + +## Installation + +```bash +go get git.happydns.org/checker-sdk-go/checker +``` + +## Getting started + +See [checker-dummy](https://git.happydns.org/checker-dummy) for a +fully working, documented template. + +## License + +Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/checker/options.go b/checker/options.go new file mode 100644 index 0000000..9b6f1ed --- /dev/null +++ b/checker/options.go @@ -0,0 +1,91 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checker + +import ( + "encoding/json" +) + +// GetOption extracts a typed value from checker options, handling both +// native Go types (in-process providers) and map[string]any values +// (from JSON round-tripping through HTTP providers). Returns the zero +// value and false if the key is missing or the value cannot be converted. +func GetOption[T any](options CheckerOptions, key string) (T, bool) { + v, ok := options[key] + if !ok { + var zero T + return zero, false + } + + // Direct type assertion (in-process path). + if t, ok := v.(T); ok { + return t, true + } + + // JSON round-trip for values deserialized as map[string]any over HTTP. + raw, err := json.Marshal(v) + if err != nil { + var zero T + return zero, false + } + var t T + if err := json.Unmarshal(raw, &t); err != nil { + var zero T + return zero, false + } + return t, true +} + +// GetFloatOption extracts a float64 from checker options, handling both +// native float64 values and json.Number. Returns defaultVal if the key +// is missing or the value cannot be converted. +func GetFloatOption(options CheckerOptions, key string, defaultVal float64) float64 { + v, ok := options[key] + if !ok { + return defaultVal + } + switch val := v.(type) { + case float64: + return val + case json.Number: + f, err := val.Float64() + if err != nil { + return defaultVal + } + return f + default: + return defaultVal + } +} + +// GetIntOption extracts an int from checker options, using GetFloatOption +// internally. Returns defaultVal if the key is missing or invalid. +func GetIntOption(options CheckerOptions, key string, defaultVal int) int { + return int(GetFloatOption(options, key, float64(defaultVal))) +} + +// GetBoolOption extracts a bool from checker options. +// Returns defaultVal if the key is missing or the value is not a bool. +func GetBoolOption(options CheckerOptions, key string, defaultVal bool) bool { + v, ok := options[key] + if !ok { + return defaultVal + } + b, ok := v.(bool) + if !ok { + return defaultVal + } + return b +} diff --git a/checker/registry.go b/checker/registry.go new file mode 100644 index 0000000..03f11c9 --- /dev/null +++ b/checker/registry.go @@ -0,0 +1,78 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checker + +import ( + "log" +) + +// checkerRegistry is the global registry for checker definitions. +// Thread-safety: all writes happen during init() before any goroutines start. +// After initialization, the map is read-only and safe for concurrent access. +var checkerRegistry = map[string]*CheckerDefinition{} + +// observationProviderRegistry is the global registry for observation providers, +// keyed by ObservationKey. +var observationProviderRegistry = map[ObservationKey]ObservationProvider{} + +// RegisterChecker registers a checker definition globally. +func RegisterChecker(c *CheckerDefinition) { + log.Println("Registering new checker:", c.ID) + c.BuildRulesInfo() + checkerRegistry[c.ID] = 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 *CheckerDefinition) { + c.Options.AdminOpts = append(c.Options.AdminOpts, + CheckerOptionDocumentation{ + Id: "endpoint", + Type: "string", + Label: "Remote checker endpoint URL", + Description: "If set, delegate observation collection to this HTTP endpoint instead of running locally.", + Placeholder: "http://checker-" + c.ID + ":8080", + NoOverride: true, + }, + ) + RegisterChecker(c) +} + +// RegisterObservationProvider registers an observation provider globally. +func RegisterObservationProvider(p ObservationProvider) { + observationProviderRegistry[p.Key()] = p +} + +// GetCheckers returns all registered checker definitions. +func GetCheckers() map[string]*CheckerDefinition { + return checkerRegistry +} + +// FindChecker returns the checker definition with the given ID, or nil. +func FindChecker(id string) *CheckerDefinition { + return checkerRegistry[id] +} + +// GetObservationProviders returns all registered observation providers. +func GetObservationProviders() map[ObservationKey]ObservationProvider { + return observationProviderRegistry +} + +// FindObservationProvider returns the observation provider for the given key, or nil. +func FindObservationProvider(key ObservationKey) ObservationProvider { + return observationProviderRegistry[key] +} diff --git a/checker/server.go b/checker/server.go new file mode 100644 index 0000000..35fa80d --- /dev/null +++ b/checker/server.go @@ -0,0 +1,224 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checker + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "time" +) + +// Server is a generic HTTP server for external checkers. +// It always exposes /health and /collect. If the provider implements +// CheckerDefinitionProvider, it also exposes /definition and /evaluate. +// If the provider implements CheckerHTMLReporter or CheckerMetricsReporter, +// it also exposes /report. +type Server struct { + provider ObservationProvider + definition *CheckerDefinition + mux *http.ServeMux +} + +// NewServer creates a new checker HTTP server backed by the given provider. +// Additional endpoints are registered based on optional interfaces the provider implements. +func NewServer(provider ObservationProvider) *Server { + s := &Server{provider: provider} + s.mux = http.NewServeMux() + s.mux.HandleFunc("GET /health", s.handleHealth) + s.mux.HandleFunc("POST /collect", s.handleCollect) + + if dp, ok := provider.(CheckerDefinitionProvider); ok { + s.definition = dp.Definition() + s.definition.BuildRulesInfo() + s.mux.HandleFunc("GET /definition", s.handleDefinition) + s.mux.HandleFunc("POST /evaluate", s.handleEvaluate) + } + + if _, ok := provider.(CheckerHTMLReporter); ok { + s.mux.HandleFunc("POST /report", s.handleReport) + } else if _, ok := provider.(CheckerMetricsReporter); ok { + s.mux.HandleFunc("POST /report", s.handleReport) + } + + return s +} + +// Handler returns the http.Handler for this server, allowing callers +// to embed it in a custom server or add middleware. +func (s *Server) Handler() http.Handler { + return requestLogger(s.mux) +} + +// ListenAndServe starts the HTTP server on the given address. +func (s *Server) ListenAndServe(addr string) error { + log.Printf("checker listening on %s", addr) + return http.ListenAndServe(addr, requestLogger(s.mux)) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +func requestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.status, time.Since(start)) + }) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (s *Server) handleDefinition(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.definition) +} + +func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) { + var req ExternalCollectRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, ExternalCollectResponse{ + Error: fmt.Sprintf("invalid request body: %v", err), + }) + return + } + + data, err := s.provider.Collect(r.Context(), req.Options) + if err != nil { + writeJSON(w, http.StatusOK, ExternalCollectResponse{ + Error: err.Error(), + }) + return + } + + raw, err := json.Marshal(data) + if err != nil { + writeJSON(w, http.StatusOK, ExternalCollectResponse{ + Error: fmt.Sprintf("failed to marshal result: %v", err), + }) + return + } + + writeJSON(w, http.StatusOK, ExternalCollectResponse{ + Data: json.RawMessage(raw), + }) +} + +func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) { + var req ExternalEvaluateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, ExternalEvaluateResponse{ + Error: fmt.Sprintf("invalid request body: %v", err), + }) + return + } + + obs := &mapObservationGetter{data: req.Observations} + + var states []CheckState + for _, rule := range s.definition.Rules { + if len(req.EnabledRules) > 0 { + if enabled, ok := req.EnabledRules[rule.Name()]; ok && !enabled { + continue + } + } + state := rule.Evaluate(r.Context(), obs, req.Options) + if state.Code == "" { + state.Code = rule.Name() + } + states = append(states, state) + } + + writeJSON(w, http.StatusOK, ExternalEvaluateResponse{States: states}) +} + +func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { + var req ExternalReportRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": fmt.Sprintf("invalid request body: %v", err), + }) + return + } + + accept := r.Header.Get("Accept") + + if strings.Contains(accept, "text/html") { + reporter, ok := s.provider.(CheckerHTMLReporter) + if !ok { + http.Error(w, "this checker does not support HTML reports", http.StatusNotImplemented) + return + } + + html, err := reporter.GetHTMLReport(req.Data) + if err != nil { + http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(html)) + return + } + + // Default: JSON metrics. + reporter, ok := s.provider.(CheckerMetricsReporter) + if !ok { + http.Error(w, "this checker does not support metrics reports", http.StatusNotImplemented) + return + } + + metrics, err := reporter.ExtractMetrics(req.Data, time.Now()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": fmt.Sprintf("failed to extract metrics: %v", err), + }) + return + } + + writeJSON(w, http.StatusOK, metrics) +} + +// mapObservationGetter implements ObservationGetter backed by a static map. +type mapObservationGetter struct { + data map[ObservationKey]json.RawMessage +} + +func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest any) error { + raw, ok := g.data[key] + if !ok { + return fmt.Errorf("observation %q not available", key) + } + return json.Unmarshal(raw, dest) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..763b897 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,347 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package checker provides the public types and helpers for writing +// happyDomain checker plugins. It is the stable API surface that all +// external checkers should depend on. +package checker + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +// CheckScopeType represents the scope level of a check target. +type CheckScopeType int + +const ( + CheckScopeAdmin CheckScopeType = 0 + CheckScopeUser CheckScopeType = iota + CheckScopeDomain + CheckScopeZone + CheckScopeService +) + +const ( + AutoFillDomainName = "domain_name" + AutoFillSubdomain = "subdomain" + AutoFillZone = "zone" + AutoFillServiceType = "service_type" + AutoFillService = "service" +) + +// CheckTarget identifies the resource a check applies to. Identifiers are +// passed as opaque strings so the SDK stays self-contained and does not +// depend on any happyDomain-specific identifier type. The host is free to +// parse them into its own representation at the boundary. +type CheckTarget struct { + UserId string `json:"userId,omitempty"` + DomainId string `json:"domainId,omitempty"` + ServiceId string `json:"serviceId,omitempty"` + ServiceType string `json:"serviceType,omitempty"` +} + +// Scope returns the most specific scope level of this target. +func (t CheckTarget) Scope() CheckScopeType { + if t.ServiceId != "" { + return CheckScopeService + } + if t.DomainId != "" { + return CheckScopeDomain + } + return CheckScopeUser +} + +// String returns a stable string representation of the target. +func (t CheckTarget) String() string { + var parts []string + if t.UserId != "" { + parts = append(parts, t.UserId) + } + if t.DomainId != "" { + parts = append(parts, t.DomainId) + } + if t.ServiceId != "" { + parts = append(parts, t.ServiceId) + } + return strings.Join(parts, "/") +} + +// CheckerAvailability declares on which scopes a checker can operate. +type CheckerAvailability struct { + ApplyToDomain bool `json:"applyToDomain,omitempty"` + ApplyToZone bool `json:"applyToZone,omitempty"` + ApplyToService bool `json:"applyToService,omitempty"` + LimitToProviders []string `json:"limitToProviders,omitempty"` + LimitToServices []string `json:"limitToServices,omitempty"` +} + +// CheckerOptions holds the runtime options for a checker execution. +type CheckerOptions map[string]any + +// CheckerOptionField describes a single checker option, used to document +// what configuration the checker accepts. The fields mirror happyDomain's +// generic Field type so that the host can re-export it as a type alias and +// keep using its existing form-rendering code unchanged. +type CheckerOptionField struct { + // Id is the option identifier (the key in CheckerOptions). + Id string `json:"id" binding:"required"` + + // Type is the string representation of the option's type + // (e.g. "string", "number", "uint", "bool"). + Type string `json:"type" binding:"required"` + + // Label is the title shown to the user. + Label string `json:"label,omitempty"` + + // Placeholder is the placeholder shown in the input. + Placeholder string `json:"placeholder,omitempty"` + + // Default is the value used when the option is not set by the user. + Default any `json:"default,omitempty"` + + // Choices holds the available choices for a dropdown option. + Choices []string `json:"choices,omitempty"` + + // Required indicates whether the option must be filled. + Required bool `json:"required,omitempty"` + + // Secret indicates that the option holds sensitive information + // (API keys, tokens, …). + Secret bool `json:"secret,omitempty"` + + // Hide indicates that the option should be hidden from the user. + Hide bool `json:"hide,omitempty"` + + // Textarea indicates that a multi-line input should be used. + Textarea bool `json:"textarea,omitempty"` + + // Description is a help sentence describing the option. + Description string `json:"description,omitempty"` + + // AutoFill indicates that this option is automatically populated by the + // host based on execution context (e.g. domain name, service payload). + AutoFill string `json:"autoFill,omitempty"` + + // NoOverride indicates that once this option is set at a given scope, + // more specific scopes cannot override its value. + NoOverride bool `json:"noOverride,omitempty"` +} + +// CheckerOptionDocumentation describes a single checker option. +type CheckerOptionDocumentation = CheckerOptionField + +// CheckerOptionsDocumentation describes all options a checker accepts, organized by level. +type CheckerOptionsDocumentation struct { + AdminOpts []CheckerOptionDocumentation `json:"adminOpts,omitempty"` + UserOpts []CheckerOptionDocumentation `json:"userOpts,omitempty"` + DomainOpts []CheckerOptionDocumentation `json:"domainOpts,omitempty"` + ServiceOpts []CheckerOptionDocumentation `json:"serviceOpts,omitempty"` + RunOpts []CheckerOptionDocumentation `json:"runOpts,omitempty"` +} + +// Status represents the result status of a check evaluation. +type Status int + +const ( + StatusUnknown Status = iota + StatusOK + StatusInfo + StatusWarn + StatusCrit + StatusError +) + +// String returns the human-readable name of the status. +func (s Status) String() string { + switch s { + case StatusUnknown: + return "UNKNOWN" + case StatusOK: + return "OK" + case StatusInfo: + return "INFO" + case StatusWarn: + return "WARN" + case StatusCrit: + return "CRIT" + case StatusError: + return "ERROR" + default: + return fmt.Sprintf("Status(%d)", int(s)) + } +} + +// CheckState is the result of evaluating a single rule. +type CheckState struct { + Status Status `json:"status"` + Message string `json:"message"` + Code string `json:"code,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +// CheckMetric represents a single metric produced by a check. +type CheckMetric struct { + Name string `json:"name" binding:"required"` + Value float64 `json:"value" binding:"required"` + Unit string `json:"unit,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Timestamp time.Time `json:"timestamp" binding:"required" format:"date-time"` +} + +// ObservationKey identifies a type of observation data. +type ObservationKey = string + +// CheckIntervalSpec defines scheduling bounds for a checker. +type CheckIntervalSpec struct { + Min time.Duration `json:"min" swaggertype:"integer"` + Max time.Duration `json:"max" swaggertype:"integer"` + Default time.Duration `json:"default" swaggertype:"integer"` +} + +// ObservationProvider collects a specific type of data for a target. +type ObservationProvider interface { + Key() ObservationKey + Collect(ctx context.Context, opts CheckerOptions) (any, error) +} + +// CheckRuleInfo is the JSON-serializable description of a rule, for API/UI listing. +type CheckRuleInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Options *CheckerOptionsDocumentation `json:"options,omitempty"` +} + +// CheckRule evaluates observations and produces a CheckState. +type CheckRule interface { + Name() string + Description() string + Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) CheckState +} + +// CheckRuleWithOptions is an optional interface that rules can implement +// to declare their own options documentation for API/UI grouping. +type CheckRuleWithOptions interface { + CheckRule + Options() CheckerOptionsDocumentation +} + +// ObservationGetter provides access to observation data (used by CheckRule). +// Get unmarshals observation data into dest (like json.Unmarshal). +type ObservationGetter interface { + Get(ctx context.Context, key ObservationKey, dest any) error +} + +// CheckAggregator combines multiple CheckStates into a single result. +type CheckAggregator interface { + Aggregate(states []CheckState) CheckState +} + +// CheckerHTMLReporter is an optional interface that observation providers can +// implement to render their stored data as a full HTML document (for iframe embedding). +// Detect support with a type assertion: _, ok := provider.(CheckerHTMLReporter) +type CheckerHTMLReporter interface { + // GetHTMLReport generates an HTML document from the JSON-encoded observation data. + GetHTMLReport(raw json.RawMessage) (string, error) +} + +// CheckerMetricsReporter is an optional interface that observation providers can +// implement to extract time-series metrics from their stored data. +// Detect support with a type assertion: _, ok := provider.(CheckerMetricsReporter) +type CheckerMetricsReporter interface { + // ExtractMetrics returns metrics from JSON-encoded observation data. + ExtractMetrics(raw json.RawMessage, collectedAt time.Time) ([]CheckMetric, error) +} + +// CheckerDefinitionProvider is an optional interface that observation providers can +// implement to expose their checker definition. Used by the SDK server to serve +// /definition and /evaluate endpoints without requiring a separate argument. +// Detect support with a type assertion: _, ok := provider.(CheckerDefinitionProvider) +type CheckerDefinitionProvider interface { + // Definition returns the checker definition for this provider. + Definition() *CheckerDefinition +} + +// CheckerDefinition is the complete definition of a checker, registered via init(). +type CheckerDefinition struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + Availability CheckerAvailability `json:"availability"` + Options CheckerOptionsDocumentation `json:"options"` + RulesInfo []CheckRuleInfo `json:"rules"` + Rules []CheckRule `json:"-"` + Aggregator CheckAggregator `json:"-"` + Interval *CheckIntervalSpec `json:"interval,omitempty"` + HasHTMLReport bool `json:"has_html_report,omitempty"` + HasMetrics bool `json:"has_metrics,omitempty"` + ObservationKeys []ObservationKey `json:"observationKeys,omitempty"` +} + +// BuildRulesInfo populates RulesInfo from the Rules slice. +func (d *CheckerDefinition) BuildRulesInfo() { + d.RulesInfo = make([]CheckRuleInfo, len(d.Rules)) + for i, rule := range d.Rules { + info := CheckRuleInfo{ + Name: rule.Name(), + Description: rule.Description(), + } + if rwo, ok := rule.(CheckRuleWithOptions); ok { + opts := rwo.Options() + info.Options = &opts + } + d.RulesInfo[i] = info + } +} + +// OptionsValidator is an optional interface that checkers (or their rules/providers) +// can implement to perform domain-specific validation of checker options. +type OptionsValidator interface { + ValidateOptions(opts CheckerOptions) error +} + +// ExternalCollectRequest is sent to POST /collect on a remote checker endpoint. +type ExternalCollectRequest struct { + Key ObservationKey `json:"key"` + Target CheckTarget `json:"target"` + Options CheckerOptions `json:"options"` +} + +// ExternalCollectResponse is returned by POST /collect on a remote checker endpoint. +type ExternalCollectResponse struct { + Data json.RawMessage `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// ExternalEvaluateRequest is sent to POST /evaluate on a remote checker endpoint. +type ExternalEvaluateRequest struct { + Observations map[ObservationKey]json.RawMessage `json:"observations"` + Options CheckerOptions `json:"options"` + EnabledRules map[string]bool `json:"enabledRules,omitempty"` +} + +// ExternalEvaluateResponse is returned by POST /evaluate on a remote checker endpoint. +type ExternalEvaluateResponse struct { + States []CheckState `json:"states"` + Error string `json:"error,omitempty"` +} + +// ExternalReportRequest is sent to POST /report on a remote checker endpoint. +type ExternalReportRequest struct { + Key ObservationKey `json:"key"` + Data json.RawMessage `json:"data"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..560bb3d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.happydns.org/checker-sdk-go + +go 1.25.0