sparkapplication_cleaner_test.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. package controller
  2. import (
  3. "context"
  4. "encoding/json"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "time"
  10. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  11. "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
  12. "k8s.io/apimachinery/pkg/runtime"
  13. utilruntime "k8s.io/apimachinery/pkg/util/runtime"
  14. ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
  15. ctrlclientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
  16. orcv1 "github.com/cyrus/spark-orchestrator/api/v1alpha1"
  17. "github.com/cyrus/spark-orchestrator/internal/k8s"
  18. "github.com/cyrus/spark-orchestrator/internal/util"
  19. )
  20. func TestSparkApplicationCleanerReconciler_cleanupNamespacePushesBeforeDelete(t *testing.T) {
  21. scheme := runtime.NewScheme()
  22. utilruntime.Must(orcv1.AddToScheme(scheme))
  23. terminationTime := time.Now().Add(-2 * time.Hour).UTC()
  24. app := newTerminalSparkApplication("spark-ns", "spark-app", terminationTime)
  25. op := newSparkSubmitOperation("op-1", "spark-ns/spark-app")
  26. var capturedBody []byte
  27. var capturedHeader string
  28. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  29. if r.Method != http.MethodPost {
  30. t.Fatalf("unexpected method: %s", r.Method)
  31. }
  32. if r.URL.Path != "/api/data/internal/task-exec/spark-jobs/op-1/archive" {
  33. t.Fatalf("unexpected path: %s", r.URL.Path)
  34. }
  35. capturedHeader = r.Header.Get("X-Internal-Token")
  36. body, err := io.ReadAll(r.Body)
  37. if err != nil {
  38. t.Fatalf("read body: %v", err)
  39. }
  40. capturedBody = append([]byte(nil), body...)
  41. w.WriteHeader(http.StatusOK)
  42. }))
  43. defer server.Close()
  44. client := ctrlclientfake.NewClientBuilder().
  45. WithScheme(scheme).
  46. WithObjects(op, app).
  47. Build()
  48. reconciler := &SparkApplicationCleanerReconciler{
  49. Client: client,
  50. TTLSeconds: int64(time.Hour.Seconds()),
  51. ArchiveGraceSeconds: int64(time.Hour.Seconds()),
  52. BackendBaseURL: server.URL,
  53. BackendTimeout: 5 * time.Second,
  54. BackendInternalToken: "internal-secret",
  55. BackendInternalHeader: "X-Internal-Token",
  56. OrchestratorSecretNamespace: "orchestrator-system",
  57. }
  58. deleted, err := reconciler.cleanupNamespace(context.Background(), "spark-ns")
  59. if err != nil {
  60. t.Fatalf("cleanupNamespace: %v", err)
  61. }
  62. if deleted != 1 {
  63. t.Fatalf("expected one deletion, got %d", deleted)
  64. }
  65. if capturedHeader != "internal-secret" {
  66. t.Fatalf("expected internal token header, got %q", capturedHeader)
  67. }
  68. if len(capturedBody) == 0 {
  69. t.Fatalf("expected archive body")
  70. }
  71. var payload map[string]any
  72. if err := json.Unmarshal(capturedBody, &payload); err != nil {
  73. t.Fatalf("unmarshal body: %v", err)
  74. }
  75. if payload["operationId"] != "op-1" {
  76. t.Fatalf("unexpected operationId: %v", payload["operationId"])
  77. }
  78. var remaining unstructured.Unstructured
  79. remaining.SetAPIVersion(k8s.SparkApplicationAPIVersion)
  80. remaining.SetKind(k8s.SparkApplicationKind)
  81. err = client.Get(context.Background(), ctrlclient.ObjectKey{Name: "spark-app", Namespace: "spark-ns"}, &remaining)
  82. if err == nil {
  83. t.Fatalf("expected spark application to be deleted")
  84. }
  85. }
  86. func TestSparkApplicationCleanerReconciler_cleanupNamespaceRetainsAppOnPushFailureUntilGraceExpires(t *testing.T) {
  87. scheme := runtime.NewScheme()
  88. utilruntime.Must(orcv1.AddToScheme(scheme))
  89. terminationTime := time.Now().Add(-70 * time.Minute).UTC()
  90. app := newTerminalSparkApplication("spark-ns", "spark-app", terminationTime)
  91. op := newSparkSubmitOperation("op-2", "spark-ns/spark-app")
  92. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  93. w.WriteHeader(http.StatusServiceUnavailable)
  94. }))
  95. defer server.Close()
  96. client := ctrlclientfake.NewClientBuilder().
  97. WithScheme(scheme).
  98. WithObjects(op, app).
  99. Build()
  100. reconciler := &SparkApplicationCleanerReconciler{
  101. Client: client,
  102. TTLSeconds: int64(time.Hour.Seconds()),
  103. ArchiveGraceSeconds: int64(time.Hour.Seconds()),
  104. BackendBaseURL: server.URL,
  105. BackendTimeout: 5 * time.Second,
  106. BackendInternalHeader: "X-Internal-Token",
  107. BackendInternalToken: "internal-secret",
  108. OrchestratorSecretNamespace: "orchestrator-system",
  109. }
  110. deleted, err := reconciler.cleanupNamespace(context.Background(), "spark-ns")
  111. if err != nil {
  112. t.Fatalf("cleanupNamespace: %v", err)
  113. }
  114. if deleted != 0 {
  115. t.Fatalf("expected no deletion while grace is active, got %d", deleted)
  116. }
  117. var remaining unstructured.Unstructured
  118. remaining.SetAPIVersion(k8s.SparkApplicationAPIVersion)
  119. remaining.SetKind(k8s.SparkApplicationKind)
  120. if err := client.Get(context.Background(), ctrlclient.ObjectKey{Name: "spark-app", Namespace: "spark-ns"}, &remaining); err != nil {
  121. t.Fatalf("expected spark application to remain: %v", err)
  122. }
  123. }
  124. func TestSparkApplicationCleanerReconciler_cleanupNamespaceForceDeletesAfterGrace(t *testing.T) {
  125. scheme := runtime.NewScheme()
  126. utilruntime.Must(orcv1.AddToScheme(scheme))
  127. terminationTime := time.Now().Add(-2*time.Hour - 5*time.Minute).UTC()
  128. app := newTerminalSparkApplication("spark-ns", "spark-app", terminationTime)
  129. op := newSparkSubmitOperation("op-3", "spark-ns/spark-app")
  130. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  131. w.WriteHeader(http.StatusBadGateway)
  132. }))
  133. defer server.Close()
  134. client := ctrlclientfake.NewClientBuilder().
  135. WithScheme(scheme).
  136. WithObjects(op, app).
  137. Build()
  138. reconciler := &SparkApplicationCleanerReconciler{
  139. Client: client,
  140. TTLSeconds: int64(time.Hour.Seconds()),
  141. ArchiveGraceSeconds: int64(time.Hour.Seconds()),
  142. BackendBaseURL: server.URL,
  143. BackendTimeout: 5 * time.Second,
  144. BackendInternalHeader: "X-Internal-Token",
  145. BackendInternalToken: "internal-secret",
  146. OrchestratorSecretNamespace: "orchestrator-system",
  147. }
  148. deleted, err := reconciler.cleanupNamespace(context.Background(), "spark-ns")
  149. if err != nil {
  150. t.Fatalf("cleanupNamespace: %v", err)
  151. }
  152. if deleted != 1 {
  153. t.Fatalf("expected forced deletion after grace, got %d", deleted)
  154. }
  155. var remaining unstructured.Unstructured
  156. remaining.SetAPIVersion(k8s.SparkApplicationAPIVersion)
  157. remaining.SetKind(k8s.SparkApplicationKind)
  158. if err := client.Get(context.Background(), ctrlclient.ObjectKey{Name: "spark-app", Namespace: "spark-ns"}, &remaining); err == nil {
  159. t.Fatalf("expected spark application to be deleted")
  160. }
  161. }
  162. func newSparkSubmitOperation(name, resourceRef string) *orcv1.ClusterOperation {
  163. return &orcv1.ClusterOperation{
  164. ObjectMeta: metav1.ObjectMeta{
  165. Name: name,
  166. Labels: map[string]string{util.LabelOperationType: string(orcv1.OperationSubmitSparkJob)},
  167. },
  168. Spec: orcv1.ClusterOperationSpec{
  169. Type: orcv1.OperationSubmitSparkJob,
  170. },
  171. Status: orcv1.ClusterOperationStatus{
  172. ResourceRef: resourceRef,
  173. },
  174. }
  175. }
  176. func newTerminalSparkApplication(namespace, name string, terminationTime time.Time) *unstructured.Unstructured {
  177. return &unstructured.Unstructured{
  178. Object: map[string]interface{}{
  179. "apiVersion": k8s.SparkApplicationAPIVersion,
  180. "kind": k8s.SparkApplicationKind,
  181. "metadata": map[string]interface{}{
  182. "name": name,
  183. "namespace": namespace,
  184. "labels": map[string]interface{}{
  185. util.LabelManagedBy: util.ManagedByValue,
  186. },
  187. },
  188. "status": map[string]interface{}{
  189. "terminationTime": terminationTime.Format(time.RFC3339),
  190. "applicationState": map[string]interface{}{
  191. "state": k8s.SparkAppStateCompleted,
  192. "stateTransitionTime": terminationTime.Format(time.RFC3339),
  193. },
  194. },
  195. },
  196. }
  197. }