| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with |
| * this work for additional information regarding copyright ownership. |
| * The ASF licenses this file to You under the Apache License, Version 2.0 |
| * (the "License"); you may not use this file except in compliance with |
| * the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package org.apache.flink.kubernetes.operator.api.validation; |
| |
| import com.fasterxml.jackson.databind.JsonNode; |
| import com.fasterxml.jackson.databind.ObjectMapper; |
| import com.fasterxml.jackson.databind.node.ObjectNode; |
| import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import java.io.File; |
| import java.io.IOException; |
| import java.net.URL; |
| import java.util.ArrayList; |
| import java.util.List; |
| |
| /** |
| * Utility for checking backward CRD compatibility. |
| * |
| * <p>The aim is to provide a very strict compatibility check logic initially that can be improved |
| * over time with compatible type changes if necessary by CRD updates |
| * |
| * <p>It performs the following checks: |
| * |
| * <ul> |
| * <li>No property removed from any object |
| * <li>No enum value removed from enums (changing enum to string is allowed) |
| * <li>No type changes for fields |
| * <li>No type changes for array/map items |
| * <li>No change in extra properties of field schema definitions |
| * </ul> |
| */ |
| public class CrdCompatibilityChecker { |
| |
| private static final Logger logger = LoggerFactory.getLogger(CrdCompatibilityChecker.class); |
| private static final ObjectMapper objectMapper = new ObjectMapper(new YAMLFactory()); |
| |
| public static void main(String[] args) throws IOException { |
| |
| for (int i = 1; i < args.length; i++) { |
| String actualSchema = args[0]; |
| String oldSchema = args[i]; |
| logger.info("New schema: {}", actualSchema); |
| logger.info("Old schema: {}", oldSchema); |
| checkObjectCompatibility("", getSchema(oldSchema), getSchema(actualSchema)); |
| } |
| System.out.println("Successful validation!"); |
| } |
| |
| private static JsonNode getSchema(String url) throws IOException { |
| var target = new URL(url); |
| var protocol = target.getProtocol(); |
| JsonNode crd; |
| if ("file".equals(protocol)) { |
| crd = objectMapper.readTree(new File(url.substring(7))); |
| } else { |
| crd = objectMapper.readTree(target); |
| } |
| return crd.get("spec").get("versions").get(0).get("schema").get("openAPIV3Schema"); |
| } |
| |
| protected static void checkObjectCompatibility( |
| String path, JsonNode oldNode, JsonNode newNode) { |
| checkTypeCompatibility(path, oldNode, newNode); |
| |
| if (oldNode.has("type") |
| && oldNode.get("type").asText().equals("object") |
| && oldNode.has("properties")) { |
| var oldProps = oldNode.get("properties"); |
| var newProps = newNode.get("properties"); |
| |
| var fieldNamesIt = oldProps.fieldNames(); |
| while (fieldNamesIt.hasNext()) { |
| var field = fieldNamesIt.next(); |
| var fieldPath = path + "." + field; |
| if (!newProps.has(field)) { |
| // This field was removed from Kubernetes ObjectMeta v1 in 1.25 as it was unused |
| // for a long time. If set for any reason (very unlikely as it does nothing), |
| // the property will be dropped / ignored by the api server. |
| if (!fieldPath.endsWith(".metadata.clusterName") |
| // This claims field was removed in Kubernetes 1.28 as it was mistakenly |
| // added in the first place. For more context please refer to |
| // https://github.com/kubernetes/api/commit/8b14183 |
| && !fieldPath.contains(".volumeClaimTemplate.spec.resources.claims")) { |
| err(fieldPath + " has been removed"); |
| } |
| } else { |
| checkObjectCompatibility(fieldPath, oldProps.get(field), newProps.get(field)); |
| } |
| } |
| logger.debug("Successfully validated property names for {}", path); |
| } else { |
| logger.debug("Successfully validated type for {}", path); |
| } |
| } |
| |
| protected static void checkTypeCompatibility(String path, JsonNode oldNode, JsonNode newNode) { |
| if (!oldNode.has("type") && oldNode.has("anyOf")) { |
| if (!oldNode.equals(newNode)) { |
| err("AnyOf type mismatch for" + path); |
| } else { |
| return; |
| } |
| } |
| |
| String oldType = oldNode.get("type").asText(); |
| |
| if (!oldType.equals(newNode.get("type").asText())) { |
| err("Type mismatch for " + path); |
| } |
| |
| verifyOtherPropsMatch(path, oldNode, newNode); |
| |
| if (oldType.equals("string")) { |
| checkStringTypeCompatibility(path, oldNode, newNode); |
| } |
| |
| if (oldType.equals("object") && oldNode.has("additionalProperties")) { |
| checkTypeCompatibility( |
| path + ".additionalProperties", |
| oldNode.get("additionalProperties"), |
| newNode.get("additionalProperties")); |
| } |
| |
| if (oldType.equals("array")) { |
| checkObjectCompatibility(path + ".items", oldNode.get("items"), newNode.get("items")); |
| } |
| } |
| |
| protected static void verifyOtherPropsMatch(String path, JsonNode oldNode, JsonNode newNode) { |
| var oldCopy = (ObjectNode) oldNode.deepCopy(); |
| var newCopy = (ObjectNode) newNode.deepCopy(); |
| List.of("items", "additionalProperties", "properties", "enum", "type") |
| .forEach( |
| k -> { |
| oldCopy.remove(k); |
| newCopy.remove(k); |
| }); |
| if (!oldCopy.equals(newCopy)) { |
| err("Other property mismatch for " + path); |
| } |
| } |
| |
| protected static void checkStringTypeCompatibility( |
| String path, JsonNode oldNode, JsonNode newNode) { |
| if (!oldNode.has("enum") && newNode.has("enum")) { |
| err("Cannot turn string into enum for " + path); |
| } |
| |
| if (oldNode.has("enum")) { |
| if (!newNode.has("enum")) { |
| return; |
| } |
| |
| List<String> newElements = new ArrayList<>(); |
| newNode.get("enum").elements().forEachRemaining(jn -> newElements.add(jn.asText())); |
| |
| oldNode.get("enum") |
| .elements() |
| .forEachRemaining( |
| jn -> { |
| if (!newElements.contains(jn.asText())) { |
| err( |
| "Enum value " |
| + jn.asText() |
| + " has been removed for " |
| + path); |
| } |
| }); |
| } |
| } |
| |
| private static void err(String err) { |
| throw new CompatibilityError(err); |
| } |
| |
| /** Compatibility Error class. */ |
| public static class CompatibilityError extends RuntimeException { |
| public CompatibilityError(String msg) { |
| super(msg); |
| } |
| } |
| } |