diff options
Diffstat (limited to 'controller-server/src/main/java/com')
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) { |