All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.strimzi.crdgenerator.CrdGenerator Maven / Gradle / Ivy

The newest version!
/*
 * Copyright Strimzi authors.
 * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html).
 */
package io.strimzi.crdgenerator;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import io.fabric8.kubernetes.api.model.Quantity;
import io.fabric8.kubernetes.client.CustomResource;
import io.strimzi.api.annotations.ApiVersion;
import io.strimzi.api.annotations.KubeVersion;
import io.strimzi.api.annotations.VersionRange;
import io.strimzi.crdgenerator.annotations.Crd;
import io.strimzi.crdgenerator.annotations.Description;
import io.strimzi.crdgenerator.annotations.Example;
import io.strimzi.crdgenerator.annotations.Maximum;
import io.strimzi.crdgenerator.annotations.Minimum;
import io.strimzi.crdgenerator.annotations.MinimumItems;
import io.strimzi.crdgenerator.annotations.OneOf;
import io.strimzi.crdgenerator.annotations.Pattern;
import io.strimzi.crdgenerator.annotations.Type;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.lang.annotation.Annotation;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.function.Function;
import java.util.stream.Collectors;

import static io.strimzi.api.annotations.ApiVersion.V1;
import static io.strimzi.crdgenerator.Property.hasAnyGetterAndAnySetter;
import static io.strimzi.crdgenerator.Property.properties;
import static io.strimzi.crdgenerator.Property.sortedProperties;
import static io.strimzi.crdgenerator.Property.subtypes;
import static java.lang.Integer.parseInt;
import static java.lang.reflect.Modifier.isAbstract;
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
import static java.util.Collections.emptyMap;

/**
 * 

Generates a Kubernetes {@code CustomResourceDefinition} YAML file * from an annotated Java model (POJOs). * The tool supports Jackson annotations and a few custom annotations in order * to generate a high-quality schema that's compatible with * K8S CRD validation schema support, which is more limited that the full OpenAPI schema * supported in the rest of K8S.

* *

The tool works by recursing through class properties (in the JavaBeans sense) * and the types of those properties, guided by the annotations.

* *

Annotations

*
*
@{@link Crd}
*
Annotates the top level class which represents an instance of the custom resource. * This provides certain information used for the {@code CustomResourceDefinition} * such as API group, version, singular and plural names etc.
* *
@{@link Description}
*
A description on a class or method: This gets added to the {@code description} * of {@code property}s within the corresponding Schema Object. * *
@{@link Example}
*
An example of usage. This gets added to the {@code example} * of {@code property}s within the corresponding Schema Object. * *
@{@link Pattern}
*
A pattern (regular expression) for checking the syntax of string-typed properties. * This gets added to the {@code pattern} * of {@code property}s within the corresponding Schema Object. * *
@{@link Minimum}
*
A inclusive minimum for checking the bounds of integer-typed properties. * This gets added to the {@code minimum} * of {@code property}s within the corresponding Schema Object.
* *
@{@link Maximum}
*
A inclusive maximum for checking the bounds of integer-typed properties. * This gets added to the {@code maximum} * of {@code property}s within the corresponding Schema Object.
* *
{@code @Deprecated}
*
When present on a JavaBean property this marks the property as being deprecated within * the corresponding Schema Object. * When present on a Class this marks properties of that type as * being deprecated within the corresponding Schema Object
* *
{@code @JsonProperty.value}
*
Overrides the default name (which is the JavaBean property name) * with the given {@code value} in the {@code properties} * of the corresponding Schema Object.
* *
{@code @JsonProperty.required} and {@code @JsonTypeInfo.property}
*
Marks a getter method as being required, adding it to the * {@code required} of the corresponding Schema Object.
* *
{@code @JsonIgnore}
*
Marks a property as ignored, omitting it from the {@code properties} * of the corresponding Schema Object.
* *
{@code @JsonSubTypes}
*
See following "Polymorphism" section.
* *
{@code @JsonPropertyOrder}
*
The declared order is reflected in ordering of the {@code properties} * in the corresponding Schema Object.
* *
* *

Polymorphism

*

Although true OpenAPI Schema Objects have some support for polymorphism via * {@code discriminator} and {@code oneOf}, CRD validation schemas don't support * {@code discriminator} or references which means CRD validation * schemas don't support proper polymorphism.

* *

This tool provides some "fake" support for polymorphism by understanding * {@code @JsonTypeInfo.use = JsonTypeInfo.Id.NAME}, * {@code @JsonTypeInfo.property} (which is Jackson's equivalent of a discriminator) * and {@code @JsonSubTypes} (which enumerates on the supertype the allowed subtypes * and their logical names).

* *

The tool will "fake" {@code oneOf} by constructing the union of the properties * of all the subtypes. This means that different subtypes cannot have (JavaBean) * properties of the same name but different types. * It also means that you have to include in the property {@code @Description} * which values of the {@code discriminator} (i.e. which subtype) the property * applies to.

* * @see OpenAPI Specification Schema Object doc * @see Additional restriction for CRDs */ @SuppressWarnings("ClassFanOutComplexity") class CrdGenerator { public static final YAMLMapper YAML_MAPPER = new YAMLMapper() .configure(YAMLGenerator.Feature.MINIMIZE_QUOTES, true) .configure(YAMLGenerator.Feature.SPLIT_LINES, false) .configure(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE, true) .configure(YAMLGenerator.Feature.WRITE_DOC_START_MARKER, false); public static final ObjectMapper JSON_MATTER = new ObjectMapper(); private final ApiVersion crdApiVersion; private final List generateVersions; private final ApiVersion storageVersion; private final VersionRange servedVersion; private final VersionRange describeVersions; // TODO CrdValidator // extraProperties // @Buildable public interface Reporter { void warn(String s); void err(String s); } public static class DefaultReporter implements Reporter { public void warn(String s) { System.err.println("CrdGenerator: warn: " + s); } public void err(String s) { System.err.println("CrdGenerator: error: " + s); } } Reporter reporter; public void warn(String s) { reporter.warn(s); } public static void argParseErr(String s) { System.err.println("CrdGenerator: error: " + s); } public void err(String s) { reporter.err(s); numErrors++; } public interface ConversionStrategy { } public static class NoneConversionStrategy implements ConversionStrategy { } public static class WebhookConversionStrategy implements ConversionStrategy { private final String url; private final String name; private final String namespace; private final String path; private final int port; private final String caBundle; public WebhookConversionStrategy(String url, String caBundle) { Objects.requireNonNull(url); Objects.requireNonNull(caBundle); this.url = url; this.name = null; this.namespace = null; this.path = null; this.port = -1; this.caBundle = caBundle; } public WebhookConversionStrategy(String name, String namespace, String path, int port, String caBundle) { Objects.requireNonNull(name); Objects.requireNonNull(namespace); Objects.requireNonNull(path); if (port <= 0) { throw new IllegalArgumentException(); } Objects.requireNonNull(caBundle); this.url = null; this.name = name; this.namespace = namespace; this.path = path; this.port = port; this.caBundle = caBundle; } public boolean isUrl() { return url != null; } } private final VersionRange targetKubeVersions; private final ObjectMapper mapper; private final JsonNodeFactory nf; private final Map labels; private final ConversionStrategy conversionStrategy; private int numErrors; public CrdGenerator(VersionRange targetKubeVersions, ApiVersion crdApiVersion) { this(targetKubeVersions, crdApiVersion, CrdGenerator.YAML_MAPPER, emptyMap(), new DefaultReporter(), emptyList(), null, null, new NoneConversionStrategy(), null); } /** * @param targetKubeVersions The targeted version(s) of Kubernetes. * @param crdApiVersion The version of the CRD API for which to generate the CRD. * @param mapper The object mapper. * @param labels The labels to add to the CRD. * @param reporter The error reporter. * @param apiVersions The API versions to generate (allows selecting a subset of those in the @Crd annotation). * @param storageVersion If not null, override the storageVersion to the given value. * @param servedVersions If not null, override the served versions according to the given range. * @param conversionStrategy The conversion strategy. * @param describeVersions The range of API versions for which descriptions should be added */ public CrdGenerator(VersionRange targetKubeVersions, ApiVersion crdApiVersion, ObjectMapper mapper, Map labels, Reporter reporter, List apiVersions, ApiVersion storageVersion, VersionRange servedVersions, ConversionStrategy conversionStrategy, VersionRange describeVersions) { this.reporter = reporter; if (targetKubeVersions.isEmpty() || targetKubeVersions.isAll()) { err("Target kubernetes version cannot be empty or all"); } this.targetKubeVersions = targetKubeVersions; this.crdApiVersion = crdApiVersion; this.mapper = mapper; this.nf = mapper.getNodeFactory(); this.labels = labels; this.generateVersions = apiVersions; this.describeVersions = describeVersions; this.storageVersion = storageVersion; this.servedVersion = servedVersions; this.conversionStrategy = conversionStrategy; } public int generate(Class crdClass, Writer out) throws IOException { ObjectNode node = nf.objectNode(); Crd crd = crdClass.getAnnotation(Crd.class); if (crd == null) { err(crdClass + " is not annotated with @Crd"); } else { node.put("apiVersion", "apiextensions.k8s.io/" + crdApiVersion) .put("kind", "CustomResourceDefinition") .putObject("metadata") .put("name", crd.spec().names().plural() + "." + crd.spec().group()); if (!labels.isEmpty()) { ((ObjectNode) node.get("metadata")) .putObject("labels") .setAll(labels.entrySet().stream() .collect(Collectors., String, JsonNode, LinkedHashMap>toMap( Map.Entry::getKey, e -> new TextNode( e.getValue() .replace("%group%", crd.spec().group()) .replace("%plural%", crd.spec().names().plural()) .replace("%singular%", crd.spec().names().singular())), (x, y) -> x, LinkedHashMap::new))); } node.set("spec", buildSpec(crdApiVersion, crd.spec(), crdClass)); } mapper.writeValue(out, node); return numErrors; } @SuppressWarnings("NPathComplexity") private ObjectNode buildSpec(ApiVersion crdApiVersion, Crd.Spec crd, Class crdClass) { ObjectNode result = nf.objectNode(); result.put("group", crd.group()); ArrayNode versions = nf.arrayNode(); Map subresources = buildSubresources(crd); Map schemas = buildSchemas(crd, crdClass); Map printerColumns = buildPrinterColumns(crd); result.set("names", buildNames(crd.names())); result.put("scope", crd.scope()); if (conversionStrategy instanceof WebhookConversionStrategy) { // "Webhook": must be None if spec.preserveUnknownFields is true result.put("preserveUnknownFields", false); } result.set("conversion", buildConversion(crdApiVersion)); for (Crd.Spec.Version version : crd.versions()) { ApiVersion crApiVersion = ApiVersion.parse(version.name()); if (!shouldIncludeVersion(crApiVersion)) { continue; } ObjectNode versionNode = versions.addObject(); versionNode.put("name", crApiVersion.toString()); versionNode.put("served", servedVersion != null ? servedVersion.contains(crApiVersion) : version.served()); versionNode.put("storage", storageVersion != null ? crApiVersion.equals(storageVersion) : version.storage()); // Subresources ObjectNode subresourcesForVersion = subresources.get(crApiVersion); if (!subresourcesForVersion.isEmpty()) { versionNode.set("subresources", subresourcesForVersion); } // Printer columns ArrayNode cols = printerColumns.get(crApiVersion); if (!cols.isEmpty()) { versionNode.set("additionalPrinterColumns", cols); } versionNode.set("schema", schemas.get(crApiVersion)); } result.set("versions", versions); if (crdApiVersion.compareTo(V1) < 0 && targetKubeVersions.intersects(KubeVersion.parseRange("1.11-1.15"))) { result.put("version", Arrays.stream(crd.versions()) .map(v -> ApiVersion.parse(v.name())) .filter(this::shouldIncludeVersion) .findFirst() .map(ApiVersion::toString) .orElseThrow()); } return result; } private ObjectNode buildConversion(ApiVersion crdApiVersion) { ObjectNode conversion = nf.objectNode(); if (conversionStrategy instanceof NoneConversionStrategy) { conversion.put("strategy", "None"); } else if (conversionStrategy instanceof WebhookConversionStrategy) { conversion.put("strategy", "Webhook"); WebhookConversionStrategy webhookStrategy = (WebhookConversionStrategy) conversionStrategy; ObjectNode webhook = conversion.putObject("webhook"); webhook.putArray("conversionReviewVersions").add("v1").add("v1beta1"); ObjectNode webhookClientConfig = webhook.putObject("clientConfig"); webhookClientConfig.put("caBundle", webhookStrategy.caBundle); if (webhookStrategy.isUrl()) { webhookClientConfig.put("url", webhookStrategy.url); } else { webhookClientConfig.putObject("service") .put("name", webhookStrategy.name) .put("namespace", webhookStrategy.namespace) .put("path", webhookStrategy.path) .put("port", webhookStrategy.port); } } else { throw new IllegalStateException(); } return conversion; } private Map buildSchemas(Crd.Spec crd, Class crdClass) { return Arrays.stream(crd.versions()) .map(version -> ApiVersion.parse(version.name())) .filter(this::shouldIncludeVersion) .collect(Collectors.toMap(Function.identity(), version -> buildValidation(crdClass, version, shouldDescribeVersion(version)))); } private Map buildSubresources(Crd.Spec crd) { return Arrays.stream(crd.versions()) .map(version -> ApiVersion.parse(version.name())) .filter(this::shouldIncludeVersion) .collect(Collectors.toMap(Function.identity(), version -> buildSubresources(crd, version))); } private boolean shouldIncludeVersion(ApiVersion version) { return generateVersions == null || generateVersions.isEmpty() || generateVersions.contains(version); } private boolean shouldDescribeVersion(ApiVersion version) { return describeVersions == null || describeVersions.isEmpty() || describeVersions.isAll() || describeVersions.contains(version); } private Map buildPrinterColumns(Crd.Spec crd) { return Arrays.stream(crd.versions()) .map(version -> ApiVersion.parse(version.name())) .filter(this::shouldIncludeVersion) .collect(Collectors.toMap(Function.identity(), version -> buildAdditionalPrinterColumns(crd, version))); } private ObjectNode buildSubresources(Crd.Spec crd, ApiVersion crApiVersion) { ObjectNode subresources = nf.objectNode(); if (crd.subresources().status().length != 0) { ObjectNode status = buildStatus(crd, crApiVersion); if (status != null) { subresources.set("status", status); } ObjectNode scaleNode = buildScale(crd, crApiVersion); if (scaleNode != null) { subresources.set("scale", scaleNode); } } return subresources; } private ObjectNode buildStatus(Crd.Spec crd, ApiVersion crApiVersion) { ObjectNode status; long length = Arrays.stream(crd.subresources().status()) .filter(st -> ApiVersion.parseRange(st.apiVersion()).contains(crApiVersion)) .count(); if (length == 1) { status = nf.objectNode(); } else if (length > 1) { err("Each custom resource definition can have only one status sub-resource."); status = null; } else { status = null; } return status; } private ObjectNode buildScale(Crd.Spec crd, ApiVersion crApiVersion) { ObjectNode scaleNode; Crd.Spec.Subresources.Scale[] scales = crd.subresources().scale(); List filteredScales = Arrays.stream(scales) .filter(sc -> ApiVersion.parseRange(sc.apiVersion()).contains(crApiVersion)) .collect(Collectors.toList()); if (filteredScales.size() == 1) { scaleNode = nf.objectNode(); Crd.Spec.Subresources.Scale scale = filteredScales.get(0); scaleNode.put("specReplicasPath", scale.specReplicasPath()); scaleNode.put("statusReplicasPath", scale.statusReplicasPath()); if (!scale.labelSelectorPath().isEmpty()) { scaleNode.put("labelSelectorPath", scale.labelSelectorPath()); } } else if (filteredScales.size() > 1) { throw new RuntimeException("Each custom resource definition can have only one scale sub-resource."); } else { scaleNode = null; } return scaleNode; } private ArrayNode buildAdditionalPrinterColumns(Crd.Spec crd, ApiVersion crApiVersion) { ArrayNode cols = nf.arrayNode(); if (crd.additionalPrinterColumns().length != 0) { for (Crd.Spec.AdditionalPrinterColumn col : Arrays.stream(crd.additionalPrinterColumns()) .filter(col -> crApiVersion == null || ApiVersion.parseRange(col.apiVersion()).contains(crApiVersion)) .collect(Collectors.toList())) { ObjectNode colNode = cols.addObject(); colNode.put("name", col.name()); colNode.put("description", col.description()); colNode.put(crdApiVersion.compareTo(V1) >= 0 ? "jsonPath" : "JSONPath", col.jsonPath()); colNode.put("type", col.type()); if (col.priority() != 0) { colNode.put("priority", col.priority()); } if (!col.format().isEmpty()) { colNode.put("format", col.format()); } } } return cols; } private JsonNode buildNames(Crd.Spec.Names names) { ObjectNode result = nf.objectNode(); String kind = names.kind(); result.put("kind", kind); String listKind = names.listKind(); if (listKind.isEmpty()) { listKind = kind + "List"; } result.put("listKind", listKind); String singular = names.singular(); if (singular.isEmpty()) { singular = kind.toLowerCase(Locale.US); } result.put("singular", singular); result.put("plural", names.plural()); if (names.shortNames().length > 0) { result.set("shortNames", stringArray(asList(names.shortNames()))); } if (names.categories().length > 0) { result.set("categories", stringArray(asList(names.categories()))); } return result; } private ObjectNode buildValidation(Class crdClass, ApiVersion crApiVersion, boolean description) { ObjectNode result = nf.objectNode(); // OpenShift Origin 3.10-rc0 doesn't like the `type: object` in schema root boolean noTopLevelTypeProperty = targetKubeVersions.intersects(KubeVersion.parseRange("1.11-1.15")); result.set("openAPIV3Schema", buildObjectSchema(crApiVersion, crdClass, crdApiVersion.compareTo(V1) >= 0 || !noTopLevelTypeProperty, description)); return result; } private ObjectNode buildObjectSchema(ApiVersion crApiVersion, Class crdClass, boolean description) { return buildObjectSchema(crApiVersion, crdClass, true, description); } private ObjectNode buildObjectSchema(ApiVersion crApiVersion, Class crdClass, boolean printType, boolean description) { ObjectNode result = nf.objectNode(); buildObjectSchema(crApiVersion, result, crdClass, printType, description); return result; } private void buildObjectSchema(ApiVersion crApiVersion, ObjectNode result, Class crdClass, boolean printType, boolean description) { if (!crdClass.getName().startsWith("java.lang.")) { // java.lang.* class does not require class validation as i.e. JsonIgnore and Builder does not apply checkClass(crdClass); } if (printType) { result.put("type", "object"); } result.set("properties", buildSchemaProperties(crApiVersion, crdClass, description)); ArrayNode oneOf = buildSchemaOneOf(crdClass); if (oneOf != null) { result.set("oneOf", oneOf); } ArrayNode required = buildSchemaRequired(crApiVersion, crdClass); if (!required.isEmpty()) { result.set("required", required); } } private ArrayNode buildSchemaOneOf(Class crdClass) { ArrayNode alternatives; OneOf oneOf = crdClass.getAnnotation(OneOf.class); if (oneOf != null && oneOf.value().length > 0) { alternatives = nf.arrayNode(); for (OneOf.Alternative alt : oneOf.value()) { ObjectNode alternative = alternatives.addObject(); ObjectNode properties = alternative.putObject("properties"); for (OneOf.Alternative.Property prop: alt.value()) { properties.putObject(prop.value()); } ArrayNode required = nf.arrayNode(); for (OneOf.Alternative.Property prop: alt.value()) { if (prop.required()) { required.add(prop.value()); } } // We attach only non-empty array. Empty arrays would be removed by Kubernetes and might confuse various // tools when diffing the resources (such as ArgoCD) if (!required.isEmpty()) { alternative.set("required", required); } } } else { alternatives = null; } return alternatives; } private void checkClass(Class crdClass) { if (!isAbstract(crdClass.getModifiers())) { checkForBuilderClass(crdClass, crdClass.getName() + "Builder"); checkForBuilderClass(crdClass, crdClass.getName() + "Fluent"); checkClassOverrides(crdClass, "hashCode"); hasAnyGetterAndAnySetter(crdClass); } else { for (Class c : subtypes(crdClass)) { hasAnyGetterAndAnySetter(c); checkDiscriminatorIsIncluded(crdClass, c); checkJsonPropertyOrder(c); } } if (crdClass.getName().startsWith("io.strimzi.")) { checkInherits(crdClass, "io.strimzi.api.kafka.model.common.UnknownPropertyPreserving"); checkJsonInclude(crdClass); checkJsonPropertyOrder(crdClass); } checkClassOverrides(crdClass, "equals", Object.class); } private void checkJsonInclude(Class crdClass) { if (!crdClass.isAnnotationPresent(JsonInclude.class)) { err(crdClass + " is missing @JsonInclude"); } else if (!crdClass.getAnnotation(JsonInclude.class).value().equals(JsonInclude.Include.NON_NULL) && !crdClass.getAnnotation(JsonInclude.class).value().equals(JsonInclude.Include.NON_DEFAULT)) { err(crdClass + " has a @JsonInclude value other than Include.NON_NULL"); } } private void checkJsonPropertyOrder(Class crdClass) { if (!isAbstract(crdClass.getModifiers()) && !crdClass.isAnnotationPresent(JsonPropertyOrder.class)) { err(crdClass + " is missing @JsonPropertyOrder"); } } private void checkDiscriminatorIsIncluded(Class crdClass, Class c) { try { String typePropertyName = crdClass.getAnnotation(JsonTypeInfo.class).property(); String methodName = "get" + typePropertyName.substring(0, 1).toUpperCase(Locale.ENGLISH) + typePropertyName.substring(1).toLowerCase(Locale.ENGLISH); @SuppressWarnings("unchecked") Method method = c.getMethod(methodName); if (!isAnnotatedWithIncludeNonNull(method)) { err(c.getCanonicalName() + "#" + methodName + " is not annotated with @JsonInclude(JsonInclude.Include.NON_NULL)"); } } catch (NoSuchMethodException e) { err(e.getMessage()); } } private boolean isAnnotatedWithIncludeNonNull(Method method) { JsonInclude ann = method.getAnnotation(JsonInclude.class); return ann != null && ann.value().equals(JsonInclude.Include.NON_NULL); } private void checkInherits(Class crdClass, String className) { if (!inherits(crdClass, className)) { err(crdClass + " does not inherit " + className); } } private boolean inherits(Class crdClass, String className) { Class c = crdClass; boolean found = false; outer: do { if (className.equals(c.getName())) { found = true; break outer; } for (Class i : c.getInterfaces()) { if (inherits(i, className)) { found = true; break outer; } } c = c.getSuperclass(); } while (c != null); return found; } private void checkForBuilderClass(Class crdClass, String builderClass) { try { Class.forName(builderClass, false, crdClass.getClassLoader()); } catch (ClassNotFoundException e) { err(crdClass + " is not annotated with @Buildable (" + builderClass + " does not exist)"); } } private void checkClassOverrides(Class crdClass, String methodName, Class... parameterTypes) { try { crdClass.getDeclaredMethod(methodName, parameterTypes); } catch (NoSuchMethodException e) { err(crdClass + " does not override " + methodName); } } private Collection unionOfSubclassProperties(ApiVersion crApiVersion, Class crdClass) { JsonPropertyOrder order = crdClass.getAnnotation(JsonPropertyOrder.class); TreeMap result = new TreeMap<>(); for (Class subtype : Property.subtypes(crdClass)) { Map properties = properties(crApiVersion, subtype); checkPropertiesInJsonPropertyOrder(subtype, properties.keySet()); result.putAll(properties); } Map properties = properties(crApiVersion, crdClass); checkPropertiesInJsonPropertyOrder(crdClass, properties.keySet()); result.putAll(properties); return sortedProperties(order != null ? order.value() : null, result).values(); } private void checkPropertiesInJsonPropertyOrder(Class crdClass, Set properties) { if (!isAbstract(crdClass.getModifiers())) { JsonPropertyOrder order = crdClass.getAnnotation(JsonPropertyOrder.class); if (order == null) { // Skip as the error is already tracked in checkClass return; } List expectedOrder = asList(order.value()); for (String property : properties) { if (!expectedOrder.contains(property)) { err(crdClass + " has a property " + property + " which is not in the @JsonPropertyOrder"); } } } } private ArrayNode buildSchemaRequired(ApiVersion crApiVersion, Class crdClass) { ArrayNode result = nf.arrayNode(); for (Property property : unionOfSubclassProperties(crApiVersion, crdClass)) { if (property.isAnnotationPresent(JsonProperty.class) && property.getAnnotation(JsonProperty.class).required() || property.isDiscriminator()) { result.add(property.getName()); } } return result; } private ObjectNode buildSchemaProperties(ApiVersion crApiVersion, Class crdClass, boolean description) { ObjectNode properties = nf.objectNode(); buildKindApiVersionAndMetadata(properties, crdClass); for (Property property : unionOfSubclassProperties(crApiVersion, crdClass)) { buildProperty(crApiVersion, properties, property, description); } return properties; } private void buildKindApiVersionAndMetadata(ObjectNode properties, Class crdClass) { if (crdClass.isAnnotationPresent(Crd.class)) { // Add metadata to the CRD class root ObjectNode apiVersion = properties.putObject("apiVersion"); apiVersion.put("type", "string"); apiVersion.put("description", "APIVersion defines the versioned schema of this representation of an object. " + "Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. " + "More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources"); ObjectNode kind = properties.putObject("kind"); kind.put("type", "string"); kind.put("description", "Kind is a string value representing the REST resource this object " + "represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. " + "In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds"); ObjectNode metadata = properties.putObject("metadata"); metadata.put("type", "object"); } } private void buildProperty(ApiVersion crdApiVersion, ObjectNode properties, Property property, boolean description) { properties.set(property.getName(), buildSchema(crdApiVersion, property, description)); } private ObjectNode buildSchema(ApiVersion crApiVersion, Property property, boolean description) { PropertyType propertyType = property.getType(); Class returnType = propertyType.getType(); final ObjectNode schema; if (propertyType.getGenericType() instanceof ParameterizedType && ((ParameterizedType) propertyType.getGenericType()).getRawType().equals(Map.class) && ((ParameterizedType) propertyType.getGenericType()).getActualTypeArguments()[0].equals(Integer.class)) { System.err.println("It's OK"); schema = nf.objectNode(); schema.put("type", "object"); schema.putObject("patternProperties").set("-?[0-9]+", buildArraySchema(crApiVersion, property, new PropertyType(null, ((ParameterizedType) propertyType.getGenericType()).getActualTypeArguments()[1]), description)); } else if (propertyType.getGenericType() instanceof ParameterizedType && ((ParameterizedType) propertyType.getGenericType()).getRawType().equals(Map.class) && isMapOfTypes(propertyType, String.class, Quantity.class)) { schema = buildQuantityTypeSchema(); } else if (Schema.isJsonScalarType(returnType) || Map.class.equals(returnType)) { schema = addSimpleTypeConstraints(crApiVersion, buildBasicTypeSchema(property, returnType), property); } else if (returnType.isArray() || List.class.equals(returnType)) { schema = buildArraySchema(crApiVersion, property, property.getType(), description); } else { schema = buildObjectSchema(crApiVersion, returnType, description); } if (description) { addDescription(crApiVersion, schema, property); } return schema; } @SuppressWarnings("unchecked") private ObjectNode buildArraySchema(ApiVersion crApiVersion, Property property, PropertyType propertyType, boolean description) { int arrayDimension = propertyType.arrayDimension(); ObjectNode result = nf.objectNode(); ObjectNode itemResult = result; for (int i = 0; i < arrayDimension; i++) { itemResult.put("type", "array"); MinimumItems minimumItems = selectVersion(crApiVersion, property, MinimumItems.class); if (minimumItems != null) { result.put("minItems", minimumItems.value()); } itemResult = itemResult.putObject("items"); } Class elementType = propertyType.arrayBase(); if (String.class.equals(elementType)) { itemResult.put("type", "string"); } else if (Integer.class.equals(elementType) || int.class.equals(elementType) || Long.class.equals(elementType) || long.class.equals(elementType)) { itemResult.put("type", "integer"); } else if (Map.class.equals(elementType)) { if (isMapOfTypes(propertyType, String.class, String.class)) { preserveUnknownStringFields(itemResult); } else { preserveUnknownFields(itemResult); } itemResult.put("type", "object"); } else if (elementType.isEnum()) { itemResult.put("type", "string"); try { Method valuesMethod = elementType.getMethod("values"); itemResult.set("enum", enumCaseArray((Enum[]) valuesMethod.invoke(null))); } catch (ReflectiveOperationException e) { throw new RuntimeException(e); } } else { buildObjectSchema(crApiVersion, itemResult, elementType, true, description); } return result; } /** * Utility method to check if Map key-value pair match specific types. * @param propertyType property to check * @param keyType key Class * @param valueType value Class * @return true if key-value types are equal to specified types, false otherwise. */ private boolean isMapOfTypes(PropertyType propertyType, Class keyType, Class valueType) { java.lang.reflect.Type[] types = ((ParameterizedType) propertyType.getGenericType()).getActualTypeArguments(); return keyType.equals(types[0]) && valueType.equals(types[1]); } private ObjectNode buildBasicTypeSchema(Property element, Class type) { ObjectNode result = nf.objectNode(); String typeName; Type typeAnno = element.getAnnotation(Type.class); if (typeAnno == null) { typeName = typeName(type); if (Map.class.equals(type)) { if (isMapOfTypes(element.getType(), String.class, String.class)) { preserveUnknownStringFields(result); } else { preserveUnknownFields(result); } } } else { typeName = typeAnno.value(); } result.put("type", typeName); return result; } private ObjectNode buildQuantityTypeSchema() { ObjectNode result = nf.objectNode(); if (crdApiVersion.compareTo(V1) < 0) return result; ObjectNode additionalProperties = result.putObject("additionalProperties"); ArrayNode anyOf = additionalProperties.putArray("anyOf"); anyOf.addObject().put("type", "integer"); anyOf.addObject().put("type", "string"); additionalProperties.put("pattern", "^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$"); additionalProperties.put("x-kubernetes-int-or-string", true); result.put("type", "object"); return result; } private void preserveUnknownFields(ObjectNode result) { if (crdApiVersion.compareTo(V1) >= 0) { result.put("x-kubernetes-preserve-unknown-fields", true); } } private void preserveUnknownStringFields(ObjectNode result) { if (crdApiVersion.compareTo(V1) >= 0) { ObjectNode additionalProperties = result.putObject("additionalProperties"); additionalProperties.put("type", "string"); } } private void addDescription(ApiVersion crApiVersion, ObjectNode result, AnnotatedElement element) { Description description = selectVersion(crApiVersion, element, Description.class); if (description != null) { result.put("description", DocGenerator.getDescription(description)); } } @SuppressWarnings("unchecked") private ObjectNode addSimpleTypeConstraints(ApiVersion crApiVersion, ObjectNode result, Property property) { Example example = property.getAnnotation(Example.class); // TODO make support versions if (example != null) { result.put("example", example.value()); } Minimum minimum = selectVersion(crApiVersion, property, Minimum.class); if (minimum != null) { result.put("minimum", minimum.value()); } Maximum maximum = selectVersion(crApiVersion, property, Maximum.class); if (maximum != null) { result.put("maximum", maximum.value()); } Pattern first = selectVersion(crApiVersion, property, Pattern.class); if (first != null) { result.put("pattern", first.value()); } if (property.getType().isEnum()) { result.set("enum", enumCaseArray(property.getType().getEnumElements())); } if (property.getDeclaringClass().isAnnotationPresent(JsonTypeInfo.class) && property.getName().equals(property.getDeclaringClass().getAnnotation(JsonTypeInfo.class).property())) { result.set("enum", stringArray(Property.subtypeNames(property.getDeclaringClass()))); } return result; } private T selectVersion(ApiVersion crApiVersion, AnnotatedElement element, Class cls) { T[] wrapperAnnotation = element.getAnnotationsByType(cls); if (wrapperAnnotation == null) { return null; } checkDisjointVersions(element, wrapperAnnotation, cls); return Arrays.stream(wrapperAnnotation) // TODO crApiVersion == null does not really imply we should return the first description. .filter(element1 -> crApiVersion == null || apiVersion(element1, cls).contains(crApiVersion)) .findFirst().orElse(null); } @SuppressWarnings("unchecked") private void checkDisjointVersions(AnnotatedElement annotated, T[] wrapperAnnotation, Class annotationClass) { long count = Arrays.stream(wrapperAnnotation) .map(element -> apiVersion(element, annotationClass)).count(); long distinctCount = Arrays.stream(wrapperAnnotation) .map(element -> apiVersion(element, annotationClass)).distinct().count(); if (count != distinctCount) { err("Duplicate version ranges on " + annotated); } Arrays.stream(wrapperAnnotation) .map(element -> apiVersion(element, annotationClass)) .flatMap(x -> Arrays.stream(wrapperAnnotation) .map(y -> apiVersion(y, annotationClass)) .filter(y -> !y.equals(x)) .map(y -> new VersionRange[]{x, y})) .forEach(pair -> { if (pair[0].intersects(pair[1])) { err(pair[0] + " and " + pair[1] + " are not disjoint on " + annotated); } }); } private static VersionRange apiVersion(T element, Class annotationClass) { try { Method apiVersionsMethod = annotationClass.getDeclaredMethod("apiVersions"); String apiVersions = (String) apiVersionsMethod.invoke(element); return ApiVersion.parseRange(apiVersions); } catch (ReflectiveOperationException | ClassCastException e) { throw new RuntimeException(e); } } private > ArrayNode enumCaseArray(E[] values) { ArrayNode arrayNode = nf.arrayNode(); arrayNode.addAll(Schema.enumCases(values)); return arrayNode; } private String typeName(Class type) { if (String.class.equals(type)) { return "string"; } else if (int.class.equals(type) || Integer.class.equals(type) || long.class.equals(type) || Long.class.equals(type) || short.class.equals(type) || Short.class.equals(type)) { return "integer"; } else if (boolean.class.equals(type) || Boolean.class.equals(type)) { return "boolean"; } else if (Map.class.equals(type)) { return "object"; } else if (List.class.equals(type) || type.isArray()) { return "array"; } else if (type.isEnum()) { return "string"; } else if (Double.class.equals(type) || double.class.equals(type) || float.class.equals(type) || Float.class.equals(type)) { return "number"; } else { throw new RuntimeException(type.getName()); } } ArrayNode stringArray(Iterable list) { ArrayNode arrayNode = nf.arrayNode(); for (String sn : list) { arrayNode.add(sn); } return arrayNode; } static class CommandOptions { private boolean yaml = false; private final LinkedHashMap labels = new LinkedHashMap<>(); VersionRange targetKubeVersions = null; ApiVersion crdApiVersion = null; List apiVersions = null; VersionRange describeVersions = null; ApiVersion storageVersion = null; Map> classes = new HashMap<>(); private final ConversionStrategy conversionStrategy; @SuppressWarnings({"unchecked", "CyclomaticComplexity", "JavaNCSS", "MethodLength"}) public CommandOptions(String[] args) throws ClassNotFoundException, IOException { String conversionServiceUrl = null; String conversionServiceName = null; String conversionServiceNamespace = null; String conversionServicePath = null; int conversionServicePort = -1; String conversionServiceCaBundle = null; for (int i = 0; i < args.length; i++) { String arg = args[i]; if (arg.startsWith("--")) { switch (arg) { case "--yaml": yaml = true; break; case "--label": i++; int index = args[i].indexOf(":"); if (index == -1) { argParseErr("Invalid --label " + args[i]); } labels.put(args[i].substring(0, index), args[i].substring(index + 1)); break; case "--target-kube": if (targetKubeVersions != null) { argParseErr("--target-kube can only be specified once"); } else if (i >= arg.length() - 1) { argParseErr("--target-kube needs an argument"); } else { targetKubeVersions = KubeVersion.parseRange(args[++i]); } break; case "--crd-api-version": if (crdApiVersion != null) { argParseErr("--crd-api-version can only be specified once"); } else if (i >= arg.length() - 1) { argParseErr("--crd-api-version needs an argument"); } else { crdApiVersion = ApiVersion.parse(args[++i]); } break; case "--api-versions": if (apiVersions != null) { argParseErr("--api-versions can only be specified once"); } else if (i >= arg.length() - 1) { argParseErr("--api-versions needs an argument"); } else { apiVersions = Arrays.stream(args[++i].split(",")).map(v -> ApiVersion.parse(v)).collect(Collectors.toList()); } break; case "--describe-api-versions": if (describeVersions != null) { argParseErr("--describe-api-versions can only be specified once"); } else if (i >= arg.length() - 1) { argParseErr("--describe-api-versions needs an argument"); } else { describeVersions = ApiVersion.parseRange(args[++i]); } break; case "--storage-version": if (storageVersion != null) { argParseErr("--storage-version can only be specified once"); } else if (i >= arg.length() - 1) { argParseErr("--storage-version needs an argument"); } else { storageVersion = ApiVersion.parse(args[++i]); } break; case "--conversion-service-url": if (conversionServiceUrl != null) { argParseErr("--conversion-service-url can only be specified once"); } else if (i >= arg.length() - 1) { argParseErr("--conversion-service-url needs an argument"); } else { conversionServiceUrl = args[++i]; } break; case "--conversion-service-name": if (conversionServiceName != null) { argParseErr("--conversion-service-name can only be specified once"); } else if (i >= arg.length() - 1) { argParseErr("--conversion-service-name needs an argument"); } else { conversionServiceName = args[++i]; } break; case "--conversion-service-namespace": if (conversionServiceNamespace != null) { argParseErr("--conversion-service-namespace can only be specified once"); } else if (i >= arg.length() - 1) { argParseErr("--conversion-service-namespace needs an argument"); } else { conversionServiceNamespace = args[++i]; } break; case "--conversion-service-path": if (conversionServicePath != null) { argParseErr("--conversion-service-path can only be specified once"); } else if (i >= arg.length() - 1) { argParseErr("--conversion-service-path needs an argument"); } else { conversionServicePath = args[++i]; } break; case "--conversion-service-port": if (conversionServicePort > 0) { argParseErr("--conversion-service-port can only be specified once"); } else if (i >= arg.length() - 1) { argParseErr("--conversion-service-port needs an argument"); } else { conversionServicePort = parseInt(args[++i]); } break; case "--conversion-service-ca-bundle": if (conversionServiceCaBundle != null) { argParseErr("--conversion-service-ca-bundle can only be specified once"); } else if (i >= arg.length() - 1) { argParseErr("--conversion-service-ca-bundle needs an argument"); } else { // TODO read file and base64 File file = new File(args[++i]); byte[] bundleBytes = Files.readAllBytes(file.toPath()); conversionServiceCaBundle = new String(bundleBytes, StandardCharsets.UTF_8); if (!conversionServiceCaBundle.contains("-----BEGIN CERTIFICATE-----")) { throw new IllegalStateException("File " + file + " given by --conversion-service-ca-bundle should be PEM encoded"); } conversionServiceCaBundle = Base64.getEncoder().encodeToString(bundleBytes); } break; default: throw new RuntimeException("Unsupported command line option " + arg); } } else { String className = arg.substring(0, arg.indexOf('=')); String fileName = arg.substring(arg.indexOf('=') + 1).replace("/", File.separator); Class cls = Class.forName(className); if (!CustomResource.class.equals(cls) && CustomResource.class.isAssignableFrom(cls)) { classes.put(fileName, (Class) cls); } else { argParseErr(cls + " is not a subclass of " + CustomResource.class.getName()); } } } if (targetKubeVersions == null) { targetKubeVersions = KubeVersion.V1_16_PLUS; } if (crdApiVersion == null) { crdApiVersion = ApiVersion.V1; } if (conversionServiceName != null) { conversionStrategy = new WebhookConversionStrategy(conversionServiceName, conversionServiceNamespace, conversionServicePath, conversionServicePort, conversionServiceCaBundle); } else if (conversionServiceUrl != null) { conversionStrategy = new WebhookConversionStrategy(conversionServiceUrl, conversionServiceCaBundle); } else { conversionStrategy = new NoneConversionStrategy(); } } } public static void main(String[] args) throws IOException, ClassNotFoundException { CommandOptions opts = new CommandOptions(args); CrdGenerator generator = new CrdGenerator(opts.targetKubeVersions, opts.crdApiVersion, opts.yaml ? YAML_MAPPER.configure(YAMLGenerator.Feature.MINIMIZE_QUOTES, true) : JSON_MATTER, opts.labels, new DefaultReporter(), opts.apiVersions, opts.storageVersion, null, opts.conversionStrategy, opts.describeVersions); for (Map.Entry> entry : opts.classes.entrySet()) { File file = new File(entry.getKey()); if (file.getParentFile().exists()) { if (!file.getParentFile().isDirectory()) { generator.err(file.getParentFile() + " is not a directory"); } } else if (!file.getParentFile().mkdirs()) { generator.err(file.getParentFile() + " does not exist and could not be created"); } try (Writer w = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) { generator.generate(entry.getValue(), w); } } if (generator.numErrors > 0) { System.err.println("There were " + generator.numErrors + " errors"); System.exit(1); } else { System.exit(0); } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy