aboutsummaryrefslogtreecommitdiffstats
path: root/controller-server
diff options
context:
space:
mode:
authorØyvind Grønnesby <oyving@verizonmedia.com>2021-06-18 11:10:57 +0200
committerGitHub <noreply@github.com>2021-06-18 11:10:57 +0200
commit04e1bb87eda8e51f499b16a80e9a768a5fe8c94c (patch)
tree2c3c53c0772c5c9a9e661522062be5ee75a28707 /controller-server
parenta883391edc005c7c635349b0279cf45b9055bc8a (diff)
parenta7fdcaa1c47d8b549d60a0e3616d142ea39b84b2 (diff)
Merge pull request #18284 from vespa-engine/ogronnesby/expire-idle-tenants
Expire trial tenants that have not logged in for 14 days
Diffstat (limited to 'controller-server')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirer.java80
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java1
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ZoneApiMock.java8
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirerTest.java93
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/responses/maintenance.json3
5 files changed, 183 insertions, 2 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
new file mode 100644
index 00000000000..be8f4254b79
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirer.java
@@ -0,0 +1,80 @@
+// Copyright Verizon Media. 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.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.tenant.LastLoginInfo;
+import com.yahoo.vespa.hosted.controller.tenant.Tenant;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Expires unused tenants from Vespa Cloud.
+ *
+ * @author ogronnesby
+ */
+public class CloudTrialExpirer extends ControllerMaintainer {
+
+ private static Duration loginExpiry = Duration.ofDays(14);
+ private final ListFlag<String> extendedTrialTenants;
+
+ public CloudTrialExpirer(Controller controller, Duration interval) {
+ super(controller, interval, null, SystemName.allOf(SystemName::isPublic));
+ this.extendedTrialTenants = PermanentFlags.EXTENDED_TRIAL_TENANTS.bindTo(controller().flagSource());
+ }
+
+ @Override
+ protected double maintain() {
+ var expiredTenants = controller().tenants().asList().stream()
+ .filter(this::tenantIsCloudTenant) // only valid for cloud tenants
+ .filter(this::tenantHasTrialPlan) // only valid to expire actual trial tenants
+ .filter(this::tenantIsNotExemptFromExpiry) // feature flag might exempt tenant from expiry
+ .filter(this::tenantReadersNotLoggedIn) // no user logged in last 14 days
+ .filter(this::tenantHasNoDeployments) // no running deployments active
+ .collect(Collectors.toList());
+
+ expireTenants(expiredTenants);
+
+ return 0;
+ }
+
+ private boolean tenantIsCloudTenant(Tenant tenant) {
+ return tenant.type() == Tenant.Type.cloud;
+ }
+
+ private boolean tenantReadersNotLoggedIn(Tenant tenant) {
+ return tenant.lastLoginInfo().get(LastLoginInfo.UserLevel.user)
+ .map(instant -> {
+ var sinceLastLogin = Duration.between(instant, controller().clock().instant());
+ return sinceLastLogin.compareTo(loginExpiry) > 0;
+ })
+ .orElse(false);
+ }
+
+ private boolean tenantHasTrialPlan(Tenant tenant) {
+ var planId = controller().serviceRegistry().billingController().getPlan(tenant.name());
+ return "trial".equals(planId.value());
+ }
+
+ private boolean tenantIsNotExemptFromExpiry(Tenant tenant) {
+ return ! extendedTrialTenants.value().contains(tenant.name().value());
+ }
+
+ private boolean tenantHasNoDeployments(Tenant tenant) {
+ return controller().applications().asList(tenant.name()).stream()
+ .flatMap(app -> app.instances().values().stream())
+ .mapToLong(instance -> instance.deployments().values().size())
+ .sum() == 0;
+ }
+
+ private void expireTenants(List<Tenant> tenants) {
+ tenants.forEach(tenant -> {
+ controller().serviceRegistry().billingController().setPlan(tenant.name(), PlanId.from("none"), false);
+ });
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java
index 5a7ef12b246..97c3c9f4091 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java
@@ -70,6 +70,7 @@ public class ControllerMaintenance extends AbstractComponent {
maintainers.add(new TenantRoleMaintainer(controller, intervals.tenantRoleMaintainer));
maintainers.add(new ChangeRequestMaintainer(controller, intervals.changeRequestMaintainer));
maintainers.add(new VCMRMaintainer(controller, intervals.vcmrMaintainer));
+ maintainers.add(new CloudTrialExpirer(controller, intervals.defaultInterval));
}
public Upgrader upgrader() { return upgrader; }
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ZoneApiMock.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ZoneApiMock.java
index 7fdbab49ba4..10fee56621c 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ZoneApiMock.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ZoneApiMock.java
@@ -78,8 +78,7 @@ public class ZoneApiMock implements ZoneApi {
public static class Builder {
- private final SystemName systemName = SystemName.defaultSystem();
-
+ private SystemName systemName = SystemName.defaultSystem();
private ZoneId id = ZoneId.defaultId();
private ZoneId virtualId ;
private CloudName cloudName = CloudName.defaultName();
@@ -90,6 +89,11 @@ public class ZoneApiMock implements ZoneApi {
return this;
}
+ public Builder withSystem(SystemName systemName) {
+ this.systemName = systemName;
+ return this;
+ }
+
public Builder withId(String id) {
return with(ZoneId.from(id));
}
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
new file mode 100644
index 00000000000..f3c4f9f7438
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/CloudTrialExpirerTest.java
@@ -0,0 +1,93 @@
+// Copyright Verizon Media. 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.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.deployment.ApplicationPackageBuilder;
+import com.yahoo.vespa.hosted.controller.deployment.DeploymentTester;
+import com.yahoo.vespa.hosted.controller.integration.ZoneApiMock;
+import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo;
+import com.yahoo.vespa.hosted.controller.tenant.Tenant;
+import org.junit.Test;
+
+import java.time.Duration;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * @author ogronnesby
+ */
+public class CloudTrialExpirerTest {
+ private final ControllerTester tester = new ControllerTester(SystemName.Public);
+ private final DeploymentTester deploymentTester = new DeploymentTester(tester);
+ private final CloudTrialExpirer expirer = new CloudTrialExpirer(tester.controller(), Duration.ofMinutes(5));
+
+ @Test
+ public void expire_inactive_tenant() {
+ registerTenant("trial-tenant", "trial", Duration.ofDays(14).plusMillis(1));
+ expirer.maintain();
+ assertPlan("trial-tenant", "none");
+ }
+
+ @Test
+ public void keep_inactive_nontrial_tenants() {
+ registerTenant("not-a-trial-tenant", "pay-as-you-go", Duration.ofDays(30));
+ expirer.maintain();
+ assertPlan("not-a-trial-tenant", "pay-as-you-go");
+ }
+
+ @Test
+ public void keep_active_trial_tenants() {
+ registerTenant("active-trial-tenant", "trial", Duration.ofHours(14).minusMillis(1));
+ expirer.maintain();
+ assertPlan("active-trial-tenant", "trial");
+ }
+
+ @Test
+ public void keep_inactive_exempt_tenants() {
+ registerTenant("exempt-trial-tenant", "trial", Duration.ofDays(40));
+ ((InMemoryFlagSource) tester.controller().flagSource()).withListFlag(PermanentFlags.EXTENDED_TRIAL_TENANTS.id(), List.of("exempt-trial-tenant"), String.class);
+ expirer.maintain();
+ assertPlan("exempt-trial-tenant", "trial");
+ }
+
+ @Test
+ public void keep_inactive_trial_tenants_with_deployments() {
+ registerTenant("with-deployments", "trial", Duration.ofDays(30));
+ registerDeployment("with-deployments", "my-app", "default", "aws-us-east-1c");
+ expirer.maintain();
+ assertPlan("with-deployments", "trial");
+ }
+
+ private void registerTenant(String tenantName, String plan, Duration timeSinceLastLogin) {
+ var name = TenantName.from(tenantName);
+ tester.createTenant(tenantName, Tenant.Type.cloud);
+ tester.serviceRegistry().billingController().setPlan(name, PlanId.from(plan), false);
+ tester.controller().tenants().updateLastLogin(name, List.of(LastLoginInfo.UserLevel.user), tester.controller().clock().instant().minus(timeSinceLastLogin));
+ }
+
+ private void registerDeployment(String tenantName, String appName, String instanceName, String regionName) {
+ var zone = ZoneApiMock.newBuilder()
+ .withSystem(tester.zoneRegistry().system())
+ .withId("prod." + regionName)
+ .build();
+ tester.zoneRegistry().setZones(zone);
+ var app = tester.createApplication(tenantName, appName, instanceName);
+ var ctx = deploymentTester.newDeploymentContext(tenantName, appName, instanceName);
+ var pkg = new ApplicationPackageBuilder()
+ .instances("default")
+ .region(regionName)
+ .trustDefaultCertificate()
+ .build();
+ ctx.submit(pkg).deploy();
+ }
+
+ private void assertPlan(String tenant, String planId) {
+ assertEquals(planId, tester.serviceRegistry().billingController().getPlan(TenantName.from(tenant)).value());
+ }
+}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/responses/maintenance.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/responses/maintenance.json
index 3cf79977fb8..914ea2f5518 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/responses/maintenance.json
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/controller/responses/maintenance.json
@@ -19,6 +19,9 @@
"name": "CloudEventReporter"
},
{
+ "name": "CloudTrialExpirer"
+ },
+ {
"name": "ContactInformationMaintainer"
},
{