549 lines
19 KiB
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;
|
|
}
|
|
}
|