tailscale/kube/state/state_test.go
Tom Proctor f421907c38
all-kube: create Tailscale Service for HA kube-apiserver ProxyGroup (#16572)
Adds a new reconciler for ProxyGroups of type kube-apiserver that will
provision a Tailscale Service for each replica to advertise. Adds two
new condition types to the ProxyGroup, TailscaleServiceValid and
TailscaleServiceConfigured, to post updates on the state of that
reconciler in a way that's consistent with the service-pg reconciler.
The created Tailscale Service name is configurable via a new ProxyGroup
field spec.kubeAPISserver.ServiceName, which expects a string of the
form "svc:<dns-label>".

Lots of supporting changes were needed to implement this in a way that's
consistent with other operator workflows, including:

* Pulled containerboot's ensureServicesUnadvertised and certManager into
  kube/ libraries to be shared with k8s-proxy. Use those in k8s-proxy to
  aid Service cert sharing between replicas and graceful Service shutdown.
* For certManager, add an initial wait to the cert loop to wait until
  the domain appears in the devices's netmap to avoid a guaranteed error
  on the first issue attempt when it's quick to start.
* Made several methods in ingress-for-pg.go and svc-for-pg.go into
  functions to share with the new reconciler
* Added a Resource struct to the owner refs stored in Tailscale Service
  annotations to be able to distinguish between Ingress- and ProxyGroup-
  based Services that need cleaning up in the Tailscale API.
* Added a ListVIPServices method to the internal tailscale client to aid
  cleaning up orphaned Services
* Support for reading config from a kube Secret, and partial support for
  config reloading, to prevent us having to force Pod restarts when
  config changes.
* Fixed up the zap logger so it's possible to set debug log level.

Updates #13358

Change-Id: Ia9607441157dd91fb9b6ecbc318eecbef446e116
Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
2025-07-21 11:03:21 +01:00

197 lines
4.9 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package state
import (
"bytes"
"fmt"
"net/netip"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"tailscale.com/ipn"
"tailscale.com/ipn/store"
klc "tailscale.com/kube/localclient"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
)
func TestSetInitialStateKeys(t *testing.T) {
var (
podUID = []byte("test-pod-uid")
expectedCapVer = fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion)
)
for name, tc := range map[string]struct {
initial map[ipn.StateKey][]byte
expected map[ipn.StateKey][]byte
}{
"empty_initial": {
initial: map[ipn.StateKey][]byte{},
expected: map[ipn.StateKey][]byte{
keyPodUID: podUID,
keyCapVer: expectedCapVer,
},
},
"existing_pod_uid_and_capver": {
initial: map[ipn.StateKey][]byte{
keyPodUID: podUID,
keyCapVer: expectedCapVer,
},
expected: map[ipn.StateKey][]byte{
keyPodUID: podUID,
keyCapVer: expectedCapVer,
},
},
"all_keys_preexisting": {
initial: map[ipn.StateKey][]byte{
keyPodUID: podUID,
keyCapVer: expectedCapVer,
keyDeviceID: []byte("existing-device-id"),
keyDeviceFQDN: []byte("existing-device-fqdn"),
keyDeviceIPs: []byte(`["1.2.3.4"]`),
},
expected: map[ipn.StateKey][]byte{
keyPodUID: podUID,
keyCapVer: expectedCapVer,
keyDeviceID: nil,
keyDeviceFQDN: nil,
keyDeviceIPs: nil,
},
},
} {
t.Run(name, func(t *testing.T) {
store, err := store.New(logger.Discard, "mem:")
if err != nil {
t.Fatalf("error creating in-memory store: %v", err)
}
for key, value := range tc.initial {
if err := store.WriteState(key, value); err != nil {
t.Fatalf("error writing initial state key %q: %v", key, err)
}
}
if err := SetInitialKeys(store, string(podUID)); err != nil {
t.Fatalf("setInitialStateKeys failed: %v", err)
}
actual := make(map[ipn.StateKey][]byte)
for expectedKey, expectedValue := range tc.expected {
actualValue, err := store.ReadState(expectedKey)
if err != nil {
t.Errorf("error reading state key %q: %v", expectedKey, err)
continue
}
actual[expectedKey] = actualValue
if !bytes.Equal(actualValue, expectedValue) {
t.Errorf("state key %q mismatch: expected %q, got %q", expectedKey, expectedValue, actualValue)
}
}
if diff := cmp.Diff(actual, tc.expected); diff != "" {
t.Errorf("state keys mismatch (-got +want):\n%s", diff)
}
})
}
}
func TestKeepStateKeysUpdated(t *testing.T) {
store := fakeStore{
writeChan: make(chan string),
}
errs := make(chan error)
notifyChan := make(chan ipn.Notify)
lc := &klc.FakeLocalClient{
FakeIPNBusWatcher: klc.FakeIPNBusWatcher{
NotifyChan: notifyChan,
},
}
go func() {
err := KeepKeysUpdated(t.Context(), store, lc)
if err != nil {
errs <- fmt.Errorf("keepStateKeysUpdated returned with error: %w", err)
}
}()
for _, tc := range []struct {
name string
notify ipn.Notify
expected []string
}{
{
name: "initial_not_authed",
notify: ipn.Notify{},
expected: nil,
},
{
name: "authed",
notify: ipn.Notify{
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: "TESTCTRL00000001",
Name: "test-node.test.ts.net",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), netip.MustParsePrefix("fd7a:115c:a1e0:ab12:4843:cd96:0:1/128")},
}).View(),
},
},
expected: []string{
fmt.Sprintf("%s=%s", keyDeviceID, "TESTCTRL00000001"),
fmt.Sprintf("%s=%s", keyDeviceFQDN, "test-node.test.ts.net"),
fmt.Sprintf("%s=%s", keyDeviceIPs, `["100.64.0.1","fd7a:115c:a1e0:ab12:4843:cd96:0:1"]`),
},
},
{
name: "updated_fields",
notify: ipn.Notify{
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: "TESTCTRL00000001",
Name: "updated.test.ts.net",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.250/32")},
}).View(),
},
},
expected: []string{
fmt.Sprintf("%s=%s", keyDeviceFQDN, "updated.test.ts.net"),
fmt.Sprintf("%s=%s", keyDeviceIPs, `["100.64.0.250"]`),
},
},
} {
t.Run(tc.name, func(t *testing.T) {
notifyChan <- tc.notify
for _, expected := range tc.expected {
select {
case got := <-store.writeChan:
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
case err := <-errs:
t.Fatalf("unexpected error: %v", err)
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for expected write %q", expected)
}
}
})
}
}
type fakeStore struct {
writeChan chan string
}
func (f fakeStore) ReadState(key ipn.StateKey) ([]byte, error) {
return nil, fmt.Errorf("ReadState not implemented")
}
func (f fakeStore) WriteState(key ipn.StateKey, value []byte) error {
f.writeChan <- fmt.Sprintf("%s=%s", key, value)
return nil
}