sparkapplication_cleaner.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. package controller
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "strings"
  10. "time"
  11. apierrors "k8s.io/apimachinery/pkg/api/errors"
  12. "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
  13. ctrl "sigs.k8s.io/controller-runtime"
  14. "sigs.k8s.io/controller-runtime/pkg/client"
  15. "sigs.k8s.io/controller-runtime/pkg/log"
  16. orcv1 "github.com/cyrus/spark-orchestrator/api/v1alpha1"
  17. "github.com/cyrus/spark-orchestrator/internal/k8s"
  18. "github.com/cyrus/spark-orchestrator/internal/service"
  19. "github.com/cyrus/spark-orchestrator/internal/util"
  20. )
  21. // SparkApplicationCleanerReconciler periodically archives completed SparkApplication logs
  22. // and deletes SparkApplications after logs have been persisted or the retry grace expires.
  23. type SparkApplicationCleanerReconciler struct {
  24. client.Client
  25. TTLSeconds int64
  26. ArchiveGraceSeconds int64
  27. BackendBaseURL string
  28. BackendTimeout time.Duration
  29. BackendInternalHeader string
  30. BackendInternalToken string
  31. OrchestratorSecretNamespace string
  32. }
  33. func (r *SparkApplicationCleanerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
  34. logger := log.FromContext(ctx)
  35. var namespaces []string
  36. if err := r.listManagedNamespaces(ctx, &namespaces); err != nil {
  37. logger.Error(err, "failed to list managed namespaces")
  38. return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil
  39. }
  40. totalDeleted := 0
  41. for _, ns := range namespaces {
  42. deleted, err := r.cleanupNamespace(ctx, ns)
  43. if err != nil {
  44. logger.Error(err, "failed to cleanup namespace", "namespace", ns)
  45. continue
  46. }
  47. totalDeleted += deleted
  48. }
  49. if totalDeleted > 0 {
  50. logger.Info("SparkApplication cleanup completed", "deleted", totalDeleted)
  51. }
  52. return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
  53. }
  54. func (r *SparkApplicationCleanerReconciler) cleanupNamespace(ctx context.Context, namespace string) (int, error) {
  55. completed, err := k8s.ListCompletedSparkApplications(ctx, r.Client, namespace)
  56. if err != nil {
  57. return 0, err
  58. }
  59. grace := r.archiveGrace()
  60. orchestratorSvc := service.NewOrchestratorService(r.Client, r.orchestratorSecretNamespace())
  61. deleted := 0
  62. for _, app := range completed {
  63. terminationTime, ok := r.getTerminationTime(&app)
  64. if ok == false {
  65. continue
  66. }
  67. ttl := r.cleanupTTLForApp(&app)
  68. age := time.Since(terminationTime)
  69. if age < ttl {
  70. continue
  71. }
  72. archived, archiveErr := r.archiveSparkApplication(ctx, orchestratorSvc, &app)
  73. if archived {
  74. if err := r.deleteSparkApplication(ctx, &app); err == nil {
  75. deleted++
  76. } else if apierrors.IsNotFound(err) {
  77. deleted++
  78. }
  79. continue
  80. }
  81. if age < ttl+grace {
  82. if archiveErr != nil {
  83. log.FromContext(ctx).Info("spark log archive will be retried", "namespace", namespace, "name", app.GetName(), "error", archiveErr.Error())
  84. }
  85. continue
  86. }
  87. if err := r.deleteSparkApplication(ctx, &app); err == nil || apierrors.IsNotFound(err) {
  88. deleted++
  89. }
  90. }
  91. return deleted, nil
  92. }
  93. func (r *SparkApplicationCleanerReconciler) archiveSparkApplication(
  94. ctx context.Context,
  95. orchestratorSvc *service.OrchestratorService,
  96. app *unstructured.Unstructured,
  97. ) (bool, error) {
  98. if app == nil {
  99. return false, fmt.Errorf("spark application is nil")
  100. }
  101. if orchestratorSvc == nil {
  102. return false, fmt.Errorf("orchestrator service is nil")
  103. }
  104. if strings.TrimSpace(r.BackendBaseURL) == "" {
  105. return false, fmt.Errorf("backend base url is empty")
  106. }
  107. operationID, err := r.findSparkOperationID(ctx, app)
  108. if err != nil {
  109. return false, err
  110. }
  111. result, err := orchestratorSvc.GetSparkJobResultByOperation(ctx, operationID)
  112. if err != nil {
  113. return false, err
  114. }
  115. payload, err := json.Marshal(result)
  116. if err != nil {
  117. return false, err
  118. }
  119. url := strings.TrimRight(strings.TrimSpace(r.BackendBaseURL), "/")
  120. url += "/api/data/internal/task-exec/spark-jobs/" + operationID + "/archive"
  121. httpClient := &http.Client{Timeout: r.backendTimeout()}
  122. req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
  123. if err != nil {
  124. return false, err
  125. }
  126. req.Header.Set("Content-Type", "application/json")
  127. if token := strings.TrimSpace(r.BackendInternalToken); token != "" {
  128. headerName := strings.TrimSpace(r.BackendInternalHeader)
  129. if headerName == "" {
  130. headerName = "X-Internal-Token"
  131. }
  132. req.Header.Set(headerName, token)
  133. }
  134. resp, err := httpClient.Do(req)
  135. if err != nil {
  136. return false, err
  137. }
  138. defer resp.Body.Close()
  139. body, _ := io.ReadAll(resp.Body)
  140. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  141. return false, fmt.Errorf("archive request failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
  142. }
  143. return true, nil
  144. }
  145. func (r *SparkApplicationCleanerReconciler) findSparkOperationID(ctx context.Context, app *unstructured.Unstructured) (string, error) {
  146. if app == nil {
  147. return "", fmt.Errorf("spark application is nil")
  148. }
  149. namespace := strings.TrimSpace(app.GetNamespace())
  150. name := strings.TrimSpace(app.GetName())
  151. if namespace == "" || name == "" {
  152. return "", fmt.Errorf("spark application namespace or name is empty")
  153. }
  154. var operations orcv1.ClusterOperationList
  155. if err := r.Client.List(ctx, &operations, client.MatchingLabels{
  156. util.LabelOperationType: string(orcv1.OperationSubmitSparkJob),
  157. }); err != nil {
  158. return "", err
  159. }
  160. targetResourceRef := namespace + "/" + name
  161. for i := range operations.Items {
  162. op := &operations.Items[i]
  163. if op.Spec.Type != orcv1.OperationSubmitSparkJob {
  164. continue
  165. }
  166. if strings.TrimSpace(op.Status.ResourceRef) != targetResourceRef {
  167. continue
  168. }
  169. if strings.TrimSpace(op.Name) == "" {
  170. continue
  171. }
  172. return op.Name, nil
  173. }
  174. return "", fmt.Errorf("spark operation not found for resourceRef=%s", targetResourceRef)
  175. }
  176. func (r *SparkApplicationCleanerReconciler) deleteSparkApplication(ctx context.Context, app *unstructured.Unstructured) error {
  177. if app == nil {
  178. return fmt.Errorf("spark application is nil")
  179. }
  180. return r.Client.Delete(ctx, app)
  181. }
  182. func (r *SparkApplicationCleanerReconciler) cleanupTTLForApp(app *unstructured.Unstructured) time.Duration {
  183. ttl := r.cleanupTTL()
  184. if app == nil {
  185. return ttl
  186. }
  187. annotations := app.GetAnnotations()
  188. if ttlStr, ok := annotations["spark.cyrus.io/ttl-seconds"]; ok {
  189. var customTTL int64
  190. if _, err := fmt.Sscanf(ttlStr, "%d", &customTTL); err == nil && customTTL > 0 {
  191. return time.Duration(customTTL) * time.Second
  192. }
  193. }
  194. return ttl
  195. }
  196. func (r *SparkApplicationCleanerReconciler) cleanupTTL() time.Duration {
  197. ttl := time.Duration(r.TTLSeconds) * time.Second
  198. if ttl <= 0 {
  199. ttl = time.Duration(k8s.SparkApplicationTTL) * time.Second
  200. }
  201. return ttl
  202. }
  203. func (r *SparkApplicationCleanerReconciler) archiveGrace() time.Duration {
  204. grace := time.Duration(r.ArchiveGraceSeconds) * time.Second
  205. if grace <= 0 {
  206. grace = time.Duration(k8s.SparkApplicationTTL) * time.Second
  207. }
  208. return grace
  209. }
  210. func (r *SparkApplicationCleanerReconciler) backendTimeout() time.Duration {
  211. if r.BackendTimeout <= 0 {
  212. return 15 * time.Second
  213. }
  214. return r.BackendTimeout
  215. }
  216. func (r *SparkApplicationCleanerReconciler) orchestratorSecretNamespace() string {
  217. if strings.TrimSpace(r.OrchestratorSecretNamespace) == "" {
  218. return "orchestrator-system"
  219. }
  220. return strings.TrimSpace(r.OrchestratorSecretNamespace)
  221. }
  222. func (r *SparkApplicationCleanerReconciler) getTerminationTime(app *unstructured.Unstructured) (time.Time, bool) {
  223. if app == nil {
  224. return time.Time{}, false
  225. }
  226. status, ok := app.Object["status"].(map[string]interface{})
  227. if !ok {
  228. return time.Time{}, false
  229. }
  230. terminationTimeStr, _ := status["terminationTime"].(string)
  231. if terminationTimeStr == "" {
  232. appState, _ := status["applicationState"].(map[string]interface{})
  233. if appState != nil {
  234. terminationTimeStr, _ = appState["stateTransitionTime"].(string)
  235. }
  236. }
  237. if terminationTimeStr == "" {
  238. return time.Time{}, false
  239. }
  240. terminationTime, err := time.Parse(time.RFC3339, terminationTimeStr)
  241. if err != nil {
  242. return time.Time{}, false
  243. }
  244. return terminationTime, true
  245. }
  246. func (r *SparkApplicationCleanerReconciler) listManagedNamespaces(ctx context.Context, namespaces *[]string) error {
  247. list := &unstructured.UnstructuredList{
  248. Object: map[string]interface{}{
  249. "apiVersion": k8s.SparkApplicationAPIVersion,
  250. "kind": k8s.SparkApplicationKind,
  251. },
  252. }
  253. if err := r.Client.List(ctx, list); err != nil {
  254. return err
  255. }
  256. seen := make(map[string]struct{})
  257. for _, app := range list.Items {
  258. ns := app.GetNamespace()
  259. if _, exists := seen[ns]; !exists {
  260. seen[ns] = struct{}{}
  261. *namespaces = append(*namespaces, ns)
  262. }
  263. }
  264. return nil
  265. }
  266. func (r *SparkApplicationCleanerReconciler) SetupWithManager(mgr ctrl.Manager) error {
  267. if r.TTLSeconds == 0 {
  268. r.TTLSeconds = k8s.SparkApplicationTTL
  269. }
  270. if r.ArchiveGraceSeconds == 0 {
  271. r.ArchiveGraceSeconds = k8s.SparkApplicationTTL
  272. }
  273. if r.BackendTimeout == 0 {
  274. r.BackendTimeout = 15 * time.Second
  275. }
  276. if strings.TrimSpace(r.BackendInternalHeader) == "" {
  277. r.BackendInternalHeader = "X-Internal-Token"
  278. }
  279. if strings.TrimSpace(r.OrchestratorSecretNamespace) == "" {
  280. r.OrchestratorSecretNamespace = "orchestrator-system"
  281. }
  282. return ctrl.NewControllerManagedBy(mgr).
  283. For(&unstructured.Unstructured{
  284. Object: map[string]interface{}{
  285. "apiVersion": k8s.SparkApplicationAPIVersion,
  286. "kind": k8s.SparkApplicationKind,
  287. },
  288. }).
  289. Complete(r)
  290. }