-
Notifications
You must be signed in to change notification settings - Fork 218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: SSA based dependent resource matching and create/update #1928
Changes from all commits
c61be33
f605a6b
1ae332d
4ee4607
f687134
5acd152
192e37e
8b5c80d
4c7b339
64f8a7f
c5a54bd
a6a150f
6cf5bff
a9d5260
93a9810
49c03de
3352127
4fe7e94
7be1f5e
157d83b
b68b53c
9109a4a
81b406a
5ce5d67
1fabc6f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
apiVersion: v1 | ||
kind: ConfigMap | ||
metadata: | ||
name: test1 | ||
namespace: default | ||
ownerReferences: | ||
- apiVersion: v1 | ||
kind: ConfigMap | ||
name: kube-root-ca.crt | ||
uid: 1ef74cb4-dbbd-45ef-9caf-aa76186594ea | ||
data: | ||
key1: "val1" | ||
# key2: "val2" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
apiVersion: v1 | ||
kind: ConfigMap | ||
metadata: | ||
name: test1 | ||
namespace: default | ||
data: | ||
key3: "val3" | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,359 @@ | ||
package io.javaoperatorsdk.operator.processing.dependent.kubernetes; | ||
|
||
import java.util.ArrayList; | ||
import java.util.HashMap; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.Optional; | ||
import java.util.Set; | ||
import java.util.SortedMap; | ||
import java.util.TreeMap; | ||
import java.util.stream.Collectors; | ||
|
||
import org.slf4j.Logger; | ||
import org.slf4j.LoggerFactory; | ||
|
||
import io.fabric8.kubernetes.api.model.HasMetadata; | ||
import io.fabric8.kubernetes.api.model.ManagedFieldsEntry; | ||
import io.javaoperatorsdk.operator.OperatorException; | ||
import io.javaoperatorsdk.operator.api.reconciler.Context; | ||
|
||
import com.fasterxml.jackson.core.JsonProcessingException; | ||
import com.fasterxml.jackson.core.type.TypeReference; | ||
import com.fasterxml.jackson.databind.ObjectMapper; | ||
|
||
/** | ||
* Matches the actual state on the server vs the desired state. Based on the managedFields of SSA. | ||
* | ||
* <p> | ||
* The basis of algorithm is to extract the fields managed we convert resources to Map/List | ||
* composition. The actual resource (from the server) is pruned, all the fields which are not | ||
* mentioed in managedFields of the target manager is removed. Some irrelevant fields are also | ||
* removed from desired. And the two resulted Maps are compared for equality. The implementation is | ||
* a bit nasty since have to deal with some specific cases of managedFields format. | ||
* </p> | ||
* | ||
* @param <R> matched resource type | ||
*/ | ||
// https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#fieldsv1-v1-meta | ||
// https://github.com/kubernetes-sigs/structured-merge-diff | ||
// https://docs.aws.amazon.com/eks/latest/userguide/kubernetes-field-management.html | ||
// see also: https://kubernetes.slack.com/archives/C0123CNN8F3/p1686141087220719 | ||
public class SSABasedGenericKubernetesResourceMatcher<R extends HasMetadata> { | ||
|
||
@SuppressWarnings("rawtypes") | ||
private static final SSABasedGenericKubernetesResourceMatcher INSTANCE = | ||
new SSABasedGenericKubernetesResourceMatcher<>(); | ||
public static final String APPLY_OPERATION = "Apply"; | ||
public static final String DOT_KEY = "."; | ||
|
||
@SuppressWarnings("unchecked") | ||
public static <L extends HasMetadata> SSABasedGenericKubernetesResourceMatcher<L> getInstance() { | ||
return INSTANCE; | ||
} | ||
|
||
private static final TypeReference<HashMap<String, Object>> typeRef = new TypeReference<>() {}; | ||
|
||
private static final String F_PREFIX = "f:"; | ||
private static final String K_PREFIX = "k:"; | ||
private static final String V_PREFIX = "v:"; | ||
private static final String METADATA_KEY = "metadata"; | ||
private static final String NAME_KEY = "name"; | ||
private static final String NAMESPACE_KEY = "namespace"; | ||
private static final String KIND_KEY = "kind"; | ||
private static final String API_VERSION_KEY = "apiVersion"; | ||
|
||
private static final Logger log = | ||
LoggerFactory.getLogger(SSABasedGenericKubernetesResourceMatcher.class); | ||
|
||
|
||
public boolean matches(R actual, R desired, Context<?> context) { | ||
try { | ||
var optionalManagedFieldsEntry = | ||
checkIfFieldManagerExists(actual, context.getControllerConfiguration().fieldManager()); | ||
// If no field is managed by our controller, that means the controller hasn't touched the | ||
// resource yet and the resource probably doesn't match the desired state. Not matching here | ||
// means that the resource will need to be updated and since this will be done using SSA, the | ||
// fields our controller cares about will become managed by it | ||
if (optionalManagedFieldsEntry.isEmpty()) { | ||
return false; | ||
} | ||
|
||
var managedFieldsEntry = optionalManagedFieldsEntry.orElseThrow(); | ||
|
||
var objectMapper = | ||
context.getControllerConfiguration().getConfigurationService().getObjectMapper(); | ||
|
||
var actualMap = objectMapper.convertValue(actual, typeRef); | ||
var desiredMap = objectMapper.convertValue(desired, typeRef); | ||
|
||
log.trace("Original actual: \n {} \n original desired: \n {} ", actual, desiredMap); | ||
|
||
var prunedActual = new HashMap<String, Object>(actualMap.size()); | ||
keepOnlyManagedFields(prunedActual, actualMap, | ||
managedFieldsEntry.getFieldsV1().getAdditionalProperties(), objectMapper); | ||
|
||
removeIrrelevantValues(desiredMap); | ||
|
||
log.debug("Pruned actual: \n {} \n desired: \n {} ", prunedActual, desiredMap); | ||
|
||
return prunedActual.equals(desiredMap); | ||
} catch (JsonProcessingException e) { | ||
throw new IllegalStateException(e); | ||
} | ||
} | ||
|
||
@SuppressWarnings("unchecked") | ||
private static void removeIrrelevantValues(Map<String, Object> desiredMap) { | ||
var metadata = (Map<String, Object>) desiredMap.get(METADATA_KEY); | ||
metadata.remove(NAME_KEY); | ||
metadata.remove(NAMESPACE_KEY); | ||
if (metadata.isEmpty()) { | ||
desiredMap.remove(METADATA_KEY); | ||
} | ||
desiredMap.remove(KIND_KEY); | ||
desiredMap.remove(API_VERSION_KEY); | ||
} | ||
|
||
@SuppressWarnings("unchecked") | ||
private static void keepOnlyManagedFields(Map<String, Object> result, | ||
Map<String, Object> actualMap, | ||
Map<String, Object> managedFields, ObjectMapper objectMapper) throws JsonProcessingException { | ||
|
||
if (managedFields.isEmpty()) { | ||
result.putAll(actualMap); | ||
return; | ||
} | ||
for (Map.Entry<String, Object> entry : managedFields.entrySet()) { | ||
String key = entry.getKey(); | ||
if (key.startsWith(F_PREFIX)) { | ||
String keyInActual = keyWithoutPrefix(key); | ||
var managedFieldValue = (Map<String, Object>) entry.getValue(); | ||
if (isNestedValue(managedFieldValue)) { | ||
var managedEntrySet = managedFieldValue.entrySet(); | ||
|
||
// two special cases "k:" and "v:" prefixes | ||
if (isListKeyEntrySet(managedEntrySet)) { | ||
handleListKeyEntrySet(result, actualMap, objectMapper, keyInActual, managedEntrySet); | ||
} else if (isSetValueField(managedEntrySet)) { | ||
handleSetValues(result, actualMap, objectMapper, keyInActual, managedEntrySet); | ||
} else { | ||
// basically if we should traverse further | ||
fillResultsAndTraverseFurther(result, actualMap, managedFields, objectMapper, key, | ||
keyInActual, managedFieldValue); | ||
} | ||
} else { | ||
// this should handle the case when the value is complex in the actual map (not just a | ||
// simple value). | ||
result.put(keyInActual, actualMap.get(keyInActual)); | ||
} | ||
} else { | ||
// .:{} is ignored, other should not be present | ||
if (!DOT_KEY.equals(key)) { | ||
throw new IllegalStateException("Key: " + key + " has no prefix: " + F_PREFIX); | ||
} | ||
} | ||
} | ||
} | ||
|
||
@SuppressWarnings("unchecked") | ||
private static void fillResultsAndTraverseFurther(Map<String, Object> result, | ||
Map<String, Object> actualMap, Map<String, Object> managedFields, ObjectMapper objectMapper, | ||
String key, String keyInActual, Object managedFieldValue) throws JsonProcessingException { | ||
var emptyMapValue = new HashMap<String, Object>(); | ||
result.put(keyInActual, emptyMapValue); | ||
var actualMapValue = actualMap.get(keyInActual); | ||
log.debug("key: {} actual map value: {} managedFieldValue: {}", keyInActual, | ||
actualMapValue, managedFieldValue); | ||
|
||
keepOnlyManagedFields(emptyMapValue, (Map<String, Object>) actualMapValue, | ||
(Map<String, Object>) managedFields.get(key), objectMapper); | ||
} | ||
|
||
private static boolean isNestedValue(Map<?, ?> managedFieldValue) { | ||
return !managedFieldValue.isEmpty(); | ||
} | ||
|
||
/** | ||
* List entries referenced by key, or when "k:" prefix is used. It works in a way that it selects | ||
* the target element based on the field(s) in "k:" for example when there is a list of element of | ||
* owner references, the uid can serve as a key for a list element: | ||
* "k:{"uid":"1ef74cb4-dbbd-45ef-9caf-aa76186594ea"}". It selects the element and recursively | ||
* processes it. Note that in these lists the order matters and seems that if there are more keys | ||
* ("k:"), the ordering of those in the managed fields are not the same as the value order. So | ||
* this also explicitly orders the result based on the value order in the resource not the key | ||
* order in managed field. | ||
*/ | ||
@SuppressWarnings("unchecked") | ||
private static void handleListKeyEntrySet(Map<String, Object> result, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add documentation as to what this method is doing and why. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added |
||
Map<String, Object> actualMap, | ||
ObjectMapper objectMapper, String keyInActual, | ||
Set<Map.Entry<String, Object>> managedEntrySet) { | ||
var valueList = new ArrayList<>(); | ||
result.put(keyInActual, valueList); | ||
var actualValueList = (List<Map<String, Object>>) actualMap.get(keyInActual); | ||
|
||
SortedMap<Integer, Map<String, Object>> targetValuesByIndex = new TreeMap<>(); | ||
Map<Integer, Map<String, Object>> managedEntryByIndex = new HashMap<>(); | ||
|
||
for (Map.Entry<String, Object> listEntry : managedEntrySet) { | ||
if (DOT_KEY.equals(listEntry.getKey())) { | ||
continue; | ||
} | ||
var actualListEntry = selectListEntryBasedOnKey(keyWithoutPrefix(listEntry.getKey()), | ||
actualValueList, objectMapper); | ||
targetValuesByIndex.put(actualListEntry.getKey(), actualListEntry.getValue()); | ||
managedEntryByIndex.put(actualListEntry.getKey(), (Map<String, Object>) listEntry.getValue()); | ||
} | ||
|
||
targetValuesByIndex.forEach((key, value) -> { | ||
var emptyResMapValue = new HashMap<String, Object>(); | ||
valueList.add(emptyResMapValue); | ||
try { | ||
keepOnlyManagedFields(emptyResMapValue, value, managedEntryByIndex.get(key), objectMapper); | ||
} catch (JsonProcessingException ex) { | ||
throw new IllegalStateException(ex); | ||
} | ||
}); | ||
} | ||
|
||
/** | ||
* Set values, the "v:" prefix. Form in managed fields: "f:some-set":{"v:1":{}},"v:2":{},"v:3":{}} | ||
* Note that this should be just used in very rare cases, actually was not able to produce a | ||
* sample. Kubernetes developers who worked on this feature were not able to provide one either | ||
* when prompted. Basically this method just adds the values from {@code "v:<value>"} to the | ||
* result. | ||
*/ | ||
@SuppressWarnings("rawtypes") | ||
private static void handleSetValues(Map<String, Object> result, Map<String, Object> actualMap, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add documentation about what this method is doing and why. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added docs |
||
ObjectMapper objectMapper, String keyInActual, | ||
Set<Map.Entry<String, Object>> managedEntrySet) { | ||
var valueList = new ArrayList<>(); | ||
result.put(keyInActual, valueList); | ||
for (Map.Entry<String, Object> valueEntry : managedEntrySet) { | ||
// not clear if this can happen | ||
if (DOT_KEY.equals(valueEntry.getKey())) { | ||
continue; | ||
} | ||
Class<?> targetClass = null; | ||
List values = (List) actualMap.get(keyInActual); | ||
if (!(values.get(0) instanceof Map)) { | ||
targetClass = values.get(0).getClass(); | ||
} | ||
|
||
var value = | ||
parseKeyValue(keyWithoutPrefix(valueEntry.getKey()), targetClass, objectMapper); | ||
valueList.add(value); | ||
} | ||
} | ||
|
||
public static Object parseKeyValue(String stringValue, Class<?> targetClass, | ||
ObjectMapper objectMapper) { | ||
try { | ||
stringValue = stringValue.trim(); | ||
if (targetClass != null) { | ||
return objectMapper.readValue(stringValue, targetClass); | ||
} else { | ||
return objectMapper.readValue(stringValue, typeRef); | ||
} | ||
} catch (JsonProcessingException e) { | ||
throw new IllegalStateException(e); | ||
} | ||
} | ||
|
||
private static boolean isSetValueField(Set<Map.Entry<String, Object>> managedEntrySet) { | ||
return isKeyPrefixedSkippingDotKey(managedEntrySet, V_PREFIX); | ||
} | ||
|
||
private static boolean isListKeyEntrySet(Set<Map.Entry<String, Object>> managedEntrySet) { | ||
return isKeyPrefixedSkippingDotKey(managedEntrySet, K_PREFIX); | ||
} | ||
|
||
/** | ||
* Sometimes (not always) the first subfield of a managed field ("f:") is ".:{}", it looks that | ||
* those are added when there are more subfields of a referenced field. See test samples. Does not | ||
* seem to provide additional functionality, so can be just skipped for now. | ||
*/ | ||
private static boolean isKeyPrefixedSkippingDotKey(Set<Map.Entry<String, Object>> managedEntrySet, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method is weird. Why do we only look at the first element of the set? Why do we only skip the dot key if it's the first element? Please add documentation as to what this method does and why. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added docs |
||
String prefix) { | ||
var iterator = managedEntrySet.iterator(); | ||
var managedFieldEntry = iterator.next(); | ||
if (managedFieldEntry.getKey().equals(DOT_KEY)) { | ||
managedFieldEntry = iterator.next(); | ||
} | ||
return managedFieldEntry.getKey().startsWith(prefix); | ||
} | ||
|
||
private static java.util.Map.Entry<Integer, Map<String, Object>> selectListEntryBasedOnKey( | ||
String key, | ||
List<Map<String, Object>> values, | ||
ObjectMapper objectMapper) { | ||
try { | ||
Map<String, Object> ids = objectMapper.readValue(key, typeRef); | ||
List<Map<String, Object>> possibleTargets = new ArrayList<>(1); | ||
int index = -1; | ||
for (int i = 0; i < values.size(); i++) { | ||
var v = values.get(i); | ||
if (v.entrySet().containsAll(ids.entrySet())) { | ||
possibleTargets.add(v); | ||
index = i; | ||
} | ||
} | ||
if (possibleTargets.isEmpty()) { | ||
throw new IllegalStateException( | ||
"Cannot find list element for key:" + key + ", in map: " + values); | ||
} | ||
if (possibleTargets.size() > 1) { | ||
throw new IllegalStateException( | ||
"More targets found in list element for key:" + key + ", in map: " + values); | ||
} | ||
final var finalIndex = index; | ||
return new Map.Entry<>() { | ||
@Override | ||
public Integer getKey() { | ||
return finalIndex; | ||
} | ||
|
||
@Override | ||
public Map<String, Object> getValue() { | ||
return possibleTargets.get(0); | ||
} | ||
|
||
@Override | ||
public Map<String, Object> setValue(Map<String, Object> stringObjectMap) { | ||
throw new IllegalStateException("should not be called"); | ||
} | ||
}; | ||
} catch (JsonProcessingException e) { | ||
throw new IllegalStateException(e); | ||
} | ||
} | ||
|
||
|
||
private Optional<ManagedFieldsEntry> checkIfFieldManagerExists(R actual, String fieldManager) { | ||
var targetManagedFields = actual.getMetadata().getManagedFields().stream() | ||
// Only the apply operations are interesting for us since those were created properly be SSA | ||
// Patch. An update can be present with same fieldManager when migrating and having the same | ||
// field manager name. | ||
.filter( | ||
f -> f.getManager().equals(fieldManager) && f.getOperation().equals(APPLY_OPERATION)) | ||
.collect(Collectors.toList()); | ||
if (targetManagedFields.isEmpty()) { | ||
log.debug("No field manager exists for resource {} with name: {} and operation Apply ", | ||
actual, actual.getMetadata().getName()); | ||
return Optional.empty(); | ||
} | ||
// this should not happen in theory | ||
if (targetManagedFields.size() > 1) { | ||
throw new OperatorException( | ||
"More than one field manager exists with name: " + fieldManager + "in resource: " + | ||
actual + " with name: " + actual.getMetadata().getName()); | ||
} | ||
return Optional.of(targetManagedFields.get(0)); | ||
} | ||
|
||
private static String keyWithoutPrefix(String key) { | ||
return key.substring(2); | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
package io.javaoperatorsdk.operator.processing.dependent.kubernetes; | ||
|
||
import java.util.Map; | ||
|
||
import org.junit.jupiter.api.BeforeEach; | ||
import org.junit.jupiter.api.Test; | ||
|
||
import io.fabric8.kubernetes.api.model.ConfigMap; | ||
import io.fabric8.kubernetes.api.model.HasMetadata; | ||
import io.fabric8.kubernetes.api.model.apps.Deployment; | ||
import io.javaoperatorsdk.operator.ReconcilerUtils; | ||
import io.javaoperatorsdk.operator.api.config.ConfigurationService; | ||
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; | ||
import io.javaoperatorsdk.operator.api.reconciler.Context; | ||
|
||
import static org.assertj.core.api.Assertions.assertThat; | ||
import static org.mockito.Mockito.mock; | ||
import static org.mockito.Mockito.when; | ||
|
||
class SSABasedGenericKubernetesResourceMatcherTest { | ||
|
||
Context<?> mockedContext = mock(Context.class); | ||
|
||
SSABasedGenericKubernetesResourceMatcher<HasMetadata> matcher = | ||
new SSABasedGenericKubernetesResourceMatcher<>(); | ||
|
||
@BeforeEach | ||
@SuppressWarnings("unchecked") | ||
void setup() { | ||
var controllerConfiguration = mock(ControllerConfiguration.class); | ||
when(controllerConfiguration.fieldManager()).thenReturn("controller"); | ||
var configurationService = mock(ConfigurationService.class); | ||
when(configurationService.getObjectMapper()).thenCallRealMethod(); | ||
when(controllerConfiguration.getConfigurationService()).thenReturn(configurationService); | ||
when(mockedContext.getControllerConfiguration()).thenReturn(controllerConfiguration); | ||
} | ||
|
||
@Test | ||
void checksIfAddsNotAddedByController() { | ||
var desired = loadResource("nginx-deployment.yaml", Deployment.class); | ||
var actual = | ||
loadResource("deployment-with-managed-fields-additional-controller.yaml", Deployment.class); | ||
|
||
assertThat(matcher.matches(actual, desired, mockedContext)).isTrue(); | ||
} | ||
|
||
// In the example the owner reference in a list is referenced by "k:", while all the fields are | ||
// managed but not listed | ||
@Test | ||
void emptyListElementMatchesAllFields() { | ||
var desiredConfigMap = loadResource("configmap.empty-owner-reference-desired.yaml", | ||
ConfigMap.class); | ||
var actualConfigMap = loadResource("configmap.empty-owner-reference.yaml", | ||
ConfigMap.class); | ||
|
||
assertThat(matcher.matches(actualConfigMap, desiredConfigMap, mockedContext)).isTrue(); | ||
} | ||
|
||
// the whole "rules:" part is just implicitly managed | ||
@Test | ||
void wholeComplexFieldManaged() { | ||
var desiredConfigMap = loadResource("sample-whole-complex-part-managed-desired.yaml", | ||
ConfigMap.class); | ||
var actualConfigMap = loadResource("sample-whole-complex-part-managed.yaml", | ||
ConfigMap.class); | ||
|
||
assertThat(matcher.matches(actualConfigMap, desiredConfigMap, mockedContext)).isTrue(); | ||
} | ||
|
||
@Test | ||
void multiItemList() { | ||
var desiredConfigMap = loadResource("multi-container-pod-desired.yaml", | ||
ConfigMap.class); | ||
var actualConfigMap = loadResource("multi-container-pod.yaml", | ||
ConfigMap.class); | ||
|
||
assertThat(matcher.matches(actualConfigMap, desiredConfigMap, mockedContext)).isTrue(); | ||
} | ||
|
||
@Test | ||
void changeValueInDesiredMakesMatchFail() { | ||
var desiredConfigMap = loadResource("configmap.empty-owner-reference-desired.yaml", | ||
ConfigMap.class); | ||
desiredConfigMap.getData().put("key1", "different value"); | ||
var actualConfigMap = loadResource("configmap.empty-owner-reference.yaml", | ||
ConfigMap.class); | ||
|
||
assertThat(matcher.matches(actualConfigMap, desiredConfigMap, mockedContext)).isFalse(); | ||
} | ||
|
||
@Test | ||
void changeValueActualMakesMatchFail() { | ||
var desiredConfigMap = loadResource("configmap.empty-owner-reference-desired.yaml", | ||
ConfigMap.class); | ||
|
||
var actualConfigMap = loadResource("configmap.empty-owner-reference.yaml", | ||
ConfigMap.class); | ||
actualConfigMap.getData().put("key1", "different value"); | ||
|
||
assertThat(matcher.matches(actualConfigMap, desiredConfigMap, mockedContext)).isFalse(); | ||
} | ||
|
||
@Test | ||
void addedLabelInDesiredMakesMatchFail() { | ||
var desiredConfigMap = loadResource("configmap.empty-owner-reference-desired.yaml", | ||
ConfigMap.class); | ||
desiredConfigMap.getMetadata().setLabels(Map.of("newlabel", "val")); | ||
|
||
var actualConfigMap = loadResource("configmap.empty-owner-reference.yaml", | ||
ConfigMap.class); | ||
|
||
assertThat(matcher.matches(actualConfigMap, desiredConfigMap, mockedContext)).isFalse(); | ||
} | ||
|
||
private <R> R loadResource(String fileName, Class<R> clazz) { | ||
return ReconcilerUtils.loadYaml(clazz, SSABasedGenericKubernetesResourceMatcherTest.class, | ||
fileName); | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
apiVersion: v1 | ||
kind: ConfigMap | ||
metadata: | ||
name: test1 | ||
namespace: default | ||
ownerReferences: | ||
- apiVersion: v1 | ||
kind: ConfigMap | ||
name: kube-root-ca.crt | ||
uid: 1ef74cb4-dbbd-45ef-9caf-aa76186594ea | ||
data: | ||
key1: "val1" | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
apiVersion: v1 | ||
data: | ||
key1: "val1" | ||
kind: ConfigMap | ||
metadata: | ||
creationTimestamp: "2023-06-07T11:08:34Z" | ||
managedFields: | ||
- apiVersion: v1 | ||
fieldsType: FieldsV1 | ||
fieldsV1: | ||
f:data: | ||
f:key1: {} | ||
f:metadata: | ||
f:ownerReferences: | ||
k:{"uid":"1ef74cb4-dbbd-45ef-9caf-aa76186594ea"}: {} | ||
manager: controller | ||
operation: Apply | ||
time: "2023-06-07T11:08:34Z" | ||
name: test1 | ||
namespace: default | ||
ownerReferences: | ||
- apiVersion: v1 | ||
kind: ConfigMap | ||
name: kube-root-ca.crt | ||
uid: 1ef74cb4-dbbd-45ef-9caf-aa76186594ea | ||
resourceVersion: "400" | ||
uid: 1d47f98f-ff1e-46d8-bbb5-6658ec488ae2 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
apiVersion: apps/v1 | ||
kind: Deployment | ||
metadata: | ||
annotations: | ||
deployment.kubernetes.io/revision: "1" | ||
creationTimestamp: "2023-06-01T08:43:47Z" | ||
generation: 1 | ||
managedFields: | ||
- apiVersion: apps/v1 | ||
fieldsType: FieldsV1 | ||
fieldsV1: | ||
f:spec: | ||
f:progressDeadlineSeconds: {} | ||
f:replicas: {} | ||
f:revisionHistoryLimit: {} | ||
f:selector: {} | ||
f:template: | ||
f:metadata: | ||
f:labels: | ||
f:app: {} | ||
f:spec: | ||
f:containers: | ||
k:{"name":"nginx"}: | ||
.: {} | ||
f:image: {} | ||
f:name: {} | ||
f:ports: | ||
k:{"containerPort":80,"protocol":"TCP"}: | ||
.: {} | ||
f:containerPort: {} | ||
manager: controller | ||
operation: Apply | ||
time: "2023-06-01T08:43:47Z" | ||
- apiVersion: apps/v1 | ||
fieldsType: FieldsV1 | ||
fieldsV1: | ||
f:metadata: | ||
f:annotations: | ||
.: {} | ||
f:deployment.kubernetes.io/revision: {} | ||
f:status: | ||
f:availableReplicas: {} | ||
f:conditions: | ||
.: {} | ||
k:{"type":"Available"}: | ||
.: {} | ||
f:lastTransitionTime: {} | ||
f:lastUpdateTime: {} | ||
f:message: {} | ||
f:reason: {} | ||
f:status: {} | ||
f:type: {} | ||
k:{"type":"Progressing"}: | ||
.: {} | ||
f:lastTransitionTime: {} | ||
f:lastUpdateTime: {} | ||
f:message: {} | ||
f:reason: {} | ||
f:status: {} | ||
f:type: {} | ||
f:observedGeneration: {} | ||
f:readyReplicas: {} | ||
f:replicas: {} | ||
f:updatedReplicas: {} | ||
manager: kube-controller-manager | ||
operation: Update | ||
subresource: status | ||
time: "2023-06-01T08:43:54Z" | ||
name: test | ||
namespace: default | ||
resourceVersion: "422" | ||
uid: f4572f1d-5fd6-4564-8e61-0d55d0398a6c | ||
spec: | ||
progressDeadlineSeconds: 600 | ||
replicas: 1 | ||
revisionHistoryLimit: 10 | ||
selector: | ||
matchLabels: | ||
app: test-dependent | ||
strategy: | ||
rollingUpdate: | ||
maxSurge: 25% | ||
maxUnavailable: 25% | ||
type: RollingUpdate | ||
template: | ||
metadata: | ||
creationTimestamp: null | ||
labels: | ||
app: test-dependent | ||
spec: | ||
containers: | ||
- image: nginx:1.17.0 | ||
imagePullPolicy: IfNotPresent | ||
name: nginx | ||
ports: | ||
- containerPort: 80 | ||
protocol: TCP | ||
resources: {} | ||
terminationMessagePath: /dev/termination-log | ||
terminationMessagePolicy: File | ||
dnsPolicy: ClusterFirst | ||
restartPolicy: Always | ||
schedulerName: default-scheduler | ||
securityContext: {} | ||
terminationGracePeriodSeconds: 30 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
apiVersion: apps/v1 | ||
kind: Deployment | ||
metadata: | ||
annotations: | ||
deployment.kubernetes.io/revision: "1" | ||
creationTimestamp: "2023-06-01T08:43:47Z" | ||
generation: 1 | ||
managedFields: | ||
- apiVersion: apps/v1 | ||
fieldsType: FieldsV1 | ||
fieldsV1: | ||
f:spec: | ||
f:progressDeadlineSeconds: {} | ||
f:replicas: {} | ||
f:revisionHistoryLimit: {} | ||
f:selector: {} | ||
f:template: | ||
f:metadata: | ||
f:labels: | ||
f:app: {} | ||
f:spec: | ||
f:containers: | ||
k:{"name":"nginx"}: | ||
.: {} | ||
f:image: {} | ||
f:name: {} | ||
f:ports: | ||
k:{"containerPort":80,"protocol":"TCP"}: | ||
.: {} | ||
f:containerPort: {} | ||
manager: controller | ||
operation: Apply | ||
time: "2023-06-01T08:43:47Z" | ||
name: test | ||
namespace: default | ||
spec: | ||
progressDeadlineSeconds: 600 | ||
revisionHistoryLimit: 10 | ||
selector: | ||
matchLabels: | ||
app: "test-dependent" | ||
replicas: 1 | ||
template: | ||
metadata: | ||
labels: | ||
app: "test-dependent" | ||
spec: | ||
containers: | ||
- name: nginx | ||
image: nginx:1.17.0 | ||
ports: | ||
- containerPort: 80 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
apiVersion: v1 | ||
kind: Pod | ||
metadata: | ||
name: shared-storage | ||
spec: | ||
volumes: | ||
- name: shared-data | ||
emptyDir: {} | ||
containers: | ||
- name: nginx-container | ||
image: nginx | ||
volumeMounts: | ||
- name: shared-data | ||
mountPath: /usr/share/nginx/html | ||
- name: debian-container | ||
image: debian | ||
volumeMounts: | ||
- name: shared-data | ||
mountPath: /data | ||
command: ["/bin/sh"] | ||
args: ["-c", "echo Level Up Blue Team! > /data/index.html"] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,214 @@ | ||
apiVersion: v1 | ||
kind: Pod | ||
metadata: | ||
creationTimestamp: "2023-06-08T11:50:59Z" | ||
managedFields: | ||
- apiVersion: v1 | ||
fieldsType: FieldsV1 | ||
fieldsV1: | ||
f:spec: | ||
f:containers: | ||
k:{"name":"debian-container"}: | ||
.: {} | ||
f:args: {} | ||
f:command: {} | ||
f:image: {} | ||
f:name: {} | ||
f:volumeMounts: | ||
k:{"mountPath":"/data"}: | ||
.: {} | ||
f:mountPath: {} | ||
f:name: {} | ||
k:{"name":"nginx-container"}: | ||
.: {} | ||
f:image: {} | ||
f:name: {} | ||
f:volumeMounts: | ||
k:{"mountPath":"/usr/share/nginx/html"}: | ||
.: {} | ||
f:mountPath: {} | ||
f:name: {} | ||
f:volumes: | ||
k:{"name":"shared-data"}: | ||
.: {} | ||
f:emptyDir: {} | ||
f:name: {} | ||
manager: controller | ||
operation: Apply | ||
time: "2023-06-08T11:50:59Z" | ||
- apiVersion: v1 | ||
fieldsType: FieldsV1 | ||
fieldsV1: | ||
f:status: | ||
f:conditions: | ||
k:{"type":"ContainersReady"}: | ||
.: {} | ||
f:lastProbeTime: {} | ||
f:lastTransitionTime: {} | ||
f:message: {} | ||
f:reason: {} | ||
f:status: {} | ||
f:type: {} | ||
k:{"type":"Initialized"}: | ||
.: {} | ||
f:lastProbeTime: {} | ||
f:lastTransitionTime: {} | ||
f:status: {} | ||
f:type: {} | ||
k:{"type":"Ready"}: | ||
.: {} | ||
f:lastProbeTime: {} | ||
f:lastTransitionTime: {} | ||
f:message: {} | ||
f:reason: {} | ||
f:status: {} | ||
f:type: {} | ||
f:containerStatuses: {} | ||
f:hostIP: {} | ||
f:phase: {} | ||
f:podIP: {} | ||
f:podIPs: | ||
.: {} | ||
k:{"ip":"10.244.0.3"}: | ||
.: {} | ||
f:ip: {} | ||
f:startTime: {} | ||
manager: kubelet | ||
operation: Update | ||
subresource: status | ||
time: "2023-06-08T11:51:21Z" | ||
name: shared-storage | ||
namespace: default | ||
resourceVersion: "1950" | ||
uid: 0c916935-8198-4d62-980e-193f3c3ec877 | ||
spec: | ||
containers: | ||
- image: nginx | ||
imagePullPolicy: Always | ||
name: nginx-container | ||
resources: {} | ||
terminationMessagePath: /dev/termination-log | ||
terminationMessagePolicy: File | ||
volumeMounts: | ||
- mountPath: /usr/share/nginx/html | ||
name: shared-data | ||
- mountPath: /var/run/secrets/kubernetes.io/serviceaccount | ||
name: kube-api-access-gxpbz | ||
readOnly: true | ||
- args: | ||
- -c | ||
- echo Level Up Blue Team! > /data/index.html | ||
command: | ||
- /bin/sh | ||
image: debian | ||
imagePullPolicy: Always | ||
name: debian-container | ||
resources: {} | ||
terminationMessagePath: /dev/termination-log | ||
terminationMessagePolicy: File | ||
volumeMounts: | ||
- mountPath: /data | ||
name: shared-data | ||
- mountPath: /var/run/secrets/kubernetes.io/serviceaccount | ||
name: kube-api-access-gxpbz | ||
readOnly: true | ||
dnsPolicy: ClusterFirst | ||
enableServiceLinks: true | ||
nodeName: minikube | ||
preemptionPolicy: PreemptLowerPriority | ||
priority: 0 | ||
restartPolicy: Always | ||
schedulerName: default-scheduler | ||
securityContext: {} | ||
serviceAccount: default | ||
serviceAccountName: default | ||
terminationGracePeriodSeconds: 30 | ||
tolerations: | ||
- effect: NoExecute | ||
key: node.kubernetes.io/not-ready | ||
operator: Exists | ||
tolerationSeconds: 300 | ||
- effect: NoExecute | ||
key: node.kubernetes.io/unreachable | ||
operator: Exists | ||
tolerationSeconds: 300 | ||
volumes: | ||
- emptyDir: {} | ||
name: shared-data | ||
- name: kube-api-access-gxpbz | ||
projected: | ||
defaultMode: 420 | ||
sources: | ||
- serviceAccountToken: | ||
expirationSeconds: 3607 | ||
path: token | ||
- configMap: | ||
items: | ||
- key: ca.crt | ||
path: ca.crt | ||
name: kube-root-ca.crt | ||
- downwardAPI: | ||
items: | ||
- fieldRef: | ||
apiVersion: v1 | ||
fieldPath: metadata.namespace | ||
path: namespace | ||
status: | ||
conditions: | ||
- lastProbeTime: null | ||
lastTransitionTime: "2023-06-08T11:50:59Z" | ||
status: "True" | ||
type: Initialized | ||
- lastProbeTime: null | ||
lastTransitionTime: "2023-06-08T11:50:59Z" | ||
message: 'containers with unready status: [debian-container]' | ||
reason: ContainersNotReady | ||
status: "False" | ||
type: Ready | ||
- lastProbeTime: null | ||
lastTransitionTime: "2023-06-08T11:50:59Z" | ||
message: 'containers with unready status: [debian-container]' | ||
reason: ContainersNotReady | ||
status: "False" | ||
type: ContainersReady | ||
- lastProbeTime: null | ||
lastTransitionTime: "2023-06-08T11:50:59Z" | ||
status: "True" | ||
type: PodScheduled | ||
containerStatuses: | ||
- containerID: docker://ead1d3e4beaaa9176daca99e55673a2176e0da51d9953d6a11d5786b730178ee | ||
image: debian:latest | ||
imageID: docker-pullable://debian@sha256:432f545c6ba13b79e2681f4cc4858788b0ab099fc1cca799cc0fae4687c69070 | ||
lastState: | ||
terminated: | ||
containerID: docker://ead1d3e4beaaa9176daca99e55673a2176e0da51d9953d6a11d5786b730178ee | ||
exitCode: 0 | ||
finishedAt: "2023-06-08T11:51:19Z" | ||
reason: Completed | ||
startedAt: "2023-06-08T11:51:19Z" | ||
name: debian-container | ||
ready: false | ||
restartCount: 1 | ||
started: false | ||
state: | ||
waiting: | ||
message: back-off 10s restarting failed container=debian-container pod=shared-storage_default(0c916935-8198-4d62-980e-193f3c3ec877) | ||
reason: CrashLoopBackOff | ||
- containerID: docker://afd6260e41afa0b149ebfd904162fb2f22bb037c18904eed599eb9ac1ce4faf0 | ||
image: nginx:latest | ||
imageID: docker-pullable://nginx@sha256:af296b188c7b7df99ba960ca614439c99cb7cf252ed7bbc23e90cfda59092305 | ||
lastState: {} | ||
name: nginx-container | ||
ready: true | ||
restartCount: 0 | ||
started: true | ||
state: | ||
running: | ||
startedAt: "2023-06-08T11:51:09Z" | ||
hostIP: 192.168.49.2 | ||
phase: Running | ||
podIP: 10.244.0.3 | ||
podIPs: | ||
- ip: 10.244.0.3 | ||
qosClass: BestEffort | ||
startTime: "2023-06-08T11:50:59Z" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
kind: FlowSchema | ||
metadata: | ||
annotations: | ||
apf.kubernetes.io/autoupdate-spec: "true" | ||
name: probes | ||
spec: | ||
matchingPrecedence: 2 | ||
priorityLevelConfiguration: | ||
name: exempt | ||
rules: | ||
- nonResourceRules: | ||
- nonResourceURLs: | ||
- /healthz | ||
- /readyz | ||
- /livez | ||
verbs: | ||
- get | ||
subjects: | ||
- group: | ||
name: system:unauthenticated | ||
kind: Group | ||
- group: | ||
name: system:authenticated | ||
kind: Group |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
kind: FlowSchema | ||
metadata: | ||
annotations: | ||
apf.kubernetes.io/autoupdate-spec: "true" | ||
creationTimestamp: "2023-06-08T11:18:25Z" | ||
generation: 1 | ||
managedFields: | ||
- apiVersion: flowcontrol.apiserver.k8s.io/v1beta3 | ||
fieldsType: FieldsV1 | ||
fieldsV1: | ||
f:metadata: | ||
f:annotations: | ||
.: {} | ||
f:apf.kubernetes.io/autoupdate-spec: {} | ||
f:spec: | ||
f:matchingPrecedence: {} | ||
f:priorityLevelConfiguration: | ||
f:name: {} | ||
f:rules: {} | ||
manager: controller | ||
operation: Apply | ||
time: "2023-06-08T11:18:25Z" | ||
name: probes | ||
resourceVersion: "68" | ||
uid: 50913e35-e855-469f-bec6-3e8cd2607ab4 | ||
spec: | ||
matchingPrecedence: 2 | ||
priorityLevelConfiguration: | ||
name: exempt | ||
rules: | ||
- nonResourceRules: | ||
- nonResourceURLs: | ||
- /healthz | ||
- /readyz | ||
- /livez | ||
verbs: | ||
- get | ||
subjects: | ||
- group: | ||
name: system:unauthenticated | ||
kind: Group | ||
- group: | ||
name: system:authenticated | ||
kind: Group |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
package io.javaoperatorsdk.operator; | ||
|
||
import java.time.Duration; | ||
import java.util.Map; | ||
|
||
import org.junit.jupiter.api.Test; | ||
import org.junit.jupiter.api.extension.RegisterExtension; | ||
|
||
import io.fabric8.kubernetes.api.model.ConfigMap; | ||
import io.fabric8.kubernetes.api.model.ConfigMapBuilder; | ||
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; | ||
import io.fabric8.kubernetes.client.dsl.base.PatchContext; | ||
import io.fabric8.kubernetes.client.dsl.base.PatchType; | ||
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; | ||
import io.javaoperatorsdk.operator.sample.dependentssa.DependentSSAReconciler; | ||
import io.javaoperatorsdk.operator.sample.dependentssa.DependentSSASpec; | ||
import io.javaoperatorsdk.operator.sample.dependentssa.DependnetSSACustomResource; | ||
import io.javaoperatorsdk.operator.sample.dependentssa.SSAConfigMapDependent; | ||
|
||
import static org.assertj.core.api.Assertions.assertThat; | ||
import static org.awaitility.Awaitility.await; | ||
|
||
public class DependentSSAMatchingIT { | ||
|
||
public static final String TEST_RESOURCE_NAME = "test1"; | ||
public static final String INITIAL_VALUE = "INITIAL_VALUE"; | ||
public static final String CHANGED_VALUE = "CHANGED_VALUE"; | ||
|
||
public static final String CUSTOM_FIELD_MANAGER_NAME = "customFieldManagerName"; | ||
public static final String OTHER_FIELD_MANAGER = "otherFieldManager"; | ||
public static final String ADDITIONAL_KEY = "key2"; | ||
public static final String ADDITIONAL_VALUE = "Additional Value"; | ||
|
||
|
||
@RegisterExtension | ||
LocallyRunOperatorExtension extension = | ||
LocallyRunOperatorExtension.builder() | ||
.withReconciler(new DependentSSAReconciler(), | ||
o -> o.withFieldManager(CUSTOM_FIELD_MANAGER_NAME)) | ||
.build(); | ||
|
||
@Test | ||
void testMatchingAndUpdate() { | ||
SSAConfigMapDependent.NUMBER_OF_UPDATES.set(0); | ||
var resource = extension.create(testResource()); | ||
|
||
await().untilAsserted(() -> { | ||
var cm = extension.get(ConfigMap.class, TEST_RESOURCE_NAME); | ||
assertThat(cm).isNotNull(); | ||
assertThat(cm.getData()).containsEntry(SSAConfigMapDependent.DATA_KEY, INITIAL_VALUE); | ||
assertThat(cm.getMetadata().getManagedFields().stream() | ||
.filter(fm -> fm.getManager().equals(CUSTOM_FIELD_MANAGER_NAME))).isNotEmpty(); | ||
assertThat(SSAConfigMapDependent.NUMBER_OF_UPDATES.get()).isZero(); | ||
}); | ||
|
||
ConfigMap cmPatch = new ConfigMapBuilder() | ||
.withMetadata(new ObjectMetaBuilder() | ||
.withName(TEST_RESOURCE_NAME) | ||
.withNamespace(resource.getMetadata().getNamespace()) | ||
.build()) | ||
.withData(Map.of(ADDITIONAL_KEY, ADDITIONAL_VALUE)) | ||
.build(); | ||
|
||
extension.getKubernetesClient().configMaps().resource(cmPatch).patch(new PatchContext.Builder() | ||
.withFieldManager(OTHER_FIELD_MANAGER) | ||
.withPatchType(PatchType.SERVER_SIDE_APPLY) | ||
.build()); | ||
|
||
await().pollDelay(Duration.ofMillis(300)).untilAsserted(() -> { | ||
var cm = extension.get(ConfigMap.class, TEST_RESOURCE_NAME); | ||
assertThat(cm.getData()).hasSize(2); | ||
assertThat(SSAConfigMapDependent.NUMBER_OF_UPDATES.get()).isZero(); | ||
assertThat(cm.getMetadata().getManagedFields()).hasSize(2); | ||
}); | ||
|
||
resource.getSpec().setValue(CHANGED_VALUE); | ||
extension.replace(resource); | ||
|
||
await().untilAsserted(() -> { | ||
var cm = extension.get(ConfigMap.class, TEST_RESOURCE_NAME); | ||
assertThat(cm.getData()).hasSize(2); | ||
assertThat(cm.getData()).containsEntry(SSAConfigMapDependent.DATA_KEY, CHANGED_VALUE); | ||
assertThat(cm.getData()).containsEntry(ADDITIONAL_KEY, ADDITIONAL_VALUE); | ||
assertThat(cm.getMetadata().getManagedFields()).hasSize(2); | ||
assertThat(SSAConfigMapDependent.NUMBER_OF_UPDATES.get()).isEqualTo(1); | ||
}); | ||
} | ||
|
||
public DependnetSSACustomResource testResource() { | ||
DependnetSSACustomResource resource = new DependnetSSACustomResource(); | ||
resource.setMetadata(new ObjectMetaBuilder() | ||
.withName(TEST_RESOURCE_NAME) | ||
.build()); | ||
resource.setSpec(new DependentSSASpec()); | ||
resource.getSpec().setValue(INITIAL_VALUE); | ||
return resource; | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,171 @@ | ||
package io.javaoperatorsdk.operator; | ||
|
||
import org.junit.jupiter.api.AfterEach; | ||
import org.junit.jupiter.api.BeforeEach; | ||
import org.junit.jupiter.api.Test; | ||
import org.junit.jupiter.api.TestInfo; | ||
|
||
import io.fabric8.kubernetes.api.model.ConfigMap; | ||
import io.fabric8.kubernetes.api.model.NamespaceBuilder; | ||
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; | ||
import io.fabric8.kubernetes.client.KubernetesClient; | ||
import io.fabric8.kubernetes.client.KubernetesClientBuilder; | ||
import io.fabric8.kubernetes.client.utils.KubernetesResourceUtil; | ||
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; | ||
import io.javaoperatorsdk.operator.sample.dependentssa.DependentSSAReconciler; | ||
import io.javaoperatorsdk.operator.sample.dependentssa.DependentSSASpec; | ||
import io.javaoperatorsdk.operator.sample.dependentssa.DependnetSSACustomResource; | ||
import io.javaoperatorsdk.operator.sample.dependentssa.SSAConfigMapDependent; | ||
|
||
import static org.assertj.core.api.Assertions.assertThat; | ||
import static org.awaitility.Awaitility.await; | ||
|
||
class DependentSSAMigrationIT { | ||
|
||
public static final String FABRIC8_CLIENT_DEFAULT_FIELD_MANAGER = "fabric8-kubernetes-client"; | ||
public static final String TEST_RESOURCE_NAME = "test1"; | ||
public static final String INITIAL_VALUE = "INITIAL_VALUE"; | ||
public static final String CHANGED_VALUE = "CHANGED_VALUE"; | ||
|
||
private String namespace; | ||
private final KubernetesClient client = new KubernetesClientBuilder().build(); | ||
|
||
@BeforeEach | ||
void setup(TestInfo testInfo) { | ||
SSAConfigMapDependent.NUMBER_OF_UPDATES.set(0); | ||
LocallyRunOperatorExtension.applyCrd(DependnetSSACustomResource.class, client); | ||
testInfo.getTestMethod().ifPresent(method -> { | ||
namespace = KubernetesResourceUtil.sanitizeName(method.getName()); | ||
cleanup(); | ||
client.namespaces().resource(new NamespaceBuilder().withMetadata(new ObjectMetaBuilder() | ||
.withName(namespace) | ||
.build()).build()).create(); | ||
}); | ||
} | ||
|
||
@AfterEach | ||
void cleanup() { | ||
client.namespaces().resource(new NamespaceBuilder().withMetadata(new ObjectMetaBuilder() | ||
.withName(namespace) | ||
.build()).build()).delete(); | ||
} | ||
|
||
@Test | ||
void migratesFromLegacyToWorksAndBack() { | ||
var legacyOperator = createOperator(client, true, null); | ||
DependnetSSACustomResource testResource = reconcileWithLegacyOperator(legacyOperator); | ||
|
||
var operator = createOperator(client, false, null); | ||
testResource = reconcileWithNewApproach(testResource, operator); | ||
var cm = getDependentConfigMap(); | ||
assertThat(cm.getMetadata().getManagedFields()).hasSize(2); | ||
|
||
reconcileAgainWithLegacy(legacyOperator, testResource); | ||
} | ||
|
||
@Test | ||
void usingDefaultFieldManagerDoesNotCreatesANewOneWithApplyOperation() { | ||
var legacyOperator = createOperator(client, true, null); | ||
DependnetSSACustomResource testResource = reconcileWithLegacyOperator(legacyOperator); | ||
|
||
var operator = createOperator(client, false, | ||
FABRIC8_CLIENT_DEFAULT_FIELD_MANAGER); | ||
reconcileWithNewApproach(testResource, operator); | ||
|
||
var cm = getDependentConfigMap(); | ||
|
||
assertThat(cm.getMetadata().getManagedFields()).hasSize(2); | ||
assertThat(cm.getMetadata().getManagedFields()) | ||
// Jetty seems to be a bug in fabric8 client, it is only the default fieldManager if Jetty | ||
// is used as http client | ||
.allMatch(fm -> fm.getManager().equals(FABRIC8_CLIENT_DEFAULT_FIELD_MANAGER) | ||
|| fm.getManager().equals("Jetty")); | ||
} | ||
|
||
private void reconcileAgainWithLegacy(Operator legacyOperator, | ||
DependnetSSACustomResource testResource) { | ||
legacyOperator.start(); | ||
|
||
testResource.getSpec().setValue(INITIAL_VALUE); | ||
testResource.getMetadata().setResourceVersion(null); | ||
client.resource(testResource).update(); | ||
|
||
await().untilAsserted(() -> { | ||
var cm = getDependentConfigMap(); | ||
assertThat(cm.getData()).containsEntry(SSAConfigMapDependent.DATA_KEY, INITIAL_VALUE); | ||
}); | ||
|
||
legacyOperator.stop(); | ||
} | ||
|
||
private DependnetSSACustomResource reconcileWithNewApproach( | ||
DependnetSSACustomResource testResource, Operator operator) { | ||
operator.start(); | ||
|
||
await().untilAsserted(() -> { | ||
var cm = getDependentConfigMap(); | ||
assertThat(cm).isNotNull(); | ||
assertThat(cm.getData()).hasSize(1); | ||
}); | ||
|
||
testResource.getSpec().setValue(CHANGED_VALUE); | ||
testResource.getMetadata().setResourceVersion(null); | ||
testResource = client.resource(testResource).update(); | ||
|
||
await().untilAsserted(() -> { | ||
var cm = getDependentConfigMap(); | ||
assertThat(cm.getData()).containsEntry(SSAConfigMapDependent.DATA_KEY, CHANGED_VALUE); | ||
}); | ||
operator.stop(); | ||
return testResource; | ||
} | ||
|
||
private ConfigMap getDependentConfigMap() { | ||
return client.configMaps().inNamespace(namespace).withName(TEST_RESOURCE_NAME).get(); | ||
} | ||
|
||
private DependnetSSACustomResource reconcileWithLegacyOperator(Operator legacyOperator) { | ||
legacyOperator.start(); | ||
|
||
var testResource = client.resource(testResource()).create(); | ||
|
||
await().untilAsserted(() -> { | ||
var cm = getDependentConfigMap(); | ||
assertThat(cm).isNotNull(); | ||
assertThat(cm.getMetadata().getManagedFields()).hasSize(1); | ||
assertThat(cm.getData()).hasSize(1); | ||
}); | ||
|
||
legacyOperator.stop(); | ||
return testResource; | ||
} | ||
|
||
|
||
private Operator createOperator(KubernetesClient client, boolean legacyDependentHandling, | ||
String fieldManager) { | ||
Operator operator = new Operator(client, | ||
o -> o.withSSABasedCreateUpdateForDependentResources(!legacyDependentHandling) | ||
.withSSABasedDefaultMatchingForDependentResources(!legacyDependentHandling) | ||
.withCloseClientOnStop(false)); | ||
operator.register(new DependentSSAReconciler(), o -> { | ||
o.settingNamespace(namespace); | ||
if (fieldManager != null) { | ||
o.withFieldManager(fieldManager); | ||
} | ||
}); | ||
return operator; | ||
} | ||
|
||
|
||
public DependnetSSACustomResource testResource() { | ||
DependnetSSACustomResource resource = new DependnetSSACustomResource(); | ||
resource.setMetadata(new ObjectMetaBuilder() | ||
.withNamespace(namespace) | ||
.withName(TEST_RESOURCE_NAME) | ||
.build()); | ||
resource.setSpec(new DependentSSASpec()); | ||
resource.getSpec().setValue(INITIAL_VALUE); | ||
return resource; | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
package io.javaoperatorsdk.operator.sample.dependentssa; | ||
|
||
import java.util.concurrent.atomic.AtomicInteger; | ||
|
||
import io.javaoperatorsdk.operator.api.reconciler.*; | ||
import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; | ||
import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; | ||
|
||
@ControllerConfiguration(dependents = {@Dependent(type = SSAConfigMapDependent.class)}) | ||
public class DependentSSAReconciler | ||
implements Reconciler<DependnetSSACustomResource>, TestExecutionInfoProvider { | ||
|
||
private final AtomicInteger numberOfExecutions = new AtomicInteger(0); | ||
|
||
@Override | ||
public UpdateControl<DependnetSSACustomResource> reconcile( | ||
DependnetSSACustomResource resource, | ||
Context<DependnetSSACustomResource> context) { | ||
numberOfExecutions.addAndGet(1); | ||
return UpdateControl.noUpdate(); | ||
} | ||
|
||
public int getNumberOfExecutions() { | ||
return numberOfExecutions.get(); | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package io.javaoperatorsdk.operator.sample.dependentssa; | ||
|
||
public class DependentSSASpec { | ||
|
||
private String value; | ||
|
||
public String getValue() { | ||
return value; | ||
} | ||
|
||
public DependentSSASpec setValue(String value) { | ||
this.value = value; | ||
return this; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package io.javaoperatorsdk.operator.sample.dependentssa; | ||
|
||
import io.fabric8.kubernetes.api.model.Namespaced; | ||
import io.fabric8.kubernetes.client.CustomResource; | ||
import io.fabric8.kubernetes.model.annotation.Group; | ||
import io.fabric8.kubernetes.model.annotation.ShortNames; | ||
import io.fabric8.kubernetes.model.annotation.Version; | ||
|
||
@Group("sample.javaoperatorsdk") | ||
@Version("v1") | ||
@ShortNames("dssa") | ||
public class DependnetSSACustomResource | ||
extends CustomResource<DependentSSASpec, Void> | ||
implements Namespaced { | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
package io.javaoperatorsdk.operator.sample.dependentssa; | ||
|
||
import java.util.Map; | ||
import java.util.concurrent.atomic.AtomicInteger; | ||
|
||
import io.fabric8.kubernetes.api.model.ConfigMap; | ||
import io.fabric8.kubernetes.api.model.ConfigMapBuilder; | ||
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; | ||
import io.javaoperatorsdk.operator.api.reconciler.Context; | ||
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; | ||
|
||
public class SSAConfigMapDependent extends | ||
CRUDKubernetesDependentResource<ConfigMap, DependnetSSACustomResource> { | ||
|
||
public static AtomicInteger NUMBER_OF_UPDATES = new AtomicInteger(0); | ||
|
||
public static final String DATA_KEY = "key1"; | ||
|
||
public SSAConfigMapDependent() { | ||
super(ConfigMap.class); | ||
} | ||
|
||
@Override | ||
protected ConfigMap desired(DependnetSSACustomResource primary, | ||
Context<DependnetSSACustomResource> context) { | ||
return new ConfigMapBuilder() | ||
.withMetadata(new ObjectMetaBuilder() | ||
.withName(primary.getMetadata().getName()) | ||
.withNamespace(primary.getMetadata().getNamespace()) | ||
.build()) | ||
.withData(Map.of(DATA_KEY, primary.getSpec().getValue())) | ||
.build(); | ||
} | ||
|
||
@Override | ||
public ConfigMap update(ConfigMap actual, ConfigMap target, | ||
DependnetSSACustomResource primary, | ||
Context<DependnetSSACustomResource> context) { | ||
NUMBER_OF_UPDATES.incrementAndGet(); | ||
return super.update(actual, target, primary, context); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
apiVersion: v1 | ||
kind: Pod | ||
metadata: | ||
name: shared-storage | ||
spec: | ||
volumes: | ||
- name: shared-data | ||
emptyDir: {} | ||
containers: | ||
- name: nginx-container | ||
image: nginx | ||
volumeMounts: | ||
- name: shared-data | ||
mountPath: /usr/share/nginx/html | ||
- name: debian-container | ||
image: debian | ||
volumeMounts: | ||
- name: shared-data | ||
mountPath: /data | ||
command: ["/bin/sh"] | ||
args: ["-c", "echo Level Up Blue Team! > /data/index.html"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there no way to return the pruned map instead of passing it as a parameter?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
probably is, just would need a refactor. If you insist can do that. A separate PR?