diff options
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!" } ] |