Repository: elastic/cloud-on-k8s

Issue:
PDB per Node type (Master, Data, Ingest)


## PodDisruptionBudget per node type (master, data, injest, ...)

If we are performing maintenance on a kubernetes cluster (cordon, drain, updates/upgrades, then reboot) an ECK cluster can end up blocking the upgrade for a lengthy period of time while the ECK cluster goes into yellow status while a data node is removed from service but replaced on a separate node.  This disruption often causes re-balancing of shards and now the Elastic PDB prevents other ECK cluster pods from being evicted even though they may not have an impact on data loss such as a master pod.  If we had a PDB per node type we could mitigate some of the nodes that would be blocked while waiting for the ECK cluster to return to healthy green state.

It also appears that regardless of the underlying data redundancy the cluster is limited to a single disruption at this point.



Discussion:
@anyasabo: Just to address your last point, the `maxUnavailable` setting can be tuned to modify that behavior: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-update-strategy.html@sebgl: `updateStrategy.maxUnavailable` only impacts rolling upgrades of the cluster, not disruptions related to the PodDisruptionBudget.

Note that you can also [set your own PodDisruptionBudget](https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-pod-disruption-budget.html), or override the default one. For your use case I think you may want something similar to the following:

```yaml
apiVersion: elasticsearch.k8s.elastic.co/v1
kind: Elasticsearch
metadata:
  name: quickstart
spec:
  version: 7.6.2
  nodeSets:
  - name: default
    count: 3
  podDisruptionBudget: {} # disable the default PDB
---
apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata:
  name: data-nodes-pdb
spec:
  minAvailable: 2 # should probably be (number of data nodes - 1), I'm considering you have 3 data nodes here
  selector:
    matchLabels:
      elasticsearch.k8s.elastic.co/cluster-name: your-es-cluster
      elasticsearch.k8s.elastic.co/node-data: true
      elasticsearch.k8s.elastic.co/node-master: false
---
apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata:
  name: master-nodes-pdb
spec:
  minAvailable: 2 # should probably be (number of master nodes - 1), I'm considering you have 3 master nodes here
  selector:
    matchLabels:
      elasticsearch.k8s.elastic.co/cluster-name: your-es-cluster
      elasticsearch.k8s.elastic.co/node-data: false
      elasticsearch.k8s.elastic.co/node-master: true
```

This example (to adapt to your use case) would:
* allow one data node to go down
* also allow one master node to go down
* allow any number of other node roles (eg. ingest) to go down, since no PDB is defined for them@sebgl: It's hard for ECK to come up with the best default depending on use cases here. In which case the example above demonstrates more flexibility to achieve one's needs.
Still keeping the issue open though, we may want to consider improving the existing defaults to allow disrupting more than one node under certain conditions.@sanderma: I also have some issues with the default pdb.
When people deploy an elastic to test with (single node) we are unable to reboot the Kubernetes nodes (see below text for the reason). This is because the operator by default sets a pdb of 1.

I have changed the operator to set the pdb to 0 when nodecount is 1.
IMHO when someone sets a nodecount of 1, he or she is not interested in being highly available.

> If you set maxUnavailable to 0% or 0, or you set minAvailable to 100% or the number of replicas, you are requiring zero voluntary evictions. When you set zero voluntary evictions for a workload object such as ReplicaSet, then you cannot successfully drain a Node running one of those Pods. If you try to drain a Node where an unevictable Pod is running, the drain never completes. This is permitted as per the semantics of PodDisruptionBudget.
source: https://kubernetes.io/docs/tasks/run-application/configure-pdb/#think-about-how-your-application-reacts-to-disruptions

```go
// buildPDBSpec returns a PDBSpec computed from the current StatefulSets,
// considering the cluster health and topology.
func buildPDBSpec(es esv1beta1.Elasticsearch, statefulSets sset.StatefulSetList) v1beta1.PodDisruptionBudgetSpec {
	// compute MinAvailable based on the maximum number of Pods we're supposed to have.
	// Do not set MinAvailable when nodecount is 1. When the PDB is 1, and the amount of pods is 1, Kubernetes will not be able to reboot the node.
	nodeCount := statefulSets.ExpectedNodeCount()
	var minAvailable int32
	if nodeCount == 1 {
		// when only single node, allow the pod to be rebooted, to ensure Kubernetes nodes do not get hung up by the pdb.
		minAvailable = 0
	} else {
		// maybe allow some Pods to be disrupted
		minAvailable = nodeCount - allowedDisruptions(es, statefulSets)
	}

	minAvailableIntStr := intstr.IntOrString{Type: intstr.Int, IntVal: minAvailable}

	return v1beta1.PodDisruptionBudgetSpec{
		// match all pods for this cluster
		Selector: &metav1.LabelSelector{
			MatchLabels: map[string]string{
				label.ClusterNameLabelName: es.Name,
			},
		},
		MinAvailable: &minAvailableIntStr,
		// MaxUnavailable can only be used if the selector matches a builtin controller selector
		// (eg. Deployments, StatefulSets, etc.). We cannot use it with our own cluster-name selector.
		MaxUnavailable: nil,
	}
}
```@sanderma: By setting this default this way, when one does not give a manual pdb, the operator will set the pdb to 0 when there is a single node cluster:
```bash
NAME                    MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
quickstart-es-default   0               N/A               1                     8s
```

When you scale the cluster up, the operator will set the pdb as intended, and treat the cluster as highly available:

```bash
NAME                    MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
quickstart-es-default   1               N/A               1                     38s
```@sebgl: @sanderma this makes sense to me. Thanks for mentioning the idea in the issue and then opening a PR! Will review it shortly.@erdody: @sebgl if you configure your own PDBs (and disable the default), you disable the ability of the operator to adjust it dynamically based on the cluster status ("It allows one Elasticsearch Pod to be taken down, as long as the cluster has a green health"). I would like to keep that behavior but restricted to data nodes only.  @sebgl: @erdody indeed. Can you give more details about what you're trying to achieve? Allowing all master nodes to be taken down would be rather dangerous?

I'm thinking about relaxing the way the default PDB can be configured to allow users to override the Pod selector only. So that you can keep that default logic for data nodes only and come up with your own additional PDBs for other type of nodes.@erdody: @sebgl that would work for us. I was in fact thinking of setting the non-data nodes PDBs separately. Besides data nodes, the other PDBs don't need any dynamic behavior anyways.@pebrc: Reading through this I don't see a specific need for a code change. The PDB fix for the single node clusters has been merged. So I am tempted to make this a documentation issue. I might make sense to include  @sebgl 's [example](https://github.com/elastic/cloud-on-k8s/issues/2936#issuecomment-618246812) to our PDB documentation page. @Tommmster: > @erdody indeed. Can you give more details about what you're trying to achieve? Allowing all master nodes to be taken down would be rather dangerous?
> 
> I'm thinking about relaxing the way the default PDB can be configured to allow users to override the Pod selector only. So that you can keep that default logic for data nodes only and come up with your own additional PDBs for other type of nodes.

@sebgl Is this a possibility or has it been discarded? We're facing the same issues (master node is restarted, no other nodes can be restarted -> rack drain is blocked). 
Thanks! @BenjaminHerbert: I understand, the default behavior is turned _off_, once I create own PDBs.

> It allows one Elasticsearch Pod to be taken down, as long as the cluster has a green health

What is the risk if this is turned off? Let's say I create separate `master` and `data` node PDBs.



@ebuildy: > What is the risk if this is turned off? Let's say I create separate `master` and `data` node PDBs.

PDB ensure a minimum pods always exist. There is no risk if you create a PDB for each nodes type.

You can lost a master and a data node in same time@BenjaminHerbert: I know about PDB behaviour. I am trying to understand the impact of using a separate PDB for each node with regard to the health of the Elasticsearch cluster. Because this would allow that a second Kubernetes pod is terminated, even though the cluster might be already in a yellow state. This is not allowed when the dynamic PDB handling is in place.@barkbay: Just to be sure I understand, if we can use `maxUnavailable ` instead of `minAvailable` as suggested in https://github.com/elastic/cloud-on-k8s/pull/8590, what is the value of managing the PDBs from the operator? Automatically manage the labels in the selector?

_Edit: Answering to myself: dynamically managing `maxUnavailable ` or `minAvailable`  depending on the cluster state._@naemono: # Update on the default PodDisruptionBudget Enhancements for NodeSets in ECK

We’ve been discussing ways to improve the management of PodDisruptionBudgets (PDBs) in the ECK operator, specifically addressing the limitations outlined in this issue. Our goal is to optimize disruption handling for Elasticsearch clusters, particularly during operations like Kubernetes upgrades. Below, we outline our proposed approach, the rationale behind it, and invite your feedback to help shape this feature.

## Current State

Currently, the ECK operator creates a single PDB for an entire Elasticsearch cluster, using a label selector that spans all nodeSets backed by StatefulSets. The minAvailable field is dynamically managed based on the total number of replicas and the cluster’s health (typically allowing disruptions only when the cluster is green).

## Problem

The single PDB approach causes delays in operations like Kubernetes upgrades, particularly for large clusters with diverse nodeSet roles (e.g., master, data_hot, coordinating). During operations like node maintenance or upgrades, the cluster may temporarily go yellow, halting further disruptions and prolonging the process. Users have expressed a need to allow disruptions for certain nodeSets (e.g., coordinating or ingest) even in a yellow state, while maintaining stricter protections for others (e.g., data_hot). The current workaround requires users to manually define PDBs per nodeSet, which is cumbersome and lacks operator-managed intelligence.

## Proposed Solution:

After evaluating several options we believe that the following solution is the best fit for our needs. This solution enhances the default PDB behavior for customers by creating dedicated PDBs per nodeSet with smarter disruption rules based on the nodeSet’s role and cluster health. The operator will automatically manage these PDBs, setting `maxUnavailable: 1` for specific nodeSets under the following conditions:

* Dedicated master nodeSet: Allow `maxUnavailable: 1` if the cluster is yellow or green.
* Dedicated coordinating nodeSet (no roles defined): Allow `maxUnavailable: 1` if the cluster is yellow or green.
* Dedicated pure data nodeSets (e.g., data_hot, data_warm, data_cold, data_content): Allow `maxUnavailable: 1` only if the cluster is green.
* Dedicated frozen data nodeSets: Allow `maxUnavailable: 1` if the cluster is yellow or green.
* Dedicated ingest, ML, or transform nodeSets: Allow maxUnavailable: 1 if the cluster is yellow or green.
* Mixed nodeSets (e.g., data_hot + ingest): Use the most conservative rule (e.g., maxUnavailable: 1 only when green).

### Pros

* No CRD changes: Maintains backward compatibility and avoids modifying the Elasticsearch spec.
* Automated management: The operator intelligently adjusts PDBs based on cluster health and nodeSet roles, reducing user effort.
* Optimized disruptions: Allows faster operations (e.g., upgrades) by permitting disruptions for certain nodeSets in yellow states.

### Cons

* Limited customization: Users cannot easily override maxUnavailable (e.g., set to 2 for specific nodeSets).
* Potential complexity: The “magic” of automatic PDB management may be less transparent to users.

## Alternatives Considered

We explored several other options, including:

* Extend the Elasticsearch spec to allow a PDB per nodeSet, fully user-controlled. This adds flexibility but requires CRD changes and potentially removes operator-managed health-based disruption controls. Unfortunately this option didn't allow PDBs to span multiple nodeSets.
* Allow a list of named PDBs in the spec, referenced within the nodeSets by name, supporting PDBs across multiple nodeSets but potentially removing operator-managed health checks.

# Example: Elasticsearch CRD with Multiple NodeSets and Resulting PDBs

Below is an example of an Elasticsearch Custom Resource Definition (CRD) with multiple nodeSets, each with distinct Elasticsearch roles, and the corresponding PodDisruptionBudgets (PDBs) generated by the ECK operator using the proposed option. This example illustrates how the operator would create dedicated PDBs per nodeSet (or group of nodeSets with similar roles) based on their roles and cluster health.

## Elasticsearch CRD Example

This CRD defines an Elasticsearch cluster with six nodeSets, each with different roles: master, data_hot, data_cold, coordinating, data_transform, and data_frozen.

```yaml
apiVersion: elasticsearch.k8s.elastic.co/v1
kind: Elasticsearch
metadata:
  name: elasticsearch
  namespace: elastic
spec:
  version: 8.16.1
  nodeSets:
  - name: master
    count: 3
    config:
      node.roles: ["master"]
  - name: data_hot
    count: 4
    config:
      node.roles: ["data_hot"]
  - name: data_cold
    count: 3
    config:
      node.roles: ["data_cold"]
  - name: coordinating
    count: 2
    config:
      node.roles: []
  - name: data-transform
    count: 2
    config:
      node.roles: ["transform"]
  - name: data-frozen
    count: 3
    config:
      node.roles: ["data_frozen"]
```

## Resulting PodDisruptionBudgets

Using the previously described approach, the ECK operator will automatically create dedicated PDBs for each nodeSet, with `maxUnavailable: 1` set based on the nodeSet's role and cluster health. Below are the PDBs that would be generated for the above CRD. Note that the operator groups nodeSets by their roles and applies the appropriate disruption policy.

### 1. PDB for Master NodeSet
- **Role**: Master
- **Policy**: Allow `maxUnavailable: 1` if the cluster is yellow or green.

```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: elasticsearch-es-master-pdb
  namespace: elastic
  labels:
    elasticsearch.k8s.elastic.co/cluster-name: elasticsearch
  ownerReferences:
  - apiVersion: elasticsearch.k8s.elastic.co/v1
    kind: Elasticsearch
    name: elasticsearch
    blockOwnerDeletion: true
    controller: true
spec:
  maxUnavailable: 1
  selector:
    matchExpressions:
      - { key: elasticsearch.k8s.elastic.co/cluster-name, operator: In, values: [elasticsearch] }
      - { key: elasticsearch.k8s.elastic.co/statefulset-name, operator: In, values: [elasticsearch-es-master] }
```

### 2. PDB for Data NodeSet (data_hot, data_cold)
- **Role**: Data (data_hot, data_cold)
- **Policy**: Allow `maxUnavailable: 1` only if the cluster is green (most conservative due to mixed data roles).

```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: elasticsearch-es-data-pdb
  namespace: elastic
  labels:
    elasticsearch.k8s.elastic.co/cluster-name: elasticsearch
  ownerReferences:
  - apiVersion: elasticsearch.k8s.elastic.co/v1
    kind: Elasticsearch
    name: elasticsearch
    blockOwnerDeletion: true
    controller: true
spec:
  maxUnavailable: 1
  selector:
    matchExpressions:
        - { key: elasticsearch.k8s.elastic.co/cluster-name, operator: In, values: [elasticsearch] }
        - { key: elasticsearch.k8s.elastic.co/statefulset-name, operator: In, values: [elasticsearch-es-data-hot, elasticsearch-es-data-cold] }
```

### 3. PDB for Coordinating NodeSet
- **Role**: Coordinating (no roles defined)
- **Policy**: Allow `maxUnavailable: 1` if the cluster is yellow or green (stateless, so more flexible).

```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: elasticsearch-es-coordinating-pdb
  namespace: elastic
  labels:
    elasticsearch.k8s.elastic.co/cluster-name: elasticsearch
  ownerReferences:
  - apiVersion: elasticsearch.k8s.elastic.co/v1
    kind: Elasticsearch
    name: elasticsearch
    blockOwnerDeletion: true
    controller: true
spec:
  maxUnavailable: 1
  selector:
    matchExpressions:
      - { key: elasticsearch.k8s.elastic.co/cluster-name, operator: In, values: [elasticsearch] }
      - { key: elasticsearch.k8s.elastic.co/statefulset-name, operator: In, values: [elasticsearch-es-coordinating] }
```

### 4. PDB for Data Transform NodeSet
- **Role**: Transform
- **Policy**: Allow `maxUnavailable: 1` if the cluster is yellow or green (stateless, so more flexible).

```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: elasticsearch-es-data-transform-pdb
  namespace: elastic
  labels:
    elasticsearch.k8s.elastic.co/cluster-name: elasticsearch
  ownerReferences:
  - apiVersion: elasticsearch.k8s.elastic.co/v1
    kind: Elasticsearch
    name: elasticsearch
    blockOwnerDeletion: true
    controller: true
spec:
  maxUnavailable: 1
  selector:
    matchExpressions:
      - { key: elasticsearch.k8s.elastic.co/cluster-name, operator: In, values: [elasticsearch] }
      - { key: elasticsearch.k8s.elastic.co/statefulset-name, operator: In, values: [elasticsearch-es-data-transform] }
```

### 5. PDB for Data Frozen NodeSet
- **Role**: Data Frozen
- **Policy**: Allow `maxUnavailable: 1` if the cluster is yellow or green (searchable snapshots reduce data-loss risk).

```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: elasticsearch-es-data-frozen-pdb
  namespace: elastic
  labels:
    elasticsearch.k8s.elastic.co/cluster-name: elasticsearch
  ownerReferences:
  - apiVersion: elasticsearch.k8s.elastic.co/v1
    kind: Elasticsearch
    name: elasticsearch
    blockOwnerDeletion: true
    controller: true
spec:
  maxUnavailable: 1
  selector:
    matchExpressions:
      - { key: elasticsearch.k8s.elastic.co/cluster-name, operator: In, values: [elasticsearch] }
      - { key: elasticsearch.k8s.elastic.co/statefulset-name, operator: In, values: [elasticsearch-es-data-frozen] }
```

## Feedback

We believe the proposed change meets the main requirement of protecting Elasticsearch during external disruptions while still allowing quick rollouts of planned upgrades, but additional feedback is welcome. 

Pinging those involved in this issue: @akrzos-bw @sanderma @erdody @Tommmster @BenjaminHerbert @ebuildy 

Solution patch:
diff --git a/.ci/packer_cache.sh b/.ci/packer_cache.sh
index 7be12774f..f356fbce8 100755
--- a/.ci/packer_cache.sh
+++ b/.ci/packer_cache.sh
@@ -17,6 +17,6 @@ docker pull kindest/node:v1.16.9
 docker pull kindest/node:v1.17.5
 
 # Elastic Stack images
-docker pull docker.elastic.co/elasticsearch/elasticsearch:7.7.0
-docker pull docker.elastic.co/kibana/kibana:7.7.0
-docker pull docker.elastic.co/apm/apm-server:7.7.0
+docker pull docker.elastic.co/elasticsearch/elasticsearch:7.7.1
+docker pull docker.elastic.co/kibana/kibana:7.7.1
+docker pull docker.elastic.co/apm/apm-server:7.7.1
diff --git a/.ci/pipelines/e2e-tests-stack-versions-gke.Jenkinsfile b/.ci/pipelines/e2e-tests-stack-versions-gke.Jenkinsfile
index 81083fbc7..2363171e0 100644
--- a/.ci/pipelines/e2e-tests-stack-versions-gke.Jenkinsfile
+++ b/.ci/pipelines/e2e-tests-stack-versions-gke.Jenkinsfile
@@ -32,11 +32,14 @@ pipeline {
         }
         stage('Run tests for different ELK stack versions in GKE') {
             parallel {
-                stage("6.8.9") {
+                stage("6.8.x") {
+                    agent {
+                        label 'linux'
+                    }
                     steps {
                         checkout scm
                         script {
-                            runWith(lib, failedTests, "eck-68-${BUILD_NUMBER}-e2e", "6.8.9")
+                            runWith(lib, failedTests, "eck-68-${BUILD_NUMBER}-e2e", "6.8.10")
                         }
                     }
                 }
@@ -106,14 +109,14 @@ pipeline {
                         }
                     }
                 }
-                stage("7.7.0") {
+                stage("7.7.1") {
                     agent {
                         label 'linux'
                     }
                     steps {
                         checkout scm
                         script {
-                            runWith(lib, failedTests, "eck-77-${BUILD_NUMBER}-e2e", "7.7.0")
+                            runWith(lib, failedTests, "eck-77-${BUILD_NUMBER}-e2e", "7.7.1")
                         }
                     }
                 }
diff --git a/Makefile b/Makefile
index cdb74f546..f1fc5931e 100644
--- a/Makefile
+++ b/Makefile
@@ -356,7 +356,7 @@ switch-registry-dev: # just use the default values of variables
 E2E_REGISTRY_NAMESPACE ?= eck-dev
 E2E_IMG                ?= $(REGISTRY)/$(E2E_REGISTRY_NAMESPACE)/eck-e2e-tests:$(TAG)
 TESTS_MATCH            ?= "^Test" # can be overriden to eg. TESTS_MATCH=TestMutationMoreNodes to match a single test
-STACK_VERSION          ?= 7.7.0
+STACK_VERSION          ?= 7.7.1
 E2E_JSON               ?= false
 TEST_TIMEOUT           ?= 5m
 E2E_SKIP_CLEANUP       ?= false
diff --git a/config/e2e/filebeat.yaml b/config/e2e/filebeat.yaml
index 86608ce13..8647d804f 100644
--- a/config/e2e/filebeat.yaml
+++ b/config/e2e/filebeat.yaml
@@ -88,7 +88,7 @@ spec:
       dnsPolicy: ClusterFirstWithHostNet
       containers:
         - name: filebeat
-          image: docker.elastic.co/beats/filebeat:7.7.0
+          image: docker.elastic.co/beats/filebeat:7.7.1
           args: [
             "-c", "/etc/filebeat.yml",
             "-e",
diff --git a/config/recipes/apm-server-jaeger/apm-server-jaeger.yaml b/config/recipes/apm-server-jaeger/apm-server-jaeger.yaml
index 3f9c64dd3..160dc0729 100644
--- a/config/recipes/apm-server-jaeger/apm-server-jaeger.yaml
+++ b/config/recipes/apm-server-jaeger/apm-server-jaeger.yaml
@@ -4,7 +4,7 @@ metadata:
   name: apm-server-quickstart
   namespace: default
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   config:
     name: elastic-apm
diff --git a/config/recipes/associations-rbac/apm_es_kibana_rbac.yaml b/config/recipes/associations-rbac/apm_es_kibana_rbac.yaml
index d87df9469..8dbfc4265 100644
--- a/config/recipes/associations-rbac/apm_es_kibana_rbac.yaml
+++ b/config/recipes/associations-rbac/apm_es_kibana_rbac.yaml
@@ -84,7 +84,7 @@ metadata:
   name: elasticsearch-sample
   namespace: elasticsearch-ns
 spec:
-  version: 7.7.0
+  version: 7.7.1
   nodeSets:
     - name: default
       count: 1
@@ -97,7 +97,7 @@ metadata:
   name: kibana-sample
   namespace: kibana-ns
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   elasticsearchRef:
     name: "elasticsearch-sample"
@@ -111,7 +111,7 @@ metadata:
   name: apm-apm-sample
   namespace: apmserver-ns
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   elasticsearchRef:
     name: "elasticsearch-sample"
diff --git a/config/recipes/beats/1_monitor.yaml b/config/recipes/beats/1_monitor.yaml
index 5356b5430..022ae7904 100644
--- a/config/recipes/beats/1_monitor.yaml
+++ b/config/recipes/beats/1_monitor.yaml
@@ -6,7 +6,7 @@ metadata:
   name: monitor
   namespace: beats
 spec:
-  version: 7.7.0
+  version: 7.7.1
   nodeSets:
   - name: mdi
     count: 3
@@ -28,7 +28,7 @@ metadata:
   name: monitor
   namespace: beats
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   elasticsearchRef:
     name: "monitor"
diff --git a/config/recipes/beats/2_filebeat-kubernetes.yaml b/config/recipes/beats/2_filebeat-kubernetes.yaml
index 1aadbb9cd..40a290583 100644
--- a/config/recipes/beats/2_filebeat-kubernetes.yaml
+++ b/config/recipes/beats/2_filebeat-kubernetes.yaml
@@ -51,7 +51,7 @@ spec:
       dnsPolicy: ClusterFirstWithHostNet
       containers:
       - name: filebeat
-        image: docker.elastic.co/beats/filebeat:7.7.0
+        image: docker.elastic.co/beats/filebeat:7.7.1
         args: [
           "-c", "/etc/filebeat.yml",
           "-e",
diff --git a/config/recipes/beats/3_metricbeat-kubernetes.yaml b/config/recipes/beats/3_metricbeat-kubernetes.yaml
index 404794014..535bbf0b9 100644
--- a/config/recipes/beats/3_metricbeat-kubernetes.yaml
+++ b/config/recipes/beats/3_metricbeat-kubernetes.yaml
@@ -112,7 +112,7 @@ spec:
       dnsPolicy: ClusterFirstWithHostNet
       containers:
       - name: metricbeat
-        image: docker.elastic.co/beats/metricbeat:7.7.0
+        image: docker.elastic.co/beats/metricbeat:7.7.1
         args: [
           "-c", "/etc/metricbeat.yml",
           "-e",
@@ -269,7 +269,7 @@ spec:
       dnsPolicy: ClusterFirstWithHostNet
       containers:
       - name: metricbeat
-        image: docker.elastic.co/beats/metricbeat:7.7.0
+        image: docker.elastic.co/beats/metricbeat:7.7.1
         args: [
           "-c", "/etc/metricbeat.yml",
           "-e",
diff --git a/config/recipes/gclb/01-elastic-stack.yaml b/config/recipes/gclb/01-elastic-stack.yaml
index 1a436199d..99eb63872 100644
--- a/config/recipes/gclb/01-elastic-stack.yaml
+++ b/config/recipes/gclb/01-elastic-stack.yaml
@@ -6,7 +6,7 @@ metadata:
   labels:
     app: hulk
 spec:
-  version: 7.6.1
+  version: 7.7.1
   http:
     service:
       metadata:
@@ -45,7 +45,7 @@ metadata:
   labels:
     app: hulk
 spec:
-  version: 7.6.1
+  version: 7.7.1
   count: 1
   http:
     service:
diff --git a/config/recipes/istio-gateway/03-elasticsearch-kibana.yaml b/config/recipes/istio-gateway/03-elasticsearch-kibana.yaml
index b73d03804..0c0f011b8 100644
--- a/config/recipes/istio-gateway/03-elasticsearch-kibana.yaml
+++ b/config/recipes/istio-gateway/03-elasticsearch-kibana.yaml
@@ -7,7 +7,7 @@ metadata:
   labels:
     app: ekmnt
 spec:
-  version: 7.7.0
+  version: 7.7.1
   http:
     tls:
       selfSignedCertificate:
@@ -97,7 +97,7 @@ metadata:
   labels:
     app: ekmnt
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   http:
     tls:
diff --git a/config/recipes/traefik/02-elastic-stack.yaml b/config/recipes/traefik/02-elastic-stack.yaml
index a6e253e81..8719c292f 100644
--- a/config/recipes/traefik/02-elastic-stack.yaml
+++ b/config/recipes/traefik/02-elastic-stack.yaml
@@ -6,7 +6,7 @@ metadata:
   labels:
     app: hulk
 spec:
-  version: 7.6.1
+  version: 7.7.1
   nodeSets:
   - name: master
     count: 1
@@ -56,7 +56,7 @@ metadata:
   labels:
     app: hulk
 spec:
-  version: 7.6.1
+  version: 7.7.1
   count: 1
   elasticsearchRef:
     name: hulk
@@ -68,7 +68,7 @@ metadata:
   labels:
     app: hulk
 spec:
-  version: 7.6.1
+  version: 7.7.1
   count: 1
   elasticsearchRef:
     name: hulk
diff --git a/config/samples/apm/apm_es_kibana.yaml b/config/samples/apm/apm_es_kibana.yaml
index d3b334e83..aa09227fb 100644
--- a/config/samples/apm/apm_es_kibana.yaml
+++ b/config/samples/apm/apm_es_kibana.yaml
@@ -5,7 +5,7 @@ kind: Elasticsearch
 metadata:
   name: es-apm-sample
 spec:
-  version: 7.7.0
+  version: 7.7.1
   nodeSets:
   - name: default
     count: 3
@@ -19,7 +19,7 @@ kind: Kibana
 metadata:
   name: kb-apm-sample
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   elasticsearchRef:
     name: "es-apm-sample"
@@ -29,7 +29,7 @@ kind: ApmServer
 metadata:
   name: apm-apm-sample
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   elasticsearchRef:
     name: "es-apm-sample"
diff --git a/config/samples/apm/apmserver.yaml b/config/samples/apm/apmserver.yaml
index f830ef9e8..f71948c11 100644
--- a/config/samples/apm/apmserver.yaml
+++ b/config/samples/apm/apmserver.yaml
@@ -3,7 +3,7 @@ kind: ApmServer
 metadata:
   name: apmserver-sample
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   config:
     output.console:
diff --git a/config/samples/beat/filebeat_es_kibana_apm.yaml b/config/samples/beat/filebeat_es_kibana_apm.yaml
index 7e2387675..d5f2593ad 100644
--- a/config/samples/beat/filebeat_es_kibana_apm.yaml
+++ b/config/samples/beat/filebeat_es_kibana_apm.yaml
@@ -4,7 +4,7 @@ kind: Elasticsearch
 metadata:
   name: elasticsearch-sample
 spec:
-  version: 7.7.0
+  version: 7.7.1
   nodeSets:
   - name: default
     count: 3
@@ -18,7 +18,7 @@ kind: Kibana
 metadata:
   name: kibana-sample
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   elasticsearchRef:
     name: elasticsearch-sample
@@ -39,7 +39,7 @@ kind: ApmServer
 metadata:
   name: apm-sample
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   elasticsearchRef:
     name: elasticsearch-sample
@@ -50,6 +50,6 @@ metadata:
   name: filebeat-sample
 spec:
   type: filebeat
-  version: 7.7.0
+  version: 7.7.1
   elasticsearchRef:
     name: elasticsearch-sample
diff --git a/config/samples/elasticsearch/elasticsearch.yaml b/config/samples/elasticsearch/elasticsearch.yaml
index 6bf9371be..9e7908e7a 100644
--- a/config/samples/elasticsearch/elasticsearch.yaml
+++ b/config/samples/elasticsearch/elasticsearch.yaml
@@ -4,7 +4,7 @@ kind: Elasticsearch
 metadata:
   name: elasticsearch-sample
 spec:
-  version: 7.7.0
+  version: 7.7.1
   nodeSets:
   - name: default
     config:
diff --git a/config/samples/enterprisesearch/ent_es.yml b/config/samples/enterprisesearch/ent_es.yaml
similarity index 52%
rename from config/samples/enterprisesearch/ent_es.yml
rename to config/samples/enterprisesearch/ent_es.yaml
index adccbd3a1..bb457d370 100644
--- a/config/samples/enterprisesearch/ent_es.yml
+++ b/config/samples/enterprisesearch/ent_es.yaml
@@ -1,10 +1,10 @@
-# This sample sets up an Elasticsearch cluster and a Enterprise Search instance preconfigured for that cluster
+# This sample sets up an Elasticsearch cluster and an Enterprise Search instance preconfigured for that cluster
 apiVersion: elasticsearch.k8s.elastic.co/v1
 kind: Elasticsearch
 metadata:
   name: elasticsearch-sample
 spec:
-  version: 7.7.0
+  version: 7.7.1
   nodeSets:
     - name: default
       count: 1
@@ -18,30 +18,9 @@ kind: EnterpriseSearch
 metadata:
   name: ent-sample
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   elasticsearchRef:
     name: elasticsearch-sample
   config:
     ent_search.external_url: https://localhost:3002
-#  configRef:
-#    - secretName: smtp-credentials
-  http:
-#    service:
-#      spec:
-#        type: LoadBalancer
-    tls:
-      selfSignedCertificate:
-        subjectAltNames:
-          - dns: localhost
-  # http:
-  #   service:
-  #     spec:
-  #       type: LoadBalancer
-  # podTemplate:
-  #   spec:
-  #     containers:
-  #       - name: enterprise-search
-  #         resources:
-  #           requests:
-  #             cpu: 4
diff --git a/config/samples/kibana/kibana_es.yaml b/config/samples/kibana/kibana_es.yaml
index 01b9b8004..eca68d1a5 100644
--- a/config/samples/kibana/kibana_es.yaml
+++ b/config/samples/kibana/kibana_es.yaml
@@ -4,7 +4,7 @@ kind: Elasticsearch
 metadata:
   name: elasticsearch-sample
 spec:
-  version: 7.7.0
+  version: 7.7.1
   nodeSets:
   - name: default
     count: 1
@@ -18,7 +18,7 @@ kind: Kibana
 metadata:
   name: kibana-sample
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   elasticsearchRef:
     name: "elasticsearch-sample"
diff --git a/docs/reference/api-docs.asciidoc b/docs/reference/api-docs.asciidoc
index b7f1ad4b2..48e564f26 100644
--- a/docs/reference/api-docs.asciidoc
+++ b/docs/reference/api-docs.asciidoc
@@ -46,7 +46,7 @@ ApmServer represents an APM Server resource in a Kubernetes cluster.
 | Field | Description
 | *`apiVersion`* __string__ | `apm.k8s.elastic.co/v1`
 | *`kind`* __string__ | `ApmServer`
-| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
+| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
 
 | *`spec`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-apm-v1-apmserverspec[$$ApmServerSpec$$]__ | 
 |===
@@ -72,7 +72,7 @@ ApmServerSpec holds the specification of an APM Server.
 | *`http`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1-httpconfig[$$HTTPConfig$$]__ | HTTP holds the HTTP layer configuration for the APM Server resource.
 | *`elasticsearchRef`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1-objectselector[$$ObjectSelector$$]__ | ElasticsearchRef is a reference to the output Elasticsearch cluster running in the same Kubernetes cluster.
 | *`kibanaRef`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1-objectselector[$$ObjectSelector$$]__ | KibanaRef is a reference to a Kibana instance running in the same Kubernetes cluster. It allows APM agent central configuration management in Kibana.
-| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the APM Server pods.
+| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the APM Server pods.
 | *`secureSettings`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1-secretsource[$$SecretSource$$]__ | SecureSettings is a list of references to Kubernetes secrets containing sensitive configuration options for APM Server.
 | *`serviceAccountName`* __string__ | ServiceAccountName is used to check access from the current resource to a resource (eg. Elasticsearch) in a different namespace. Can only be used if ECK is enforcing RBAC on references.
 |===
@@ -101,7 +101,7 @@ ApmServer represents an APM Server resource in a Kubernetes cluster.
 | Field | Description
 | *`apiVersion`* __string__ | `apm.k8s.elastic.co/v1beta1`
 | *`kind`* __string__ | `ApmServer`
-| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
+| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
 
 | *`spec`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-apm-v1beta1-apmserverspec[$$ApmServerSpec$$]__ | 
 |===
@@ -126,7 +126,7 @@ ApmServerSpec holds the specification of an APM Server.
 | *`config`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1beta1-config[$$Config$$]__ | Config holds the APM Server configuration. See: https://www.elastic.co/guide/en/apm/server/current/configuring-howto-apm-server.html
 | *`http`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1beta1-httpconfig[$$HTTPConfig$$]__ | HTTP holds the HTTP layer configuration for the APM Server resource.
 | *`elasticsearchRef`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1beta1-objectselector[$$ObjectSelector$$]__ | ElasticsearchRef is a reference to the output Elasticsearch cluster running in the same Kubernetes cluster.
-| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the APM Server pods.
+| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the APM Server pods.
 | *`secureSettings`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1beta1-secretsource[$$SecretSource$$]__ | SecureSettings is a list of references to Kubernetes secrets containing sensitive configuration options for APM Server.
 |===
 
@@ -154,7 +154,7 @@ Beat is the Schema for the Beats API.
 | Field | Description
 | *`apiVersion`* __string__ | `beat.k8s.elastic.co/v1beta1`
 | *`kind`* __string__ | `Beat`
-| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
+| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
 
 | *`spec`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-beat-v1beta1-beatspec[$$BeatSpec$$]__ | 
 |===
@@ -197,7 +197,7 @@ BeatSpec defines the desired state of a Beat.
 [cols="25a,75a", options="header"]
 |===
 | Field | Description
-| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | 
+| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | 
 |===
 
 
@@ -214,7 +214,7 @@ BeatSpec defines the desired state of a Beat.
 [cols="25a,75a", options="header"]
 |===
 | Field | Description
-| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | 
+| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | 
 | *`replicas`* __integer__ | 
 |===
 
@@ -317,9 +317,9 @@ PodDisruptionBudgetTemplate defines the template for creating a PodDisruptionBud
 [cols="25a,75a", options="header"]
 |===
 | Field | Description
-| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
+| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
 
-| *`spec`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#poddisruptionbudgetspec-v1beta1-policy[$$PodDisruptionBudgetSpec$$]__ | Spec is the specification of the PDB.
+| *`spec`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#poddisruptionbudgetspec-v1beta1-policy[$$PodDisruptionBudgetSpec$$]__ | Spec is the specification of the PDB.
 |===
 
 
@@ -395,9 +395,9 @@ ServiceTemplate defines the template for a Kubernetes Service.
 [cols="25a,75a", options="header"]
 |===
 | Field | Description
-| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
+| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
 
-| *`spec`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#servicespec-v1-core[$$ServiceSpec$$]__ | Spec is the specification of the service.
+| *`spec`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#servicespec-v1-core[$$ServiceSpec$$]__ | Spec is the specification of the service.
 |===
 
 
@@ -532,9 +532,9 @@ PodDisruptionBudgetTemplate defines the template for creating a PodDisruptionBud
 [cols="25a,75a", options="header"]
 |===
 | Field | Description
-| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
+| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
 
-| *`spec`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#poddisruptionbudgetspec-v1beta1-policy[$$PodDisruptionBudgetSpec$$]__ | Spec is the specification of the PDB.
+| *`spec`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#poddisruptionbudgetspec-v1beta1-policy[$$PodDisruptionBudgetSpec$$]__ | Spec is the specification of the PDB.
 |===
 
 
@@ -606,9 +606,9 @@ ServiceTemplate defines the template for a Kubernetes Service.
 [cols="25a,75a", options="header"]
 |===
 | Field | Description
-| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
+| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
 
-| *`spec`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#servicespec-v1-core[$$ServiceSpec$$]__ | Spec is the specification of the service.
+| *`spec`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#servicespec-v1-core[$$ServiceSpec$$]__ | Spec is the specification of the service.
 |===
 
 
@@ -710,7 +710,7 @@ Elasticsearch represents an Elasticsearch resource in a Kubernetes cluster.
 | Field | Description
 | *`apiVersion`* __string__ | `elasticsearch.k8s.elastic.co/v1`
 | *`kind`* __string__ | `Elasticsearch`
-| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
+| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
 
 | *`spec`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-elasticsearch-v1-elasticsearchspec[$$ElasticsearchSpec$$]__ | 
 |===
@@ -779,8 +779,8 @@ ElasticsearchSpec holds the specification of an Elasticsearch cluster.
 | *`name`* __string__ | Name of this set of nodes. Becomes a part of the Elasticsearch node.name setting.
 | *`config`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1-config[$$Config$$]__ | Config holds the Elasticsearch configuration.
 | *`count`* __integer__ | Count of Elasticsearch nodes to deploy.
-| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the Pods belonging to this NodeSet.
-| *`volumeClaimTemplates`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#persistentvolumeclaim-v1-core[$$PersistentVolumeClaim$$] array__ | VolumeClaimTemplates is a list of persistent volume claims to be used by each Pod in this NodeSet. Every claim in this list must have a matching volumeMount in one of the containers defined in the PodTemplate. Items defined here take precedence over any default claims added by the operator with the same name. See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-volume-claim-templates.html
+| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the Pods belonging to this NodeSet.
+| *`volumeClaimTemplates`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#persistentvolumeclaim-v1-core[$$PersistentVolumeClaim$$] array__ | VolumeClaimTemplates is a list of persistent volume claims to be used by each Pod in this NodeSet. Every claim in this list must have a matching volumeMount in one of the containers defined in the PodTemplate. Items defined here take precedence over any default claims added by the operator with the same name. See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-volume-claim-templates.html
 |===
 
 
@@ -899,7 +899,7 @@ Elasticsearch represents an Elasticsearch resource in a Kubernetes cluster.
 | Field | Description
 | *`apiVersion`* __string__ | `elasticsearch.k8s.elastic.co/v1beta1`
 | *`kind`* __string__ | `Elasticsearch`
-| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
+| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
 
 | *`spec`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-elasticsearch-v1beta1-elasticsearchspec[$$ElasticsearchSpec$$]__ | 
 |===
@@ -946,8 +946,8 @@ ElasticsearchSpec holds the specification of an Elasticsearch cluster.
 | *`name`* __string__ | Name of this set of nodes. Becomes a part of the Elasticsearch node.name setting.
 | *`config`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1beta1-config[$$Config$$]__ | Config holds the Elasticsearch configuration.
 | *`count`* __integer__ | Count of Elasticsearch nodes to deploy.
-| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the Pods belonging to this NodeSet.
-| *`volumeClaimTemplates`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#persistentvolumeclaim-v1-core[$$PersistentVolumeClaim$$] array__ | VolumeClaimTemplates is a list of persistent volume claims to be used by each Pod in this NodeSet. Every claim in this list must have a matching volumeMount in one of the containers defined in the PodTemplate. Items defined here take precedence over any default claims added by the operator with the same name. See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-volume-claim-templates.html
+| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the Pods belonging to this NodeSet.
+| *`volumeClaimTemplates`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#persistentvolumeclaim-v1-core[$$PersistentVolumeClaim$$] array__ | VolumeClaimTemplates is a list of persistent volume claims to be used by each Pod in this NodeSet. Every claim in this list must have a matching volumeMount in one of the containers defined in the PodTemplate. Items defined here take precedence over any default claims added by the operator with the same name. See: https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-volume-claim-templates.html
 |===
 
 
@@ -1011,7 +1011,7 @@ EnterpriseSearch is a Kubernetes CRD to represent Enterprise Search.
 | Field | Description
 | *`apiVersion`* __string__ | `enterprisesearch.k8s.elastic.co/v1beta1`
 | *`kind`* __string__ | `EnterpriseSearch`
-| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
+| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
 
 | *`spec`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-enterprisesearch-v1beta1-enterprisesearchspec[$$EnterpriseSearchSpec$$]__ | 
 |===
@@ -1037,7 +1037,7 @@ EnterpriseSearchSpec holds the specification of an Enterprise Search resource.
 | *`configRef`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-enterprisesearch-v1beta1-configsource[$$ConfigSource$$] array__ | ConfigRef contains references to Kubernetes Secrets holding the Enterprise Search configuration. Configuration settings are merged and have prcedence over plain text settings specified in  `config`. Multiple secrets can be referenced: if duplicate settings exist in multiple secrets, the last one takes precedence.
 | *`http`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1-httpconfig[$$HTTPConfig$$]__ | HTTP holds the HTTP layer configuration for Enterprise Search resource.
 | *`elasticsearchRef`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1-objectselector[$$ObjectSelector$$]__ | ElasticsearchRef is a reference to the Elasticsearch cluster running in the same Kubernetes cluster.
-| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the Enterprise Search pods.
+| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the Enterprise Search pods.
 | *`serviceAccountName`* __string__ | ServiceAccountName is used to check access from the current resource to a resource (eg. Elasticsearch) in a different namespace. Can only be used if ECK is enforcing RBAC on references.
 |===
 
@@ -1065,7 +1065,7 @@ Kibana represents a Kibana resource in a Kubernetes cluster.
 | Field | Description
 | *`apiVersion`* __string__ | `kibana.k8s.elastic.co/v1`
 | *`kind`* __string__ | `Kibana`
-| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
+| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
 
 | *`spec`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-kibana-v1-kibanaspec[$$KibanaSpec$$]__ | 
 |===
@@ -1090,7 +1090,7 @@ KibanaSpec holds the specification of a Kibana instance.
 | *`elasticsearchRef`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1-objectselector[$$ObjectSelector$$]__ | ElasticsearchRef is a reference to an Elasticsearch cluster running in the same Kubernetes cluster.
 | *`config`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1-config[$$Config$$]__ | Config holds the Kibana configuration. See: https://www.elastic.co/guide/en/kibana/current/settings.html
 | *`http`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1-httpconfig[$$HTTPConfig$$]__ | HTTP holds the HTTP layer configuration for Kibana.
-| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the Kibana pods
+| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the Kibana pods
 | *`secureSettings`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1-secretsource[$$SecretSource$$]__ | SecureSettings is a list of references to Kubernetes secrets containing sensitive configuration options for Kibana.
 | *`serviceAccountName`* __string__ | ServiceAccountName is used to check access from the current resource to a resource (eg. Elasticsearch) in a different namespace. Can only be used if ECK is enforcing RBAC on references.
 |===
@@ -1119,7 +1119,7 @@ Kibana represents a Kibana resource in a Kubernetes cluster.
 | Field | Description
 | *`apiVersion`* __string__ | `kibana.k8s.elastic.co/v1beta1`
 | *`kind`* __string__ | `Kibana`
-| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
+| *`metadata`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta[$$ObjectMeta$$]__ | Refer to Kubernetes API documentation for fields of `metadata`.
 
 | *`spec`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-kibana-v1beta1-kibanaspec[$$KibanaSpec$$]__ | 
 |===
@@ -1144,7 +1144,7 @@ KibanaSpec holds the specification of a Kibana instance.
 | *`elasticsearchRef`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1beta1-objectselector[$$ObjectSelector$$]__ | ElasticsearchRef is a reference to an Elasticsearch cluster running in the same Kubernetes cluster.
 | *`config`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1beta1-config[$$Config$$]__ | Config holds the Kibana configuration. See: https://www.elastic.co/guide/en/kibana/current/settings.html
 | *`http`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1beta1-httpconfig[$$HTTPConfig$$]__ | HTTP holds the HTTP layer configuration for Kibana.
-| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.17/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the Kibana pods
+| *`podTemplate`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#podtemplatespec-v1-core[$$PodTemplateSpec$$]__ | PodTemplate provides customisation options (labels, annotations, affinity rules, resource requests, and so on) for the Kibana pods
 | *`secureSettings`* __xref:{anchor_prefix}-github-com-elastic-cloud-on-k8s-pkg-apis-common-v1beta1-secretsource[$$SecretSource$$]__ | SecureSettings is a list of references to Kubernetes secrets containing sensitive configuration options for Kibana.
 |===
 
diff --git a/hack/api-docs/config.yaml b/hack/api-docs/config.yaml
index cd4546191..70e6b00e6 100644
--- a/hack/api-docs/config.yaml
+++ b/hack/api-docs/config.yaml
@@ -11,4 +11,4 @@ processor:
     - "TypeMeta$"
 
 render:
-  kubernetesVersion: 1.17
+  kubernetesVersion: 1.18
diff --git a/pkg/controller/elasticsearch/client/client.go b/pkg/controller/elasticsearch/client/client.go
index e76dd7d50..6a7774155 100644
--- a/pkg/controller/elasticsearch/client/client.go
+++ b/pkg/controller/elasticsearch/client/client.go
@@ -72,6 +72,8 @@ type Client interface {
 	Flush(ctx context.Context) error
 	// GetClusterHealth calls the _cluster/health api.
 	GetClusterHealth(ctx context.Context) (Health, error)
+	// GetClusterHealthWaitForAllEvents calls _cluster/health?wait_for_events=languid&timeout=0s
+	GetClusterHealthWaitForAllEvents(ctx context.Context) (Health, error)
 	// SetMinimumMasterNodes sets the transient and persistent setting of the same name in cluster settings.
 	SetMinimumMasterNodes(ctx context.Context, n int) error
 	// ReloadSecureSettings will decrypt and re-read the entire keystore, on every cluster node,
@@ -140,6 +142,16 @@ func (e *APIError) Error() string {
 	return fmt.Sprintf("%s: %s", e.response.Status, reason)
 }
 
+// IsForbidden checks whether the error was an HTTP 403 error.
+func IsForbidden(err error) bool {
+	switch err := err.(type) {
+	case *APIError:
+		return err.response.StatusCode == http.StatusForbidden
+	default:
+		return false
+	}
+}
+
 // IsNotFound checks whether the error was an HTTP 404 error.
 func IsNotFound(err error) bool {
 	switch err := err.(type) {
@@ -150,21 +162,21 @@ func IsNotFound(err error) bool {
 	}
 }
 
-// IsConflict checks whether the error was an HTTP 409 error.
-func IsConflict(err error) bool {
+// IsTimeout checks whether the error was an HTTP 408 error
+func IsTimeout(err error) bool {
 	switch err := err.(type) {
 	case *APIError:
-		return err.response.StatusCode == http.StatusConflict
+		return err.response.StatusCode == http.StatusRequestTimeout
 	default:
 		return false
 	}
 }
 
-// IsForbidden checks whether the error was an HTTP 403 error.
-func IsForbidden(err error) bool {
+// IsConflict checks whether the error was an HTTP 409 error.
+func IsConflict(err error) bool {
 	switch err := err.(type) {
 	case *APIError:
-		return err.response.StatusCode == http.StatusForbidden
+		return err.response.StatusCode == http.StatusConflict
 	default:
 		return false
 	}
diff --git a/pkg/controller/elasticsearch/client/v6.go b/pkg/controller/elasticsearch/client/v6.go
index e4af010f6..842d38467 100644
--- a/pkg/controller/elasticsearch/client/v6.go
+++ b/pkg/controller/elasticsearch/client/v6.go
@@ -64,6 +64,19 @@ func (c *clientV6) GetClusterHealth(ctx context.Context) (Health, error) {
 	return result, c.get(ctx, "/_cluster/health", &result)
 }
 
+func (c *clientV6) GetClusterHealthWaitForAllEvents(ctx context.Context) (Health, error) {
+	var result Health
+	// wait for all events means wait for all events down to `languid` events which is the lowest event priority
+	err := c.get(ctx, "/_cluster/health?wait_for_events=languid&timeout=0s", &result)
+	if IsTimeout(err) {
+		// ignore timeout errors as they are communicated in the returned payload and a timeout is to be expected
+		// given the query parameters. 408 for other reasons than the clients timeout parameter should not happen
+		// as they are expected only on idle connections https://go-review.googlesource.com/c/go/+/179457/4/src/net/http/transport.go#1931
+		err = nil
+	}
+	return result, err
+}
+
 func (c *clientV6) SetMinimumMasterNodes(ctx context.Context, n int) error {
 	zenSettings := DiscoveryZenSettings{
 		Transient:  DiscoveryZen{MinimumMasterNodes: n},
diff --git a/pkg/controller/elasticsearch/driver/driver.go b/pkg/controller/elasticsearch/driver/driver.go
index 9409d5207..41a250124 100644
--- a/pkg/controller/elasticsearch/driver/driver.go
+++ b/pkg/controller/elasticsearch/driver/driver.go
@@ -201,7 +201,7 @@ func (d *defaultDriver) Reconcile(ctx context.Context) *reconciler.Results {
 				events.EventReasonUnexpected,
 				fmt.Sprintf("Could not update cluster license: %s", err.Error()),
 			)
-			log.Error(err, "Could not update cluster license", "namespace", d.ES.Namespace, "es_name", d.ES.Name)
+			log.Info("Could not update cluster license", "err", err, "namespace", d.ES.Namespace, "es_name", d.ES.Name)
 			// don't error out the entire reconciliation, move on with next steps and retry later
 			results.WithResult(defaultRequeue)
 		}
diff --git a/pkg/controller/elasticsearch/driver/esstate.go b/pkg/controller/elasticsearch/driver/esstate.go
index 7b222bccc..f37912675 100644
--- a/pkg/controller/elasticsearch/driver/esstate.go
+++ b/pkg/controller/elasticsearch/driver/esstate.go
@@ -8,7 +8,6 @@ import (
 	"context"
 	"sync"
 
-	esv1 "github.com/elastic/cloud-on-k8s/pkg/apis/elasticsearch/v1"
 	esclient "github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/client"
 	"github.com/elastic/cloud-on-k8s/pkg/utils/stringsutil"
 )
@@ -20,7 +19,7 @@ type ESState interface {
 	// ShardAllocationsEnabled returns true if shards allocation are enabled in the cluster.
 	ShardAllocationsEnabled() (bool, error)
 	// Health returns the health of the Elasticsearch cluster.
-	Health() (esv1.ElasticsearchHealth, error)
+	Health() (esclient.Health, error)
 }
 
 // MemoizingESState requests Elasticsearch for the requested information only once, at first call.
@@ -111,11 +110,11 @@ func (s *memoizingShardsAllocationEnabled) ShardAllocationsEnabled() (bool, erro
 	return s.enabled, nil
 }
 
-// -- Cluster Health
+// -- Cluster Status
 
 // memoizingHealth provides cluster health information.
 type memoizingHealth struct {
-	health   esv1.ElasticsearchHealth
+	health   esclient.Health
 	once     sync.Once
 	esClient esclient.Client
 	ctx      context.Context
@@ -125,18 +124,21 @@ type memoizingHealth struct {
 func (h *memoizingHealth) initialize() error {
 	ctx, cancel := context.WithTimeout(h.ctx, esclient.DefaultReqTimeout)
 	defer cancel()
-	health, err := h.esClient.GetClusterHealth(ctx)
+
+	// get cluster health but make sure we have no pending shard initializations
+	// by requiring the event queue to be empty
+	health, err := h.esClient.GetClusterHealthWaitForAllEvents(ctx)
 	if err != nil {
 		return err
 	}
-	h.health = health.Status
+	h.health = health
 	return nil
 }
 
 // Health returns the cluster health.
-func (h *memoizingHealth) Health() (esv1.ElasticsearchHealth, error) {
+func (h *memoizingHealth) Health() (esclient.Health, error) {
 	if err := initOnce(&h.once, h.initialize); err != nil {
-		return "", err
+		return esclient.Health{}, err
 	}
 	return h.health, nil
 }
diff --git a/pkg/controller/elasticsearch/driver/fixtures.go b/pkg/controller/elasticsearch/driver/fixtures.go
index 9e3fe950c..270f38eff 100644
--- a/pkg/controller/elasticsearch/driver/fixtures.go
+++ b/pkg/controller/elasticsearch/driver/fixtures.go
@@ -5,6 +5,7 @@
 package driver
 
 import (
+	"github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/client"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -271,7 +272,7 @@ func (t testPod) toPodPtr() *corev1.Pod {
 
 type testESState struct {
 	inCluster []string
-	health    esv1.ElasticsearchHealth
+	health    client.Health
 	ESState
 }
 
@@ -279,7 +280,7 @@ func (t *testESState) ShardAllocationsEnabled() (bool, error) {
 	return true, nil
 }
 
-func (t *testESState) Health() (esv1.ElasticsearchHealth, error) {
+func (t *testESState) Health() (client.Health, error) {
 	return t.health, nil
 }
 
diff --git a/pkg/controller/elasticsearch/driver/upgrade_predicates.go b/pkg/controller/elasticsearch/driver/upgrade_predicates.go
index 45e024cd7..a4e69c254 100644
--- a/pkg/controller/elasticsearch/driver/upgrade_predicates.go
+++ b/pkg/controller/elasticsearch/driver/upgrade_predicates.go
@@ -163,7 +163,7 @@ var predicates = [...]Predicate{
 			if err != nil {
 				return false, err
 			}
-			if health == esv1.ElasticsearchGreenHealth || health == esv1.ElasticsearchYellowHealth {
+			if health.Status == esv1.ElasticsearchGreenHealth || health.Status == esv1.ElasticsearchYellowHealth {
 				return true, nil
 			}
 			_, healthy := context.healthyPods[candidate.Name]
@@ -194,7 +194,7 @@ var predicates = [...]Predicate{
 				return false, err
 			}
 			_, healthyNode := context.healthyPods[candidate.Name]
-			if health != esv1.ElasticsearchYellowHealth || !healthyNode {
+			if health.Status != esv1.ElasticsearchYellowHealth || !healthyNode {
 				// This predicate is only relevant on healthy node if cluster health is yellow
 				return true, nil
 			}
@@ -203,22 +203,9 @@ var predicates = [...]Predicate{
 				// Restart in yellow state is only allowed during version upgrade
 				return false, nil
 			}
-			// This candidate needs a version upgrade, check if the Shards are in a compatible state.
-			shards, err := context.shardLister.GetShards(context.ctx)
-			if err != nil {
-				return false, err
-			}
-			for _, shard := range shards {
-				switch shard.State {
-				case client.INITIALIZING, client.RELOCATING:
-					return false, nil
-				case client.UNASSIGNED:
-					if !shard.IsReplica() {
-						return false, nil
-					}
-				}
-			}
-			return true, nil
+			// This candidate needs a version upgrade, check if the primaries are assigned and shards are not moving or
+			// initializing
+			return isSafeToRoll(health), nil
 		},
 	},
 	{
@@ -435,3 +422,16 @@ func conflictingShards(shards1, shards2 []client.Shard) bool {
 	}
 	return false
 }
+
+// IsSafeToRoll indicates that a rolling update can continue with the next node if
+// - no relocating or initializing shards or shards being fetched
+// - all primaries allocated
+// only reliable if Status result was created with wait_for_events=languid
+// so that there are no pending initialisations in the task queue
+func isSafeToRoll(health client.Health) bool {
+	return !health.TimedOut && // make sure request did not time out (i.e. no pending events)
+		health.Status != esv1.ElasticsearchRedHealth && // all primaries allocated
+		health.NumberOfInFlightFetch == 0 && // no shards being fetched
+		health.InitializingShards == 0 && // no shards initializing
+		health.RelocatingShards == 0 // no shards relocating
+}
diff --git a/pkg/controller/elasticsearch/license/apply.go b/pkg/controller/elasticsearch/license/apply.go
index b90392649..5b065f50a 100644
--- a/pkg/controller/elasticsearch/license/apply.go
+++ b/pkg/controller/elasticsearch/license/apply.go
@@ -43,7 +43,7 @@ func applyLinkedLicense(
 	defer cancel()
 	current, err := updater.GetLicense(ctx)
 	if err != nil {
-		return err
+		return fmt.Errorf("while getting current license level %w", err)
 	}
 
 	// get the expected license
diff --git a/pkg/controller/elasticsearch/pdb/reconcile.go b/pkg/controller/elasticsearch/pdb/reconcile.go
index b2facddf8..152d9ea3a 100644
--- a/pkg/controller/elasticsearch/pdb/reconcile.go
+++ b/pkg/controller/elasticsearch/pdb/reconcile.go
@@ -141,6 +141,11 @@ func buildPDBSpec(es esv1.Elasticsearch, statefulSets sset.StatefulSetList) v1be
 
 // allowedDisruptions returns the number of Pods that we allow to be disrupted while keeping the cluster healthy.
 func allowedDisruptions(es esv1.Elasticsearch, actualSsets sset.StatefulSetList) int32 {
+	if actualSsets.ExpectedNodeCount() == 1 {
+		// single node cluster (not highly-available)
+		// allow the node to be disrupted to ensure K8s nodes operations can be performed
+		return 1
+	}
 	if es.Status.Health != esv1.ElasticsearchGreenHealth {
 		// A non-green cluster may become red if we disrupt one node, don't allow it.
 		// The health information we're using here may be out-of-date, that's best effort.



Test patch:
diff --git a/pkg/controller/elasticsearch/driver/esstate_test.go b/pkg/controller/elasticsearch/driver/esstate_test.go
index be1d5ffa1..6405dbeda 100644
--- a/pkg/controller/elasticsearch/driver/esstate_test.go
+++ b/pkg/controller/elasticsearch/driver/esstate_test.go
@@ -100,6 +100,11 @@ func (f *fakeESClient) GetClusterHealth(_ context.Context) (esclient.Health, err
 	return f.health, nil
 }
 
+func (f *fakeESClient) GetClusterHealthWaitForAllEvents(_ context.Context) (esclient.Health, error) {
+	f.GetClusterHealthCalledCount++
+	return f.health, nil
+}
+
 // -- ESState tests
 
 func Test_memoizingNodes_NodesInCluster(t *testing.T) {
@@ -166,12 +171,12 @@ func Test_memoizingGreenHealth_GreenHealth(t *testing.T) {
 	require.NoError(t, err)
 	// es should be requested on first call
 	require.Equal(t, 1, esClient.GetClusterHealthCalledCount)
-	require.Equal(t, esv1.ElasticsearchGreenHealth, health)
+	require.Equal(t, esv1.ElasticsearchGreenHealth, health.Status)
 	// ES should not be requested again on subsequent calls
 	health, err = h.Health()
 	require.NoError(t, err)
 	require.Equal(t, 1, esClient.GetClusterHealthCalledCount)
-	require.Equal(t, esv1.ElasticsearchGreenHealth, health)
+	require.Equal(t, esv1.ElasticsearchGreenHealth, health.Status)
 
 	// simulate yellow health
 	esClient = &fakeESClient{health: esclient.Health{Status: esv1.ElasticsearchYellowHealth}}
@@ -179,7 +184,7 @@ func Test_memoizingGreenHealth_GreenHealth(t *testing.T) {
 	health, err = h.Health()
 	require.NoError(t, err)
 	require.Equal(t, 1, esClient.GetClusterHealthCalledCount)
-	require.NotEqual(t, esv1.ElasticsearchGreenHealth, health)
+	require.NotEqual(t, esv1.ElasticsearchGreenHealth, health.Status)
 }
 
 func TestNewMemoizingESState(t *testing.T) {
diff --git a/pkg/controller/elasticsearch/driver/upgrade_predicates_test.go b/pkg/controller/elasticsearch/driver/upgrade_predicates_test.go
index b4e4083de..452d21324 100644
--- a/pkg/controller/elasticsearch/driver/upgrade_predicates_test.go
+++ b/pkg/controller/elasticsearch/driver/upgrade_predicates_test.go
@@ -25,7 +25,7 @@ func TestUpgradePodsDeletion_WithNodeTypeMutations(t *testing.T) {
 		esVersion       string
 		upgradeTestPods upgradeTestPods
 		ES              esv1.Elasticsearch
-		health          esv1.ElasticsearchHealth
+		health          client.Health
 		mutation        mutation
 		maxUnavailable  int
 	}
@@ -53,7 +53,7 @@ func TestUpgradePodsDeletion_WithNodeTypeMutations(t *testing.T) {
 					newTestPod("other-master-0").withVersion("7.2.0").isMaster(true).isData(true).isHealthy(true).needsUpgrade(true).isInCluster(true).inStatefulset("other-master"),
 				),
 				maxUnavailable: 1,
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				mutation:       removeMasterType("other-master"),
 			},
 			deleted:                      []string{"other-master-0"},
@@ -70,7 +70,7 @@ func TestUpgradePodsDeletion_WithNodeTypeMutations(t *testing.T) {
 					newTestPod("other-master-0").withVersion("6.8.0").isMaster(true).isData(true).isHealthy(true).needsUpgrade(true).isInCluster(true),
 				),
 				maxUnavailable: 1,
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				mutation:       removeMasterType("other-master"),
 			},
 			deleted:                      []string{"other-master-0"},
@@ -90,7 +90,7 @@ func TestUpgradePodsDeletion_WithNodeTypeMutations(t *testing.T) {
 					newTestPod("other-master-0").withVersion("7.2.0").isMaster(true).isData(true).isHealthy(true).needsUpgrade(true).isInCluster(true),
 				),
 				maxUnavailable: 2, // 2 unavailable nodes to be sure that the predicate managing the masters is actually called
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				mutation:       removeMasterType("other-master"),
 			},
 			deleted:                      []string{},
@@ -107,7 +107,7 @@ func TestUpgradePodsDeletion_WithNodeTypeMutations(t *testing.T) {
 					newTestPod("data-to-masters-1").withVersion("7.2.0").isMaster(false).isData(true).isHealthy(true).needsUpgrade(true).isInCluster(true),
 				),
 				maxUnavailable: 2, // 2 unavailable nodes to be sure that the predicate managing the masters is actually called
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				mutation:       addMasterType("data-to-masters"),
 			},
 			deleted:                      []string{"data-to-masters-1"},
@@ -124,7 +124,7 @@ func TestUpgradePodsDeletion_WithNodeTypeMutations(t *testing.T) {
 					newTestPod("data-to-masters-1").withVersion("7.2.0").isMaster(true).isData(true).isHealthy(true).needsUpgrade(false).isInCluster(true),
 				),
 				maxUnavailable: 2, // 2 unavailable nodes to be sure that the predicate managing the masters is actually called
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				mutation:       addMasterType("data-to-masters"),
 			},
 			deleted:                      []string{"data-to-masters-0"},
@@ -177,7 +177,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 		upgradeTestPods upgradeTestPods
 		shardLister     client.ShardLister
 		ES              esv1.Elasticsearch
-		health          esv1.ElasticsearchHealth
+		health          client.Health
 		maxUnavailable  int
 		podFilter       filter
 		esVersion       string
@@ -201,7 +201,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				maxUnavailable: 2,
 				shardLister:    migration.NewFakeShardLister(client.Shards{}),
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				podFilter:      nothing,
 			},
 			deleted:                      []string{"node-1"},
@@ -219,7 +219,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				maxUnavailable: 1,
 				shardLister:    migration.NewFakeShardLister(client.Shards{}),
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				podFilter:      nothing,
 			},
 			deleted:                      []string{},
@@ -237,7 +237,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				maxUnavailable: 1,
 				shardLister:    migration.NewFakeShardLister(client.Shards{}),
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				podFilter:      nothing,
 			},
 			deleted:                      []string{"masters-0"},
@@ -255,7 +255,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				maxUnavailable: 1,
 				shardLister:    migration.NewFakeShardLister(client.Shards{}),
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				podFilter:      nothing,
 			},
 			deleted:                      []string{"masters-2"},
@@ -273,7 +273,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				maxUnavailable: 2,
 				shardLister:    migration.NewFakeShardLister(client.Shards{}),
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				podFilter:      nothing,
 			},
 			deleted:                      []string{"masters-2"},
@@ -293,7 +293,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				maxUnavailable: 2,
 				shardLister:    migration.NewFakeShardLister(client.Shards{}),
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				podFilter:      nothing,
 			},
 			deleted:                      []string{"master-1"},
@@ -310,7 +310,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				maxUnavailable: 1,
 				shardLister:    migration.NewFakeShardLister(client.Shards{}),
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				podFilter:      nothing,
 			},
 			deleted:                      []string{"node-0"},
@@ -326,7 +326,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				maxUnavailable: 1,
 				shardLister:    migration.NewFakeShardLister(client.Shards{}),
-				health:         esv1.ElasticsearchRedHealth,
+				health:         client.Health{Status: esv1.ElasticsearchRedHealth},
 				podFilter:      nothing,
 			},
 			deleted:                      []string{},
@@ -342,7 +342,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				maxUnavailable: 1,
 				shardLister:    migration.NewFakeShardLister(client.Shards{}),
-				health:         esv1.ElasticsearchUnknownHealth,
+				health:         client.Health{Status: esv1.ElasticsearchUnknownHealth},
 				podFilter:      nothing,
 			},
 			deleted:                      []string{},
@@ -360,7 +360,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				maxUnavailable: 1,
 				shardLister:    migration.NewFakeShardLister(client.Shards{}),
-				health:         esv1.ElasticsearchUnknownHealth,
+				health:         client.Health{Status: esv1.ElasticsearchUnknownHealth},
 				podFilter:      nothing,
 			},
 			deleted:                      []string{"masters-2"},
@@ -377,7 +377,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 					newTestPod("masters-2").isMaster(true).isData(true).isHealthy(false).needsUpgrade(true).isInCluster(false).withVersion("7.5.0"),
 				),
 				maxUnavailable: 1,
-				health:         esv1.ElasticsearchYellowHealth,
+				health:         client.Health{Status: esv1.ElasticsearchYellowHealth},
 				shardLister: migration.NewFakeShardLister(client.Shards{
 					client.Shard{
 						Index:    "index_a",
@@ -410,7 +410,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 					newTestPod("masters-2").isMaster(true).isData(true).isHealthy(true).needsUpgrade(false).isInCluster(true).withVersion("7.5.0"),
 				),
 				maxUnavailable: 1,
-				health:         esv1.ElasticsearchYellowHealth,
+				health:         client.Health{Status: esv1.ElasticsearchYellowHealth},
 				shardLister: migration.NewFakeShardLister(client.Shards{
 					client.Shard{
 						Index:    "index_a",
@@ -445,7 +445,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 					newTestPod("masters-4").isMaster(true).isData(true).isHealthy(true).needsUpgrade(true).isInCluster(true).withVersion("7.4.0"),
 				),
 				maxUnavailable: 1,
-				health:         esv1.ElasticsearchYellowHealth,
+				health:         client.Health{Status: esv1.ElasticsearchYellowHealth},
 				shardLister: migration.NewFakeShardLister(client.Shards{
 					client.Shard{
 						Index:    "index_a",
@@ -492,7 +492,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 					newTestPod("masters-2").isMaster(true).isData(true).isHealthy(true).needsUpgrade(true).isInCluster(true).withVersion("7.5.0"),
 				),
 				maxUnavailable: 1,
-				health:         esv1.ElasticsearchYellowHealth,
+				health:         client.Health{Status: esv1.ElasticsearchYellowHealth},
 				shardLister: migration.NewFakeShardLister(client.Shards{
 					client.Shard{
 						Index:    "index_a",
@@ -525,7 +525,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 					newTestPod("masters-2").isMaster(true).isData(true).isHealthy(true).needsUpgrade(false).isInCluster(true).withVersion("7.5.0"),
 				),
 				maxUnavailable: 1,
-				health:         esv1.ElasticsearchYellowHealth,
+				health:         client.Health{Status: esv1.ElasticsearchYellowHealth, RelocatingShards: 1},
 				shardLister: migration.NewFakeShardLister(client.Shards{
 					client.Shard{
 						Index:    "index_a",
@@ -550,7 +550,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 					newTestPod("masters-1").isMaster(true).isData(true).isHealthy(true).needsUpgrade(false).isInCluster(true).withVersion("7.5.0"),
 				),
 				maxUnavailable: 1,
-				health:         esv1.ElasticsearchYellowHealth,
+				health:         client.Health{Status: esv1.ElasticsearchYellowHealth},
 				shardLister: migration.NewFakeShardLister(client.Shards{
 					// One shard is not assigned on masters-0 because its version is not the expected one
 					client.Shard{
@@ -592,7 +592,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				maxUnavailable: 1,
 				shardLister:    migration.NewFakeShardLister(client.Shards{}),
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				podFilter:      nothing,
 			},
 			deleted:                      []string{"masters-1"},
@@ -610,7 +610,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				maxUnavailable: 1,
 				shardLister:    migration.NewFakeShardLister(client.Shards{}),
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				podFilter:      byName("masters-2"),
 			},
 			deleted:                      []string{},
@@ -637,7 +637,7 @@ func TestUpgradePodsDeletion_Delete(t *testing.T) {
 				),
 				shardLister:    migration.NewFakeShardFromFile("shards.json"),
 				maxUnavailable: 2, // Allow 2 to be upgraded at the same time
-				health:         esv1.ElasticsearchGreenHealth,
+				health:         client.Health{Status: esv1.ElasticsearchGreenHealth},
 				podFilter:      nothing,
 			},
 			// elasticsearch-sample-es-nodes-3 must be skipped because it shares a shard with elasticsearch-sample-es-nodes-4
@@ -703,7 +703,7 @@ func TestDeletionStrategy_SortFunction(t *testing.T) {
 				),
 				esState: &testESState{
 					inCluster: []string{"data-1", "data-0", "amasters-2", "amasters-1", "amasters-0", "masters-0"},
-					health:    esv1.ElasticsearchUnknownHealth,
+					health:    client.Health{Status: esv1.ElasticsearchUnknownHealth},
 				},
 			},
 			want: []string{"data-1", "data-0", "amasters-2", "amasters-1", "amasters-0", "masters-0"},
@@ -722,7 +722,7 @@ func TestDeletionStrategy_SortFunction(t *testing.T) {
 				),
 				esState: &testESState{
 					inCluster: []string{"data-2", "data-1", "data-0", "amasters-2", "amasters-1", "amasters-0"},
-					health:    esv1.ElasticsearchUnknownHealth,
+					health:    client.Health{Status: esv1.ElasticsearchUnknownHealth},
 				},
 			},
 			want: []string{"data-2", "data-1", "data-0", "amasters-2", "amasters-1", "amasters-0"},
diff --git a/pkg/controller/elasticsearch/pdb/reconcile_test.go b/pkg/controller/elasticsearch/pdb/reconcile_test.go
index 00b4ccc8c..8d3dcad43 100644
--- a/pkg/controller/elasticsearch/pdb/reconcile_test.go
+++ b/pkg/controller/elasticsearch/pdb/reconcile_test.go
@@ -308,12 +308,12 @@ func Test_allowedDisruptions(t *testing.T) {
 			want: 1,
 		},
 		{
-			name: "green health but single-node cluster: 0 disruption allowed",
+			name: "single-node cluster (not high-available): 1 disruption allowed",
 			args: args{
 				es:          esv1.Elasticsearch{Status: esv1.ElasticsearchStatus{Health: esv1.ElasticsearchGreenHealth}},
 				actualSsets: sset.StatefulSetList{sset.TestSset{Replicas: 1, Master: true, Data: true}.Build()},
 			},
-			want: 0,
+			want: 1,
 		},
 		{
 			name: "green health but only 1 master: 0 disruption allowed",
diff --git a/test/e2e/ent/association_test.go b/test/e2e/ent/association_test.go
new file mode 100644
index 000000000..d209a736f
--- /dev/null
+++ b/test/e2e/ent/association_test.go
@@ -0,0 +1,32 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package ent
+
+import (
+	"testing"
+
+	"github.com/elastic/cloud-on-k8s/test/e2e/test"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test/elasticsearch"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test/enterprisesearch"
+)
+
+// TestEnterpriseSearchCrossNSAssociation tests associating Elasticsearch and Enterprise Search running in different namespaces.
+func TestEnterpriseSearchCrossNSAssociation(t *testing.T) {
+	esNamespace := test.Ctx().ManagedNamespace(0)
+	entNamespace := test.Ctx().ManagedNamespace(1)
+	name := "test-cross-ns-ent-es"
+
+	esBuilder := elasticsearch.NewBuilder(name).
+		WithNamespace(esNamespace).
+		WithESMasterDataNodes(1, elasticsearch.DefaultResources).
+		WithRestrictedSecurityContext()
+	entBuilder := enterprisesearch.NewBuilder(name).
+		WithNamespace(entNamespace).
+		WithElasticsearchRef(esBuilder.Ref()).
+		WithNodeCount(1).
+		WithRestrictedSecurityContext()
+
+	test.Sequence(nil, test.EmptySteps, esBuilder, entBuilder).RunSequential(t)
+}
diff --git a/test/e2e/ent/configuration_test.go b/test/e2e/ent/configuration_test.go
new file mode 100644
index 000000000..13051f85b
--- /dev/null
+++ b/test/e2e/ent/configuration_test.go
@@ -0,0 +1,168 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package ent
+
+import (
+	"fmt"
+	"reflect"
+	"testing"
+
+	"github.com/pkg/errors"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v2"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	commonv1 "github.com/elastic/cloud-on-k8s/pkg/apis/common/v1"
+	entv1beta1 "github.com/elastic/cloud-on-k8s/pkg/apis/enterprisesearch/v1beta1"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test/elasticsearch"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test/enterprisesearch"
+)
+
+// TestEnterpriseSearchConfigUpdate updates an existing EnterpriseSearch deployment twice:
+// 1. with an additional config
+// 2. with an additional configRef
+func TestEnterpriseSearchConfigUpdate(t *testing.T) {
+	name := "test-ent-config-ref"
+	es := elasticsearch.NewBuilder(name).
+		WithESMasterDataNodes(1, elasticsearch.DefaultResources).
+		WithRestrictedSecurityContext()
+
+	// initial Enterprise Search with no custom config
+	entNoConfig := enterprisesearch.NewBuilder(name).
+		WithElasticsearchRef(es.Ref()).
+		WithNodeCount(1).
+		WithRestrictedSecurityContext()
+
+	// 1. additional config setting
+	entWithConfig := enterprisesearch.Builder{EnterpriseSearch: *entNoConfig.EnterpriseSearch.DeepCopy()}.
+		WithConfig(map[string]interface{}{"app_search.engine.document_size.limit": "100kb"}).
+		WithMutatedFrom(&entNoConfig)
+	var expectedAdditionalConfig PartialConfig
+	err := yaml.Unmarshal([]byte(`app_search:
+  engine:
+    document_size:
+      limit: 100kb`), &expectedAdditionalConfig)
+	require.NoError(t, err)
+
+	// 2. additional configRef
+	configRefSecret := corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "ent-smtp-credentials",
+			Namespace: test.Ctx().ManagedNamespace(0),
+		},
+		Data: map[string][]byte{
+			"enterprise-search.yml": []byte(`email.account.enabled: true
+email.account.smtp.auth: plain
+email.account.smtp.starttls.enable: false
+email.account.smtp.host: 127.0.0.1
+email.account.smtp.port: 25
+email.account.smtp.user: myuser
+email.account.smtp.password: mypassword
+email.account.email_defaults.from: my@email.com`),
+		},
+	}
+	var expectedAdditionalCfgRef PartialConfig
+	// contains custom config + custom configRef (from which we just check one entry)
+	err = yaml.Unmarshal([]byte(
+		`app_search:
+  engine:
+    document_size:
+      limit: 100kb
+email:
+  account:
+    smtp:
+      password: mypassword`),
+		&expectedAdditionalCfgRef,
+	)
+	require.NoError(t, err)
+	entWithConfigRef := enterprisesearch.Builder{EnterpriseSearch: *entWithConfig.EnterpriseSearch.DeepCopy()}.
+		WithConfigRef(entv1beta1.ConfigSource{SecretRef: commonv1.SecretRef{SecretName: configRefSecret.Name}}).
+		WithMutatedFrom(&entWithConfig)
+
+	stepsFn := func(k *test.K8sClient) test.StepList {
+		return test.StepList{}.
+			// mutate with additional config
+			WithSteps(entWithConfig.MutationTestSteps(k)).
+			WithStep(test.Step{
+				Name: "Config file in the Pod should contain the additional config",
+				Test: func(t *testing.T) {
+					require.NoError(t, CheckPartialConfig(k, entWithConfig.EnterpriseSearch, expectedAdditionalConfig))
+				},
+			}).
+			// mutate with additional configRef
+			WithStep(test.Step{
+				Name: "Create configRef secret",
+				Test: func(t *testing.T) {
+					// remove if already exists (ignoring errors)
+					_ = k.Client.Delete(&configRefSecret)
+					// and create a fresh one
+					err := k.Client.Create(&configRefSecret)
+					require.NoError(t, err)
+				},
+			}).
+			WithSteps(entWithConfigRef.MutationTestSteps(k)).
+			WithStep(test.Step{
+				Name: "Config file in the Pod should contain the additional config & configRef",
+				Test: func(t *testing.T) {
+					require.NoError(t, CheckPartialConfig(k, entWithConfigRef.EnterpriseSearch, expectedAdditionalCfgRef))
+				},
+			})
+	}
+
+	test.Sequence(nil, stepsFn, es, entNoConfig).RunSequential(t)
+}
+
+// CheckPartialConfig retrieves the configuration file from all Pods and compares it with the expected PartialConfig.
+func CheckPartialConfig(k *test.K8sClient, ent entv1beta1.EnterpriseSearch, expected PartialConfig) error {
+	var pods corev1.PodList
+	err := k.Client.List(&pods, test.EnterpriseSearchPodListOptions(ent.Namespace, ent.Name)...)
+	if err != nil {
+		return err
+	}
+	if len(pods.Items) == 0 {
+		return errors.New("found 0 pod to check config from")
+	}
+	for _, p := range pods.Items {
+		cfg, err := GetConfigFromPod(k, types.NamespacedName{Namespace: p.Namespace, Name: p.Name})
+		if err != nil {
+			return err
+		}
+		if !reflect.DeepEqual(expected, cfg) {
+			return fmt.Errorf("expected config %+v, got %+v", expected, cfg)
+		}
+	}
+	return nil
+}
+
+// PartialConfig partially models an Enterprise Search configuration for test needs.
+type PartialConfig struct {
+	AppSearch struct {
+		Engine struct {
+			DocumentSize struct {
+				Limit string `yaml:"limit"`
+			} `yaml:"document_size"`
+		} `yaml:"engine"`
+	} `yaml:"app_search"`
+	Email struct {
+		Account struct {
+			SMTP struct {
+				Password string `yaml:"password"`
+			} `yaml:"smtp"`
+		} `yaml:"account"`
+	} `yaml:"email"`
+}
+
+// GetConfigFromPod execs into the Pod to retrieve the Enterprise Search configuration file.
+func GetConfigFromPod(k *test.K8sClient, pod types.NamespacedName) (PartialConfig, error) {
+	stdout, stderr, err := k.Exec(pod, []string{"cat", "/mnt/elastic-internal/config/enterprise-search.yml"})
+	if err != nil {
+		return PartialConfig{}, errors.Wrap(err, fmt.Sprintf("failed to get config from Pod, stdout: %s; stderr: %s", stdout, stderr))
+	}
+	var cfg PartialConfig
+	return cfg, yaml.Unmarshal([]byte(stdout), &cfg)
+}
diff --git a/test/e2e/kb/keystore_test.go b/test/e2e/kb/keystore_test.go
index a555dd0ed..4197818dc 100644
--- a/test/e2e/kb/keystore_test.go
+++ b/test/e2e/kb/keystore_test.go
@@ -56,8 +56,7 @@ func TestUpdateKibanaSecureSettings(t *testing.T) {
 					// remove if already exists (ignoring errors)
 					_ = k.Client.Delete(&secureSettings)
 					// and create a fresh one
-					err := k.Client.Create(&secureSettings)
-					require.NoError(t, err)
+					require.NoError(t, k.Client.Create(&secureSettings))
 				},
 			},
 		}
diff --git a/test/e2e/kb/testdata/kibana_standalone.yaml b/test/e2e/kb/testdata/kibana_standalone.yaml
index 279424082..632752019 100644
--- a/test/e2e/kb/testdata/kibana_standalone.yaml
+++ b/test/e2e/kb/testdata/kibana_standalone.yaml
@@ -4,7 +4,7 @@ kind: Elasticsearch
 metadata:
   name: {{ .ESName }}
 spec:
-  version: 7.7.0
+  version: 7.7.1
   nodeSets:
   - count: 1
     name: mdi
@@ -18,7 +18,7 @@ kind: Kibana
 metadata:
   name: test-kibana-standalone-{{ .Suffix }}
 spec:
-  version: 7.7.0
+  version: 7.7.1
   count: 1
   config:
     elasticsearch.hosts:
diff --git a/test/e2e/samples_test.go b/test/e2e/samples_test.go
index 8fd500573..f7e175888 100644
--- a/test/e2e/samples_test.go
+++ b/test/e2e/samples_test.go
@@ -18,6 +18,7 @@ import (
 	"github.com/elastic/cloud-on-k8s/test/e2e/test/apmserver"
 	"github.com/elastic/cloud-on-k8s/test/e2e/test/beat"
 	"github.com/elastic/cloud-on-k8s/test/e2e/test/elasticsearch"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test/enterprisesearch"
 	"github.com/elastic/cloud-on-k8s/test/e2e/test/helper"
 	"github.com/elastic/cloud-on-k8s/test/e2e/test/kibana"
 	"github.com/stretchr/testify/require"
@@ -92,6 +93,13 @@ func createBuilders(t *testing.T, decoder *helper.YAMLDecoder, sampleFile, testN
 				WithLabel(run.TestNameLabel, testName).
 				WithPodLabel(run.TestNameLabel, testName).
 				WithESValidations(beat.HasEventFromBeat(beatcommon.Type(b.Beat.Spec.Type)))
+		case enterprisesearch.Builder:
+			return b.WithNamespace(namespace).
+				WithSuffix(suffix).
+				WithElasticsearchRef(tweakServiceRef(b.EnterpriseSearch.Spec.ElasticsearchRef, suffix)).
+				WithRestrictedSecurityContext().
+				WithLabel(run.TestNameLabel, fullTestName).
+				WithPodLabel(run.TestNameLabel, fullTestName)
 		default:
 			return b
 		}
diff --git a/test/e2e/test/apmserver/builder.go b/test/e2e/test/apmserver/builder.go
index dc44a1134..20ec3bb9f 100644
--- a/test/e2e/test/apmserver/builder.go
+++ b/test/e2e/test/apmserver/builder.go
@@ -23,6 +23,10 @@ type Builder struct {
 
 var _ test.Builder = Builder{}
 
+func (b Builder) SkipTest() bool {
+	return false
+}
+
 func NewBuilder(name string) Builder {
 	return newBuilder(name, rand.String(4))
 }
diff --git a/test/e2e/test/beat/builder.go b/test/e2e/test/beat/builder.go
index 38a8f5379..ba41f3fd9 100644
--- a/test/e2e/test/beat/builder.go
+++ b/test/e2e/test/beat/builder.go
@@ -24,6 +24,10 @@ type Builder struct {
 	Validations []ValidationFunc
 }
 
+func (b Builder) SkipTest() bool {
+	return false
+}
+
 func NewBuilderWithoutSuffix(name string, typ beatcommon.Type) Builder {
 	return newBuilder(name, typ, "")
 }
diff --git a/test/e2e/test/beat/pod_builder.go b/test/e2e/test/beat/pod_builder.go
index 9b7bc4b15..f87b32e80 100644
--- a/test/e2e/test/beat/pod_builder.go
+++ b/test/e2e/test/beat/pod_builder.go
@@ -29,6 +29,10 @@ type PodBuilder struct {
 	Logged string
 }
 
+func (pb PodBuilder) SkipTest() bool {
+	return false
+}
+
 func NewPodBuilder(name string) PodBuilder {
 	return newPodBuilder(name, rand.String(4))
 }
diff --git a/test/e2e/test/builder.go b/test/e2e/test/builder.go
index b7be1fa4a..26d0f8932 100644
--- a/test/e2e/test/builder.go
+++ b/test/e2e/test/builder.go
@@ -29,4 +29,6 @@ type Builder interface {
 	// We assume the resource to be ready and running.
 	// We assume the resource to be the same as the original resource after reversion.
 	MutationReversalTestContext() ReversalTestContext
+	// Skip the test if true.
+	SkipTest() bool
 }
diff --git a/test/e2e/test/elasticsearch/builder.go b/test/e2e/test/elasticsearch/builder.go
index 91b93e440..992f0e737 100644
--- a/test/e2e/test/elasticsearch/builder.go
+++ b/test/e2e/test/elasticsearch/builder.go
@@ -49,6 +49,10 @@ type Builder struct {
 
 var _ test.Builder = Builder{}
 
+func (b Builder) SkipTest() bool {
+	return false
+}
+
 func NewBuilder(name string) Builder {
 	return newBuilder(name, rand.String(4))
 }
diff --git a/test/e2e/test/enterprisesearch/builder.go b/test/e2e/test/enterprisesearch/builder.go
new file mode 100644
index 000000000..c569612a3
--- /dev/null
+++ b/test/e2e/test/enterprisesearch/builder.go
@@ -0,0 +1,149 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package enterprisesearch
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/rand"
+
+	commonv1 "github.com/elastic/cloud-on-k8s/pkg/apis/common/v1"
+	entv1beta1 "github.com/elastic/cloud-on-k8s/pkg/apis/enterprisesearch/v1beta1"
+	"github.com/elastic/cloud-on-k8s/pkg/controller/common/version"
+	"github.com/elastic/cloud-on-k8s/test/e2e/cmd/run"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test"
+)
+
+var (
+	minVersion = version.MustParse("7.7.0")
+)
+
+// Builder to create Enterprise Search.
+type Builder struct {
+	EnterpriseSearch entv1beta1.EnterpriseSearch
+	MutatedFrom      *Builder
+}
+
+var _ test.Builder = Builder{}
+
+func (b Builder) SkipTest() bool {
+	// no Enterprise Search before 7.7.0
+	testVersion := version.MustParse(test.Ctx().ElasticStackVersion)
+	return !testVersion.IsSameOrAfter(minVersion)
+}
+
+func NewBuilder(name string) Builder {
+	return newBuilder(name, rand.String(4))
+}
+
+func NewBuilderWithoutSuffix(name string) Builder {
+	return newBuilder(name, "")
+}
+
+func newBuilder(name, randSuffix string) Builder {
+	meta := metav1.ObjectMeta{
+		Name:      name,
+		Namespace: test.Ctx().ManagedNamespace(0),
+	}
+
+	return Builder{
+		EnterpriseSearch: entv1beta1.EnterpriseSearch{
+			ObjectMeta: meta,
+			Spec: entv1beta1.EnterpriseSearchSpec{
+				Count:   1,
+				Version: test.Ctx().ElasticStackVersion,
+			},
+		},
+	}.
+		WithSuffix(randSuffix).
+		WithLabel(run.TestNameLabel, name).
+		WithPodLabel(run.TestNameLabel, name)
+}
+
+func (b Builder) WithSuffix(suffix string) Builder {
+	if suffix != "" {
+		b.EnterpriseSearch.ObjectMeta.Name = b.EnterpriseSearch.ObjectMeta.Name + "-" + suffix
+	}
+	return b
+}
+
+// WithRestrictedSecurityContext helps to enforce a restricted security context on the objects.
+func (b Builder) WithRestrictedSecurityContext() Builder {
+	b.EnterpriseSearch.Spec.PodTemplate.Spec.SecurityContext = test.DefaultSecurityContext()
+	return b
+}
+
+func (b Builder) WithElasticsearchRef(ref commonv1.ObjectSelector) Builder {
+	b.EnterpriseSearch.Spec.ElasticsearchRef = ref
+	return b
+}
+
+func (b Builder) WithNamespace(namespace string) Builder {
+	b.EnterpriseSearch.ObjectMeta.Namespace = namespace
+	return b
+}
+
+func (b Builder) WithVersion(version string) Builder {
+	b.EnterpriseSearch.Spec.Version = version
+	return b
+}
+
+func (b Builder) WithNodeCount(count int) Builder {
+	b.EnterpriseSearch.Spec.Count = int32(count)
+	return b
+}
+
+func (b Builder) WithConfig(cfg map[string]interface{}) Builder {
+	if b.EnterpriseSearch.Spec.Config == nil || b.EnterpriseSearch.Spec.Config.Data == nil {
+		b.EnterpriseSearch.Spec.Config = &commonv1.Config{
+			Data: cfg,
+		}
+		return b
+	}
+	for k, v := range cfg {
+		b.EnterpriseSearch.Spec.Config.Data[k] = v
+	}
+	return b
+}
+
+func (b Builder) WithConfigRef(ref entv1beta1.ConfigSource) Builder {
+	if b.EnterpriseSearch.Spec.ConfigRef == nil {
+		b.EnterpriseSearch.Spec.ConfigRef = []entv1beta1.ConfigSource{}
+	}
+	b.EnterpriseSearch.Spec.ConfigRef = append(b.EnterpriseSearch.Spec.ConfigRef, ref)
+	return b
+}
+
+func (b Builder) WithMutatedFrom(mutatedFrom *Builder) Builder {
+	b.MutatedFrom = mutatedFrom
+	return b
+}
+
+func (b Builder) WithLabel(key, value string) Builder {
+	if b.EnterpriseSearch.Labels == nil {
+		b.EnterpriseSearch.Labels = make(map[string]string)
+	}
+	b.EnterpriseSearch.Labels[key] = value
+
+	return b
+}
+
+// WithPodLabel sets the label in the pod template. All invocations can be removed when
+// https://github.com/elastic/cloud-on-k8s/issues/2652 is implemented.
+func (b Builder) WithPodLabel(key, value string) Builder {
+	labels := b.EnterpriseSearch.Spec.PodTemplate.Labels
+	if labels == nil {
+		labels = make(map[string]string)
+	}
+	labels[key] = value
+	b.EnterpriseSearch.Spec.PodTemplate.Labels = labels
+	return b
+}
+
+// -- Helper functions
+
+func (b Builder) RuntimeObjects() []runtime.Object {
+	return []runtime.Object{&b.EnterpriseSearch}
+}
diff --git a/test/e2e/test/enterprisesearch/checks_ent.go b/test/e2e/test/enterprisesearch/checks_ent.go
new file mode 100644
index 000000000..0125b9ce6
--- /dev/null
+++ b/test/e2e/test/enterprisesearch/checks_ent.go
@@ -0,0 +1,121 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package enterprisesearch
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/elastic/cloud-on-k8s/test/e2e/test"
+)
+
+const (
+	appSearchEngine     = "test-engine"
+	appSearchSampleDocs = `[
+	{
+		"id": "park_rocky-mountain",
+		"title": "Rocky Mountain",
+		"description": "Bisected north to south by the Continental Divide, this portion of the Rockies has ecosystems varying from over 150 riparian lakes to montane and subalpine forests to treeless alpine tundra. Wildlife including mule deer, bighorn sheep, black bears, and cougars inhabit its igneous mountains and glacial valleys. Longs Peak, a classic Colorado fourteener, and the scenic Bear Lake are popular destinations, as well as the historic Trail Ridge Road, which reaches an elevation of more than 12,000 feet (3,700 m).",
+		"nps_link": "https://www.nps.gov/romo/index.htm",
+		"states": [
+			"Colorado"
+	],
+		"visitors": 4517585,
+		"world_heritage_site": false,
+		"location": "40.4,-105.58",
+		"acres": 265795.2,
+		"square_km": 1075.6,
+		"date_established": "1915-01-26T06:00:00Z"
+	},
+	{
+		"id": "park_saguaro",
+		"title": "Saguaro",
+		"description": "Split into the separate Rincon Mountain and Tucson Mountain districts, this park is evidence that the dry Sonoran Desert is still home to a great variety of life spanning six biotic communities. Beyond the namesake giant saguaro cacti, there are barrel cacti, chollas, and prickly pears, as well as lesser long-nosed bats, spotted owls, and javelinas.",
+		"nps_link": "https://www.nps.gov/sagu/index.htm",
+		"states": [
+		"Arizona"
+		],
+		"visitors": 820426,
+		"world_heritage_site": false,
+		"location": "32.25,-110.5",
+		"acres": 91715.72,
+		"square_km": 371.2,
+		"date_established": "1994-10-14T05:00:00Z"
+	}
+	]`
+)
+
+func (b Builder) CheckStackTestSteps(k *test.K8sClient) test.StepList {
+	var entClient EnterpriseSearchClient
+	var appSearchClient AppSearchClient
+	return test.StepList{
+		test.Step{
+			Name: "Every secret should be set so that we can build an Enterprise Search client",
+			Test: test.Eventually(func() error {
+				var err error
+				entClient, err = NewEnterpriseSearchClient(b.EnterpriseSearch, k)
+				return err
+			}),
+		},
+		test.Step{
+			Name: "Enterprise Search health endpoint should eventually respond",
+			Test: test.Eventually(func() error { // nolint
+				return entClient.HealthCheck()
+			}),
+		},
+
+		// App Search tests
+		test.Step{
+			Name: "Retrieve the App Search API Key",
+			Test: func(t *testing.T) {
+				appSearchClient = entClient.AppSearch()
+				key, err := appSearchClient.GetAPIKey()
+				require.NoError(t, err)
+				appSearchClient = appSearchClient.WithAPIKey(key)
+			},
+		},
+		test.Step{
+			Name: "Create an App Search engine (if not already done)",
+			Test: func(t *testing.T) {
+				results, err := appSearchClient.GetEngines()
+				require.NoError(t, err)
+				if len(results.Results) == 0 {
+					require.NoError(t, appSearchClient.CreateEngine(appSearchEngine))
+				}
+			},
+		},
+		test.Step{
+			Name: "Index documents in the App Search engine (if not already done)",
+			Test: func(t *testing.T) {
+				results, err := appSearchClient.GetDocuments(appSearchEngine)
+				require.NoError(t, err)
+				if len(results.Results) == 0 {
+					require.NoError(t, appSearchClient.IndexDocument(appSearchEngine, appSearchSampleDocs))
+				}
+			},
+		},
+		test.Step{
+			Name: "Querying 'mountain' in App Search should eventually return 2 documents",
+			// the index operation is not atomic, hence the search retry
+			Test: test.Eventually(func() error {
+				results, err := appSearchClient.SearchDocuments(appSearchEngine, "mountain")
+				if err != nil {
+					return err
+				}
+				if len(results.Results) != 2 {
+					return fmt.Errorf("expected %d documents, got %d", 2, len(results.Results))
+				}
+				return nil
+			}),
+		},
+		// Workplace Search tests are much harder to perform, so there are none so far:
+		// - we need a trial license (already manipulated once in other tests)
+		// - connecting to an external source (eg. Google Drive) is probably too much work
+		// Both the Healthcheck and AppSearch tests already validated the Enterprise Search instance can
+		// correctly communicate with Elasticsearch.
+	}
+}
diff --git a/test/e2e/test/enterprisesearch/checks_k8s.go b/test/e2e/test/enterprisesearch/checks_k8s.go
new file mode 100644
index 000000000..b457f9dca
--- /dev/null
+++ b/test/e2e/test/enterprisesearch/checks_k8s.go
@@ -0,0 +1,208 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package enterprisesearch
+
+import (
+	"fmt"
+
+	appsv1 "k8s.io/api/apps/v1"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+
+	"github.com/elastic/cloud-on-k8s/pkg/controller/common/hash"
+	"github.com/elastic/cloud-on-k8s/pkg/utils/k8s"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test"
+)
+
+func (b Builder) CheckK8sTestSteps(k *test.K8sClient) test.StepList {
+	return test.StepList{
+		CheckDeployment(b, k),
+		CheckPods(b, k),
+		CheckServices(b, k),
+		CheckServicesEndpoints(b, k),
+		CheckSecrets(b, k),
+	}
+}
+
+// CheckDeployment checks the Deployment resource exists
+func CheckDeployment(b Builder, k *test.K8sClient) test.Step {
+	return test.Step{
+		Name: "EnterpriseSearch deployment should be created",
+		Test: test.Eventually(func() error {
+			var dep appsv1.Deployment
+			err := k.Client.Get(types.NamespacedName{
+				Namespace: b.EnterpriseSearch.Namespace,
+				Name:      b.EnterpriseSearch.Name + "-ent",
+			}, &dep)
+			if b.EnterpriseSearch.Spec.Count == 0 && apierrors.IsNotFound(err) {
+				return nil
+			}
+			if err != nil {
+				return err
+			}
+			if *dep.Spec.Replicas != b.EnterpriseSearch.Spec.Count {
+				return fmt.Errorf("invalid EnterpriseSearch replicas count: expected %d, got %d", b.EnterpriseSearch.Spec.Count, *dep.Spec.Replicas)
+			}
+			return nil
+		}),
+	}
+}
+
+// CheckPods checks expected Enterprise Search pods are eventually ready.
+// TODO: use a common function for all deployments (kb, apm, ent)
+func CheckPods(b Builder, k *test.K8sClient) test.Step {
+	return test.Step{
+		Name: "Enterprise Search Pods should eventually be ready",
+		Test: test.Eventually(func() error {
+			var pods corev1.PodList
+			if err := k.Client.List(&pods, test.EnterpriseSearchPodListOptions(b.EnterpriseSearch.Namespace, b.EnterpriseSearch.Name)...); err != nil {
+				return err
+			}
+
+			// builder hash matches
+			expectedBuilderHash := hash.HashObject(b.EnterpriseSearch.Spec)
+			for _, pod := range pods.Items {
+				if err := test.ValidateBuilderHashAnnotation(pod, expectedBuilderHash); err != nil {
+					return err
+				}
+			}
+
+			// pod count matches
+			if len(pods.Items) != int(b.EnterpriseSearch.Spec.Count) {
+				return fmt.Errorf("invalid EnterpriseSearch pod count: expected %d, got %d", b.EnterpriseSearch.Spec.Count, len(pods.Items))
+			}
+
+			// pods are running
+			for _, pod := range pods.Items {
+				if pod.Status.Phase != corev1.PodRunning {
+					return fmt.Errorf("pod not running yet")
+				}
+			}
+
+			// pods are ready
+			for _, pod := range pods.Items {
+				if !k8s.IsPodReady(pod) {
+					return fmt.Errorf("pod not ready yet")
+				}
+			}
+
+			return nil
+		}),
+	}
+}
+
+// CheckServices checks that all Enterprise Search services are created
+// TODO: refactor with other resources
+func CheckServices(b Builder, k *test.K8sClient) test.Step {
+	return test.Step{
+		Name: "Enterprise Search services should be created",
+		Test: test.Eventually(func() error {
+			for _, s := range []string{
+				b.EnterpriseSearch.Name + "-ent-http",
+			} {
+				if _, err := k.GetService(b.EnterpriseSearch.Namespace, s); err != nil {
+					return err
+				}
+			}
+			return nil
+		}),
+	}
+}
+
+// CheckServicesEndpoints checks that services have the expected number of endpoints
+func CheckServicesEndpoints(b Builder, k *test.K8sClient) test.Step {
+	return test.Step{
+		Name: "EnterpriseSearch services should have endpoints",
+		Test: test.Eventually(func() error {
+			for endpointName, addrCount := range map[string]int{
+				b.EnterpriseSearch.Name + "-ent-http": int(b.EnterpriseSearch.Spec.Count),
+			} {
+				endpoints, err := k.GetEndpoints(b.EnterpriseSearch.Namespace, endpointName)
+				if err != nil {
+					return err
+				}
+				if len(endpoints.Subsets) == 0 {
+					return fmt.Errorf("no subset for endpoint %s", endpointName)
+				}
+				if len(endpoints.Subsets[0].Addresses) != addrCount {
+					return fmt.Errorf("%d addresses found for endpoint %s, expected %d", len(endpoints.Subsets[0].Addresses), endpointName, addrCount)
+				}
+			}
+			return nil
+		}),
+	}
+}
+
+// CheckSecrets checks that expected secrets have been created.
+func CheckSecrets(b Builder, k *test.K8sClient) test.Step {
+	return test.CheckSecretsContent(k, b.EnterpriseSearch.Namespace, func() []test.ExpectedSecret {
+		entName := b.EnterpriseSearch.Name
+		// hardcode all secret names and keys to catch any breaking change
+		expected := []test.ExpectedSecret{
+			{
+				Name: entName + "-ent-config",
+				Keys: []string{"enterprise-search.yml", "readiness-probe.sh"},
+				Labels: map[string]string{
+					"common.k8s.elastic.co/type":           "enterprise-search",
+					"eck.k8s.elastic.co/credentials":       "true",
+					"enterprisesearch.k8s.elastic.co/name": entName,
+				},
+			},
+		}
+		if b.EnterpriseSearch.Spec.ElasticsearchRef.Name != "" {
+			expected = append(expected,
+				test.ExpectedSecret{
+					Name: entName + "-ent-es-ca",
+					Keys: []string{"ca.crt", "tls.crt"},
+					Labels: map[string]string{
+						"elasticsearch.k8s.elastic.co/cluster-name": b.EnterpriseSearch.Spec.ElasticsearchRef.Name,
+						"entassociation.k8s.elastic.co/name":        entName,
+						"entassociation.k8s.elastic.co/namespace":   b.EnterpriseSearch.Namespace,
+					},
+				},
+				test.ExpectedSecret{
+					Name: entName + "-ent-user",
+					Keys: []string{b.EnterpriseSearch.Namespace + "-" + entName + "-ent-user"},
+					Labels: map[string]string{
+						"eck.k8s.elastic.co/credentials":            "true",
+						"elasticsearch.k8s.elastic.co/cluster-name": b.EnterpriseSearch.Spec.ElasticsearchRef.Name,
+						"entassociation.k8s.elastic.co/name":        entName,
+						"entassociation.k8s.elastic.co/namespace":   b.EnterpriseSearch.Namespace,
+					},
+				},
+			)
+		}
+		if b.EnterpriseSearch.Spec.HTTP.TLS.Enabled() {
+			expected = append(expected,
+				test.ExpectedSecret{
+					Name: entName + "-ent-http-ca-internal",
+					Keys: []string{"tls.crt", "tls.key"},
+					Labels: map[string]string{
+						"enterprisesearch.k8s.elastic.co/name": entName,
+						"common.k8s.elastic.co/type":           "enterprise-search",
+					},
+				},
+				test.ExpectedSecret{
+					Name: entName + "-ent-http-certs-internal",
+					Keys: []string{"tls.crt", "tls.key", "ca.crt"},
+					Labels: map[string]string{
+						"enterprisesearch.k8s.elastic.co/name": entName,
+						"common.k8s.elastic.co/type":           "enterprise-search",
+					},
+				},
+				test.ExpectedSecret{
+					Name: entName + "-ent-http-certs-public",
+					Keys: []string{"ca.crt", "tls.crt"},
+					Labels: map[string]string{
+						"enterprisesearch.k8s.elastic.co/name": entName,
+						"common.k8s.elastic.co/type":           "enterprise-search",
+					},
+				},
+			)
+		}
+		return expected
+	})
+}
diff --git a/test/e2e/test/enterprisesearch/http_client.go b/test/e2e/test/enterprisesearch/http_client.go
new file mode 100644
index 000000000..06d3c7dc7
--- /dev/null
+++ b/test/e2e/test/enterprisesearch/http_client.go
@@ -0,0 +1,226 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package enterprisesearch
+
+import (
+	"bytes"
+	"context"
+	"crypto/x509"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"time"
+
+	"k8s.io/apimachinery/pkg/types"
+
+	entv1beta1 "github.com/elastic/cloud-on-k8s/pkg/apis/enterprisesearch/v1beta1"
+	"github.com/elastic/cloud-on-k8s/pkg/controller/enterprisesearch"
+	entName "github.com/elastic/cloud-on-k8s/pkg/controller/enterprisesearch/name"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test"
+)
+
+const (
+	ReqTimeout          = 1 * time.Minute
+	AppSearchPrivateKey = "private-key"
+)
+
+type EnterpriseSearchClient struct {
+	httpClient *http.Client
+	endpoint   string
+	username   string
+	password   string
+}
+
+func NewEnterpriseSearchClient(ent entv1beta1.EnterpriseSearch, k *test.K8sClient) (EnterpriseSearchClient, error) {
+	var caCerts []*x509.Certificate
+	scheme := "http"
+	if ent.Spec.HTTP.TLS.Enabled() {
+		scheme = "https"
+		crts, err := k.GetHTTPCerts(entName.EntNamer, ent.Namespace, ent.Name)
+		if err != nil {
+			return EnterpriseSearchClient{}, err
+		}
+		caCerts = crts
+	}
+	esNamespace := ent.Spec.ElasticsearchRef.Namespace
+	if len(esNamespace) == 0 {
+		esNamespace = ent.Namespace
+	}
+	password, err := k.GetElasticPassword(types.NamespacedName{Namespace: esNamespace, Name: ent.Spec.ElasticsearchRef.Name})
+	if err != nil {
+		return EnterpriseSearchClient{}, err
+	}
+	endpoint := fmt.Sprintf("%s://%s.%s.svc:%d", scheme, ent.Name+"-ent-http", ent.Namespace, enterprisesearch.HTTPPort)
+	return EnterpriseSearchClient{
+		httpClient: test.NewHTTPClient(caCerts),
+		endpoint:   endpoint,
+		username:   "elastic",
+		password:   password,
+	}, nil
+}
+
+func (e EnterpriseSearchClient) doRequest(request *http.Request) ([]byte, error) {
+	if len(request.Header.Get("Authorization")) == 0 {
+		// no api key set, use basic auth
+		request.SetBasicAuth(e.username, e.password)
+	}
+
+	request.Header.Set("Content-Type", "application/json")
+
+	ctx, cancel := context.WithTimeout(context.Background(), ReqTimeout)
+	defer cancel()
+
+	resp, err := e.httpClient.Do(request.WithContext(ctx))
+	if err != nil {
+		return nil, err
+	}
+	if resp.StatusCode < 200 || resp.StatusCode > 299 {
+		return nil, fmt.Errorf("http response status code is %d)", resp.StatusCode)
+	}
+
+	defer resp.Body.Close()
+	return ioutil.ReadAll(resp.Body)
+}
+
+func (e EnterpriseSearchClient) HealthCheck() error {
+	// check the endpoint responds to requests
+	url := e.endpoint + "/api/ent/v1/internal/health"
+	req, err := http.NewRequest(http.MethodGet, url, http.NoBody)
+	if err != nil {
+		return err
+	}
+	_, err = e.doRequest(req)
+	return err
+}
+
+func (e EnterpriseSearchClient) AppSearch() AppSearchClient {
+	return AppSearchClient{EnterpriseSearchClient: e}
+}
+
+type AppSearchClient struct {
+	EnterpriseSearchClient
+	apiKey string
+}
+
+func (a AppSearchClient) WithAPIKey(key string) AppSearchClient {
+	a.apiKey = key
+	return a
+}
+
+func (a AppSearchClient) doRequest(request *http.Request) ([]byte, error) {
+	if len(a.apiKey) > 0 {
+		// set the api key header
+		request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.apiKey))
+	}
+	return a.EnterpriseSearchClient.doRequest(request)
+}
+
+type CredentialsCollection struct {
+	Results []struct {
+		Name string `json:"name"`
+		Key  string `json:"key"`
+	} `json:"results"`
+}
+
+func (a AppSearchClient) GetAPIKey() (string, error) {
+	url := a.endpoint + "/as/credentials/collection"
+
+	req, err := http.NewRequest(http.MethodGet, url, nil)
+	if err != nil {
+		return "", err
+	}
+	resp, err := a.doRequest(req)
+	if err != nil {
+		return "", err
+	}
+
+	var credentials CredentialsCollection
+	if err := json.Unmarshal(resp, &credentials); err != nil {
+		return "", err
+	}
+	for _, res := range credentials.Results {
+		if res.Name == AppSearchPrivateKey {
+			return res.Key, nil
+		}
+	}
+
+	return "", fmt.Errorf("cannot find %s in results (len %d)", AppSearchPrivateKey, len(credentials.Results))
+}
+
+type Results struct {
+	Results []map[string]interface{} `json:"results"`
+}
+
+func (a AppSearchClient) GetEngines() (Results, error) {
+	url := a.endpoint + "/api/as/v1/engines"
+
+	req, err := http.NewRequest(http.MethodGet, url, nil)
+	if err != nil {
+		return Results{}, err
+	}
+	respBody, err := a.doRequest(req)
+	if err != nil {
+		return Results{}, err
+	}
+	var results Results
+	return results, json.Unmarshal(respBody, &results)
+}
+
+func (a AppSearchClient) CreateEngine(name string) error {
+	url := a.endpoint + "/api/as/v1/engines"
+	body := []byte(fmt.Sprintf(`{"name": "%s"}`, name))
+
+	req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(body))
+	if err != nil {
+		return err
+	}
+	_, err = a.doRequest(req)
+	return err
+}
+
+func (a AppSearchClient) GetDocuments(engineName string) (Results, error) {
+	url := a.endpoint + fmt.Sprintf("/api/as/v1/engines/%s/documents/list", engineName)
+
+	req, err := http.NewRequest(http.MethodGet, url, nil)
+	if err != nil {
+		return Results{}, err
+	}
+	respBody, err := a.doRequest(req)
+	if err != nil {
+		return Results{}, err
+	}
+	var results Results
+	return results, json.Unmarshal(respBody, &results)
+}
+
+func (a AppSearchClient) IndexDocument(engineName string, document string) error {
+	url := a.endpoint + fmt.Sprintf("/api/as/v1/engines/%s/documents", engineName)
+
+	req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer([]byte(document)))
+	if err != nil {
+		return err
+	}
+	_, err = a.doRequest(req)
+	return err
+}
+
+func (a AppSearchClient) SearchDocuments(engineName string, query string) (Results, error) {
+	url := a.endpoint + fmt.Sprintf("/api/as/v1/engines/%s/search", engineName)
+	body := fmt.Sprintf(`{"query": "%s"}`, query)
+
+	req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer([]byte(body)))
+	if err != nil {
+		return Results{}, err
+	}
+
+	respBody, err := a.doRequest(req)
+	if err != nil {
+		return Results{}, err
+	}
+
+	var results Results
+	return results, json.Unmarshal(respBody, &results)
+}
diff --git a/test/e2e/test/enterprisesearch/steps_creation.go b/test/e2e/test/enterprisesearch/steps_creation.go
new file mode 100644
index 000000000..d55e856fb
--- /dev/null
+++ b/test/e2e/test/enterprisesearch/steps_creation.go
@@ -0,0 +1,38 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package enterprisesearch
+
+import (
+	"testing"
+
+	entv1beta1 "github.com/elastic/cloud-on-k8s/pkg/apis/enterprisesearch/v1beta1"
+	"github.com/elastic/cloud-on-k8s/pkg/utils/k8s"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test"
+	"github.com/stretchr/testify/require"
+	_ "k8s.io/client-go/plugin/pkg/client/auth/gcp" // auth on gke
+)
+
+func (b Builder) CreationTestSteps(k *test.K8sClient) test.StepList {
+	return test.StepList{
+		{
+			Name: "Creating Enterprise Search should succeed",
+			Test: func(t *testing.T) {
+				for _, obj := range b.RuntimeObjects() {
+					err := k.Client.Create(obj)
+					require.NoError(t, err)
+				}
+			},
+		},
+		{
+			Name: "Enterprise Search should be created",
+			Test: func(t *testing.T) {
+				var createdEnt entv1beta1.EnterpriseSearch
+				err := k.Client.Get(k8s.ExtractNamespacedName(&b.EnterpriseSearch), &createdEnt)
+				require.NoError(t, err)
+				require.Equal(t, b.EnterpriseSearch.Spec.Version, createdEnt.Spec.Version)
+			},
+		},
+	}
+}
diff --git a/test/e2e/test/enterprisesearch/steps_deletion.go b/test/e2e/test/enterprisesearch/steps_deletion.go
new file mode 100644
index 000000000..76da573c1
--- /dev/null
+++ b/test/e2e/test/enterprisesearch/steps_deletion.go
@@ -0,0 +1,58 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package enterprisesearch
+
+import (
+	"testing"
+
+	"github.com/elastic/cloud-on-k8s/pkg/utils/k8s"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test"
+	"github.com/pkg/errors"
+	"github.com/stretchr/testify/require"
+	"k8s.io/apimachinery/pkg/api/meta"
+
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+)
+
+func (b Builder) DeletionTestSteps(k *test.K8sClient) test.StepList {
+	return test.StepList{
+		{
+			Name: "Deleting EnterpriseSearch should return no error",
+			Test: func(t *testing.T) {
+				for _, obj := range b.RuntimeObjects() {
+					err := k.Client.Delete(obj)
+					require.NoError(t, err)
+
+				}
+			},
+		},
+		{
+			Name: "EnterpriseSearch should not be there anymore",
+			Test: test.Eventually(func() error {
+				for _, obj := range b.RuntimeObjects() {
+					m, err := meta.Accessor(obj)
+					if err != nil {
+						return err
+					}
+					err = k.Client.Get(k8s.ExtractNamespacedName(m), obj.DeepCopyObject())
+					if err != nil {
+						if apierrors.IsNotFound(err) {
+							continue
+						}
+					}
+					return errors.Wrap(err, "expected 404 not found API error here")
+
+				}
+				return nil
+			}),
+		},
+		{
+			Name: "EnterpriseSearch pods should eventually be removed",
+			Test: test.Eventually(func() error {
+				return k.CheckPodCount(0, test.EnterpriseSearchPodListOptions(b.EnterpriseSearch.Namespace, b.EnterpriseSearch.Name)...)
+			}),
+		},
+	}
+}
diff --git a/test/e2e/test/enterprisesearch/steps_init.go b/test/e2e/test/enterprisesearch/steps_init.go
new file mode 100644
index 000000000..5ebb36ee8
--- /dev/null
+++ b/test/e2e/test/enterprisesearch/steps_init.go
@@ -0,0 +1,66 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package enterprisesearch
+
+import (
+	entv1beta1 "github.com/elastic/cloud-on-k8s/pkg/apis/enterprisesearch/v1beta1"
+	"github.com/elastic/cloud-on-k8s/test/e2e/cmd/run"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+)
+
+func (b Builder) InitTestSteps(k *test.K8sClient) test.StepList {
+	return []test.Step{
+		{
+			Name: "K8S should be accessible",
+			Test: test.Eventually(func() error {
+				pods := corev1.PodList{}
+				return k.Client.List(&pods)
+			}),
+		},
+		{
+			Name: "Label test pods",
+			Test: test.Eventually(func() error {
+				return test.LabelTestPods(
+					k.Client,
+					test.Ctx(),
+					run.TestNameLabel,
+					b.EnterpriseSearch.Labels[run.TestNameLabel])
+			}),
+			Skip: func() bool {
+				return test.Ctx().Local
+			},
+		},
+		{
+			Name: "EnterpriseSearch CRDs should exist",
+			Test: test.Eventually(func() error {
+				crds := []runtime.Object{
+					&entv1beta1.EnterpriseSearchList{},
+				}
+				for _, crd := range crds {
+					if err := k.Client.List(crd); err != nil {
+						return err
+					}
+				}
+				return nil
+			}),
+		},
+		{
+			Name: "Remove EnterpriseSearch if it already exists",
+			Test: test.Eventually(func() error {
+				for _, obj := range b.RuntimeObjects() {
+					err := k.Client.Delete(obj)
+					if err != nil && !apierrors.IsNotFound(err) {
+						return err
+					}
+				}
+				// wait for pods to disappear
+				return k.CheckPodCount(0, test.EnterpriseSearchPodListOptions(b.EnterpriseSearch.Namespace, b.EnterpriseSearch.Name)...)
+			}),
+		},
+	}
+}
diff --git a/test/e2e/test/enterprisesearch/steps_mutation.go b/test/e2e/test/enterprisesearch/steps_mutation.go
new file mode 100644
index 000000000..1e7b23315
--- /dev/null
+++ b/test/e2e/test/enterprisesearch/steps_mutation.go
@@ -0,0 +1,62 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package enterprisesearch
+
+import (
+	"testing"
+
+	entv1beta1 "github.com/elastic/cloud-on-k8s/pkg/apis/enterprisesearch/v1beta1"
+	"github.com/elastic/cloud-on-k8s/pkg/controller/common/hash"
+	"github.com/elastic/cloud-on-k8s/pkg/utils/k8s"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test"
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+)
+
+func (b Builder) MutationTestSteps(k *test.K8sClient) test.StepList {
+	return b.AnnotatePodsWithBuilderHash(k).
+		WithSteps(b.UpgradeTestSteps(k)).
+		WithSteps(b.CheckK8sTestSteps(k)).
+		WithSteps(b.CheckStackTestSteps(k))
+}
+
+func (b Builder) AnnotatePodsWithBuilderHash(k *test.K8sClient) test.StepList {
+	return []test.Step{
+		{
+			Name: "Annotate Pods with a hash of their Builder spec",
+			Test: test.Eventually(func() error {
+				var pods corev1.PodList
+				if err := k.Client.List(&pods, test.EnterpriseSearchPodListOptions(b.EnterpriseSearch.Namespace, b.EnterpriseSearch.Name)...); err != nil {
+					return err
+				}
+
+				expectedHash := hash.HashObject(b.MutatedFrom.EnterpriseSearch.Spec)
+				for _, pod := range pods.Items {
+					if err := test.AnnotatePodWithBuilderHash(k, pod, expectedHash); err != nil {
+						return err
+					}
+				}
+				return nil
+			}),
+		},
+	}
+}
+
+func (b Builder) MutationReversalTestContext() test.ReversalTestContext {
+	panic("not implemented")
+}
+
+func (b Builder) UpgradeTestSteps(k *test.K8sClient) test.StepList {
+	return test.StepList{
+		{
+			Name: "Applying the Enterprise Search mutation should succeed",
+			Test: func(t *testing.T) {
+				var ent entv1beta1.EnterpriseSearch
+				require.NoError(t, k.Client.Get(k8s.ExtractNamespacedName(&b.EnterpriseSearch), &ent))
+				ent.Spec = b.EnterpriseSearch.Spec
+				require.NoError(t, k.Client.Update(&ent))
+			},
+		}}
+}
diff --git a/test/e2e/test/helper/yaml.go b/test/e2e/test/helper/yaml.go
index 9b1622083..7aa976610 100644
--- a/test/e2e/test/helper/yaml.go
+++ b/test/e2e/test/helper/yaml.go
@@ -12,12 +12,14 @@ import (
 	apmv1 "github.com/elastic/cloud-on-k8s/pkg/apis/apm/v1"
 	beatv1beta1 "github.com/elastic/cloud-on-k8s/pkg/apis/beat/v1beta1"
 	esv1 "github.com/elastic/cloud-on-k8s/pkg/apis/elasticsearch/v1"
+	entv1beta1 "github.com/elastic/cloud-on-k8s/pkg/apis/enterprisesearch/v1beta1"
 	kbv1 "github.com/elastic/cloud-on-k8s/pkg/apis/kibana/v1"
 	beatcommon "github.com/elastic/cloud-on-k8s/pkg/controller/beat/common"
 	"github.com/elastic/cloud-on-k8s/test/e2e/test"
 	"github.com/elastic/cloud-on-k8s/test/e2e/test/apmserver"
 	"github.com/elastic/cloud-on-k8s/test/e2e/test/beat"
 	"github.com/elastic/cloud-on-k8s/test/e2e/test/elasticsearch"
+	"github.com/elastic/cloud-on-k8s/test/e2e/test/enterprisesearch"
 	"github.com/elastic/cloud-on-k8s/test/e2e/test/kibana"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/serializer"
@@ -37,6 +39,7 @@ func NewYAMLDecoder() *YAMLDecoder {
 	scheme.AddKnownTypes(kbv1.GroupVersion, &kbv1.Kibana{}, &kbv1.KibanaList{})
 	scheme.AddKnownTypes(apmv1.GroupVersion, &apmv1.ApmServer{}, &apmv1.ApmServerList{})
 	scheme.AddKnownTypes(beatv1beta1.GroupVersion, &beatv1beta1.Beat{}, &beatv1beta1.BeatList{})
+	scheme.AddKnownTypes(entv1beta1.GroupVersion, &entv1beta1.EnterpriseSearch{}, &entv1beta1.EnterpriseSearchList{})
 	decoder := serializer.NewCodecFactory(scheme).UniversalDeserializer()
 
 	return &YAMLDecoder{decoder: decoder}
@@ -78,6 +81,10 @@ func (yd *YAMLDecoder) ToBuilders(reader *bufio.Reader, transform BuilderTransfo
 			b := beat.NewBuilderWithoutSuffix(decodedObj.Name, beatcommon.Type(decodedObj.Spec.Type))
 			b.Beat = *decodedObj
 			builder = transform(b)
+		case *entv1beta1.EnterpriseSearch:
+			b := enterprisesearch.NewBuilderWithoutSuffix(decodedObj.Name)
+			b.EnterpriseSearch = *decodedObj
+			builder = transform(b)
 		default:
 			return builders, fmt.Errorf("unexpected object type: %t", decodedObj)
 		}
diff --git a/test/e2e/test/k8s_client.go b/test/e2e/test/k8s_client.go
index 5a2faafc1..a3a3946f7 100644
--- a/test/e2e/test/k8s_client.go
+++ b/test/e2e/test/k8s_client.go
@@ -27,6 +27,7 @@ import (
 	apmv1 "github.com/elastic/cloud-on-k8s/pkg/apis/apm/v1"
 	beatv1beta1 "github.com/elastic/cloud-on-k8s/pkg/apis/beat/v1beta1"
 	esv1 "github.com/elastic/cloud-on-k8s/pkg/apis/elasticsearch/v1"
+	entv1beta1 "github.com/elastic/cloud-on-k8s/pkg/apis/enterprisesearch/v1beta1"
 	kbv1 "github.com/elastic/cloud-on-k8s/pkg/apis/kibana/v1"
 	"github.com/elastic/cloud-on-k8s/pkg/controller/apmserver"
 	beatcommon "github.com/elastic/cloud-on-k8s/pkg/controller/beat/common"
@@ -34,6 +35,7 @@ import (
 	"github.com/elastic/cloud-on-k8s/pkg/controller/common/certificates"
 	"github.com/elastic/cloud-on-k8s/pkg/controller/common/name"
 	"github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/label"
+	"github.com/elastic/cloud-on-k8s/pkg/controller/enterprisesearch"
 	"github.com/elastic/cloud-on-k8s/pkg/controller/kibana"
 	"github.com/elastic/cloud-on-k8s/pkg/utils/k8s"
 )
@@ -78,6 +80,9 @@ func CreateClient() (k8s.Client, error) {
 	if err := beatv1beta1.AddToScheme(scheme.Scheme); err != nil {
 		return nil, err
 	}
+	if err := entv1beta1.AddToScheme(scheme.Scheme); err != nil {
+		return nil, err
+	}
 	client, err := k8sclient.New(cfg, k8sclient.Options{Scheme: scheme.Scheme})
 	if err != nil {
 		return nil, err
@@ -317,7 +322,15 @@ func ApmServerPodListOptions(apmNamespace, apmName string) []k8sclient.ListOptio
 		apmserver.ApmServerNameLabelName: apmName,
 	})
 	return []k8sclient.ListOption{ns, matchLabels}
+}
 
+func EnterpriseSearchPodListOptions(entNamespace, entName string) []k8sclient.ListOption {
+	ns := k8sclient.InNamespace(entNamespace)
+	matchLabels := k8sclient.MatchingLabels(map[string]string{
+		common.TypeLabelName:                           enterprisesearch.Type,
+		enterprisesearch.EnterpriseSearchNameLabelName: entName,
+	})
+	return []k8sclient.ListOption{ns, matchLabels}
 }
 
 func BeatPodListOptions(beatNamespace, beatName, beatType string) []k8sclient.ListOption {
diff --git a/test/e2e/test/kibana/builder.go b/test/e2e/test/kibana/builder.go
index 3c4987703..ba8bb5a3c 100644
--- a/test/e2e/test/kibana/builder.go
+++ b/test/e2e/test/kibana/builder.go
@@ -5,12 +5,13 @@
 package kibana
 
 import (
-	"github.com/elastic/cloud-on-k8s/test/e2e/cmd/run"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/util/rand"
 
+	"github.com/elastic/cloud-on-k8s/test/e2e/cmd/run"
+
 	commonv1 "github.com/elastic/cloud-on-k8s/pkg/apis/common/v1"
 	kbv1 "github.com/elastic/cloud-on-k8s/pkg/apis/kibana/v1"
 	"github.com/elastic/cloud-on-k8s/test/e2e/test"
@@ -25,6 +26,10 @@ type Builder struct {
 
 var _ test.Builder = Builder{}
 
+func (b Builder) SkipTest() bool {
+	return false
+}
+
 func NewBuilder(name string) Builder {
 	return newBuilder(name, rand.String(4))
 }
diff --git a/test/e2e/test/kibana/checks_k8s.go b/test/e2e/test/kibana/checks_k8s.go
index 7de9aaa8f..ffc85c679 100644
--- a/test/e2e/test/kibana/checks_k8s.go
+++ b/test/e2e/test/kibana/checks_k8s.go
@@ -50,9 +50,9 @@ func CheckKibanaPods(b Builder, k *test.K8sClient) test.Step {
 			}
 
 			// builder hash matches
-			goodBuilderHash := hash.HashObject(b.Kibana.Spec)
+			expectedBuilderHash := hash.HashObject(b.Kibana.Spec)
 			for _, pod := range pods.Items {
-				if err := test.ValidateBuilderHashAnnotation(pod, goodBuilderHash); err != nil {
+				if err := test.ValidateBuilderHashAnnotation(pod, expectedBuilderHash); err != nil {
 					return err
 				}
 			}
diff --git a/test/e2e/test/run.go b/test/e2e/test/run.go
index dab456b5d..cc168c812 100644
--- a/test/e2e/test/run.go
+++ b/test/e2e/test/run.go
@@ -7,9 +7,15 @@ package test
 // Sequence returns a list of steps corresponding to the basic workflow (some optional init steps, then init steps,
 // create steps, check steps, then something and delete steps to terminate).
 func Sequence(before StepsFunc, f StepsFunc, builders ...Builder) StepList {
-	k := NewK8sClientOrFatal()
-
 	steps := StepList{}
+	for _, b := range builders {
+		// ignore the test if some builders cannot be tested
+		if b.SkipTest() {
+			return steps
+		}
+	}
+
+	k := NewK8sClientOrFatal()
 
 	if before != nil {
 		steps = steps.WithSteps(before(k))
diff --git a/test/e2e/test/version.go b/test/e2e/test/version.go
index d25a29b0e..189460b63 100644
--- a/test/e2e/test/version.go
+++ b/test/e2e/test/version.go
@@ -14,9 +14,9 @@ import (
 // Elastic Stack versions used in the E2E tests
 const (
 	// Minimum version for 6.8.x tested with the operator
-	MinVersion68x = "6.8.9"
+	MinVersion68x = "6.8.10"
 	// Current latest version for 7.x
-	LatestVersion7x = "7.7.0" // version to synchronize with the latest release of the Elastic Stack
+	LatestVersion7x = "7.7.1" // version to synchronize with the latest release of the Elastic Stack
 )
 
 // SkipInvalidUpgrade skips a test that would do an invalid upgrade.