summaryrefslogtreecommitdiffstats
path: root/controller-server/src/main/java/com
diff options
context:
space:
mode:
Diffstat (limited to 'controller-server/src/main/java/com')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java2
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/InternalStepRunner.java2
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java13
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/BcpGroupUpdater.java22
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirer.java57
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notification.java42
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationFormatter.java5
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDb.java78
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java11
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java17
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java36
11 files changed, 199 insertions, 86 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java
index 0de0ea06904..d7a3d4fb9e5 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java
@@ -554,7 +554,7 @@ public class ApplicationController {
if (warnings.isEmpty())
controller.notificationsDb().removeNotification(source, Notification.Type.applicationPackage);
else
- controller.notificationsDb().setNotification(source, Notification.Type.applicationPackage, Notification.Level.warning, warnings);
+ controller.notificationsDb().setApplicationPackageNotification(source, warnings);
}
lockApplicationOrThrow(applicationId, application ->
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/InternalStepRunner.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/InternalStepRunner.java
index d62e477a51f..9bfa2674754 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/InternalStepRunner.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/InternalStepRunner.java
@@ -856,7 +856,7 @@ public class InternalStepRunner implements StepRunner {
private void updateConsoleNotification(Run run, boolean isRemoved) {
NotificationSource source = NotificationSource.from(run.id());
- Consumer<String> updater = msg -> controller.notificationsDb().setNotification(source, Notification.Type.deployment, Notification.Level.error, msg);
+ Consumer<String> updater = msg -> controller.notificationsDb().setDeploymentNotification(run.id(), msg);
switch (isRemoved ? success : run.status()) {
case aborted, cancelled: return; // wait and see how the next run goes.
case noTests:
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java
index 0dc30f54d61..ae6bcdea00c 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java
@@ -37,7 +37,6 @@ import com.yahoo.vespa.hosted.controller.application.pkg.ApplicationPackage;
import com.yahoo.vespa.hosted.controller.application.pkg.ApplicationPackageDiff;
import com.yahoo.vespa.hosted.controller.application.pkg.TestPackage;
import com.yahoo.vespa.hosted.controller.deployment.Run.Reason;
-import com.yahoo.vespa.hosted.controller.notification.Notification;
import com.yahoo.vespa.hosted.controller.notification.Notification.Type;
import com.yahoo.vespa.hosted.controller.notification.NotificationSource;
import com.yahoo.vespa.hosted.controller.persistence.BufferedLogStore;
@@ -625,19 +624,15 @@ public class JobController {
private void validateTests(TenantAndApplicationId id, Submission submission) {
var testSummary = TestPackage.validateTests(submission.applicationPackage().deploymentSpec(), submission.testPackage());
if ( ! testSummary.problems().isEmpty())
- controller.notificationsDb().setNotification(NotificationSource.from(id),
- Type.testPackage,
- Notification.Level.warning,
- testSummary.problems());
-
+ controller.notificationsDb().setTestPackageNotification(id, testSummary.problems());
}
private void validateMajorVersion(TenantAndApplicationId id, Submission submission) {
submission.applicationPackage().deploymentSpec().majorVersion().ifPresent(explicitMajor -> {
if ( ! controller.readVersionStatus().isOnCurrentMajor(new Version(explicitMajor)))
- controller.notificationsDb().setNotification(NotificationSource.from(id), Type.submission, Notification.Level.warning,
- "Vespa " + explicitMajor + " will soon reach end of life, upgrade to Vespa " + (explicitMajor + 1) + " now: " +
- "https://cloud.vespa.ai/en/vespa" + (explicitMajor + 1) + "-release-notes.html"); // ∠( ᐛ 」∠)_
+ controller.notificationsDb().setSubmissionNotification(id,
+ "Vespa " + explicitMajor + " will soon reach end of life, upgrade to [Vespa " + (explicitMajor + 1) + " now](" +
+ "https://cloud.vespa.ai/en/vespa" + (explicitMajor + 1) + "-release-notes.html)"); // ∠( ᐛ 」∠)_
});
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/BcpGroupUpdater.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/BcpGroupUpdater.java
index 1ad4feb1897..92aaacaa1f0 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/BcpGroupUpdater.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/BcpGroupUpdater.java
@@ -71,6 +71,24 @@ public class BcpGroupUpdater extends ControllerMaintainer {
var patch = new ApplicationPatch();
addTrafficShare(deployment, bcpGroups, patch);
addBcpGroupInfo(deployment.zone().region(), metrics.get(instance.id()), bcpGroups, patch);
+
+ StringBuilder patchAsStringBuilder = new StringBuilder("Patch of instance ").append(instance.id().serializedForm()).append(": ")
+ .append("\n\tcurrentReadShare: ")
+ .append(patch.currentReadShare)
+ .append("\n\tmaxReadShare: ")
+ .append(patch.maxReadShare);
+ for (Map.Entry<String, ApplicationPatch.ClusterPatch> entry : patch.clusters.entrySet()) {
+ String key = entry.getKey();
+ ApplicationPatch.ClusterPatch value = entry.getValue();
+ patchAsStringBuilder.append("\n\tbcpGroupInfo for ").append(key).append(": ")
+ .append("\n\t\tcpuCostPerQuery: ")
+ .append(value.bcpGroupInfo.cpuCostPerQuery)
+ .append("\n\t\tqueryRate: ")
+ .append(value.bcpGroupInfo.queryRate)
+ .append("\n\t\tgrowthRateHeadroom: ")
+ .append(value.bcpGroupInfo.growthRateHeadroom);
+ }
+ log.log(Level.FINER, patchAsStringBuilder.toString());
nodeRepository.patchApplication(deployment.zone(), instance.id(), patch);
}
catch (Exception e) {
@@ -103,7 +121,9 @@ public class BcpGroupUpdater extends ControllerMaintainer {
currentReadShare += groupQps == 0 ? 0 : fraction * deploymentQps / groupQps;
maxReadShare += group.size() == 1
? currentReadShare
- : fraction * ( deploymentQps + group.maxQpsExcluding(deployment.zone().region()) / (group.size() - 1) ) / groupQps;
+ : groupQps != 0
+ ? fraction * (deploymentQps + group.maxQpsExcluding(deployment.zone().region()) / (group.size() - 1)) / groupQps
+ : 0;
}
patch.currentReadShare = currentReadShare;
patch.maxReadShare = maxReadShare;
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 9121c139b00..55428e80493 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
@@ -121,32 +121,22 @@ public class CloudTrialExpirer extends ControllerMaintainer {
// Ignore tenants that are on a paid plan and skip from inclusion in updated data structure
} else if (status == null && "trial".equals(plan) && ageInDays <= 1) {
updatedStatus.add(updatedStatus(tenant, now, SIGNED_UP));
- queueNotification(tenant, "Welcome to Vespa Cloud", "Welcome to Vespa Cloud",
- "Welcome to Vespa Cloud! We hope you will enjoy your trial. " +
- "Please reach out to us if you have any questions or feedback.");
+ notifySignup(tenant);
} else if ("none".equals(plan) && !List.of(EXPIRED).contains(state)) {
updatedStatus.add(updatedStatus(tenant, now, EXPIRED));
- queueNotification(tenant, "Your Vespa Cloud trial has expired", "Your Vespa Cloud trial has expired",
- "Your Vespa Cloud trial has expired. " +
- "Please reach out to us if you have any questions or feedback.");
+ notifyExpired(tenant);
} else if ("trial".equals(plan) && ageInDays >= 13
&& !List.of(EXPIRES_IMMEDIATELY, EXPIRED).contains(state)) {
updatedStatus.add(updatedStatus(tenant, now, EXPIRES_IMMEDIATELY));
- queueNotification(tenant, "Your Vespa Cloud trial expires tomorrow", "Your Vespa Cloud trial expires tomorrow",
- "Your Vespa Cloud trial expires tomorrow. " +
- "Please reach out to us if you have any questions or feedback.");
+ notifyExpiresImmediately(tenant);
} else if ("trial".equals(plan) && ageInDays >= 12
&& !List.of(EXPIRES_SOON, EXPIRES_IMMEDIATELY, EXPIRED).contains(state)) {
updatedStatus.add(updatedStatus(tenant, now, EXPIRES_SOON));
- queueNotification(tenant, "Your Vespa Cloud trial expires in 2 days", "Your Vespa Cloud trial expires in 2 days",
- "Your Vespa Cloud trial expires in 2 days. " +
- "Please reach out to us if you have any questions or feedback.");
+ notifyExpiresSoon(tenant);
} else if ("trial".equals(plan) && ageInDays >= 7
&& !List.of(MID_CHECK_IN, EXPIRES_SOON, EXPIRES_IMMEDIATELY, EXPIRED).contains(state)) {
updatedStatus.add(updatedStatus(tenant, now, MID_CHECK_IN));
- queueNotification(tenant, "How is your Vespa Cloud trial going?", "How is your Vespa Cloud trial going?",
- "How is your Vespa Cloud trial going? " +
- "Please reach out to us if you have any questions or feedback.");
+ notifyMidCheckIn(tenant);
} else {
updatedStatus.add(status);
}
@@ -160,6 +150,41 @@ public class CloudTrialExpirer extends ControllerMaintainer {
}
}
+ private void notifySignup(Tenant tenant) {
+ var consoleMsg = "Welcome to Vespa Cloud trial! [Manage plan](%s)".formatted(billingUrl(tenant));
+ queueNotification(tenant, consoleMsg, "Welcome to Vespa Cloud",
+ "Welcome to Vespa Cloud! We hope you will enjoy your trial. " +
+ "Please reach out to us if you have any questions or feedback.");
+ }
+
+ private void notifyMidCheckIn(Tenant tenant) {
+ var consoleMsg = "You're halfway through the **14 day** trial period. [Manage plan](%s)".formatted(billingUrl(tenant));
+ queueNotification(tenant, consoleMsg, "How is your Vespa Cloud trial going?",
+ "How is your Vespa Cloud trial going? " +
+ "Please reach out to us if you have any questions or feedback.");
+ }
+
+ private void notifyExpiresSoon(Tenant tenant) {
+ var consoleMsg = "Your Vespa Cloud trial expires in **2** days. [Manage plan](%s)".formatted(billingUrl(tenant));
+ queueNotification(tenant, consoleMsg, "Your Vespa Cloud trial expires in 2 days",
+ "Your Vespa Cloud trial expires in 2 days. " +
+ "Please reach out to us if you have any questions or feedback.");
+ }
+
+ private void notifyExpiresImmediately(Tenant tenant) {
+ var consoleMsg = "Your Vespa Cloud trial expires **tomorrow**. [Manage plan](%s)".formatted(billingUrl(tenant));
+ queueNotification(tenant, consoleMsg, "Your Vespa Cloud trial expires tomorrow",
+ "Your Vespa Cloud trial expires tomorrow. " +
+ "Please reach out to us if you have any questions or feedback.");
+ }
+
+ private void notifyExpired(Tenant tenant) {
+ var consoleMsg = "Your Vespa Cloud trial has expired. [Upgrade plan](%s)".formatted(billingUrl(tenant));
+ queueNotification(tenant, consoleMsg, "Your Vespa Cloud trial has expired",
+ "Your Vespa Cloud trial has expired. " +
+ "Please reach out to us if you have any questions or feedback.");
+ }
+
private void queueNotification(Tenant tenant, String consoleMsg, String emailSubject, String emailMsg) {
var mail = Optional.of(Notification.MailContent.fromTemplate(MailTemplating.Template.DEFAULT_MAIL_CONTENT)
.subject(emailSubject)
@@ -175,6 +200,8 @@ public class CloudTrialExpirer extends ControllerMaintainer {
source, Notification.Type.account, Notification.Level.info, consoleMsg, List.of(), mail);
}
+ private String billingUrl(Tenant t) { return controller().serviceRegistry().consoleUrls().tenantBilling(t.name()); }
+
private static TrialNotifications.Status updatedStatus(Tenant t, Instant i, TrialNotifications.State s) {
return new TrialNotifications.Status(t.name(), s, i);
}
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 4a94098ce98..897e0be2d22 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
@@ -32,18 +32,18 @@ public record Notification(Instant at, Notification.Type type, Notification.Leve
}
public Notification {
- at = Objects.requireNonNull(at, "at cannot be null");
- type = Objects.requireNonNull(type, "type cannot be null");
- level = Objects.requireNonNull(level, "level cannot be null");
- source = Objects.requireNonNull(source, "source cannot be null");
- title = Objects.requireNonNull(title, "title cannot be null");
+ Objects.requireNonNull(at, "at cannot be null");
+ Objects.requireNonNull(type, "type cannot be null");
+ Objects.requireNonNull(level, "level cannot be null");
+ Objects.requireNonNull(source, "source cannot be null");
+ Objects.requireNonNull(title, "title cannot be null");
messages = List.copyOf(Objects.requireNonNull(messages, "messages cannot be null"));
// Allowing empty title temporarily until all notifications have a title
// if (title.isBlank()) throw new IllegalArgumentException("title cannot be empty");
if (messages.isEmpty() && title.isBlank()) throw new IllegalArgumentException("messages cannot be empty when title is empty");
- mailContent = Objects.requireNonNull(mailContent);
+ Objects.requireNonNull(mailContent);
}
public enum Level {
@@ -53,40 +53,26 @@ public record Notification(Instant at, Notification.Type type, Notification.Leve
public enum Type {
- /**
- * Related to contents of application package, e.g., usage of deprecated features/syntax
- */
+ /** Related to contents of application package, e.g., usage of deprecated features/syntax */
applicationPackage,
- /**
- * Related to contents of application package detectable by the controller on submission
- */
+ /** Related to contents of application package detectable by the controller on submission */
submission,
- /**
- * Related to contents of application test package, e.g., mismatch between deployment spec and provided tests
- */
+ /** Related to contents of application test package, e.g., mismatch between deployment spec and provided tests */
testPackage,
- /**
- * Related to deployment of application, e.g., system test failure, node allocation failure, internal errors, etc.
- */
+ /** Related to deployment of application, e.g., system test failure, node allocation failure, internal errors, etc. */
deployment,
- /**
- * Application cluster is (near) external feed blocked
- */
+ /** Application cluster is (near) external feed blocked */
feedBlock,
- /**
- * Application cluster is reindexing document(s)
- */
+ /** Application cluster is reindexing document(s) */
reindex,
- /**
- * Account, e.g. expiration of trial plan
- */
- account
+ /** Account, e.g. expiration of trial plan */
+ account,
}
public static class MailContent {
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationFormatter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationFormatter.java
index 243e1af8f35..e9b38f7a122 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationFormatter.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationFormatter.java
@@ -38,10 +38,9 @@ public class NotificationFormatter {
private FormattedNotification applicationPackage(Notification n) {
var source = n.source();
var application = requirePresent(source.application(), "application");
- var instance = requirePresent(source.instance(), "instance");
- var message = Text.format("Application package for %s.%s has %s",
+ var message = Text.format("Application package for %s%s has %s",
application,
- instance,
+ source.instance().map(instance -> "." + instance.value()).orElse(""),
levelText(n.level(), n.messages().size()));
return new FormattedNotification(n, "Application package", message, notificationLink(consoleUrls, n.source()));
}
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 081fd5a2c1d..e279e4feacd 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
@@ -9,7 +9,10 @@ import com.yahoo.transaction.Mutex;
import com.yahoo.vespa.hosted.controller.Controller;
import com.yahoo.vespa.hosted.controller.api.application.v4.model.ClusterMetrics;
import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
+import com.yahoo.vespa.hosted.controller.api.integration.ConsoleUrls;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.ApplicationReindexing;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.RunId;
+import com.yahoo.vespa.hosted.controller.application.TenantAndApplicationId;
import com.yahoo.vespa.hosted.controller.notification.Notification.MailContent;
import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
@@ -26,6 +29,7 @@ import java.util.stream.Stream;
import static com.yahoo.vespa.hosted.controller.api.integration.configserver.ApplicationReindexing.Cluster;
import static com.yahoo.vespa.hosted.controller.notification.Notification.Level;
import static com.yahoo.vespa.hosted.controller.notification.Notification.Type;
+import static com.yahoo.vespa.hosted.controller.notification.Notifier.notificationLink;
/**
* Adds, updates and removes tenant notifications in ZK
@@ -39,15 +43,17 @@ public class NotificationsDb {
private final Clock clock;
private final CuratorDb curatorDb;
private final Notifier notifier;
+ private final ConsoleUrls consoleUrls;
public NotificationsDb(Controller controller) {
- this(controller.clock(), controller.curator(), controller.notifier());
+ this(controller.clock(), controller.curator(), controller.notifier(), controller.serviceRegistry().consoleUrls());
}
- NotificationsDb(Clock clock, CuratorDb curatorDb, Notifier notifier) {
+ NotificationsDb(Clock clock, CuratorDb curatorDb, Notifier notifier, ConsoleUrls consoleUrls) {
this.clock = clock;
this.curatorDb = curatorDb;
this.notifier = notifier;
+ this.consoleUrls = consoleUrls;
}
public List<TenantName> listTenantsWithNotifications() {
@@ -60,12 +66,44 @@ public class NotificationsDb {
.toList();
}
- public void setNotification(NotificationSource source, Type type, Level level, String message) {
- setNotification(source, type, level, List.of(message));
+ public void setSubmissionNotification(TenantAndApplicationId tenantApp, String message) {
+ NotificationSource source = NotificationSource.from(tenantApp);
+ String title = "Application package for [%s](%s) has a warning".formatted(
+ tenantApp.application().value(), notificationLink(consoleUrls, source));
+ setNotification(source, Type.submission, Level.warning, title, List.of(message), Optional.empty());
}
- public void setNotification(NotificationSource source, Type type, Level level, List<String> messages) {
- setNotification(source, type, level, "", messages, Optional.empty());
+ public void setApplicationPackageNotification(NotificationSource source, List<String> messages) {
+ String title = "Application package for [%s%s](%s) has %s".formatted(
+ source.application().get().value(), source.instance().map(i -> "." + i.value()).orElse(""), notificationLink(consoleUrls, source),
+ messages.size() == 1 ? "a warning" : "warnings");
+ setNotification(source, Type.applicationPackage, Level.warning, title, messages, Optional.empty());
+ }
+
+ public void setTestPackageNotification(TenantAndApplicationId tenantApp, List<String> messages) {
+ NotificationSource source = NotificationSource.from(tenantApp);
+ String title = "There %s with tests for [%s](%s)".formatted(
+ messages.size() == 1 ? "is a problem" : "are problems", tenantApp.application().value(),
+ notificationLink(consoleUrls, source));
+ setNotification(source, Type.testPackage, Level.warning, title, messages, Optional.empty());
+ }
+
+ public void setDeploymentNotification(RunId runId, String message) {
+ String description, linkText;
+ if (runId.type().isProduction()) {
+ description = runId.type().isTest() ? "Test job " : "Deployment job ";
+ linkText = "#" + runId.number() + " to " + runId.type().zone().region().value();
+ } else if (runId.type().isTest()) {
+ description = "";
+ linkText = (runId.type().isStagingTest() ? "Staging" : "System") + " test #" + runId.number();
+ } else if (runId.type().isDeployment()) {
+ description = "Deployment job ";
+ linkText = "#" + runId.number() + " to " + runId.type().zone().value();
+ } else throw new IllegalStateException("Unexpected job type " + runId.type());
+ NotificationSource source = NotificationSource.from(runId);
+ String title = "%s[%s](%s) for application **%s.%s** has failed".formatted(
+ description, linkText, notificationLink(consoleUrls, source), runId.application().application().value(), runId.application().instance().value());
+ setNotification(source, Type.deployment, Level.error, title, List.of(message), Optional.empty());
}
/**
@@ -134,14 +172,9 @@ public class NotificationsDb {
Instant now = clock.instant();
List<Notification> changed = List.of();
List<Notification> newNotifications = Stream.concat(
- clusterMetrics.stream().map(metric -> {
- NotificationSource source = NotificationSource.from(deploymentId, ClusterSpec.Id.from(metric.getClusterId()));
- return createFeedBlockNotification(source, now, metric);
- }),
- applicationReindexing.clusters().entrySet().stream().map(entry -> {
- NotificationSource source = NotificationSource.from(deploymentId, ClusterSpec.Id.from(entry.getKey()));
- return createReindexNotification(source, now, entry.getValue());
- }))
+ clusterMetrics.stream().map(metric -> createFeedBlockNotification(consoleUrls, deploymentId, metric.getClusterId(), now, metric)),
+ applicationReindexing.clusters().entrySet().stream().map(entry ->
+ createReindexNotification(consoleUrls, deploymentId, entry.getKey(), now, entry.getValue())))
.flatMap(Optional::stream)
.toList();
@@ -175,25 +208,34 @@ public class NotificationsDb {
return exists;
}
- private static Optional<Notification> createFeedBlockNotification(NotificationSource source, Instant at, ClusterMetrics metric) {
+ private static Optional<Notification> createFeedBlockNotification(ConsoleUrls consoleUrls, DeploymentId deployment, String clusterId, Instant at, ClusterMetrics metric) {
Optional<Pair<Level, String>> memoryStatus =
resourceUtilToFeedBlockStatus("memory", metric.memoryUtil(), metric.memoryFeedBlockLimit());
Optional<Pair<Level, String>> diskStatus =
resourceUtilToFeedBlockStatus("disk", metric.diskUtil(), metric.diskFeedBlockLimit());
if (memoryStatus.isEmpty() && diskStatus.isEmpty()) return Optional.empty();
+ NotificationSource source = NotificationSource.from(deployment, ClusterSpec.Id.from(clusterId));
// Find the max among levels
Level level = Stream.of(memoryStatus, diskStatus)
.flatMap(status -> status.stream().map(Pair::getFirst))
.max(Comparator.comparing(Enum::ordinal)).get();
+ String title = "Cluster [%s](%s) in **%s** for **%s.%s** is %sfeed blocked".formatted(
+ clusterId, notificationLink(consoleUrls, source), deployment.zoneId().value(), deployment.applicationId().application().value(),
+ deployment.applicationId().instance().value(), level == Level.warning ? "nearly " : "");
List<String> messages = Stream.concat(memoryStatus.stream(), diskStatus.stream())
.filter(status -> status.getFirst() == level) // Do not mix message from different levels
.map(Pair::getSecond)
.toList();
- return Optional.of(new Notification(at, Type.feedBlock, level, source, "", messages));
+
+ return Optional.of(new Notification(at, Type.feedBlock, level, source, title, messages));
}
- private static Optional<Notification> createReindexNotification(NotificationSource source, Instant at, Cluster cluster) {
+ private static Optional<Notification> createReindexNotification(ConsoleUrls consoleUrls, DeploymentId deployment, String clusterId, Instant at, Cluster cluster) {
+ NotificationSource source = NotificationSource.from(deployment, ClusterSpec.Id.from(clusterId));
+ String title = "Cluster [%s](%s) in **%s** for **%s.%s** is [reindexing](https://docs.vespa.ai/en/operations/reindexing.html)".formatted(
+ clusterId, consoleUrls.clusterReindexing(deployment.applicationId(), deployment.zoneId(), source.clusterId().get()),
+ deployment.zoneId().value(), deployment.applicationId().application().value(), deployment.applicationId().instance().value());
List<String> messages = cluster.ready().entrySet().stream()
.filter(entry -> entry.getValue().progress().isPresent())
.map(entry -> Text.format("document type '%s'%s (%.1f%% done)",
@@ -201,7 +243,7 @@ public class NotificationsDb {
.sorted()
.toList();
if (messages.isEmpty()) return Optional.empty();
- return Optional.of(new Notification(at, Type.reindex, Level.info, source, "", messages));
+ return Optional.of(new Notification(at, Type.reindex, Level.info, source, title, messages));
}
/**
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java
index b0b43866fae..f27e69c4636 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java
@@ -101,10 +101,13 @@ public class Notifier {
log.fine(() -> "Sending notification " + notification + " to " +
contacts.stream().map(c -> c.email().getEmailAddress()).toList());
var content = formatter.format(notification);
- mailer.send(mailOf(content, contacts.stream()
- .filter(c -> c.email().isVerified())
- .map(c -> c.email().getEmailAddress())
- .toList()));
+ var verifiedContacts = contacts.stream()
+ .filter(c -> c.email().isVerified()).map(c -> c.email().getEmailAddress()).toList();
+ if (verifiedContacts.isEmpty()) {
+ log.fine(() -> "None of the %d contact(s) are verified - skipping delivery of %s".formatted(contacts.size(), notification));
+ return;
+ }
+ mailer.send(mailOf(content, verifiedContacts));
} catch (MailerException e) {
log.log(Level.SEVERE, "Failed sending email", e);
} catch (MissingOptionalException e) {
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java
index d5bb47c94b0..eae8f86f289 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java
@@ -28,6 +28,8 @@ import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
import com.yahoo.vespa.hosted.controller.tenant.DeletedTenant;
import com.yahoo.vespa.hosted.controller.tenant.Email;
import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo;
+import com.yahoo.vespa.hosted.controller.tenant.PurchaseOrder;
+import com.yahoo.vespa.hosted.controller.tenant.TaxId;
import com.yahoo.vespa.hosted.controller.tenant.Tenant;
import com.yahoo.vespa.hosted.controller.tenant.TenantAddress;
import com.yahoo.vespa.hosted.controller.tenant.TenantBilling;
@@ -93,6 +95,9 @@ public class TenantSerializer {
private static final String cloudAccountsField = "cloudAccounts";
private static final String accountField = "account";
private static final String templateVersionField = "templateVersion";
+ private static final String taxIdField = "taxId";
+ private static final String purchaseOrderField = "purchaseOrder";
+ private static final String invoiceEmailField = "invoiceEmail";
private static final String awsIdField = "awsId";
private static final String roleField = "role";
@@ -282,12 +287,19 @@ public class TenantSerializer {
}
private TenantBilling tenantInfoBillingContactFromSlime(Inspector billingObject) {
+ var taxId = new TaxId(billingObject.field(taxIdField).asString());
+ var purchaseOrder = new PurchaseOrder(billingObject.field(purchaseOrderField).asString());
+ var invoiceEmail = new Email(billingObject.field(invoiceEmailField).asString(), false);
+
return TenantBilling.empty()
.withContact(TenantContact.from(
billingObject.field("name").asString(),
new Email(billingObject.field("email").asString(), billingObject.field("emailVerified").asBool()),
billingObject.field("phone").asString()))
- .withAddress(tenantInfoAddressFromSlime(billingObject.field("address")));
+ .withAddress(tenantInfoAddressFromSlime(billingObject.field("address")))
+ .withTaxId(taxId)
+ .withPurchaseOrder(purchaseOrder)
+ .withInvoiceEmail(invoiceEmail);
}
private List<TenantSecretStore> secretStoresFromSlime(Inspector secretStoresObject) {
@@ -349,6 +361,9 @@ public class TenantSerializer {
billingCursor.setString("email", billingContact.contact().email().getEmailAddress());
billingCursor.setBool("emailVerified", billingContact.contact().email().isVerified());
billingCursor.setString("phone", billingContact.contact().phone());
+ billingCursor.setString(taxIdField, billingContact.getTaxId().value());
+ billingCursor.setString(purchaseOrderField, billingContact.getPurchaseOrder().value());
+ billingCursor.setString(invoiceEmailField, billingContact.getInvoiceEmail().getEmailAddress());
toSlime(billingContact.address(), billingCursor);
}
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 6a6c8a51d72..5548928b9d0 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
@@ -127,6 +127,8 @@ import com.yahoo.vespa.hosted.controller.tenant.DeletedTenant;
import com.yahoo.vespa.hosted.controller.tenant.Email;
import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo;
import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification;
+import com.yahoo.vespa.hosted.controller.tenant.PurchaseOrder;
+import com.yahoo.vespa.hosted.controller.tenant.TaxId;
import com.yahoo.vespa.hosted.controller.tenant.Tenant;
import com.yahoo.vespa.hosted.controller.tenant.TenantAddress;
import com.yahoo.vespa.hosted.controller.tenant.TenantBilling;
@@ -694,6 +696,9 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
contact.setString("email", billingContact.contact().email().getEmailAddress());
contact.setBool("emailVerified", billingContact.contact().email().isVerified());
contact.setString("phone", billingContact.contact().phone());
+ root.setString("taxId", billingContact.getTaxId().value());
+ root.setString("purchaseOrder", billingContact.getPurchaseOrder().value());
+ root.setString("invoiceEmail", billingContact.getInvoiceEmail().getEmailAddress());
toSlime(billingContact.address(), root); // will create "address" on the parent
}
@@ -703,15 +708,22 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
private SlimeJsonResponse putTenantInfoBilling(CloudTenant cloudTenant, Inspector inspector) {
var info = cloudTenant.info();
- var contact = info.billingContact().contact();
- var address = info.billingContact().address();
+ var billing = info.billingContact();
+ var contact = billing.contact();
+ var address = billing.address();
var mergedContact = updateBillingContact(inspector.field("contact"), cloudTenant.name(), contact);
- var mergedAddress = updateTenantInfoAddress(inspector.field("address"), info.billingContact().address());
+ var mergedAddress = updateTenantInfoAddress(inspector.field("address"), billing.address());
+ var mergedTaxId = optional("taxId", inspector).map(TaxId::new).orElse(billing.getTaxId());
+ var mergedPurchaseOrder = optional("purchaseOrder", inspector).map(PurchaseOrder::new).orElse(billing.getPurchaseOrder());
+ var mergedInvoiceEmail = optional("invoiceEmail", inspector).map(mail -> new Email(mail, false)).orElse(billing.getInvoiceEmail());
var mergedBilling = info.billingContact()
.withContact(mergedContact)
- .withAddress(mergedAddress);
+ .withAddress(mergedAddress)
+ .withTaxId(mergedTaxId)
+ .withPurchaseOrder(mergedPurchaseOrder)
+ .withInvoiceEmail(mergedInvoiceEmail);
var mergedInfo = info.withBilling(mergedBilling);
@@ -764,6 +776,11 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
throw new IllegalArgumentException("'website' needs to be a valid address");
}
}
+ if (! mergedInfo.billingContact().getInvoiceEmail().isBlank()) {
+ // TODO: Validate invoice email is set if collection method is INVOICE
+ if (! mergedInfo.billingContact().getInvoiceEmail().getEmailAddress().contains("@"))
+ throw new IllegalArgumentException("'Invoice email' needs to be an email address");
+ }
}
private void toSlime(TenantAddress address, Cursor parentCursor) {
@@ -785,6 +802,9 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
billingCursor.setString("email", billingContact.contact().email().getEmailAddress());
billingCursor.setBool("emailVerified", billingContact.contact().email().isVerified());
billingCursor.setString("phone", billingContact.contact().phone());
+ billingCursor.setString("taxId", billingContact.getTaxId().value());
+ billingCursor.setString("purchaseOrder", billingContact.getPurchaseOrder().value());
+ billingCursor.setString("invoiceEmail", billingContact.getInvoiceEmail().getEmailAddress());
toSlime(billingContact.address(), billingCursor);
}
@@ -914,9 +934,15 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
private TenantBilling updateTenantInfoBillingContact(Inspector insp, TenantName tenantName, TenantBilling oldContact) {
if (!insp.valid()) return oldContact;
+ var taxId = optional("taxId", insp).map(TaxId::new).orElse(oldContact.getTaxId());
+ var purchaseOrder = optional("purchaseOrder", insp).map(PurchaseOrder::new).orElse(oldContact.getPurchaseOrder());
+ var invoiceEmail = optional("invoiceEmail", insp).map(mail -> new Email(mail, false)).orElse(oldContact.getInvoiceEmail());
return TenantBilling.empty()
.withContact(updateBillingContact(insp, tenantName, oldContact.contact()))
- .withAddress(updateTenantInfoAddress(insp.field("address"), oldContact.address()));
+ .withAddress(updateTenantInfoAddress(insp.field("address"), oldContact.address()))
+ .withTaxId(taxId)
+ .withPurchaseOrder(purchaseOrder)
+ .withInvoiceEmail(invoiceEmail);
}
private TenantContacts updateTenantInfoContacts(Inspector insp, TenantName tenantName, TenantContacts oldContacts) {