aboutsummaryrefslogtreecommitdiffstats
path: root/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirer.java
diff options
context:
space:
mode:
Diffstat (limited to 'controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirer.java')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirer.java139
1 files changed, 134 insertions, 5 deletions
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 18ef47759f4..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
@@ -1,26 +1,42 @@
-// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.controller.maintenance;
import com.yahoo.config.provision.SystemName;
import com.yahoo.config.provision.TenantName;
+import com.yahoo.vespa.flags.BooleanFlag;
+import com.yahoo.vespa.flags.FetchVector;
+import com.yahoo.vespa.flags.Flags;
import com.yahoo.vespa.flags.ListFlag;
import com.yahoo.vespa.flags.PermanentFlags;
import com.yahoo.vespa.hosted.controller.Controller;
import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanId;
+import com.yahoo.vespa.hosted.controller.notification.MailTemplating;
+import com.yahoo.vespa.hosted.controller.notification.Notification;
+import com.yahoo.vespa.hosted.controller.notification.NotificationSource;
+import com.yahoo.vespa.hosted.controller.persistence.TrialNotifications;
import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo;
import com.yahoo.vespa.hosted.controller.tenant.Tenant;
import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
+import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
+import static com.yahoo.vespa.hosted.controller.persistence.TrialNotifications.State.EXPIRED;
+import static com.yahoo.vespa.hosted.controller.persistence.TrialNotifications.State.EXPIRES_IMMEDIATELY;
+import static com.yahoo.vespa.hosted.controller.persistence.TrialNotifications.State.EXPIRES_SOON;
+import static com.yahoo.vespa.hosted.controller.persistence.TrialNotifications.State.MID_CHECK_IN;
+import static com.yahoo.vespa.hosted.controller.persistence.TrialNotifications.State.SIGNED_UP;
+import static com.yahoo.vespa.hosted.controller.persistence.TrialNotifications.State.UNKNOWN;
+
/**
* Expires unused tenants from Vespa Cloud.
- * <p>
- * TODO: Should support sending notifications some time before the various expiry events happen.
*
* @author ogronnesby
*/
@@ -28,19 +44,22 @@ public class CloudTrialExpirer extends ControllerMaintainer {
private static final Logger log = Logger.getLogger(CloudTrialExpirer.class.getName());
private static final Duration nonePlanAfter = Duration.ofDays(14);
- private static final Duration tombstoneAfter = Duration.ofDays(183);
+ private static final Duration tombstoneAfter = Duration.ofDays(91);
private final ListFlag<String> extendedTrialTenants;
+ private final BooleanFlag cloudTrialNotificationEnabled;
public CloudTrialExpirer(Controller controller, Duration interval) {
super(controller, interval, null, SystemName.allOf(SystemName::isPublic));
this.extendedTrialTenants = PermanentFlags.EXTENDED_TRIAL_TENANTS.bindTo(controller().flagSource());
+ this.cloudTrialNotificationEnabled = Flags.CLOUD_TRIAL_NOTIFICATIONS.bindTo(controller().flagSource());
}
@Override
protected double maintain() {
var a = tombstoneNonePlanTenants();
var b = moveInactiveTenantsToNonePlan();
- return (a ? 0.5 : 0.0) + (b ? 0.5 : 0.0);
+ var c = notifyTenants();
+ return (a ? 0.0 : -(1D/3)) + (b ? 0.0 : -(1D/3) + (c ? 0.0 : -(1D/3)));
}
private boolean moveInactiveTenantsToNonePlan() {
@@ -76,6 +95,116 @@ public class CloudTrialExpirer extends ControllerMaintainer {
return tombstoneTenants(idleOldPlanTenants);
}
+ private boolean notifyTenants() {
+ try {
+ var currentStatus = controller().curator().readTrialNotifications()
+ .map(TrialNotifications::tenants).orElse(List.of());
+ log.fine(() -> "Current: %s".formatted(currentStatus));
+ var currentStatusByTenant = new HashMap<TenantName, TrialNotifications.Status>();
+ currentStatus.forEach(status -> currentStatusByTenant.put(status.tenant(), status));
+ var updatedStatus = new ArrayList<TrialNotifications.Status>();
+ var now = controller().clock().instant();
+
+ for (var tenant : controller().tenants().asList()) {
+
+ var status = currentStatusByTenant.get(tenant.name());
+ var state = status == null ? UNKNOWN : status.state();
+ var plan = controller().serviceRegistry().billingController().getPlan(tenant.name()).value();
+ var ageInDays = Duration.between(tenant.createdAt(), now).toDays();
+
+ // TODO Replace stubs with proper email content stored in templates.
+
+ var enabled = cloudTrialNotificationEnabled.with(FetchVector.Dimension.TENANT_ID, tenant.name().value()).value();
+ if (!enabled) {
+ if (status != null) updatedStatus.add(status);
+ } else if (!List.of("none", "trial").contains(plan)) {
+ // 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));
+ notifySignup(tenant);
+ } else if ("none".equals(plan) && !List.of(EXPIRED).contains(state)) {
+ updatedStatus.add(updatedStatus(tenant, now, EXPIRED));
+ notifyExpired(tenant);
+ } else if ("trial".equals(plan) && ageInDays >= 13
+ && !List.of(EXPIRES_IMMEDIATELY, EXPIRED).contains(state)) {
+ updatedStatus.add(updatedStatus(tenant, now, EXPIRES_IMMEDIATELY));
+ 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));
+ 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));
+ notifyMidCheckIn(tenant);
+ } else {
+ updatedStatus.add(status);
+ }
+ }
+ log.fine(() -> "Updated: %s".formatted(updatedStatus));
+ controller().curator().writeTrialNotifications(new TrialNotifications(updatedStatus));
+ return true;
+ } catch (Exception e) {
+ log.log(Level.WARNING, "Failed to process trial notifications", e);
+ return false;
+ }
+ }
+
+ 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)
+ .with("mailMessageTemplate", "cloud-trial-notification")
+ .with("cloudTrialMessage", emailMsg)
+ .with("mailTitle", emailSubject)
+ .with("consoleLink", controller().serviceRegistry().consoleUrls().tenantOverview(tenant.name()))
+ .build());
+ var source = NotificationSource.from(tenant.name());
+ // Remove previous notification to ensure new notification is sent by email
+ controller().notificationsDb().removeNotification(source, Notification.Type.account);
+ controller().notificationsDb().setNotification(
+ 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);
+ }
private boolean tenantIsCloudTenant(Tenant tenant) {
return tenant.type() == Tenant.Type.cloud;