configuration-as-code-plugin/plugin/src/main/java/io/jenkins/plugins/casc/Attribute.java

549 lines
19 KiB
Java

package io.jenkins.plugins.casc;
import static io.jenkins.plugins.casc.ConfigurationAsCode.printThrowable;
import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.util.Secret;
import io.jenkins.plugins.casc.model.CNode;
import io.jenkins.plugins.casc.model.Scalar;
import io.jenkins.plugins.casc.model.Sequence;
import io.jenkins.plugins.casc.util.ExtraFieldUtils;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.kohsuke.accmod.AccessRestriction;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.export.Exported;
/**
* One attribute of {@link Configurator}.
*
* @author <a href="mailto:nicolas.deloof@gmail.com">Nicolas De Loof</a>
* @see Configurator#describe()
*/
public class Attribute<Owner, Type> {
private static final Logger LOGGER = Logger.getLogger(Attribute.class.getName());
private static final Class[] EMPTY = new Class[0];
/** For pseudo-attributes which are actually managed directly as singletons, not set on some owner component */
private static final Setter NOOP = (target, value) -> {
// Nop
};
// TODO: Concurrent cache?
// private static final HashMap<Class, Boolean> SECRET_ATTRIBUTE_CACHE =
// new HashMap<>();
protected final String name;
protected final Class type;
protected boolean multiple;
protected String preferredName;
private Setter<Owner, Type> setter;
private Getter<Owner, Type> getter;
private boolean secret;
private boolean isJsonSchema;
private boolean deprecated;
private Class<? extends AccessRestriction>[] restrictions;
protected List<String> aliases;
public Attribute(String name, Class type) {
this.name = name;
this.type = type;
this.getter = this::_getValue;
this.setter = this::_setValue;
this.aliases = new ArrayList<>();
this.aliases.add(name);
this.secret = type == Secret.class || calculateIfSecret(null, this.name);
}
public Attribute(List<String> name, Class type) {
this.name = name.get(0);
this.type = type;
this.getter = this::_getValue;
this.setter = this::_setValue;
this.aliases = name;
this.secret = type == Secret.class || calculateIfSecret(null, this.name);
}
@SuppressWarnings("unchecked")
public static <O, T> Optional<Attribute<O, T>> get(Set<Attribute<O, ?>> attributes, String name) {
return attributes.stream()
.filter(a -> a.name.equals(name))
.map(a -> (Attribute<O, T>) a)
.findFirst();
}
@Override
public String toString() {
return String.format("%s(class: %s, multiple: %s)", name, type, multiple);
}
public String getName() {
return preferredName != null ? preferredName : name;
}
public Class getType() {
return type;
}
public boolean isDeprecated() {
return deprecated;
}
boolean isIgnored() {
return isDeprecated() || isRestricted() || IgnoreList.isIgnored(this);
}
public Class<? extends AccessRestriction>[] getRestrictions() {
return restrictions != null ? restrictions : EMPTY;
}
/**
* Set jsonSchema is used to tell the describe function to call the describe structure
* so that it supports and returns a nested structure
*/
public void setJsonSchema(boolean jsonSchema) {
isJsonSchema = jsonSchema;
}
public boolean isRestricted() {
return restrictions != null && restrictions.length > 0;
}
/**
* Attribute is actually a Collection of documented type
* @return boolean indicating if this attribute is a list of multiple items of documented type
*/
public boolean isMultiple() {
return multiple;
}
public Attribute<Owner, Type> multiple(boolean multiple) {
this.multiple = multiple;
return this;
}
public Attribute<Owner, Type> preferredName(String preferredName) {
this.preferredName = preferredName;
return this;
}
public Attribute<Owner, Type> setter(Setter<Owner, Type> setter) {
this.setter = setter;
return this;
}
public Attribute<Owner, Type> alias(String alias) {
this.aliases.add(alias);
return this;
}
public Attribute<Owner, Type> getter(Getter<Owner, Type> getter) {
this.getter = getter;
return this;
}
/**
* Sets whether the attribute is secret.
* If so, various outputs will be suppressed (exports, logging).
* @param secret {@code true} to make an attribute secret
* @since 1.25
*/
public Attribute<Owner, Type> secret(boolean secret) {
this.secret = secret;
return this;
}
public Attribute deprecated(boolean deprecated) {
this.deprecated = deprecated;
return this;
}
public Attribute restrictions(Class<? extends AccessRestriction>[] restrictions) {
this.restrictions = restrictions.clone();
return this;
}
public Setter<Owner, Type> getSetter() {
return setter;
}
public Getter<Owner, Type> getGetter() {
return getter;
}
public List<String> getAliases() {
return aliases;
}
/**
* If this attribute is constrained to a limited set of value, here they are
*
* @return A list of possible types
*/
public List<String> possibleValues() {
if (type.isEnum()) {
Class<Enum> e = (Class<Enum>) type;
return Arrays.stream(e.getEnumConstants()).map(Enum::name).collect(Collectors.toList());
}
return Collections.EMPTY_LIST;
}
/**
* Checks whether an attribute is considered a secret one.
* @return {@code true} if the attribute is secret
* @param target Target object.
* If {@code null}, only the attribute metadata is checked
* @since 1.25
*/
public boolean isSecret(@CheckForNull Owner target) {
// This.secret should be always true for the first condition, but getType() is overridable
// Here we define an additional check just in case getType() is overridden in another implementation
return secret || calculateIfSecret(target != null ? target.getClass() : null, this.name);
}
public void setValue(Owner target, Type value) throws Exception {
LOGGER.log(
Level.FINE, "Setting {0}.{1} = {2}", new Object[] {target, name, (isSecret(target) ? "****" : value)});
setter.setValue(target, value);
}
public Type getValue(Owner target) throws Exception {
return getter.getValue(target);
}
public CNode describe(Owner instance, ConfigurationContext context) throws ConfiguratorException {
final Configurator c = context.lookup(type);
if (c == null) {
return new Scalar("FAILED TO EXPORT\n" + instance.getClass().getName() + "#" + name
+ ": No configurator found for type " + type);
}
try {
Object o = getValue(instance);
if (o == null) {
return null;
}
// In Export we sensitive only those values which do not get rendered as secrets
boolean shouldBeMasked = isSecret(instance);
if (multiple) {
Sequence seq = new Sequence();
if (o.getClass().isArray()) {
o = Arrays.asList((Object[]) o);
}
if (o instanceof Iterable) {
for (Object value : (Iterable) o) {
seq.add(_describe(c, context, value, shouldBeMasked));
}
} else {
LOGGER.log(Level.FINE, o.getClass() + " is not iterable");
}
return seq;
}
return _describe(c, context, o, shouldBeMasked);
} catch (Exception | /* Jenkins.getDescriptorOrDie */ AssertionError e) {
// Don't fail the whole export, prefer logging this error
LOGGER.log(Level.WARNING, "Failed to export", e);
return new Scalar(
"FAILED TO EXPORT\n" + instance.getClass().getName() + "#" + name + ": " + printThrowable(e));
}
}
/**
* This function is for the JSONSchemaGeneration
* @param instance Owner Instance
* @param context Context to be passed
* @return CNode object describing the structure of the node
*/
public CNode describeForSchema(Owner instance, ConfigurationContext context) {
final Configurator c = context.lookup(type);
if (c == null) {
return new Scalar("FAILED TO EXPORT\n" + instance.getClass().getName() + "#" + name
+ ": No configurator found for type " + type);
}
try {
Object o = getType();
if (o == null) {
return null;
}
// In Export we sensitive only those values which do not get rendered as secrets
boolean shouldBeMasked = isSecret(instance);
if (multiple) {
Sequence seq = new Sequence();
if (o.getClass().isArray()) {
o = Collections.singletonList(o);
}
if (o instanceof Iterable) {
for (Object value : (Iterable) o) {
seq.add(_describe(c, context, value, shouldBeMasked));
}
}
return seq;
}
return _describe(c, context, o, shouldBeMasked);
} catch (Exception e) {
// Don't fail the whole export, prefer logging this error
LOGGER.log(Level.WARNING, "Failed to export", e);
return new Scalar(
"FAILED TO EXPORT\n" + instance.getClass().getName() + "#" + name + ": " + printThrowable(e));
}
}
/**
* Describes a node.
* @param c Configurator
* @param context Context to be passed
* @param value Value
* @param shouldBeMasked If {@code true}, the value should be masked in the output.
* It will be applied to {@link Scalar} nodes only.
* @throws Exception export error
* @return Node
*/
private CNode _describe(Configurator c, ConfigurationContext context, Object value, boolean shouldBeMasked)
throws Exception {
CNode node;
if (isJsonSchema) {
node = c.describeStructure(value, context);
} else {
node = c.describe(value, context);
}
if (shouldBeMasked && node instanceof Scalar) {
((Scalar) node).sensitive(true);
}
return node;
}
public boolean equals(Owner o1, Owner o2) throws Exception {
final Object v1 = getValue(o1);
final Object v2 = getValue(o2);
if (v1 == null && v2 == null) {
return true;
}
if (multiple && v1 instanceof Collection && v2 instanceof Collection) {
Collection c1 = (Collection) v1;
Collection c2 = (Collection) v2;
return CollectionUtils.isEqualCollection(c1, c2);
}
return v1 != null && v1.equals(v2);
}
/**
* Abstracts away how to assign a value to a 'target' Jenkins object.
*/
@FunctionalInterface
public interface Setter<O, T> {
Setter NOP = (o, v) -> {};
void setValue(O target, T value) throws Exception;
}
/**
* Abstracts away how to retrieve attribute value from a 'target' Jenkins object.
*/
@FunctionalInterface
public interface Getter<O, T> {
T getValue(O target) throws Exception;
}
@CheckForNull
private static Method locateGetter(Class<?> clazz, @NonNull String fieldName) {
final String upname = StringUtils.capitalize(fieldName);
final List<String> accessors = Arrays.asList("get" + upname, "is" + upname);
for (Method method : clazz.getMethods()) {
if (method.getParameterCount() != 0) {
continue;
}
if (accessors.contains(method.getName())) {
return method;
}
final Exported exported = method.getAnnotation(Exported.class);
if (exported != null && exported.name().equalsIgnoreCase(fieldName)) {
return method;
}
}
return null;
}
@CheckForNull
private static Field locatePublicField(Class<?> clazz, @NonNull String fieldName) {
return ExtraFieldUtils.getField(clazz, fieldName, false);
}
@CheckForNull
private static Field locatePrivateFieldInHierarchy(Class<?> clazz, @NonNull String fieldName) {
return ExtraFieldUtils.getFieldNoForce(clazz, fieldName);
}
// TODO: consider Boolean and third condition
/**
* This is a method which tries to guess whether an attribute is {@link Secret}.
* @param targetClass Class of the target object. {@code null} if unknown
* @param fieldName Field name
* @return {@code true} if the attribute is secret
* {@code false} if not or if there is no conclusive answer.
*/
@Restricted(NoExternalUse.class)
public static boolean calculateIfSecret(@CheckForNull Class<?> targetClass, @NonNull String fieldName) {
if (targetClass == Secret.class) { // Class is final, so the check is safe
LOGGER.log(Level.FINER, "Attribute {0}#{1} is secret, because it has a Secret type", new Object[] {
targetClass.getName(), fieldName
});
return true;
}
if (targetClass == null) {
LOGGER.log(
Level.FINER,
"Attribute {0} is assumed to be non-secret, because there is no class instance in the call. "
+ "This call is used only for fast-fetch caching, and the result may be adjusted later",
new Object[] {fieldName});
return false; // All methods below require a known target class
}
// TODO: Cache decisions?
Method m = locateGetter(targetClass, fieldName);
if (m != null && m.getReturnType() == Secret.class) {
LOGGER.log(
Level.FINER,
"Attribute {0}#{1} is secret, because there is a getter {2} which returns a Secret type",
new Object[] {targetClass.getName(), fieldName, m});
return true;
}
Field f = locatePublicField(targetClass, fieldName);
if (f != null && f.getType() == Secret.class) {
LOGGER.log(
Level.FINER,
"Attribute {0}#{1} is secret, because there is a public field {2} which has a Secret type",
new Object[] {targetClass.getName(), fieldName, f});
return true;
}
f = locatePrivateFieldInHierarchy(targetClass, fieldName);
if (f != null && f.getType() == Secret.class) {
LOGGER.log(
Level.FINER,
"Attribute {0}#{1} is secret, because there is a private field {2} which has a Secret type",
new Object[] {targetClass.getName(), fieldName, f});
return true;
}
// TODO(oleg_nenashev): Consider setters? Gonna be more interesting since there might be many of them
LOGGER.log(Level.FINER, "Attribute {0}#{1} is not a secret, because all checks have passed", new Object[] {
targetClass.getName(), fieldName
});
return false;
}
private Type _getValue(Owner target) throws ConfiguratorException {
final Class<?> clazz = target.getClass();
try {
final Method method = locateGetter(clazz, this.name);
if (method != null) {
return (Type) method.invoke(target);
}
// If this is a public final field, developers don't define getters as jelly can use them as-is
final Field field = ExtraFieldUtils.getField(clazz, this.name, true);
if (field != null) {
return (Type) field.get(target);
}
throw new ConfiguratorException("Can't read attribute '" + name + "' from " + target);
} catch (IllegalArgumentException | IllegalAccessException | InvocationTargetException e) {
throw new ConfiguratorException("Can't read attribute '" + name + "' from " + target, e);
}
}
/**
* Default Setter implementation based on JavaBean property write method.
*
*/
private void _setValue(Owner target, Type value) throws Exception {
final String setterId = target.getClass().getCanonicalName() + '#' + name;
Method writeMethod = null;
for (Method method : target.getClass().getMethods()) {
// Find most specialized variant of setter because the method
// can to have been overridden with concretized type
if (method.getName().equals("set" + StringUtils.capitalize(name))
&& (writeMethod == null
|| writeMethod.getParameterTypes()[0].isAssignableFrom(method.getParameterTypes()[0]))) {
writeMethod = method;
}
}
if (writeMethod == null) {
throw new IllegalStateException("Default value setter cannot find Property Descriptor for " + setterId);
}
Object o = value;
if (multiple) {
if (!(value instanceof Collection)) {
throw new IllegalArgumentException(setterId + " should be a list.");
}
// if setter expect an Array, convert Collection to expected array type
// Typically required for hudson.tools.ToolDescriptor.setInstallations
// as java varargs unfortunately only supports Arrays, not all Iterable (sic)
final Class c = writeMethod.getParameterTypes()[0];
if (c.isArray()) {
Collection collection = (Collection) value;
o = collection.toArray((Object[]) Array.newInstance(type, collection.size()));
// if setter expect a Set, convert Collection to Set
} else if (c.isAssignableFrom(Set.class)) {
o = new HashSet((Collection) value);
}
}
writeMethod.invoke(target, o);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Attribute<?, ?> attribute = (Attribute<?, ?>) o;
return Objects.equals(name, attribute.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
public static <T, V> Setter<T, V> noop() {
return NOOP;
}
}