summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java1
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java19
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java7
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java2
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/InternalStepRunner.java54
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notification.java61
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationSource.java153
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDb.java84
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java25
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializer.java104
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java43
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java107
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializerTest.java59
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java21
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/notifications-tenant1-app2.json15
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/notifications-tenant1.json23
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/us-east-3-log-without-first.json2
17 files changed, 766 insertions, 14 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java
index a0da8cbddba..d052a000860 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java
@@ -54,6 +54,7 @@ enum PathGroup {
tenantInfo(Matcher.tenant,
"/application/v4/tenant/{tenant}/application/",
"/application/v4/tenant/{tenant}/info/",
+ "/application/v4/tenant/{tenant}/notifications",
"/routing/v1/status/tenant/{tenant}/{*}"),
tenantKeys(Matcher.tenant,
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 c85d23d58d1..85401f1e033 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
@@ -11,6 +11,7 @@ import com.yahoo.config.provision.Environment;
import com.yahoo.config.provision.InstanceName;
import com.yahoo.config.provision.TenantName;
import com.yahoo.config.provision.zone.ZoneId;
+import com.yahoo.log.LogLevel;
import com.yahoo.vespa.athenz.api.AthenzDomain;
import com.yahoo.vespa.athenz.api.AthenzIdentity;
import com.yahoo.vespa.athenz.api.AthenzPrincipal;
@@ -21,7 +22,6 @@ import com.yahoo.vespa.flags.FetchVector;
import com.yahoo.vespa.flags.FlagSource;
import com.yahoo.vespa.flags.PermanentFlags;
import com.yahoo.vespa.flags.StringFlag;
-import com.yahoo.vespa.hosted.controller.application.ActivateResult;
import com.yahoo.vespa.hosted.controller.api.application.v4.model.DeploymentData;
import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
import com.yahoo.vespa.hosted.controller.api.identifiers.InstanceId;
@@ -35,7 +35,6 @@ import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServ
import com.yahoo.vespa.hosted.controller.api.integration.configserver.ContainerEndpoint;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.Log;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.Node;
-import com.yahoo.vespa.hosted.controller.api.integration.configserver.NotFoundException;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.ApplicationStore;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.ApplicationVersion;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.ArtifactRepository;
@@ -43,6 +42,7 @@ import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobId;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.TesterId;
import com.yahoo.vespa.hosted.controller.api.integration.noderepository.RestartFilter;
import com.yahoo.vespa.hosted.controller.api.integration.secrets.TenantSecretStore;
+import com.yahoo.vespa.hosted.controller.application.ActivateResult;
import com.yahoo.vespa.hosted.controller.application.ApplicationPackage;
import com.yahoo.vespa.hosted.controller.application.ApplicationPackageValidator;
import com.yahoo.vespa.hosted.controller.application.Deployment;
@@ -58,6 +58,8 @@ import com.yahoo.vespa.hosted.controller.deployment.DeploymentTrigger;
import com.yahoo.vespa.hosted.controller.deployment.JobStatus;
import com.yahoo.vespa.hosted.controller.deployment.Run;
import com.yahoo.vespa.hosted.controller.deployment.RunStatus;
+import com.yahoo.vespa.hosted.controller.notification.Notification;
+import com.yahoo.vespa.hosted.controller.notification.NotificationSource;
import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
import com.yahoo.vespa.hosted.controller.security.AccessControl;
import com.yahoo.vespa.hosted.controller.security.Credentials;
@@ -85,6 +87,7 @@ import java.util.TreeMap;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;
+import java.util.stream.Collectors;
import static com.yahoo.vespa.hosted.controller.api.integration.configserver.Node.State.active;
import static com.yahoo.vespa.hosted.controller.api.integration.configserver.Node.State.reserved;
@@ -391,6 +394,16 @@ public class ApplicationController {
// Record the quota usage for this application
var quotaUsage = deploymentQuotaUsage(zone, job.application());
+ // For direct deployments use the full application ID, but otherwise use just the tenant and application as
+ // the source since it's the same application, so it should have the same warnings
+ NotificationSource source = zone.environment().isManuallyDeployed() ?
+ NotificationSource.from(job.application()) : NotificationSource.from(applicationId);
+ List<String> warnings = Optional.ofNullable(result.prepareResponse().log)
+ .map(logs -> logs.stream().filter(log -> LogLevel.parse(log.level).intValue() >= Level.WARNING.intValue()).map(log -> log.message).collect(Collectors.toList()))
+ .orElseGet(List::of);
+ if (warnings.isEmpty()) controller.notificationsDb().removeNotification(source, Notification.Type.APPLICATION_PACKAGE_WARNING);
+ else controller.notificationsDb().setNotification(source, Notification.Type.APPLICATION_PACKAGE_WARNING, warnings);
+
lockApplicationOrThrow(applicationId, application ->
store(application.with(job.application().instance(),
instance -> instance.withNewDeployment(zone, revision, platform,
@@ -561,6 +574,7 @@ public class ApplicationController {
curator.removeApplication(id);
controller.jobController().collectGarbage();
+ controller.notificationsDb().removeNotifications(NotificationSource.from(id));
log.info("Deleted " + id);
});
}
@@ -588,6 +602,7 @@ public class ApplicationController {
controller.routing().removeEndpointsInDns(application.get(), instanceId.instance());
curator.writeApplication(application.without(instanceId.instance()).get());
controller.jobController().collectGarbage();
+ controller.notificationsDb().removeNotifications(NotificationSource.from(instanceId));
log.info("Deleted " + instanceId);
});
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
index 5b2c2d74d20..2de8fa6457a 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
@@ -23,6 +23,7 @@ import com.yahoo.vespa.hosted.controller.config.ControllerConfig;
import com.yahoo.vespa.hosted.controller.deployment.JobController;
import com.yahoo.vespa.hosted.controller.dns.NameServiceForwarder;
import com.yahoo.vespa.hosted.controller.metric.ConfigServerMetrics;
+import com.yahoo.vespa.hosted.controller.notification.NotificationsDb;
import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
import com.yahoo.vespa.hosted.controller.persistence.JobControlFlags;
import com.yahoo.vespa.hosted.controller.security.AccessControl;
@@ -82,6 +83,7 @@ public class Controller extends AbstractComponent {
private final ControllerConfig controllerConfig;
private final SecretStore secretStore;
private final CuratorArchiveBucketDb archiveBucketDb;
+ private final NotificationsDb notificationsDb;
/**
* Creates a controller
@@ -118,6 +120,7 @@ public class Controller extends AbstractComponent {
auditLogger = new AuditLogger(curator, clock);
jobControl = new JobControl(new JobControlFlags(curator, flagSource));
archiveBucketDb = new CuratorArchiveBucketDb(this);
+ notificationsDb = new NotificationsDb(this);
this.controllerConfig = controllerConfig;
this.secretStore = secretStore;
@@ -306,4 +309,8 @@ public class Controller extends AbstractComponent {
public CuratorArchiveBucketDb archiveBucketDb() {
return archiveBucketDb;
}
+
+ public NotificationsDb notificationsDb() {
+ return notificationsDb;
+ }
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java
index f3e192aef90..4b102ef3077 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java
@@ -10,6 +10,7 @@ import com.yahoo.vespa.flags.Flags;
import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
import com.yahoo.vespa.hosted.controller.application.SystemApplication;
import com.yahoo.vespa.hosted.controller.concurrent.Once;
+import com.yahoo.vespa.hosted.controller.notification.NotificationSource;
import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
import com.yahoo.vespa.hosted.controller.security.AccessControl;
import com.yahoo.vespa.hosted.controller.security.Credentials;
@@ -171,6 +172,7 @@ public class TenantController {
curator.removeTenant(tenant);
accessControl.deleteTenant(tenant, credentials);
+ controller.notificationsDb().removeNotifications(NotificationSource.from(tenant));
}
}
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 548f6a9aaf2..a907cbe2406 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
@@ -25,9 +25,9 @@ import com.yahoo.security.X509CertificateUtils;
import com.yahoo.vespa.hosted.controller.Application;
import com.yahoo.vespa.hosted.controller.Controller;
import com.yahoo.vespa.hosted.controller.Instance;
-import com.yahoo.vespa.hosted.controller.application.ActivateResult;
import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
import com.yahoo.vespa.hosted.controller.api.integration.LogEntry;
+import com.yahoo.vespa.hosted.controller.api.integration.certificates.EndpointCertificateException;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServerException;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.Node;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.PrepareResponse;
@@ -39,13 +39,15 @@ import com.yahoo.vespa.hosted.controller.api.integration.deployment.TesterCloud;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.TesterId;
import com.yahoo.vespa.hosted.controller.api.integration.organization.DeploymentFailureMails;
import com.yahoo.vespa.hosted.controller.api.integration.organization.Mail;
+import com.yahoo.vespa.hosted.controller.application.ActivateResult;
import com.yahoo.vespa.hosted.controller.application.ApplicationPackage;
import com.yahoo.vespa.hosted.controller.application.Deployment;
import com.yahoo.vespa.hosted.controller.application.Endpoint;
import com.yahoo.vespa.hosted.controller.application.TenantAndApplicationId;
-import com.yahoo.vespa.hosted.controller.api.integration.certificates.EndpointCertificateException;
import com.yahoo.vespa.hosted.controller.config.ControllerConfig;
import com.yahoo.vespa.hosted.controller.maintenance.JobRunner;
+import com.yahoo.vespa.hosted.controller.notification.Notification;
+import com.yahoo.vespa.hosted.controller.notification.NotificationSource;
import com.yahoo.vespa.hosted.controller.routing.RoutingPolicyId;
import com.yahoo.yolean.Exceptions;
@@ -67,6 +69,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -229,7 +232,7 @@ public class InternalStepRunner implements StepRunner {
case CERTIFICATE_NOT_READY:
logger.log("Waiting for certificate to become ready on config server: New application, or old one has expired");
if (startTime.plus(timeouts.endpointCertificate()).isBefore(controller.clock().instant())) {
- logger.log("Certificate did not become available on config server within (" + timeouts.endpointCertificate() + ")");
+ logger.log(WARNING, "Certificate did not become available on config server within (" + timeouts.endpointCertificate() + ")");
return Optional.of(RunStatus.endpointCertificateTimeout);
}
return result;
@@ -249,7 +252,7 @@ public class InternalStepRunner implements StepRunner {
: Optional.of(outOfCapacity);
case INVALID_APPLICATION_PACKAGE:
case BAD_REQUEST:
- logger.log(e.getMessage());
+ logger.log(WARNING, e.getMessage());
return Optional.of(deploymentFailed);
}
@@ -261,7 +264,7 @@ public class InternalStepRunner implements StepRunner {
// Same as CERTIFICATE_NOT_READY above, only from the controller
logger.log("Waiting for certificate to become valid: New application, or old one has expired");
if (startTime.plus(timeouts.endpointCertificate()).isBefore(controller.clock().instant())) {
- logger.log("Controller could not validate certificate within " +
+ logger.log(WARNING, "Controller could not validate certificate within " +
timeouts.endpointCertificate() + ": " + Exceptions.toMessageString(e));
return Optional.of(RunStatus.endpointCertificateTimeout);
}
@@ -596,7 +599,7 @@ public class InternalStepRunner implements StepRunner {
testerCertificate.get().checkValidity(Date.from(controller.clock().instant()));
}
catch (CertificateExpiredException | CertificateNotYetValidException e) {
- logger.log(INFO, "Tester certificate expired before tests could complete.");
+ logger.log(WARNING, "Tester certificate expired before tests could complete.");
return Optional.of(aborted);
}
}
@@ -671,7 +674,8 @@ public class InternalStepRunner implements StepRunner {
try {
controller.jobController().active(id).ifPresent(run -> {
if (run.hasFailed())
- sendNotification(run, logger);
+ sendEmailNotification(run, logger);
+ updateConsoleNotification(run);
});
}
catch (IllegalStateException e) {
@@ -682,7 +686,7 @@ public class InternalStepRunner implements StepRunner {
}
/** Sends a mail with a notification of a failed run, if one should be sent. */
- private void sendNotification(Run run, DualLogger logger) {
+ private void sendEmailNotification(Run run, DualLogger logger) {
Application application = controller.applications().requireApplication(TenantAndApplicationId.from(run.id().application()));
Notifications notifications = application.deploymentSpec().requireInstance(run.id().application().instance()).notifications();
boolean newCommit = application.require(run.id().application().instance()).change().application()
@@ -702,8 +706,40 @@ public class InternalStepRunner implements StepRunner {
mailOf(run, recipients).ifPresent(controller.serviceRegistry().mailer()::send);
}
catch (RuntimeException e) {
- logger.log(INFO, "Exception trying to send mail for " + run.id(), e);
+ logger.log(WARNING, "Exception trying to send mail for " + run.id(), e);
+ }
+ }
+
+ private void updateConsoleNotification(Run run) {
+ NotificationSource source = NotificationSource.from(run.id());
+ Consumer<String> updater = msg -> controller.notificationsDb().setNotification(source, Notification.Type.DEPLOYMENT_FAILURE, msg);
+ switch (run.status()) {
+ case running:
+ case aborted:
+ return; // If running, its too early to update. If aborted, let's wait and see how the next run goes.
+ case success:
+ controller.notificationsDb().removeNotification(source, Notification.Type.DEPLOYMENT_FAILURE);
+ return;
+ case outOfCapacity:
+ if ( ! run.id().type().environment().isTest()) updater.accept("lack of capacity. Please contact the Vespa team to request more!");
+ return;
+ case deploymentFailed:
+ updater.accept("invalid application configuration, or timeout of other deployments of the same application");
+ return;
+ case installationFailed:
+ updater.accept("nodes were not able to start the new Java containers");
+ return;
+ case testFailure:
+ updater.accept("one or more verification tests against the deployment failed");
+ return;
+ case error:
+ case endpointCertificateTimeout:
+ break;
+ default:
+ logger.log(WARNING, "Don't know what to set console notification to for run status '" + run.status() + "'");
}
+ updater.accept("something in the framework went wrong. Such errors are " +
+ "usually transient. Please contact the Vespa team if the problem persists!");
}
private Optional<Mail> mailOf(Run run, List<String> recipients) {
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
new file mode 100644
index 00000000000..7a4052367c6
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notification.java
@@ -0,0 +1,61 @@
+// 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.notification;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * @author freva
+ */
+public class Notification {
+ private final Instant at;
+ private final Type type;
+ private final NotificationSource source;
+ private final List<String> messages;
+
+ public Notification(Instant at, Type type, NotificationSource source, List<String> messages) {
+ this.at = Objects.requireNonNull(at, "at cannot be null");
+ this.type = Objects.requireNonNull(type, "type cannot be null");
+ this.source = Objects.requireNonNull(source, "source cannot be null");
+ this.messages = List.copyOf(Objects.requireNonNull(messages, "messages cannot be null"));
+ if (messages.size() < 1) throw new IllegalArgumentException("messages cannot be empty");
+ }
+
+ public Instant at() { return at; }
+ public Type type() { return type; }
+ public NotificationSource source() { return source; }
+ public List<String> messages() { return messages; }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Notification that = (Notification) o;
+ return at.equals(that.at) && type == that.type && source.equals(that.source) && messages.equals(that.messages);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(at, type, source, messages);
+ }
+
+ @Override
+ public String toString() {
+ return "Notification{" +
+ "at=" + at +
+ ", type=" + type +
+ ", source=" + source +
+ ", messages=" + messages +
+ '}';
+ }
+
+ public enum Type {
+ /** Warnings about usage of deprecated features in application package */
+ APPLICATION_PACKAGE_WARNING,
+
+ /** Failure to deploy application package */
+ DEPLOYMENT_FAILURE
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationSource.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationSource.java
new file mode 100644
index 00000000000..2e7f1948eed
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationSource.java
@@ -0,0 +1,153 @@
+// 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.notification;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.ApplicationName;
+import com.yahoo.config.provision.ClusterSpec;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.config.provision.InstanceName;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.config.provision.zone.ZoneId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.RunId;
+import com.yahoo.vespa.hosted.controller.application.TenantAndApplicationId;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalLong;
+
+/**
+ * Denotes the source of the notification.
+ *
+ * @author freva
+ */
+public class NotificationSource {
+ private final TenantName tenant;
+ private final Optional<ApplicationName> application;
+ private final Optional<InstanceName> instance;
+ private final Optional<ZoneId> zoneId;
+ private final Optional<ClusterSpec.Id> clusterId;
+ private final Optional<JobType> jobType;
+ private final OptionalLong runNumber;
+
+ public NotificationSource(TenantName tenant, Optional<ApplicationName> application, Optional<InstanceName> instance,
+ Optional<ZoneId> zoneId, Optional<ClusterSpec.Id> clusterId, Optional<JobType> jobType, OptionalLong runNumber) {
+ this.tenant = Objects.requireNonNull(tenant, "tenant cannot be null");
+ this.application = Objects.requireNonNull(application, "application cannot be null");
+ this.instance = Objects.requireNonNull(instance, "instance cannot be null");
+ this.zoneId = Objects.requireNonNull(zoneId, "zoneId cannot be null");
+ this.clusterId = Objects.requireNonNull(clusterId, "clusterId cannot be null");
+ this.jobType = Objects.requireNonNull(jobType, "jobType cannot be null");
+ this.runNumber = Objects.requireNonNull(runNumber, "runNumber cannot be null");
+
+ if (instance.isPresent() && application.isEmpty())
+ throw new IllegalArgumentException("Application name must be present with instance name");
+ if (zoneId.isPresent() && instance.isEmpty())
+ throw new IllegalArgumentException("Instance name must be present with zone ID");
+ if (clusterId.isPresent() && zoneId.isEmpty())
+ throw new IllegalArgumentException("Zone ID must be present with cluster ID");
+ if (clusterId.isPresent() && jobType.isPresent())
+ throw new IllegalArgumentException("Cannot set both cluster ID and job type");
+ if (jobType.isPresent() && instance.isEmpty())
+ throw new IllegalArgumentException("Instance name must be present with job type");
+ if (jobType.isPresent() != runNumber.isPresent())
+ throw new IllegalArgumentException(String.format("Run number (%s) must be 1-to-1 with job type (%s)",
+ runNumber.isPresent() ? "present" : "missing", jobType.map(i -> "present").orElse("missing")));
+ }
+
+
+ public TenantName tenant() { return tenant; }
+ public Optional<ApplicationName> application() { return application; }
+ public Optional<InstanceName> instance() { return instance; }
+ public Optional<ZoneId> zoneId() { return zoneId; }
+ public Optional<ClusterSpec.Id> clusterId() { return clusterId; }
+ public Optional<JobType> jobType() { return jobType; }
+ public OptionalLong runNumber() { return runNumber; }
+
+ /**
+ * Returns true iff this source contains the given source. A source contains the other source if
+ * all the set fields in this source are equal to the given source, while the fields not set
+ * in this source are ignored.
+ */
+ public boolean contains(NotificationSource other) {
+ return tenant.equals(other.tenant) &&
+ (application.isEmpty() || application.equals(other.application)) &&
+ (instance.isEmpty() || instance.equals(other.instance)) &&
+ (zoneId.isEmpty() || zoneId.equals(other.zoneId)) &&
+ (clusterId.isEmpty() || clusterId.equals(other.clusterId)) &&
+ (jobType.isEmpty() || jobType.equals(other.jobType)); // Do not consider run number (it's unique!)
+ }
+
+ /**
+ * Returns whether this source from a production deployment or deployment related to prod deployment (e.g. to
+ * staging zone), or if this is at tenant or application level
+ */
+ public boolean isProduction() {
+ if (instance.isEmpty()) return true;
+ return ! zoneId.map(ZoneId::environment)
+ .or(() -> jobType.map(JobType::environment))
+ .map(Environment::isManuallyDeployed)
+ .orElse(true); // Assume that notification with full application ID concern dev deployments
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ NotificationSource that = (NotificationSource) o;
+ return tenant.equals(that.tenant) && application.equals(that.application) && instance.equals(that.instance) &&
+ zoneId.equals(that.zoneId) && clusterId.equals(that.clusterId) && jobType.equals(that.jobType) &&
+ runNumber.equals(that.runNumber);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(tenant, application, instance, zoneId, clusterId, jobType, runNumber);
+ }
+
+ @Override
+ public String toString() {
+ return "NotificationSource{" +
+ "tenant=" + tenant +
+ application.map(application -> ", application=" + application.value()).orElse("") +
+ instance.map(instance -> ", instance=" + instance.value()).orElse("") +
+ zoneId.map(zoneId -> ", zone=" + zoneId.value()).orElse("") +
+ clusterId.map(clusterId -> ", clusterId=" + clusterId.value()).orElse("") +
+ jobType.map(jobType -> ", job=" + jobType.jobName() + "#" + runNumber.getAsLong()).orElse("") +
+ '}';
+ }
+
+ private static NotificationSource from(TenantName tenant, ApplicationName application, InstanceName instance, ZoneId zoneId,
+ ClusterSpec.Id clusterId, JobType jobType, Long runNumber) {
+ return new NotificationSource(tenant, Optional.ofNullable(application), Optional.ofNullable(instance), Optional.ofNullable(zoneId),
+ Optional.ofNullable(clusterId), Optional.ofNullable(jobType), runNumber == null ? OptionalLong.empty() : OptionalLong.of(runNumber));
+ }
+
+ public static NotificationSource from(TenantName tenantName) {
+ return from(tenantName, null, null, null, null, null, null);
+ }
+
+ public static NotificationSource from(TenantAndApplicationId id) {
+ return from(id.tenant(), id.application(), null, null, null, null, null);
+ }
+
+ public static NotificationSource from(ApplicationId app) {
+ return from(app.tenant(), app.application(), app.instance(), null, null, null, null);
+ }
+
+ public static NotificationSource from(DeploymentId deploymentId) {
+ ApplicationId app = deploymentId.applicationId();
+ return from(app.tenant(), app.application(), app.instance(), deploymentId.zoneId(), null, null, null);
+ }
+
+ public static NotificationSource from(DeploymentId deploymentId, ClusterSpec.Id clusterId) {
+ ApplicationId app = deploymentId.applicationId();
+ return from(app.tenant(), app.application(), app.instance(), deploymentId.zoneId(), clusterId, null, null);
+ }
+
+ public static NotificationSource from(RunId runId) {
+ ApplicationId app = runId.application();
+ return from(app.tenant(), app.application(), app.instance(), null, null, runId.job().type(), runId.number());
+ }
+}
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
new file mode 100644
index 00000000000..950dddfc056
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDb.java
@@ -0,0 +1,84 @@
+// 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.notification;
+
+import com.yahoo.vespa.curator.Lock;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
+
+import java.time.Clock;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Adds, updates and removes tenant notifications in ZK
+ *
+ * @author freva
+ */
+public class NotificationsDb {
+
+ private final Clock clock;
+ private final CuratorDb curatorDb;
+
+ public NotificationsDb(Controller controller) {
+ this(controller.clock(), controller.curator());
+ }
+
+ NotificationsDb(Clock clock, CuratorDb curatorDb) {
+ this.clock = clock;
+ this.curatorDb = curatorDb;
+ }
+
+ public List<Notification> listNotifications(NotificationSource source, boolean productionOnly) {
+ return curatorDb.readNotifications(source.tenant()).stream()
+ .filter(notification -> source.contains(notification.source()) && (!productionOnly || notification.source().isProduction()))
+ .collect(Collectors.toUnmodifiableList());
+ }
+
+ public void setNotification(NotificationSource source, Notification.Type type, String message) {
+ setNotification(source, type, List.of(message));
+ }
+
+ /**
+ * Add a notification with given source and type. If a notification with same source and type
+ * already exists, it'll be replaced by this one instead
+ */
+ public void setNotification(NotificationSource source, Notification.Type type, List<String> messages) {
+ try (Lock lock = curatorDb.lockNotifications(source.tenant())) {
+ List<Notification> notifications = curatorDb.readNotifications(source.tenant()).stream()
+ .filter(notification -> !source.equals(notification.source()) || type != notification.type())
+ .collect(Collectors.toCollection(ArrayList::new));
+ notifications.add(new Notification(clock.instant(), type, source, messages));
+ curatorDb.writeNotifications(source.tenant(), notifications);
+ }
+ }
+
+ /** Remove the notification with the given source and type */
+ public void removeNotification(NotificationSource source, Notification.Type type) {
+ try (Lock lock = curatorDb.lockNotifications(source.tenant())) {
+ List<Notification> initial = curatorDb.readNotifications(source.tenant());
+ List<Notification> filtered = initial.stream()
+ .filter(notification -> !source.equals(notification.source()) || type != notification.type())
+ .collect(Collectors.toUnmodifiableList());
+ if (initial.size() > filtered.size())
+ curatorDb.writeNotifications(source.tenant(), filtered);
+ }
+ }
+
+ /** Remove all notifications for this source or sources contained by this source */
+ public void removeNotifications(NotificationSource source) {
+ try (Lock lock = curatorDb.lockNotifications(source.tenant())) {
+ if (source.application().isEmpty()) { // Source is tenant
+ curatorDb.deleteNotifications(source.tenant());
+ return;
+ }
+
+ List<Notification> initial = curatorDb.readNotifications(source.tenant());
+ List<Notification> filtered = initial.stream()
+ .filter(notification -> !source.contains(notification.source()))
+ .collect(Collectors.toUnmodifiableList());
+ if (initial.size() > filtered.size())
+ curatorDb.writeNotifications(source.tenant(), filtered);
+ }
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
index bf1ee4b23ce..3d6cb45aeb1 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
@@ -25,6 +25,7 @@ import com.yahoo.vespa.hosted.controller.deployment.Run;
import com.yahoo.vespa.hosted.controller.deployment.Step;
import com.yahoo.vespa.hosted.controller.dns.NameServiceQueue;
import com.yahoo.vespa.hosted.controller.api.integration.vcmr.VespaChangeRequest;
+import com.yahoo.vespa.hosted.controller.notification.Notification;
import com.yahoo.vespa.hosted.controller.routing.GlobalRouting;
import com.yahoo.vespa.hosted.controller.routing.RoutingPolicy;
import com.yahoo.vespa.hosted.controller.routing.RoutingPolicyId;
@@ -89,6 +90,7 @@ public class CuratorDb {
private static final Path endpointCertificateRoot = root.append("applicationCertificates");
private static final Path archiveBucketsRoot = root.append("archiveBuckets");
private static final Path changeRequestsRoot = root.append("changeRequests");
+ private static final Path notificationsRoot = root.append("notifications");
private final NodeVersionSerializer nodeVersionSerializer = new NodeVersionSerializer();
private final VersionStatusSerializer versionStatusSerializer = new VersionStatusSerializer(nodeVersionSerializer);
@@ -208,6 +210,10 @@ public class CuratorDb {
return curator.lock(lockRoot.append("changeRequests"), defaultLockTimeout);
}
+ public Lock lockNotifications(TenantName tenantName) {
+ return curator.lock(lockRoot.append("notifications").append(tenantName.value()), defaultLockTimeout);
+ }
+
// -------------- Helpers ------------------------------------------
/** Try locking with a low timeout, meaning it is OK to fail lock acquisition.
@@ -589,6 +595,21 @@ public class CuratorDb {
curator.delete(changeRequestPath(changeRequest.getId()));
}
+ // -------------- Notifications ---------------------------------------------------
+
+ public List<Notification> readNotifications(TenantName tenantName) {
+ return readSlime(notificationsPath(tenantName))
+ .map(slime -> NotificationsSerializer.fromSlime(tenantName, slime)).orElseGet(List::of);
+ }
+
+ public void writeNotifications(TenantName tenantName, List<Notification> notifications) {
+ curator.set(notificationsPath(tenantName), asJson(NotificationsSerializer.toSlime(notifications)));
+ }
+
+ public void deleteNotifications(TenantName tenantName) {
+ curator.delete(notificationsPath(tenantName));
+ }
+
// -------------- Paths ---------------------------------------------------
private Path lockPath(TenantName tenant) {
@@ -718,4 +739,8 @@ public class CuratorDb {
return changeRequestsRoot.append(id);
}
+ private static Path notificationsPath(TenantName tenantName) {
+ return notificationsRoot.append(tenantName.value());
+ }
+
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializer.java
new file mode 100644
index 00000000000..dcb485b9016
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializer.java
@@ -0,0 +1,104 @@
+// 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.persistence;
+
+import com.yahoo.config.provision.ApplicationName;
+import com.yahoo.config.provision.ClusterSpec;
+import com.yahoo.config.provision.InstanceName;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.config.provision.zone.ZoneId;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Inspector;
+import com.yahoo.slime.Slime;
+import com.yahoo.slime.SlimeUtils;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
+import com.yahoo.vespa.hosted.controller.notification.Notification;
+import com.yahoo.vespa.hosted.controller.notification.NotificationSource;
+
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * (de)serializes notifications for a tenant
+ *
+ * @author freva
+ */
+public class NotificationsSerializer {
+
+ // WARNING: Since there are multiple servers in a ZooKeeper cluster and they upgrade one by one
+ // (and rewrite all nodes on startup), changes to the serialized format must be made
+ // such that what is serialized on version N+1 can be read by version N:
+ // - ADDING FIELDS: Always ok
+ // - REMOVING FIELDS: Stop reading the field first. Stop writing it on a later version.
+ // - CHANGING THE FORMAT OF A FIELD: Don't do it bro.
+
+ private static final String notificationsFieldName = "notifications";
+ private static final String atFieldName = "at";
+ private static final String typeField = "type";
+ private static final String messagesField = "messages";
+ private static final String applicationField = "application";
+ private static final String instanceField = "instance";
+ private static final String zoneField = "zone";
+ private static final String clusterIdField = "clusterId";
+ private static final String jobTypeField = "jobId";
+ private static final String runNumberField = "runNumber";
+
+ public static Slime toSlime(List<Notification> notifications) {
+ Slime slime = new Slime();
+ Cursor notificationsArray = slime.setObject().setArray(notificationsFieldName);
+
+ for (Notification notification : notifications) {
+ Cursor notificationObject = notificationsArray.addObject();
+ notificationObject.setLong(atFieldName, notification.at().toEpochMilli());
+ notificationObject.setString(typeField, asString(notification.type()));
+ Cursor messagesArray = notificationObject.setArray(messagesField);
+ notification.messages().forEach(messagesArray::addString);
+
+ notification.source().application().ifPresent(application -> notificationObject.setString(applicationField, application.value()));
+ notification.source().instance().ifPresent(instance -> notificationObject.setString(instanceField, instance.value()));
+ notification.source().zoneId().ifPresent(zoneId -> notificationObject.setString(zoneField, zoneId.value()));
+ notification.source().clusterId().ifPresent(clusterId -> notificationObject.setString(clusterIdField, clusterId.value()));
+ notification.source().jobType().ifPresent(jobType -> notificationObject.setString(jobTypeField, jobType.jobName()));
+ notification.source().runNumber().ifPresent(runNumber -> notificationObject.setLong(runNumberField, runNumber));
+ }
+
+ return slime;
+ }
+
+ public static List<Notification> fromSlime(TenantName tenantName, Slime slime) {
+ return SlimeUtils.entriesStream(slime.get().field(notificationsFieldName))
+ .map(inspector -> fromInspector(tenantName, inspector))
+ .collect(Collectors.toUnmodifiableList());
+ }
+
+ private static Notification fromInspector(TenantName tenantName, Inspector inspector) {
+ return new Notification(
+ Serializers.instant(inspector.field(atFieldName)),
+ typeFrom(inspector.field(typeField)),
+ new NotificationSource(
+ tenantName,
+ Serializers.optionalString(inspector.field(applicationField)).map(ApplicationName::from),
+ Serializers.optionalString(inspector.field(instanceField)).map(InstanceName::from),
+ Serializers.optionalString(inspector.field(zoneField)).map(ZoneId::from),
+ Serializers.optionalString(inspector.field(clusterIdField)).map(ClusterSpec.Id::from),
+ Serializers.optionalString(inspector.field(jobTypeField)).map(JobType::fromJobName),
+ Serializers.optionalLong(inspector.field(runNumberField))),
+ SlimeUtils.entriesStream(inspector.field(messagesField)).map(Inspector::asString).collect(Collectors.toUnmodifiableList()));
+ }
+
+ private static String asString(Notification.Type type) {
+ switch (type) {
+ case APPLICATION_PACKAGE_WARNING: return "APPLICATION_PACKAGE_WARNING";
+ case DEPLOYMENT_FAILURE: return "DEPLOYMENT_FAILURE";
+ default: throw new IllegalArgumentException("No serialization defined for notification type " + type);
+ }
+ }
+
+ private static Notification.Type typeFrom(Inspector field) {
+ switch (field.asString()) {
+ case "APPLICATION_PACKAGE_WARNING": return Notification.Type.APPLICATION_PACKAGE_WARNING;
+ case "DEPLOYMENT_FAILURE": return Notification.Type.DEPLOYMENT_FAILURE;
+ default: throw new IllegalArgumentException("Unknown serialized notification type value '" + field.asString() + "'");
+ }
+ }
+}
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 78dab2cceb6..e5ec3f324ad 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
@@ -87,6 +87,8 @@ import com.yahoo.vespa.hosted.controller.deployment.DeploymentTrigger.ChangesToC
import com.yahoo.vespa.hosted.controller.deployment.JobStatus;
import com.yahoo.vespa.hosted.controller.deployment.Run;
import com.yahoo.vespa.hosted.controller.deployment.TestConfigSerializer;
+import com.yahoo.vespa.hosted.controller.notification.Notification;
+import com.yahoo.vespa.hosted.controller.notification.NotificationSource;
import com.yahoo.vespa.hosted.controller.rotation.RotationId;
import com.yahoo.vespa.hosted.controller.rotation.RotationState;
import com.yahoo.vespa.hosted.controller.rotation.RotationStatus;
@@ -136,8 +138,6 @@ import java.util.stream.Stream;
import static com.yahoo.jdisc.Response.Status.BAD_REQUEST;
import static com.yahoo.jdisc.Response.Status.CONFLICT;
-import static com.yahoo.jdisc.Response.Status.INTERNAL_SERVER_ERROR;
-import static com.yahoo.jdisc.Response.Status.NOT_FOUND;
import static java.util.Map.Entry.comparingByKey;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
@@ -223,6 +223,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler {
if (path.matches("/application/v4/tenant")) return tenants(request);
if (path.matches("/application/v4/tenant/{tenant}")) return tenant(path.get("tenant"), request);
if (path.matches("/application/v4/tenant/{tenant}/info")) return tenantInfo(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/notifications")) return notifications(path.get("tenant"), request);
if (path.matches("/application/v4/tenant/{tenant}/secret-store/{name}/validate")) return validateSecretStore(path.get("tenant"), path.get("name"), request);
if (path.matches("/application/v4/tenant/{tenant}/application")) return applications(path.get("tenant"), Optional.empty(), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return application(path.get("tenant"), path.get("application"), request);
@@ -480,6 +481,44 @@ public class ApplicationApiHandler extends LoggingRequestHandler {
.withAddress(updateTenantInfoAddress(insp.field("address"), oldContact.address()));
}
+ private HttpResponse notifications(String tenantName, HttpRequest request) {
+ NotificationSource notificationSource = new NotificationSource(TenantName.from(tenantName),
+ Optional.ofNullable(request.getProperty("application")).map(ApplicationName::from),
+ Optional.ofNullable(request.getProperty("instance")).map(InstanceName::from),
+ Optional.empty(), Optional.empty(), Optional.empty(), OptionalLong.empty());
+
+ Slime slime = new Slime();
+ Cursor notificationsArray = slime.setObject().setArray("notifications");
+ controller.notificationsDb().listNotifications(notificationSource, showOnlyProductionInstances(request))
+ .forEach(notification -> toSlime(notificationsArray.addObject(), notification));
+ return new SlimeJsonResponse(slime);
+ }
+
+ private static void toSlime(Cursor cursor, Notification notification) {
+ cursor.setLong("at", notification.at().toEpochMilli());
+ cursor.setString("type", notificationTypeAsString(notification.type()));
+ Cursor messagesArray = cursor.setArray("messages");
+ notification.messages().forEach(messagesArray::addString);
+
+ notification.source().application().ifPresent(application -> cursor.setString("application", application.value()));
+ notification.source().instance().ifPresent(instance -> cursor.setString("instance", instance.value()));
+ notification.source().zoneId().ifPresent(zoneId -> {
+ cursor.setString("environment", zoneId.environment().value());
+ cursor.setString("region", zoneId.region().value());
+ });
+ notification.source().clusterId().ifPresent(clusterId -> cursor.setString("clusterId", clusterId.value()));
+ notification.source().jobType().ifPresent(jobType -> cursor.setString("jobType", jobType.jobName()));
+ notification.source().runNumber().ifPresent(runNumber -> cursor.setLong("runNumber", runNumber));
+ }
+
+ private static String notificationTypeAsString(Notification.Type type) {
+ switch (type) {
+ case APPLICATION_PACKAGE_WARNING: return "APPLICATION_PACKAGE_WARNING";
+ case DEPLOYMENT_FAILURE: return "DEPLOYMENT_FAILURE";
+ default: throw new IllegalArgumentException("No serialization defined for notification type " + type);
+ }
+ }
+
private HttpResponse applications(String tenantName, Optional<String> applicationName, HttpRequest request) {
TenantName tenant = TenantName.from(tenantName);
if (controller.tenants().get(tenantName).isEmpty())
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java
new file mode 100644
index 00000000000..90d1ecb2f20
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java
@@ -0,0 +1,107 @@
+// 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.notification;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.ClusterSpec;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.config.provision.zone.ZoneId;
+import com.yahoo.path.Path;
+import com.yahoo.test.ManualClock;
+import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.RunId;
+import com.yahoo.vespa.hosted.controller.application.TenantAndApplicationId;
+import com.yahoo.vespa.hosted.controller.persistence.MockCuratorDb;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * @author freva
+ */
+public class NotificationsDbTest {
+
+ private static final TenantName tenant = TenantName.from("tenant1");
+ private static final List<Notification> notifications = List.of(
+ notification(1001, Notification.Type.DEPLOYMENT_FAILURE, NotificationSource.from(tenant), "tenant msg"),
+ notification(1101, Notification.Type.DEPLOYMENT_FAILURE, NotificationSource.from(TenantAndApplicationId.from(tenant.value(), "app1")), "app msg"),
+ notification(1201, Notification.Type.DEPLOYMENT_FAILURE, NotificationSource.from(ApplicationId.from(tenant.value(), "app2", "instance2")), "instance msg"),
+ notification(1301, Notification.Type.DEPLOYMENT_FAILURE, NotificationSource.from(new DeploymentId(ApplicationId.from(tenant.value(), "app2", "instance2"), ZoneId.from("prod", "us-north-2"))), "deployment msg"),
+ notification(1401, Notification.Type.DEPLOYMENT_FAILURE, NotificationSource.from(new DeploymentId(ApplicationId.from(tenant.value(), "app1", "instance1"), ZoneId.from("dev", "us-south-1")), ClusterSpec.Id.from("cluster1")), "cluster msg"),
+ notification(1501, Notification.Type.DEPLOYMENT_FAILURE, NotificationSource.from(new RunId(ApplicationId.from(tenant.value(), "app1", "instance1"), JobType.devUsEast1, 4)), "run id msg"));
+
+ private final ManualClock clock = new ManualClock(Instant.ofEpochSecond(12345));
+ private final MockCuratorDb curatorDb = new MockCuratorDb();
+ private final NotificationsDb notificationsDb = new NotificationsDb(clock, curatorDb);
+
+ @Test
+ public void list_test() {
+ assertEquals(notifications, notificationsDb.listNotifications(NotificationSource.from(tenant), false));
+ assertEquals(notificationIndices(0, 1, 3), notificationsDb.listNotifications(NotificationSource.from(tenant), true));
+ assertEquals(notificationIndices(2, 3), notificationsDb.listNotifications(NotificationSource.from(TenantAndApplicationId.from(tenant.value(), "app2")), false));
+ assertEquals(notificationIndices(4, 5), notificationsDb.listNotifications(NotificationSource.from(ApplicationId.from(tenant.value(), "app1", "instance1")), false));
+ assertEquals(notificationIndices(5), notificationsDb.listNotifications(NotificationSource.from(new RunId(ApplicationId.from(tenant.value(), "app1", "instance1"), JobType.devUsEast1, 5)), false));
+ assertEquals(List.of(), notificationsDb.listNotifications(NotificationSource.from(new RunId(ApplicationId.from(tenant.value(), "app1", "instance1"), JobType.productionUsEast3, 4)), false));
+ }
+
+ @Test
+ public void add_test() {
+ Notification notification1 = notification(12345, Notification.Type.DEPLOYMENT_FAILURE, NotificationSource.from(ApplicationId.from(tenant.value(), "app2", "instance2")), "instance msg #2");
+ Notification notification2 = notification(12345, Notification.Type.DEPLOYMENT_FAILURE, NotificationSource.from(ApplicationId.from(tenant.value(), "app3", "instance2")), "instance msg #3");
+
+ // Replace the 3rd notification
+ notificationsDb.setNotification(notification1.source(), notification1.type(), notification1.messages());
+
+ // Notification for a new app, add without replacement
+ notificationsDb.setNotification(notification2.source(), notification2.type(), notification2.messages());
+
+ List<Notification> expected = notificationIndices(0, 1, 3, 4, 5);
+ expected.addAll(List.of(notification1, notification2));
+ assertEquals(expected, curatorDb.readNotifications(tenant));
+ }
+
+ @Test
+ public void remove_single_test() {
+ // Remove the 3rd notification
+ notificationsDb.removeNotification(NotificationSource.from(ApplicationId.from(tenant.value(), "app2", "instance2")), Notification.Type.DEPLOYMENT_FAILURE);
+
+ // Removing something that doesn't exist is OK
+ notificationsDb.removeNotification(NotificationSource.from(ApplicationId.from(tenant.value(), "app3", "instance2")), Notification.Type.DEPLOYMENT_FAILURE);
+
+ assertEquals(notificationIndices(0, 1, 3, 4, 5), curatorDb.readNotifications(tenant));
+ }
+
+ @Test
+ public void remove_multiple_test() {
+ // Remove the 3rd notification
+ notificationsDb.removeNotifications(NotificationSource.from(ApplicationId.from(tenant.value(), "app1", "instance1")));
+ assertEquals(notificationIndices(0, 1, 2, 3), curatorDb.readNotifications(tenant));
+ assertTrue(curatorDb.curator().exists(Path.fromString("/controller/v1/notifications/" + tenant.value())));
+
+ notificationsDb.removeNotifications(NotificationSource.from(tenant));
+ assertEquals(List.of(), curatorDb.readNotifications(tenant));
+ assertFalse(curatorDb.curator().exists(Path.fromString("/controller/v1/notifications/" + tenant.value())));
+ }
+
+ @Before
+ public void init() {
+ curatorDb.writeNotifications(tenant, notifications);
+ }
+
+ private static List<Notification> notificationIndices(int... indices) {
+ return Arrays.stream(indices).mapToObj(notifications::get).collect(Collectors.toCollection(ArrayList::new));
+ }
+
+ private static Notification notification(long secondsSinceEpoch, Notification.Type type, NotificationSource source, String... messages) {
+ return new Notification(Instant.ofEpochSecond(secondsSinceEpoch), type, source, List.of(messages));
+ }
+}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializerTest.java
new file mode 100644
index 00000000000..f3f2d10cfd0
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/NotificationsSerializerTest.java
@@ -0,0 +1,59 @@
+// 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.persistence;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.slime.Slime;
+import com.yahoo.slime.SlimeUtils;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
+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;
+import com.yahoo.vespa.hosted.controller.notification.NotificationSource;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * @author freva
+ */
+public class NotificationsSerializerTest {
+
+ @Test
+ public void serialization_test() throws IOException {
+ TenantName tenantName = TenantName.from("tenant1");
+ List<Notification> notifications = List.of(
+ new Notification(Instant.ofEpochSecond(1234),
+ Notification.Type.APPLICATION_PACKAGE_WARNING,
+ NotificationSource.from(TenantAndApplicationId.from(tenantName.value(), "app1")),
+ List.of("Something something deprecated...")),
+ new Notification(Instant.ofEpochSecond(2345),
+ Notification.Type.DEPLOYMENT_FAILURE,
+ NotificationSource.from(new RunId(ApplicationId.from(tenantName.value(), "app1", "instance1"), JobType.systemTest, 12)),
+ List.of("Failed to deploy: Out of capacity")));
+
+ Slime serialized = NotificationsSerializer.toSlime(notifications);
+ assertEquals("{\"notifications\":[" +
+ "{" +
+ "\"at\":1234000," +
+ "\"type\":\"APPLICATION_PACKAGE_WARNING\"," +
+ "\"messages\":[\"Something something deprecated...\"]," +
+ "\"application\":\"app1\"" +
+ "},{" +
+ "\"at\":2345000," +
+ "\"type\":\"DEPLOYMENT_FAILURE\"," +
+ "\"messages\":[\"Failed to deploy: Out of capacity\"]," +
+ "\"application\":\"app1\"," +
+ "\"instance\":\"instance1\"," +
+ "\"jobId\":\"system-test\"," +
+ "\"runNumber\":12" +
+ "}]}", new String(SlimeUtils.toJsonBytes(serialized)));
+
+ List<Notification> deserialized = NotificationsSerializer.fromSlime(tenantName, serialized);
+ assertEquals(notifications, deserialized);
+ }
+} \ No newline at end of file
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java
index b1b1c7ffe7a..abea055dde8 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java
@@ -39,6 +39,7 @@ import com.yahoo.vespa.hosted.controller.api.integration.athenz.AthenzDbMock;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServerException;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.ApplicationVersion;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
+import com.yahoo.vespa.hosted.controller.api.integration.deployment.RunId;
import com.yahoo.vespa.hosted.controller.api.integration.organization.Contact;
import com.yahoo.vespa.hosted.controller.api.integration.organization.IssueId;
import com.yahoo.vespa.hosted.controller.api.integration.organization.User;
@@ -58,6 +59,8 @@ import com.yahoo.vespa.hosted.controller.deployment.DeploymentTrigger;
import com.yahoo.vespa.hosted.controller.integration.ConfigServerMock;
import com.yahoo.vespa.hosted.controller.integration.ZoneApiMock;
import com.yahoo.vespa.hosted.controller.metric.ApplicationMetrics;
+import com.yahoo.vespa.hosted.controller.notification.Notification;
+import com.yahoo.vespa.hosted.controller.notification.NotificationSource;
import com.yahoo.vespa.hosted.controller.restapi.ContainerTester;
import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerTest;
import com.yahoo.vespa.hosted.controller.routing.GlobalRouting;
@@ -801,6 +804,13 @@ public class ApplicationApiTest extends ControllerContainerTest {
.userIdentity(USER_ID),
"");
+ addNotifications(TenantName.from("tenant1"));
+ tester.assertResponse(request("/application/v4/tenant/tenant1/notifications", GET).userIdentity(USER_ID),
+ new File("notifications-tenant1.json"));
+ tester.assertResponse(request("/application/v4/tenant/tenant1/notifications", GET)
+ .properties(Map.of("application", "app2")).userIdentity(USER_ID),
+ new File("notifications-tenant1-app2.json"));
+
// DELETE the application which no longer has any deployments
tester.assertResponse(request("/application/v4/tenant/tenant1/application/application1", DELETE)
.userIdentity(USER_ID)
@@ -1628,6 +1638,17 @@ public class ApplicationApiTest extends ControllerContainerTest {
));
}
+ private void addNotifications(TenantName tenantName) {
+ tester.controller().notificationsDb().setNotification(
+ NotificationSource.from(TenantAndApplicationId.from(tenantName.value(), "app1")),
+ Notification.Type.APPLICATION_PACKAGE_WARNING,
+ "Something something deprecated...");
+ tester.controller().notificationsDb().setNotification(
+ NotificationSource.from(new RunId(ApplicationId.from(tenantName.value(), "app2", "instance1"), JobType.systemTest, 12)),
+ Notification.Type.DEPLOYMENT_FAILURE,
+ "Failed to deploy: Out of capacity");
+ }
+
private void assertGlobalRouting(DeploymentId deployment, GlobalRouting.Status status, GlobalRouting.Agent agent) {
var changedAt = tester.controller().clock().instant();
var westPolicies = tester.controller().routing().policies().get(deployment);
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/notifications-tenant1-app2.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/notifications-tenant1-app2.json
new file mode 100644
index 00000000000..7f583a7d803
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/notifications-tenant1-app2.json
@@ -0,0 +1,15 @@
+{
+ "notifications": [
+ {
+ "at": "(ignore)",
+ "type": "DEPLOYMENT_FAILURE",
+ "messages": [
+ "Failed to deploy: Out of capacity"
+ ],
+ "application": "app2",
+ "instance": "instance1",
+ "jobType": "system-test",
+ "runNumber": 12
+ }
+ ]
+}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/notifications-tenant1.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/notifications-tenant1.json
new file mode 100644
index 00000000000..0ed8e9201a0
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/notifications-tenant1.json
@@ -0,0 +1,23 @@
+{
+ "notifications": [
+ {
+ "at": "(ignore)",
+ "type": "APPLICATION_PACKAGE_WARNING",
+ "messages": [
+ "Something something deprecated..."
+ ],
+ "application": "app1"
+ },
+ {
+ "at": "(ignore)",
+ "type": "DEPLOYMENT_FAILURE",
+ "messages": [
+ "Failed to deploy: Out of capacity"
+ ],
+ "application": "app2",
+ "instance": "instance1",
+ "jobType": "system-test",
+ "runNumber": 12
+ }
+ ]
+}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/us-east-3-log-without-first.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/us-east-3-log-without-first.json
index 6c9315ca64b..588f8839ab7 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/us-east-3-log-without-first.json
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/us-east-3-log-without-first.json
@@ -5,7 +5,7 @@
"deployReal": [
{
"at": 1000,
- "type": "info",
+ "type": "warning",
"message": "Failed to deploy application: ERROR!"
}
]