api-server-proxy-pg_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  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. if err := populateTLSSecret(t.Context(), fc, pgName, defaultDomain); err != nil {
  173. t.Fatalf("populating TLS Secret: %v", err)
  174. }
  175. expectReconciled(t, r, "", pgName)
  176. expectedCfg.AdvertiseServices = []string{"svc:" + pgName}
  177. expectCfg(&expectedCfg)
  178. expectEqual(t, fc, pg, omitPGStatusConditionMessages) // Unchanged status.
  179. // Simulate Pod prefs updated with advertised services; should see Configured condition updated to true.
  180. mustCreate(t, fc, &corev1.Secret{
  181. ObjectMeta: metav1.ObjectMeta{
  182. Name: "test-pg-0",
  183. Namespace: ns,
  184. Labels: pgSecretLabels(pgName, kubetypes.LabelSecretTypeState),
  185. },
  186. Data: map[string][]byte{
  187. "_current-profile": []byte("profile-foo"),
  188. "profile-foo": []byte(`{"AdvertiseServices":["svc:test-pg"],"Config":{"NodeID":"node-foo"}}`),
  189. },
  190. })
  191. expectReconciled(t, r, "", pgName)
  192. tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionTrue, reasonKubeAPIServerProxyConfigured, "", 1, r.clock, r.logger)
  193. pg.Status.URL = "https://" + defaultDomain
  194. expectEqual(t, fc, pg, omitPGStatusConditionMessages)
  195. // Rename the Tailscale Service - old one + cert resources should be cleaned up.
  196. updatedServiceName := tailcfg.ServiceName("svc:test-pg-renamed")
  197. updatedDomain := "test-pg-renamed.ts.net"
  198. pg.Spec.KubeAPIServer = &tsapi.KubeAPIServerConfig{
  199. Hostname: updatedServiceName.WithoutPrefix(),
  200. }
  201. mustUpdate(t, fc, "", pgName, func(p *tsapi.ProxyGroup) {
  202. p.Spec.KubeAPIServer = pg.Spec.KubeAPIServer
  203. })
  204. expectReconciled(t, r, "", pgName)
  205. _, err = ft.GetVIPService(t.Context(), "svc:"+pgName)
  206. if !isErrorTailscaleServiceNotFound(err) {
  207. t.Fatalf("Expected 404, got: %v", err)
  208. }
  209. tsSvc, err = ft.GetVIPService(t.Context(), updatedServiceName)
  210. if err != nil {
  211. t.Fatalf("Expected renamed svc, got error: %v", err)
  212. }
  213. expectedTSSvc.Name = updatedServiceName
  214. if !reflect.DeepEqual(tsSvc, expectedTSSvc) {
  215. t.Fatalf("expected Tailscale Service to be %+v, got %+v", expectedTSSvc, tsSvc)
  216. }
  217. // Check cfg and status reset until TLS certs are available again.
  218. expectedCfg.APIServerProxy.ServiceName = ptr.To(updatedServiceName)
  219. expectedCfg.AdvertiseServices = nil
  220. expectCfg(&expectedCfg)
  221. tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionFalse, reasonKubeAPIServerProxyNoBackends, "", 1, r.clock, r.logger)
  222. pg.Status.URL = ""
  223. expectEqual(t, fc, pg, omitPGStatusConditionMessages)
  224. expectEqual(t, fc, certSecret(pgName, ns, updatedDomain, pg))
  225. expectEqual(t, fc, certSecretRole(pgName, ns, updatedDomain))
  226. expectEqual(t, fc, certSecretRoleBinding(pg, ns, updatedDomain))
  227. expectMissing[corev1.Secret](t, fc, ns, defaultDomain)
  228. expectMissing[rbacv1.Role](t, fc, ns, defaultDomain)
  229. expectMissing[rbacv1.RoleBinding](t, fc, ns, defaultDomain)
  230. // Check we get the new hostname in the status once ready.
  231. if err := populateTLSSecret(t.Context(), fc, pgName, updatedDomain); err != nil {
  232. t.Fatalf("populating TLS Secret: %v", err)
  233. }
  234. mustUpdate(t, fc, "operator-ns", "test-pg-0", func(s *corev1.Secret) {
  235. s.Data["profile-foo"] = []byte(`{"AdvertiseServices":["svc:test-pg"],"Config":{"NodeID":"node-foo"}}`)
  236. })
  237. expectReconciled(t, r, "", pgName)
  238. expectedCfg.AdvertiseServices = []string{updatedServiceName.String()}
  239. expectCfg(&expectedCfg)
  240. tsoperator.SetProxyGroupCondition(pg, tsapi.KubeAPIServerProxyConfigured, metav1.ConditionTrue, reasonKubeAPIServerProxyConfigured, "", 1, r.clock, r.logger)
  241. pg.Status.URL = "https://" + updatedDomain
  242. // Delete the ProxyGroup and verify Tailscale Service and cert resources are cleaned up.
  243. if err := fc.Delete(t.Context(), pg); err != nil {
  244. t.Fatalf("deleting ProxyGroup: %v", err)
  245. }
  246. expectReconciled(t, r, "", pgName)
  247. expectMissing[corev1.Secret](t, fc, ns, updatedDomain)
  248. expectMissing[rbacv1.Role](t, fc, ns, updatedDomain)
  249. expectMissing[rbacv1.RoleBinding](t, fc, ns, updatedDomain)
  250. _, err = ft.GetVIPService(t.Context(), updatedServiceName)
  251. if !isErrorTailscaleServiceNotFound(err) {
  252. t.Fatalf("Expected 404, got: %v", err)
  253. }
  254. // Ingress Tailscale Service should not be affected.
  255. svc, err := ft.GetVIPService(t.Context(), ingressTSSvc.Name)
  256. if err != nil {
  257. t.Fatalf("getting ingress Tailscale Service: %v", err)
  258. }
  259. if !reflect.DeepEqual(svc, ingressTSSvc) {
  260. t.Fatalf("expected ingress Tailscale Service to be unmodified %+v, got %+v", ingressTSSvc, svc)
  261. }
  262. }
  263. func TestExclusiveOwnerAnnotations(t *testing.T) {
  264. pg := &tsapi.ProxyGroup{
  265. ObjectMeta: metav1.ObjectMeta{
  266. Name: "pg1",
  267. UID: "pg1-uid",
  268. },
  269. }
  270. const (
  271. selfOperatorID = "self-id"
  272. pg1Owner = `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"pg1","uid":"pg1-uid"}}]}`
  273. )
  274. for name, tc := range map[string]struct {
  275. svc *tailscale.VIPService
  276. wantErr string
  277. }{
  278. "no_svc": {
  279. svc: nil,
  280. },
  281. "empty_svc": {
  282. svc: &tailscale.VIPService{},
  283. wantErr: "likely a resource created by something other than the Tailscale Kubernetes operator",
  284. },
  285. "already_owner": {
  286. svc: &tailscale.VIPService{
  287. Annotations: map[string]string{
  288. ownerAnnotation: pg1Owner,
  289. },
  290. },
  291. },
  292. "already_owner_name_updated": {
  293. svc: &tailscale.VIPService{
  294. Annotations: map[string]string{
  295. ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"old-pg1-name","uid":"pg1-uid"}}]}`,
  296. },
  297. },
  298. },
  299. "preserves_existing_annotations": {
  300. svc: &tailscale.VIPService{
  301. Annotations: map[string]string{
  302. "existing": "annotation",
  303. ownerAnnotation: pg1Owner,
  304. },
  305. },
  306. },
  307. "owned_by_another_operator": {
  308. svc: &tailscale.VIPService{
  309. Annotations: map[string]string{
  310. ownerAnnotation: `{"ownerRefs":[{"operatorID":"operator-2"}]}`,
  311. },
  312. },
  313. wantErr: "already owned by other operator(s)",
  314. },
  315. "owned_by_an_ingress": {
  316. svc: &tailscale.VIPService{
  317. Annotations: map[string]string{
  318. ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id"}]}`, // Ingress doesn't set Resource field (yet).
  319. },
  320. },
  321. wantErr: "does not reference an owning resource",
  322. },
  323. "owned_by_another_pg": {
  324. svc: &tailscale.VIPService{
  325. Annotations: map[string]string{
  326. ownerAnnotation: `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"pg2","uid":"pg2-uid"}}]}`,
  327. },
  328. },
  329. wantErr: "already owned by another resource",
  330. },
  331. } {
  332. t.Run(name, func(t *testing.T) {
  333. got, err := exclusiveOwnerAnnotations(pg, "self-id", tc.svc)
  334. if tc.wantErr != "" {
  335. if !strings.Contains(err.Error(), tc.wantErr) {
  336. t.Errorf("exclusiveOwnerAnnotations() error = %v, wantErr %v", err, tc.wantErr)
  337. }
  338. } else if diff := cmp.Diff(pg1Owner, got[ownerAnnotation]); diff != "" {
  339. t.Errorf("exclusiveOwnerAnnotations() mismatch (-want +got):\n%s", diff)
  340. }
  341. if tc.svc == nil {
  342. return // Don't check annotations being preserved.
  343. }
  344. for k, v := range tc.svc.Annotations {
  345. if k == ownerAnnotation {
  346. continue
  347. }
  348. if got[k] != v {
  349. t.Errorf("exclusiveOwnerAnnotations() did not preserve annotation %q: got %q, want %q", k, got[k], v)
  350. }
  351. }
  352. })
  353. }
  354. }
  355. func omitPGStatusConditionMessages(p *tsapi.ProxyGroup) {
  356. for i := range p.Status.Conditions {
  357. // Don't bother validating the message.
  358. p.Status.Conditions[i].Message = ""
  359. }
  360. }