recommender_nginx_ingress.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. //go:build ee
  2. /*
  3. === NGINX Recommender Job ===
  4. This job checks an NGINX instance installed on a cluster and makes a recommendation.
  5. TODO: recommender alg details
  6. */
  7. package jobs
  8. import (
  9. "context"
  10. "fmt"
  11. "log"
  12. "os"
  13. "strings"
  14. "time"
  15. "github.com/mitchellh/mapstructure"
  16. "github.com/porter-dev/porter/api/server/shared/config/env"
  17. "github.com/porter-dev/porter/api/server/shared/requestutils"
  18. "github.com/porter-dev/porter/pkg/logger"
  19. "k8s.io/apimachinery/pkg/api/resource"
  20. v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  21. "github.com/porter-dev/porter/ee/integrations/vault"
  22. "github.com/porter-dev/porter/internal/helm"
  23. "github.com/porter-dev/porter/internal/helm/grapher"
  24. "github.com/porter-dev/porter/internal/kubernetes"
  25. "github.com/porter-dev/porter/internal/models"
  26. "github.com/porter-dev/porter/internal/oauth"
  27. "github.com/porter-dev/porter/internal/repository"
  28. rcreds "github.com/porter-dev/porter/internal/repository/credentials"
  29. rgorm "github.com/porter-dev/porter/internal/repository/gorm"
  30. "golang.org/x/oauth2"
  31. "gorm.io/gorm"
  32. )
  33. type nginxRecommender struct {
  34. enqueueTime time.Time
  35. db *gorm.DB
  36. repo repository.Repository
  37. doConf *oauth2.Config
  38. projectID, clusterID uint
  39. }
  40. // HelmRevisionsCountTrackerOpts holds the options required to run this job
  41. type NGINXRecommenderOpts struct {
  42. DBConf *env.DBConf
  43. DOClientID string
  44. DOClientSecret string
  45. DOScopes []string
  46. ServerURL string
  47. Input map[string]interface{}
  48. }
  49. type nginxRecommenderInput struct {
  50. ProjectID uint `form:"required" mapstructure:"project_id"`
  51. ClusterID uint `form:"required" mapstructure:"cluster_id"`
  52. }
  53. type RecommendationSeverity string
  54. const (
  55. RecommendationSeverityUrgent RecommendationSeverity = "urgent"
  56. RecommendationSeverityHigh RecommendationSeverity = "high"
  57. RecommendationSeverityLow RecommendationSeverity = "low"
  58. )
  59. type RecommendationID string
  60. const (
  61. RecommendationIDNginxIngressHPA RecommendationID = "nginx-ingress-hpa"
  62. RecommendationIDNginxIngressTopologySpreadConstraint RecommendationID = "nginx-ingress-topology-spread-constraint"
  63. RecommendationIDNginxIngressMemory RecommendationID = "nginx-ingress-memory-limit"
  64. RecommendationIDNginxLifecycleHook RecommendationID = "nginx-ingress-lifecycle-hook"
  65. )
  66. type Recommendation struct {
  67. ID RecommendationID
  68. Message string
  69. Automatic bool
  70. Severity RecommendationSeverity
  71. Warning string
  72. LastTested time.Time
  73. }
  74. func NewNGINXRecommender(
  75. db *gorm.DB,
  76. enqueueTime time.Time,
  77. opts *NGINXRecommenderOpts,
  78. ) (*nginxRecommender, error) {
  79. var credBackend rcreds.CredentialStorage
  80. if opts.DBConf.VaultAPIKey != "" && opts.DBConf.VaultServerURL != "" && opts.DBConf.VaultPrefix != "" {
  81. credBackend = vault.NewClient(
  82. opts.DBConf.VaultServerURL,
  83. opts.DBConf.VaultAPIKey,
  84. opts.DBConf.VaultPrefix,
  85. )
  86. }
  87. var key [32]byte
  88. for i, b := range []byte(opts.DBConf.EncryptionKey) {
  89. key[i] = b
  90. }
  91. repo := rgorm.NewRepository(db, &key, credBackend)
  92. doConf := oauth.NewDigitalOceanClient(&oauth.Config{
  93. ClientID: opts.DOClientID,
  94. ClientSecret: opts.DOClientSecret,
  95. Scopes: opts.DOScopes,
  96. BaseURL: opts.ServerURL,
  97. })
  98. // parse input
  99. parsedInput := &nginxRecommenderInput{}
  100. err := mapstructure.Decode(opts.Input, parsedInput)
  101. if err != nil {
  102. return nil, err
  103. }
  104. // validate
  105. validator := requestutils.NewDefaultValidator()
  106. if requestErr := validator.Validate(parsedInput); requestErr != nil {
  107. return nil, fmt.Errorf(requestErr.Error())
  108. }
  109. return &nginxRecommender{
  110. enqueueTime, db, repo, doConf, parsedInput.ProjectID, parsedInput.ClusterID,
  111. }, nil
  112. }
  113. func (n *nginxRecommender) ID() string {
  114. return "nginx-recommender"
  115. }
  116. func (n *nginxRecommender) EnqueueTime() time.Time {
  117. return n.enqueueTime
  118. }
  119. func (n *nginxRecommender) Run() error {
  120. fmt.Println(n.projectID, n.clusterID)
  121. cluster, err := n.repo.Cluster().ReadCluster(n.projectID, n.clusterID)
  122. if err != nil {
  123. log.Printf("error reading cluster ID %d: %v. skipping cluster ...", n.clusterID, err)
  124. return err
  125. }
  126. k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(&kubernetes.OutOfClusterConfig{
  127. Cluster: cluster,
  128. Repo: n.repo,
  129. DigitalOceanOAuth: n.doConf,
  130. AllowInClusterConnections: false,
  131. })
  132. if err != nil {
  133. log.Printf("error getting k8s agent for cluster ID %d: %v. skipping cluster ...", n.clusterID, err)
  134. return err
  135. }
  136. helmAgent, err := helm.GetAgentOutOfClusterConfig(&helm.Form{
  137. Cluster: cluster,
  138. Namespace: "ingress-nginx",
  139. Repo: n.repo,
  140. DigitalOceanOAuth: n.doConf,
  141. AllowInClusterConnections: false,
  142. }, logger.New(true, os.Stdout))
  143. if err != nil {
  144. log.Printf("error getting helm agent for cluster ID %d: %v. skipping cluster ...", n.clusterID, err)
  145. return err
  146. }
  147. // read the nginx ingress helm release
  148. nginxIngressRelease, err := helmAgent.GetRelease("nginx-ingress", 0, false)
  149. if err != nil {
  150. log.Printf("could not get nginx-ingress for cluster ID %d: %v. skipping cluster ...", n.clusterID, err)
  151. return err
  152. }
  153. // parse the manifests for the deployment name
  154. multiArr := grapher.ImportMultiDocYAML([]byte(nginxIngressRelease.Manifest))
  155. grapherObj := grapher.ParseObjs(multiArr, "ingress-nginx")
  156. recs := generateRecommendations(k8sAgent, cluster, grapherObj)
  157. for _, rec := range recs {
  158. fmt.Println(rec.ID, rec.Message)
  159. }
  160. return nil
  161. }
  162. func generateRecommendations(k8sAgent *kubernetes.Agent, cluster *models.Cluster, grapherObj []grapher.Object) []*Recommendation {
  163. res := make([]*Recommendation, 0)
  164. if hpaRec := generateHPARecommendation(grapherObj); hpaRec != nil {
  165. res = append(res, hpaRec)
  166. }
  167. if tscRec := generateTopologySpreadConstraintRecommendation(k8sAgent, grapherObj); tscRec != nil {
  168. res = append(res, tscRec)
  169. }
  170. if memRec := generateMemoryLimitRecommendation(k8sAgent, grapherObj); memRec != nil {
  171. res = append(res, memRec)
  172. }
  173. if lhRec := generateLifecycleHookRecommendation(k8sAgent, cluster, grapherObj); lhRec != nil {
  174. res = append(res, lhRec)
  175. }
  176. return res
  177. }
  178. func generateHPARecommendation(grapherObj []grapher.Object) *Recommendation {
  179. // check if a horizontal pod autoscaler has been enabled
  180. isEnabled := false
  181. for _, obj := range grapherObj {
  182. if strings.ToLower(obj.Kind) == "horizontalpodautoscaler" {
  183. isEnabled = true
  184. }
  185. }
  186. // if not enabled, return recommendation
  187. if !isEnabled {
  188. return &Recommendation{
  189. Severity: RecommendationSeverityLow,
  190. ID: "nginx-ingress-hpa",
  191. Message: "Horizontal pod autoscaling should be enabled on the NGINX ingress controller, which allows for the proxy to scale during load.",
  192. Automatic: true,
  193. }
  194. }
  195. return nil
  196. }
  197. func generateTopologySpreadConstraintRecommendation(k8sAgent *kubernetes.Agent, grapherObj []grapher.Object) *Recommendation {
  198. for _, obj := range grapherObj {
  199. if strings.ToLower(obj.Kind) == "deployment" {
  200. // query the live deployment
  201. depl, err := k8sAgent.Clientset.AppsV1().Deployments(obj.Namespace).Get(context.Background(), obj.Name, v1.GetOptions{})
  202. if err != nil {
  203. continue
  204. }
  205. // make sure deployment is a controller type
  206. if compLabel, exists := depl.Labels["app.kubernetes.io/component"]; exists && compLabel == "controller" {
  207. // check if the pod has a topology spread constraint set
  208. if len(depl.Spec.Template.Spec.TopologySpreadConstraints) == 0 {
  209. return &Recommendation{
  210. Severity: RecommendationSeverityLow,
  211. ID: RecommendationIDNginxIngressTopologySpreadConstraint,
  212. Message: "Topology spread constraints should be enabled on the NGINX deployment, which ensures that the NGINX instances are balanced across different zones and machines.",
  213. Automatic: true,
  214. }
  215. }
  216. }
  217. }
  218. }
  219. return nil
  220. }
  221. func generateMemoryLimitRecommendation(k8sAgent *kubernetes.Agent, grapherObj []grapher.Object) *Recommendation {
  222. for _, obj := range grapherObj {
  223. if strings.ToLower(obj.Kind) == "deployment" {
  224. // query the live deployment
  225. depl, err := k8sAgent.Clientset.AppsV1().Deployments(obj.Namespace).Get(context.Background(), obj.Name, v1.GetOptions{})
  226. if err != nil {
  227. continue
  228. }
  229. // make sure deployment is a controller type
  230. if compLabel, exists := depl.Labels["app.kubernetes.io/component"]; exists && compLabel == "controller" {
  231. // make sure the controller container has memory limits set
  232. for _, container := range depl.Spec.Template.Spec.Containers {
  233. if container.Name == "controller" {
  234. if mem := container.Resources.Limits.Memory(); mem == nil || resource.NewQuantity(0, resource.BinarySI).Equal(*mem) {
  235. return &Recommendation{
  236. Severity: RecommendationSeverityHigh,
  237. ID: RecommendationIDNginxIngressMemory,
  238. Message: "Memory limits should be enabled for the NGINX instance.",
  239. Automatic: true,
  240. }
  241. }
  242. }
  243. }
  244. }
  245. }
  246. }
  247. return nil
  248. }
  249. func generateLifecycleHookRecommendation(k8sAgent *kubernetes.Agent, cluster *models.Cluster, grapherObj []grapher.Object) *Recommendation {
  250. // only generate this recommendation for EKS clusters
  251. if cluster.AWSIntegrationID == 0 {
  252. return nil
  253. }
  254. rec := &Recommendation{
  255. Severity: RecommendationSeverityLow,
  256. ID: RecommendationIDNginxLifecycleHook,
  257. Message: "Lifecycle hook should be modified to sleep for 2 minutes before NGINX ingress termination, to allow for AWS load balancers to update targets.",
  258. Automatic: true,
  259. }
  260. for _, obj := range grapherObj {
  261. if strings.ToLower(obj.Kind) == "deployment" {
  262. // query the live deployment
  263. depl, err := k8sAgent.Clientset.AppsV1().Deployments(obj.Namespace).Get(context.Background(), obj.Name, v1.GetOptions{})
  264. if err != nil {
  265. continue
  266. }
  267. // make sure deployment is a controller type
  268. if compLabel, exists := depl.Labels["app.kubernetes.io/component"]; exists && compLabel == "controller" {
  269. // make sure the controller container has memory limits set
  270. for _, container := range depl.Spec.Template.Spec.Containers {
  271. if container.Name != "controller" {
  272. continue
  273. }
  274. if container.Lifecycle == nil || container.Lifecycle.PreStop == nil || container.Lifecycle.PreStop.Exec == nil {
  275. return rec
  276. }
  277. if len(container.Lifecycle.PreStop.Exec.Command) == 0 || container.Lifecycle.PreStop.Exec.Command[0] == "/wait-shutdown" {
  278. return rec
  279. }
  280. }
  281. }
  282. }
  283. }
  284. return nil
  285. }
  286. func (n *nginxRecommender) SetData([]byte) {}