mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
443 lines
14 KiB
Go
443 lines
14 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
"github.com/Control-D-Inc/ctrld/internal/controld"
|
|
)
|
|
|
|
func TestContextFromStopCh(t *testing.T) {
|
|
t.Run("cancels when stopCh closes", func(t *testing.T) {
|
|
stopCh := make(chan struct{})
|
|
ctx, cancel := contextFromStopCh(stopCh)
|
|
defer cancel()
|
|
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("context cancelled before the stop request: %v", ctx.Err())
|
|
}
|
|
close(stopCh)
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("context was not cancelled after stopCh closed")
|
|
}
|
|
if !errors.Is(ctx.Err(), context.Canceled) {
|
|
t.Errorf("ctx.Err() = %v, want %v", ctx.Err(), context.Canceled)
|
|
}
|
|
})
|
|
|
|
t.Run("cancel releases the watcher", func(t *testing.T) {
|
|
// stopCh is never closed: cancel() must still end the goroutine watching it.
|
|
ctx, cancel := contextFromStopCh(make(chan struct{}))
|
|
cancel()
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("context was not cancelled by cancel()")
|
|
}
|
|
})
|
|
|
|
t.Run("nil stopCh is usable", func(t *testing.T) {
|
|
// Mobile callers have no stop channel; preflight must still run.
|
|
ctx, cancel := contextFromStopCh(nil)
|
|
defer cancel()
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("context cancelled immediately: %v", ctx.Err())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestManagedConfigFetchMetadataScope(t *testing.T) {
|
|
oldFetch, oldStartup := fetchResolverConfig, startupMetadata
|
|
oldUID := cdUID
|
|
t.Cleanup(func() {
|
|
fetchResolverConfig, startupMetadata = oldFetch, oldStartup
|
|
cdUID = oldUID
|
|
})
|
|
cdUID = "testuid"
|
|
startupMetadata = func(context.Context) map[string]string {
|
|
return map[string]string{"username": "session-user"}
|
|
}
|
|
|
|
requests := make(chan *controld.ResolverConfigRequest, 2)
|
|
fetchResolverConfig = func(_ context.Context, req *controld.ResolverConfigRequest, _ bool) (*controld.ResolverConfig, error) {
|
|
requests <- req
|
|
return nil, errors.New("stop after capturing metadata")
|
|
}
|
|
|
|
_, _ = processCDFlags(context.Background(), &ctrld.Config{})
|
|
startup := <-requests
|
|
if got := startup.Metadata["username"]; got != "session-user" {
|
|
t.Errorf("managed startup username = %q, want the startup metadata source", got)
|
|
}
|
|
if !startup.IncludeHostnameHints {
|
|
t.Error("managed startup did not request hostname hints")
|
|
}
|
|
|
|
p := &prog{stopCh: make(chan struct{})}
|
|
_, _ = p.fetchCDConfigBoundedByLifetime(&ctrld.Config{})
|
|
runtime := <-requests
|
|
if _, ok := runtime.Metadata["username"]; ok {
|
|
t.Error("runtime reload metadata included username")
|
|
}
|
|
if runtime.IncludeHostnameHints {
|
|
t.Error("runtime reload requested hostname hints")
|
|
}
|
|
}
|
|
|
|
// retryableNetworkErr is the shape processCDFlags treats as "retry with bootstrap
|
|
// DNS": a url.Error wrapping a network failure.
|
|
func retryableNetworkErr() error {
|
|
return &url.Error{
|
|
Op: "Post",
|
|
URL: "https://api.controld.com/utility",
|
|
Err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED},
|
|
}
|
|
}
|
|
|
|
func TestProcessCDFlagsStopsWhenCancelled(t *testing.T) {
|
|
oldFetch := fetchResolverConfig
|
|
oldUID := cdUID
|
|
t.Cleanup(func() {
|
|
fetchResolverConfig = oldFetch
|
|
cdUID = oldUID
|
|
})
|
|
cdUID = "testuid"
|
|
|
|
var calls atomic.Int64
|
|
fetchResolverConfig = func(ctx context.Context, req *controld.ResolverConfigRequest, dev bool) (*controld.ResolverConfig, error) {
|
|
calls.Add(1)
|
|
return nil, retryableNetworkErr()
|
|
}
|
|
|
|
// A stop request arriving while the API is unreachable. Before this was
|
|
// cancellable, the retry loop kept running after the service reported itself
|
|
// stopped, which is what kept the incident's process alive and enforcing.
|
|
stopCh := make(chan struct{})
|
|
ctx, cancel := contextFromStopCh(stopCh)
|
|
defer cancel()
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
cfg := ctrld.Config{}
|
|
_, err := processCDFlags(ctx, &cfg)
|
|
done <- err
|
|
}()
|
|
|
|
// Let it fail at least once and settle into backoff before stopping.
|
|
deadline := time.After(10 * time.Second)
|
|
for calls.Load() == 0 {
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("resolver config was never fetched")
|
|
case err := <-done:
|
|
t.Fatalf("processCDFlags returned before any fetch: %v", err)
|
|
default:
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
close(stopCh)
|
|
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("processCDFlags err = %v, want it to report %v", err, context.Canceled)
|
|
}
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("processCDFlags did not return after the stop request")
|
|
}
|
|
}
|
|
|
|
func TestProcessCDFlagsReturnsImmediatelyWhenAlreadyCancelled(t *testing.T) {
|
|
oldFetch := fetchResolverConfig
|
|
oldUID := cdUID
|
|
t.Cleanup(func() {
|
|
fetchResolverConfig = oldFetch
|
|
cdUID = oldUID
|
|
})
|
|
cdUID = "testuid"
|
|
|
|
var calls atomic.Int64
|
|
fetchResolverConfig = func(ctx context.Context, req *controld.ResolverConfigRequest, dev bool) (*controld.ResolverConfig, error) {
|
|
calls.Add(1)
|
|
return nil, retryableNetworkErr()
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
cfg := ctrld.Config{}
|
|
_, err := processCDFlags(ctx, &cfg)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("processCDFlags err = %v, want %v", err, context.Canceled)
|
|
}
|
|
// One attempt is made before the loop notices; it must not retry past that.
|
|
if got := calls.Load(); got > 1 {
|
|
t.Errorf("fetched %d times with a cancelled context, want at most 1", got)
|
|
}
|
|
}
|
|
|
|
// TestRunAPIPreflightClassification is the regression guard for classifying a preflight
|
|
// failure as an operator stop.
|
|
//
|
|
// runAPIPreflight cancels the context it derived from stopCh. Sampling the stop state
|
|
// from that context afterwards reports "stopped" unconditionally, because
|
|
// context.CancelFunc sets ctx.Err() whether or not anyone asked to stop. run() then
|
|
// takes the stop branch for every failure, which skips self-uninstalling a deleted
|
|
// device, skips the mobile exit callback, and tells the service manager a failed start
|
|
// was a clean exit.
|
|
func TestRunAPIPreflightClassification(t *testing.T) {
|
|
oldFetch := fetchResolverConfig
|
|
oldUID := cdUID
|
|
t.Cleanup(func() {
|
|
fetchResolverConfig = oldFetch
|
|
cdUID = oldUID
|
|
})
|
|
cdUID = "testuid"
|
|
|
|
// A deleted ControlD device: non-retryable, so preflight returns promptly.
|
|
deletedDevice := func() error {
|
|
e := &controld.ErrorResponse{}
|
|
e.ErrorField.Code = controld.InvalidConfigCode
|
|
e.ErrorField.Message = "device does not exist"
|
|
return e
|
|
}
|
|
|
|
openCh := make(chan struct{})
|
|
closedCh := make(chan struct{})
|
|
close(closedCh)
|
|
|
|
tests := []struct {
|
|
name string
|
|
stopCh <-chan struct{}
|
|
fetchErr func() error
|
|
wantStop bool
|
|
}{
|
|
{
|
|
// The P1: no stop was requested, so this must reach the failure branch.
|
|
name: "api error with no stop request",
|
|
stopCh: openCh,
|
|
fetchErr: deletedDevice,
|
|
},
|
|
{
|
|
// Mobile passes no stop channel at all, so it could never have stopped.
|
|
name: "api error with a nil stop channel",
|
|
stopCh: nil,
|
|
fetchErr: deletedDevice,
|
|
},
|
|
{
|
|
name: "stop requested during preflight",
|
|
stopCh: closedCh,
|
|
fetchErr: func() error { return retryableNetworkErr() },
|
|
wantStop: true,
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
|
|
return nil, tc.fetchErr()
|
|
}
|
|
cfg := ctrld.Config{}
|
|
pf := runAPIPreflight(tc.stopCh, &cfg)
|
|
|
|
if pf.err == nil {
|
|
t.Fatal("expected preflight to fail")
|
|
}
|
|
if pf.stopRequested != tc.wantStop {
|
|
t.Errorf("stopRequested = %v, want %v", pf.stopRequested, tc.wantStop)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRunAPIPreflightPreservesAPIError verifies the error reaches the caller in a form
|
|
// the failure branch can still act on: self-uninstall keys off an *ErrorResponse with
|
|
// InvalidConfigCode, and it only runs if that error is both classified as a failure and
|
|
// still unwrappable.
|
|
func TestRunAPIPreflightPreservesAPIError(t *testing.T) {
|
|
oldFetch := fetchResolverConfig
|
|
oldUID := cdUID
|
|
t.Cleanup(func() {
|
|
fetchResolverConfig = oldFetch
|
|
cdUID = oldUID
|
|
})
|
|
cdUID = "testuid"
|
|
|
|
want := &controld.ErrorResponse{}
|
|
want.ErrorField.Code = controld.InvalidConfigCode
|
|
fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
|
|
return nil, want
|
|
}
|
|
|
|
cfg := ctrld.Config{}
|
|
pf := runAPIPreflight(make(chan struct{}), &cfg)
|
|
|
|
if pf.stopRequested {
|
|
t.Error("a device-deleted failure must not be reported as an operator stop")
|
|
}
|
|
var got *controld.ErrorResponse
|
|
if !errors.As(pf.err, &got) {
|
|
t.Fatalf("error no longer unwraps to *controld.ErrorResponse: %v", pf.err)
|
|
}
|
|
if got.ErrorField.Code != controld.InvalidConfigCode {
|
|
t.Errorf("code = %d, want %d (self-uninstall would not trigger)", got.ErrorField.Code, controld.InvalidConfigCode)
|
|
}
|
|
}
|
|
|
|
// TestPermanentAPIRejectionNarrowsToClientErrors is the regression guard for the clean
|
|
// exit added above.
|
|
//
|
|
// controld builds an *ErrorResponse for any non-200 whose body decodes, so the Go type
|
|
// says nothing about whether the API's answer will change on a retry. Keying the clean
|
|
// exit off the type alone meant a 502 from a load balancer, or an API having a bad ten
|
|
// minutes, stopped ctrld on every affected host with no service-manager retry behind it -
|
|
// worse than the abnormal exit it replaced, because a Fatal at least gets restarted.
|
|
//
|
|
// Only a client-error status may take that path.
|
|
func TestPermanentAPIRejectionNarrowsToClientErrors(t *testing.T) {
|
|
rejection := func(status, code int) error {
|
|
e := &controld.ErrorResponse{StatusCode: status}
|
|
e.ErrorField.Code = code
|
|
e.ErrorField.Message = "api said no"
|
|
return e
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
wantPermanent bool
|
|
}{
|
|
{
|
|
// The case the clean exit exists for: the device is gone, and every restart
|
|
// will be told the same thing.
|
|
name: "deleted device",
|
|
err: rejection(http.StatusNotFound, controld.InvalidConfigCode),
|
|
wantPermanent: true,
|
|
},
|
|
{"revoked credentials", rejection(http.StatusUnauthorized, 0), true},
|
|
{"forbidden", rejection(http.StatusForbidden, 0), true},
|
|
{"malformed request", rejection(http.StatusBadRequest, 0), true},
|
|
|
|
// Server-side trouble. These must keep the abnormal exit so the service
|
|
// manager's recovery policy retries.
|
|
{"bad gateway", rejection(http.StatusBadGateway, 0), false},
|
|
{"internal error", rejection(http.StatusInternalServerError, 0), false},
|
|
{"service unavailable", rejection(http.StatusServiceUnavailable, 0), false},
|
|
|
|
// 4xx, but both are the API asking for a later attempt rather than refusing
|
|
// this configuration.
|
|
{"request timeout", rejection(http.StatusRequestTimeout, 0), false},
|
|
{"rate limited", rejection(http.StatusTooManyRequests, 0), false},
|
|
|
|
// An *ErrorResponse built without a recorded status carries no verdict. A
|
|
// hand-constructed one, or a decode path that forgets to record the status,
|
|
// must not silently gain the clean exit.
|
|
{"no recorded status", rejection(0, controld.InvalidConfigCode), false},
|
|
|
|
// Not an API answer at all: the incident's denied socket reaches Fatal.
|
|
{"network failure", retryableNetworkErr(), false},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, ok := permanentAPIRejection(tc.err)
|
|
if ok != tc.wantPermanent {
|
|
t.Errorf("permanentAPIRejection() = %v, want %v", ok, tc.wantPermanent)
|
|
}
|
|
if ok && got == nil {
|
|
t.Error("a permanent rejection must return the rejection for reporting")
|
|
}
|
|
})
|
|
}
|
|
|
|
// The wrapped form matters too: preflight composes the fetch error, and errors.As has
|
|
// to reach through that for either branch to be chosen correctly.
|
|
wrapped := fmt.Errorf("processCDFlags: %w", rejection(http.StatusNotFound, controld.InvalidConfigCode))
|
|
if _, ok := permanentAPIRejection(wrapped); !ok {
|
|
t.Error("a wrapped API rejection must still be recognised")
|
|
}
|
|
wrappedTransient := fmt.Errorf("processCDFlags: %w", rejection(http.StatusBadGateway, 0))
|
|
if _, ok := permanentAPIRejection(wrappedTransient); ok {
|
|
t.Error("a wrapped 502 must not be treated as a permanent rejection")
|
|
}
|
|
}
|
|
|
|
func TestStopRequested(t *testing.T) {
|
|
closedCh := make(chan struct{})
|
|
close(closedCh)
|
|
|
|
if stopRequested(nil) {
|
|
t.Error("a nil stop channel must read as no stop (mobile passes none)")
|
|
}
|
|
if stopRequested(make(chan struct{})) {
|
|
t.Error("an open stop channel must read as no stop")
|
|
}
|
|
if !stopRequested(closedCh) {
|
|
t.Error("a closed stop channel must read as a stop")
|
|
}
|
|
}
|
|
|
|
// TestReloadFetchIsBoundedByServiceLifetime covers the reload path's stop wiring.
|
|
//
|
|
// Reload fetches the ControlD config too, and it used to build the bounded context
|
|
// itself. Nothing tested that: the wrong channel, or a dropped cancel, would have left a
|
|
// reload retrying against an unreachable API after "Service stopped" was logged, and no
|
|
// test would have failed. The reload path still binds its runtime-only metadata fetch to
|
|
// the service lifetime, so this pins both cancellation and watcher cleanup.
|
|
func TestReloadFetchIsBoundedByServiceLifetime(t *testing.T) {
|
|
original := processCDFlagsRuntimeFn
|
|
t.Cleanup(func() { processCDFlagsRuntimeFn = original })
|
|
|
|
t.Run("a stop request cancels the reload fetch", func(t *testing.T) {
|
|
stopCh := make(chan struct{})
|
|
close(stopCh)
|
|
|
|
var sawCancelled bool
|
|
processCDFlagsRuntimeFn = func(ctx context.Context, _ *ctrld.Config) (*controld.ResolverConfig, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
sawCancelled = true
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
p := &prog{stopCh: stopCh}
|
|
if _, err := p.fetchCDConfigBoundedByLifetime(&ctrld.Config{}); !errors.Is(err, context.Canceled) {
|
|
t.Errorf("reload fetch err = %v, want %v", err, context.Canceled)
|
|
}
|
|
if !sawCancelled {
|
|
t.Error("the reload fetch did not observe the stop request: it is not bound to the service lifetime")
|
|
}
|
|
})
|
|
|
|
t.Run("the derived context is always released", func(t *testing.T) {
|
|
// stopCh stays open: the fetch's own cancel is what must end the watcher, or
|
|
// every reload leaks a goroutine.
|
|
var captured context.Context
|
|
processCDFlagsRuntimeFn = func(ctx context.Context, _ *ctrld.Config) (*controld.ResolverConfig, error) {
|
|
captured = ctx
|
|
return nil, nil
|
|
}
|
|
|
|
p := &prog{stopCh: make(chan struct{})}
|
|
if _, err := p.fetchCDConfigBoundedByLifetime(&ctrld.Config{}); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
select {
|
|
case <-captured.Done():
|
|
case <-time.After(time.Second):
|
|
t.Error("the reload fetch left its context uncancelled")
|
|
}
|
|
})
|
|
}
|