api-server-proxy-pg_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. // Copyright (c) Tailscale Inc & AUTHORS
  2. // SPDX-License-Identifier: BSD-3-Clause
  3. package main
  4. import (
  5. "encoding/json"
  6. "reflect"
  7. "strings"
  8. "testing"
  9. "github.com/google/go-cmp/cmp"
  10. "go.uber.org/zap"
  11. corev1 "k8s.io/api/core/v1"
  12. rbacv1 "k8s.io/api/rbac/v1"
  13. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  14. "k8s.io/client-go/tools/record"
  15. "sigs.k8s.io/controller-runtime/pkg/client/fake"
  16. "tailscale.com/internal/client/tailscale"
  17. "tailscale.com/ipn/ipnstate"
  18. tsoperator "tailscale.com/k8s-operator"
  19. tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
  20. "tailscale.com/kube/k8s-proxy/conf"
  21. "tailscale.com/kube/kubetypes"
  22. "tailscale.com/tailcfg"
  23. "tailscale.com/tstest"
  24. "tailscale.com/types/opt"
  25. "tailscale.com/types/ptr"
  26. )
  27. func TestAPIServerProxyReconciler(t *testing.T) {
  28. const (
  29. pgName = "test-pg"
  30. pgUID = "test-pg-uid"
  31. ns = "operator-ns"
  32. defaultDomain = "test-pg.ts.net"
  33. )
  34. pg := &tsapi.ProxyGroup{
  35. ObjectMeta: metav1.ObjectMeta{
  36. Name: pgName,
  37. Generation: 1,
  38. UID: pgUID,
  39. },
  40. Spec: tsapi.ProxyGroupSpec{
  41. Type: tsapi.ProxyGroupTypeKubernetesAPIServer,
  42. },
  43. Status: tsapi.ProxyGroupStatus{
  44. Conditions: []metav1.Condition{
  45. {
  46. Type: string(tsapi.ProxyGroupAvailable),
  47. Status: metav1.ConditionTrue,
  48. ObservedGeneration: 1,
  49. },
  50. },
  51. },
  52. }
  53. initialCfg := &conf.VersionedConfig{
  54. Version: "v1alpha1",
  55. ConfigV1Alpha1: &conf.ConfigV1Alpha1{
  56. AuthKey: ptr.To("test-key"),
  57. APIServerProxy: &conf.APIServerProxyConfig{
  58. Enabled: opt.NewBool(true),
  59. },
  60. },
  61. }
  62. expectedCfg := *initialCfg
  63. initialCfgB, err := json.Marshal(initialCfg)
  64. if err != nil {
  65. t.Fatalf("marshaling initial config: %v", err)
  66. }
  67. pgCfgSecret := &corev1.Secret{
  68. ObjectMeta: metav1.ObjectMeta{
  69. Name: pgConfigSecretName(pgName, 0),
  70. Namespace: ns,
  71. Labels: pgSecretLabels(pgName, kubetypes.LabelSecretTypeConfig),
  72. },
  73. Data: map[string][]byte{
  74. // Existing config should be preserved.
  75. kubetypes.KubeAPIServerConfigFile: initialCfgB,
  76. },
  77. }
  78. fc := fake.NewClientBuilder().
  79. WithScheme(tsapi.GlobalScheme).
  80. WithObjects(pg, pgCfgSecret).
  81. WithStatusSubresource(pg).
  82. Build()
  83. expectCfg := func(c *conf.VersionedConfig) {
  84. t.Helper()
  85. cBytes, err := json.Marshal(c)
  86. if err != nil {
  87. t.Fatalf("marshaling expected config: %v", err)
  88. }
  89. pgCfgSecret.Data[kubetypes.KubeAPIServerConfigFile] = cBytes
  90. expectEqual(t, fc, pgCfgSecret)
  91. }
  92. ft := &fakeTSClient{}
  93. ingressTSSvc := &tailscale.VIPService{
  94. Name: "svc:some-ingress-hostname",
  95. Comment: managedTSServiceComment,
  96. Annotations: map[string]string{
  97. // No resource field.
  98. ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id"}]}`,
  99. },
  100. Ports: []string{"tcp:443"},
  101. Tags: []string{"tag:k8s"},
  102. Addrs: []string{"5.6.7.8"},
  103. }
  104. ft.CreateOrUpdateVIPService(t.Context(), ingressTSSvc)
  105. lc := &fakeLocalClient{
  106. status: &ipnstate.Status{
  107. CurrentTailnet: &ipnstate.TailnetStatus{
  108. MagicDNSSuffix: "ts.net",
  109. },
  110. },
  111. }
  112. r := &KubeAPIServerTSServiceReconciler{
  113. Client: fc,
  114. tsClient: ft,
  115. defaultTags: []string{"tag:k8s"},
  116. tsNamespace: ns,
  117. logger: zap.Must(zap.NewDevelopment()).Sugar(),
  118. recorder: record.NewFakeRecorder(10),
  119. lc: lc,
  120. clock: tstest.NewClock(tstest.ClockOpts{}),
  121. operatorID: "self-id",
  122. }
  123. // Create a Tailscale Service that will conflict with the initial config.
  124. if err := ft.CreateOrUpdateVIPService(t.Context(), &tailscale.VIPService{
  125. Name: "svc:" + pgName,
  126. }); err != nil {
  127. t.Fatalf("creating initial Tailscale Service: %v", err)
  128. }
  129. expectReconciled(t, r, "", pgName)
  130. pg.ObjectMeta.Finalizers = []string{proxyPGFinalizerName}
  131. tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyValid, metav1.ConditionFalse, reasonKubeAPIServerProxyInvalid, "", 1, r.clock, r.logger)
  132. tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionFalse, reasonKubeAPIServerProxyNoBackends, "", 1, r.clock, r.logger)
  133. expectEqual(t, fc, pg, omitPGStatusConditionMessages)
  134. expectMissing[corev1.Secret](t, fc, ns, defaultDomain)
  135. expectMissing[rbacv1.Role](t, fc, ns, defaultDomain)
  136. expectMissing[rbacv1.RoleBinding](t, fc, ns, defaultDomain)
  137. expectEqual(t, fc, pgCfgSecret) // Unchanged.
  138. // Delete Tailscale Service; should see Service created and valid condition updated to true.
  139. if err := ft.DeleteVIPService(t.Context(), "svc:"+pgName); err != nil {
  140. t.Fatalf("deleting initial Tailscale Service: %v", err)
  141. }
  142. expectReconciled(t, r, "", pgName)
  143. tsSvc, err := ft.GetVIPService(t.Context(), "svc:"+pgName)
  144. if err != nil {
  145. t.Fatalf("getting Tailscale Service: %v", err)
  146. }
  147. if tsSvc == nil {
  148. t.Fatalf("expected Tailscale Service to be created, but got nil")
  149. }
  150. expectedTSSvc := &tailscale.VIPService{
  151. Name: "svc:" + pgName,
  152. Comment: managedTSServiceComment,
  153. Annotations: map[string]string{
  154. ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"test-pg","uid":"test-pg-uid"}}]}`,
  155. },
  156. Ports: []string{"tcp:443"},
  157. Tags: []string{"tag:k8s"},
  158. Addrs: []string{"5.6.7.8"},
  159. }
  160. if !reflect.DeepEqual(tsSvc, expectedTSSvc) {
  161. t.Fatalf("expected Tailscale Service to be %+v, got %+v", expectedTSSvc, tsSvc)
  162. }
  163. tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyValid, metav1.ConditionTrue, reasonKubeAPIServerProxyValid, "", 1, r.clock, r.logger)
  164. tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionFalse, reasonKubeAPIServerProxyNoBackends, "", 1, r.clock, r.logger)
  165. expectEqual(t, fc, pg, omitPGStatusConditionMessages)
  166. expectedCfg.APIServerProxy.ServiceName = ptr.To(tailcfg.ServiceName("svc:" + pgName))
  167. expectCfg(&expectedCfg)
  168. expectEqual(t, fc, certSecret(pgName, ns, defaultDomain, pg))
  169. expectEqual(t, fc, certSecretRole(pgName, ns, defaultDomain))
  170. expectEqual(t, fc, certSecretRoleBinding(pg, ns, defaultDomain))
  171. // Simulate certs being issued; should observe AdvertiseServices config change.
  172. populateTLSSecret(t, fc, pgName, defaultDomain)
  173. expectReconciled(t, r, "", pgName)
  174. expectedCfg.AdvertiseServices = []string{"svc:" + pgName}
  175. expectCfg(&expectedCfg)
  176. expectEqual(t, fc, pg, omitPGStatusConditionMessages) // Unchanged status.
  177. // Simulate Pod prefs updated with advertised services; should see Configured condition updated to true.
  178. mustCreate(t, fc, &corev1.Secret{
  179. ObjectMeta: metav1.ObjectMeta{
  180. Name: "test-pg-0",
  181. Namespace: ns,
  182. Labels: pgSecretLabels(pgName, kubetypes.LabelSecretTypeState),
  183. },
  184. Data: map[string][]byte{
  185. "_current-profile": []byte("profile-foo"),
  186. "profile-foo": []byte(`{"AdvertiseServices":["svc:test-pg"],"Config":{"NodeID":"node-foo"}}`),
  187. },
  188. })
  189. expectReconciled(t, r, "", pgName)
  190. tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionTrue, reasonKubeAPIServerProxyConfigured, "", 1, r.clock, r.logger)
  191. pg.Status.URL = "https://" + defaultDomain
  192. expectEqual(t, fc, pg, omitPGStatusConditionMessages)
  193. // Rename the Tailscale Service - old one + cert resources should be cleaned up.
  194. updatedServiceName := tailcfg.ServiceName("svc:test-pg-renamed")
  195. updatedDomain := "test-pg-renamed.ts.net"
  196. pg.Spec.KubeAPIServer = &tsapi.KubeAPIServerConfig{
  197. Hostname: updatedServiceName.WithoutPrefix(),
  198. }
  199. mustUpdate(t, fc, "", pgName, func(p *tsapi.ProxyGroup) {
  200. p.Spec.KubeAPIServer = pg.Spec.KubeAPIServer
  201. })
  202. expectReconciled(t, r, "", pgName)
  203. _, err = ft.GetVIPService(t.Context(), "svc:"+pgName)
  204. if !isErrorTailscaleServiceNotFound(err) {
  205. t.Fatalf("Expected 404, got: %v", err)
  206. }
  207. tsSvc, err = ft.GetVIPService(t.Context(), updatedServiceName)
  208. if err != nil {
  209. t.Fatalf("Expected renamed svc, got error: %v", err)
  210. }
  211. expectedTSSvc.Name = updatedServiceName
  212. if !reflect.DeepEqual(tsSvc, expectedTSSvc) {
  213. t.Fatalf("expected Tailscale Service to be %+v, got %+v", expectedTSSvc, tsSvc)
  214. }
  215. // Check cfg and status reset until TLS certs are available again.
  216. expectedCfg.APIServerProxy.ServiceName = ptr.To(updatedServiceName)
  217. expectedCfg.AdvertiseServices = nil
  218. expectCfg(&expectedCfg)
  219. tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionFalse, reasonKubeAPIServerProxyNoBackends, "", 1, r.clock, r.logger)
  220. pg.Status.URL = ""
  221. expectEqual(t, fc, pg, omitPGStatusConditionMessages)
  222. expectEqual(t, fc, certSecret(pgName, ns, updatedDomain, pg))
  223. expectEqual(t, fc, certSecretRole(pgName, ns, updatedDomain))
  224. expectEqual(t, fc, certSecretRoleBinding(pg, ns, updatedDomain))
  225. expectMissing[corev1.Secret](t, fc, ns, defaultDomain)
  226. expectMissing[rbacv1.Role](t, fc, ns, defaultDomain)
  227. expectMissing[rbacv1.RoleBinding](t, fc, ns, defaultDomain)
  228. // Check we get the new hostname in the status once ready.
  229. populateTLSSecret(t, fc, pgName, updatedDomain)
  230. mustUpdate(t, fc, "operator-ns", "test-pg-0", func(s *corev1.Secret) {
  231. s.Data["profile-foo"] = []byte(`{"AdvertiseServices":["svc:test-pg"],"Config":{"NodeID":"node-foo"}}`)
  232. })
  233. expectReconciled(t, r, "", pgName)
  234. expectedCfg.AdvertiseServices = []string{updatedServiceName.String()}
  235. expectCfg(&expectedCfg)
  236. tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionTrue, reasonKubeAPIServerProxyConfigured, "", 1, r.clock, r.logger)
  237. pg.Status.URL = "https://" + updatedDomain
  238. // Delete the ProxyGroup and verify Tailscale Service and cert resources are cleaned up.
  239. if err := fc.Delete(t.Context(), pg); err != nil {
  240. t.Fatalf("deleting ProxyGroup: %v", err)
  241. }
  242. expectReconciled(t, r, "", pgName)
  243. expectMissing[corev1.Secret](t, fc, ns, updatedDomain)
  244. expectMissing[rbacv1.Role](t, fc, ns, updatedDomain)
  245. expectMissing[rbacv1.RoleBinding](t, fc, ns, updatedDomain)
  246. _, err = ft.GetVIPService(t.Context(), updatedServiceName)
  247. if !isErrorTailscaleServiceNotFound(err) {
  248. t.Fatalf("Expected 404, got: %v", err)
  249. }
  250. // Ingress Tailscale Service should not be affected.
  251. svc, err := ft.GetVIPService(t.Context(), ingressTSSvc.Name)
  252. if err != nil {
  253. t.Fatalf("getting ingress Tailscale Service: %v", err)
  254. }
  255. if !reflect.DeepEqual(svc, ingressTSSvc) {
  256. t.Fatalf("expected ingress Tailscale Service to be unmodified %+v, got %+v", ingressTSSvc, svc)
  257. }
  258. }
  259. func TestExclusiveOwnerAnnotations(t *testing.T) {
  260. pg := &tsapi.ProxyGroup{
  261. ObjectMeta: metav1.ObjectMeta{
  262. Name: "pg1",
  263. UID: "pg1-uid",
  264. },
  265. }
  266. const (
  267. selfOperatorID = "self-id"
  268. pg1Owner = `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"pg1","uid":"pg1-uid"}}]}`
  269. )
  270. for name, tc := range map[string]struct {
  271. svc *tailscale.VIPService
  272. wantErr string
  273. }{
  274. "no_svc": {
  275. svc: nil,
  276. },
  277. "empty_svc": {
  278. svc: &tailscale.VIPService{},
  279. wantErr: "likely a resource created by something other than the Tailscale Kubernetes operator",
  280. },
  281. "already_owner": {
  282. svc: &tailscale.VIPService{
  283. Annotations: map[string]string{
  284. ownerAnnotation: pg1Owner,
  285. },
  286. },
  287. },
  288. "already_owner_name_updated": {
  289. svc: &tailscale.VIPService{
  290. Annotations: map[string]string{
  291. ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"old-pg1-name","uid":"pg1-uid"}}]}`,
  292. },
  293. },
  294. },
  295. "preserves_existing_annotations": {
  296. svc: &tailscale.VIPService{
  297. Annotations: map[string]string{
  298. "existing": "annotation",
  299. ownerAnnotation: pg1Owner,
  300. },
  301. },
  302. },
  303. "owned_by_another_operator": {
  304. svc: &tailscale.VIPService{
  305. Annotations: map[string]string{
  306. ownerAnnotation: `{"ownerRefs":[{"operatorID":"operator-2"}]}`,
  307. },
  308. },
  309. wantErr: "already owned by other operator(s)",
  310. },
  311. "owned_by_an_ingress": {
  312. svc: &tailscale.VIPService{
  313. Annotations: map[string]string{
  314. ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id"}]}`, // Ingress doesn't set Resource field (yet).
  315. },
  316. },
  317. wantErr: "does not reference an owning resource",
  318. },
  319. "owned_by_another_pg": {
  320. svc: &tailscale.VIPService{
  321. Annotations: map[string]string{
  322. ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"pg2","uid":"pg2-uid"}}]}`,
  323. },
  324. },
  325. wantErr: "already owned by another resource",
  326. },
  327. } {
  328. t.Run(name, func(t *testing.T) {
  329. got, err := exclusiveOwnerAnnotations(pg, "self-id", tc.svc)
  330. if tc.wantErr != "" {
  331. if !strings.Contains(err.Error(), tc.wantErr) {
  332. t.Errorf("exclusiveOwnerAnnotations() error = %v, wantErr %v", err, tc.wantErr)
  333. }
  334. } else if diff := cmp.Diff(pg1Owner, got[ownerAnnotation]); diff != "" {
  335. t.Errorf("exclusiveOwnerAnnotations() mismatch (-want +got):\n%s", diff)
  336. }
  337. if tc.svc == nil {
  338. return // Don't check annotations being preserved.
  339. }
  340. for k, v := range tc.svc.Annotations {
  341. if k == ownerAnnotation {
  342. continue
  343. }
  344. if got[k] != v {
  345. t.Errorf("exclusiveOwnerAnnotations() did not preserve annotation %q: got %q, want %q", k, got[k], v)
  346. }
  347. }
  348. })
  349. }
  350. }
  351. func omitPGStatusConditionMessages(p *tsapi.ProxyGroup) {
  352. for i := range p.Status.Conditions {
  353. // Don't bother validating the message.
  354. p.Status.Conditions[i].Message = ""
  355. }
  356. }