diff options
75 files changed, 4723 insertions, 402 deletions
diff --git a/config-model/src/main/java/com/yahoo/schema/RankProfile.java b/config-model/src/main/java/com/yahoo/schema/RankProfile.java index 6007a1cf4b1..e2577f4f834 100644 --- a/config-model/src/main/java/com/yahoo/schema/RankProfile.java +++ b/config-model/src/main/java/com/yahoo/schema/RankProfile.java @@ -22,6 +22,7 @@ import com.yahoo.searchlib.rankingexpression.FeatureList; import com.yahoo.searchlib.rankingexpression.RankingExpression; import com.yahoo.searchlib.rankingexpression.Reference; import com.yahoo.searchlib.rankingexpression.rule.Arguments; +import com.yahoo.searchlib.rankingexpression.rule.ExpressionNode; import com.yahoo.searchlib.rankingexpression.rule.ReferenceNode; import com.yahoo.tensor.Tensor; import com.yahoo.tensor.TensorType; @@ -30,6 +31,7 @@ import java.io.IOException; import java.io.Reader; import java.io.StringReader; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -1058,21 +1060,45 @@ public class RankProfile implements Cloneable { functions = compileFunctions(this::getFunctions, queryProfiles, featureTypes, importedModels, inlineFunctions, expressionTransforms); allFunctionsCached = null; + var context = new RankProfileTransformContext(this, + queryProfiles, + featureTypes, + importedModels, + constants(), + inlineFunctions); + var allNormalizers = getFeatureNormalizers(); + verifyNoNormalizers("first-phase expression", firstPhaseRanking, allNormalizers, context); + verifyNoNormalizers("second-phase expression", secondPhaseRanking, allNormalizers, context); + for (ReferenceNode mf : getMatchFeatures()) { + verifyNoNormalizers("match-feature " + mf, mf, allNormalizers, context); + } + for (ReferenceNode sf : getSummaryFeatures()) { + verifyNoNormalizers("summary-feature " + sf, sf, allNormalizers, context); + } if (globalPhaseRanking != null) { - var context = new RankProfileTransformContext(this, - queryProfiles, - featureTypes, - importedModels, - constants(), - inlineFunctions); var needInputs = new HashSet<String>(); + Set<String> userDeclaredMatchFeatures = new HashSet<>(); + for (ReferenceNode mf : getMatchFeatures()) { + userDeclaredMatchFeatures.add(mf.toString()); + } var recorder = new InputRecorder(needInputs); - if (matchFeatures != null) { - for (ReferenceNode mf : matchFeatures) { - recorder.alreadyHandled(mf.toString()); + recorder.alreadyMatchFeatures(userDeclaredMatchFeatures); + recorder.addKnownNormalizers(allNormalizers.keySet()); + recorder.process(globalPhaseRanking.function().getBody(), context); + for (var normalizerName : recorder.normalizersUsed()) { + var normalizer = allNormalizers.get(normalizerName); + var func = functions.get(normalizer.input()); + if (func != null) { + verifyNoNormalizers("normalizer input " + normalizer.input(), func, allNormalizers, context); + if (! userDeclaredMatchFeatures.contains(normalizer.input())) { + var subRecorder = new InputRecorder(needInputs); + subRecorder.alreadyMatchFeatures(userDeclaredMatchFeatures); + subRecorder.process(func.function().getBody(), context); + } + } else { + needInputs.add(normalizer.input()); } } - recorder.process(globalPhaseRanking.function().getBody(), context); List<FeatureList> addIfMissing = new ArrayList<>(); for (String input : needInputs) { if (input.startsWith("constant(") || input.startsWith("query(")) { @@ -1630,4 +1656,70 @@ public class RankProfile implements Cloneable { } + public static record RankFeatureNormalizer(Reference original, String name, String input, String algo, double kparam) { + @Override + public String toString() { + return "normalizer{name=" + name + ",input=" + input + ",algo=" + algo + ",k=" + kparam + "}"; + } + private static long hash(String s) { + int bob = com.yahoo.collections.BobHash.hash(s); + return bob + 0x100000000L; + } + public static RankFeatureNormalizer linear(Reference original, Reference inputRef) { + long h = hash(original.toString()); + String name = "normalize@" + h + "@linear"; + return new RankFeatureNormalizer(original, name, inputRef.toString(), "LINEAR", 0.0); + } + public static RankFeatureNormalizer rrank(Reference original, Reference inputRef, double k) { + long h = hash(original.toString()); + String name = "normalize@" + h + "@rrank"; + return new RankFeatureNormalizer(original, name, inputRef.toString(), "RRANK", k); + } + } + + private List<RankFeatureNormalizer> featureNormalizers = new ArrayList<>(); + + public Map<String, RankFeatureNormalizer> getFeatureNormalizers() { + Map<String, RankFeatureNormalizer> all = new LinkedHashMap<>(); + for (var inheritedProfile : inherited()) { + all.putAll(inheritedProfile.getFeatureNormalizers()); + } + for (var n : featureNormalizers) { + all.put(n.name(), n); + } + return all; + } + + public void addFeatureNormalizer(RankFeatureNormalizer n) { + if (functions.get(n.name()) != null) { + throw new IllegalArgumentException("cannot use name '" + name + "' for both function and normalizer"); + } + featureNormalizers.add(n); + } + + private void verifyNoNormalizers(String where, RankingExpressionFunction f, Map<String, RankFeatureNormalizer> allNormalizers, RankProfileTransformContext context) { + if (f == null) return; + verifyNoNormalizers(where, f.function(), allNormalizers, context); + } + + private void verifyNoNormalizers(String where, ExpressionFunction func, Map<String, RankFeatureNormalizer> allNormalizers, RankProfileTransformContext context) { + if (func == null) return; + var body = func.getBody(); + if (body == null) return; + verifyNoNormalizers(where, body.getRoot(), allNormalizers, context); + } + + private void verifyNoNormalizers(String where, ExpressionNode node, Map<String, RankFeatureNormalizer> allNormalizers, RankProfileTransformContext context) { + var needInputs = new HashSet<String>(); + var recorder = new InputRecorder(needInputs); + recorder.process(node, context); + for (var input : needInputs) { + var normalizer = allNormalizers.get(input); + if (normalizer != null) { + throw new IllegalArgumentException("Cannot use " + normalizer.original() + " from " + where + ", only valid in global-phase expression"); + } + } + } + + } diff --git a/config-model/src/main/java/com/yahoo/schema/derived/RawRankProfile.java b/config-model/src/main/java/com/yahoo/schema/derived/RawRankProfile.java index 05e5f17ea3d..eb9f7d44c91 100644 --- a/config-model/src/main/java/com/yahoo/schema/derived/RawRankProfile.java +++ b/config-model/src/main/java/com/yahoo/schema/derived/RawRankProfile.java @@ -54,6 +54,7 @@ public class RawRankProfile implements RankProfilesConfig.Producer { private final String name; private final Compressor.Compression compressedProperties; + private final Map<String, RankProfile.RankFeatureNormalizer> featureNormalizers; /** The compiled profile this is created from. */ private final Collection<RankProfile.Constant> constants; @@ -66,13 +67,14 @@ public class RawRankProfile implements RankProfilesConfig.Producer { this.name = rankProfile.name(); /* * Forget the RankProfiles as soon as possible. They can become very large and memory hungry - * Especially do not refer then through any member variables due to the RawRankProfile living forever. + * Especially do not refer them through any member variables due to the RawRankProfile living forever. */ RankProfile compiled = rankProfile.compile(queryProfiles, importedModels); constants = compiled.constants().values(); onnxModels = compiled.onnxModels().values(); - compressedProperties = compress(new Deriver(compiled, attributeFields, deployProperties, queryProfiles) - .derive(largeExpressions)); + var deriver = new Deriver(compiled, attributeFields, deployProperties, queryProfiles); + compressedProperties = compress(deriver.derive(largeExpressions)); + this.featureNormalizers = compiled.getFeatureNormalizers(); } public Collection<RankProfile.Constant> constants() { return constants; } @@ -111,6 +113,18 @@ public class RawRankProfile implements RankProfilesConfig.Producer { b.fef(fefB); } + private void buildNormalizers(RankProfilesConfig.Rankprofile.Builder b) { + for (var normalizer : featureNormalizers.values()) { + var nBuilder = new RankProfilesConfig.Rankprofile.Normalizer.Builder(); + nBuilder.name(normalizer.name()); + nBuilder.input(normalizer.input()); + var algo = RankProfilesConfig.Rankprofile.Normalizer.Algo.Enum.valueOf(normalizer.algo()); + nBuilder.algo(algo); + nBuilder.kparam(normalizer.kparam()); + b.normalizer(nBuilder); + } + } + /** * Returns the properties of this as an unmodifiable list. * Note: This method is expensive. @@ -121,6 +135,7 @@ public class RawRankProfile implements RankProfilesConfig.Producer { public void getConfig(RankProfilesConfig.Builder builder) { RankProfilesConfig.Rankprofile.Builder b = new RankProfilesConfig.Rankprofile.Builder().name(getName()); getRankProperties(b); + buildNormalizers(b); builder.rankprofile(b); } diff --git a/config-model/src/main/java/com/yahoo/schema/expressiontransforms/ExpressionTransforms.java b/config-model/src/main/java/com/yahoo/schema/expressiontransforms/ExpressionTransforms.java index cf46bedf223..42c8147b3dc 100644 --- a/config-model/src/main/java/com/yahoo/schema/expressiontransforms/ExpressionTransforms.java +++ b/config-model/src/main/java/com/yahoo/schema/expressiontransforms/ExpressionTransforms.java @@ -35,7 +35,8 @@ public class ExpressionTransforms { new FunctionShadower(), new TensorMaxMinTransformer(), new Simplifier(), - new BooleanExpressionTransformer()); + new BooleanExpressionTransformer(), + new NormalizerFunctionExpander()); } public RankingExpression transform(RankingExpression expression, RankProfileTransformContext context) { diff --git a/config-model/src/main/java/com/yahoo/schema/expressiontransforms/InputRecorder.java b/config-model/src/main/java/com/yahoo/schema/expressiontransforms/InputRecorder.java index 1128aaf3681..ab18f9c83db 100644 --- a/config-model/src/main/java/com/yahoo/schema/expressiontransforms/InputRecorder.java +++ b/config-model/src/main/java/com/yahoo/schema/expressiontransforms/InputRecorder.java @@ -14,6 +14,7 @@ import com.yahoo.searchlib.rankingexpression.transform.ExpressionTransformer; import com.yahoo.tensor.functions.Generate; import java.io.StringReader; +import java.util.Collection; import java.util.HashSet; import java.util.Set; import java.util.logging.Logger; @@ -29,19 +30,35 @@ public class InputRecorder extends ExpressionTransformer<InputRecorderContext> { private final Set<String> neededInputs; private final Set<String> handled = new HashSet<>(); + private final Set<String> availableNormalizers = new HashSet<>(); + private final Set<String> usedNormalizers = new HashSet<>(); public InputRecorder(Set<String> target) { this.neededInputs = target; } public void process(RankingExpression expression, RankProfileTransformContext context) { - transform(expression.getRoot(), new InputRecorderContext(context)); + process(expression.getRoot(), context); } - public void alreadyHandled(String name) { - handled.add(name); + public void process(ExpressionNode node, RankProfileTransformContext context) { + transform(node, new InputRecorderContext(context)); } + public void alreadyMatchFeatures(Collection<String> matchFeatures) { + for (String mf : matchFeatures) { + handled.add(mf); + } + } + + public void addKnownNormalizers(Collection<String> names) { + for (String name : names) { + availableNormalizers.add(name); + } + } + + public Set<String> normalizersUsed() { return this.usedNormalizers; } + @Override public ExpressionNode transform(ExpressionNode node, InputRecorderContext context) { if (node instanceof ReferenceNode r) { @@ -77,6 +94,10 @@ public class InputRecorder extends ExpressionTransformer<InputRecorderContext> { if (simpleFunctionOrIdentifier && context.localVariables().contains(name)) { return; } + if (simpleFunctionOrIdentifier && availableNormalizers.contains(name)) { + usedNormalizers.add(name); + return; + } if (ref.isSimpleRankingExpressionWrapper()) { name = ref.simpleArgument().get(); simpleFunctionOrIdentifier = true; @@ -113,13 +134,21 @@ public class InputRecorder extends ExpressionTransformer<InputRecorderContext> { } } if ("onnx".equals(name)) { - if (args.size() != 1) { + if (args.size() < 1) { throw new IllegalArgumentException("expected name of ONNX model as argument: " + feature); } var arg = args.expressions().get(0); var models = context.rankProfile().onnxModels(); var model = models.get(arg.toString()); if (model == null) { + var tmp = OnnxModelTransformer.transformFeature(feature, context.rankProfile()); + if (tmp instanceof ReferenceNode newRefNode) { + args = newRefNode.getArguments(); + arg = args.expressions().get(0); + model = models.get(arg.toString()); + } + } + if (model == null) { throw new IllegalArgumentException("missing onnx model: " + arg); } model.getInputMap().forEach((__, onnxInput) -> { diff --git a/config-model/src/main/java/com/yahoo/schema/expressiontransforms/NormalizerFunctionExpander.java b/config-model/src/main/java/com/yahoo/schema/expressiontransforms/NormalizerFunctionExpander.java new file mode 100644 index 00000000000..a8fee966656 --- /dev/null +++ b/config-model/src/main/java/com/yahoo/schema/expressiontransforms/NormalizerFunctionExpander.java @@ -0,0 +1,134 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.schema.expressiontransforms; + +import com.yahoo.schema.FeatureNames; +import com.yahoo.schema.RankProfile.RankFeatureNormalizer; +import com.yahoo.searchlib.rankingexpression.evaluation.BooleanValue; +import com.yahoo.searchlib.rankingexpression.rule.OperationNode; +import com.yahoo.searchlib.rankingexpression.rule.Operator; +import com.yahoo.searchlib.rankingexpression.rule.CompositeNode; +import com.yahoo.searchlib.rankingexpression.rule.ConstantNode; +import com.yahoo.searchlib.rankingexpression.rule.ExpressionNode; +import com.yahoo.searchlib.rankingexpression.rule.IfNode; +import com.yahoo.searchlib.rankingexpression.transform.ExpressionTransformer; +import com.yahoo.searchlib.rankingexpression.transform.TransformContext; +import com.yahoo.searchlib.rankingexpression.RankingExpression; +import com.yahoo.searchlib.rankingexpression.Reference; +import com.yahoo.searchlib.rankingexpression.parser.ParseException; +import com.yahoo.searchlib.rankingexpression.rule.CompositeNode; +import com.yahoo.searchlib.rankingexpression.rule.ConstantNode; +import com.yahoo.searchlib.rankingexpression.rule.ExpressionNode; +import com.yahoo.searchlib.rankingexpression.rule.ReferenceNode; +import com.yahoo.searchlib.rankingexpression.rule.TensorFunctionNode; +import com.yahoo.searchlib.rankingexpression.transform.ExpressionTransformer; +import com.yahoo.tensor.functions.Generate; + +import java.io.StringReader; +import java.util.HashSet; +import java.util.Set; +import java.util.logging.Logger; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; +import java.util.ArrayList; + +/** + * Recognizes pseudo-functions and creates global-phase normalizers + * @author arnej + */ +public class NormalizerFunctionExpander extends ExpressionTransformer<RankProfileTransformContext> { + + public final static String NORMALIZE_LINEAR = "normalize_linear"; + public final static String RECIPROCAL_RANK = "reciprocal_rank"; + public final static String RECIPROCAL_RANK_FUSION = "reciprocal_rank_fusion"; + + @Override + public ExpressionNode transform(ExpressionNode node, RankProfileTransformContext context) { + if (node instanceof ReferenceNode r) { + node = transformReference(r, context); + } + if (node instanceof CompositeNode composite) { + node = transformChildren(composite, context); + } + return node; + } + + private ExpressionNode transformReference(ReferenceNode node, RankProfileTransformContext context) { + Reference ref = node.reference(); + String name = ref.name(); + if (ref.output() != null) { + return node; + } + var f = context.rankProfile().getFunctions().get(name); + if (f != null) { + // never transform declared functions + return node; + } + return switch(name) { + case RECIPROCAL_RANK_FUSION -> transform(expandRRF(ref), context); + case NORMALIZE_LINEAR -> transformNormLin(ref, context); + case RECIPROCAL_RANK -> transformRRank(ref, context); + default -> node; + }; + } + + private ExpressionNode expandRRF(Reference ref) { + var args = ref.arguments(); + if (args.size() < 2) { + throw new IllegalArgumentException("must have at least 2 arguments: " + ref); + } + List<ExpressionNode> children = new ArrayList<>(); + List<Operator> operators = new ArrayList<>(); + for (var arg : args.expressions()) { + if (! children.isEmpty()) operators.add(Operator.plus); + children.add(new ReferenceNode(RECIPROCAL_RANK, List.of(arg), null)); + } + // must be further transformed (see above) + return new OperationNode(children, operators); + } + + private ExpressionNode transformNormLin(Reference ref, RankProfileTransformContext context) { + var args = ref.arguments(); + if (args.size() != 1) { + throw new IllegalArgumentException("must have exactly 1 argument: " + ref); + } + var input = args.expressions().get(0); + if (input instanceof ReferenceNode inputRefNode) { + var inputRef = inputRefNode.reference(); + RankFeatureNormalizer normalizer = RankFeatureNormalizer.linear(ref, inputRef); + context.rankProfile().addFeatureNormalizer(normalizer); + var newRef = Reference.fromIdentifier(normalizer.name()); + return new ReferenceNode(newRef); + } else { + throw new IllegalArgumentException("the first argument must be a simple feature: " + ref + " => " + input.getClass()); + } + } + + private ExpressionNode transformRRank(Reference ref, RankProfileTransformContext context) { + var args = ref.arguments(); + if (args.size() < 1 || args.size() > 2) { + throw new IllegalArgumentException("must have 1 or 2 arguments: " + ref); + } + double k = 60.0; + if (args.size() == 2) { + var kArg = args.expressions().get(1); + if (kArg instanceof ConstantNode kNode) { + k = kNode.getValue().asDouble(); + } else { + throw new IllegalArgumentException("the second argument (k) must be a constant in: " + ref); + } + } + var input = args.expressions().get(0); + if (input instanceof ReferenceNode inputRefNode) { + var inputRef = inputRefNode.reference(); + RankFeatureNormalizer normalizer = RankFeatureNormalizer.rrank(ref, inputRef, k); + context.rankProfile().addFeatureNormalizer(normalizer); + var newRef = Reference.fromIdentifier(normalizer.name()); + return new ReferenceNode(newRef); + } else { + throw new IllegalArgumentException("the first argument must be a simple feature: " + ref); + } + } +} diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/component/Handler.java b/config-model/src/main/java/com/yahoo/vespa/model/container/component/Handler.java index 0af970e016a..099255975b6 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/container/component/Handler.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/container/component/Handler.java @@ -64,8 +64,8 @@ public class Handler extends Component<Component<?, ?>, ComponentModel> { clientBindings.addAll(Arrays.asList(bindings)); } - public final Set<BindingPattern> getServerBindings() { - return Collections.unmodifiableSet(serverBindings); + public final Collection<BindingPattern> getServerBindings() { + return List.copyOf(serverBindings); } public final List<BindingPattern> getClientBindings() { diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/http/JettyHttpServer.java b/config-model/src/main/java/com/yahoo/vespa/model/container/http/JettyHttpServer.java index d2faff7850b..b14495756c3 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/container/http/JettyHttpServer.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/container/http/JettyHttpServer.java @@ -9,6 +9,7 @@ import com.yahoo.jdisc.http.ServerConfig; import com.yahoo.osgi.provider.model.ComponentModel; import com.yahoo.vespa.model.container.ApplicationContainerCluster; import com.yahoo.vespa.model.container.ContainerCluster; +import com.yahoo.vespa.model.container.component.ConnectionLogComponent; import com.yahoo.vespa.model.container.component.SimpleComponent; import java.util.ArrayList; @@ -24,13 +25,11 @@ import java.util.TreeSet; public class JettyHttpServer extends SimpleComponent implements ServerConfig.Producer { private final ContainerCluster<?> cluster; - private volatile boolean isHostedVespa; private final List<ConnectorFactory> connectorFactories = new ArrayList<>(); private final SortedSet<String> ignoredUserAgentsList = new TreeSet<>(); public JettyHttpServer(String componentId, ContainerCluster<?> cluster, DeployState deployState) { super(new ComponentModel(componentId, com.yahoo.jdisc.http.server.jetty.JettyHttpServer.class.getName(), null)); - this.isHostedVespa = deployState.isHosted(); this.cluster = cluster; FilterBindingsProviderComponent filterBindingsProviderComponent = new FilterBindingsProviderComponent(componentId); addChild(filterBindingsProviderComponent); @@ -42,8 +41,6 @@ public class JettyHttpServer extends SimpleComponent implements ServerConfig.Pro } } - public void setHostedVespa(boolean isHostedVespa) { this.isHostedVespa = isHostedVespa; } - public void addConnector(ConnectorFactory connectorFactory) { connectorFactories.add(connectorFactory); addChild(connectorFactory); @@ -64,10 +61,8 @@ public class JettyHttpServer extends SimpleComponent implements ServerConfig.Pro .ignoredUserAgents(ignoredUserAgentsList) .searchHandlerPaths(List.of("/search")) ); - if (isHostedVespa) { - // Enable connection log hosted Vespa + if (cluster.getAllComponents().stream().anyMatch(c -> c instanceof ConnectionLogComponent)) builder.connectionLog(new ServerConfig.ConnectionLog.Builder().enabled(true)); - } configureJettyThreadpool(builder); builder.stopTimeout(300); } diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ConfigServerContainerModelBuilder.java b/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ConfigServerContainerModelBuilder.java index 7653d814d8a..119a3ad18c2 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ConfigServerContainerModelBuilder.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ConfigServerContainerModelBuilder.java @@ -3,19 +3,14 @@ package com.yahoo.vespa.model.container.xml; import com.yahoo.config.model.ConfigModelContext; import com.yahoo.config.model.deploy.DeployState; -import com.yahoo.container.logging.AccessLog; import com.yahoo.container.logging.FileConnectionLog; -import com.yahoo.jdisc.http.server.jetty.VoidRequestLog; import com.yahoo.vespa.model.container.ApplicationContainerCluster; import com.yahoo.vespa.model.container.ContainerModel; -import com.yahoo.vespa.model.container.component.AccessLogComponent; import com.yahoo.vespa.model.container.component.ConnectionLogComponent; import com.yahoo.vespa.model.container.configserver.ConfigserverCluster; import com.yahoo.vespa.model.container.configserver.option.CloudConfigOptions; import org.w3c.dom.Element; -import static com.yahoo.vespa.model.container.component.AccessLogComponent.AccessLogType.jsonAccessLog; - /** * Builds the config model for the standalone config server. * @@ -57,12 +52,6 @@ public class ConfigServerContainerModelBuilder extends ContainerModelBuilder { } @Override - protected void addHttp(DeployState deployState, Element spec, ApplicationContainerCluster cluster, ConfigModelContext context) { - super.addHttp(deployState, spec, cluster, context); - cluster.getHttp().getHttpServer().get().setHostedVespa(isHosted()); - } - - @Override protected void addModelEvaluationRuntime(ApplicationContainerCluster cluster) { // Model evaluation bundles are pre-installed in the standalone container. } diff --git a/config-model/src/main/java/com/yahoo/vespa/model/filedistribution/UserConfiguredFiles.java b/config-model/src/main/java/com/yahoo/vespa/model/filedistribution/UserConfiguredFiles.java index b10da29ee04..a454c1141ca 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/filedistribution/UserConfiguredFiles.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/filedistribution/UserConfiguredFiles.java @@ -26,6 +26,7 @@ import java.util.Optional; import java.util.logging.Level; import static com.yahoo.vespa.model.container.ApplicationContainerCluster.UserConfiguredUrls; +import static java.util.logging.Level.WARNING; /** * Utility methods for registering file distribution of files/paths/urls/models defined by the user. @@ -74,7 +75,7 @@ public class UserConfiguredFiles implements Serializable { if (configDefinition == null) { String message = "Unable to find config definition " + key + ". Will not register files for file distribution for this config"; switch (unknownConfigDefinition) { - case "warning" -> logger.logApplicationPackage(Level.WARNING, message); + case "warning" -> logger.logApplicationPackage(WARNING, message); case "fail" -> throw new IllegalArgumentException("Unable to find config definition for " + key); } return; @@ -162,7 +163,7 @@ public class UserConfiguredFiles implements Serializable { ApplicationFile file = applicationPackage.getFile(path); if (file.isDirectory() && (file.listFiles() == null || file.listFiles().isEmpty())) - throw new IllegalArgumentException("Directory '" + path.getRelative() + "' is empty"); + logger.logApplicationPackage(WARNING, "Directory '" + path.getRelative() + "' is empty"); FileReference reference = registeredFiles.get(path); if (reference == null) { diff --git a/config-model/src/test/derived/rankingexpression/rank-profiles.cfg b/config-model/src/test/derived/rankingexpression/rank-profiles.cfg index b0f7d0f2477..b3257c962dd 100644 --- a/config-model/src/test/derived/rankingexpression/rank-profiles.cfg +++ b/config-model/src/test/derived/rankingexpression/rank-profiles.cfg @@ -520,3 +520,65 @@ rankprofile[].fef.property[].name "vespa.type.attribute.t1" rankprofile[].fef.property[].value "tensor(m{},v[3])" rankprofile[].fef.property[].name "vespa.type.query.v" rankprofile[].fef.property[].value "tensor(v[3])" +rankprofile[].name "withnorm" +rankprofile[].fef.property[].name "rankingExpression(normBar).rankingScript" +rankprofile[].fef.property[].value "attribute(foo1) + attribute(year)" +rankprofile[].fef.property[].name "vespa.rank.firstphase" +rankprofile[].fef.property[].value "attribute(foo1)" +rankprofile[].fef.property[].name "vespa.rank.globalphase" +rankprofile[].fef.property[].value "rankingExpression(globalphase)" +rankprofile[].fef.property[].name "rankingExpression(globalphase).rankingScript" +rankprofile[].fef.property[].value "normalize@3551296680@linear + normalize@2879443254@rrank" +rankprofile[].fef.property[].name "vespa.match.feature" +rankprofile[].fef.property[].value "nativeRank" +rankprofile[].fef.property[].name "vespa.match.feature" +rankprofile[].fef.property[].value "attribute(year)" +rankprofile[].fef.property[].name "vespa.match.feature" +rankprofile[].fef.property[].value "attribute(foo1)" +rankprofile[].fef.property[].name "vespa.hidden.matchfeature" +rankprofile[].fef.property[].value "attribute(year)" +rankprofile[].fef.property[].name "vespa.hidden.matchfeature" +rankprofile[].fef.property[].value "attribute(foo1)" +rankprofile[].fef.property[].name "vespa.globalphase.rerankcount" +rankprofile[].fef.property[].value "123" +rankprofile[].fef.property[].name "vespa.type.attribute.t1" +rankprofile[].fef.property[].value "tensor(m{},v[3])" +rankprofile[].normalizer[].name "normalize@3551296680@linear" +rankprofile[].normalizer[].input "nativeRank" +rankprofile[].normalizer[].algo LINEAR +rankprofile[].normalizer[].kparam 0.0 +rankprofile[].normalizer[].name "normalize@2879443254@rrank" +rankprofile[].normalizer[].input "normBar" +rankprofile[].normalizer[].algo RRANK +rankprofile[].normalizer[].kparam 42.0 +rankprofile[].name "withfusion" +rankprofile[].fef.property[].name "rankingExpression(normBar).rankingScript" +rankprofile[].fef.property[].value "attribute(foo1) + attribute(year)" +rankprofile[].fef.property[].name "vespa.rank.firstphase" +rankprofile[].fef.property[].value "attribute(foo1)" +rankprofile[].fef.property[].name "vespa.rank.globalphase" +rankprofile[].fef.property[].value "rankingExpression(globalphase)" +rankprofile[].fef.property[].name "rankingExpression(globalphase).rankingScript" +rankprofile[].fef.property[].value "normalize@5385018767@rrank + normalize@3221316369@rrank" +rankprofile[].fef.property[].name "vespa.match.feature" +rankprofile[].fef.property[].value "nativeRank" +rankprofile[].fef.property[].name "vespa.match.feature" +rankprofile[].fef.property[].value "attribute(year)" +rankprofile[].fef.property[].name "vespa.match.feature" +rankprofile[].fef.property[].value "attribute(foo1)" +rankprofile[].fef.property[].name "vespa.hidden.matchfeature" +rankprofile[].fef.property[].value "attribute(year)" +rankprofile[].fef.property[].name "vespa.hidden.matchfeature" +rankprofile[].fef.property[].value "attribute(foo1)" +rankprofile[].fef.property[].name "vespa.globalphase.rerankcount" +rankprofile[].fef.property[].value "456" +rankprofile[].fef.property[].name "vespa.type.attribute.t1" +rankprofile[].fef.property[].value "tensor(m{},v[3])" +rankprofile[].normalizer[].name "normalize@5385018767@rrank" +rankprofile[].normalizer[].input "normBar" +rankprofile[].normalizer[].algo RRANK +rankprofile[].normalizer[].kparam 60.0 +rankprofile[].normalizer[].name "normalize@3221316369@rrank" +rankprofile[].normalizer[].input "nativeRank" +rankprofile[].normalizer[].algo RRANK +rankprofile[].normalizer[].kparam 60.0 diff --git a/config-model/src/test/derived/rankingexpression/rankexpression.sd b/config-model/src/test/derived/rankingexpression/rankexpression.sd index 16dff61b63a..15537f1f9d0 100644 --- a/config-model/src/test/derived/rankingexpression/rankexpression.sd +++ b/config-model/src/test/derived/rankingexpression/rankexpression.sd @@ -441,4 +441,32 @@ schema rankexpression { } } + rank-profile withnorm { + first-phase { + expression: attribute(foo1) + } + function normBar() { + expression: attribute(foo1) + attribute(year) + } + global-phase { + expression: normalize_linear(nativeRank) + reciprocal_rank(normBar(), 42.0) + rerank-count: 123 + } + match-features: nativeRank + } + + rank-profile withfusion { + first-phase { + expression: attribute(foo1) + } + function normBar() { + expression: attribute(foo1) + attribute(year) + } + global-phase { + expression: reciprocal_rank_fusion(normBar, nativeRank) + rerank-count: 456 + } + match-features: nativeRank + } + } diff --git a/config-model/src/test/java/com/yahoo/schema/NoNormalizersTestCase.java b/config-model/src/test/java/com/yahoo/schema/NoNormalizersTestCase.java new file mode 100644 index 00000000000..6e2efadfa2c --- /dev/null +++ b/config-model/src/test/java/com/yahoo/schema/NoNormalizersTestCase.java @@ -0,0 +1,172 @@ +// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.schema; + +import com.yahoo.search.query.profile.QueryProfileRegistry; +import com.yahoo.schema.parser.ParseException; +import com.yahoo.yolean.Exceptions; +import ai.vespa.rankingexpression.importer.configmodelview.ImportedMlModels; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests rank profiles with normalizers in bad places + * + * @author arnej + */ +public class NoNormalizersTestCase extends AbstractSchemaTestCase { + + void compileSchema(String schema) throws ParseException { + RankProfileRegistry registry = new RankProfileRegistry(); + var qp = new QueryProfileRegistry(); + ApplicationBuilder builder = new ApplicationBuilder(registry, qp); + builder.addSchema(schema); + builder.build(true); + for (RankProfile rp : registry.all()) { + rp.compile(qp, new ImportedMlModels()); + } + } + + @Test + void requireThatNormalizerInFirstPhaseIsChecked() throws ParseException { + try { + compileSchema(""" + search test { + document test { } + rank-profile p1 { + first-phase { + expression: normalize_linear(nativeRank) + } + } + } + """); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("Rank profile 'p1' is invalid: " + + "Cannot use normalize_linear(nativeRank) from first-phase expression, only valid in global-phase expression", + Exceptions.toMessageString(e)); + } + } + + @Test + void requireThatNormalizerInSecondPhaseIsChecked() throws ParseException { + try { + compileSchema(""" + search test { + document test { + field title type string { + indexing: index + } + } + rank-profile p2 { + function foobar() { + expression: 42 + reciprocal_rank(whatever, 1.0) + } + function whatever() { + expression: fieldMatch(title) + } + first-phase { + expression: nativeRank + } + second-phase { + expression: foobar + } + } + } + """); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("Rank profile 'p2' is invalid: " + + "Cannot use reciprocal_rank(whatever,1.0) from second-phase expression, only valid in global-phase expression", + Exceptions.toMessageString(e)); + } + } + + @Test + void requireThatNormalizerInMatchFeatureIsChecked() throws ParseException { + try { + compileSchema(""" + search test { + document test { } + rank-profile p3 { + function foobar() { + expression: normalize_linear(nativeRank) + } + first-phase { + expression: nativeRank + } + match-features { + nativeRank + foobar + } + } + } + """); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("Rank profile 'p3' is invalid: " + + "Cannot use normalize_linear(nativeRank) from match-feature foobar, only valid in global-phase expression", + Exceptions.toMessageString(e)); + } + } + + @Test + void requireThatNormalizerInSummaryFeatureIsChecked() throws ParseException { + try { + compileSchema(""" + search test { + document test { } + rank-profile p4 { + function foobar() { + expression: normalize_linear(nativeRank) + } + first-phase { + expression: nativeRank + } + summary-features { + nativeRank + foobar + } + } + } + """); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("Rank profile 'p4' is invalid: " + + "Cannot use normalize_linear(nativeRank) from summary-feature foobar, only valid in global-phase expression", + Exceptions.toMessageString(e)); + } + } + + @Test + void requireThatNormalizerInNormalizerIsChecked() throws ParseException { + try { + compileSchema(""" + search test { + document test { + field title type string { + indexing: index + } + } + rank-profile p5 { + function foobar() { + expression: reciprocal_rank(nativeRank) + } + first-phase { + expression: nativeRank + } + global-phase { + expression: normalize_linear(fieldMatch(title)) + normalize_linear(foobar) + } + } + } + """); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("Rank profile 'p5' is invalid: " + + "Cannot use reciprocal_rank(nativeRank) from normalizer input foobar, only valid in global-phase expression", + Exceptions.toMessageString(e)); + } + } +} diff --git a/config-model/src/test/java/com/yahoo/vespa/model/container/xml/AccessLogTest.java b/config-model/src/test/java/com/yahoo/vespa/model/container/xml/AccessLogTest.java index f2e4ec052cb..a38a29893e0 100644 --- a/config-model/src/test/java/com/yahoo/vespa/model/container/xml/AccessLogTest.java +++ b/config-model/src/test/java/com/yahoo/vespa/model/container/xml/AccessLogTest.java @@ -16,6 +16,7 @@ import com.yahoo.container.logging.ConnectionLogConfig; import com.yahoo.container.logging.FileConnectionLog; import com.yahoo.container.logging.JSONAccessLog; import com.yahoo.container.logging.VespaAccessLog; +import com.yahoo.jdisc.http.ServerConfig; import com.yahoo.vespa.model.container.ApplicationContainerCluster; import com.yahoo.vespa.model.container.component.Component; import org.junit.jupiter.api.Test; @@ -129,6 +130,7 @@ public class AccessLogTest extends ContainerModelBuilderTestBase { assertEquals("default", config.cluster()); assertEquals(-1, config.queueSize()); assertEquals(256 * 1024, config.bufferSize()); + assertTrue(root.getConfig(ServerConfig.class, "default/container.0/DefaultHttpServer").connectionLog().enabled()); } @Test @@ -141,6 +143,7 @@ public class AccessLogTest extends ContainerModelBuilderTestBase { createModel(root, clusterElem); Component<?, ?> fileConnectionLogComponent = getComponent("default", FileConnectionLog.class.getName()); assertNull(fileConnectionLogComponent); + assertFalse(root.getConfig(ServerConfig.class, "default/container.0/DefaultHttpServer").connectionLog().enabled()); } @Test diff --git a/config-model/src/test/java/com/yahoo/vespa/model/container/xml/HandlerBuilderTest.java b/config-model/src/test/java/com/yahoo/vespa/model/container/xml/HandlerBuilderTest.java index 8a7ca27eec5..fdeea85c5a3 100644 --- a/config-model/src/test/java/com/yahoo/vespa/model/container/xml/HandlerBuilderTest.java +++ b/config-model/src/test/java/com/yahoo/vespa/model/container/xml/HandlerBuilderTest.java @@ -1,11 +1,11 @@ // Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.model.container.xml; +import com.yahoo.config.application.api.DeployLogger; import com.yahoo.config.model.ConfigModelContext; import com.yahoo.config.model.builder.xml.test.DomBuilderTest; import com.yahoo.config.model.deploy.DeployState; import com.yahoo.config.model.deploy.TestProperties; -import com.yahoo.config.provision.ApplicationId; import com.yahoo.container.ComponentsConfig; import com.yahoo.container.jdisc.JdiscBindingsConfig; import com.yahoo.container.usability.BindingsOverviewHandler; @@ -15,8 +15,10 @@ import com.yahoo.vespa.model.container.component.Handler; import org.junit.jupiter.api.Test; import org.w3c.dom.Element; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.logging.Level; import static com.yahoo.vespa.model.container.ContainerCluster.ROOT_HANDLER_BINDING; import static com.yahoo.vespa.model.container.ContainerCluster.STATE_HANDLER_BINDING_1; @@ -25,7 +27,11 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasItem; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; /** * Tests for container model building with custom handlers. @@ -63,6 +69,31 @@ public class HandlerBuilderTest extends ContainerModelBuilderTestBase { } @Test + void warn_on_bindings_shared_by_multiple_handlers() { + class TestDeployLogger implements DeployLogger { + List<String> logs = new ArrayList<>(); + @Override public void log(Level level, String message) { logs.add(message); } + } + var clusterElem = DomBuilderTest.parse( + "<container id='default' version='1.0'>", + " <handler id='myHandler1'>", + " <binding>http://*/myhandler</binding>", + " <binding>https://*/myhandler</binding>", + " </handler>", + " <handler id='myHandler2'>", + " <binding>http://*/myhandler</binding>", + " <binding>https://*/myhandler</binding>", + " </handler>", + "</container>"); + var logger = new TestDeployLogger(); + createModel(root, logger, clusterElem); + assertEquals( + List.of("Binding 'http://*/myhandler' was already in use by handler 'myHandler1', but will now be taken over by handler: myHandler2", + "Binding 'https://*/myhandler' was already in use by handler 'myHandler1', but will now be taken over by handler: myHandler2"), + logger.logs); + } + + @Test void default_root_handler_binding_can_be_stolen_by_user_configured_handler() { Element clusterElem = DomBuilderTest.parse( "<container id='default' version='1.0'>" + diff --git a/config-model/src/test/java/com/yahoo/vespa/model/filedistribution/UserConfiguredFilesTest.java b/config-model/src/test/java/com/yahoo/vespa/model/filedistribution/UserConfiguredFilesTest.java index 92fb89a5c4c..b4a54548062 100644 --- a/config-model/src/test/java/com/yahoo/vespa/model/filedistribution/UserConfiguredFilesTest.java +++ b/config-model/src/test/java/com/yahoo/vespa/model/filedistribution/UserConfiguredFilesTest.java @@ -5,12 +5,15 @@ import com.yahoo.config.FileNode; import com.yahoo.config.FileReference; import com.yahoo.config.ModelReference; import com.yahoo.config.UrlReference; +import com.yahoo.config.application.api.ApplicationPackage; +import com.yahoo.config.application.api.DeployLogger; import com.yahoo.config.application.api.FileRegistry; import com.yahoo.config.model.application.provider.BaseDeployLogger; import com.yahoo.config.model.deploy.TestProperties; import com.yahoo.config.model.producer.UserConfigRepo; import com.yahoo.config.model.test.MockApplicationPackage; import com.yahoo.config.model.test.MockRoot; +import com.yahoo.schema.processing.ReservedRankingExpressionFunctionNamesTestCase; import com.yahoo.vespa.config.ConfigDefinition; import com.yahoo.vespa.config.ConfigDefinitionKey; import com.yahoo.vespa.config.ConfigPayloadBuilder; @@ -27,6 +30,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.logging.Level; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -72,22 +76,19 @@ public class UserConfiguredFilesTest { } private UserConfiguredFiles userConfiguredFiles() { - return new UserConfiguredFiles(fileRegistry, - new BaseDeployLogger(), - new TestProperties(), - new ApplicationContainerCluster.UserConfiguredUrls(), - new MockApplicationPackage.Builder().build()); + return userConfiguredFiles(new MockApplicationPackage.Builder().build()); + } + + private UserConfiguredFiles userConfiguredFiles(ApplicationPackage applicationPackage) { + return userConfiguredFiles(applicationPackage, new BaseDeployLogger()); } - private UserConfiguredFiles userConfiguredFiles(File root, com.yahoo.path.Path path) { + private UserConfiguredFiles userConfiguredFiles(ApplicationPackage applicationPackage, DeployLogger deployLogger) { return new UserConfiguredFiles(fileRegistry, - new BaseDeployLogger(), + deployLogger, new TestProperties(), new ApplicationContainerCluster.UserConfiguredUrls(), - new MockApplicationPackage.Builder() - .withRoot(root) - .withFiles(Map.of(path, "")) - .build()); + applicationPackage); } @BeforeEach @@ -304,17 +305,18 @@ public class UserConfiguredFilesTest { @Test void require_that_using_empty_dir_fails(@TempDir Path tempDir) { String relativeTempDir = tempDir.toString().substring(tempDir.toString().lastIndexOf("target") + 7); - try { - def.addPathDef("pathVal"); - builder.setField("pathVal", relativeTempDir); - fileRegistry.pathToRef.put(relativeTempDir, new FileReference("bazshash")); - userConfiguredFiles(tempDir.toFile().getParentFile(), - com.yahoo.path.Path.fromString(tempDir.toFile().getAbsolutePath())).register(producer); - fail("Should have thrown exception"); - } catch (IllegalArgumentException e) { - assertEquals("Invalid config in services.xml for 'mynamespace.myname': Directory '" + relativeTempDir + "' is empty", - e.getMessage()); - } + ApplicationPackage applicationPackage = + new MockApplicationPackage.Builder() + .withRoot(tempDir.toFile().getParentFile()) + .withFiles(Map.of(com.yahoo.path.Path.fromString(tempDir.toFile().getAbsolutePath()), "")) + .build(); + + var logger = new TestDeployLogger(); + def.addPathDef("pathVal"); + builder.setField("pathVal", relativeTempDir); + fileRegistry.pathToRef.put(relativeTempDir, new FileReference("bazshash")); + userConfiguredFiles(applicationPackage, logger).register(producer); + assertEquals("Directory '" + relativeTempDir + "' is empty", logger.log); } @Test @@ -331,4 +333,12 @@ public class UserConfiguredFilesTest { } } + private static class TestDeployLogger implements DeployLogger { + public String log = ""; + @Override + public void log(Level level, String message) { + log += message; + } + } + } diff --git a/config-provisioning/src/main/java/com/yahoo/config/provision/ClusterSpec.java b/config-provisioning/src/main/java/com/yahoo/config/provision/ClusterSpec.java index ed0f9aac884..d0b4ad9e917 100644 --- a/config-provisioning/src/main/java/com/yahoo/config/provision/ClusterSpec.java +++ b/config-provisioning/src/main/java/com/yahoo/config/provision/ClusterSpec.java @@ -2,8 +2,6 @@ package com.yahoo.config.provision; import com.yahoo.component.Version; -import com.yahoo.config.provision.ZoneEndpoint.AccessType; -import com.yahoo.config.provision.ZoneEndpoint.AllowedUrn; import java.util.Objects; import java.util.Optional; @@ -79,6 +77,7 @@ public final class ClusterSpec { return combinedId; } + /** * Returns whether the physical hosts running the nodes of this application can * also run nodes of other applications. Using exclusive nodes for containers increases security and cost. @@ -96,12 +95,6 @@ public final class ClusterSpec { return new ClusterSpec(type, id, groupId, vespaVersion, exclusive, combinedId, dockerImageRepo, zoneEndpoint, stateful); } - // TODO: Remove after July 2023 - @Deprecated - public ClusterSpec exclusive(boolean exclusive) { - return new ClusterSpec(type, id, groupId, vespaVersion, exclusive, combinedId, dockerImageRepo, zoneEndpoint, stateful); - } - /** Creates a ClusterSpec when requesting a cluster */ public static Builder request(Type type, Id id) { return new Builder(type, id); @@ -121,6 +114,7 @@ public final class ClusterSpec { private Optional<DockerImage> dockerImageRepo = Optional.empty(); private Version vespaVersion; private boolean exclusive = false; + private boolean provisionForApplication = false; private Optional<Id> combinedId = Optional.empty(); private ZoneEndpoint zoneEndpoint = ZoneEndpoint.defaultEndpoint; private boolean stateful; @@ -155,6 +149,11 @@ public final class ClusterSpec { return this; } + public Builder provisionForApplication(boolean provisionForApplication) { + this.provisionForApplication = provisionForApplication; + return this; + } + public Builder combinedId(Optional<Id> combinedId) { this.combinedId = combinedId; return this; diff --git a/configserver/src/main/java/com/yahoo/vespa/config/server/ApplicationRepository.java b/configserver/src/main/java/com/yahoo/vespa/config/server/ApplicationRepository.java index 2680b4babb1..3de9d5aef4b 100644 --- a/configserver/src/main/java/com/yahoo/vespa/config/server/ApplicationRepository.java +++ b/configserver/src/main/java/com/yahoo/vespa/config/server/ApplicationRepository.java @@ -660,11 +660,14 @@ public class ApplicationRepository implements com.yahoo.config.provision.Deploye log.log(Level.FINE, () -> "Remove unused file references last modified before " + instant); List<String> fileReferencesToDelete = sortedUnusedFileReferences(fileDirectory.getRoot(), fileReferencesInUse, instant); - if (fileReferencesToDelete.size() > 0) { - log.log(Level.FINE, () -> "Will delete file references not in use: " + fileReferencesToDelete); - fileReferencesToDelete.forEach(fileReference -> fileDirectory.delete(new FileReference(fileReference), this::isFileReferenceInUse)); + // Do max 20 at a time + var toDelete = fileReferencesToDelete.subList(0, Math.min(fileReferencesToDelete.size(), 20)); + if (toDelete.size() > 0) { + log.log(Level.FINE, () -> "Will delete file references not in use: " + toDelete); + toDelete.forEach(fileReference -> fileDirectory.delete(new FileReference(fileReference), this::isFileReferenceInUse)); + log.log(Level.FINE, () -> "Deleted " + toDelete.size() + " file references not in use"); } - return fileReferencesToDelete; + return toDelete; } private boolean isFileReferenceInUse(FileReference fileReference) { diff --git a/container-disc/src/main/java/com/yahoo/container/jdisc/DataplaneProxyService.java b/container-disc/src/main/java/com/yahoo/container/jdisc/DataplaneProxyService.java index 4c5ac951384..d94244b0e47 100644 --- a/container-disc/src/main/java/com/yahoo/container/jdisc/DataplaneProxyService.java +++ b/container-disc/src/main/java/com/yahoo/container/jdisc/DataplaneProxyService.java @@ -109,6 +109,10 @@ public class DataplaneProxyService extends AbstractComponent { config.tokenPort(), config.tokenEndpoints(), root)); + if (configChanged) { + logger.log(Level.INFO, "Configuring data plane proxy service. Token endpoints: [%s]" + .formatted(String.join(", ", config.tokenEndpoints()))); + } if (configChanged && state == NginxState.RUNNING) { changeState(NginxState.RELOAD_REQUIRED); } diff --git a/container-search/src/main/java/com/yahoo/search/ranking/GlobalPhaseRanker.java b/container-search/src/main/java/com/yahoo/search/ranking/GlobalPhaseRanker.java index bb5a991c304..829d0c268e5 100644 --- a/container-search/src/main/java/com/yahoo/search/ranking/GlobalPhaseRanker.java +++ b/container-search/src/main/java/com/yahoo/search/ranking/GlobalPhaseRanker.java @@ -50,9 +50,7 @@ public class GlobalPhaseRanker { return Optional.empty(); } - public void rerankHits(Query query, Result result, String schema) { - var setup = globalPhaseSetupFor(query, schema).orElse(null); - if (setup == null) return; + static void rerankHitsImpl(GlobalPhaseSetup setup, Query query, Result result) { var mainSpec = setup.globalPhaseEvalSpec; var mainSrc = withQueryPrep(mainSpec.evalSource(), mainSpec.fromQuery(), query); int rerankCount = resolveRerankCount(setup, query); @@ -68,6 +66,13 @@ public class GlobalPhaseRanker { hideImplicitMatchFeatures(result, setup.matchFeaturesToHide); } + public void rerankHits(Query query, Result result, String schema) { + var setup = globalPhaseSetupFor(query, schema); + if (setup.isPresent()) { + rerankHitsImpl(setup.get(), query, result); + } + } + static Supplier<Evaluator> withQueryPrep(Supplier<Evaluator> evalSource, List<String> queryFeatures, Query query) { var prepared = PreparedInput.findFromQuery(query, queryFeatures); Supplier<Evaluator> supplier = () -> { @@ -80,7 +85,7 @@ public class GlobalPhaseRanker { return supplier; } - private void hideImplicitMatchFeatures(Result result, Collection<String> namesToHide) { + private static void hideImplicitMatchFeatures(Result result, Collection<String> namesToHide) { if (namesToHide.size() == 0) return; var filter = new MatchFeatureFilter(namesToHide); for (var iterator = result.hits().deepIterator(); iterator.hasNext();) { @@ -94,7 +99,7 @@ public class GlobalPhaseRanker { if (newValue.fieldCount() == 0) { hit.removeField("matchfeatures"); } else { - hit.setField("matchfeatures", newValue); + hit.setField("matchfeatures", new FeatureData(newValue)); } } } @@ -106,7 +111,7 @@ public class GlobalPhaseRanker { .flatMap(evaluator -> evaluator.getGlobalPhaseSetup(query.getRanking().getProfile())); } - private int resolveRerankCount(GlobalPhaseSetup setup, Query query) { + private static int resolveRerankCount(GlobalPhaseSetup setup, Query query) { if (setup == null) { // there is no global-phase at all (ignore override) return 0; diff --git a/container-search/src/main/java/com/yahoo/search/ranking/PreparedInput.java b/container-search/src/main/java/com/yahoo/search/ranking/PreparedInput.java index 5ab2d7160f9..346acccd916 100644 --- a/container-search/src/main/java/com/yahoo/search/ranking/PreparedInput.java +++ b/container-search/src/main/java/com/yahoo/search/ranking/PreparedInput.java @@ -30,7 +30,7 @@ record PreparedInput(String name, Tensor value) { for (String queryFeatureName : queryFeatures) { String needed = "query(" + queryFeatureName + ")"; // searchers are recommended to place query features here: - var feature = rankFeatures.getTensor(queryFeatureName); + var feature = rankFeatures.getTensor(needed); if (feature.isPresent()) { result.add(new PreparedInput(needed, feature.get())); } else { @@ -38,6 +38,8 @@ record PreparedInput(String name, Tensor value) { var objList = rankProps.get(queryFeatureName); if (objList != null && objList.size() == 1 && objList.get(0) instanceof Tensor t) { result.add(new PreparedInput(needed, t)); + } else if (objList != null && objList.size() == 1 && objList.get(0) instanceof Double d) { + result.add(new PreparedInput(needed, Tensor.from(d))); } else { throw new IllegalArgumentException("missing query feature: " + queryFeatureName); } diff --git a/container-search/src/test/java/com/yahoo/search/ranking/GlobalPhaseRerankHitsImplTest.java b/container-search/src/test/java/com/yahoo/search/ranking/GlobalPhaseRerankHitsImplTest.java new file mode 100644 index 00000000000..ce9ac377908 --- /dev/null +++ b/container-search/src/test/java/com/yahoo/search/ranking/GlobalPhaseRerankHitsImplTest.java @@ -0,0 +1,238 @@ +// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.ranking; + +import com.yahoo.data.access.Inspectable; +import com.yahoo.data.access.Type; +import com.yahoo.data.access.helpers.MatchFeatureData; +import com.yahoo.data.access.simple.Value; +import com.yahoo.search.Query; +import com.yahoo.search.Result; +import com.yahoo.search.result.FeatureData; +import com.yahoo.search.result.Hit; +import com.yahoo.tensor.Tensor; +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.*; + +public class GlobalPhaseRerankHitsImplTest { + static class EvalSum implements Evaluator { + double baseValue; + List<Tensor> values = new ArrayList<>(); + EvalSum(double baseValue) { this.baseValue = baseValue; } + @Override public Evaluator bind(String name, Tensor value) { + values.add(value); + return this; + } + @Override public double evaluateScore() { + double result = baseValue; + for (var value: values) { + result += value.asDouble(); + } + return result; + } + } + static FunEvalSpec makeConstSpec(double constValue) { + return new FunEvalSpec(() -> new EvalSum(constValue), Collections.emptyList(), Collections.emptyList()); + } + static FunEvalSpec makeSumSpec(List<String> fromQuery, List<String> fromMF) { + return new FunEvalSpec(() -> new EvalSum(0.0), fromQuery, fromMF); + } + static class ExpectingNormalizer extends Normalizer { + List<Double> expected; + ExpectingNormalizer(List<Double> expected) { + super(100); + this.expected = expected; + } + @Override void normalize() { + double rank = 1; + assertEquals(size, expected.size()); + for (int i = 0; i < size; i++) { + assertEquals(data[i], expected.get(i)); + data[i] = rank; + rank += 1; + } + } + @Override String normalizing() { return "expecting"; } + } + static NormalizerSetup makeNormalizer(String name, List<Double> expected, FunEvalSpec evalSpec) { + return new NormalizerSetup(name, () -> new ExpectingNormalizer(expected), evalSpec); + } + static GlobalPhaseSetup makeFullSetup(FunEvalSpec mainSpec, int rerankCount, + List<String> hiddenMF, List<NormalizerSetup> normalizers) + { + return new GlobalPhaseSetup(mainSpec, rerankCount, hiddenMF, normalizers); + } + static GlobalPhaseSetup makeSimpleSetup(FunEvalSpec mainSpec, int rerankCount) { + return makeFullSetup(mainSpec, rerankCount, Collections.emptyList(), Collections.emptyList()); + } + static GlobalPhaseSetup makeNormSetup(FunEvalSpec mainSpec, List<NormalizerSetup> normalizers) { + return makeFullSetup(mainSpec, 100, Collections.emptyList(), normalizers); + } + static record NamedValue(String name, double value) {} + NamedValue value(String name, double value) { + return new NamedValue(name, value); + } + Query makeQuery(List<NamedValue> inQuery, boolean withPrepare) { + var query = new Query(); + for (var v: inQuery) { + query.getRanking().getFeatures().put(v.name, v.value); + } + if (withPrepare) { + query.getRanking().prepare(); + } + return query; + } + Query makeQuery(List<NamedValue> inQuery) { return makeQuery(inQuery, false); } + Query makeQueryWithPrepare(List<NamedValue> inQuery) { return makeQuery(inQuery, true); } + + static Hit makeHit(String id, double score, FeatureData mf) { + Hit hit = new Hit(id, score); + hit.setField("matchfeatures", mf); + return hit; + } + static Hit hit(String id, double score) { + return makeHit(id, score, FeatureData.empty()); + } + static class HitFactory { + MatchFeatureData mfData; + Map<String,Integer> map = new HashMap<>(); + HitFactory(List<String> mfNames) { + int i = 0; + for (var name: mfNames) { + map.put(name, i++); + } + mfData = new MatchFeatureData(mfNames); + } + Hit create(String id, double score, List<NamedValue> inMF) { + var mf = mfData.addHit(); + for (var v: inMF) { + var idx = map.get(v.name); + assertNotNull(idx); + mf.set(idx, v.value); + } + return makeHit(id, score, new FeatureData(mf)); + } + } + Result makeResult(Query query, List<Hit> hits) { + var result = new Result(query); + result.hits().addAll(hits); + return result; + } + static class Expect { + Map<String,Double> map = new HashMap<>(); + static Expect make(List<Hit> hits) { + var result = new Expect(); + for (var hit : hits) { + result.map.put(hit.getId().stringValue(), hit.getRelevance().getScore()); + } + return result; + } + void verifyScores(Result actual) { + double prev = Double.MAX_VALUE; + assertEquals(actual.hits().size(), map.size()); + for (var hit : actual.hits()) { + var name = hit.getId().stringValue(); + var score = map.get(name); + assertNotNull(score, name); + assertEquals(score.doubleValue(), hit.getRelevance().getScore(), name); + assertTrue(score <= prev); + prev = score; + } + } + } + void verifyHasMF(Result result, String name) { + for (var hit: result.hits()) { + if (hit.getField("matchfeatures") instanceof FeatureData mf) { + assertNotNull(mf.getTensor(name)); + } else { + fail("matchfeatures are missing"); + } + } + } + void verifyDoesNotHaveMF(Result result, String name) { + for (var hit: result.hits()) { + if (hit.getField("matchfeatures") instanceof FeatureData mf) { + assertNull(mf.getTensor(name)); + } else { + fail("matchfeatures are missing"); + } + } + } + void verifyDoesNotHaveMatchFeaturesField(Result result) { + for (var hit: result.hits()) { + assertNull(hit.getField("matchfeatures")); + } + } + @Test void partialRerankWithRescaling() { + var setup = makeSimpleSetup(makeConstSpec(3.0), 2); + var query = makeQuery(Collections.emptyList()); + var result = makeResult(query, List.of(hit("a", 3), hit("b", 4), hit("c", 5), hit("d", 6))); + var expect = Expect.make(List.of(hit("a", 1), hit("b", 2), hit("c", 3), hit("d", 3))); + GlobalPhaseRanker.rerankHitsImpl(setup, query, result); + expect.verifyScores(result); + } + @Test void matchFeaturesCanBePartiallyHidden() { + var setup = makeFullSetup(makeSumSpec(Collections.emptyList(), List.of("public_value", "private_value")), 2, + List.of("private_value"), Collections.emptyList()); + var query = makeQuery(Collections.emptyList()); + var factory = new HitFactory(List.of("public_value", "private_value")); + var result = makeResult(query, List.of(factory.create("a", 1, List.of(value("public_value", 2), value("private_value", 3))), + factory.create("b", 2, List.of(value("public_value", 5), value("private_value", 7))))); + var expect = Expect.make(List.of(hit("a", 5), hit("b", 12))); + GlobalPhaseRanker.rerankHitsImpl(setup, query, result); + expect.verifyScores(result); + verifyHasMF(result, "public_value"); + verifyDoesNotHaveMF(result, "private_value"); + } + @Test void matchFeaturesCanBeRemoved() { + var setup = makeFullSetup(makeSumSpec(Collections.emptyList(), List.of("private_value")), 2, + List.of("private_value"), Collections.emptyList()); + var query = makeQuery(Collections.emptyList()); + var factory = new HitFactory(List.of("private_value")); + var result = makeResult(query, List.of(factory.create("a", 1, List.of(value("private_value", 3))), + factory.create("b", 2, List.of(value("private_value", 7))))); + var expect = Expect.make(List.of(hit("a", 3), hit("b", 7))); + GlobalPhaseRanker.rerankHitsImpl(setup, query, result); + expect.verifyScores(result); + verifyDoesNotHaveMatchFeaturesField(result); + } + @Test void queryFeaturesCanBeUsed() { + var setup = makeSimpleSetup(makeSumSpec(List.of("foo"), List.of("bar")), 2); + var query = makeQuery(List.of(value("query(foo)", 7))); + var factory = new HitFactory(List.of("bar")); + var result = makeResult(query, List.of(factory.create("a", 1, List.of(value("bar", 2))), + factory.create("b", 2, List.of(value("bar", 5))))); + var expect = Expect.make(List.of(hit("a", 9), hit("b", 12))); + GlobalPhaseRanker.rerankHitsImpl(setup, query, result); + expect.verifyScores(result); + verifyHasMF(result, "bar"); + } + @Test void queryFeaturesCanBeUsedWhenPrepared() { + var setup = makeSimpleSetup(makeSumSpec(List.of("foo"), List.of("bar")), 2); + var query = makeQueryWithPrepare(List.of(value("query(foo)", 7))); + var factory = new HitFactory(List.of("bar")); + var result = makeResult(query, List.of(factory.create("a", 1, List.of(value("bar", 2))), + factory.create("b", 2, List.of(value("bar", 5))))); + var expect = Expect.make(List.of(hit("a", 9), hit("b", 12))); + GlobalPhaseRanker.rerankHitsImpl(setup, query, result); + expect.verifyScores(result); + verifyHasMF(result, "bar"); + } + @Test void withNormalizer() { + var setup = makeNormSetup(makeSumSpec(Collections.emptyList(), List.of("bar")), + List.of(makeNormalizer("foo", List.of(115.0, 65.0, 55.0, 45.0, 15.0), makeSumSpec(List.of("x"), List.of("bar"))))); + var query = makeQuery(List.of(value("query(x)", 5))); + var factory = new HitFactory(List.of("bar")); + var result = makeResult(query, List.of(factory.create("a", 1, List.of(value("bar", 10))), + factory.create("b", 2, List.of(value("bar", 40))), + factory.create("c", 3, List.of(value("bar", 50))), + factory.create("d", 4, List.of(value("bar", 60))), + factory.create("e", 5, List.of(value("bar", 110))))); + var expect = Expect.make(List.of(hit("a", 15), hit("b", 44), hit("c", 53), hit("d", 62), hit("e", 111))); + GlobalPhaseRanker.rerankHitsImpl(setup, query, result); + expect.verifyScores(result); + } +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java index ee0df3adbfb..b451df87727 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java @@ -13,7 +13,6 @@ import java.util.List; import static com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingInfo.SupportLevel.BASIC; import static java.math.BigDecimal.ZERO; -import static java.math.BigDecimal.valueOf; public class MockPricingController implements PricingController { @@ -23,34 +22,35 @@ public class MockPricingController implements PricingController { @Override public Prices priceForApplications(List<ApplicationResources> applicationResources, PricingInfo pricingInfo, Plan plan) { - ApplicationResources resources = applicationResources.get(0); - - BigDecimal listPrice = resources.vcpu().multiply(cpuCost) - .add(resources.memoryGb().multiply(memoryCost) - .add(resources.diskGb().multiply(diskCost)) - .add(resources.enclaveVcpu().multiply(cpuCost) - .add(resources.enclaveMemoryGb().multiply(memoryCost)) - .add(resources.enclaveDiskGb().multiply(diskCost)))); + List<PriceInformation> appPrices = applicationResources.stream() + .map(resources -> { + BigDecimal listPrice = resources.vcpu().multiply(cpuCost) + .add(resources.memoryGb().multiply(memoryCost)) + .add(resources.diskGb().multiply(diskCost)) + .add(resources.enclaveVcpu().multiply(cpuCost)) + .add(resources.enclaveMemoryGb().multiply(memoryCost)) + .add(resources.enclaveDiskGb().multiply(diskCost)); - BigDecimal supportLevelCost = pricingInfo.supportLevel() == BASIC ? new BigDecimal("-1.00") : new BigDecimal("8.00"); - BigDecimal listPriceWithSupport = listPrice.add(supportLevelCost); - BigDecimal enclaveDiscount = isEnclave(resources) ? new BigDecimal("-0.15") : BigDecimal.ZERO; - BigDecimal volumeDiscount = new BigDecimal("-0.1"); - BigDecimal appTotalAmount = listPrice.add(supportLevelCost).add(enclaveDiscount).add(volumeDiscount); + BigDecimal supportLevelCost = pricingInfo.supportLevel() == BASIC ? new BigDecimal("-1.00") : new BigDecimal("8.00"); + BigDecimal listPriceWithSupport = listPrice.add(supportLevelCost); + BigDecimal enclaveDiscount = isEnclave(resources) ? new BigDecimal("-0.15") : BigDecimal.ZERO; + BigDecimal volumeDiscount = new BigDecimal("-0.1"); + BigDecimal appTotalAmount = listPrice.add(supportLevelCost).add(enclaveDiscount).add(volumeDiscount); - List<PriceInformation> appPrices = applicationResources.stream() - .map(appResources -> new PriceInformation(listPriceWithSupport, - volumeDiscount, - ZERO, - enclaveDiscount, - appTotalAmount)) + return new PriceInformation(listPriceWithSupport, + volumeDiscount, + ZERO, + enclaveDiscount, + appTotalAmount); + }) .toList(); PriceInformation sum = PriceInformation.sum(appPrices); - var committedAmountDiscount = new BigDecimal("-0.2"); + System.out.println(pricingInfo.committedHourlyAmount()); + var committedAmountDiscount = pricingInfo.committedHourlyAmount().compareTo(ZERO) > 0 ? new BigDecimal("-0.2") : ZERO; var totalAmount = sum.totalAmount().add(committedAmountDiscount); var enclave = ZERO; - if (resources.enclave() && totalAmount.compareTo(new BigDecimal("14.00")) < 0) + if (applicationResources.stream().anyMatch(ApplicationResources::enclave) && totalAmount.compareTo(new BigDecimal("14.00")) < 0) enclave = new BigDecimal("14.00").subtract(totalAmount); var totalPrice = new PriceInformation(ZERO, ZERO, committedAmountDiscount, enclave, totalAmount); diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java index 4a61ff30c25..9ceeba32061 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java @@ -51,11 +51,16 @@ public class CloudTenant extends Tenant { /** Creates a tenant with the given name, provided it passes validation. */ public static CloudTenant create(TenantName tenantName, Instant createdAt, Principal creator) { + // Initialize with creator as verified contact + var info = TenantInfo.empty().withContacts(new TenantContacts(List.of( + new TenantContacts.EmailContact( + List.of(TenantContacts.Audience.TENANT, TenantContacts.Audience.NOTIFICATIONS), + new Email(creator.getName(), true))))); return new CloudTenant(requireName(tenantName), createdAt, LastLoginInfo.EMPTY, Optional.ofNullable(creator).map(SimplePrincipal::of), - ImmutableBiMap.of(), TenantInfo.empty(), List.of(), new ArchiveAccess(), Optional.empty(), + ImmutableBiMap.of(), info, List.of(), new ArchiveAccess(), Optional.empty(), Instant.EPOCH, List.of(), Optional.empty(), PlanId.from("none")); } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/RoutingController.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/RoutingController.java index f9798fb2559..f11d67762ad 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/RoutingController.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/RoutingController.java @@ -64,6 +64,8 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.TreeMap; +import java.util.logging.Level; +import java.util.logging.Logger; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -79,6 +81,8 @@ import static java.util.stream.Collectors.toMap; */ public class RoutingController { + private static final Logger LOG = Logger.getLogger(RoutingController.class.getName()); + private final Controller controller; private final RoutingPolicies routingPolicies; private final RotationRepository rotationRepository; @@ -213,6 +217,8 @@ public class RoutingController { // Register rotation-backed endpoints in DNS registerRotationEndpointsInDns(prepared); + LOG.log(Level.FINE, () -> "Prepared endpoints: " + prepared); + return prepared; } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/AssignedRotation.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/AssignedRotation.java index 7fa2a03d0a9..c2949e395e9 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/AssignedRotation.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/AssignedRotation.java @@ -23,16 +23,6 @@ public record AssignedRotation(ClusterSpec.Id clusterId, EndpointId endpointId, this.regions = Set.copyOf(Objects.requireNonNull(regions)); } - @Override - public String toString() { - return "AssignedRotation{" + - "clusterId=" + clusterId + - ", endpointId='" + endpointId + '\'' + - ", rotationId=" + rotationId + - ", regions=" + regions + - '}'; - } - private static <T> T requireNonEmpty(T object, String value, String field) { Objects.requireNonNull(object); Objects.requireNonNull(value); diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirer.java index fcb64b4c00f..d46f59f36a7 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirer.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirer.java @@ -36,7 +36,6 @@ import static com.yahoo.vespa.hosted.controller.persistence.TrialNotifications.S /** * Expires unused tenants from Vespa Cloud. - * <p> * * @author ogronnesby */ @@ -165,6 +164,8 @@ public class CloudTrialExpirer extends ControllerMaintainer { .subject(emailSubject) .with("mailMessageTemplate", "cloud-trial-notification") .with("cloudTrialMessage", emailMsg) + .with("mailTitle", emailSubject) + .with("consoleLink", controller().zoneRegistry().dashboardUrl(tenant.name()).toString()) .build()); var source = NotificationSource.from(tenant.name()); // Remove previous notification to ensure new notification is sent by email diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notification.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notification.java index 40c24c6f339..5116ecaf053 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notification.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notification.java @@ -2,11 +2,15 @@ package com.yahoo.vespa.hosted.controller.notification; import java.time.Instant; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.SortedMap; +import java.util.TreeMap; /** * Represents an event that we want to notify the tenant about. The message(s) should be short @@ -78,34 +82,57 @@ public record Notification(Instant at, Notification.Type type, Notification.Leve public static class MailContent { private final String template; - private final Map<String, Object> values; + private final SortedMap<String, Object> values; private final String subject; private MailContent(Builder b) { template = Objects.requireNonNull(b.template); - values = Map.copyOf(b.values); + values = new TreeMap<>(b.values); subject = b.subject; } public String template() { return template; } - public Map<String, Object> values() { return Map.copyOf(values); } + public SortedMap<String, Object> values() { return Collections.unmodifiableSortedMap(values); } public Optional<String> subject() { return Optional.ofNullable(subject); } public static Builder fromTemplate(String template) { return new Builder(template); } public static class Builder { private final String template; - private final HashMap<String, Object> values = new HashMap<>(); + private final Map<String, Object> values = new HashMap<>(); private String subject; private Builder(String template) { this.template = template; } - public Builder with(String name, Object value) { values.put(name, value); return this; } + public Builder with(String name, String value) { values.put(name, value); return this; } + public Builder with(String name, Collection<String> items) { values.put(name, List.copyOf(items)); return this; } public Builder subject(String s) { this.subject = s; return this; } public MailContent build() { return new MailContent(this); } } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MailContent that = (MailContent) o; + return Objects.equals(template, that.template) && Objects.equals(values, that.values) && Objects.equals(subject, that.subject); + } + + @Override + public int hashCode() { + return Objects.hash(template, values, subject); + } + + @Override + public String toString() { + return "MailContent{" + + "template='" + template + '\'' + + ", values=" + values + + ", subject='" + subject + '\'' + + '}'; + } } } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDb.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDb.java index e752e13eddd..a5d26feafaa 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDb.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDb.java @@ -71,7 +71,6 @@ public class NotificationsDb { /** * Add a notification with given source and type. If a notification with same source and type * already exists, it'll be replaced by this one instead. - * Email content is not persisted here. The email dispatcher is responsible for reliable delivery. */ public void setNotification(NotificationSource source, Type type, Level level, List<String> messages, Optional<MailContent> mailContent) { diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializer.java index 7915a833be6..62b35d4cfd4 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializer.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializer.java @@ -8,6 +8,7 @@ import com.yahoo.config.provision.TenantName; import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.slime.Cursor; import com.yahoo.slime.Inspector; +import com.yahoo.slime.ObjectTraverser; import com.yahoo.slime.Slime; import com.yahoo.slime.SlimeUtils; import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType; @@ -15,6 +16,7 @@ import com.yahoo.vespa.hosted.controller.notification.Notification; import com.yahoo.vespa.hosted.controller.notification.NotificationSource; import java.util.List; +import java.util.Optional; /** * (de)serializes notifications for a tenant @@ -60,6 +62,22 @@ public class NotificationsSerializer { notification.source().clusterId().ifPresent(clusterId -> notificationObject.setString(clusterIdField, clusterId.value())); notification.source().jobType().ifPresent(jobType -> notificationObject.setString(jobTypeField, jobType.serialized())); notification.source().runNumber().ifPresent(runNumber -> notificationObject.setLong(runNumberField, runNumber)); + + notification.mailContent().ifPresent(mc -> { + notificationObject.setString("mail-template", mc.template()); + mc.subject().ifPresent(s -> notificationObject.setString("mail-subject", s)); + var mailParamsCursor = notificationObject.setObject("mail-params"); + mc.values().forEach((key, value) -> { + if (value instanceof String str) { + mailParamsCursor.setString(key, str); + } else if (value instanceof List<?> l) { + var array = mailParamsCursor.setArray(key); + l.forEach(elem -> array.addString((String) elem)); + } else { + throw new ClassCastException("Unsupported param type: " + value.getClass()); + } + }); + }); } return slime; @@ -92,7 +110,24 @@ public class NotificationsSerializer { SlimeUtils.optionalString(inspector.field(clusterIdField)).map(ClusterSpec.Id::from), SlimeUtils.optionalString(inspector.field(jobTypeField)).map(jobName -> JobType.ofSerialized(jobName)), SlimeUtils.optionalLong(inspector.field(runNumberField))), - SlimeUtils.entriesStream(inspector.field(messagesField)).map(Inspector::asString).toList()); + SlimeUtils.entriesStream(inspector.field(messagesField)).map(Inspector::asString).toList(), + mailContentFrom(inspector)); + } + + private Optional<Notification.MailContent> mailContentFrom(final Inspector inspector) { + return SlimeUtils.optionalString(inspector.field("mail-template")).map(template -> { + var builder = Notification.MailContent.fromTemplate(template); + SlimeUtils.optionalString(inspector.field("mail-subject")).ifPresent(builder::subject); + var paramsCursor = inspector.field("mail-params"); + inspector.field("mail-params").traverse((ObjectTraverser) (name, insp) -> { + switch (insp.type()) { + case STRING -> builder.with(name, insp.asString()); + case ARRAY -> builder.with(name, SlimeUtils.entriesStream(insp).map(Inspector::asString).toList()); + default -> throw new IllegalArgumentException("Unsupported param type: " + insp.type()); + } + }); + return builder.build(); + }); } private static String asString(Notification.Type type) { diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java index d7fb09cdf73..fdde87074e9 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java @@ -705,7 +705,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { var contact = info.billingContact().contact(); var address = info.billingContact().address(); - var mergedContact = updateTenantInfoContact(inspector.field("contact"), cloudTenant.name(), contact, false); + var mergedContact = updateBillingContact(inspector.field("contact"), cloudTenant.name(), contact); var mergedAddress = updateTenantInfoAddress(inspector.field("address"), info.billingContact().address()); var mergedBilling = info.billingContact() @@ -893,15 +893,13 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { throw new IllegalArgumentException("All address fields must be set"); } - private TenantContact updateTenantInfoContact(Inspector insp, TenantName tenantName, TenantContact oldContact, boolean isBillingContact) { + private TenantContact updateBillingContact(Inspector insp, TenantName tenantName, TenantContact oldContact) { if (!insp.valid()) return oldContact; var mergedEmail = optional("email", insp) .filter(address -> !address.equals(oldContact.email().getEmailAddress())) .map(address -> { - var mailType = isBillingContact ? PendingMailVerification.MailType.BILLING : - PendingMailVerification.MailType.TENANT_CONTACT; - controller.mailVerifier().sendMailVerification(tenantName, address, mailType); + controller.mailVerifier().sendMailVerification(tenantName, address, PendingMailVerification.MailType.BILLING); return new Email(address, false); }) .orElse(oldContact.email()); @@ -916,7 +914,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { if (!insp.valid()) return oldContact; return TenantBilling.empty() - .withContact(updateTenantInfoContact(insp, tenantName, oldContact.contact(), true)) + .withContact(updateBillingContact(insp, tenantName, oldContact.contact())) .withAddress(updateTenantInfoAddress(insp.field("address"), oldContact.address())); } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java index 718320c02ca..83cd5dab2f3 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java @@ -4,6 +4,7 @@ package com.yahoo.vespa.hosted.controller.restapi.billing; import com.yahoo.config.provision.TenantName; import com.yahoo.container.jdisc.HttpResponse; import com.yahoo.container.jdisc.ThreadedHttpRequestHandler; +import com.yahoo.messagebus.Message; import com.yahoo.restapi.MessageResponse; import com.yahoo.restapi.RestApi; import com.yahoo.restapi.RestApiException; @@ -98,6 +99,9 @@ public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandler .post(Slime.class, self::newAdditionalItem)) .addRoute(RestApi.route("/billing/v2/accountant/tenant/{tenant}/item/{item}") .delete(self::deleteAdditionalItem)) + .addRoute(RestApi.route("/billing/v2/accountant/tenant/{tenant}/plan") + .get(self::accountantTenantPlan) + .post(Slime.class, self::setAccountantTenantPlan)) .addRoute(RestApi.route("/billing/v2/accountant/bill/{invoice}/export") .put(Slime.class, self::putAccountantInvoiceExport)) .addRoute(RestApi.route("/billing/v2/accountant/plans") @@ -361,6 +365,39 @@ public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandler return slime; } + private MessageResponse setAccountantTenantPlan(RestApi.RequestContext requestContext, Slime body) { + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + + var planId = PlanId.from(getInspectorFieldOrThrow(body.get(), "id")); + var response = billing.setPlan(tenant.name(), planId, false, true); + + if (response.isSuccess()) { + return new MessageResponse("Plan: " + planId.value()); + } else { + throw new RestApiException.BadRequest("Could not change plan: " + response.getErrorMessage()); + } + } + + private Slime accountantTenantPlan(RestApi.RequestContext requestContext) { + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + + var planId = billing.getPlan(tenant.name()); + var plan = planRegistry.plan(planId); + + if (plan.isEmpty()) { + throw new RestApiException.BadRequest("Plan with ID '" + planId.value() + "' does not exist"); + } + + var slime = new Slime(); + var root = slime.setObject(); + root.setString("id", plan.get().id().value()); + root.setString("name", plan.get().displayName()); + + return slime; + } + // --------- INVOICE RENDERING ---------- private void invoicesSummaryToSlime(Cursor slime, List<Bill> bills) { diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandler.java index 0a43ec599d5..9a2a57359d7 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandler.java @@ -4,7 +4,6 @@ package com.yahoo.vespa.hosted.controller.restapi.pricing; import com.yahoo.collections.Pair; import com.yahoo.component.annotation.Inject; import com.yahoo.config.provision.ClusterResources; -import com.yahoo.config.provision.NodeResources; import com.yahoo.container.jdisc.HttpRequest; import com.yahoo.container.jdisc.HttpResponse; import com.yahoo.container.jdisc.ThreadedHttpRequestHandler; @@ -35,8 +34,6 @@ import java.util.logging.Logger; import static com.yahoo.jdisc.http.HttpRequest.Method.GET; import static com.yahoo.restapi.ErrorResponse.methodNotAllowed; import static com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingInfo.SupportLevel; -import static java.lang.Double.parseDouble; -import static java.lang.Integer.parseInt; import static java.math.BigDecimal.ZERO; import static java.nio.charset.StandardCharsets.UTF_8; @@ -116,41 +113,13 @@ public class PricingApiHandler extends ThreadedHttpRequestHandler { default -> throw new IllegalArgumentException("Unknown query parameter '" + entry.getFirst() + '\''); } } - if (appResources.isEmpty()) throw new IllegalArgumentException("No application resources found in query"); PricingInfo pricingInfo = new PricingInfo(supportLevel, committedSpend); return new PriceParameters(List.of(), pricingInfo, plan, appResources); } - private ClusterResources clusterResources(String resourcesString) { - List<String> elements = Arrays.stream(resourcesString.split(",")).toList(); - - var nodes = 0; - var vcpu = 0d; - var memoryGb = 0d; - var diskGb = 0d; - var gpuMemoryGb = 0d; - - for (var element : keysAndValues(elements)) { - var value = element.getSecond(); - switch (element.getFirst().toLowerCase()) { - case "nodes" -> nodes = parseInt(value); - case "vcpu" -> vcpu = parseDouble(value); - case "memorygb" -> memoryGb = parseDouble(value); - case "diskgb" -> diskGb = parseDouble(value); - case "gpumemorygb" -> gpuMemoryGb = parseDouble(value); - default -> throw new IllegalArgumentException("Unknown resource type '" + element.getFirst() + '\''); - } - } - - var nodeResources = new NodeResources(vcpu, memoryGb, diskGb, 0); // 0 bandwidth, not used in price calculation - if (gpuMemoryGb > 0) - nodeResources = nodeResources.with(new NodeResources.GpuResources(1, gpuMemoryGb)); - return new ClusterResources(nodes, 1, nodeResources); - } - private ApplicationResources applicationResources(String appResourcesString) { - List<String> elements = Arrays.stream(appResourcesString.split(",")).toList(); + List<String> elements = List.of(appResourcesString.split(",")); var vcpu = ZERO; var memoryGb = ZERO; diff --git a/controller-server/src/main/resources/mail/cloud-trial-notification.vm b/controller-server/src/main/resources/mail/cloud-trial-notification.vm index 27bc9b1ad1b..c1ba394bf8e 100644 --- a/controller-server/src/main/resources/mail/cloud-trial-notification.vm +++ b/controller-server/src/main/resources/mail/cloud-trial-notification.vm @@ -1,3 +1,3 @@ <p> - $esc.html($cloudTrialMessage): + $esc.html($cloudTrialMessage) </p>
\ No newline at end of file diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirerTest.java index b595c8a8be3..7e8237606c6 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirerTest.java @@ -9,6 +9,7 @@ import com.yahoo.vespa.flags.InMemoryFlagSource; import com.yahoo.vespa.flags.PermanentFlags; import com.yahoo.vespa.hosted.controller.ControllerTester; import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanId; +import com.yahoo.vespa.hosted.controller.api.integration.stubs.MockMailer; import com.yahoo.vespa.hosted.controller.deployment.ApplicationPackageBuilder; import com.yahoo.vespa.hosted.controller.deployment.DeploymentTester; import com.yahoo.vespa.hosted.controller.notification.Notification; @@ -17,10 +18,15 @@ import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.Tenant; import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; import java.time.Duration; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -28,6 +34,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; */ public class CloudTrialExpirerTest { + private static final boolean OVERWRITE_TEST_FILES = false; + private final ControllerTester tester = new ControllerTester(SystemName.PublicCd); private final DeploymentTester deploymentTester = new DeploymentTester(tester); private final CloudTrialExpirer expirer = new CloudTrialExpirer(tester.controller(), Duration.ofMinutes(5)); @@ -94,30 +102,50 @@ public class CloudTrialExpirerTest { } @Test - void queues_trial_notification_based_on_account_age() { + void queues_trial_notification_based_on_account_age() throws IOException { var clock = (ManualClock)tester.controller().clock(); + var mailer = (MockMailer) tester.serviceRegistry().mailer(); var tenant = TenantName.from("trial-tenant"); ((InMemoryFlagSource) tester.controller().flagSource()) .withBooleanFlag(Flags.CLOUD_TRIAL_NOTIFICATIONS.id(), true); registerTenant(tenant.value(), "trial", Duration.ZERO); assertEquals(0.0, expirer.maintain()); assertEquals("Welcome to Vespa Cloud", lastAccountLevelNotificationTitle(tenant)); + assertLastEmailEquals(mailer, "welcome.html"); clock.advance(Duration.ofDays(7)); assertEquals(0.0, expirer.maintain()); assertEquals("How is your Vespa Cloud trial going?", lastAccountLevelNotificationTitle(tenant)); + assertLastEmailEquals(mailer, "trial-reminder.html"); clock.advance(Duration.ofDays(5)); assertEquals(0.0, expirer.maintain()); assertEquals("Your Vespa Cloud trial expires in 2 days", lastAccountLevelNotificationTitle(tenant)); + assertLastEmailEquals(mailer, "trial-expiring-soon.html"); clock.advance(Duration.ofDays(1)); assertEquals(0.0, expirer.maintain()); assertEquals("Your Vespa Cloud trial expires tomorrow", lastAccountLevelNotificationTitle(tenant)); + assertLastEmailEquals(mailer, "trial-expiring-immediately.html"); clock.advance(Duration.ofDays(2)); assertEquals(0.0, expirer.maintain()); assertEquals("Your Vespa Cloud trial has expired", lastAccountLevelNotificationTitle(tenant)); + assertLastEmailEquals(mailer, "trial-expired.html"); + } + + private void assertLastEmailEquals(MockMailer mailer, String expectedContentFile) throws IOException { + var mails = mailer.inbox("dev-trial-tenant"); + assertFalse(mails.isEmpty()); + var content = mails.get(mails.size() - 1).htmlMessage().orElseThrow(); + var path = Paths.get("src/test/resources/mail/" + expectedContentFile); + if (OVERWRITE_TEST_FILES) { + Files.write(path, content.getBytes(), + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + } else { + var expectedContent = Files.readString(path); + assertEquals(expectedContent, content); + } } private void registerTenant(String tenantName, String plan, Duration timeSinceLastLogin) { diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializerTest.java index 63aa45a5a34..26eb30b6525 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializerTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.Test; import java.io.IOException; import java.time.Instant; import java.util.List; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -27,6 +28,8 @@ public class NotificationsSerializerTest { void serialization_test() throws IOException { NotificationsSerializer serializer = new NotificationsSerializer(); TenantName tenantName = TenantName.from("tenant1"); + var mail = Notification.MailContent.fromTemplate("my-template").subject("My mail subject") + .with("string-param", "string-value").with("list-param", List.of("elem1", "elem2")).build(); List<Notification> notifications = List.of( new Notification(Instant.ofEpochSecond(1234), Notification.Type.applicationPackage, @@ -37,7 +40,8 @@ public class NotificationsSerializerTest { Notification.Type.deployment, Notification.Level.error, NotificationSource.from(new RunId(ApplicationId.from(tenantName.value(), "app1", "instance1"), DeploymentContext.systemTest, 12)), - List.of("Failed to deploy: Node allocation failure"))); + List.of("Failed to deploy: Node allocation failure"), + Optional.of(mail))); Slime serialized = serializer.toSlime(notifications); assertEquals("{\"notifications\":[" + @@ -55,7 +59,10 @@ public class NotificationsSerializerTest { "\"application\":\"app1\"," + "\"instance\":\"instance1\"," + "\"jobId\":\"test.us-east-1\"," + - "\"runNumber\":12" + + "\"runNumber\":12," + + "\"mail-template\":\"my-template\"," + + "\"mail-subject\":\"My mail subject\"," + + "\"mail-params\":{\"list-param\":[\"elem1\",\"elem2\"],\"string-param\":\"string-value\"}" + "}]}", new String(SlimeUtils.toJsonBytes(serialized))); List<Notification> deserialized = serializer.fromSlime(tenantName, serialized); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java index bf908069c1e..90bd2323cb3 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java @@ -49,7 +49,6 @@ import static com.yahoo.application.container.handler.Request.Method.DELETE; import static com.yahoo.application.container.handler.Request.Method.GET; import static com.yahoo.application.container.handler.Request.Method.POST; import static com.yahoo.application.container.handler.Request.Method.PUT; -import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -79,7 +78,7 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { void tenant_info_profile() { var request = request("/application/v4/tenant/scoober/info/profile", GET) .roles(Set.of(Role.reader(tenantName))); - tester.assertResponse(request, "{}", 200); + tester.assertResponse(request, "{\"contact\":{\"name\":\"\",\"email\":\"\",\"emailVerified\":true},\"tenant\":{\"company\":\"\",\"website\":\"\"}}", 200); var updateRequest = request("/application/v4/tenant/scoober/info/profile", PUT) .data("{\"contact\":{\"name\":\"Some Name\",\"email\":\"foo@example.com\"},\"tenant\":{\"company\":\"Scoober, Inc.\",\"website\":\"https://example.com/\"}}") @@ -101,7 +100,7 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { void tenant_info_billing() { var request = request("/application/v4/tenant/scoober/info/billing", GET) .roles(Set.of(Role.reader(tenantName))); - tester.assertResponse(request, "{}", 200); + tester.assertResponse(request, "{\"contact\":{\"name\":\"\",\"email\":\"\",\"phone\":\"\"}}", 200); var fullAddress = "{\"addressLines\":\"addressLines\",\"postalCodeOrZip\":\"postalCodeOrZip\",\"city\":\"city\",\"stateRegionProvince\":\"stateRegionProvince\",\"country\":\"country\"}"; var fullBillingContact = "{\"contact\":{\"name\":\"name\",\"email\":\"foo@example\",\"phone\":\"phone\"},\"address\":" + fullAddress + "}"; @@ -118,7 +117,7 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { void tenant_info_contacts() { var request = request("/application/v4/tenant/scoober/info/contacts", GET) .roles(Set.of(Role.reader(tenantName))); - tester.assertResponse(request, "{\"contacts\":[]}", 200); + tester.assertResponse(request, "{\"contacts\":[{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"developer@scoober\",\"emailVerified\":true}]}", 200); var fullContacts = "{\"contacts\":[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\",\"emailVerified\":false},{\"audiences\":[\"notifications\"],\"email\":\"contact2@example.com\",\"emailVerified\":false},{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"contact3@example.com\",\"emailVerified\":false}]}"; @@ -134,7 +133,7 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { var infoRequest = request("/application/v4/tenant/scoober/info", GET) .roles(Set.of(Role.reader(tenantName))); - tester.assertResponse(infoRequest, "{}", 200); + tester.assertResponse(infoRequest, "{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"contactName\":\"\",\"contactEmail\":\"\",\"contactEmailVerified\":true,\"contacts\":[{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"developer@scoober\",\"emailVerified\":true}]}", 200); String partialInfo = "{\"contactName\":\"newName\", \"contactEmail\": \"foo@example.com\", \"billingContact\":{\"name\":\"billingName\"}}"; var postPartial = @@ -189,7 +188,7 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { var infoRequest = request("/application/v4/tenant/scoober/info", GET) .roles(Set.of(Role.reader(tenantName))); - tester.assertResponse(infoRequest, "{}", 200); + tester.assertResponse(infoRequest, "{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"contactName\":\"\",\"contactEmail\":\"\",\"contactEmailVerified\":true,\"contacts\":[{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"developer@scoober\",\"emailVerified\":true}]}", 200); // name needs to be present and not blank var partialInfoMissingName = "{\"contactName\": \" \"}"; diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java index a2290f1f664..424b8d84472 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java @@ -185,4 +185,30 @@ public class BillingApiHandlerV2Test extends ControllerContainerCloudTest { {"message":"Successfully deleted line item line-item-id"}"""); } } + + @Test + void require_current_plan() { + { + var accountantRequest = request("/billing/v2/accountant/tenant/tenant1/plan") + .roles(Role.hostedAccountant()); + tester.assertResponse(accountantRequest, """ + {"id":"trial","name":"Free Trial - for testing purposes"}"""); + } + + { + var accountantRequest = request("/billing/v2/accountant/tenant/tenant1/plan", Request.Method.POST) + .roles(Role.hostedAccountant()) + .data(""" + {"id": "paid"}"""); + tester.assertResponse(accountantRequest, """ + {"message":"Plan: paid"}"""); + } + + { + var accountantRequest = request("/billing/v2/accountant/tenant/tenant1/plan") + .roles(Role.hostedAccountant()); + tester.assertResponse(accountantRequest, """ + {"id":"paid","name":"Paid Plan - for testing purposes"}"""); + } + } } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilterTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilterTest.java index d37097b8068..9477e71af33 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilterTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilterTest.java @@ -71,7 +71,7 @@ public class SignatureFilterTest { filter = new SignatureFilter(tester.controller()); signer = new RequestSigner(privateKey, id.serializedForm(), tester.clock()); - tester.curator().writeTenant(CloudTenant.create(appId.tenant(), Instant.EPOCH, null)); + tester.curator().writeTenant(CloudTenant.create(appId.tenant(), Instant.EPOCH, new SimplePrincipal("owner@my-tenant.my-app"))); tester.curator().writeApplication(new Application(appId, tester.clock().instant())); } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandlerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandlerTest.java index c4b5a771725..f2ce0dfeef2 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandlerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandlerTest.java @@ -21,6 +21,12 @@ public class PricingApiHandlerTest extends ControllerContainerCloudTest { @Test void testPricingInfoBasic() { + tester().assertJsonResponse(request("/pricing/v1/pricing?supportLevel=basic&committedSpend=0"), + """ + { "applications": [ ], "priceInfo": [ ], "totalAmount": "0.00" } + """, + 200); + var request = request("/pricing/v1/pricing?" + urlEncodedPriceInformation1App(BASIC)); tester().assertJsonResponse(request, """ { @@ -110,10 +116,8 @@ public class PricingApiHandlerTest extends ControllerContainerCloudTest { ] } ], - "priceInfo": [ - {"description": "Committed spend", "amount": "-0.20"} - ], - "totalAmount": "25.90" + "priceInfo": [ ], + "totalAmount": "26.10" } """, 200); @@ -128,9 +132,6 @@ public class PricingApiHandlerTest extends ControllerContainerCloudTest { tester.assertJsonResponse(request("/pricing/v1/pricing?"), "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Error in query parameter, expected '=' between key and value: ''\"}", 400); - tester.assertJsonResponse(request("/pricing/v1/pricing?supportLevel=basic&committedSpend=0"), - "{\"error-code\":\"BAD_REQUEST\",\"message\":\"No application resources found in query\"}", - 400); tester.assertJsonResponse(request("/pricing/v1/pricing?supportLevel=basic&committedSpend=0&resources"), "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Error in query parameter, expected '=' between key and value: 'resources'\"}", 400); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json index 6702eff8dde..1926dcc9f82 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json @@ -5,5 +5,14 @@ "contactName": "administrator", "contactEmail": "administrator@tenant", "contactEmailVerified": true, - "contacts": [ ] + "contacts": [ + { + "audiences": [ + "tenant", + "notifications" + ], + "email": "administrator@tenant", + "emailVerified": true + } + ] } diff --git a/controller-server/src/test/resources/mail/trial-expired.html b/controller-server/src/test/resources/mail/trial-expired.html new file mode 100644 index 00000000000..4e6fda61b33 --- /dev/null +++ b/controller-server/src/test/resources/mail/trial-expired.html @@ -0,0 +1,646 @@ +<!DOCTYPE html> +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:v="urn:schemas-microsoft-com:vml" + xmlns:o="urn:schemas-microsoft-com:office:office" +> + <head> + <title></title> + <!--[if !mso]><!--> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <!--<![endif]--> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <style type="text/css"> + #outlook a { + padding: 0; + } + + body { + margin: 0; + padding: 0; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + } + + table, + td { + border-collapse: collapse; + mso-table-lspace: 0pt; + mso-table-rspace: 0pt; + } + + img { + border: 0; + height: auto; + line-height: 100%; + outline: none; + text-decoration: none; + -ms-interpolation-mode: bicubic; + } + + p { + display: block; + margin: 13px 0; + } + </style> + <!--[if mso]> + <noscript> + <xml> + <o:OfficeDocumentSettings> + <o:AllowPNG /> + <o:PixelsPerInch>96</o:PixelsPerInch> + </o:OfficeDocumentSettings> + </xml> + </noscript> + <![endif]--> + <!--[if lte mso 11]> + <style type="text/css"> + .mj-outlook-group-fix { + width: 100% !important; + } + </style> + <![endif]--> + <!--[if !mso]><!--> + <link + href="https://fonts.googleapis.com/css?family=Open Sans" + rel="stylesheet" + type="text/css" + /> + <style type="text/css"> + @import url(https://fonts.googleapis.com/css?family=Open Sans); + </style> + <!--<![endif]--> + <style type="text/css"> + @media only screen and (min-width: 480px) { + .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + } + </style> + <style media="screen and (min-width:480px)"> + .moz-text-html .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + </style> + <style type="text/css"> + [owa] .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + </style> + <style type="text/css"> + @media only screen and (max-width: 480px) { + table.mj-full-width-mobile { + width: 100% !important; + } + + td.mj-full-width-mobile { + width: auto !important; + } + } + </style> + </head> + + <body style="word-spacing: normal; background-color: #f2f7fa"> + <div style="background-color: #f2f7fa"> + <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div style="margin: 0px auto; max-width: 600px"> + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 0px; + padding-top: 0px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 0px 0px 25px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 11px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + <br /> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div + style=" + background: #ffffff; + background-color: #ffffff; + margin: 0px auto; + max-width: 600px; + " + > + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="background: #ffffff; background-color: #ffffff; width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0; + padding-bottom: 0px; + padding-left: 0px; + padding-right: 0px; + padding-top: 0px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 0px; + padding-right: 0px; + padding-bottom: 40px; + padding-left: 0px; + word-break: break-word; + " + > + <p + style=" + border-top: solid 8px #005a8e; + font-size: 1px; + margin: 0px auto; + width: 100%; + " + ></p> + <!--[if mso | IE + ]><table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + style=" + border-top: solid 8px #005a8e; + font-size: 1px; + margin: 0px auto; + width: 600px; + " + role="presentation" + width="600px" + > + <tr> + <td style="height: 0; line-height: 0"> + + </td> + </tr> + </table><! + [endif]--> + </td> + </tr> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + border-collapse: collapse; + border-spacing: 0px; + " + > + <tbody> + <tr> + <td style="width: 121px"> + <img + alt="" + height="auto" + src="https://data.vespa.oath.cloud/assets/vespa-cloud-logo.png" + style=" + border: none; + display: block; + outline: none; + text-decoration: none; + height: auto; + width: 100%; + font-size: 13px; + " + width="121" + /> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div + style=" + background: #ffffff; + background-color: #ffffff; + margin: 0px auto; + max-width: 600px; + " + > + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="background: #ffffff; background-color: #ffffff; width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 70px; + padding-top: 30px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + +<tbody> +<tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 25px 0px 25px; + padding-top: 0px; + padding-right: 50px; + padding-bottom: 0px; + padding-left: 50px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + <h1 + style=" + text-align: center; + color: #000000; + line-height: 32px; + " + > + Your Vespa Cloud trial has expired + </h1> + </div> + </td> +</tr> +<tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 25px 0px 25px; + padding-top: 0px; + padding-right: 50px; + padding-bottom: 0px; + padding-left: 50px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + +<p> + Your Vespa Cloud trial has expired. Please reach out to us if you have any questions or feedback. +</p> + </div> + </td> +</tr> +<tr> + <td + align="center" + vertical-align="middle" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 20px; + padding-bottom: 20px; + word-break: break-word; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="border-collapse: separate; line-height: 100%" + > + <tbody> + <tr> + <td + align="center" + bgcolor="#005A8E" + role="presentation" + style=" + border: none; + border-radius: 100px; + cursor: auto; + mso-padding-alt: 15px 25px 15px 25px; + background: #005a8e; + " + valign="middle" + > + <a + href="https://dashboard.tld/trial-tenant" + style=" + display: inline-block; + background: #005a8e; + color: #ffffff; + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + font-weight: normal; + line-height: 120%; + margin: 0; + text-decoration: none; + text-transform: none; + padding: 15px 25px 15px 25px; + mso-padding-alt: 0px; + border-radius: 100px; + " + target="_blank" + ><b style="font-weight: 700" + ><b style="font-weight: 700" + >Go to Console</b + ></b + ></a + > + </td> + </tr> + </tbody> + </table> + </td> +</tr> +</tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div style="margin: 0px auto; max-width: 600px"> + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 0px; + padding-top: 20px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 0px 20px 0px 20px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 11px; + line-height: 22px; + text-align: center; + color: #797e82; + " + > + <p style="margin: 10px 0"> + <a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://legal.yahoo.com/xw/en/yahoo/privacy/topic/b2bprivacypolicy/index.html" + ><span style="color: #005a8e" + >Yahoo Privacy Policy</span + ></a + ><span style="color: #797e82" + > | </span + ><a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://dashboard.tld/terms-of-service-trial.html" + ><span style="color: #005a8e" + >Terms of Service</span + ></a + ><span style="color: #797e82" + > | </span + ><a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://dashboard.tld/support" + ><span style="color: #005a8e">Support</span></a + > + </p> + <p style="margin: 10px 0"> + <a + target="_blank" + rel="noopener noreferrer" + style="color: inherit; text-decoration: none" + href="https://dashboard.tld/tenant/trial-tenant/account/notifications" + >Click + <span style="color: #005a8e"><u>here</u></span> + to manage your notifications setting.</a + ><br /> + </p> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </div> + </body> +</html> diff --git a/controller-server/src/test/resources/mail/trial-expiring-immediately.html b/controller-server/src/test/resources/mail/trial-expiring-immediately.html new file mode 100644 index 00000000000..4b16619fe9c --- /dev/null +++ b/controller-server/src/test/resources/mail/trial-expiring-immediately.html @@ -0,0 +1,646 @@ +<!DOCTYPE html> +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:v="urn:schemas-microsoft-com:vml" + xmlns:o="urn:schemas-microsoft-com:office:office" +> + <head> + <title></title> + <!--[if !mso]><!--> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <!--<![endif]--> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <style type="text/css"> + #outlook a { + padding: 0; + } + + body { + margin: 0; + padding: 0; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + } + + table, + td { + border-collapse: collapse; + mso-table-lspace: 0pt; + mso-table-rspace: 0pt; + } + + img { + border: 0; + height: auto; + line-height: 100%; + outline: none; + text-decoration: none; + -ms-interpolation-mode: bicubic; + } + + p { + display: block; + margin: 13px 0; + } + </style> + <!--[if mso]> + <noscript> + <xml> + <o:OfficeDocumentSettings> + <o:AllowPNG /> + <o:PixelsPerInch>96</o:PixelsPerInch> + </o:OfficeDocumentSettings> + </xml> + </noscript> + <![endif]--> + <!--[if lte mso 11]> + <style type="text/css"> + .mj-outlook-group-fix { + width: 100% !important; + } + </style> + <![endif]--> + <!--[if !mso]><!--> + <link + href="https://fonts.googleapis.com/css?family=Open Sans" + rel="stylesheet" + type="text/css" + /> + <style type="text/css"> + @import url(https://fonts.googleapis.com/css?family=Open Sans); + </style> + <!--<![endif]--> + <style type="text/css"> + @media only screen and (min-width: 480px) { + .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + } + </style> + <style media="screen and (min-width:480px)"> + .moz-text-html .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + </style> + <style type="text/css"> + [owa] .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + </style> + <style type="text/css"> + @media only screen and (max-width: 480px) { + table.mj-full-width-mobile { + width: 100% !important; + } + + td.mj-full-width-mobile { + width: auto !important; + } + } + </style> + </head> + + <body style="word-spacing: normal; background-color: #f2f7fa"> + <div style="background-color: #f2f7fa"> + <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div style="margin: 0px auto; max-width: 600px"> + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 0px; + padding-top: 0px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 0px 0px 25px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 11px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + <br /> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div + style=" + background: #ffffff; + background-color: #ffffff; + margin: 0px auto; + max-width: 600px; + " + > + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="background: #ffffff; background-color: #ffffff; width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0; + padding-bottom: 0px; + padding-left: 0px; + padding-right: 0px; + padding-top: 0px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 0px; + padding-right: 0px; + padding-bottom: 40px; + padding-left: 0px; + word-break: break-word; + " + > + <p + style=" + border-top: solid 8px #005a8e; + font-size: 1px; + margin: 0px auto; + width: 100%; + " + ></p> + <!--[if mso | IE + ]><table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + style=" + border-top: solid 8px #005a8e; + font-size: 1px; + margin: 0px auto; + width: 600px; + " + role="presentation" + width="600px" + > + <tr> + <td style="height: 0; line-height: 0"> + + </td> + </tr> + </table><! + [endif]--> + </td> + </tr> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + border-collapse: collapse; + border-spacing: 0px; + " + > + <tbody> + <tr> + <td style="width: 121px"> + <img + alt="" + height="auto" + src="https://data.vespa.oath.cloud/assets/vespa-cloud-logo.png" + style=" + border: none; + display: block; + outline: none; + text-decoration: none; + height: auto; + width: 100%; + font-size: 13px; + " + width="121" + /> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div + style=" + background: #ffffff; + background-color: #ffffff; + margin: 0px auto; + max-width: 600px; + " + > + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="background: #ffffff; background-color: #ffffff; width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 70px; + padding-top: 30px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + +<tbody> +<tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 25px 0px 25px; + padding-top: 0px; + padding-right: 50px; + padding-bottom: 0px; + padding-left: 50px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + <h1 + style=" + text-align: center; + color: #000000; + line-height: 32px; + " + > + Your Vespa Cloud trial expires tomorrow + </h1> + </div> + </td> +</tr> +<tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 25px 0px 25px; + padding-top: 0px; + padding-right: 50px; + padding-bottom: 0px; + padding-left: 50px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + +<p> + Your Vespa Cloud trial expires tomorrow. Please reach out to us if you have any questions or feedback. +</p> + </div> + </td> +</tr> +<tr> + <td + align="center" + vertical-align="middle" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 20px; + padding-bottom: 20px; + word-break: break-word; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="border-collapse: separate; line-height: 100%" + > + <tbody> + <tr> + <td + align="center" + bgcolor="#005A8E" + role="presentation" + style=" + border: none; + border-radius: 100px; + cursor: auto; + mso-padding-alt: 15px 25px 15px 25px; + background: #005a8e; + " + valign="middle" + > + <a + href="https://dashboard.tld/trial-tenant" + style=" + display: inline-block; + background: #005a8e; + color: #ffffff; + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + font-weight: normal; + line-height: 120%; + margin: 0; + text-decoration: none; + text-transform: none; + padding: 15px 25px 15px 25px; + mso-padding-alt: 0px; + border-radius: 100px; + " + target="_blank" + ><b style="font-weight: 700" + ><b style="font-weight: 700" + >Go to Console</b + ></b + ></a + > + </td> + </tr> + </tbody> + </table> + </td> +</tr> +</tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div style="margin: 0px auto; max-width: 600px"> + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 0px; + padding-top: 20px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 0px 20px 0px 20px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 11px; + line-height: 22px; + text-align: center; + color: #797e82; + " + > + <p style="margin: 10px 0"> + <a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://legal.yahoo.com/xw/en/yahoo/privacy/topic/b2bprivacypolicy/index.html" + ><span style="color: #005a8e" + >Yahoo Privacy Policy</span + ></a + ><span style="color: #797e82" + > | </span + ><a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://dashboard.tld/terms-of-service-trial.html" + ><span style="color: #005a8e" + >Terms of Service</span + ></a + ><span style="color: #797e82" + > | </span + ><a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://dashboard.tld/support" + ><span style="color: #005a8e">Support</span></a + > + </p> + <p style="margin: 10px 0"> + <a + target="_blank" + rel="noopener noreferrer" + style="color: inherit; text-decoration: none" + href="https://dashboard.tld/tenant/trial-tenant/account/notifications" + >Click + <span style="color: #005a8e"><u>here</u></span> + to manage your notifications setting.</a + ><br /> + </p> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </div> + </body> +</html> diff --git a/controller-server/src/test/resources/mail/trial-expiring-soon.html b/controller-server/src/test/resources/mail/trial-expiring-soon.html new file mode 100644 index 00000000000..b4c85173171 --- /dev/null +++ b/controller-server/src/test/resources/mail/trial-expiring-soon.html @@ -0,0 +1,646 @@ +<!DOCTYPE html> +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:v="urn:schemas-microsoft-com:vml" + xmlns:o="urn:schemas-microsoft-com:office:office" +> + <head> + <title></title> + <!--[if !mso]><!--> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <!--<![endif]--> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <style type="text/css"> + #outlook a { + padding: 0; + } + + body { + margin: 0; + padding: 0; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + } + + table, + td { + border-collapse: collapse; + mso-table-lspace: 0pt; + mso-table-rspace: 0pt; + } + + img { + border: 0; + height: auto; + line-height: 100%; + outline: none; + text-decoration: none; + -ms-interpolation-mode: bicubic; + } + + p { + display: block; + margin: 13px 0; + } + </style> + <!--[if mso]> + <noscript> + <xml> + <o:OfficeDocumentSettings> + <o:AllowPNG /> + <o:PixelsPerInch>96</o:PixelsPerInch> + </o:OfficeDocumentSettings> + </xml> + </noscript> + <![endif]--> + <!--[if lte mso 11]> + <style type="text/css"> + .mj-outlook-group-fix { + width: 100% !important; + } + </style> + <![endif]--> + <!--[if !mso]><!--> + <link + href="https://fonts.googleapis.com/css?family=Open Sans" + rel="stylesheet" + type="text/css" + /> + <style type="text/css"> + @import url(https://fonts.googleapis.com/css?family=Open Sans); + </style> + <!--<![endif]--> + <style type="text/css"> + @media only screen and (min-width: 480px) { + .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + } + </style> + <style media="screen and (min-width:480px)"> + .moz-text-html .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + </style> + <style type="text/css"> + [owa] .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + </style> + <style type="text/css"> + @media only screen and (max-width: 480px) { + table.mj-full-width-mobile { + width: 100% !important; + } + + td.mj-full-width-mobile { + width: auto !important; + } + } + </style> + </head> + + <body style="word-spacing: normal; background-color: #f2f7fa"> + <div style="background-color: #f2f7fa"> + <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div style="margin: 0px auto; max-width: 600px"> + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 0px; + padding-top: 0px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 0px 0px 25px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 11px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + <br /> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div + style=" + background: #ffffff; + background-color: #ffffff; + margin: 0px auto; + max-width: 600px; + " + > + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="background: #ffffff; background-color: #ffffff; width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0; + padding-bottom: 0px; + padding-left: 0px; + padding-right: 0px; + padding-top: 0px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 0px; + padding-right: 0px; + padding-bottom: 40px; + padding-left: 0px; + word-break: break-word; + " + > + <p + style=" + border-top: solid 8px #005a8e; + font-size: 1px; + margin: 0px auto; + width: 100%; + " + ></p> + <!--[if mso | IE + ]><table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + style=" + border-top: solid 8px #005a8e; + font-size: 1px; + margin: 0px auto; + width: 600px; + " + role="presentation" + width="600px" + > + <tr> + <td style="height: 0; line-height: 0"> + + </td> + </tr> + </table><! + [endif]--> + </td> + </tr> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + border-collapse: collapse; + border-spacing: 0px; + " + > + <tbody> + <tr> + <td style="width: 121px"> + <img + alt="" + height="auto" + src="https://data.vespa.oath.cloud/assets/vespa-cloud-logo.png" + style=" + border: none; + display: block; + outline: none; + text-decoration: none; + height: auto; + width: 100%; + font-size: 13px; + " + width="121" + /> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div + style=" + background: #ffffff; + background-color: #ffffff; + margin: 0px auto; + max-width: 600px; + " + > + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="background: #ffffff; background-color: #ffffff; width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 70px; + padding-top: 30px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + +<tbody> +<tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 25px 0px 25px; + padding-top: 0px; + padding-right: 50px; + padding-bottom: 0px; + padding-left: 50px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + <h1 + style=" + text-align: center; + color: #000000; + line-height: 32px; + " + > + Your Vespa Cloud trial expires in 2 days + </h1> + </div> + </td> +</tr> +<tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 25px 0px 25px; + padding-top: 0px; + padding-right: 50px; + padding-bottom: 0px; + padding-left: 50px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + +<p> + Your Vespa Cloud trial expires in 2 days. Please reach out to us if you have any questions or feedback. +</p> + </div> + </td> +</tr> +<tr> + <td + align="center" + vertical-align="middle" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 20px; + padding-bottom: 20px; + word-break: break-word; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="border-collapse: separate; line-height: 100%" + > + <tbody> + <tr> + <td + align="center" + bgcolor="#005A8E" + role="presentation" + style=" + border: none; + border-radius: 100px; + cursor: auto; + mso-padding-alt: 15px 25px 15px 25px; + background: #005a8e; + " + valign="middle" + > + <a + href="https://dashboard.tld/trial-tenant" + style=" + display: inline-block; + background: #005a8e; + color: #ffffff; + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + font-weight: normal; + line-height: 120%; + margin: 0; + text-decoration: none; + text-transform: none; + padding: 15px 25px 15px 25px; + mso-padding-alt: 0px; + border-radius: 100px; + " + target="_blank" + ><b style="font-weight: 700" + ><b style="font-weight: 700" + >Go to Console</b + ></b + ></a + > + </td> + </tr> + </tbody> + </table> + </td> +</tr> +</tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div style="margin: 0px auto; max-width: 600px"> + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 0px; + padding-top: 20px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 0px 20px 0px 20px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 11px; + line-height: 22px; + text-align: center; + color: #797e82; + " + > + <p style="margin: 10px 0"> + <a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://legal.yahoo.com/xw/en/yahoo/privacy/topic/b2bprivacypolicy/index.html" + ><span style="color: #005a8e" + >Yahoo Privacy Policy</span + ></a + ><span style="color: #797e82" + > | </span + ><a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://dashboard.tld/terms-of-service-trial.html" + ><span style="color: #005a8e" + >Terms of Service</span + ></a + ><span style="color: #797e82" + > | </span + ><a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://dashboard.tld/support" + ><span style="color: #005a8e">Support</span></a + > + </p> + <p style="margin: 10px 0"> + <a + target="_blank" + rel="noopener noreferrer" + style="color: inherit; text-decoration: none" + href="https://dashboard.tld/tenant/trial-tenant/account/notifications" + >Click + <span style="color: #005a8e"><u>here</u></span> + to manage your notifications setting.</a + ><br /> + </p> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </div> + </body> +</html> diff --git a/controller-server/src/test/resources/mail/trial-reminder.html b/controller-server/src/test/resources/mail/trial-reminder.html new file mode 100644 index 00000000000..2644b187764 --- /dev/null +++ b/controller-server/src/test/resources/mail/trial-reminder.html @@ -0,0 +1,646 @@ +<!DOCTYPE html> +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:v="urn:schemas-microsoft-com:vml" + xmlns:o="urn:schemas-microsoft-com:office:office" +> + <head> + <title></title> + <!--[if !mso]><!--> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <!--<![endif]--> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <style type="text/css"> + #outlook a { + padding: 0; + } + + body { + margin: 0; + padding: 0; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + } + + table, + td { + border-collapse: collapse; + mso-table-lspace: 0pt; + mso-table-rspace: 0pt; + } + + img { + border: 0; + height: auto; + line-height: 100%; + outline: none; + text-decoration: none; + -ms-interpolation-mode: bicubic; + } + + p { + display: block; + margin: 13px 0; + } + </style> + <!--[if mso]> + <noscript> + <xml> + <o:OfficeDocumentSettings> + <o:AllowPNG /> + <o:PixelsPerInch>96</o:PixelsPerInch> + </o:OfficeDocumentSettings> + </xml> + </noscript> + <![endif]--> + <!--[if lte mso 11]> + <style type="text/css"> + .mj-outlook-group-fix { + width: 100% !important; + } + </style> + <![endif]--> + <!--[if !mso]><!--> + <link + href="https://fonts.googleapis.com/css?family=Open Sans" + rel="stylesheet" + type="text/css" + /> + <style type="text/css"> + @import url(https://fonts.googleapis.com/css?family=Open Sans); + </style> + <!--<![endif]--> + <style type="text/css"> + @media only screen and (min-width: 480px) { + .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + } + </style> + <style media="screen and (min-width:480px)"> + .moz-text-html .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + </style> + <style type="text/css"> + [owa] .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + </style> + <style type="text/css"> + @media only screen and (max-width: 480px) { + table.mj-full-width-mobile { + width: 100% !important; + } + + td.mj-full-width-mobile { + width: auto !important; + } + } + </style> + </head> + + <body style="word-spacing: normal; background-color: #f2f7fa"> + <div style="background-color: #f2f7fa"> + <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div style="margin: 0px auto; max-width: 600px"> + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 0px; + padding-top: 0px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 0px 0px 25px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 11px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + <br /> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div + style=" + background: #ffffff; + background-color: #ffffff; + margin: 0px auto; + max-width: 600px; + " + > + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="background: #ffffff; background-color: #ffffff; width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0; + padding-bottom: 0px; + padding-left: 0px; + padding-right: 0px; + padding-top: 0px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 0px; + padding-right: 0px; + padding-bottom: 40px; + padding-left: 0px; + word-break: break-word; + " + > + <p + style=" + border-top: solid 8px #005a8e; + font-size: 1px; + margin: 0px auto; + width: 100%; + " + ></p> + <!--[if mso | IE + ]><table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + style=" + border-top: solid 8px #005a8e; + font-size: 1px; + margin: 0px auto; + width: 600px; + " + role="presentation" + width="600px" + > + <tr> + <td style="height: 0; line-height: 0"> + + </td> + </tr> + </table><! + [endif]--> + </td> + </tr> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + border-collapse: collapse; + border-spacing: 0px; + " + > + <tbody> + <tr> + <td style="width: 121px"> + <img + alt="" + height="auto" + src="https://data.vespa.oath.cloud/assets/vespa-cloud-logo.png" + style=" + border: none; + display: block; + outline: none; + text-decoration: none; + height: auto; + width: 100%; + font-size: 13px; + " + width="121" + /> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div + style=" + background: #ffffff; + background-color: #ffffff; + margin: 0px auto; + max-width: 600px; + " + > + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="background: #ffffff; background-color: #ffffff; width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 70px; + padding-top: 30px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + +<tbody> +<tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 25px 0px 25px; + padding-top: 0px; + padding-right: 50px; + padding-bottom: 0px; + padding-left: 50px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + <h1 + style=" + text-align: center; + color: #000000; + line-height: 32px; + " + > + How is your Vespa Cloud trial going? + </h1> + </div> + </td> +</tr> +<tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 25px 0px 25px; + padding-top: 0px; + padding-right: 50px; + padding-bottom: 0px; + padding-left: 50px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + +<p> + How is your Vespa Cloud trial going? Please reach out to us if you have any questions or feedback. +</p> + </div> + </td> +</tr> +<tr> + <td + align="center" + vertical-align="middle" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 20px; + padding-bottom: 20px; + word-break: break-word; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="border-collapse: separate; line-height: 100%" + > + <tbody> + <tr> + <td + align="center" + bgcolor="#005A8E" + role="presentation" + style=" + border: none; + border-radius: 100px; + cursor: auto; + mso-padding-alt: 15px 25px 15px 25px; + background: #005a8e; + " + valign="middle" + > + <a + href="https://dashboard.tld/trial-tenant" + style=" + display: inline-block; + background: #005a8e; + color: #ffffff; + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + font-weight: normal; + line-height: 120%; + margin: 0; + text-decoration: none; + text-transform: none; + padding: 15px 25px 15px 25px; + mso-padding-alt: 0px; + border-radius: 100px; + " + target="_blank" + ><b style="font-weight: 700" + ><b style="font-weight: 700" + >Go to Console</b + ></b + ></a + > + </td> + </tr> + </tbody> + </table> + </td> +</tr> +</tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div style="margin: 0px auto; max-width: 600px"> + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 0px; + padding-top: 20px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 0px 20px 0px 20px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 11px; + line-height: 22px; + text-align: center; + color: #797e82; + " + > + <p style="margin: 10px 0"> + <a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://legal.yahoo.com/xw/en/yahoo/privacy/topic/b2bprivacypolicy/index.html" + ><span style="color: #005a8e" + >Yahoo Privacy Policy</span + ></a + ><span style="color: #797e82" + > | </span + ><a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://dashboard.tld/terms-of-service-trial.html" + ><span style="color: #005a8e" + >Terms of Service</span + ></a + ><span style="color: #797e82" + > | </span + ><a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://dashboard.tld/support" + ><span style="color: #005a8e">Support</span></a + > + </p> + <p style="margin: 10px 0"> + <a + target="_blank" + rel="noopener noreferrer" + style="color: inherit; text-decoration: none" + href="https://dashboard.tld/tenant/trial-tenant/account/notifications" + >Click + <span style="color: #005a8e"><u>here</u></span> + to manage your notifications setting.</a + ><br /> + </p> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </div> + </body> +</html> diff --git a/controller-server/src/test/resources/mail/welcome.html b/controller-server/src/test/resources/mail/welcome.html new file mode 100644 index 00000000000..a21a7cdf45f --- /dev/null +++ b/controller-server/src/test/resources/mail/welcome.html @@ -0,0 +1,646 @@ +<!DOCTYPE html> +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:v="urn:schemas-microsoft-com:vml" + xmlns:o="urn:schemas-microsoft-com:office:office" +> + <head> + <title></title> + <!--[if !mso]><!--> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <!--<![endif]--> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <style type="text/css"> + #outlook a { + padding: 0; + } + + body { + margin: 0; + padding: 0; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + } + + table, + td { + border-collapse: collapse; + mso-table-lspace: 0pt; + mso-table-rspace: 0pt; + } + + img { + border: 0; + height: auto; + line-height: 100%; + outline: none; + text-decoration: none; + -ms-interpolation-mode: bicubic; + } + + p { + display: block; + margin: 13px 0; + } + </style> + <!--[if mso]> + <noscript> + <xml> + <o:OfficeDocumentSettings> + <o:AllowPNG /> + <o:PixelsPerInch>96</o:PixelsPerInch> + </o:OfficeDocumentSettings> + </xml> + </noscript> + <![endif]--> + <!--[if lte mso 11]> + <style type="text/css"> + .mj-outlook-group-fix { + width: 100% !important; + } + </style> + <![endif]--> + <!--[if !mso]><!--> + <link + href="https://fonts.googleapis.com/css?family=Open Sans" + rel="stylesheet" + type="text/css" + /> + <style type="text/css"> + @import url(https://fonts.googleapis.com/css?family=Open Sans); + </style> + <!--<![endif]--> + <style type="text/css"> + @media only screen and (min-width: 480px) { + .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + } + </style> + <style media="screen and (min-width:480px)"> + .moz-text-html .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + </style> + <style type="text/css"> + [owa] .mj-column-per-100 { + width: 100% !important; + max-width: 100%; + } + </style> + <style type="text/css"> + @media only screen and (max-width: 480px) { + table.mj-full-width-mobile { + width: 100% !important; + } + + td.mj-full-width-mobile { + width: auto !important; + } + } + </style> + </head> + + <body style="word-spacing: normal; background-color: #f2f7fa"> + <div style="background-color: #f2f7fa"> + <!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div style="margin: 0px auto; max-width: 600px"> + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 0px; + padding-top: 0px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 0px 0px 25px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 11px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + <br /> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div + style=" + background: #ffffff; + background-color: #ffffff; + margin: 0px auto; + max-width: 600px; + " + > + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="background: #ffffff; background-color: #ffffff; width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0; + padding-bottom: 0px; + padding-left: 0px; + padding-right: 0px; + padding-top: 0px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 0px; + padding-right: 0px; + padding-bottom: 40px; + padding-left: 0px; + word-break: break-word; + " + > + <p + style=" + border-top: solid 8px #005a8e; + font-size: 1px; + margin: 0px auto; + width: 100%; + " + ></p> + <!--[if mso | IE + ]><table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + style=" + border-top: solid 8px #005a8e; + font-size: 1px; + margin: 0px auto; + width: 600px; + " + role="presentation" + width="600px" + > + <tr> + <td style="height: 0; line-height: 0"> + + </td> + </tr> + </table><! + [endif]--> + </td> + </tr> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style=" + border-collapse: collapse; + border-spacing: 0px; + " + > + <tbody> + <tr> + <td style="width: 121px"> + <img + alt="" + height="auto" + src="https://data.vespa.oath.cloud/assets/vespa-cloud-logo.png" + style=" + border: none; + display: block; + outline: none; + text-decoration: none; + height: auto; + width: 100%; + font-size: 13px; + " + width="121" + /> + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div + style=" + background: #ffffff; + background-color: #ffffff; + margin: 0px auto; + max-width: 600px; + " + > + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="background: #ffffff; background-color: #ffffff; width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 70px; + padding-top: 30px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + +<tbody> +<tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 25px 0px 25px; + padding-top: 0px; + padding-right: 50px; + padding-bottom: 0px; + padding-left: 50px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + <h1 + style=" + text-align: center; + color: #000000; + line-height: 32px; + " + > + Welcome to Vespa Cloud + </h1> + </div> + </td> +</tr> +<tr> + <td + align="left" + style=" + font-size: 0px; + padding: 0px 25px 0px 25px; + padding-top: 0px; + padding-right: 50px; + padding-bottom: 0px; + padding-left: 50px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + line-height: 22px; + text-align: left; + color: #797e82; + " + > + +<p> + Welcome to Vespa Cloud! We hope you will enjoy your trial. Please reach out to us if you have any questions or feedback. +</p> + </div> + </td> +</tr> +<tr> + <td + align="center" + vertical-align="middle" + style=" + font-size: 0px; + padding: 10px 25px; + padding-top: 20px; + padding-bottom: 20px; + word-break: break-word; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="border-collapse: separate; line-height: 100%" + > + <tbody> + <tr> + <td + align="center" + bgcolor="#005A8E" + role="presentation" + style=" + border: none; + border-radius: 100px; + cursor: auto; + mso-padding-alt: 15px 25px 15px 25px; + background: #005a8e; + " + valign="middle" + > + <a + href="https://dashboard.tld/trial-tenant" + style=" + display: inline-block; + background: #005a8e; + color: #ffffff; + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 13px; + font-weight: normal; + line-height: 120%; + margin: 0; + text-decoration: none; + text-transform: none; + padding: 15px 25px 15px 25px; + mso-padding-alt: 0px; + border-radius: 100px; + " + target="_blank" + ><b style="font-weight: 700" + ><b style="font-weight: 700" + >Go to Console</b + ></b + ></a + > + </td> + </tr> + </tbody> + </table> + </td> +</tr> +</tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--> + <div style="margin: 0px auto; max-width: 600px"> + <table + align="center" + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="width: 100%" + > + <tbody> + <tr> + <td + style=" + direction: ltr; + font-size: 0px; + padding: 20px 0px 20px 0px; + padding-bottom: 0px; + padding-top: 20px; + text-align: center; + " + > + <!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--> + <div + class="mj-column-per-100 mj-outlook-group-fix" + style=" + font-size: 0px; + text-align: left; + direction: ltr; + display: inline-block; + vertical-align: top; + width: 100%; + " + > + <table + border="0" + cellpadding="0" + cellspacing="0" + role="presentation" + style="vertical-align: top" + width="100%" + > + <tbody> + <tr> + <td + align="center" + style=" + font-size: 0px; + padding: 0px 20px 0px 20px; + padding-top: 0px; + padding-bottom: 0px; + word-break: break-word; + " + > + <div + style=" + font-family: Open Sans, Helvetica, Arial, + sans-serif; + font-size: 11px; + line-height: 22px; + text-align: center; + color: #797e82; + " + > + <p style="margin: 10px 0"> + <a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://legal.yahoo.com/xw/en/yahoo/privacy/topic/b2bprivacypolicy/index.html" + ><span style="color: #005a8e" + >Yahoo Privacy Policy</span + ></a + ><span style="color: #797e82" + > | </span + ><a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://dashboard.tld/terms-of-service-trial.html" + ><span style="color: #005a8e" + >Terms of Service</span + ></a + ><span style="color: #797e82" + > | </span + ><a + target="_blank" + rel="noopener noreferrer" + style="color: #005a8e" + href="https://dashboard.tld/support" + ><span style="color: #005a8e">Support</span></a + > + </p> + <p style="margin: 10px 0"> + <a + target="_blank" + rel="noopener noreferrer" + style="color: inherit; text-decoration: none" + href="https://dashboard.tld/tenant/trial-tenant/account/notifications" + >Click + <span style="color: #005a8e"><u>here</u></span> + to manage your notifications setting.</a + ><br /> + </p> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + </div> + <!--[if mso | IE]></td></tr></table><![endif]--> + </div> + </body> +</html> diff --git a/documentapi/src/tests/policies/policies_test.cpp b/documentapi/src/tests/policies/policies_test.cpp index 9dd73a71920..7091b63b6b3 100644 --- a/documentapi/src/tests/policies/policies_test.cpp +++ b/documentapi/src/tests/policies/policies_test.cpp @@ -806,7 +806,7 @@ Test::requireThatContentPolicyIsRandomWithoutState() ContentPolicy &policy = setupContentPolicy( frame, param, "storage/cluster.mycluster/distributor/*/default", 5); - ASSERT_TRUE(policy.getSystemState() == nullptr); + ASSERT_FALSE(policy.getSystemState()); std::set<string> lst; for (uint32_t i = 0; i < 666; i++) { @@ -858,12 +858,12 @@ Test::requireThatContentPolicyIsTargetedWithState() "cluster=mycluster;slobroks=tcp/localhost:%d;clusterconfigid=%s;syncinit", slobrok.port(), getDefaultDistributionConfig(2, 5).c_str()); ContentPolicy &policy = setupContentPolicy(frame, param, "storage/cluster.mycluster/distributor/*/default", 5); - ASSERT_TRUE(policy.getSystemState() == nullptr); + ASSERT_FALSE(policy.getSystemState()); { std::vector<mbus::RoutingNode*> leaf; ASSERT_TRUE(frame.select(leaf, 1)); leaf[0]->handleReply(std::make_unique<WrongDistributionReply>("distributor:5 storage:5")); - ASSERT_TRUE(policy.getSystemState() != nullptr); + ASSERT_TRUE(policy.getSystemState()); EXPECT_EQUAL(policy.getSystemState()->toString(), "distributor:5 storage:5"); } std::set<string> lst; @@ -897,12 +897,12 @@ Test::requireThatContentPolicyCombinesSystemAndSlobrokState() ContentPolicy &policy = setupContentPolicy( frame, param, "storage/cluster.mycluster/distributor/*/default", 1); - ASSERT_TRUE(policy.getSystemState() == nullptr); + ASSERT_FALSE(policy.getSystemState()); { std::vector<mbus::RoutingNode*> leaf; ASSERT_TRUE(frame.select(leaf, 1)); leaf[0]->handleReply(std::make_unique<WrongDistributionReply>("distributor:99 storage:99")); - ASSERT_TRUE(policy.getSystemState() != nullptr); + ASSERT_TRUE(policy.getSystemState()); EXPECT_EQUAL(policy.getSystemState()->toString(), "distributor:99 storage:99"); } for (int i = 0; i < 666; i++) { diff --git a/documentapi/src/vespa/documentapi/messagebus/policies/contentpolicy.cpp b/documentapi/src/vespa/documentapi/messagebus/policies/contentpolicy.cpp index e391699b750..ea27d42e790 100644 --- a/documentapi/src/vespa/documentapi/messagebus/policies/contentpolicy.cpp +++ b/documentapi/src/vespa/documentapi/messagebus/policies/contentpolicy.cpp @@ -43,7 +43,7 @@ namespace { class CallBack : public config::IFetcherCallback<storage::lib::Distribution::DistributionConfig> { public: - CallBack(ContentPolicy & policy) : _policy(policy) { } + explicit CallBack(ContentPolicy & policy) : _policy(policy) { } void configure(std::unique_ptr<storage::lib::Distribution::DistributionConfig> config) override { _policy.configure(std::move(config)); } @@ -78,13 +78,13 @@ string ContentPolicy::init() ContentPolicy::~ContentPolicy() = default; string -ContentPolicy::createConfigId(const string & clusterName) const +ContentPolicy::createConfigId(const string & clusterName) { return clusterName; } string -ContentPolicy::createPattern(const string & clusterName, int distributor) const +ContentPolicy::createPattern(const string & clusterName, int distributor) { vespalib::asciistream ost; @@ -103,7 +103,8 @@ void ContentPolicy::configure(std::unique_ptr<vespa::config::content::StorDistributionConfig> config) { try { - _nextDistribution = std::make_unique<storage::lib::Distribution>(*config); + std::lock_guard guard(_rw_lock); + _distribution = std::make_unique<storage::lib::Distribution>(*config); } catch (const std::exception& e) { LOG(warning, "Got exception when configuring distribution, config id was %s", _clusterConfigId.c_str()); throw e; @@ -116,8 +117,9 @@ ContentPolicy::doSelect(mbus::RoutingContext &context) const mbus::Message &msg = context.getMessage(); int distributor = -1; + auto [cur_state, cur_distribution] = internal_state_snapshot(); - if (_state.get()) { + if (cur_state) { document::BucketId id; switch(msg.getType()) { case DocumentProtocol::MESSAGE_PUTDOCUMENT: @@ -168,15 +170,10 @@ ContentPolicy::doSelect(mbus::RoutingContext &context) // Pick a distributor using ideal state algorithm try { - // Update distribution here, to make it not take lock in average case - if (_nextDistribution) { - _distribution = std::move(_nextDistribution); - _nextDistribution.reset(); - } - assert(_distribution.get()); - distributor = _distribution->getIdealDistributorNode(*_state, id); + assert(cur_distribution); + distributor = cur_distribution->getIdealDistributorNode(*cur_state, id); } catch (storage::lib::TooFewBucketBitsInUseException& e) { - auto reply = std::make_unique<WrongDistributionReply>(_state->toString()); + auto reply = std::make_unique<WrongDistributionReply>(cur_state->toString()); reply->addError(mbus::Error( DocumentProtocol::ERROR_WRONG_DISTRIBUTION, "Too few distribution bits used for given cluster state")); @@ -185,7 +182,7 @@ ContentPolicy::doSelect(mbus::RoutingContext &context) } catch (storage::lib::NoDistributorsAvailableException& e) { // No distributors available in current cluster state. Remove // cluster state we cannot use and send to random target - _state.reset(); + reset_state(); distributor = -1; } } @@ -216,7 +213,7 @@ ContentPolicy::getRecipient(mbus::RoutingContext& context, int distributor) return mbus::Hop::parse(entries[random() % entries.size()].second + "/default"); } - return mbus::Hop(); + return {}; } void @@ -226,9 +223,9 @@ ContentPolicy::merge(mbus::RoutingContext &context) mbus::Reply::UP reply = it.removeReply(); if (reply->getType() == DocumentProtocol::REPLY_WRONGDISTRIBUTION) { - updateStateFromReply(static_cast<WrongDistributionReply&>(*reply)); + updateStateFromReply(dynamic_cast<WrongDistributionReply&>(*reply)); } else if (reply->hasErrors()) { - _state.reset(); + reset_state(); } context.setReply(std::move(reply)); @@ -237,8 +234,8 @@ ContentPolicy::merge(mbus::RoutingContext &context) void ContentPolicy::updateStateFromReply(WrongDistributionReply& wdr) { - std::unique_ptr<storage::lib::ClusterState> newState( - new storage::lib::ClusterState(wdr.getSystemState())); + auto newState = std::make_unique<storage::lib::ClusterState>(wdr.getSystemState()); + std::lock_guard guard(_rw_lock); if (!_state || newState->getVersion() >= _state->getVersion()) { if (_state) { wdr.getTrace().trace(1, make_string("System state changed from version %u to %u", @@ -256,4 +253,28 @@ ContentPolicy::updateStateFromReply(WrongDistributionReply& wdr) } } +ContentPolicy::StateSnapshot +ContentPolicy::internal_state_snapshot() +{ + std::shared_lock guard(_rw_lock); + return {_state, _distribution}; +} + +std::shared_ptr<const storage::lib::ClusterState> +ContentPolicy::getSystemState() const noexcept +{ + std::shared_lock guard(_rw_lock); + return _state; +} + +void +ContentPolicy::reset_state() +{ + // It's possible for the caller to race between checking and resetting the state, + // but this should never lead to a worse outcome than sending to a random distributor + // as if no state had been cached prior. + std::lock_guard guard(_rw_lock); + _state.reset(); +} + } // documentapi diff --git a/documentapi/src/vespa/documentapi/messagebus/policies/contentpolicy.h b/documentapi/src/vespa/documentapi/messagebus/policies/contentpolicy.h index e49ad378b90..7a3675c3001 100644 --- a/documentapi/src/vespa/documentapi/messagebus/policies/contentpolicy.h +++ b/documentapi/src/vespa/documentapi/messagebus/policies/contentpolicy.h @@ -6,55 +6,62 @@ #include <vespa/vdslib/distribution/distribution.h> #include <vespa/document/bucket/bucketidfactory.h> #include <vespa/messagebus/routing/hop.h> +#include <shared_mutex> namespace config { class ICallback; class ConfigFetcher; } -namespace storage { -namespace lib { +namespace storage::lib { class Distribution; class ClusterState; } -} namespace documentapi { class ContentPolicy : public ExternSlobrokPolicy { private: - document::BucketIdFactory _bucketIdFactory; - std::unique_ptr<storage::lib::ClusterState> _state; - string _clusterName; - string _clusterConfigId; - std::unique_ptr<config::ICallback> _callBack; - std::unique_ptr<config::ConfigFetcher> _configFetcher; - std::unique_ptr<storage::lib::Distribution> _distribution; - std::unique_ptr<storage::lib::Distribution> _nextDistribution; + document::BucketIdFactory _bucketIdFactory; + mutable std::shared_mutex _rw_lock; + std::shared_ptr<const storage::lib::ClusterState> _state; + string _clusterName; + string _clusterConfigId; + std::unique_ptr<config::ICallback> _callBack; + std::unique_ptr<config::ConfigFetcher> _configFetcher; + std::shared_ptr<const storage::lib::Distribution> _distribution; + + using StateSnapshot = std::pair<std::shared_ptr<const storage::lib::ClusterState>, + std::shared_ptr<const storage::lib::Distribution>>; + + // Acquires _lock + [[nodiscard]] StateSnapshot internal_state_snapshot(); mbus::Hop getRecipient(mbus::RoutingContext& context, int distributor); + // Acquires _lock + void updateStateFromReply(WrongDistributionReply& reply); + // Acquires _lock + void reset_state(); public: - ContentPolicy(const string& param); - ~ContentPolicy(); + explicit ContentPolicy(const string& param); + ~ContentPolicy() override; void doSelect(mbus::RoutingContext &context) override; void merge(mbus::RoutingContext &context) override; - void updateStateFromReply(WrongDistributionReply& reply); - /** * @return a pointer to the system state registered with this policy. If - * we haven't received a system state yet, returns NULL. + * we haven't received a system state yet, returns nullptr. */ - const storage::lib::ClusterState* getSystemState() const { return _state.get(); } + std::shared_ptr<const storage::lib::ClusterState> getSystemState() const noexcept; virtual void configure(std::unique_ptr<storage::lib::Distribution::DistributionConfig> config); string init() override; private: - string createConfigId(const string & clusterName) const; - string createPattern(const string & clusterName, int distributor) const; + static string createConfigId(const string & clusterName); + static string createPattern(const string & clusterName, int distributor); }; } diff --git a/flags/src/main/java/com/yahoo/vespa/flags/Flags.java b/flags/src/main/java/com/yahoo/vespa/flags/Flags.java index 2b3fe84ec84..d02cccb2885 100644 --- a/flags/src/main/java/com/yahoo/vespa/flags/Flags.java +++ b/flags/src/main/java/com/yahoo/vespa/flags/Flags.java @@ -309,12 +309,6 @@ public class Flags { "Whether to enable CrowdStrike.", "Takes effect on next host admin tick", HOSTNAME); - public static final UnboundBooleanFlag RANDOMIZED_ENDPOINT_NAMES = defineFeatureFlag( - "randomized-endpoint-names", false, List.of("andreer"), "2023-04-26", "2023-11-14", - "Whether to use randomized endpoint names", - "Takes effect on application deployment", - INSTANCE_ID, APPLICATION_ID, TENANT_ID); - public static final UnboundBooleanFlag ENABLE_THE_ONE_THAT_SHOULD_NOT_BE_NAMED = defineFeatureFlag( "enable-the-one-that-should-not-be-named", false, List.of("hmusum"), "2023-05-08", "2023-11-01", "Whether to enable the one program that should not be named", @@ -340,6 +334,13 @@ public class Flags { "Takes effect at redeployment", INSTANCE_ID); + public static final UnboundBooleanFlag EXCLUSIVE_PROVISIONING = defineFeatureFlag( + "exclusive-provisioning", false, + List.of("hakonhall"), "2023-10-12", "2023-12-12", + "Whether to provision a host exclusively to an application ID only based on exclusive=\"true\" from services.xml. " + + "Enabling this will produce hosts with exclusiveTo[ApplicationId] without provisionedToApplicationId.", + "Takes immediate effect when provisioning new hosts"); + public static final UnboundBooleanFlag WRITE_CONFIG_SERVER_SESSION_DATA_AS_ONE_BLOB = defineFeatureFlag( "write-config-server-session-data-as-blob", false, List.of("hmusum"), "2023-07-19", "2023-11-01", @@ -380,20 +381,6 @@ public class Flags { "Takes effect immediately", INSTANCE_ID, CLUSTER_ID, CLUSTER_TYPE); - public static final UnboundBooleanFlag ASSIGN_RANDOMIZED_ID = defineFeatureFlag( - "assign-randomized-id", true, - List.of("mortent"), "2023-08-31", "2024-02-01", - "Whether to assign randomized id to the application", - "Takes effect immediately", - INSTANCE_ID); - - public static final UnboundIntFlag ASSIGNED_RANDOMIZED_ID_RATE = defineIntFlag( - "assign-randomized-id-rate", 5, - List.of("mortent"), "2023-09-11", "2024-02-01", - "Rate for requesting assigned ids for existing certificates. Rate is per maintainer cycle.", - "Takes effect immediately", - INSTANCE_ID); - public static final UnboundIntFlag CONTENT_LAYER_METADATA_FEATURE_LEVEL = defineIntFlag( "content-layer-metadata-feature-level", 0, List.of("vekterli"), "2022-09-12", "2024-02-01", @@ -416,12 +403,6 @@ public class Flags { "Takes effect at redeployment", INSTANCE_ID); - public static final UnboundBooleanFlag LEGACY_ENDPOINTS = defineFeatureFlag( - "legacy-endpoints", true, List.of("mpolden", "tokle"), "2023-09-29", "2024-03-01", - "Whether legacy (non-anonymized) endpoints should be created in DNS", - "Takes effect on redeployment through controller", - INSTANCE_ID, APPLICATION_ID, TENANT_ID); - public static final UnboundIntFlag SEARCH_HANDLER_THREADPOOL = defineIntFlag( "search-handler-threadpool", 2, List.of("bjorncs", "baldersheim"), "2023-10-01", "2024-01-01", diff --git a/metrics/src/main/java/ai/vespa/metrics/ControllerMetrics.java b/metrics/src/main/java/ai/vespa/metrics/ControllerMetrics.java index f03c54aa822..34cf2d98ef8 100644 --- a/metrics/src/main/java/ai/vespa/metrics/ControllerMetrics.java +++ b/metrics/src/main/java/ai/vespa/metrics/ControllerMetrics.java @@ -61,7 +61,12 @@ public enum ControllerMetrics implements VespaMetrics { METERING_MEMORY_GB("metering.memoryGB", Unit.GIGABYTE, "Controller: Metering memory GB"), METERING_VCPU("metering.vcpu", Unit.VCPU, "Controller: Metering VCPU"), METERING_LAST_REPORTED("metering_last_reported", Unit.SECONDS_SINCE_EPOCH, "Controller: Metering last reported"), - METERING_TOTAL_REPORTED("metering_total_reported", Unit.ITEM, "Controller: Metering total reported (sum of resources)"); + METERING_TOTAL_REPORTED("metering_total_reported", Unit.ITEM, "Controller: Metering total reported (sum of resources)"), + + MAIL_SENT("mail.sent", Unit.OPERATION, "Mail sent"), + MAIL_FAILED("mail.failed", Unit.OPERATION, "Mail delivery failed"), + MAIL_THROTTLED("mail.throttled", Unit.OPERATION, "Mail delivery throttled"); + private final String name; private final Unit unit; diff --git a/metrics/src/main/java/ai/vespa/metrics/docs/MetricSetDocumentation.java b/metrics/src/main/java/ai/vespa/metrics/docs/MetricSetDocumentation.java index b046d55c089..a15f2916091 100644 --- a/metrics/src/main/java/ai/vespa/metrics/docs/MetricSetDocumentation.java +++ b/metrics/src/main/java/ai/vespa/metrics/docs/MetricSetDocumentation.java @@ -45,8 +45,14 @@ public class MetricSetDocumentation { referenceBuilder.append(String.format(""" --- # Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + # Note: This file is generated by + # https://github.com/vespa-engine/vespa/blob/master/metrics/src/main/java/ai/vespa/metrics/docs/MetricSetDocumentation.java title: "%s Metric Set" - ---""", name)); + --- + <p> + This document provides reference documentation for the %s metric set, including suffixes present per metric. + If the suffix column contains "N/A" then the base name of the corresponding metric is used with no suffix. + </p>""", name, name)); metricsByType.keySet() .stream() .sorted() diff --git a/metrics/src/main/java/ai/vespa/metrics/set/InfrastructureMetricSet.java b/metrics/src/main/java/ai/vespa/metrics/set/InfrastructureMetricSet.java index 9443a08e28b..36750adb749 100644 --- a/metrics/src/main/java/ai/vespa/metrics/set/InfrastructureMetricSet.java +++ b/metrics/src/main/java/ai/vespa/metrics/set/InfrastructureMetricSet.java @@ -181,6 +181,10 @@ public class InfrastructureMetricSet { addMetric(metrics, ControllerMetrics.METERING_AGE_SECONDS.min()); addMetric(metrics, ControllerMetrics.METERING_LAST_REPORTED.max()); + addMetric(metrics, ControllerMetrics.MAIL_SENT.count()); + addMetric(metrics, ControllerMetrics.MAIL_FAILED.count()); + addMetric(metrics, ControllerMetrics.MAIL_THROTTLED.count()); + return metrics; } diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/HostProvisioner.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/HostProvisioner.java index 09d6f96d88e..a876999e80b 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/HostProvisioner.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/HostProvisioner.java @@ -22,14 +22,21 @@ public interface HostProvisioner { enum HostSharing { - /** The host must be provisioned exclusively for the applicationId */ + /** The host must be provisioned exclusively for the application ID. */ + provision, + + /** The host must be exclusive to a single application ID */ exclusive, /** The host must be provisioned to be shared with other applications. */ shared, /** The client has no requirements on whether the host must be provisioned exclusively or shared. */ - any + any; + + public boolean isExclusiveAllocation() { + return this == provision || this == exclusive; + } } diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/Preparer.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/Preparer.java index 0ffd42aedba..89ff0938d59 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/Preparer.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/Preparer.java @@ -208,7 +208,9 @@ public class Preparer { private HostSharing hostSharing(ClusterSpec cluster, NodeType hostType) { if ( hostType.isSharable()) - return nodeRepository.exclusiveAllocation(cluster) ? HostSharing.exclusive : HostSharing.any; + return cluster.isExclusive() ? HostSharing.provision : + nodeRepository.exclusiveAllocation(cluster) ? HostSharing.exclusive : + HostSharing.any; else return HostSharing.any; } diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/ProvisionedHost.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/ProvisionedHost.java index 7da80440667..8a84cfef09a 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/ProvisionedHost.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/ProvisionedHost.java @@ -31,6 +31,7 @@ public class ProvisionedHost { private final Flavor hostFlavor; private final NodeType hostType; private final Optional<ApplicationId> provisionedForApplicationId; + private final Optional<ApplicationId> exclusiveToApplicationId; private final Optional<ClusterSpec.Type> exclusiveToClusterType; private final List<HostName> nodeHostnames; private final NodeResources nodeResources; @@ -38,7 +39,9 @@ public class ProvisionedHost { private final CloudAccount cloudAccount; public ProvisionedHost(String id, String hostHostname, Flavor hostFlavor, NodeType hostType, - Optional<ApplicationId> provisionedForApplicationId, Optional<ClusterSpec.Type> exclusiveToClusterType, + Optional<ApplicationId> provisionedForApplicationId, + Optional<ApplicationId> exclusiveToApplicationId, + Optional<ClusterSpec.Type> exclusiveToClusterType, List<HostName> nodeHostnames, NodeResources nodeResources, Version osVersion, CloudAccount cloudAccount) { if (!hostType.isHost()) throw new IllegalArgumentException(hostType + " is not a host"); @@ -47,6 +50,7 @@ public class ProvisionedHost { this.hostFlavor = Objects.requireNonNull(hostFlavor, "Host flavor must be set"); this.hostType = Objects.requireNonNull(hostType, "Host type must be set"); this.provisionedForApplicationId = Objects.requireNonNull(provisionedForApplicationId, "provisionedForApplicationId must be set"); + this.exclusiveToApplicationId = Objects.requireNonNull(exclusiveToApplicationId, "exclusiveToApplicationId must be set"); this.exclusiveToClusterType = Objects.requireNonNull(exclusiveToClusterType, "exclusiveToClusterType must be set"); this.nodeHostnames = validateNodeAddresses(nodeHostnames); this.nodeResources = Objects.requireNonNull(nodeResources, "Node resources must be set"); @@ -68,6 +72,7 @@ public class ProvisionedHost { .status(Status.initial().withOsVersion(OsVersion.EMPTY.withCurrent(Optional.of(osVersion)))) .cloudAccount(cloudAccount); provisionedForApplicationId.ifPresent(builder::provisionedForApplicationId); + exclusiveToApplicationId.ifPresent(builder::exclusiveToApplicationId); exclusiveToClusterType.ifPresent(builder::exclusiveToClusterType); if ( ! hostTTL.isZero()) builder.hostTTL(hostTTL); return builder.build(); @@ -85,6 +90,7 @@ public class ProvisionedHost { public Flavor hostFlavor() { return hostFlavor; } public NodeType hostType() { return hostType; } public Optional<ApplicationId> provisionedForApplicationId() { return provisionedForApplicationId; } + public Optional<ApplicationId> exclusiveToApplicationId() { return exclusiveToApplicationId; } public Optional<ClusterSpec.Type> exclusiveToClusterType() { return exclusiveToClusterType; } public List<HostName> nodeHostnames() { return nodeHostnames; } public NodeResources nodeResources() { return nodeResources; } @@ -103,6 +109,7 @@ public class ProvisionedHost { hostFlavor.equals(that.hostFlavor) && hostType == that.hostType && provisionedForApplicationId.equals(that.provisionedForApplicationId) && + exclusiveToApplicationId.equals(that.exclusiveToApplicationId) && exclusiveToClusterType.equals(that.exclusiveToClusterType) && nodeHostnames.equals(that.nodeHostnames) && nodeResources.equals(that.nodeResources) && @@ -112,7 +119,7 @@ public class ProvisionedHost { @Override public int hashCode() { - return Objects.hash(id, hostHostname, hostFlavor, hostType, provisionedForApplicationId, exclusiveToClusterType, nodeHostnames, nodeResources, osVersion, cloudAccount); + return Objects.hash(id, hostHostname, hostFlavor, hostType, provisionedForApplicationId, exclusiveToApplicationId, exclusiveToClusterType, nodeHostnames, nodeResources, osVersion, cloudAccount); } @Override @@ -123,8 +130,9 @@ public class ProvisionedHost { ", hostFlavor=" + hostFlavor + ", hostType=" + hostType + ", provisionedForApplicationId=" + provisionedForApplicationId + + ", exclusiveToApplicationId=" + exclusiveToApplicationId + ", exclusiveToClusterType=" + exclusiveToClusterType + - ", nodeAddresses=" + nodeHostnames + + ", nodeHostnames=" + nodeHostnames + ", nodeResources=" + nodeResources + ", osVersion=" + osVersion + ", cloudAccount=" + cloudAccount + diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockHostProvisioner.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockHostProvisioner.java index def3e003ab3..f7710ca7019 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockHostProvisioner.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockHostProvisioner.java @@ -78,8 +78,8 @@ public class MockHostProvisioner implements HostProvisioner { Flavor hostFlavor = hostFlavors.get(request.clusterType().orElse(ClusterSpec.Type.content)); if (hostFlavor == null) hostFlavor = flavors.stream() - .filter(f -> request.sharing() == HostSharing.exclusive ? compatible(f, request.resources()) - : satisfies(f, request.resources())) + .filter(f -> request.sharing().isExclusiveAllocation() ? compatible(f, request.resources()) + : satisfies(f, request.resources())) .filter(f -> realHostResourcesWithinLimits.test(f.resources())) .findFirst() .orElseThrow(() -> new NodeAllocationException("No host flavor matches " + request.resources(), true)); @@ -91,7 +91,8 @@ public class MockHostProvisioner implements HostProvisioner { hostHostname, hostFlavor, request.type(), - request.sharing() == HostSharing.exclusive ? Optional.of(request.owner()) : Optional.empty(), + request.sharing() == HostSharing.provision ? Optional.of(request.owner()) : Optional.empty(), + request.sharing().isExclusiveAllocation() ? Optional.of(request.owner()) : Optional.empty(), Optional.empty(), createHostnames(request.type(), hostFlavor, index), request.resources(), diff --git a/screwdriver.yaml b/screwdriver.yaml index 6efb9145b09..a3eedc02999 100644 --- a/screwdriver.yaml +++ b/screwdriver.yaml @@ -34,7 +34,7 @@ shared: du -sh /tmp/vespa/* if [[ -z "$SD_PULL_REQUEST" ]]; then - if [[ -z $VESPA_USE_SANITIZER ]] || [[ $VESPA_USE_SANITIZER == null ]]; then + if [[ -z "$VESPA_USE_SANITIZER" ]] || [[ "$VESPA_USE_SANITIZER" == null ]]; then # Remove what we have produced rm -rf $LOCAL_MVN_REPO/com/yahoo rm -rf $LOCAL_MVN_REPO/ai/vespa diff --git a/storage/src/tests/storageserver/documentapiconvertertest.cpp b/storage/src/tests/storageserver/documentapiconvertertest.cpp index 5e70c00cc5f..eb4789b25d4 100644 --- a/storage/src/tests/storageserver/documentapiconvertertest.cpp +++ b/storage/src/tests/storageserver/documentapiconvertertest.cpp @@ -77,7 +77,7 @@ struct DocumentApiConverterTest : Test { } void SetUp() override { - _converter = std::make_unique<DocumentApiConverter>(config::ConfigUri("raw:"), _bucketResolver); + _converter = std::make_unique<DocumentApiConverter>(_bucketResolver); }; template <typename DerivedT, typename BaseT> diff --git a/storage/src/tests/storageserver/priorityconvertertest.cpp b/storage/src/tests/storageserver/priorityconvertertest.cpp index 5462c83d2a2..69f9d313242 100644 --- a/storage/src/tests/storageserver/priorityconvertertest.cpp +++ b/storage/src/tests/storageserver/priorityconvertertest.cpp @@ -1,7 +1,6 @@ // Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. #include <vespa/storage/storageserver/priorityconverter.h> -#include <tests/common/testhelper.h> #include <vespa/vespalib/gtest/gtest.h> using namespace ::testing; @@ -12,8 +11,7 @@ struct PriorityConverterTest : Test { std::unique_ptr<PriorityConverter> _converter; void SetUp() override { - vdstestlib::DirConfig config(getStandardConfig(true)); - _converter = std::make_unique<PriorityConverter>(config::ConfigUri(config.getConfigId())); + _converter = std::make_unique<PriorityConverter>(); }; }; diff --git a/storage/src/tests/storageserver/testvisitormessagesession.h b/storage/src/tests/storageserver/testvisitormessagesession.h index 86d4dc92a58..cc7dab7ef9e 100644 --- a/storage/src/tests/storageserver/testvisitormessagesession.h +++ b/storage/src/tests/storageserver/testvisitormessagesession.h @@ -49,9 +49,11 @@ struct TestVisitorMessageSessionFactory : public VisitorMessageSessionFactory bool _createAutoReplyVisitorSessions; PriorityConverter _priConverter; - TestVisitorMessageSessionFactory(vespalib::stringref configId = "") + TestVisitorMessageSessionFactory() : _createAutoReplyVisitorSessions(false), - _priConverter(config::ConfigUri(configId)) {} + _priConverter() + { + } VisitorMessageSession::UP createSession(Visitor& v, VisitorThread& vt) override { std::lock_guard lock(_accessLock); diff --git a/storage/src/tests/visiting/visitormanagertest.cpp b/storage/src/tests/visiting/visitormanagertest.cpp index 991b98e5489..2b7039f36ea 100644 --- a/storage/src/tests/visiting/visitormanagertest.cpp +++ b/storage/src/tests/visiting/visitormanagertest.cpp @@ -83,7 +83,7 @@ VisitorManagerTest::initializeTest(bool defer_manager_thread_start) vdstestlib::DirConfig config(getStandardConfig(true)); config.getConfig("stor-visitor").set("visitorthreads", "1"); - _messageSessionFactory = std::make_unique<TestVisitorMessageSessionFactory>(config.getConfigId()); + _messageSessionFactory = std::make_unique<TestVisitorMessageSessionFactory>(); _node = std::make_unique<TestServiceLayerApp>(config.getConfigId()); _node->setupDummyPersistence(); _node->getStateUpdater().setClusterState(std::make_shared<lib::ClusterState>("storage:1 distributor:1")); diff --git a/storage/src/tests/visiting/visitortest.cpp b/storage/src/tests/visiting/visitortest.cpp index 1f1d27ab4cb..49f1bc778fc 100644 --- a/storage/src/tests/visiting/visitortest.cpp +++ b/storage/src/tests/visiting/visitortest.cpp @@ -161,7 +161,7 @@ VisitorTest::initializeTest(const TestParams& params) std::filesystem::create_directories(std::filesystem::path(vespalib::make_string("%s/disks/d0", rootFolder.c_str()))); std::filesystem::create_directories(std::filesystem::path(vespalib::make_string("%s/disks/d1", rootFolder.c_str()))); - _messageSessionFactory = std::make_unique<TestVisitorMessageSessionFactory>(config.getConfigId()); + _messageSessionFactory = std::make_unique<TestVisitorMessageSessionFactory>(); if (params._autoReplyError.getCode() != mbus::ErrorCode::NONE) { _messageSessionFactory->_autoReplyError = params._autoReplyError; _messageSessionFactory->_createAutoReplyVisitorSessions = true; diff --git a/storage/src/vespa/storage/storageserver/communicationmanager.cpp b/storage/src/vespa/storage/storageserver/communicationmanager.cpp index c126ec01dc6..bbd4e87cb40 100644 --- a/storage/src/vespa/storage/storageserver/communicationmanager.cpp +++ b/storage/src/vespa/storage/storageserver/communicationmanager.cpp @@ -230,7 +230,7 @@ CommunicationManager::CommunicationManager(StorageComponentRegister& compReg, _mbus(), _configUri(configUri), _closed(false), - _docApiConverter(configUri, std::make_shared<PlaceHolderBucketResolver>()), // TODO wire config from outside + _docApiConverter(std::make_shared<PlaceHolderBucketResolver>()), _thread() { _component.registerMetricUpdateHook(*this, 5s); diff --git a/storage/src/vespa/storage/storageserver/documentapiconverter.cpp b/storage/src/vespa/storage/storageserver/documentapiconverter.cpp index 04b3d8b6ce7..ca46e87285b 100644 --- a/storage/src/vespa/storage/storageserver/documentapiconverter.cpp +++ b/storage/src/vespa/storage/storageserver/documentapiconverter.cpp @@ -23,9 +23,8 @@ using document::BucketSpace; namespace storage { -DocumentApiConverter::DocumentApiConverter(const config::ConfigUri &configUri, - std::shared_ptr<const BucketResolver> bucketResolver) - : _priConverter(std::make_unique<PriorityConverter>(configUri)), +DocumentApiConverter::DocumentApiConverter(std::shared_ptr<const BucketResolver> bucketResolver) + : _priConverter(std::make_unique<PriorityConverter>()), _bucketResolver(std::move(bucketResolver)) {} diff --git a/storage/src/vespa/storage/storageserver/documentapiconverter.h b/storage/src/vespa/storage/storageserver/documentapiconverter.h index 5990d6f9017..96b119ff44e 100644 --- a/storage/src/vespa/storage/storageserver/documentapiconverter.h +++ b/storage/src/vespa/storage/storageserver/documentapiconverter.h @@ -22,18 +22,17 @@ class PriorityConverter; class DocumentApiConverter { public: - DocumentApiConverter(const config::ConfigUri &configUri, - std::shared_ptr<const BucketResolver> bucketResolver); + explicit DocumentApiConverter(std::shared_ptr<const BucketResolver> bucketResolver); ~DocumentApiConverter(); - std::unique_ptr<api::StorageCommand> toStorageAPI(documentapi::DocumentMessage& msg); - std::unique_ptr<api::StorageReply> toStorageAPI(documentapi::DocumentReply& reply, api::StorageCommand& originalCommand); + [[nodiscard]] std::unique_ptr<api::StorageCommand> toStorageAPI(documentapi::DocumentMessage& msg); + [[nodiscard]] std::unique_ptr<api::StorageReply> toStorageAPI(documentapi::DocumentReply& reply, api::StorageCommand& originalCommand); void transferReplyState(storage::api::StorageReply& from, mbus::Reply& to); - std::unique_ptr<mbus::Message> toDocumentAPI(api::StorageCommand& cmd); + [[nodiscard]] std::unique_ptr<mbus::Message> toDocumentAPI(api::StorageCommand& cmd); const PriorityConverter& getPriorityConverter() const { return *_priConverter; } // BucketResolver getter and setter are both thread safe. - std::shared_ptr<const BucketResolver> bucketResolver() const; + [[nodiscard]] std::shared_ptr<const BucketResolver> bucketResolver() const; void setBucketResolver(std::shared_ptr<const BucketResolver> resolver); private: mutable std::mutex _mutex; diff --git a/storage/src/vespa/storage/storageserver/priorityconverter.cpp b/storage/src/vespa/storage/storageserver/priorityconverter.cpp index 13ab572c561..fe7570ff53a 100644 --- a/storage/src/vespa/storage/storageserver/priorityconverter.cpp +++ b/storage/src/vespa/storage/storageserver/priorityconverter.cpp @@ -1,85 +1,91 @@ // Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. #include "priorityconverter.h" -#include <vespa/config/subscription/configuri.h> -#include <vespa/config/helper/configfetcher.hpp> - +#include <map> namespace storage { -PriorityConverter::PriorityConverter(const config::ConfigUri & configUri) - : _configFetcher(std::make_unique<config::ConfigFetcher>(configUri.getContext())) +PriorityConverter::PriorityConverter() + : _mapping(), + _reverse_mapping() { - _configFetcher->subscribe<vespa::config::content::core::StorPrioritymappingConfig>(configUri.getConfigId(), this); - _configFetcher->start(); + init_static_priority_mappings(); } PriorityConverter::~PriorityConverter() = default; -uint8_t -PriorityConverter::toStoragePriority(documentapi::Priority::Value documentApiPriority) const +void +PriorityConverter::init_static_priority_mappings() { - const uint32_t index(static_cast<uint32_t>(documentApiPriority)); - if (index >= PRI_ENUM_SIZE) { - return 255; - } + // Defaults from `stor-prioritymapping` config + constexpr uint8_t highest = 50; + constexpr uint8_t very_high = 60; + constexpr uint8_t high_1 = 70; + constexpr uint8_t high_2 = 80; + constexpr uint8_t high_3 = 90; + constexpr uint8_t normal_1 = 100; + constexpr uint8_t normal_2 = 110; + constexpr uint8_t normal_3 = 120; + constexpr uint8_t normal_4 = 130; + constexpr uint8_t normal_5 = 140; + constexpr uint8_t normal_6 = 150; + constexpr uint8_t low_1 = 160; + constexpr uint8_t low_2 = 170; + constexpr uint8_t low_3 = 180; + constexpr uint8_t very_low = 190; + constexpr uint8_t lowest = 200; - return _mapping[index]; -} + _mapping[documentapi::Priority::PRI_HIGHEST] = highest; + _mapping[documentapi::Priority::PRI_VERY_HIGH] = very_high; + _mapping[documentapi::Priority::PRI_HIGH_1] = high_1; + _mapping[documentapi::Priority::PRI_HIGH_2] = high_2; + _mapping[documentapi::Priority::PRI_HIGH_3] = high_3; + _mapping[documentapi::Priority::PRI_NORMAL_1] = normal_1; + _mapping[documentapi::Priority::PRI_NORMAL_2] = normal_2; + _mapping[documentapi::Priority::PRI_NORMAL_3] = normal_3; + _mapping[documentapi::Priority::PRI_NORMAL_4] = normal_4; + _mapping[documentapi::Priority::PRI_NORMAL_5] = normal_5; + _mapping[documentapi::Priority::PRI_NORMAL_6] = normal_6; + _mapping[documentapi::Priority::PRI_LOW_1] = low_1; + _mapping[documentapi::Priority::PRI_LOW_2] = low_2; + _mapping[documentapi::Priority::PRI_LOW_3] = low_3; + _mapping[documentapi::Priority::PRI_VERY_LOW] = very_low; + _mapping[documentapi::Priority::PRI_LOWEST] = lowest; -documentapi::Priority::Value -PriorityConverter::toDocumentPriority(uint8_t storagePriority) const -{ - std::lock_guard guard(_mutex); - std::map<uint8_t, documentapi::Priority::Value>::const_iterator iter = - _reverseMapping.lower_bound(storagePriority); + std::map<uint8_t, documentapi::Priority::Value> reverse_map_helper; + reverse_map_helper[highest] = documentapi::Priority::PRI_HIGHEST; + reverse_map_helper[very_high] = documentapi::Priority::PRI_VERY_HIGH; + reverse_map_helper[high_1] = documentapi::Priority::PRI_HIGH_1; + reverse_map_helper[high_2] = documentapi::Priority::PRI_HIGH_2; + reverse_map_helper[high_3] = documentapi::Priority::PRI_HIGH_3; + reverse_map_helper[normal_1] = documentapi::Priority::PRI_NORMAL_1; + reverse_map_helper[normal_2] = documentapi::Priority::PRI_NORMAL_2; + reverse_map_helper[normal_3] = documentapi::Priority::PRI_NORMAL_3; + reverse_map_helper[normal_4] = documentapi::Priority::PRI_NORMAL_4; + reverse_map_helper[normal_5] = documentapi::Priority::PRI_NORMAL_5; + reverse_map_helper[normal_6] = documentapi::Priority::PRI_NORMAL_6; + reverse_map_helper[low_1] = documentapi::Priority::PRI_LOW_1; + reverse_map_helper[low_2] = documentapi::Priority::PRI_LOW_2; + reverse_map_helper[low_3] = documentapi::Priority::PRI_LOW_3; + reverse_map_helper[very_low] = documentapi::Priority::PRI_VERY_LOW; + reverse_map_helper[lowest] = documentapi::Priority::PRI_LOWEST; - if (iter != _reverseMapping.end()) { - return iter->second; + // Precompute a 1-1 LUT to avoid having to lower-bound lookup values in a fixed map + _reverse_mapping.resize(256); + for (size_t i = 0; i < 256; ++i) { + auto iter = reverse_map_helper.lower_bound(static_cast<uint8_t>(i)); + _reverse_mapping[i] = (iter != reverse_map_helper.cend()) ? iter->second : documentapi::Priority::PRI_LOWEST; } - - return documentapi::Priority::PRI_LOWEST; } -void -PriorityConverter::configure(std::unique_ptr<vespa::config::content::core::StorPrioritymappingConfig> config) +uint8_t +PriorityConverter::toStoragePriority(documentapi::Priority::Value documentApiPriority) const noexcept { - // Data race free; _mapping is an array of std::atomic. - _mapping[documentapi::Priority::PRI_HIGHEST] = config->highest; - _mapping[documentapi::Priority::PRI_VERY_HIGH] = config->veryHigh; - _mapping[documentapi::Priority::PRI_HIGH_1] = config->high1; - _mapping[documentapi::Priority::PRI_HIGH_2] = config->high2; - _mapping[documentapi::Priority::PRI_HIGH_3] = config->high3; - _mapping[documentapi::Priority::PRI_NORMAL_1] = config->normal1; - _mapping[documentapi::Priority::PRI_NORMAL_2] = config->normal2; - _mapping[documentapi::Priority::PRI_NORMAL_3] = config->normal3; - _mapping[documentapi::Priority::PRI_NORMAL_4] = config->normal4; - _mapping[documentapi::Priority::PRI_NORMAL_5] = config->normal5; - _mapping[documentapi::Priority::PRI_NORMAL_6] = config->normal6; - _mapping[documentapi::Priority::PRI_LOW_1] = config->low1; - _mapping[documentapi::Priority::PRI_LOW_2] = config->low2; - _mapping[documentapi::Priority::PRI_LOW_3] = config->low3; - _mapping[documentapi::Priority::PRI_VERY_LOW] = config->veryLow; - _mapping[documentapi::Priority::PRI_LOWEST] = config->lowest; - - std::lock_guard guard(_mutex); - _reverseMapping.clear(); - _reverseMapping[config->highest] = documentapi::Priority::PRI_HIGHEST; - _reverseMapping[config->veryHigh] = documentapi::Priority::PRI_VERY_HIGH; - _reverseMapping[config->high1] = documentapi::Priority::PRI_HIGH_1; - _reverseMapping[config->high2] = documentapi::Priority::PRI_HIGH_2; - _reverseMapping[config->high3] = documentapi::Priority::PRI_HIGH_3; - _reverseMapping[config->normal1] = documentapi::Priority::PRI_NORMAL_1; - _reverseMapping[config->normal2] = documentapi::Priority::PRI_NORMAL_2; - _reverseMapping[config->normal3] = documentapi::Priority::PRI_NORMAL_3; - _reverseMapping[config->normal4] = documentapi::Priority::PRI_NORMAL_4; - _reverseMapping[config->normal5] = documentapi::Priority::PRI_NORMAL_5; - _reverseMapping[config->normal6] = documentapi::Priority::PRI_NORMAL_6; - _reverseMapping[config->low1] = documentapi::Priority::PRI_LOW_1; - _reverseMapping[config->low2] = documentapi::Priority::PRI_LOW_2; - _reverseMapping[config->low3] = documentapi::Priority::PRI_LOW_3; - _reverseMapping[config->veryLow] = documentapi::Priority::PRI_VERY_LOW; - _reverseMapping[config->lowest] = documentapi::Priority::PRI_LOWEST; + const auto index = static_cast<uint32_t>(documentApiPriority); + if (index >= PRI_ENUM_SIZE) { + return 255; + } + return _mapping[index]; } } // storage diff --git a/storage/src/vespa/storage/storageserver/priorityconverter.h b/storage/src/vespa/storage/storageserver/priorityconverter.h index 47326e54243..48c7424433b 100644 --- a/storage/src/vespa/storage/storageserver/priorityconverter.h +++ b/storage/src/vespa/storage/storageserver/priorityconverter.h @@ -2,50 +2,34 @@ #pragma once -#include <vespa/storage/config/config-stor-prioritymapping.h> -#include <vespa/config/helper/ifetchercallback.h> #include <vespa/documentapi/messagebus/priority.h> -#include <atomic> #include <array> -#include <mutex> - -namespace config { - class ConfigUri; - class ConfigFetcher; -} +#include <vector> namespace storage { -class PriorityConverter - : public config::IFetcherCallback< - vespa::config::content::core::StorPrioritymappingConfig> -{ +class PriorityConverter { public: - using Config = vespa::config::content::core::StorPrioritymappingConfig; - - explicit PriorityConverter(const config::ConfigUri& configUri); - ~PriorityConverter() override; + PriorityConverter(); + ~PriorityConverter(); /** Converts the given priority into a storage api priority number. */ - uint8_t toStoragePriority(documentapi::Priority::Value) const; + [[nodiscard]] uint8_t toStoragePriority(documentapi::Priority::Value) const noexcept; /** Converts the given priority into a document api priority number. */ - documentapi::Priority::Value toDocumentPriority(uint8_t) const; - - void configure(std::unique_ptr<Config> config) override; + [[nodiscard]] documentapi::Priority::Value toDocumentPriority(uint8_t storage_priority) const noexcept { + return _reverse_mapping[storage_priority]; + } private: - static_assert(documentapi::Priority::PRI_ENUM_SIZE == 16, - "Unexpected size of priority enumeration"); - static_assert(documentapi::Priority::PRI_LOWEST == 15, - "Priority enum value out of bounds"); - static constexpr size_t PRI_ENUM_SIZE = documentapi::Priority::PRI_ENUM_SIZE; + void init_static_priority_mappings(); - std::array<std::atomic<uint8_t>, PRI_ENUM_SIZE> _mapping; - std::map<uint8_t, documentapi::Priority::Value> _reverseMapping; - mutable std::mutex _mutex; + static_assert(documentapi::Priority::PRI_ENUM_SIZE == 16, "Unexpected size of priority enumeration"); + static_assert(documentapi::Priority::PRI_LOWEST == 15, "Priority enum value out of bounds"); + static constexpr size_t PRI_ENUM_SIZE = documentapi::Priority::PRI_ENUM_SIZE; - std::unique_ptr<config::ConfigFetcher> _configFetcher; + std::array<uint8_t, PRI_ENUM_SIZE> _mapping; + std::vector<documentapi::Priority::Value> _reverse_mapping; }; } // storage diff --git a/vespajlib/abi-spec.json b/vespajlib/abi-spec.json index 1c19c2ba5d6..3e588e24d47 100644 --- a/vespajlib/abi-spec.json +++ b/vespajlib/abi-spec.json @@ -4045,7 +4045,8 @@ "abstract" ], "methods" : [ - "public abstract java.util.List complete(ai.vespa.llm.completion.Prompt)" + "public abstract java.util.List complete(ai.vespa.llm.completion.Prompt)", + "public abstract java.util.concurrent.CompletableFuture completeAsync(ai.vespa.llm.completion.Prompt, java.util.function.Consumer)" ], "fields" : [ ] }, @@ -4059,6 +4060,7 @@ "public void <init>(java.lang.String)", "public ai.vespa.llm.client.openai.OpenAiClient$Builder model(java.lang.String)", "public ai.vespa.llm.client.openai.OpenAiClient$Builder temperature(double)", + "public ai.vespa.llm.client.openai.OpenAiClient$Builder maxTokens(long)", "public ai.vespa.llm.client.openai.OpenAiClient build()" ], "fields" : [ ] @@ -4072,7 +4074,8 @@ "public" ], "methods" : [ - "public java.util.List complete(ai.vespa.llm.completion.Prompt)" + "public java.util.List complete(ai.vespa.llm.completion.Prompt)", + "public java.util.concurrent.CompletableFuture completeAsync(ai.vespa.llm.completion.Prompt, java.util.function.Consumer)" ], "fields" : [ ] }, @@ -4090,7 +4093,8 @@ ], "fields" : [ "public static final enum ai.vespa.llm.completion.Completion$FinishReason length", - "public static final enum ai.vespa.llm.completion.Completion$FinishReason stop" + "public static final enum ai.vespa.llm.completion.Completion$FinishReason stop", + "public static final enum ai.vespa.llm.completion.Completion$FinishReason none" ] }, "ai.vespa.llm.completion.Completion" : { @@ -4167,7 +4171,8 @@ ], "methods" : [ "public void <init>(ai.vespa.llm.test.MockLanguageModel$Builder)", - "public java.util.List complete(ai.vespa.llm.completion.Prompt)" + "public java.util.List complete(ai.vespa.llm.completion.Prompt)", + "public java.util.concurrent.CompletableFuture completeAsync(ai.vespa.llm.completion.Prompt, java.util.function.Consumer)" ], "fields" : [ ] } diff --git a/vespajlib/src/main/java/ai/vespa/llm/LanguageModel.java b/vespajlib/src/main/java/ai/vespa/llm/LanguageModel.java index bd9004a659b..f4b8938934b 100644 --- a/vespajlib/src/main/java/ai/vespa/llm/LanguageModel.java +++ b/vespajlib/src/main/java/ai/vespa/llm/LanguageModel.java @@ -6,6 +6,8 @@ import ai.vespa.llm.completion.Prompt; import com.yahoo.api.annotations.Beta; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; /** * Interface to language models. @@ -17,4 +19,6 @@ public interface LanguageModel { List<Completion> complete(Prompt prompt); + CompletableFuture<Completion.FinishReason> completeAsync(Prompt prompt, Consumer<Completion> action); + } diff --git a/vespajlib/src/main/java/ai/vespa/llm/client/openai/OpenAiClient.java b/vespajlib/src/main/java/ai/vespa/llm/client/openai/OpenAiClient.java index efa8927988c..d7334b40963 100644 --- a/vespajlib/src/main/java/ai/vespa/llm/client/openai/OpenAiClient.java +++ b/vespajlib/src/main/java/ai/vespa/llm/client/openai/OpenAiClient.java @@ -18,25 +18,34 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * A client to the OpenAI language model API. Refer to https://platform.openai.com/docs/api-reference/. - * Currently only completions are implemented. + * Currently, only completions are implemented. * * @author bratseth */ @Beta public class OpenAiClient implements LanguageModel { + private static final String DATA_FIELD = "data: "; + private final String token; private final String model; private final double temperature; + private final long maxTokens; + private final HttpClient httpClient; private OpenAiClient(Builder builder) { this.token = builder.token; this.model = builder.model; this.temperature = builder.temperature; + this.maxTokens = builder.maxTokens; this.httpClient = HttpClient.newBuilder().build(); } @@ -54,13 +63,63 @@ public class OpenAiClient implements LanguageModel { } } + @Override + public CompletableFuture<Completion.FinishReason> completeAsync(Prompt prompt, Consumer<Completion> consumer) { + try { + var request = toRequest(prompt, true); + var futureResponse = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofLines()); + var completionFuture = new CompletableFuture<Completion.FinishReason>(); + + futureResponse.thenAcceptAsync(response -> { + try { + int responseCode = response.statusCode(); + if (responseCode != 200) { + throw new IllegalArgumentException("Received code " + responseCode + ": " + + response.body().collect(Collectors.joining())); + } + + Stream<String> lines = response.body(); + lines.forEach(line -> { + if (line.startsWith(DATA_FIELD)) { + var root = SlimeUtils.jsonToSlime(line.substring(DATA_FIELD.length())).get(); + var completion = toCompletions(root, "delta").get(0); + consumer.accept(completion); + if (!completion.finishReason().equals(Completion.FinishReason.none)) { + completionFuture.complete(completion.finishReason()); + } + } + }); + } catch (Exception e) { + completionFuture.completeExceptionally(e); + } + }); + return completionFuture; + + } catch (Exception e) { + throw new RuntimeException(e); + } + } + private HttpRequest toRequest(Prompt prompt) throws IOException, URISyntaxException { + return toRequest(prompt, false); + } + + private HttpRequest toRequest(Prompt prompt, boolean stream) throws IOException, URISyntaxException { var slime = new Slime(); var root = slime.setObject(); root.setString("model", model); root.setDouble("temperature", temperature); - root.setString("prompt", prompt.asString()); - return HttpRequest.newBuilder(new URI("https://api.openai.com/v1/completions")) + root.setBool("stream", stream); + root.setLong("n", 1); + if (maxTokens > 0) { + root.setLong("max_tokens", maxTokens); + } + var messagesArray = root.setArray("messages"); + var messagesObject = messagesArray.addObject(); + messagesObject.setString("role", "user"); + messagesObject.setString("content", prompt.asString()); + + return HttpRequest.newBuilder(new URI("https://api.openai.com/v1/chat/completions")) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + token) .POST(HttpRequest.BodyPublishers.ofByteArray(SlimeUtils.toJsonBytes(slime))) @@ -68,21 +127,27 @@ public class OpenAiClient implements LanguageModel { } private List<Completion> toCompletions(Inspector response) { + return toCompletions(response, "message"); + } + + private List<Completion> toCompletions(Inspector response, String field) { List<Completion> completions = new ArrayList<>(); response.field("choices") - .traverse((ArrayTraverser) (__, choice) -> completions.add(toCompletion(choice))); + .traverse((ArrayTraverser) (__, choice) -> completions.add(toCompletion(choice, field))); return completions; } - private Completion toCompletion(Inspector choice) { - return new Completion(choice.field("text").asString(), - toFinishReason(choice.field("finish_reason").asString())); + private Completion toCompletion(Inspector choice, String field) { + var content = choice.field(field).field("content").asString(); + var finishReason = toFinishReason(choice.field("finish_reason").asString()); + return new Completion(content, finishReason); } private Completion.FinishReason toFinishReason(String finishReasonString) { return switch(finishReasonString) { case "length" -> Completion.FinishReason.length; case "stop" -> Completion.FinishReason.stop; + case "", "null" -> Completion.FinishReason.none; default -> throw new IllegalStateException("Unknown OpenAi completion finish reason '" + finishReasonString + "'"); }; } @@ -90,8 +155,9 @@ public class OpenAiClient implements LanguageModel { public static class Builder { private final String token; - private String model = "text-davinci-003"; - private double temperature = 0; + private String model = "gpt-3.5-turbo"; + private double temperature = 0.0; + private long maxTokens = 0; public Builder(String token) { this.token = token; @@ -109,6 +175,12 @@ public class OpenAiClient implements LanguageModel { return this; } + /** Maximum number of tokens to generate */ + public Builder maxTokens(long maxTokens) { + this.maxTokens = maxTokens; + return this; + } + public OpenAiClient build() { return new OpenAiClient(this); } diff --git a/vespajlib/src/main/java/ai/vespa/llm/completion/Completion.java b/vespajlib/src/main/java/ai/vespa/llm/completion/Completion.java index f5731852d93..ea784013812 100644 --- a/vespajlib/src/main/java/ai/vespa/llm/completion/Completion.java +++ b/vespajlib/src/main/java/ai/vespa/llm/completion/Completion.java @@ -19,8 +19,10 @@ public record Completion(String text, FinishReason finishReason) { length, /** The completion is the predicted ending of the prompt. */ - stop + stop, + /** The completion is not finished yet, more tokens are incoming. */ + none } public Completion(String text, FinishReason finishReason) { diff --git a/vespajlib/src/main/java/ai/vespa/llm/test/MockLanguageModel.java b/vespajlib/src/main/java/ai/vespa/llm/test/MockLanguageModel.java index 16e9c4e1848..db1b42fbbac 100644 --- a/vespajlib/src/main/java/ai/vespa/llm/test/MockLanguageModel.java +++ b/vespajlib/src/main/java/ai/vespa/llm/test/MockLanguageModel.java @@ -7,6 +7,8 @@ import ai.vespa.llm.completion.Prompt; import com.yahoo.api.annotations.Beta; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; import java.util.function.Function; /** @@ -26,6 +28,11 @@ public class MockLanguageModel implements LanguageModel { return completer.apply(prompt); } + @Override + public CompletableFuture<Completion.FinishReason> completeAsync(Prompt prompt, Consumer<Completion> action) { + throw new RuntimeException("Not implemented"); + } + public static class Builder { private Function<Prompt, List<Completion>> completer = prompt -> List.of(Completion.from("")); diff --git a/vespajlib/src/test/java/ai/vespa/llm/client/openai/OpenAiClientCompletionTest.java b/vespajlib/src/test/java/ai/vespa/llm/client/openai/OpenAiClientCompletionTest.java index 444f082b1c0..45ef7e270aa 100644 --- a/vespajlib/src/test/java/ai/vespa/llm/client/openai/OpenAiClientCompletionTest.java +++ b/vespajlib/src/test/java/ai/vespa/llm/client/openai/OpenAiClientCompletionTest.java @@ -11,12 +11,14 @@ import org.junit.jupiter.api.Test; */ public class OpenAiClientCompletionTest { + private static final String apiKey = "your-api-key-here"; + @Test @Disabled public void testClient() { - var client = new OpenAiClient.Builder("your token here").build(); + var client = new OpenAiClient.Builder(apiKey).maxTokens(10).build(); String input = "You are an unhelpful assistant who never answers questions straightforwardly. " + - "Be as long-winded as possible. Are humans smarter than cats?"; + "Be as long-winded as possible. Are humans smarter than cats?\n\n"; StringPrompt prompt = StringPrompt.from(input); System.out.print(prompt); for (int i = 0; i < 10; i++) { @@ -27,4 +29,19 @@ public class OpenAiClientCompletionTest { } } + @Test + @Disabled + public void testAsyncClient() { + var client = new OpenAiClient.Builder(apiKey).build(); + String input = "You are an unhelpful assistant who never answers questions straightforwardly. " + + "Be as long-winded as possible. Are humans smarter than cats?\n\n"; + StringPrompt prompt = StringPrompt.from(input); + System.out.print(prompt); + var future = client.completeAsync(prompt, completion -> { + System.out.print(completion.text()); + }); + System.out.println("Waiting for completion..."); + System.out.println("\nFinished streaming because of " + future.join()); + } + } diff --git a/vespalib/src/vespa/fastos/linux_file.cpp b/vespalib/src/vespa/fastos/linux_file.cpp index b6094a050d9..0f32aa953a8 100644 --- a/vespalib/src/vespa/fastos/linux_file.cpp +++ b/vespalib/src/vespa/fastos/linux_file.cpp @@ -202,7 +202,7 @@ FastOS_Linux_File::Write2(const void *buffer, size_t length) if (writtenNow > 0) { written += writtenNow; } else { - return (written > 0) ? written : writtenNow;; + return (written > 0) ? written : writtenNow; } } return written; @@ -239,8 +239,8 @@ FastOS_Linux_File::internalWrite2(const void *buffer, size_t length) } if (writeRes > 0) { _filePointer += writeRes; - if (_filePointer > _cachedSize) { - _cachedSize = _filePointer; + if (_filePointer > _cachedSize.load(std::memory_order_relaxed)) { + _cachedSize.store(_filePointer, std::memory_order_relaxed); } } } else { @@ -277,7 +277,7 @@ FastOS_Linux_File::SetSize(int64_t newSize) bool rc = FastOS_UNIX_File::SetSize(newSize); if (rc) { - _cachedSize = newSize; + _cachedSize.store(newSize, std::memory_order_relaxed); } return rc; } @@ -334,19 +334,21 @@ FastOS_Linux_File::DirectIOPadding (int64_t offset, size_t length, size_t &padBe if (padAfter == _directIOFileAlign) { padAfter = 0; } - if (int64_t(offset+length+padAfter) > _cachedSize) { + int64_t fileSize = _cachedSize.load(std::memory_order_relaxed); + if (int64_t(offset+length+padAfter) > fileSize) { // _cachedSize is not really trustworthy, so if we suspect it is not correct, we correct it. // The main reason is that it will not reflect the file being extended by another filedescriptor. - _cachedSize = getSize(); + fileSize = getSize(); + _cachedSize.store(fileSize, std::memory_order_relaxed); } if ((padAfter != 0) && - (static_cast<int64_t>(offset + length + padAfter) > _cachedSize) && - (static_cast<int64_t>(offset + length) <= _cachedSize)) + (static_cast<int64_t>(offset + length + padAfter) > fileSize) && + (static_cast<int64_t>(offset + length) <= fileSize)) { - padAfter = _cachedSize - (offset + length); + padAfter = fileSize - (offset + length); } - if (static_cast<uint64_t>(offset + length + padAfter) <= static_cast<uint64_t>(_cachedSize)) { + if (static_cast<uint64_t>(offset + length + padAfter) <= static_cast<uint64_t>(fileSize)) { return true; } } diff --git a/vespalib/src/vespa/fastos/linux_file.h b/vespalib/src/vespa/fastos/linux_file.h index 1295ce38316..af6e6af51af 100644 --- a/vespalib/src/vespa/fastos/linux_file.h +++ b/vespalib/src/vespa/fastos/linux_file.h @@ -10,21 +10,23 @@ #pragma once #include "unix_file.h" +#include <atomic> /** * This is the Linux implementation of @ref FastOS_File. Most * methods are inherited from @ref FastOS_UNIX_File. */ -class FastOS_Linux_File : public FastOS_UNIX_File +class FastOS_Linux_File final : public FastOS_UNIX_File { public: using FastOS_UNIX_File::ReadBuf; protected: - int64_t _cachedSize; + std::atomic<int64_t> _cachedSize; int64_t _filePointer; // Only maintained/used in directio mode public: - FastOS_Linux_File (const char *filename = nullptr); + FastOS_Linux_File() : FastOS_Linux_File(nullptr) {} + explicit FastOS_Linux_File(const char *filename); ~FastOS_Linux_File () override; bool GetDirectIORestrictions(size_t &memoryAlignment, size_t &transferGranularity, size_t &transferMaximum) override; bool DirectIOPadding(int64_t offset, size_t length, size_t &padBefore, size_t &padAfter) override; |