nodeinventory_controller.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. package controller
  2. import (
  3. "context"
  4. "fmt"
  5. "strconv"
  6. "strings"
  7. "time"
  8. corev1 "k8s.io/api/core/v1"
  9. apierrors "k8s.io/apimachinery/pkg/api/errors"
  10. meta "k8s.io/apimachinery/pkg/api/meta"
  11. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  12. "k8s.io/apimachinery/pkg/runtime"
  13. "k8s.io/apimachinery/pkg/types"
  14. ctrl "sigs.k8s.io/controller-runtime"
  15. "sigs.k8s.io/controller-runtime/pkg/client"
  16. "sigs.k8s.io/controller-runtime/pkg/log"
  17. orcv1 "github.com/cyrus/spark-orchestrator/api/v1alpha1"
  18. "github.com/cyrus/spark-orchestrator/internal/k8s"
  19. "github.com/cyrus/spark-orchestrator/internal/onboarding"
  20. )
  21. type NodeInventoryReconciler struct {
  22. client.Client
  23. Scheme *runtime.Scheme
  24. Onboarder onboarding.Onboarder
  25. DefaultJoinCommand string
  26. }
  27. func (r *NodeInventoryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
  28. logger := log.FromContext(ctx).WithValues("nodeInventory", req.Name)
  29. var nodeInv orcv1.NodeInventory
  30. if err := r.Get(ctx, req.NamespacedName, &nodeInv); err != nil {
  31. return ctrl.Result{}, client.IgnoreNotFound(err)
  32. }
  33. if nodeInv.Status.Phase == "" {
  34. nodeInv.Status.Phase = orcv1.NodePhaseNew
  35. nodeInv.Status.ObservedAt = metav1.Now()
  36. nodeInv.Status.Conditions = []metav1.Condition{orcv1.NewCondition(orcv1.ConditionRunning, "Initialized", "Node registration initialized", metav1.ConditionTrue, nodeInv.Generation)}
  37. if err := r.Status().Update(ctx, &nodeInv); err != nil {
  38. return ctrl.Result{}, err
  39. }
  40. return ctrl.Result{RequeueAfter: 1 * time.Second}, nil
  41. }
  42. switch nodeInv.Status.Phase {
  43. case orcv1.NodePhaseNew:
  44. nodeInv.Status.Phase = orcv1.NodePhaseProvisioning
  45. nodeInv.Status.ObservedAt = metav1.Now()
  46. setCondition(&nodeInv.Status.Conditions, orcv1.NewCondition(orcv1.ConditionRunning, "Provisioning", "Node provisioning started", metav1.ConditionTrue, nodeInv.Generation))
  47. if err := r.Status().Update(ctx, &nodeInv); err != nil {
  48. return ctrl.Result{}, err
  49. }
  50. return ctrl.Result{Requeue: true}, nil
  51. case orcv1.NodePhaseProvisioning:
  52. if err := r.ensureNodeJoined(ctx, &nodeInv); err != nil {
  53. logger.Error(err, "node onboarding failed")
  54. nodeInv.Status.Phase = orcv1.NodePhaseFailed
  55. nodeInv.Status.LastError = err.Error()
  56. nodeInv.Status.ObservedAt = metav1.Now()
  57. setCondition(&nodeInv.Status.Conditions, orcv1.NewCondition(orcv1.ConditionFailed, "OnboardingFailed", err.Error(), metav1.ConditionTrue, nodeInv.Generation))
  58. _ = r.Status().Update(ctx, &nodeInv)
  59. return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
  60. }
  61. // Prefer the name already resolved by ensureNodeJoined (set via --node-name).
  62. // Fall back to IP-based lookup for nodes joined without an explicit name.
  63. nodeName := nodeInv.Status.KubernetesNodeName
  64. if nodeName == "" {
  65. name, err := k8s.FindNodeByInternalIP(ctx, r.Client, nodeInv.Spec.Address)
  66. if err != nil {
  67. if apierrors.IsNotFound(err) {
  68. return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
  69. }
  70. return ctrl.Result{}, err
  71. }
  72. nodeName = name
  73. }
  74. var k8sNode corev1.Node
  75. if err := r.Get(ctx, types.NamespacedName{Name: nodeName}, &k8sNode); err != nil {
  76. if apierrors.IsNotFound(err) {
  77. return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
  78. }
  79. return ctrl.Result{}, err
  80. }
  81. nodeInv.Status.KubernetesNodeName = nodeName
  82. if !k8s.IsNodeReady(&k8sNode) {
  83. nodeInv.Status.ObservedAt = metav1.Now()
  84. if err := r.Status().Update(ctx, &nodeInv); err != nil {
  85. return ctrl.Result{}, err
  86. }
  87. return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
  88. }
  89. nodeInv.Status.Phase = orcv1.NodePhaseIdle
  90. nodeInv.Status.LastError = ""
  91. nodeInv.Status.ObservedAt = metav1.Now()
  92. setCondition(&nodeInv.Status.Conditions, orcv1.NewCondition(orcv1.ConditionReady, "NodeReady", "Node is ready for allocation", metav1.ConditionTrue, nodeInv.Generation))
  93. if err := r.Status().Update(ctx, &nodeInv); err != nil {
  94. return ctrl.Result{}, err
  95. }
  96. return ctrl.Result{}, nil
  97. case orcv1.NodePhaseFailed:
  98. // Retry failed nodes in controller loop.
  99. nodeInv.Status.Phase = orcv1.NodePhaseProvisioning
  100. nodeInv.Status.ObservedAt = metav1.Now()
  101. if err := r.Status().Update(ctx, &nodeInv); err != nil {
  102. return ctrl.Result{}, err
  103. }
  104. return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
  105. default:
  106. return ctrl.Result{}, nil
  107. }
  108. }
  109. func (r *NodeInventoryReconciler) ensureNodeJoined(ctx context.Context, nodeInv *orcv1.NodeInventory) error {
  110. if nodeInv.Status.KubernetesNodeName != "" {
  111. return nil
  112. }
  113. // Check by predictable node name (set via --node-name in kubeadm join).
  114. // This handles the case where join succeeded but the node name wasn't yet persisted.
  115. var candidate corev1.Node
  116. if err := r.Get(ctx, types.NamespacedName{Name: nodeInv.Name}, &candidate); err == nil {
  117. nodeInv.Status.KubernetesNodeName = candidate.Name
  118. return nil
  119. }
  120. // Fallback: search by internal IP (covers nodes joined without --node-name).
  121. if existing, err := k8s.FindNodeByInternalIP(ctx, r.Client, nodeInv.Spec.Address); err == nil && existing != "" {
  122. nodeInv.Status.KubernetesNodeName = existing
  123. return nil
  124. }
  125. if r.Onboarder == nil {
  126. return fmt.Errorf("onboarder is not configured")
  127. }
  128. secretNs, secretName := parseSecretRef(nodeInv.Spec.SSHSecretRef)
  129. if secretNs == "" || secretName == "" {
  130. return fmt.Errorf("invalid sshSecretRef %q", nodeInv.Spec.SSHSecretRef)
  131. }
  132. var secret corev1.Secret
  133. if err := r.Get(ctx, types.NamespacedName{Namespace: secretNs, Name: secretName}, &secret); err != nil {
  134. return fmt.Errorf("load ssh secret: %w", err)
  135. }
  136. user := string(secret.Data["sshUser"])
  137. if user == "" {
  138. user = "root"
  139. }
  140. password := string(secret.Data["sshPassword"])
  141. if password == "" {
  142. return fmt.Errorf("sshPassword key missing in secret")
  143. }
  144. port := int32(22)
  145. if rawPort := string(secret.Data["sshPort"]); rawPort != "" {
  146. parsed, err := strconv.Atoi(rawPort)
  147. if err != nil {
  148. return fmt.Errorf("invalid sshPort in secret: %w", err)
  149. }
  150. port = int32(parsed)
  151. }
  152. joinCmd := nodeInv.Spec.Bootstrap.JoinCommandOverride
  153. if joinCmd == "" {
  154. joinCmd = r.DefaultJoinCommand
  155. }
  156. if joinCmd == "" {
  157. return fmt.Errorf("join command is empty")
  158. }
  159. return r.Onboarder.Onboard(ctx, onboarding.NodeJoinRequest{
  160. Address: nodeInv.Spec.Address,
  161. Port: port,
  162. User: user,
  163. Password: password,
  164. JoinCommand: joinCmd,
  165. NodeNameHint: nodeInv.Name,
  166. })
  167. }
  168. func (r *NodeInventoryReconciler) SetupWithManager(mgr ctrl.Manager) error {
  169. return ctrl.NewControllerManagedBy(mgr).
  170. For(&orcv1.NodeInventory{}).
  171. Complete(r)
  172. }
  173. func setCondition(conditions *[]metav1.Condition, cond metav1.Condition) {
  174. meta.SetStatusCondition(conditions, cond)
  175. }
  176. func parseSecretRef(ref string) (string, string) {
  177. parts := strings.Split(ref, "/")
  178. if len(parts) != 2 {
  179. return "", ""
  180. }
  181. return parts[0], parts[1]
  182. }