mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
Internal Domains now support three modes. "os" is unchanged. "resolvers" becomes explicit resolver with network fallback: the configured resolvers are tried first, and a timeout, unreachable resolver, SERVFAIL or NXDOMAIN hands the query to matching VPN DNS servers, then domain-less VPN DNS servers, then the OS resolver's LAN nameservers through the new LanOnlyQueryCtx. "resolvers_only" keeps the previous strict behavior. Down fallback-mode resolvers are skipped and re-checked in the background at most every 30s. Generated upstreams are identified by the UpstreamConfig.InternalDomain marker instead of the internal_ key prefix. Master adaptations: the fallback state lives on proxyRequest and is applied in tryUpstreams, shouldContinueWithNextUpstream and handleAllUpstreamsFailure instead of v1.0's monolithic proxy loop. Resolver construction uses master's context-aware NewResolver, and the background re-check marks its context private so transport errors that name the resolver stay at debug. Tests use master's zap JSON capture and VPN manager logger.
469 lines
17 KiB
Go
469 lines
17 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"runtime"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/Control-D-Inc/ctrld"
|
|
"github.com/Control-D-Inc/ctrld/internal/dnscache"
|
|
"github.com/Control-D-Inc/ctrld/testhelper"
|
|
)
|
|
|
|
// wantDNSConfigStrings compares one array field of a logged resolver entry.
|
|
func wantDNSConfigStrings(t *testing.T, event map[string]any, field string, want ...string) {
|
|
t.Helper()
|
|
values, ok := event[field].([]any)
|
|
if !ok {
|
|
t.Fatalf("field %q: got %T, want an array", field, event[field])
|
|
}
|
|
if len(values) != len(want) {
|
|
t.Fatalf("field %q: got %v, want %v", field, values, want)
|
|
}
|
|
for i, value := range values {
|
|
if value != want[i] {
|
|
t.Fatalf("field %q at %d: got %v, want %v", field, i, value, want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test_queryHealthCountsFailedProxyQueries drives the real query path against a
|
|
// refusing upstream: every sampled error reaches the tracker, so the window of
|
|
// the daemon grades as failing.
|
|
func Test_queryHealthCountsFailedProxyQueries(t *testing.T) {
|
|
captureDebugMainLog(t)
|
|
cfg := &ctrld.Config{}
|
|
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
|
|
p := &prog{cfg: cfg, um: newUpstreamMonitor(cfg, mainLog.Load()), health: newQueryHealth()}
|
|
p.logger.Store(mainLog.Load())
|
|
// A new monitor holds back the failure counts for one second, and its
|
|
// timer would mark the upstream down after the test.
|
|
p.um.clearRecovered(upstreamOS)
|
|
p.um.after = func(time.Duration, func()) {}
|
|
p.querySampler.onFailure = p.health.countFailure
|
|
// The zero value sampler arms a real timer. Close its window after the
|
|
// assertions, so no late summary line reaches another test.
|
|
t.Cleanup(func() { p.querySampler.closeExpired(time.Now().Add(querySampleWindow)) })
|
|
|
|
origOSUpstream := osUpstreamConfig
|
|
t.Cleanup(func() { osUpstreamConfig = origOSUpstream })
|
|
osUpstreamConfig = &ctrld.UpstreamConfig{
|
|
Name: "dead resolver",
|
|
Type: ctrld.ResolverTypeLegacy,
|
|
Endpoint: deadUpstreamEndpoint,
|
|
Timeout: 500,
|
|
}
|
|
|
|
const failedQueries = 12
|
|
const servedQueries = 20
|
|
ctx := context.Background()
|
|
for i := 0; i < failedQueries; i++ {
|
|
if rcode := askThroughProxy(t, p, ctx).Rcode; rcode != dns.RcodeServerFailure {
|
|
t.Fatalf("query %d: rcode %s, want SERVFAIL", i+1, dns.RcodeToString[rcode])
|
|
}
|
|
}
|
|
// serveDNS counts every query it serves, and the eight answered ones never
|
|
// reach the sampler.
|
|
for i := 0; i < servedQueries; i++ {
|
|
p.health.countQuery()
|
|
}
|
|
|
|
event, emit := p.health.evaluate(time.Now(), 0, false)
|
|
if !emit {
|
|
t.Fatal("the first evaluate did not emit")
|
|
}
|
|
if event.Class != queryHealthFailing {
|
|
t.Fatalf("class: got %q, want %q", event.Class, queryHealthFailing)
|
|
}
|
|
if event.Queries != servedQueries {
|
|
t.Fatalf("queries: got %d, want %d", event.Queries, servedQueries)
|
|
}
|
|
// The grade follows one failure per failed query. Each of those queries
|
|
// also raised two sampler events, and the breakdown still shows them.
|
|
if event.FailedQueries != failedQueries {
|
|
t.Fatalf("failed queries: got %d, want %d", event.FailedQueries, failedQueries)
|
|
}
|
|
if got := event.Failures[sampleClassResolveFailed]; got != failedQueries {
|
|
t.Fatalf("%s failures: got %d, want %d", sampleClassResolveFailed, got, failedQueries)
|
|
}
|
|
if got := event.Failures[sampleClassAllEndpointsFailed]; got != failedQueries {
|
|
t.Fatalf("%s failures: got %d, want %d", sampleClassAllEndpointsFailed, got, failedQueries)
|
|
}
|
|
}
|
|
|
|
// Test_queryHealthKeepsInternalDomainFailuresOutOfTheGrade drives the same
|
|
// number of failed queries against an Internal Domain resolver. An endpoint
|
|
// away from the organization network cannot reach that resolver, so the class
|
|
// stays healthy, the failures keep their own name, and the down entry of that
|
|
// resolver stays out of upstreams_down.
|
|
func Test_queryHealthKeepsInternalDomainFailuresOutOfTheGrade(t *testing.T) {
|
|
captureDebugMainLog(t)
|
|
const internalUpstream = upstreamPrefix + internalDomainUpstreamPrefix + "1"
|
|
cfg := &ctrld.Config{}
|
|
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
|
|
cfg.Upstream = map[string]*ctrld.UpstreamConfig{
|
|
internalDomainUpstreamPrefix + "1": {
|
|
Name: "dead internal resolver",
|
|
Type: ctrld.ResolverTypeLegacy,
|
|
Endpoint: deadUpstreamEndpoint,
|
|
Timeout: 500,
|
|
InternalDomain: internalDomainUpstreamOnly,
|
|
},
|
|
}
|
|
p := &prog{cfg: cfg, um: newUpstreamMonitor(cfg, mainLog.Load()), health: newQueryHealth()}
|
|
p.logger.Store(mainLog.Load())
|
|
p.um.after = func(time.Duration, func()) {}
|
|
p.um.clearRecovered(internalUpstream)
|
|
p.querySampler.onFailure = p.health.countFailure
|
|
t.Cleanup(func() { p.querySampler.closeExpired(time.Now().Add(querySampleWindow)) })
|
|
|
|
const failedQueries = 12
|
|
const servedQueries = 20
|
|
ctx := context.Background()
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("internal.corp.example.", dns.TypeA)
|
|
for i := 0; i < failedQueries; i++ {
|
|
res := p.proxy(ctx, &proxyRequest{
|
|
msg: msg,
|
|
ufr: &upstreamForResult{upstreams: []string{internalUpstream}, srcAddr: "192.168.0.1:1234"},
|
|
})
|
|
if res == nil || res.answer == nil || res.answer.Rcode != dns.RcodeServerFailure {
|
|
t.Fatalf("query %d: the internal resolver answered", i+1)
|
|
}
|
|
}
|
|
for i := 0; i < servedQueries; i++ {
|
|
p.health.countQuery()
|
|
}
|
|
p.um.mu.Lock()
|
|
p.um.markDown(internalUpstream, 3, "immediate")
|
|
p.um.mu.Unlock()
|
|
|
|
event, emit := p.health.evaluate(time.Now(), p.um.countDownExcept(p.isInternalDomainUpstream), false)
|
|
if !emit {
|
|
t.Fatal("the first evaluate did not emit")
|
|
}
|
|
if event.Class != queryHealthHealthy {
|
|
t.Fatalf("class: got %q, want %q", event.Class, queryHealthHealthy)
|
|
}
|
|
if event.FailedQueries != 0 {
|
|
t.Fatalf("failed queries: got %d, want 0", event.FailedQueries)
|
|
}
|
|
if got := event.Failures[sampleClassInternalDomain]; got != failedQueries {
|
|
t.Fatalf("%s failures: got %d, want %d", sampleClassInternalDomain, got, failedQueries)
|
|
}
|
|
if event.UpstreamsDown != 0 {
|
|
t.Fatalf("upstreams_down: got %d, want 0", event.UpstreamsDown)
|
|
}
|
|
if got := p.um.countDown(); got != 1 {
|
|
t.Fatalf("countDown: got %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
// Test_queryHealthCountsProxyCacheHits proves that an answer from the cache
|
|
// counts a cache hit, so the ratio of the journal follows the query path.
|
|
func Test_queryHealthCountsProxyCacheHits(t *testing.T) {
|
|
captureDebugMainLog(t)
|
|
p := &prog{cfg: testhelper.SampleConfig(t), health: newQueryHealth()}
|
|
p.logger.Store(mainLog.Load())
|
|
cache, err := dnscache.NewLRUCache(16)
|
|
require.NoError(t, err)
|
|
p.cache = cache
|
|
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("cache.test.", dns.TypeA)
|
|
answer := new(dns.Msg)
|
|
answer.SetRcode(msg, dns.RcodeSuccess)
|
|
p.cache.Add(dnscache.NewKey(msg, "upstream.1"), dnscache.NewValue(answer, time.Now().Add(time.Minute)))
|
|
|
|
res := p.proxy(context.Background(), &proxyRequest{
|
|
msg: msg,
|
|
ufr: &upstreamForResult{upstreams: []string{"upstream.1"}},
|
|
})
|
|
require.NotNil(t, res)
|
|
require.True(t, res.cached, "the answer did not come from the cache")
|
|
|
|
event, _ := p.health.evaluate(time.Now(), 0, false)
|
|
if event.CacheHits != 1 {
|
|
t.Fatalf("cache hits: got %d, want 1", event.CacheHits)
|
|
}
|
|
}
|
|
|
|
// Test_logDNSConfigChangesLogsEveryEntry proves that each changed resolver of
|
|
// the host reaches the journal with its fields. A resolver that vanished keeps
|
|
// an empty nameserver list.
|
|
func Test_logDNSConfigChangesLogsEveryEntry(t *testing.T) {
|
|
logs := captureDebugMainLog(t)
|
|
p := &prog{}
|
|
p.logger.Store(mainLog.Load())
|
|
|
|
p.logDNSConfigChanges([]dnsResolverEntry{
|
|
{
|
|
Order: 200000,
|
|
Nameservers: []string{"192.168.50.200", "192.168.50.1"},
|
|
IfIndex: 16,
|
|
Interface: "en0",
|
|
Flags: "Request A records, Request AAAA records",
|
|
SearchDomains: []string{"corp.example"},
|
|
Reachable: "Reachable,Directly Reachable Address",
|
|
},
|
|
{Order: 101600, IfIndex: 21, Interface: "utun4", Flags: "Scoped"},
|
|
})
|
|
|
|
events := jsonLogEvents(t, logs, dnsConfigChangedMessage)
|
|
if len(events) != 2 {
|
|
t.Fatalf("configuration lines: got %d, want 2", len(events))
|
|
}
|
|
|
|
wantField(t, events[0], "journal", true)
|
|
wantField(t, events[0], "level", "info")
|
|
wantField(t, events[0], "if_index", float64(16))
|
|
wantField(t, events[0], "interface", "en0")
|
|
wantField(t, events[0], "flags", "Request A records, Request AAAA records")
|
|
wantField(t, events[0], "order", float64(200000))
|
|
wantField(t, events[0], "reachable", "Reachable,Directly Reachable Address")
|
|
wantDNSConfigStrings(t, events[0], "nameservers", "192.168.50.200", "192.168.50.1")
|
|
// The suffixes of an organization stay out of the journal. Only their
|
|
// number goes in.
|
|
wantField(t, events[0], "search_domain_count", float64(1))
|
|
if _, ok := events[0]["search_domains"]; ok {
|
|
t.Fatal("the journal holds the search domains")
|
|
}
|
|
|
|
wantField(t, events[1], "journal", true)
|
|
wantField(t, events[1], "interface", "utun4")
|
|
wantDNSConfigStrings(t, events[1], "nameservers")
|
|
wantField(t, events[1], "search_domain_count", float64(0))
|
|
}
|
|
|
|
// Test_startNetworkJournalLogsTheStartSnapshot proves that the daemon opens the
|
|
// journal with one snapshot and runs the trackers that follow the query path
|
|
// and the DNS configuration of the host.
|
|
func Test_startNetworkJournalLogsTheStartSnapshot(t *testing.T) {
|
|
logs := captureDebugMainLog(t)
|
|
stubHeaderSnapshotSources(t)
|
|
origTick := queryHealthTick
|
|
t.Cleanup(func() { queryHealthTick = origTick })
|
|
queryHealthTick = time.Millisecond
|
|
|
|
var polls atomic.Int64
|
|
// The poll loop reads the delay on every pass, so it parks on a long delay
|
|
// before the test puts the package value back.
|
|
parked := make(chan struct{}, 1)
|
|
origRunner, origDelay := runSCUtilDNSFn, dnsConfigDelayFn
|
|
t.Cleanup(func() { runSCUtilDNSFn, dnsConfigDelayFn = origRunner, origDelay })
|
|
movedResolver := wifiResolverEntry()
|
|
movedResolver.Nameservers = []string{"10.0.0.1"}
|
|
runSCUtilDNSFn = func() ([]dnsResolverEntry, error) {
|
|
if polls.Add(1) == 1 {
|
|
return []dnsResolverEntry{wifiResolverEntry()}, nil
|
|
}
|
|
return []dnsResolverEntry{movedResolver}, nil
|
|
}
|
|
dnsConfigDelayFn = func(*dnsConfigPoller, time.Time) time.Duration {
|
|
if polls.Load() < 2 {
|
|
return time.Millisecond
|
|
}
|
|
select {
|
|
case parked <- struct{}{}:
|
|
default:
|
|
}
|
|
return time.Hour
|
|
}
|
|
|
|
cfg := &ctrld.Config{}
|
|
p := &prog{cfg: cfg, um: newUpstreamMonitor(cfg, mainLog.Load()), stopCh: make(chan struct{})}
|
|
p.logger.Store(mainLog.Load())
|
|
// The monitor arms a real timer for each upstream it resets. Nothing in
|
|
// this test waits for one.
|
|
p.um.after = func(time.Duration, func()) {}
|
|
p.lastNetworkState.Store(sourceTestState("en0", true, "192.0.2.9/24"))
|
|
p.recoveryBypass.Store(true)
|
|
p.um.mu.Lock()
|
|
p.um.markDown(upstreamOS, 3, "timer")
|
|
p.um.mu.Unlock()
|
|
|
|
p.newNetworkJournal()
|
|
if p.health == nil || p.querySampler.onFailure == nil {
|
|
t.Fatal("the trackers do not exist before the loops start")
|
|
}
|
|
// A counter of the query path works as soon as the trackers exist.
|
|
p.health.countQuery()
|
|
waitForJournalLoops := p.startNetworkJournal()
|
|
t.Cleanup(func() {
|
|
close(p.stopCh)
|
|
waitForJournalLoops()
|
|
})
|
|
|
|
events := jsonLogEvents(t, logs, networkSnapshotMessage)
|
|
if len(events) != 1 {
|
|
t.Fatalf("snapshot lines: got %d, want 1", len(events))
|
|
}
|
|
wantField(t, events[0], "journal", true)
|
|
wantField(t, events[0], "level", "info")
|
|
wantField(t, events[0], "trigger", "start")
|
|
|
|
waitForLogLine(t, logs, queryHealthMessage)
|
|
health := jsonLogEvents(t, logs, queryHealthMessage)
|
|
wantField(t, health[0], "upstreams_down", float64(1))
|
|
wantField(t, health[0], "bypass_active", true)
|
|
|
|
if runtime.GOOS != "darwin" {
|
|
if p.dnsConfig != nil {
|
|
t.Fatal("the DNS configuration poller runs outside macOS")
|
|
}
|
|
return
|
|
}
|
|
if p.dnsConfig == nil {
|
|
t.Fatal("macOS starts no DNS configuration poller")
|
|
}
|
|
waitForLogLine(t, logs, dnsConfigChangedMessage)
|
|
select {
|
|
case <-parked:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the poll loop did not park")
|
|
}
|
|
resolvers := jsonLogEvents(t, logs, dnsConfigChangedMessage)
|
|
if len(resolvers) != 1 {
|
|
t.Fatalf("configuration lines: got %d, want 1", len(resolvers))
|
|
}
|
|
wantField(t, resolvers[0], "interface", "en0")
|
|
wantDNSConfigStrings(t, resolvers[0], "nameservers", "10.0.0.1")
|
|
}
|
|
|
|
// Test_startNetworkJournalReadsTheMonitorAcrossAReload replaces the upstream
|
|
// monitor while the health loop runs. A reload builds a new monitor, and the
|
|
// loop read the field of the prog with no lock, which the race detector
|
|
// reports.
|
|
func Test_startNetworkJournalReadsTheMonitorAcrossAReload(t *testing.T) {
|
|
captureDebugMainLog(t)
|
|
stubHeaderSnapshotSources(t)
|
|
origTick := queryHealthTick
|
|
t.Cleanup(func() { queryHealthTick = origTick })
|
|
queryHealthTick = time.Millisecond
|
|
|
|
cfg := &ctrld.Config{}
|
|
p := &prog{cfg: cfg, stopCh: make(chan struct{})}
|
|
p.logger.Store(mainLog.Load())
|
|
p.lastNetworkState.Store(sourceTestState("en0", true, "192.0.2.9/24"))
|
|
p.replaceUpstreamMonitor()
|
|
p.health = newQueryHealth()
|
|
|
|
waitForJournalLoops := p.startNetworkJournal()
|
|
for i := 0; i < 30; i++ {
|
|
p.replaceUpstreamMonitor()
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
close(p.stopCh)
|
|
waitForJournalLoops()
|
|
}
|
|
|
|
// Test_startNetworkJournalRunsAfterAFailedMonitorStart proves that a network
|
|
// monitor that cannot start leaves the journal running. The error line goes
|
|
// out, the start snapshot opens the journal, and the health loop reports.
|
|
func Test_startNetworkJournalRunsAfterAFailedMonitorStart(t *testing.T) {
|
|
logs := captureDebugMainLog(t)
|
|
stubHeaderSnapshotSources(t)
|
|
origTick := queryHealthTick
|
|
t.Cleanup(func() { queryHealthTick = origTick })
|
|
queryHealthTick = time.Millisecond
|
|
|
|
origMonitor := newNetworkChangeMonitorFn
|
|
t.Cleanup(func() { newNetworkChangeMonitorFn = origMonitor })
|
|
newNetworkChangeMonitorFn = func(func(string, ...any)) (networkChangeMonitor, error) {
|
|
return nil, errors.New("no network monitor here")
|
|
}
|
|
origRunner, origDelay := runSCUtilDNSFn, dnsConfigDelayFn
|
|
t.Cleanup(func() { runSCUtilDNSFn, dnsConfigDelayFn = origRunner, origDelay })
|
|
runSCUtilDNSFn = func() ([]dnsResolverEntry, error) {
|
|
return []dnsResolverEntry{wifiResolverEntry()}, nil
|
|
}
|
|
dnsConfigDelayFn = func(*dnsConfigPoller, time.Time) time.Duration { return time.Hour }
|
|
|
|
cfg := &ctrld.Config{}
|
|
p := &prog{cfg: cfg, stopCh: make(chan struct{})}
|
|
p.logger.Store(mainLog.Load())
|
|
p.lastNetworkState.Store(sourceTestState("en0", true, "192.0.2.9/24"))
|
|
p.replaceUpstreamMonitor()
|
|
p.upstreamMonitorNow().after = func(time.Duration, func()) {}
|
|
|
|
p.newNetworkJournal()
|
|
p.health.countQuery()
|
|
p.health.mu.Lock()
|
|
queries := p.health.queries
|
|
p.health.mu.Unlock()
|
|
if queries != 1 {
|
|
t.Fatalf("queries after one count: got %d, want 1", queries)
|
|
}
|
|
|
|
waitForJournalLoops := p.startNetworkMonitorAndJournal(context.Background())
|
|
t.Cleanup(func() {
|
|
close(p.stopCh)
|
|
waitForJournalLoops()
|
|
})
|
|
|
|
failures := jsonLogEvents(t, logs, "Failed to start network monitoring")
|
|
if len(failures) != 1 {
|
|
t.Fatalf("monitor failure lines: got %d, want 1", len(failures))
|
|
}
|
|
wantField(t, failures[0], "level", "error")
|
|
|
|
snapshots := jsonLogEvents(t, logs, networkSnapshotMessage)
|
|
if len(snapshots) != 1 {
|
|
t.Fatalf("snapshot lines: got %d, want 1", len(snapshots))
|
|
}
|
|
wantField(t, snapshots[0], "trigger", "start")
|
|
|
|
waitForLogLine(t, logs, queryHealthMessage)
|
|
}
|
|
|
|
// Test_queryHealthCountsNoFailureWhenAnUpstreamAnswers proves that a failure on
|
|
// one upstream does not grade the query. The client got an answer, so the
|
|
// failure stays in the breakdown only. The DNS64 companion lookup fails through
|
|
// the same call, so it cannot grade a query either.
|
|
func Test_queryHealthCountsNoFailureWhenAnUpstreamAnswers(t *testing.T) {
|
|
captureDebugMainLog(t)
|
|
fixture := startDNSFixture(t)
|
|
cfg := &ctrld.Config{}
|
|
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
|
|
cfg.Upstream = map[string]*ctrld.UpstreamConfig{
|
|
"0": {Name: "dead resolver", Type: ctrld.ResolverTypeLegacy, Endpoint: deadUpstreamEndpoint, Timeout: 500},
|
|
"1": {Name: "live resolver", Type: ctrld.ResolverTypeLegacy, Endpoint: fixture.addr, Timeout: 2000},
|
|
}
|
|
p := &prog{cfg: cfg, um: newUpstreamMonitor(cfg, mainLog.Load()), health: newQueryHealth()}
|
|
p.logger.Store(mainLog.Load())
|
|
p.um.after = func(time.Duration, func()) {}
|
|
p.um.clearRecovered(upstreamPrefix + "0")
|
|
p.querySampler.onFailure = p.health.countFailure
|
|
t.Cleanup(func() { p.querySampler.closeExpired(time.Now().Add(querySampleWindow)) })
|
|
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("failover.test.", dns.TypeA)
|
|
res := p.proxy(context.Background(), &proxyRequest{
|
|
msg: msg,
|
|
ufr: &upstreamForResult{
|
|
upstreams: []string{upstreamPrefix + "0", upstreamPrefix + "1"},
|
|
srcAddr: "192.168.0.1:1234",
|
|
},
|
|
})
|
|
if res == nil || res.answer == nil || res.answer.Rcode != dns.RcodeSuccess {
|
|
t.Fatal("the second upstream did not answer")
|
|
}
|
|
p.health.countQuery()
|
|
|
|
event, _ := p.health.evaluate(time.Now(), 0, false)
|
|
if event.FailedQueries != 0 {
|
|
t.Fatalf("failed queries: got %d, want 0", event.FailedQueries)
|
|
}
|
|
if got := event.Failures[sampleClassResolveFailed]; got != 1 {
|
|
t.Fatalf("%s failures: got %d, want 1", sampleClassResolveFailed, got)
|
|
}
|
|
if event.Class != queryHealthHealthy {
|
|
t.Fatalf("class: got %q, want %q", event.Class, queryHealthHealthy)
|
|
}
|
|
}
|