New namespaced CRD KumaStatusPage (uptime.renner.uno/v1alpha1) and a reconciler that owns the Kuma status page completely: config, domain names and the full group/monitor list. Sections select KumaMonitors in any namespace by label; monitors are ordered by displayName, then namespace/name, a monitor goes to the first matching section only, and monitors without a Kuma ID are left out and counted in status. The reconciler adopts an existing page with the slug, writes only when the managed content differs (no write per resync), reuses Kuma group IDs by position so saves do not churn them, reverts UI edits on resync, deletes the page on resource deletion, and reports Ready=False/Conflict for a younger resource claiming the same slug. Every KumaMonitor change to labels, displayName, monitor ID or deletion enqueues all pages. kumaclient gains a StatusPages interface (Breml + Fake). The socket API returns no group list, so groups are read from Kuma's public GET /api/status-page/<slug>, as the bootstrap did. Fields kuma-sync does not manage (logo, custom CSS, analytics, RSS title, auto refresh interval) are written back unchanged. autoRefreshInterval is not in the spec: go-uptime-kuma-client v0.6.2 (latest) never sends it on save. The Ansible bootstrap no longer creates or edits status pages; the existing page is left in place for kuma-sync to adopt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
630 lines
18 KiB
Go
630 lines
18 KiB
Go
package kumaclient
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
kuma "github.com/breml/go-uptime-kuma-client"
|
|
"github.com/breml/go-uptime-kuma-client/monitor"
|
|
"github.com/breml/go-uptime-kuma-client/notification"
|
|
"github.com/breml/go-uptime-kuma-client/tag"
|
|
)
|
|
|
|
const (
|
|
ownerTagName = "kuma-sync"
|
|
ownerTagColor = "#2563eb"
|
|
|
|
operationTimeout = 30 * time.Second
|
|
probeTimeout = 10 * time.Second
|
|
connectTimeout = 30 * time.Second
|
|
reconnectAttempts = 6
|
|
reconnectMaxDelay = 8 * time.Second
|
|
)
|
|
|
|
// Breml is a Client backed by github.com/breml/go-uptime-kuma-client.
|
|
// It serialises all calls on one session. The socket.io transport does not
|
|
// reconnect, so when a call fails and a probe shows the session is dead, the
|
|
// session is rebuilt (session token first, password as fallback; bounded
|
|
// backoff) and the call is retried once. Tag ownership caveats: the kuma-sync
|
|
// tag is the lowest-ID tag of that name, and the library clobbers monitor
|
|
// conditions and retryOnlyOnStatusCodeFailure on update.
|
|
type Breml struct {
|
|
mu sync.Mutex
|
|
c *kuma.Client
|
|
tagID int64
|
|
|
|
sessCtx context.Context
|
|
url string
|
|
user string
|
|
pass string
|
|
opts []kuma.Option
|
|
token string
|
|
backoff0 time.Duration
|
|
}
|
|
|
|
var _ Client = (*Breml)(nil)
|
|
|
|
// NewBreml connects and logs in once, then ensures the kuma-sync tag exists.
|
|
// ctx bounds the lifetime of the socket session, not just the connect call.
|
|
//
|
|
// After a Kuma restart or dropped connection, the next call that fails with a
|
|
// dead session reconnects and retries once. Create is the exception: it is
|
|
// never retried, because its outcome can be unknown. Two windows can leave an
|
|
// untagged duplicate in Kuma, each signalled by an error wrapping
|
|
// ErrPossibleOrphan (the controller should log it at error level):
|
|
// 1. Kuma stored the monitor but the ack was lost.
|
|
// 2. Kuma stored the monitor, then tagging and the cleanup delete failed.
|
|
//
|
|
// In both cases the untagged monitor is never adopted or touched, so it needs
|
|
// manual cleanup in the Kuma UI, and the next reconcile may create a tagged
|
|
// monitor beside it.
|
|
func NewBreml(ctx context.Context, url, username, password string) (Client, error) {
|
|
return newBreml(ctx, url, username, password)
|
|
}
|
|
|
|
func newBreml(ctx context.Context, url, username, password string, opts ...kuma.Option) (*Breml, error) {
|
|
opts = append([]kuma.Option{kuma.WithOperationTimeout(operationTimeout)}, opts...)
|
|
c, err := kuma.New(ctx, url, username, password, opts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("connect to kuma: %w", err)
|
|
}
|
|
b := &Breml{
|
|
c: c, sessCtx: ctx, url: url, user: username, pass: password, opts: opts,
|
|
token: c.SessionToken(), backoff0: time.Second,
|
|
}
|
|
if b.tagID, err = b.ensureTag(ctx); err != nil {
|
|
_ = c.Disconnect()
|
|
return nil, err
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// Close ends the session.
|
|
func (b *Breml) Close() error {
|
|
return b.c.Disconnect()
|
|
}
|
|
|
|
// pickOwnerTag returns the lowest-ID tag named kuma-sync. Kuma does not
|
|
// enforce unique tag names.
|
|
func pickOwnerTag(tags []tag.Tag) (int64, bool) {
|
|
var id int64
|
|
found := false
|
|
for _, t := range tags {
|
|
if t.Name == ownerTagName && (!found || t.ID < id) {
|
|
id, found = t.ID, true
|
|
}
|
|
}
|
|
return id, found
|
|
}
|
|
|
|
// ownerKeyOf returns the kuma-sync tag value of a monitor. Different values on
|
|
// one monitor are a conflict, never resolved by picking one.
|
|
// The error is a *ConflictError listing all distinct values.
|
|
func ownerKeyOf(monitorID int64, tags []tag.MonitorTag, tagID int64) (string, error) {
|
|
var keys []string
|
|
for _, t := range tags {
|
|
if t.TagID == tagID && t.Value != "" && !slices.Contains(keys, t.Value) {
|
|
keys = append(keys, t.Value)
|
|
}
|
|
}
|
|
switch len(keys) {
|
|
case 0:
|
|
return "", nil
|
|
case 1:
|
|
return keys[0], nil
|
|
}
|
|
slices.Sort(keys)
|
|
return "", &ConflictError{MonitorID: monitorID, Keys: keys}
|
|
}
|
|
|
|
// createdDespite reports whether a CreateMonitor result means the monitor
|
|
// exists. The library returns the ID together with ErrUpdateEventTimeout when
|
|
// the monitor was created and only the update event is missing (library
|
|
// monitor.go: CreateMonitor doc and the errors.Is(err, ErrUpdateEventTimeout)
|
|
// branch).
|
|
func createdDespite(id int64, err error) bool {
|
|
return err == nil || (id != 0 && errors.Is(err, kuma.ErrUpdateEventTimeout))
|
|
}
|
|
|
|
func (b *Breml) ensureTag(ctx context.Context) (int64, error) {
|
|
tags, err := b.c.GetTags(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if id, ok := pickOwnerTag(tags); ok {
|
|
return id, nil
|
|
}
|
|
return b.c.CreateTag(ctx, tag.Tag{Name: ownerTagName, Color: ownerTagColor})
|
|
}
|
|
|
|
// recoverSession reports whether the session was rebuilt after err, so the
|
|
// caller may retry. Application-level errors and a probe on a healthy session
|
|
// return false.
|
|
func (b *Breml) recoverSession(ctx context.Context, err error) bool {
|
|
rebuilt, _ := b.checkSession(ctx, err)
|
|
return rebuilt
|
|
}
|
|
|
|
// checkSession probes the session after err. dead reports that the probe
|
|
// failed, i.e. err was a transport failure; rebuilt that it was then replaced.
|
|
func (b *Breml) checkSession(ctx context.Context, err error) (rebuilt, dead bool) {
|
|
if ctx.Err() != nil {
|
|
return false, false
|
|
}
|
|
for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict, ErrStatusPageNotFound} {
|
|
if errors.Is(err, app) {
|
|
return false, false
|
|
}
|
|
}
|
|
pctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
|
defer cancel()
|
|
if _, perr := b.c.GetTags(pctx); perr == nil {
|
|
return false, false
|
|
}
|
|
return b.reconnect(ctx) == nil, true
|
|
}
|
|
|
|
func (b *Breml) reconnect(ctx context.Context) error {
|
|
delay := b.backoff0
|
|
var last error
|
|
for attempt := 0; attempt < reconnectAttempts; attempt++ {
|
|
if attempt > 0 {
|
|
select {
|
|
case <-time.After(delay):
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-b.sessCtx.Done():
|
|
return b.sessCtx.Err()
|
|
}
|
|
delay = min(delay*2, reconnectMaxDelay)
|
|
}
|
|
opts := append(slices.Clone(b.opts), kuma.WithConnectTimeout(connectTimeout))
|
|
if b.token != "" {
|
|
opts = append(opts, kuma.WithSessionToken(b.token))
|
|
}
|
|
c, err := kuma.New(b.sessCtx, b.url, b.user, b.pass, opts...)
|
|
if err != nil {
|
|
last = err
|
|
continue
|
|
}
|
|
_ = b.c.Disconnect()
|
|
b.c = c
|
|
if t := c.SessionToken(); t != "" {
|
|
b.token = t
|
|
}
|
|
return nil
|
|
}
|
|
return fmt.Errorf("reconnect to kuma: %w", last)
|
|
}
|
|
|
|
// withRecovery runs op and, if it fails on a dead session, retries it once on
|
|
// a rebuilt one.
|
|
func withRecovery[T any](ctx context.Context, b *Breml, op func() (T, error)) (T, error) {
|
|
v, err := op()
|
|
if err != nil && b.recoverSession(ctx, err) {
|
|
return op()
|
|
}
|
|
return v, err
|
|
}
|
|
|
|
func (b *Breml) ListOwned(ctx context.Context) ([]Monitor, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return withRecovery(ctx, b, func() ([]Monitor, error) { return b.listOwned(ctx) })
|
|
}
|
|
|
|
func (b *Breml) listOwned(ctx context.Context) ([]Monitor, error) {
|
|
tags, err := b.c.GetTags(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !slices.ContainsFunc(tags, func(t tag.Tag) bool { return t.ID == b.tagID && t.Name == ownerTagName }) {
|
|
return nil, ErrOwnerTagMissing
|
|
}
|
|
bases, err := b.c.GetMonitors(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []Monitor
|
|
var conflicts []error
|
|
for _, base := range bases {
|
|
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
|
|
if err != nil {
|
|
conflicts = append(conflicts, err)
|
|
continue
|
|
}
|
|
if key == "" {
|
|
continue
|
|
}
|
|
m, err := toDomain(base, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, m.Normalized())
|
|
}
|
|
return out, errors.Join(conflicts...)
|
|
}
|
|
|
|
// Create is never retried. Two windows can leave an untagged monitor in Kuma,
|
|
// both signalled by an error wrapping ErrPossibleOrphan:
|
|
// 1. Kuma stored the monitor but the ack was lost (transport or operation
|
|
// timeout error, no ID returned).
|
|
// 2. Kuma stored the monitor, then tagging failed and removing it failed too.
|
|
//
|
|
// Either way an untagged duplicate may exist; the controller never touches
|
|
// untagged monitors, so it needs manual cleanup in the Kuma UI. The next
|
|
// reconcile may create a tagged monitor beside it.
|
|
func (b *Breml) Create(ctx context.Context, m Monitor) (int64, error) {
|
|
if m.OwnerKey == "" {
|
|
return 0, errors.New("create: empty OwnerKey")
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return flagUnknownCreate(
|
|
func() (int64, error) { return b.createOnce(ctx, m) },
|
|
func(err error) bool { _, dead := b.checkSession(ctx, err); return dead },
|
|
)
|
|
}
|
|
|
|
// flagUnknownCreate runs create once. A failure without an ID that is a
|
|
// transport or operation-timeout error leaves the outcome unknown, so it is
|
|
// wrapped in ErrPossibleOrphan instead of being retried.
|
|
func flagUnknownCreate(create func() (int64, error), sessionDead func(error) bool) (int64, error) {
|
|
id, err := create()
|
|
if err == nil || id != 0 || errors.Is(err, ErrPossibleOrphan) {
|
|
return id, err
|
|
}
|
|
if errors.Is(err, kuma.ErrOperationTimeout) || sessionDead(err) {
|
|
return 0, errors.Join(err, fmt.Errorf("create outcome unknown: %w", ErrPossibleOrphan))
|
|
}
|
|
return 0, err
|
|
}
|
|
|
|
func (b *Breml) createOnce(ctx context.Context, m Monitor) (int64, error) {
|
|
wire, err := toWire(m, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
id, err := b.c.CreateMonitor(ctx, wire)
|
|
if !createdDespite(id, err) {
|
|
return 0, err
|
|
}
|
|
// Here the monitor exists, even if err is an update-event timeout.
|
|
if _, terr := b.c.AddMonitorTag(ctx, b.tagID, id, m.OwnerKey); terr != nil {
|
|
// An untagged leftover would be invisible to ListOwned.
|
|
if derr := b.c.DeleteMonitor(ctx, id); derr != nil {
|
|
return 0, errors.Join(terr, fmt.Errorf("remove untagged monitor %d: %w", id, derr),
|
|
fmt.Errorf("monitor %d: %w", id, ErrPossibleOrphan))
|
|
}
|
|
return 0, terr
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// lookupOwned returns the current monitor and verifies the owner tag.
|
|
func (b *Breml) lookupOwned(ctx context.Context, op string, id int64) (monitor.Base, string, error) {
|
|
bases, err := b.c.GetMonitors(ctx)
|
|
if err != nil {
|
|
return monitor.Base{}, "", err
|
|
}
|
|
for _, base := range bases {
|
|
if base.ID != id {
|
|
continue
|
|
}
|
|
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
|
|
if err != nil {
|
|
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, err)
|
|
}
|
|
if key == "" {
|
|
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, ErrNotOwned)
|
|
}
|
|
return base, key, nil
|
|
}
|
|
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, ErrNotFound)
|
|
}
|
|
|
|
type probeResult struct {
|
|
found bool
|
|
key string
|
|
}
|
|
|
|
// Probe reports whether a monitor with this ID exists and which owner key it
|
|
// carries; an untagged monitor has "". Conflicting tag values return the
|
|
// *ConflictError.
|
|
func (b *Breml) Probe(ctx context.Context, id int64) (bool, string, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
res, err := withRecovery(ctx, b, func() (probeResult, error) {
|
|
bases, err := b.c.GetMonitors(ctx)
|
|
if err != nil {
|
|
return probeResult{}, err
|
|
}
|
|
for _, base := range bases {
|
|
if base.ID != id {
|
|
continue
|
|
}
|
|
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
|
|
return probeResult{found: true, key: key}, err
|
|
}
|
|
return probeResult{}, nil
|
|
})
|
|
return res.found, res.key, err
|
|
}
|
|
|
|
func (b *Breml) Update(ctx context.Context, m Monitor) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
_, err := withRecovery(ctx, b, func() (struct{}, error) { return struct{}{}, b.update(ctx, m) })
|
|
return err
|
|
}
|
|
|
|
func (b *Breml) update(ctx context.Context, m Monitor) error {
|
|
cur, key, err := b.lookupOwned(ctx, "update", m.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if key != m.OwnerKey {
|
|
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
|
|
}
|
|
// Start from the full current object, also across a type change, so
|
|
// fields we do not manage (proxy, resend interval, upside down, auth)
|
|
// survive the overwrite.
|
|
wire, err := toWire(m, &cur)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := b.c.UpdateMonitor(ctx, wire); err != nil {
|
|
return err
|
|
}
|
|
// editMonitor does not change the active state.
|
|
switch {
|
|
case cur.IsActive && !m.Active:
|
|
return b.c.PauseMonitor(ctx, m.ID)
|
|
case !cur.IsActive && m.Active:
|
|
return b.c.ResumeMonitor(ctx, m.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Breml) Delete(ctx context.Context, id int64) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
_, err := withRecovery(ctx, b, func() (struct{}, error) {
|
|
if _, _, err := b.lookupOwned(ctx, "delete", id); err != nil {
|
|
return struct{}{}, err
|
|
}
|
|
return struct{}{}, b.c.DeleteMonitor(ctx, id)
|
|
})
|
|
return err
|
|
}
|
|
|
|
// DefaultNotifications reads the notification list cached by the session.
|
|
// Kuma has no command to request it: it pushes the list at login and to every
|
|
// session of the user after each change.
|
|
func (b *Breml) DefaultNotifications(ctx context.Context) ([]int64, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return defaultIDs(b.c.GetNotifications(ctx)), nil
|
|
}
|
|
|
|
func defaultIDs(list []notification.Base) []int64 {
|
|
var ids []int64
|
|
for _, n := range list {
|
|
if n.IsDefault {
|
|
ids = append(ids, n.ID)
|
|
}
|
|
}
|
|
slices.Sort(ids)
|
|
return ids
|
|
}
|
|
|
|
// toDomain converts a monitor from the Kuma monitor list.
|
|
func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
|
|
m := Monitor{
|
|
ID: base.ID,
|
|
Type: base.Type(),
|
|
Name: base.Name,
|
|
Parent: base.Parent,
|
|
Active: base.IsActive,
|
|
Interval: int32(base.Interval),
|
|
RetryInterval: int32(base.RetryInterval),
|
|
MaxRetries: int32(base.MaxRetries),
|
|
NotificationIDs: slices.Clone(base.NotificationIDs),
|
|
OwnerKey: ownerKey,
|
|
}
|
|
if base.Description != nil {
|
|
m.Description = *base.Description
|
|
}
|
|
switch m.Type {
|
|
case "http":
|
|
var v monitor.HTTP
|
|
if err := base.As(&v); err != nil {
|
|
return Monitor{}, err
|
|
}
|
|
if err := readHTTP(&m, v.HTTPDetails); err != nil {
|
|
return Monitor{}, err
|
|
}
|
|
case "keyword":
|
|
var v monitor.HTTPKeyword
|
|
if err := base.As(&v); err != nil {
|
|
return Monitor{}, err
|
|
}
|
|
if err := readHTTP(&m, v.HTTPDetails); err != nil {
|
|
return Monitor{}, err
|
|
}
|
|
m.Keyword = v.Keyword
|
|
m.InvertKeyword = v.InvertKeyword
|
|
case "json-query":
|
|
var v monitor.HTTPJSONQuery
|
|
if err := base.As(&v); err != nil {
|
|
return Monitor{}, err
|
|
}
|
|
if err := readHTTP(&m, v.HTTPDetails); err != nil {
|
|
return Monitor{}, err
|
|
}
|
|
m.JSONPath = v.JSONPath
|
|
m.JSONPathOperator = v.JSONPathOperator
|
|
m.ExpectedValue = v.ExpectedValue
|
|
case "dns":
|
|
var v monitor.DNS
|
|
if err := base.As(&v); err != nil {
|
|
return Monitor{}, err
|
|
}
|
|
m.Hostname = v.Hostname
|
|
m.Port = int32(v.Port)
|
|
m.DNSResolveType = string(v.ResolveType)
|
|
m.DNSResolveServer = v.ResolverServer
|
|
for _, c := range v.Conditions {
|
|
m.DNSConditions = append(m.DNSConditions, Condition{
|
|
Variable: c.Variable, Operator: c.Operator, Value: c.Value, AndOr: string(c.AndOr),
|
|
})
|
|
}
|
|
case "port":
|
|
var v monitor.TCPPort
|
|
if err := base.As(&v); err != nil {
|
|
return Monitor{}, err
|
|
}
|
|
m.Hostname = v.Hostname
|
|
m.Port = int32(v.Port)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func readHTTP(m *Monitor, d monitor.HTTPDetails) error {
|
|
m.URL = d.URL
|
|
m.Method = d.Method
|
|
m.Body = d.Body
|
|
m.AcceptedStatusCodes = slices.Clone(d.AcceptedStatusCodes)
|
|
m.MaxRedirects = int32(d.MaxRedirects)
|
|
m.IgnoreTLS = d.IgnoreTLS
|
|
m.ExpiryNotification = d.ExpiryNotification
|
|
m.Timeout = int32(math.Round(d.Timeout))
|
|
if strings.TrimSpace(d.Headers) != "" {
|
|
if err := json.Unmarshal([]byte(d.Headers), &m.Headers); err != nil {
|
|
return fmt.Errorf("monitor %d: parse headers: %w", m.ID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// toWire builds the library monitor for m. With existing set, the typed
|
|
// object is first decoded from it so fields we do not manage are kept.
|
|
func toWire(m Monitor, existing *monitor.Base) (monitor.Monitor, error) {
|
|
m = m.Normalized()
|
|
load := func(target any) error {
|
|
if existing == nil {
|
|
return nil
|
|
}
|
|
return existing.As(target)
|
|
}
|
|
switch m.Type {
|
|
case "group":
|
|
v := &monitor.Group{}
|
|
if err := load(v); err != nil {
|
|
return nil, err
|
|
}
|
|
writeBase(&v.Base, m)
|
|
return v, nil
|
|
case "http":
|
|
v := &monitor.HTTP{}
|
|
if err := load(v); err != nil {
|
|
return nil, err
|
|
}
|
|
writeBase(&v.Base, m)
|
|
return v, writeHTTP(&v.HTTPDetails, m)
|
|
case "keyword":
|
|
v := &monitor.HTTPKeyword{}
|
|
if err := load(v); err != nil {
|
|
return nil, err
|
|
}
|
|
writeBase(&v.Base, m)
|
|
v.Keyword = m.Keyword
|
|
v.InvertKeyword = m.InvertKeyword
|
|
return v, writeHTTP(&v.HTTPDetails, m)
|
|
case "json-query":
|
|
v := &monitor.HTTPJSONQuery{}
|
|
if err := load(v); err != nil {
|
|
return nil, err
|
|
}
|
|
writeBase(&v.Base, m)
|
|
v.JSONPath = m.JSONPath
|
|
v.JSONPathOperator = m.JSONPathOperator
|
|
v.ExpectedValue = m.ExpectedValue
|
|
return v, writeHTTP(&v.HTTPDetails, m)
|
|
case "dns":
|
|
v := &monitor.DNS{}
|
|
if err := load(v); err != nil {
|
|
return nil, err
|
|
}
|
|
writeBase(&v.Base, m)
|
|
v.Hostname = m.Hostname
|
|
v.Port = int(m.Port)
|
|
v.ResolveType = monitor.DNSResolveType(m.DNSResolveType)
|
|
v.ResolverServer = m.DNSResolveServer
|
|
v.Conditions = nil
|
|
for _, c := range m.DNSConditions {
|
|
v.Conditions = append(v.Conditions, monitor.Condition{
|
|
Variable: c.Variable, Operator: c.Operator, Value: c.Value, AndOr: monitor.ConditionOperator(c.AndOr),
|
|
})
|
|
}
|
|
return v, nil
|
|
case "port":
|
|
v := &monitor.TCPPort{}
|
|
if err := load(v); err != nil {
|
|
return nil, err
|
|
}
|
|
writeBase(&v.Base, m)
|
|
v.Hostname = m.Hostname
|
|
v.Port = int(m.Port)
|
|
return v, nil
|
|
}
|
|
return nil, fmt.Errorf("unsupported monitor type %q", m.Type)
|
|
}
|
|
|
|
func writeBase(b *monitor.Base, m Monitor) {
|
|
b.ID = m.ID
|
|
b.Name = m.Name
|
|
desc := m.Description
|
|
b.Description = &desc
|
|
b.Parent = m.clone().Parent
|
|
b.Interval = int64(m.Interval)
|
|
b.RetryInterval = int64(m.RetryInterval)
|
|
b.MaxRetries = int64(m.MaxRetries)
|
|
b.IsActive = m.Active
|
|
b.NotificationIDs = slices.Clone(m.NotificationIDs)
|
|
}
|
|
|
|
// writeHTTP expects m to be normalized.
|
|
func writeHTTP(d *monitor.HTTPDetails, m Monitor) error {
|
|
d.URL = m.URL
|
|
d.Timeout = float64(m.Timeout)
|
|
d.ExpiryNotification = m.ExpiryNotification
|
|
d.IgnoreTLS = m.IgnoreTLS
|
|
d.MaxRedirects = int(m.MaxRedirects)
|
|
d.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
|
|
d.Method = m.Method
|
|
d.Body = m.Body
|
|
if d.HTTPBodyEncoding == "" {
|
|
d.HTTPBodyEncoding = "json"
|
|
}
|
|
d.Headers = ""
|
|
if len(m.Headers) > 0 {
|
|
raw, err := json.Marshal(m.Headers)
|
|
if err != nil {
|
|
return fmt.Errorf("encode headers: %w", err)
|
|
}
|
|
d.Headers = string(raw)
|
|
}
|
|
return nil
|
|
}
|