proxyclass_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. // Copyright (c) Tailscale Inc & AUTHORS
  2. // SPDX-License-Identifier: BSD-3-Clause
  3. //go:build !plan9
  4. // tailscale-operator provides a way to expose services running in a Kubernetes
  5. // cluster to your Tailnet.
  6. package main
  7. import (
  8. "context"
  9. "testing"
  10. "time"
  11. "go.uber.org/zap"
  12. apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
  13. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  14. "k8s.io/apimachinery/pkg/types"
  15. "k8s.io/client-go/tools/record"
  16. "sigs.k8s.io/controller-runtime/pkg/client/fake"
  17. tsoperator "tailscale.com/k8s-operator"
  18. tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
  19. "tailscale.com/tstest"
  20. )
  21. func TestProxyClass(t *testing.T) {
  22. pc := &tsapi.ProxyClass{
  23. TypeMeta: metav1.TypeMeta{Kind: "ProxyClass", APIVersion: "tailscale.com/v1alpha1"},
  24. ObjectMeta: metav1.ObjectMeta{
  25. Name: "test",
  26. // The apiserver is supposed to set the UID, but the fake client
  27. // doesn't. So, set it explicitly because other code later depends
  28. // on it being set.
  29. UID: types.UID("1234-UID"),
  30. Finalizers: []string{"tailscale.com/finalizer"},
  31. },
  32. Spec: tsapi.ProxyClassSpec{
  33. StatefulSet: &tsapi.StatefulSet{
  34. Labels: tsapi.Labels{"foo": "bar", "xyz1234": "abc567"},
  35. Annotations: map[string]string{"foo.io/bar": "{'key': 'val1232'}"},
  36. Pod: &tsapi.Pod{
  37. Labels: tsapi.Labels{"foo": "bar", "xyz1234": "abc567"},
  38. Annotations: map[string]string{"foo.io/bar": "{'key': 'val1232'}"},
  39. TailscaleContainer: &tsapi.Container{
  40. Env: []tsapi.Env{{Name: "FOO", Value: "BAR"}},
  41. ImagePullPolicy: "IfNotPresent",
  42. Image: "ghcr.my-repo/tailscale:v0.01testsomething",
  43. },
  44. },
  45. },
  46. },
  47. }
  48. fc := fake.NewClientBuilder().
  49. WithScheme(tsapi.GlobalScheme).
  50. WithObjects(pc).
  51. WithStatusSubresource(pc).
  52. Build()
  53. zl, err := zap.NewDevelopment()
  54. if err != nil {
  55. t.Fatal(err)
  56. }
  57. fr := record.NewFakeRecorder(3) // bump this if you expect a test case to throw more events
  58. cl := tstest.NewClock(tstest.ClockOpts{})
  59. pcr := &ProxyClassReconciler{
  60. Client: fc,
  61. logger: zl.Sugar(),
  62. clock: cl,
  63. recorder: fr,
  64. }
  65. // 1. A valid ProxyClass resource gets its status updated to Ready.
  66. expectReconciled(t, pcr, "", "test")
  67. pc.Status.Conditions = append(pc.Status.Conditions, metav1.Condition{
  68. Type: string(tsapi.ProxyClassReady),
  69. Status: metav1.ConditionTrue,
  70. Reason: reasonProxyClassValid,
  71. Message: reasonProxyClassValid,
  72. LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
  73. })
  74. expectEqual(t, fc, pc)
  75. // 2. A ProxyClass resource with invalid labels gets its status updated to Invalid with an error message.
  76. pc.Spec.StatefulSet.Labels["foo"] = "?!someVal"
  77. mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) {
  78. proxyClass.Spec.StatefulSet.Labels = pc.Spec.StatefulSet.Labels
  79. })
  80. expectReconciled(t, pcr, "", "test")
  81. msg := `ProxyClass is not valid: .spec.statefulSet.labels: Invalid value: "?!someVal": a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?')`
  82. tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassReady, metav1.ConditionFalse, reasonProxyClassInvalid, msg, 0, cl, zl.Sugar())
  83. expectEqual(t, fc, pc)
  84. expectedEvent := "Warning ProxyClassInvalid ProxyClass is not valid: .spec.statefulSet.labels: Invalid value: \"?!someVal\": a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?')"
  85. expectEvents(t, fr, []string{expectedEvent})
  86. // 3. A ProxyClass resource with invalid image reference gets it status updated to Invalid with an error message.
  87. pc.Spec.StatefulSet.Labels = nil
  88. pc.Spec.StatefulSet.Pod.TailscaleContainer.Image = "FOO bar"
  89. mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) {
  90. proxyClass.Spec.StatefulSet.Labels = nil
  91. proxyClass.Spec.StatefulSet.Pod.TailscaleContainer.Image = pc.Spec.StatefulSet.Pod.TailscaleContainer.Image
  92. })
  93. expectReconciled(t, pcr, "", "test")
  94. msg = `ProxyClass is not valid: spec.statefulSet.pod.tailscaleContainer.image: Invalid value: "FOO bar": invalid reference format: repository name (library/FOO bar) must be lowercase`
  95. tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassReady, metav1.ConditionFalse, reasonProxyClassInvalid, msg, 0, cl, zl.Sugar())
  96. expectEqual(t, fc, pc)
  97. expectedEvent = `Warning ProxyClassInvalid ProxyClass is not valid: spec.statefulSet.pod.tailscaleContainer.image: Invalid value: "FOO bar": invalid reference format: repository name (library/FOO bar) must be lowercase`
  98. expectEvents(t, fr, []string{expectedEvent})
  99. // 4. A ProxyClass resource with invalid init container image reference gets it status updated to Invalid with an error message.
  100. pc.Spec.StatefulSet.Labels = nil
  101. pc.Spec.StatefulSet.Pod.TailscaleContainer.Image = ""
  102. pc.Spec.StatefulSet.Pod.TailscaleInitContainer = &tsapi.Container{
  103. Image: "FOO bar",
  104. }
  105. mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) {
  106. proxyClass.Spec.StatefulSet.Pod.TailscaleContainer.Image = pc.Spec.StatefulSet.Pod.TailscaleContainer.Image
  107. proxyClass.Spec.StatefulSet.Pod.TailscaleInitContainer = &tsapi.Container{
  108. Image: pc.Spec.StatefulSet.Pod.TailscaleInitContainer.Image,
  109. }
  110. })
  111. expectReconciled(t, pcr, "", "test")
  112. msg = `ProxyClass is not valid: spec.statefulSet.pod.tailscaleInitContainer.image: Invalid value: "FOO bar": invalid reference format: repository name (library/FOO bar) must be lowercase`
  113. tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassReady, metav1.ConditionFalse, reasonProxyClassInvalid, msg, 0, cl, zl.Sugar())
  114. expectEqual(t, fc, pc)
  115. expectedEvent = `Warning ProxyClassInvalid ProxyClass is not valid: spec.statefulSet.pod.tailscaleInitContainer.image: Invalid value: "FOO bar": invalid reference format: repository name (library/FOO bar) must be lowercase`
  116. expectEvents(t, fr, []string{expectedEvent})
  117. // 5. An valid ProxyClass but with a Tailscale env vars set results in warning events.
  118. pc.Spec.StatefulSet.Pod.TailscaleInitContainer.Image = "" // unset previous test
  119. mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) {
  120. proxyClass.Spec.StatefulSet.Pod.TailscaleInitContainer.Image = pc.Spec.StatefulSet.Pod.TailscaleInitContainer.Image
  121. proxyClass.Spec.StatefulSet.Pod.TailscaleContainer.Env = []tsapi.Env{{Name: "TS_USERSPACE", Value: "true"}, {Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH"}, {Name: "EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS"}}
  122. })
  123. expectedEvents := []string{
  124. "Warning CustomTSEnvVar ProxyClass overrides the default value for TS_USERSPACE env var for tailscale container. Running with custom values for Tailscale env vars is not recommended and might break in the future.",
  125. "Warning CustomTSEnvVar ProxyClass overrides the default value for EXPERIMENTAL_TS_CONFIGFILE_PATH env var for tailscale container. Running with custom values for Tailscale env vars is not recommended and might break in the future.",
  126. "Warning CustomTSEnvVar ProxyClass overrides the default value for EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS env var for tailscale container. Running with custom values for Tailscale env vars is not recommended and might break in the future.",
  127. }
  128. expectReconciled(t, pcr, "", "test")
  129. expectEvents(t, fr, expectedEvents)
  130. // 6. A ProxyClass with ServiceMonitor enabled and in a cluster that has not ServiceMonitor CRD is invalid
  131. pc.Spec.Metrics = &tsapi.Metrics{Enable: true, ServiceMonitor: &tsapi.ServiceMonitor{Enable: true}}
  132. mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) {
  133. proxyClass.Spec = pc.Spec
  134. })
  135. expectReconciled(t, pcr, "", "test")
  136. msg = `ProxyClass is not valid: spec.metrics.serviceMonitor: Invalid value: "enable": ProxyClass defines that a ServiceMonitor custom resource should be created, but "servicemonitors.monitoring.coreos.com" CRD was not found`
  137. tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassReady, metav1.ConditionFalse, reasonProxyClassInvalid, msg, 0, cl, zl.Sugar())
  138. expectEqual(t, fc, pc)
  139. expectedEvent = "Warning ProxyClassInvalid " + msg
  140. expectEvents(t, fr, []string{expectedEvent})
  141. // 7. A ProxyClass with ServiceMonitor enabled and in a cluster that does have the ServiceMonitor CRD is valid
  142. crd := &apiextensionsv1.CustomResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: serviceMonitorCRD}}
  143. mustCreate(t, fc, crd)
  144. expectReconciled(t, pcr, "", "test")
  145. tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassReady, metav1.ConditionTrue, reasonProxyClassValid, reasonProxyClassValid, 0, cl, zl.Sugar())
  146. expectEqual(t, fc, pc)
  147. // 7. A ProxyClass with invalid ServiceMonitor labels gets its status updated to Invalid with an error message.
  148. pc.Spec.Metrics.ServiceMonitor.Labels = tsapi.Labels{"foo": "bar!"}
  149. mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) {
  150. proxyClass.Spec.Metrics.ServiceMonitor.Labels = pc.Spec.Metrics.ServiceMonitor.Labels
  151. })
  152. expectReconciled(t, pcr, "", "test")
  153. msg = `ProxyClass is not valid: .spec.metrics.serviceMonitor.labels: Invalid value: "bar!": a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?')`
  154. tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassReady, metav1.ConditionFalse, reasonProxyClassInvalid, msg, 0, cl, zl.Sugar())
  155. expectEqual(t, fc, pc)
  156. // 8. A ProxyClass with valid ServiceMonitor labels gets its status updated to Valid.
  157. pc.Spec.Metrics.ServiceMonitor.Labels = tsapi.Labels{"foo": "bar", "xyz1234": "abc567", "empty": "", "onechar": "a"}
  158. mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) {
  159. proxyClass.Spec.Metrics.ServiceMonitor.Labels = pc.Spec.Metrics.ServiceMonitor.Labels
  160. })
  161. expectReconciled(t, pcr, "", "test")
  162. tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassReady, metav1.ConditionTrue, reasonProxyClassValid, reasonProxyClassValid, 0, cl, zl.Sugar())
  163. expectEqual(t, fc, pc)
  164. }
  165. func TestValidateProxyClassStaticEndpoints(t *testing.T) {
  166. for name, tc := range map[string]struct {
  167. staticEndpointConfig *tsapi.StaticEndpointsConfig
  168. valid bool
  169. }{
  170. "no_static_endpoints": {
  171. staticEndpointConfig: nil,
  172. valid: true,
  173. },
  174. "valid_specific_ports": {
  175. staticEndpointConfig: &tsapi.StaticEndpointsConfig{
  176. NodePort: &tsapi.NodePortConfig{
  177. Ports: []tsapi.PortRange{
  178. {Port: 3001},
  179. {Port: 3005},
  180. },
  181. Selector: map[string]string{"kubernetes.io/hostname": "foobar"},
  182. },
  183. },
  184. valid: true,
  185. },
  186. "valid_port_ranges": {
  187. staticEndpointConfig: &tsapi.StaticEndpointsConfig{
  188. NodePort: &tsapi.NodePortConfig{
  189. Ports: []tsapi.PortRange{
  190. {Port: 3000, EndPort: 3002},
  191. {Port: 3005, EndPort: 3007},
  192. },
  193. Selector: map[string]string{"kubernetes.io/hostname": "foobar"},
  194. },
  195. },
  196. valid: true,
  197. },
  198. "overlapping_port_ranges": {
  199. staticEndpointConfig: &tsapi.StaticEndpointsConfig{
  200. NodePort: &tsapi.NodePortConfig{
  201. Ports: []tsapi.PortRange{
  202. {Port: 1000, EndPort: 2000},
  203. {Port: 1500, EndPort: 1800},
  204. },
  205. Selector: map[string]string{"kubernetes.io/hostname": "foobar"},
  206. },
  207. },
  208. valid: false,
  209. },
  210. "clashing_port_and_range": {
  211. staticEndpointConfig: &tsapi.StaticEndpointsConfig{
  212. NodePort: &tsapi.NodePortConfig{
  213. Ports: []tsapi.PortRange{
  214. {Port: 3005},
  215. {Port: 3001, EndPort: 3010},
  216. },
  217. Selector: map[string]string{"kubernetes.io/hostname": "foobar"},
  218. },
  219. },
  220. valid: false,
  221. },
  222. "malformed_port_range": {
  223. staticEndpointConfig: &tsapi.StaticEndpointsConfig{
  224. NodePort: &tsapi.NodePortConfig{
  225. Ports: []tsapi.PortRange{
  226. {Port: 3001, EndPort: 3000},
  227. },
  228. Selector: map[string]string{"kubernetes.io/hostname": "foobar"},
  229. },
  230. },
  231. valid: false,
  232. },
  233. "empty_selector": {
  234. staticEndpointConfig: &tsapi.StaticEndpointsConfig{
  235. NodePort: &tsapi.NodePortConfig{
  236. Ports: []tsapi.PortRange{{Port: 3000}},
  237. Selector: map[string]string{},
  238. },
  239. },
  240. valid: true,
  241. },
  242. } {
  243. t.Run(name, func(t *testing.T) {
  244. fc := fake.NewClientBuilder().
  245. WithScheme(tsapi.GlobalScheme).
  246. Build()
  247. zl, _ := zap.NewDevelopment()
  248. pcr := &ProxyClassReconciler{
  249. logger: zl.Sugar(),
  250. Client: fc,
  251. }
  252. pc := &tsapi.ProxyClass{
  253. Spec: tsapi.ProxyClassSpec{
  254. StaticEndpoints: tc.staticEndpointConfig,
  255. },
  256. }
  257. logger := pcr.logger.With("ProxyClass", pc)
  258. err := pcr.validate(context.Background(), pc, logger)
  259. valid := err == nil
  260. if valid != tc.valid {
  261. t.Errorf("expected valid=%v, got valid=%v, err=%v", tc.valid, valid, err)
  262. }
  263. })
  264. }
  265. }
  266. func TestValidateProxyClass(t *testing.T) {
  267. for name, tc := range map[string]struct {
  268. pc *tsapi.ProxyClass
  269. valid bool
  270. }{
  271. "empty": {
  272. valid: true,
  273. pc: &tsapi.ProxyClass{},
  274. },
  275. "debug_enabled_for_main_container": {
  276. valid: true,
  277. pc: &tsapi.ProxyClass{
  278. Spec: tsapi.ProxyClassSpec{
  279. StatefulSet: &tsapi.StatefulSet{
  280. Pod: &tsapi.Pod{
  281. TailscaleContainer: &tsapi.Container{
  282. Debug: &tsapi.Debug{
  283. Enable: true,
  284. },
  285. },
  286. },
  287. },
  288. },
  289. },
  290. },
  291. "debug_enabled_for_init_container": {
  292. valid: false,
  293. pc: &tsapi.ProxyClass{
  294. Spec: tsapi.ProxyClassSpec{
  295. StatefulSet: &tsapi.StatefulSet{
  296. Pod: &tsapi.Pod{
  297. TailscaleInitContainer: &tsapi.Container{
  298. Debug: &tsapi.Debug{
  299. Enable: true,
  300. },
  301. },
  302. },
  303. },
  304. },
  305. },
  306. },
  307. } {
  308. t.Run(name, func(t *testing.T) {
  309. zl, _ := zap.NewDevelopment()
  310. pcr := &ProxyClassReconciler{
  311. logger: zl.Sugar(),
  312. }
  313. logger := pcr.logger.With("ProxyClass", tc.pc)
  314. err := pcr.validate(context.Background(), tc.pc, logger)
  315. valid := err == nil
  316. if valid != tc.valid {
  317. t.Errorf("expected valid=%v, got valid=%v, err=%v", tc.valid, valid, err)
  318. }
  319. })
  320. }
  321. }