aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJon Marius Venstad <jonmv@users.noreply.github.com>2017-10-27 10:08:42 +0200
committerGitHub <noreply@github.com>2017-10-27 10:08:42 +0200
commitefbf5553d9aac346f70d7d2502249f48ec8b5e38 (patch)
treee3ad6a5c1203eeb7c2439ba9851f685b4e0daade
parente046148b2e28e2d8bf261c356fa884f882f433e4 (diff)
parent45d6fc68b29cde7ccaf627e8d04470a0e50d8314 (diff)
Merge pull request #3873 from vespa-engine/jvenstad/expose-property-info-in-application-api
Jvenstad/expose property info in application api
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Contacts.java136
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Issues.java186
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Properties.java16
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/jira/JiraMock.java50
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentIssues.java26
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Issue.java75
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/IssueId.java49
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/MockOrganization.java154
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Organization.java124
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/User.java52
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/ContactsMock.java31
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingDeploymentIssues.java96
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingIssues.java95
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/PropertiesMock.java26
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Application.java5
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java5
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java20
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/Tenant.java21
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentJobs.java29
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java9
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentIssueReporter.java240
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializer.java9
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java47
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java4
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentIssueReporterTest.java189
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java6
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java18
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/opsdb-tenant-with-id-without-applications.json13
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/opsdb-tenant-with-new-id-without-applications.json2
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-list.json2
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-without-applications-with-id.json13
31 files changed, 828 insertions, 920 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Contacts.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Contacts.java
deleted file mode 100644
index 329483a85c5..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Contacts.java
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration;
-
-import org.jetbrains.annotations.NotNull;
-
-import java.net.URI;
-import java.util.Collection;
-import java.util.Objects;
-
-import static com.yahoo.vespa.hosted.controller.api.integration.Contacts.Category.unknown;
-import static java.util.Comparator.reverseOrder;
-
-/**
- * @author jvenstad
- */
-public interface Contacts {
-
- /**
- * Returns the most relevant user of lowest non-empty level above that of @assignee, or, if no such user exists,
- * the @assignee with @Category information.
- */
- static UserContact escalationTargetFrom(Collection<UserContact> userContacts, String assignee) {
- return userContacts.stream()
- .filter(contact -> ! contact.username().isEmpty()) // don't assign to empty names
- .sorted(reverseOrder()).distinct() // Pick out the highest category per user.
- // Keep the assignee, or the last user on the first non-empty level above her.
- .sorted().reduce(new UserContact(assignee, assignee, unknown), (current, next) ->
- next.is(assignee) || (current.is(assignee) ^ current.level() == next.level()) ? next : current);
- }
-
- /**
- * Return a list of all contact entries for property with id @propertyId, where username is set.
- */
- Collection<UserContact> userContactsFor(long propertyId);
-
- /** Returns the URL listing contacts for the given property */
- URI contactsUri(long propertyId);
-
- /**
- * Return a target of escalation above @assignee, from the set of @UserContact entries found for @propertyId.
- */
- default UserContact escalationTargetFor(long propertyId, String assignee) {
- return escalationTargetFrom(userContactsFor(propertyId), assignee);
- }
-
- /**
- * A list of contact roles, in the order in which we look for escalation targets.
- * Categories must be listed in increasing order of relevancy per level, and by increasing level.
- */
- enum Category {
-
- unknown(-1, Level.none, "Unknown"),
- admin(54, Level.grunt, "Administrator"), // TODO: Find more grunts?
- businessOwner(567, Level.owner, "Business Owner"),
- serviceOwner(646, Level.owner, "Service Engineering Owner"),
- engineeringOwner(566, Level.owner, "Engineering Owner"),
- vpBusiness(11, Level.VP, "VP Business"),
- vpService(647, Level.VP, "VP Service Engineering"),
- vpEngineering(9, Level.VP, "VP Engineering");
-
- public final long id;
- public final Level level;
- public final String name;
-
- Category(long id, Level level, String name) {
- this.id = id;
- this.level = level;
- this.name = name;
- }
-
- /** Find the category for the given id, or unknown if the id is unknown. */
- public static Category of(Long id) {
- for (Category category : values())
- if (category.id == id)
- return category;
- return unknown;
- }
-
- public enum Level {
- none,
- grunt,
- owner,
- VP;
- }
-
- }
-
- /** Container class for user contact information; sorts by category and identifies by username. Immutable. */
- class UserContact implements Comparable<UserContact> {
-
- private final String username;
- private final String name;
- private final Category category;
-
- public UserContact(String username, String name, Category category) {
- Objects.requireNonNull(username, "username cannot be null");
- Objects.requireNonNull(name, "name cannot be null");
- Objects.requireNonNull(category, "category cannot be null");
- this.username = username;
- this.name = name;
- this.category = category;
- }
-
- public String username() { return username; }
- public String name() { return name; }
- public Category category() { return category; }
- public Category.Level level() { return category.level; }
-
- public boolean is(String username) { return this.username.equals(username); }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (o == null || getClass() != o.getClass()) return false;
- UserContact that = (UserContact) o;
- return Objects.equals(username, that.username);
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(username);
- }
-
- @Override
- public int compareTo(@NotNull UserContact other) {
- return category().compareTo(other.category());
- }
-
- @Override
- public String toString() {
- return String.format("%s, %s, %s", username, name, category.name);
- }
-
- }
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Issues.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Issues.java
deleted file mode 100644
index 8f24b4e3ede..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Issues.java
+++ /dev/null
@@ -1,186 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration;
-
-import java.time.Instant;
-import java.util.List;
-import java.util.Optional;
-
-/**
- * @author jvenstad
- */
-public interface Issues {
-
- /**
- * Returns information about an issue.
- * If this issue does not exist this returns an issue id containing the id and default values.
- */
- IssueInfo fetch(String issueId);
-
- /**
- * Returns the @Meta of all unresolved issues which have the same summary (and queue, if present) as @issue.
- */
- List<IssueInfo> fetchSimilarTo(Issue issue);
-
- /**
- * Files the given issue
- *
- * @return the id of the created issue
- */
- String file(Issue issue);
-
- /**
- * Update the description fields of the issue stored with id @issueId to be @description.
- */
- void update(String issueId, String description);
-
- /**
- * Set the assignee of the issue with id @issueId to the user with usename @assignee.
- */
- void reassign(String issueId, String assignee);
-
- /**
- * Add the user with username @watcher to the watcher list of the issue with id @issueId.
- */
- void addWatcher(String issueId, String watcher);
-
- /**
- * Post @comment as a comment to the issue with id @issueId.
- */
- void comment(String issueId, String comment);
-
-
- /** Contains information used to file an issue with the responsible party; only @queue is mandatory. */
- class Classification {
-
- private final String queue;
- private final String component;
- private final String label;
- private final String assignee;
-
- public Classification(String queue, String component, String label, String assignee) {
- if (queue.isEmpty()) throw new IllegalArgumentException("Queue can not be empty!");
-
- this.queue = queue;
- this.component = component;
- this.label = label;
- this.assignee = assignee;
- }
-
- public Classification(String queue) {
- this(queue, null, null, null);
- }
-
- public Classification withComponent(String component) { return new Classification(queue, component, label, assignee); }
- public Classification withLabel(String label) { return new Classification(queue, component, label, assignee); }
- public Classification withAssignee(String assignee) { return new Classification(queue, component, label, assignee); }
-
- public String queue() { return queue; }
- public Optional<String> component() { return Optional.ofNullable(component); }
- public Optional<String> label() { return Optional.ofNullable(label); }
- public Optional<String> assignee() { return Optional.ofNullable(assignee); }
-
- @Override
- public String toString() {
- return "Queue : " + queue() + "\n" +
- "Component : " + component() + "\n" +
- "Label : " + label() + "\n" +
- "Assignee : " + assignee() + "\n";
- }
-
- }
-
-
- /** Information about a stored issue */
- class IssueInfo {
-
- private final String id;
- private final String key;
- private final Instant updated;
- private final Optional<String> assignee;
- private final Status status;
-
- public IssueInfo(String id, String key, Instant updated, Optional<String> assignee, Status status) {
- if (assignee == null || assignee.isPresent() && assignee.get().isEmpty()) // TODO: Throw on these things
- assignee = Optional.empty();
- this.id = id;
- this.key = key;
- this.updated = updated;
- this.assignee = assignee;
- this.status = status;
- }
-
- public IssueInfo withAssignee(Optional<String> assignee) {
- return new IssueInfo(id, key, updated, assignee, status);
- }
-
- public String id() { return id; }
- public String key() { return key; }
- public Instant updated() { return updated; }
- public Optional<String> assignee() { return assignee; }
- public Status status() { return status; }
-
- public enum Status {
-
- toDo("To Do"),
- inProgress("In Progress"),
- done("Done"),
- noCategory("No Category");
-
- private final String value;
-
- Status(String value) { this.value = value; }
-
- public static Status fromValue(String value) {
- for (Status status : Status.values())
- if (status.value.equals(value))
- return status;
- throw new IllegalArgumentException(value + " is not a valid status.");
- }
-
- }
-
- }
-
-
- /**
- * A representation of an issue with a Vespa application which can be reported and escalated through an external issue service.
- * This class is immutable.
- *
- * @author jvenstad
- */
- class Issue {
-
- private final String summary;
- private final String description;
- private final Classification classification;
-
- public Issue(String summary, String description, Classification classification) {
- if (summary.isEmpty()) throw new IllegalArgumentException("Summary can not be empty.");
- if (description.isEmpty()) throw new IllegalArgumentException("Description can not be empty.");
-
- this.summary = summary;
- this.description = description;
- this.classification = classification;
- }
-
- public Issue(String summary, String description) {
- this(summary, description, null);
- }
-
- public Issue with(Classification classification) {
- return new Issue(summary, description, classification);
- }
- public Issue withDescription(String description) { return new Issue(summary, description, classification); }
-
- /** Return a new @Issue with the description of @this, but with @appendage appended. */
- public Issue append(String appendage) {
- return new Issue(summary, description + "\n\n" + appendage, classification);
- }
-
- public String summary() { return summary; }
- public String description() { return description; }
- public Optional<Classification> classification() { return Optional.ofNullable(classification); }
-
- }
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Properties.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Properties.java
deleted file mode 100644
index 652b5495bc5..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Properties.java
+++ /dev/null
@@ -1,16 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration;
-
-import java.util.Optional;
-
-/**
- * @author jvenstad
- */
-public interface Properties {
-
- /**
- * Return the @Issues.Classification listed for the property with id @propertyId.
- */
- Optional<Issues.Classification> classificationFor(long propertyId);
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/jira/JiraMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/jira/JiraMock.java
deleted file mode 100644
index da653ddd8a8..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/jira/JiraMock.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration.jira;
-
-import java.time.Instant;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-/**
- * @author jvenstad
- */
-// TODO: Make mock.
-public class JiraMock implements Jira {
-
- public final Map<String, JiraCreateIssue.JiraFields> issues = new HashMap<>();
-
- private Long counter = 0L;
-
- @Override
- public List<JiraIssue> searchByProjectAndSummary(String project, String summary) {
- return issues.entrySet().stream()
- .filter(entry -> entry.getValue().project.key.equals(project))
- .filter(entry -> entry.getValue().summary.contains(summary))
- .map(entry -> new JiraIssue(entry.getKey(), new JiraIssue.Fields(Instant.now())))
- .collect(Collectors.toList());
- }
-
- @Override
- public JiraIssue createIssue(JiraCreateIssue issueData) {
- JiraIssue issue = uniqueKey();
- issues.put(issue.key, issueData.fields);
- return issue;
- }
-
- @Override
- public void commentIssue(JiraIssue issue, JiraComment comment) {
- // Add mock when relevant.
- }
-
- @Override
- public void addAttachment(JiraIssue issue, String filename, String fileContent) {
- // Add mock when relevant.
- }
-
- private JiraIssue uniqueKey() {
- return new JiraIssue((++counter).toString(), new JiraIssue.Fields(Instant.now()));
- }
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentIssues.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentIssues.java
new file mode 100644
index 00000000000..7874fcd8c45
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentIssues.java
@@ -0,0 +1,26 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.api.integration.organization;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Optional;
+
+/**
+ * Represents the people responsible for keeping Vespa up and running in a given organization, etc..
+ *
+ * @author jvenstad
+ */
+public interface DeploymentIssues {
+
+ IssueId fileUnlessOpen(Optional<IssueId> issueId, ApplicationId applicationId, PropertyId propertyId);
+
+ IssueId fileUnlessOpen(Optional<IssueId> issueId, ApplicationId applicationId, User assignee);
+
+ IssueId fileUnlessOpen(Collection<ApplicationId> applicationIds);
+
+ void escalateIfInactive(IssueId issueId, Optional<PropertyId> propertyId, Duration maxInactivity);
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Issue.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Issue.java
new file mode 100644
index 00000000000..df182b56fd8
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Issue.java
@@ -0,0 +1,75 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.api.integration.organization;
+
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Represents an issue which needs to reported, typically from the controller, to a responsible party,
+ * the identity of which is determined by the propertyId and, possibly, assignee fields.
+ *
+ * @author jvenstad
+ */
+public class Issue {
+
+ private final String summary;
+ private final String description;
+ private final String label;
+ private final User assignee;
+ private final PropertyId propertyId;
+
+ private Issue(String summary, String description, String label, User assignee, PropertyId propertyId) {
+ if (summary.isEmpty()) throw new IllegalArgumentException("Issue summary can not be empty!");
+ if (description.isEmpty()) throw new IllegalArgumentException("Issue description can not be empty!");
+ Objects.requireNonNull(propertyId, "An issue must belong to a property!");
+
+ this.summary = summary;
+ this.description = description;
+ this.label = label;
+ this.assignee = assignee;
+ this.propertyId = propertyId;
+ }
+
+ public Issue(String summary, String description, PropertyId propertyId) {
+ this(summary, description, null, null, propertyId);
+ }
+
+ public Issue append(String appendage) {
+ return new Issue(summary, description + appendage, label, assignee, propertyId);
+ }
+
+ public Issue withLabel(String label) {
+ return new Issue(summary, description, label, assignee, propertyId);
+ }
+
+ public Issue withAssignee(User assignee) {
+ return new Issue(summary, description, label, assignee, propertyId);
+ }
+
+ public Issue withPropertyId(PropertyId propertyId) {
+ return new Issue(summary, description, label, assignee, propertyId);
+ }
+
+ public String summary() {
+ return summary;
+ }
+
+ public String description() {
+ return description;
+ }
+
+ public Optional<String> label() {
+ return Optional.ofNullable(label);
+ }
+
+ public Optional<User> assignee() {
+ return Optional.ofNullable(assignee);
+ }
+
+ public PropertyId propertyId() {
+ return propertyId;
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/IssueId.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/IssueId.java
new file mode 100644
index 00000000000..84b441ff4a8
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/IssueId.java
@@ -0,0 +1,49 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.api.integration.organization;
+
+import java.util.Objects;
+
+/**
+ * Used to identify issues stored in some issue tracking system.
+ * The {@code value()} and {@code from()} methods should be inverses.
+ *
+ * @author jvenstad
+ */
+public class IssueId {
+
+ protected final String id;
+
+ protected IssueId(String id) {
+ this.id = id;
+ }
+
+ public static IssueId from(String value) {
+ if (value.isEmpty())
+ throw new IllegalArgumentException("Can not make an IssueId from an empty value.");
+
+ return new IssueId(value);
+ }
+
+ public String value() {
+ return id;
+ }
+
+ @Override
+ public String toString() {
+ return value();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ IssueId issueId = (IssueId) o;
+ return Objects.equals(id, issueId.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id);
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/MockOrganization.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/MockOrganization.java
new file mode 100644
index 00000000000..4eaca8fb642
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/MockOrganization.java
@@ -0,0 +1,154 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.api.integration.organization;
+
+import com.google.inject.Inject;
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+
+import java.net.URI;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class MockOrganization implements Organization {
+
+ private final Clock clock;
+ private final AtomicLong counter;
+ private final HashMap<IssueId, WrappedIssue> issues;
+ private final HashMap<PropertyId, PropertyInfo> properties;
+
+ @Inject
+ @SuppressWarnings("unused")
+ public MockOrganization() {
+ this(Clock.systemUTC());
+ }
+
+ public MockOrganization(Clock clock) {
+ this.clock = clock;
+
+ counter = new AtomicLong();
+ issues = new HashMap<>();
+ properties = new HashMap<>();
+ }
+
+ @Override
+ public IssueId file(Issue issue) {
+ IssueId issueId = IssueId.from("" + counter.incrementAndGet());
+ issues.put(issueId, new WrappedIssue(issue));
+ return issueId;
+ }
+
+ @Override
+ public Optional<IssueId> findBySimilarity(Issue issue) {
+ return issues.entrySet().stream()
+ .filter(entry -> entry.getValue().issue.summary().equals(issue.summary()))
+ .findFirst()
+ .map(Map.Entry::getKey);
+ }
+
+ @Override
+ public void update(IssueId issueId, String description) {
+ touch(issueId);
+ }
+
+ @Override
+ public void commentOn(IssueId issueId, String comment) {
+ touch(issueId);
+ }
+
+ @Override
+ public boolean isOpen(IssueId issueId) {
+ return issues.get(issueId).open;
+ }
+
+ @Override
+ public boolean isActive(IssueId issueId, Duration maxInactivity) {
+ return issues.get(issueId).updated.isAfter(clock.instant().minus(maxInactivity));
+ }
+
+ @Override
+ public Optional<User> assigneeOf(IssueId issueId) {
+ return Optional.ofNullable(issues.get(issueId).assignee);
+ }
+
+ @Override
+ public boolean reassign(IssueId issueId, User assignee) {
+ issues.get(issueId).assignee = assignee;
+ touch(issueId);
+ return true;
+ }
+
+ @Override
+ public List<? extends List<? extends User>> contactsFor(PropertyId propertyId) {
+ return properties.get(propertyId).contacts;
+ }
+
+ @Override
+ public URI issueCreationUri(PropertyId propertyId) {
+ return URI.create("www.issues.tld/" + propertyId.id());
+ }
+
+ @Override
+ public URI contactsUri(PropertyId propertyId) {
+ return URI.create("www.contacts.tld/" + propertyId.id());
+ }
+
+ @Override
+ public URI propertyUri(PropertyId propertyId) {
+ return URI.create("www.properties.tld/" + propertyId.id());
+ }
+
+ public void close(IssueId issueId) {
+ issues.get(issueId).open = false;
+ touch(issueId);
+ }
+
+ public void setDefaultAssigneeFor(PropertyId propertyId, User defaultAssignee) {
+ properties.get(propertyId).defaultAssignee = defaultAssignee;
+ }
+
+ public void setContactsFor(PropertyId propertyId, List<List<User>> contacts) {
+ properties.get(propertyId).contacts = contacts;
+ }
+
+ public void addProperty(PropertyId propertyId) {
+ properties.put(propertyId, new PropertyInfo());
+ }
+
+ private void touch(IssueId issueId) {
+ issues.get(issueId).updated = clock.instant();
+ }
+
+
+ private class WrappedIssue {
+
+ private Issue issue;
+ private Instant updated;
+ private boolean open;
+ private User assignee;
+
+ private WrappedIssue(Issue issue) {
+ this.issue = issue;
+
+ updated = clock.instant();
+ open = true;
+ assignee = issue.assignee().orElse(properties.get(issue.propertyId()).defaultAssignee);
+ }
+
+ }
+
+
+ private class PropertyInfo {
+
+ private User defaultAssignee;
+ private List<List<User>> contacts = Collections.emptyList();
+
+ }
+
+}
+
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Organization.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Organization.java
new file mode 100644
index 00000000000..00c0d87554a
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Organization.java
@@ -0,0 +1,124 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.api.integration.organization;
+
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Represents the humans who use this software, and their organization.
+ * Lets the software report issues to its caretakers, and provides other useful human resource lookups.
+ *
+ * @author jvenstad
+ */
+public interface Organization {
+
+ /**
+ * File an issue with its given property or the default, and with the specific assignee, if present.
+ *
+ * @param issue The issue to file.
+ * @return ID of the created issue.
+ */
+ IssueId file(Issue issue);
+
+ /**
+ * Returns the ID of this issue, if it exists and is open, based on a similarity search.
+ *
+ * @param issue The issue to search for; relevant fields are the summary and the owner (propertyId).
+ * @return ID of the issue, if it is found.
+ */
+ Optional<IssueId> findBySimilarity(Issue issue);
+
+ /**
+ * Update the description of the issue with the given ID.
+ *
+ * @param issueId ID of the issue to comment on.
+ * @param description The updated description.
+ */
+ void update(IssueId issueId, String description);
+
+ /**
+ * Add a comment to the issue with the given ID.
+ *
+ * @param issueId ID of the issue to comment on.
+ * @param comment The comment to add.
+ */
+ void commentOn(IssueId issueId, String comment);
+
+ /**
+ * Returns whether the issue is still under investigation.
+ *
+ * @param issueId ID of the issue to examine.
+ * @return Whether the given issue is under investigation.
+ */
+ boolean isOpen(IssueId issueId);
+
+ /**
+ * Returns whether there has been significant activity on the issue within the given duration.
+ *
+ * @param issueId ID of the issue to examine.
+ * @return Whether the given issue is actively worked on.
+ */
+ boolean isActive(IssueId issueId, Duration maxInactivity);
+
+ /**
+ * Returns the user assigned to the given issue, if any.
+ *
+ * @param issueId ID of the issue for which to find the assignee.
+ * @return The user responsible for fixing the given issue, if found.
+ */
+ Optional<User> assigneeOf(IssueId issueId);
+
+ /**
+ * Reassign the issue with the given ID to the given user, and returns the outcome of this.
+ *
+ * @param issueId ID of the issue to be reassigned.
+ * @param assignee User to which the issue shall be assigned.
+ * @return Whether the reassignment was successful.
+ */
+ boolean reassign(IssueId issueId, User assignee);
+
+ /**
+ * Escalate an issue filed with the given property.
+ *
+ * @param issueId ID of the issue to escalate.
+ * @param propertyId PropertyId of the tenant owning the application for which the issue was filed.
+ */
+ default boolean escalate(IssueId issueId, PropertyId propertyId) {
+ List<? extends List<? extends User>> contacts = contactsFor(propertyId);
+
+ Optional<User> assignee = assigneeOf(issueId);
+ int assigneeLevel = -1;
+ if (assignee.isPresent())
+ for (int level = contacts.size(); --level > assigneeLevel; )
+ if (contacts.get(level).contains(assignee.get()))
+ assigneeLevel = level;
+
+ for (int level = assigneeLevel + 1; level < contacts.size(); level++)
+ for (User target : contacts.get(level))
+ if (reassign(issueId, target))
+ return true;
+
+ return false;
+ }
+
+ /**
+ * Returns a nested list where the entries have increasing rank, and where each entry is
+ * a list of the users of that rank, by decreasing relevance.
+ *
+ * @param propertyId ID of the property for which to list contacts.
+ * @return A sorted, nested, reverse sorted list of contacts.
+ */
+ List<? extends List<? extends User>> contactsFor(PropertyId propertyId);
+
+ URI issueCreationUri(PropertyId propertyId);
+
+ URI contactsUri(PropertyId propertyId);
+
+ URI propertyUri(PropertyId propertyId);
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/User.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/User.java
new file mode 100644
index 00000000000..82a86de3824
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/User.java
@@ -0,0 +1,52 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.api.integration.organization;
+
+import java.util.Objects;
+
+/**
+ * Represents a human computer user, typically by UNIX account name.
+ *
+ * @author jvenstad
+ */
+public class User {
+
+ private final String username;
+
+ protected User(String username) {
+ this.username = username;
+ }
+
+ public String username() {
+ return username;
+ }
+
+ public String displayName() {
+ return username;
+ }
+
+ public static User from(String username) {
+ if (username.isEmpty())
+ throw new IllegalArgumentException("Username may not be empty!");
+
+ return new User(username);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if ( ! (o instanceof User)) return false;
+ User that = (User) o;
+ return Objects.equals(username, that.username);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(username);
+ }
+
+ @Override
+ public String toString() {
+ return username();
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/ContactsMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/ContactsMock.java
deleted file mode 100644
index 9114cf20ccc..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/ContactsMock.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration.stubs;
-
-import com.yahoo.vespa.hosted.controller.api.integration.Contacts;
-
-import java.net.URI;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-/**
- * @author mpolden
- */
-public class ContactsMock implements Contacts {
-
- private final Map<Long, List<UserContact>> userContacts = new HashMap<>();
-
- public void addContact(long propertyId, List<UserContact> contacts) {
- userContacts.put(propertyId, contacts);
- }
-
- public List<UserContact> userContactsFor(long propertyId) {
- return userContacts.get(propertyId);
- }
-
- @Override
- public URI contactsUri(long propertyId) {
- return URI.create("http://contacts.test?propertyId=" + propertyId);
- }
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingDeploymentIssues.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingDeploymentIssues.java
new file mode 100644
index 00000000000..62dde3efe55
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingDeploymentIssues.java
@@ -0,0 +1,96 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package com.yahoo.vespa.hosted.controller.api.integration.stubs;
+
+import com.google.inject.Inject;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.DeploymentIssues;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.IssueId;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.User;
+import org.jetbrains.annotations.TestOnly;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.logging.Logger;
+
+/**
+ * A memory backed implementation of the Issues API which logs changes and does nothing else.
+ *
+ * @author bratseth, jvenstad
+ */
+public class LoggingDeploymentIssues implements DeploymentIssues {
+
+ private static final Logger log = Logger.getLogger(LoggingDeploymentIssues.class.getName());
+
+ /** Whether the platform is currently broken. */
+ protected final AtomicBoolean platformIssue = new AtomicBoolean(false);
+ /** Last updates for each issue -- used to determine if issues are already logged and when to escalate. */
+ protected final Map<IssueId, Instant> issueUpdates = new HashMap<>();
+
+ /** Used to fabricate unique issue ids. */
+ private final AtomicLong issueIdSequence = new AtomicLong(0);
+
+ private final Clock clock;
+
+ @SuppressWarnings("unused") // Created by dependency injection.
+ @Inject
+ public LoggingDeploymentIssues() {
+ this(Clock.systemUTC());
+ }
+
+ @TestOnly
+ protected LoggingDeploymentIssues(Clock clock) {
+ this.clock = clock;
+ }
+
+ @Override
+ public IssueId fileUnlessOpen(Optional<IssueId> issueId, ApplicationId applicationId, PropertyId propertyId) {
+ return fileUnlessPresent(issueId, applicationId);
+ }
+
+ @Override
+ public IssueId fileUnlessOpen(Optional<IssueId> issueId, ApplicationId applicationId, User assignee) {
+ return fileUnlessPresent(issueId, applicationId);
+ }
+
+ @Override
+ public IssueId fileUnlessOpen(Collection<ApplicationId> applicationIds) {
+ if ( ! platformIssue.get())
+ log.info("These applications are all failing deployment:\n" + applicationIds);
+
+ platformIssue.set(true);
+ return null;
+ }
+
+ @Override
+ public void escalateIfInactive(IssueId issueId, Optional<PropertyId> propertyId, Duration maxInactivity) {
+ if (issueUpdates.containsKey(issueId) && issueUpdates.get(issueId).isBefore(clock.instant().minus(maxInactivity)))
+ escalateIssue(issueId);
+ }
+
+ protected void escalateIssue(IssueId issueId) {
+ issueUpdates.put(issueId, clock.instant());
+ log.info("Deployment issue " + issueId + " should be escalated.");
+ }
+
+ protected IssueId fileIssue(ApplicationId applicationId) {
+ IssueId issueId = IssueId.from("" + issueIdSequence.incrementAndGet());
+ issueUpdates.put(issueId, clock.instant());
+ log.info("Deployment issue " + issueId +": " + applicationId + " has failing deployments.");
+ return issueId;
+ }
+
+ private IssueId fileUnlessPresent(Optional<IssueId> issueId, ApplicationId applicationId) {
+ platformIssue.set(false);
+ return issueId.filter(issueUpdates::containsKey).orElseGet(() -> fileIssue(applicationId));
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingIssues.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingIssues.java
deleted file mode 100644
index 4801f551307..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingIssues.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration.stubs;
-
-import com.yahoo.vespa.hosted.controller.api.integration.Issues;
-
-import java.time.Instant;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.logging.Logger;
-
-/**
- * An memory backed implementation of the Issues API which logs changes and does nothing else.
- *
- * @author bratseth
- */
-@SuppressWarnings("unused") // created by dependency injection
-public class LoggingIssues implements Issues {
-
- private static final Logger log = Logger.getLogger(LoggingIssues.class.getName());
-
- /** Used to fabricate unique issue ids */
- private AtomicLong issueIdSequence = new AtomicLong(0);
-
- // These two maps should have precisely the same keys
- private final Map<String, Issue> issues = new HashMap<>();
- private final Map<String, IssueInfo> issueInfos = new HashMap<>();
-
- @Override
- public IssueInfo fetch(String issueId) {
- return issueInfos.getOrDefault(issueId,
- new IssueInfo(issueId, null, Instant.ofEpochMilli(0), null, IssueInfo.Status.noCategory));
- }
-
- @Override
- public List<IssueInfo> fetchSimilarTo(Issue issue) {
- return Collections.emptyList();
- }
-
- @Override
- public String file(Issue issue) {
- log.info("Want to file " + issue);
- String issueId = "issue-" + issueIdSequence.getAndIncrement();
- file(issueId, issue);
- return issueId;
- }
-
- private IssueInfo file(String issueId, Issue issue) {
- IssueInfo issueInfo = new IssueInfo(issueId, null, Instant.now(), null, IssueInfo.Status.noCategory);
- issues.put(issueId, issue);
- issueInfos.put(issueId, issueInfo);
- return issueInfo;
- }
-
- @Override
- public void update(String issueId, String description) {
- log.info("Want to update " + issueId);
- issues.put(issueId, requireIssue(issueId).withDescription(description));
- }
-
- @Override
- public void reassign(String issueId, String assignee) {
- log.info("Want to reassign issue " + issueId + " to " + assignee);
- issueInfos.put(issueId, requireInfo(issueId).withAssignee(Optional.of(assignee)));
- }
-
- @Override
- public void addWatcher(String issueId, String watcher) {
- log.info("Want to add watcher " + watcher + " to issue " + issueId);
- }
-
- @Override
- public void comment(String issueId, String comment) {
- log.info("Want to comment on issue " + issueId);
- }
-
- private Issue requireIssue(String issueId) {
- Issue issue = issues.get(issueId);
- if (issue == null)
- throw new IllegalArgumentException("No issue with id '" + issueId + "'");
- return issue;
- }
-
- private IssueInfo requireInfo(String issueId) {
- IssueInfo info = issueInfos.get(issueId);
- if (info != null) // we still remember this issue
- return info;
- else // we forgot this issue (due to restart) - recreate it here to avoid log noise
- return file(issueId, new Issue("(Forgotten)", "(Forgotten)"));
- }
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/PropertiesMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/PropertiesMock.java
deleted file mode 100644
index 53a31933e03..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/PropertiesMock.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration.stubs;
-
-import com.yahoo.vespa.hosted.controller.api.integration.Issues;
-import com.yahoo.vespa.hosted.controller.api.integration.Properties;
-
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Optional;
-
-/**
- * @author mpolden
- */
-public class PropertiesMock implements Properties {
-
- private final Map<Long, Issues.Classification> projects = new HashMap<>();
-
- public void addClassification(long propertyId, String classification) {
- projects.put(propertyId, new Issues.Classification(classification));
- }
-
- public Optional<Issues.Classification> classificationFor(long propertyId) {
- return Optional.ofNullable(projects.get(propertyId));
- }
-
-}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Application.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Application.java
index 2066b98aeb9..6f5116a1825 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Application.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Application.java
@@ -8,6 +8,7 @@ import com.yahoo.config.application.api.ValidationOverrides;
import com.yahoo.config.provision.ApplicationId;
import com.yahoo.config.provision.Environment;
import com.yahoo.config.provision.Zone;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.IssueId;
import com.yahoo.vespa.hosted.controller.application.ApplicationRevision;
import com.yahoo.vespa.hosted.controller.application.Change;
import com.yahoo.vespa.hosted.controller.application.Deployment;
@@ -140,8 +141,8 @@ public class Application {
return new Application(id, deploymentSpec, validationOverrides, deployments, deploymentJobs.withProjectId(projectId), deploying, outstandingChange);
}
- public Application withJiraIssueId(Optional<String> jiraIssueId) {
- return new Application(id, deploymentSpec, validationOverrides, deployments, deploymentJobs.withJiraIssueId(jiraIssueId), deploying, outstandingChange);
+ public Application with(IssueId issueId) {
+ return new Application(id, deploymentSpec, validationOverrides, deployments, deploymentJobs.with(issueId), deploying, outstandingChange);
}
public Application withJobCompletion(JobReport report, Instant notificationTime, Controller controller) {
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 1bb78078cd5..1ff6802f0ab 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
@@ -22,6 +22,7 @@ import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
import com.yahoo.vespa.hosted.controller.api.identifiers.Hostname;
import com.yahoo.vespa.hosted.controller.api.identifiers.RevisionId;
import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.IssueId;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServerClient;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.Log;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.NoInstanceException;
@@ -499,9 +500,9 @@ public class ApplicationController {
}
}
- public void setJiraIssueId(ApplicationId id, Optional<String> jiraIssueId) {
+ public void setIssueId(ApplicationId id, IssueId issueId) {
try (Lock lock = lock(id)) {
- get(id).ifPresent(application -> store(application.withJiraIssueId(jiraIssueId), lock));
+ get(id).ifPresent(application -> store(application.with(issueId), lock));
}
}
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 3b87af5c95a..660013daa62 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
@@ -20,7 +20,7 @@ import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServ
import com.yahoo.vespa.hosted.controller.api.integration.dns.NameService;
import com.yahoo.vespa.hosted.controller.api.integration.entity.EntityService;
import com.yahoo.vespa.hosted.controller.api.integration.github.GitHub;
-import com.yahoo.vespa.hosted.controller.api.integration.jira.Jira;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.Organization;
import com.yahoo.vespa.hosted.controller.api.integration.routing.GlobalRoutingService;
import com.yahoo.vespa.hosted.controller.api.integration.routing.RotationStatus;
import com.yahoo.vespa.hosted.controller.api.integration.routing.RoutingGenerator;
@@ -72,6 +72,7 @@ public class Controller extends AbstractComponent {
private final ConfigServerClient configServerClient;
private final MetricsService metricsService;
private final Chef chefClient;
+ private final Organization organization;
private final AthenzClientFactory athenzClientFactory;
/**
@@ -82,19 +83,19 @@ public class Controller extends AbstractComponent {
*/
@Inject
public Controller(ControllerDb db, CuratorDb curator, RotationRepository rotationRepository,
- GitHub gitHub, Jira jiraClient, EntityService entityService,
+ GitHub gitHub, EntityService entityService, Organization organization,
GlobalRoutingService globalRoutingService,
ZoneRegistry zoneRegistry, ConfigServerClient configServerClient,
MetricsService metricsService, NameService nameService,
RoutingGenerator routingGenerator, Chef chefClient, AthenzClientFactory athenzClientFactory) {
this(db, curator, rotationRepository,
- gitHub, jiraClient, entityService, globalRoutingService, zoneRegistry,
+ gitHub, entityService, organization, globalRoutingService, zoneRegistry,
configServerClient, metricsService, nameService, routingGenerator, chefClient,
Clock.systemUTC(), athenzClientFactory);
}
public Controller(ControllerDb db, CuratorDb curator, RotationRepository rotationRepository,
- GitHub gitHub, Jira jiraClient, EntityService entityService,
+ GitHub gitHub, EntityService entityService, Organization organization,
GlobalRoutingService globalRoutingService,
ZoneRegistry zoneRegistry, ConfigServerClient configServerClient,
MetricsService metricsService, NameService nameService,
@@ -104,8 +105,8 @@ public class Controller extends AbstractComponent {
Objects.requireNonNull(curator, "Curator cannot be null");
Objects.requireNonNull(rotationRepository, "Rotation repository cannot be null");
Objects.requireNonNull(gitHub, "GitHubClient cannot be null");
- Objects.requireNonNull(jiraClient, "JiraClient cannot be null");
Objects.requireNonNull(entityService, "EntityService cannot be null");
+ Objects.requireNonNull(organization, "Organization cannot be null");
Objects.requireNonNull(globalRoutingService, "GlobalRoutingService cannot be null");
Objects.requireNonNull(zoneRegistry, "ZoneRegistry cannot be null");
Objects.requireNonNull(configServerClient, "ConfigServerClient cannot be null");
@@ -120,6 +121,7 @@ public class Controller extends AbstractComponent {
this.curator = curator;
this.gitHub = gitHub;
this.entityService = entityService;
+ this.organization = organization;
this.globalRoutingService = globalRoutingService;
this.zoneRegistry = zoneRegistry;
this.configServerClient = configServerClient;
@@ -240,7 +242,13 @@ public class Controller extends AbstractComponent {
return chefClient;
}
- public CuratorDb curator() { return curator; }
+ public Organization organization() {
+ return organization;
+ }
+
+ public CuratorDb curator() {
+ return curator;
+ }
private String printableVersion(Optional<VespaVersion> vespaVersion) {
return vespaVersion.map(v -> v.versionNumber().toFullString()).orElse("Unknown");
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/Tenant.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/Tenant.java
index 4889f789819..9b8643c7167 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/Tenant.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/Tenant.java
@@ -29,24 +29,25 @@ public class Tenant {
public Tenant(TenantId id, Optional<UserGroup> userGroup, Optional<Property> property, Optional<AthenzDomain> athenzDomain, Optional<PropertyId> propertyId) {
if (id.isUser()) {
- require(!userGroup.isPresent(), "User tenant '%s' cannot have a user group.", id);
- require(!property.isPresent(), "User tenant '%s' cannot have a property.", id);
- require(!propertyId.isPresent(), "User tenant '%s' cannot have a property ID.", id);
- require(!athenzDomain.isPresent(), "User tenant '%s' cannot have an athens domain.", id);
+ require( ! userGroup.isPresent(), "User tenant '%s' cannot have a user group.", id);
+ require( ! property.isPresent(), "User tenant '%s' cannot have a property.", id);
+ require( ! propertyId.isPresent(), "User tenant '%s' cannot have a property ID.", id);
+ require( ! athenzDomain.isPresent(), "User tenant '%s' cannot have an athens domain.", id);
} else if (athenzDomain.isPresent()) {
- require(property.isPresent(), "Athens tenant '%s' must have a property.", id);
- require(!userGroup.isPresent(), "Athens tenant '%s' cannot have a user group.", id);
- require(athenzDomain.isPresent(), "Athens tenant '%s' must have an athens domain.", id);
+ require( property.isPresent(), "Athens tenant '%s' must have a property.", id);
+ require( ! userGroup.isPresent(), "Athens tenant '%s' cannot have a user group.", id);
+ require( athenzDomain.isPresent(), "Athens tenant '%s' must have an athens domain.", id);
} else {
- require(property.isPresent(), "OpsDB tenant '%s' must have a property.", id);
- require(userGroup.isPresent(), "OpsDb tenant '%s' must have a user group.", id);
- require(!athenzDomain.isPresent(), "OpsDb tenant '%s' cannot have an athens domain.", id);
+ require( property.isPresent(), "OpsDB tenant '%s' must have a property.", id);
+ require( userGroup.isPresent(), "OpsDb tenant '%s' must have a user group.", id);
+ require( ! athenzDomain.isPresent(), "OpsDb tenant '%s' cannot have an athens domain.", id);
}
this.id = id;
this.userGroup = userGroup;
this.property = property;
this.athenzDomain = athenzDomain;
this.propertyId = propertyId; // TODO: Check validity after TODO@14. OpsDb tenants have this set in Sherpa, while athens tenants do not.
+ // TODO: Require PropertyId for non-users, and fetch Property from EntityService (which will be moved to Organization) in the controller.
}
public boolean isAthensTenant() { return athenzDomain.isPresent(); }
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentJobs.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentJobs.java
index 1ffa06bb624..68e0ce39c5c 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentJobs.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentJobs.java
@@ -9,6 +9,7 @@ import com.yahoo.config.provision.RegionName;
import com.yahoo.config.provision.SystemName;
import com.yahoo.config.provision.Zone;
import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.IssueId;
import java.time.Instant;
import java.util.Collection;
@@ -30,20 +31,20 @@ public class DeploymentJobs {
private final Optional<Long> projectId;
private final ImmutableMap<JobType, JobStatus> status;
- private final Optional<String> jiraIssueId;
+ private final Optional<IssueId> issueId;
public DeploymentJobs(Optional<Long> projectId, Collection<JobStatus> jobStatusEntries,
- Optional<String> jiraIssueId) {
- this(projectId, asMap(jobStatusEntries), jiraIssueId);
+ Optional<IssueId> issueId) {
+ this(projectId, asMap(jobStatusEntries), issueId);
}
- private DeploymentJobs(Optional<Long> projectId, Map<JobType, JobStatus> status, Optional<String> jiraIssueId) {
- requireId(projectId, "projectId cannot be null or <= 0");
+ private DeploymentJobs(Optional<Long> projectId, Map<JobType, JobStatus> status, Optional<IssueId> issueId) {
+ requireId(projectId, "projectId must be a positive integer");
Objects.requireNonNull(status, "status cannot be null");
- Objects.requireNonNull(jiraIssueId, "jiraIssueId cannot be null");
+ Objects.requireNonNull(issueId, "issueId cannot be null");
this.projectId = projectId;
this.status = ImmutableMap.copyOf(status);
- this.jiraIssueId = jiraIssueId;
+ this.issueId = issueId;
}
private static Map<JobType, JobStatus> asMap(Collection<JobStatus> jobStatusEntries) {
@@ -60,7 +61,7 @@ public class DeploymentJobs {
if (job == null) job = JobStatus.initial(report.jobType());
return job.withCompletion(report.jobError(), notificationTime, controller);
});
- return new DeploymentJobs(Optional.of(report.projectId()), status, jiraIssueId);
+ return new DeploymentJobs(Optional.of(report.projectId()), status, issueId);
}
public DeploymentJobs withTriggering(JobType jobType,
@@ -75,21 +76,21 @@ public class DeploymentJobs {
change.isPresent() && change.get() instanceof Change.VersionChange,
triggerTime);
});
- return new DeploymentJobs(projectId, status, jiraIssueId);
+ return new DeploymentJobs(projectId, status, issueId);
}
public DeploymentJobs withProjectId(long projectId) {
- return new DeploymentJobs(Optional.of(projectId), status, jiraIssueId);
+ return new DeploymentJobs(Optional.of(projectId), status, issueId);
}
- public DeploymentJobs withJiraIssueId(Optional<String> jiraIssueId) {
- return new DeploymentJobs(projectId, status, jiraIssueId);
+ public DeploymentJobs with(IssueId issueId) {
+ return new DeploymentJobs(projectId, status, Optional.ofNullable(issueId));
}
public DeploymentJobs without(JobType job) {
Map<JobType, JobStatus> status = new HashMap<>(this.status);
status.remove(job);
- return new DeploymentJobs(projectId, status, jiraIssueId);
+ return new DeploymentJobs(projectId, status, issueId);
}
/** Returns an immutable map of the status entries in this */
@@ -158,7 +159,7 @@ public class DeploymentJobs {
*/
public Optional<Long> projectId() { return projectId; }
- public Optional<String> jiraIssueId() { return jiraIssueId; }
+ public Optional<IssueId> issueId() { return issueId; }
/** Job types that exist in the build system */
public enum JobType {
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java
index fd2e7496ec0..2fdce2802ab 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java
@@ -4,9 +4,7 @@ package com.yahoo.vespa.hosted.controller.maintenance;
import com.yahoo.component.AbstractComponent;
import com.yahoo.jdisc.Metric;
import com.yahoo.vespa.hosted.controller.Controller;
-import com.yahoo.vespa.hosted.controller.api.integration.Contacts;
-import com.yahoo.vespa.hosted.controller.api.integration.Issues;
-import com.yahoo.vespa.hosted.controller.api.integration.Properties;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.DeploymentIssues;
import com.yahoo.vespa.hosted.controller.api.integration.chef.Chef;
import com.yahoo.vespa.hosted.controller.maintenance.config.MaintainerConfig;
import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
@@ -40,12 +38,11 @@ public class ControllerMaintenance extends AbstractComponent {
@SuppressWarnings("unused") // instantiated by Dependency Injection
public ControllerMaintenance(MaintainerConfig maintainerConfig, Controller controller, CuratorDb curator,
JobControl jobControl, Metric metric, Chef chefClient,
- Contacts contactsClient, Properties propertiesClient, Issues issuesClient) {
+ DeploymentIssues deploymentIssues) {
Duration maintenanceInterval = Duration.ofMinutes(maintainerConfig.intervalMinutes());
this.jobControl = jobControl;
deploymentExpirer = new DeploymentExpirer(controller, maintenanceInterval, jobControl);
- deploymentIssueReporter = new DeploymentIssueReporter(controller, contactsClient, propertiesClient,
- issuesClient, maintenanceInterval, jobControl);
+ deploymentIssueReporter = new DeploymentIssueReporter(controller, deploymentIssues, maintenanceInterval, jobControl);
metricsReporter = new MetricsReporter(controller, metric, chefClient, jobControl, controller.system());
failureRedeployer = new FailureRedeployer(controller, maintenanceInterval, jobControl);
outstandingChangeDeployer = new OutstandingChangeDeployer(controller, maintenanceInterval, jobControl);
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentIssueReporter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentIssueReporter.java
index b2f010eeb79..4ef92513393 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentIssueReporter.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentIssueReporter.java
@@ -8,52 +8,35 @@ import com.yahoo.vespa.hosted.controller.api.Tenant;
import com.yahoo.vespa.hosted.controller.api.application.v4.model.TenantType;
import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
-import com.yahoo.vespa.hosted.controller.api.integration.Contacts;
-import com.yahoo.vespa.hosted.controller.api.integration.Contacts.UserContact;
-import com.yahoo.vespa.hosted.controller.api.integration.Issues;
-import com.yahoo.vespa.hosted.controller.api.integration.Issues.Classification;
-import com.yahoo.vespa.hosted.controller.api.integration.Issues.Issue;
-import com.yahoo.vespa.hosted.controller.api.integration.Issues.IssueInfo;
-import com.yahoo.vespa.hosted.controller.api.integration.Properties;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.DeploymentIssues;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.IssueId;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.User;
import com.yahoo.vespa.hosted.controller.application.DeploymentJobs;
import java.time.Duration;
-import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;
-import java.util.regex.Pattern;
-import java.util.stream.Collectors;
-
-import static com.yahoo.vespa.hosted.controller.api.integration.Contacts.Category.admin;
-import static com.yahoo.vespa.hosted.controller.api.integration.Issues.IssueInfo.Status.done;
+import java.util.logging.Level;
/**
- * Maintenance job which creates Jira issues for tenants when they have jobs which fails continuously
- * and escalates issues which are not handled.
+ * Maintenance job which files issues for tenants when they have jobs which fails continuously
+ * and escalates issues which are not handled in a timely manner.
*
* @author jvenstad
*/
public class DeploymentIssueReporter extends Maintainer {
static final Duration maxFailureAge = Duration.ofDays(2);
- static final Duration maxInactivityAge = Duration.ofDays(4);
- static final String deploymentFailureLabel = "vespaDeploymentFailure";
- static final Classification vespaOps = new Classification("VESPA", "Services", deploymentFailureLabel, null);
- static final UserContact terminalUser = new UserContact("frodelu", "Frode Lundgren", admin);
+ static final Duration maxInactivity = Duration.ofDays(4);
- private final Contacts contacts;
- private final Properties properties;
- private final Issues issues;
+ private final DeploymentIssues deploymentIssues;
- DeploymentIssueReporter(Controller controller, Contacts contacts, Properties properties, Issues issues,
- Duration maintenanceInterval, JobControl jobControl) {
+ DeploymentIssueReporter(Controller controller, DeploymentIssues deploymentIssues, Duration maintenanceInterval, JobControl jobControl) {
super(controller, maintenanceInterval, jobControl);
- this.contacts = contacts;
- this.properties = properties;
- this.issues = issues;
+ this.deploymentIssues = deploymentIssues;
}
@Override
@@ -63,182 +46,71 @@ public class DeploymentIssueReporter extends Maintainer {
}
/**
- * File issues for applications which have failed deployment for longer than @maxFailureAge
- * and store the issue id for the filed issues. Also, clear the @issueIds of applications
+ * File issues for applications which have failed deployment for longer than maxFailureAge
+ * and store the issue id for the filed issues. Also, clear the issueIds of applications
* where deployment has not failed for this amount of time.
*/
private void maintainDeploymentIssues(List<Application> applications) {
- Collection<Application> failingApplications = new ArrayList<>();
+ List<ApplicationId> failingApplications = new ArrayList<>();
for (Application application : applications)
- if (failingSinceBefore(application.deploymentJobs(), controller().clock().instant().minus(maxFailureAge)))
- failingApplications.add(application);
+ if (hasFailuresOlderThanThreshold(application.deploymentJobs()))
+ failingApplications.add(application.id());
else
- controller().applications().setJiraIssueId(application.id(), Optional.empty());
-
- // TODO: Do this when version.confidence is BROKEN instead? Or, exclude above those upgrading to BROKEN version?
- if (failingApplications.size() > 0.2 * applications.size()) {
- fileOrUpdate(manyFailingDeploymentsIssueFrom(failingApplications)); // Problems with Vespa is the most likely cause when so many deployments fail.
- }
- else {
- for (Application application : failingApplications) {
- Issue deploymentIssue = deploymentIssueFrom(application);
- Tenant applicationTenant = null;
- Classification applicationOwner = null;
- try {
- applicationTenant= ownerOf(application);
- applicationOwner = applicationTenant.tenantType() == TenantType.USER
- ? vespaOps.withAssignee(applicationTenant.getId().id().replaceFirst("by-", ""))
- : jiraClassificationOf(applicationTenant);
- fileFor(application, deploymentIssue.with(applicationOwner));
- }
- catch (RuntimeException e) { // Catch errors due to inconsistent or missing data in Sherpa, OpsDB, JIRA, and send to ourselves.
- Pattern componentError = Pattern.compile(".*Component name '.*' is not valid.*", Pattern.DOTALL);
- if (componentError.matcher(e.getMessage()).matches()) // Several properties seem to list invalid components, in which case we simply ignore this.
- fileFor(application,
- deploymentIssue
- .with(applicationOwner.withComponent(null))
- .append("\n\nNote: The 'Queue Component' field in [opsdb|https://opsdb.ops.yahoo.com/properties.php?id=" +
- applicationTenant.getPropertyId().get() +
- "&action=view] for your property was rejected by JIRA. Please check your spelling."));
- else
- fileFor(application, deploymentIssue.with(vespaOps).append(e.getMessage() + "\n\nAddressee:\n" + applicationOwner));
- }
- }
- }
- }
-
- /** Returns whether @deploymentJobs has a job which has been failing since before @failureThreshold or not. */
- private boolean failingSinceBefore(DeploymentJobs deploymentJobs, Instant failureThreshold) {
- return deploymentJobs.hasFailures() && deploymentJobs.failingSince().isBefore(failureThreshold);
- }
-
- private Tenant ownerOf(Application application) {
- return controller().tenants().tenant(new TenantId(application.id().tenant().value())).get();
- }
-
- /** Use the @propertyId of @tenant, if present, to look up JIRA information in OpsDB. */
- private Classification jiraClassificationOf(Tenant tenant) {
- Long propertyId = tenant.getPropertyId().map(PropertyId::value).orElseThrow(() ->
- new NoSuchElementException("No property id is listed for " + tenant));
-
- Classification classification = properties.classificationFor(propertyId).orElseThrow(() ->
- new NoSuchElementException("No property was found with id " + propertyId));
-
- return classification.withLabel(deploymentFailureLabel);
- }
-
- /** File @issue for @application, if @application doesn't already have an @Issue associated with it. */
- private void fileFor(Application application, Issue issue) {
- Optional<String> ourIssueId = application.deploymentJobs().jiraIssueId()
- .filter(jiraIssueId -> issues.fetch(jiraIssueId).status() != done);
-
- if ( ! ourIssueId.isPresent())
- controller().applications().setJiraIssueId(application.id(), Optional.of(issues.file(issue)));
- }
-
- /** File @issue, or update a JIRA issue representing the same issue. */
- private void fileOrUpdate(Issue issue) {
- Optional<String> jiraIssueId = issues.fetchSimilarTo(issue)
- .stream().findFirst().map(Issues.IssueInfo::id);
+ controller().applications().setIssueId(application.id(), null);
- if (jiraIssueId.isPresent())
- issues.update(jiraIssueId.get(), issue.description());
+ // TODO: Change this logic, depending on the controller's definition of BROKEN, whether it updates applications
+ // TODO: to an older version when the system version is BROKEN, etc..
+ if (failingApplications.size() > 0.2 * applications.size())
+ deploymentIssues.fileUnlessOpen(failingApplications);
else
- issues.file(issue);
+ failingApplications.forEach(this::fileDeploymentIssueFor);
}
- /** Escalate JIRA issues for which there has been no activity for a set amount of time. */
- private void escalateInactiveDeploymentIssues(List<Application> applications) {
- applications.forEach(application ->
- application.deploymentJobs().jiraIssueId().ifPresent(jiraIssueId -> {
- Issues.IssueInfo issueInfo = issues.fetch(jiraIssueId);
- if (issueInfo.updated().isBefore(controller().clock().instant().minus(maxInactivityAge)))
- escalateAndComment(issueInfo, application);
- }));
+ /** Returns whether deploymentJobs has a job which has been failing since before failureThreshold. */
+ private boolean hasFailuresOlderThanThreshold(DeploymentJobs deploymentJobs) {
+ return deploymentJobs.hasFailures()
+ && deploymentJobs.failingSince().isBefore(controller().clock().instant().minus(maxFailureAge));
}
- /** Reassign the JIRA issue for @application one step up in the OpsDb escalation chain, and add an explanatory comment to it. */
- private void escalateAndComment(IssueInfo issueInfo, Application application) {
- Optional<String> assignee = issueInfo.assignee();
- if (assignee.isPresent()) {
- if (assignee.get().equals(terminalUser.username())) return;
- issues.addWatcher(issueInfo.id(), assignee.get());
- }
-
- Long propertyId = ownerOf(application).getPropertyId().get().value();
-
- UserContact escalationTarget = contacts.escalationTargetFor(propertyId, assignee.orElse("no one"));
- if (escalationTarget.is(assignee.orElse("no one")))
- escalationTarget = terminalUser;
-
- String comment = deploymentIssueEscalationComment(application, propertyId, assignee.orElse("anyone"));
-
- issues.comment(issueInfo.id(), comment);
- issues.reassign(issueInfo.id(), escalationTarget.username());
- }
-
- Issue deploymentIssueFrom(Application application) {
- return new Issue(deploymentIssueSummary(application), deploymentIssueDescription(application))
- .with(vespaOps);
- }
-
- Issue manyFailingDeploymentsIssueFrom(Collection<Application> applications) {
- return new Issue(
- "More than 20% of Hosted Vespa deployments are failing",
- applications.stream()
- .map(application -> "[" + application.id().toShortString() + "|" + toUrl(application.id()) + "]")
- .collect(Collectors.joining("\n")),
- vespaOps);
+ private Tenant ownerOf(ApplicationId applicationId) {
+ return controller().tenants().tenant(new TenantId(applicationId.tenant().value()))
+ .orElseThrow(() -> new IllegalStateException("No tenant found for application " + applicationId));
}
- // TODO: Use the method of the same name in ApplicationId
- private static String toShortString(ApplicationId id) {
- return id.tenant().value() + "." + id.application().value() +
- ( id.instance().isDefault() ? "" : "." + id.instance().value() );
+ private User userFor(Tenant tenant) {
+ return User.from(tenant.getId().id().replaceFirst("by-", ""));
}
- private String toUrl(ApplicationId applicationId) {
- return controller().zoneRegistry().getDashboardUri().resolve("/apps" +
- "/tenant/" + applicationId.tenant().value() +
- "/application/" + applicationId.application().value()).toString();
+ private PropertyId propertyIdFor(Tenant tenant) {
+ return tenant.getPropertyId()
+ .orElseThrow(() -> new NoSuchElementException("No PropertyId is listed for non-user tenant " + tenant));
}
- private String toOpsDbUrl(long propertyId) {
- return contacts.contactsUri(propertyId).toString();
-
- }
-
- /** Returns the summary text what will be assigned to a new issue */
- private static String deploymentIssueSummary(Application application) {
- return "[" + toShortString(application.id()) + "] Action required: Repair deployment";
- }
-
- /** Returns the description text what will be assigned to a new issue */
- private String deploymentIssueDescription(Application application) {
- return "Deployment jobs of the Vespa application " +
- "[" + toShortString(application.id()) + "|" + toUrl(application.id()) + "] have been failing " +
- "continuously for over 48 hours. This blocks any change to this application from being deployed " +
- "and will also block global rollout of new Vespa versions for everybody.\n\n" +
- "Please assign your highest priority to fixing this. If you need support, request it using " +
- "[yo/vespa-support|http://yo/vespa-support]. " +
- "If this application is not in use, please re-assign this issue to project \"VESPA\" " +
- "with component \"Services\", and ask for the application to be removed.\n\n" +
- "If we do not get a response on this issue, we will auto-escalate it.";
+ /** File an issue for applicationId, if it doesn't already have an open issue associated with it. */
+ private void fileDeploymentIssueFor(ApplicationId applicationId) {
+ try {
+ Tenant tenant = ownerOf(applicationId);
+ Optional<IssueId> ourIssueId = controller().applications().require(applicationId).deploymentJobs().issueId();
+ IssueId issueId = tenant.tenantType() == TenantType.USER
+ ? deploymentIssues.fileUnlessOpen(ourIssueId, applicationId, userFor(tenant))
+ : deploymentIssues.fileUnlessOpen(ourIssueId, applicationId, propertyIdFor(tenant));
+ controller().applications().setIssueId(applicationId, issueId);
+ }
+ catch (RuntimeException e) { // Catch errors due to wrong data in the controller, or issues client timeout.
+ log.log(Level.WARNING, "Exception caught when attempting to file an issue for " + applicationId, e);
+ }
}
- /** Returns the comment text that what will be added to an issue each time it is escalated */
- private String deploymentIssueEscalationComment(Application application, long propertyId, String priorAssignee) {
- return "This issue tracks the failing deployment of Vespa application " +
- "[" + toShortString(application.id()) + "|" + toUrl(application.id()) + "]. " +
- "Since we have not received a response from " + priorAssignee +
- ", we are escalating to you, " +
- "based on [your OpsDb information|" + toOpsDbUrl(propertyId) + "]. " +
- "Please acknowledge this issue and assign somebody to " +
- "fix it as soon as possible.\n\n" +
- "If we do not receive a response we will keep auto-escalating this issue. " +
- "If we run out of escalation options for your OpsDb property, we will assume this application " +
- "is not managed by anyone and DELETE it. In the meantime, this issue will block global deployment " +
- "of Vespa for the entire company.";
+ /** Escalate issues for which there has been no activity for a certain amount of time. */
+ private void escalateInactiveDeploymentIssues(Collection<Application> applications) {
+ applications.forEach(application -> application.deploymentJobs().issueId().ifPresent(issueId -> {
+ try {
+ deploymentIssues.escalateIfInactive(issueId, ownerOf(application.id()).getPropertyId(), maxInactivity);
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Exception caught when attempting to escalate issue with id " + issueId, e);
+ }
+ }));
}
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializer.java
index 41d1a2d624c..fa4341b8e25 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializer.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializer.java
@@ -15,6 +15,7 @@ import com.yahoo.slime.Inspector;
import com.yahoo.slime.Slime;
import com.yahoo.vespa.config.SlimeUtils;
import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.IssueId;
import com.yahoo.vespa.hosted.controller.application.ApplicationRevision;
import com.yahoo.vespa.hosted.controller.application.Change;
import com.yahoo.vespa.hosted.controller.application.ClusterInfo;
@@ -66,7 +67,7 @@ public class ApplicationSerializer {
// DeploymentJobs fields
private final String projectIdField = "projectId";
private final String jobStatusField = "jobStatus";
- private final String jiraIssueIdField = "jiraIssueId";
+ private final String issueIdField = "jiraIssueId";
// JobStatus field
private final String jobTypeField = "jobType";
@@ -203,7 +204,7 @@ public class ApplicationSerializer {
.filter(id -> id > 0) // TODO: Discards invalid data. Remove filter after October 2017
.ifPresent(projectId -> cursor.setLong(projectIdField, projectId));
jobStatusToSlime(deploymentJobs.jobStatus().values(), cursor.setArray(jobStatusField));
- deploymentJobs.jiraIssueId().ifPresent(jiraIssueId -> cursor.setString(jiraIssueIdField, jiraIssueId));
+ deploymentJobs.issueId().ifPresent(jiraIssueId -> cursor.setString(issueIdField, jiraIssueId.value()));
}
private void jobStatusToSlime(Collection<JobStatus> jobStatuses, Cursor jobStatusArray) {
@@ -345,9 +346,9 @@ public class ApplicationSerializer {
Optional<Long> projectId = optionalLong(object.field(projectIdField))
.filter(id -> id > 0); // TODO: Discards invalid data. Remove filter after October 2017
List<JobStatus> jobStatusList = jobStatusListFromSlime(object.field(jobStatusField));
- Optional<String> jiraIssueKey = optionalString(object.field(jiraIssueIdField));
+ Optional<IssueId> issueId = optionalString(object.field(issueIdField)).map(IssueId::from);
- return new DeploymentJobs(projectId, jobStatusList, jiraIssueKey);
+ return new DeploymentJobs(projectId, jobStatusList, issueId);
}
private Optional<Change> changeFromSlime(Inspector object) {
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 c50f1464be7..6ae9761b305 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
@@ -53,6 +53,7 @@ import com.yahoo.vespa.hosted.controller.api.identifiers.UserId;
import com.yahoo.vespa.hosted.controller.api.integration.MetricsService;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServerException;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.Log;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.User;
import com.yahoo.vespa.hosted.controller.api.integration.routing.RotationStatus;
import com.yahoo.vespa.hosted.controller.application.ApplicationPackage;
import com.yahoo.vespa.hosted.controller.application.ApplicationRevision;
@@ -300,12 +301,28 @@ public class ApplicationApiHandler extends LoggingRequestHandler {
}
private HttpResponse tenant(String tenantName, HttpRequest request) {
- Optional<Tenant> tenant = controller.tenants().tenant(new TenantId(tenantName));
- if ( ! tenant.isPresent())
- return ErrorResponse.notFoundError("Tenant '" + tenantName + "' does not exist");
- return new SlimeJsonResponse(toSlime(tenant.get(), request, true));
+ return controller.tenants().tenant(new TenantId((tenantName)))
+ .map(tenant -> tenant(tenant, request, true))
+ .orElseGet(() -> ErrorResponse.notFoundError("Tenant '" + tenantName + "' does not exist"));
+ }
+
+ private HttpResponse tenant(Tenant tenant, HttpRequest request, boolean listApplications) {
+ Slime tenantSlime = toSlime(tenant, request, listApplications);
+ tenant.getPropertyId().ifPresent(propertyId -> {
+ try {
+ toSlime(tenantSlime.get(),
+ controller.organization().propertyUri(propertyId),
+ controller.organization().contactsUri(propertyId),
+ controller.organization().issueCreationUri(propertyId),
+ controller.organization().contactsFor(propertyId));
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Error fetching property info for " + tenant + " with propertyId " + propertyId, e);
+ }
+ });
+ return new SlimeJsonResponse(tenantSlime);
}
-
+
private HttpResponse applications(String tenantName, HttpRequest request) {
TenantName tenant = TenantName.from(tenantName);
Slime slime = new Slime();
@@ -621,7 +638,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler {
throw new BadRequestException("Unknown tenant type: " + existingTenant.get().tenantType());
}
}
- return new SlimeJsonResponse(toSlime(updatedTenant, request, true));
+ return tenant(updatedTenant, request, true);
}
private HttpResponse createTenant(String tenantName, HttpRequest request) {
@@ -641,7 +658,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler {
throwIfNotAthenzDomainAdmin(new AthenzDomain(mandatory("athensDomain", requestData).asString()), request);
controller.tenants().addTenant(tenant, authorizer.getNToken(request));
- return new SlimeJsonResponse(toSlime(tenant, request, true));
+ return tenant(tenant, request, true);
}
private HttpResponse migrateTenant(String tenantName, HttpRequest request) {
@@ -657,7 +674,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler {
.orElseThrow(() ->
new BadRequestException("The NToken for a domain admin is required to migrate tenant to Athens"));
Tenant tenant = controller.tenants().migrateTenantToAthenz(tenantid, tenantDomain, propertyId, property, nToken);
- return new SlimeJsonResponse(toSlime(tenant, request, true));
+ return tenant(tenant, request, true);
}
private HttpResponse createApplication(String tenantName, String applicationName, HttpRequest request) {
@@ -802,7 +819,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler {
controller.tenants().deleteTenant(new TenantId(tenantName), authorizer.getNToken(request));
// TODO: Change to a message response saying the tenant was deleted
- return new SlimeJsonResponse(toSlime(tenant.get(), request, false));
+ return tenant(tenant.get(), request, false);
}
private HttpResponse deleteApplication(String tenantName, String applicationName, HttpRequest request) {
@@ -990,6 +1007,18 @@ public class ApplicationApiHandler extends LoggingRequestHandler {
return slime;
}
+ private void toSlime(Cursor root, URI propertyUri, URI contactsUri, URI issueCreationUri, List<? extends List<? extends User>> contacts) {
+ root.setString("propertyUrl", propertyUri.toString());
+ root.setString("contactsUrl", contactsUri.toString());
+ root.setString("issueCreationUrl", issueCreationUri.toString());
+ Cursor lists = root.setArray("contacts");
+ for (List<? extends User> contactList : contacts) {
+ Cursor list = lists.addArray();
+ for (User contact : contactList)
+ list.addString(contact.displayName());
+ }
+ }
+
private void toSlime(Application application, Cursor object, HttpRequest request) {
object.setString("application", application.id().application().value());
object.setString("instance", application.id().instance().value());
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java
index b49d55aeb3b..deb7b77eb56 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java
@@ -25,7 +25,7 @@ import com.yahoo.vespa.hosted.controller.api.integration.chef.ChefMock;
import com.yahoo.vespa.hosted.controller.api.integration.dns.MemoryNameService;
import com.yahoo.vespa.hosted.controller.api.integration.entity.MemoryEntityService;
import com.yahoo.vespa.hosted.controller.api.integration.github.GitHubMock;
-import com.yahoo.vespa.hosted.controller.api.integration.jira.JiraMock;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.MockOrganization;
import com.yahoo.vespa.hosted.controller.api.integration.routing.MemoryGlobalRoutingService;
import com.yahoo.vespa.hosted.controller.application.ApplicationPackage;
import com.yahoo.vespa.hosted.controller.athenz.mock.AthenzDbMock;
@@ -204,8 +204,8 @@ public final class ControllerTester {
curator,
new MemoryRotationRepository(),
gitHubClientMock,
- new JiraMock(),
new MemoryEntityService(),
+ new MockOrganization(clock),
new MemoryGlobalRoutingService(),
zoneRegistryMock,
configServerClientMock,
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentIssueReporterTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentIssueReporterTest.java
index f8d09ac8b27..aa93fb1cfe2 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentIssueReporterTest.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentIssueReporterTest.java
@@ -1,13 +1,11 @@
// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.controller.maintenance;
+import com.yahoo.config.provision.ApplicationId;
import com.yahoo.config.provision.Environment;
import com.yahoo.vespa.hosted.controller.Application;
-import com.yahoo.vespa.hosted.controller.api.integration.Contacts.UserContact;
-import com.yahoo.vespa.hosted.controller.api.integration.Issues;
-import com.yahoo.vespa.hosted.controller.api.integration.Issues.IssueInfo;
-import com.yahoo.vespa.hosted.controller.api.integration.stubs.ContactsMock;
-import com.yahoo.vespa.hosted.controller.api.integration.stubs.PropertiesMock;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.IssueId;
+import com.yahoo.vespa.hosted.controller.api.integration.stubs.LoggingDeploymentIssues;
import com.yahoo.vespa.hosted.controller.application.ApplicationPackage;
import com.yahoo.vespa.hosted.controller.deployment.ApplicationPackageBuilder;
import com.yahoo.vespa.hosted.controller.deployment.DeploymentTester;
@@ -15,28 +13,18 @@ import com.yahoo.vespa.hosted.controller.persistence.MockCuratorDb;
import org.junit.Before;
import org.junit.Test;
-import java.time.Clock;
import java.time.Duration;
-import java.util.Arrays;
import java.util.HashMap;
-import java.util.List;
import java.util.Map;
-import java.util.Optional;
-import java.util.stream.Collectors;
-import static com.yahoo.vespa.hosted.controller.api.integration.Contacts.Category.admin;
-import static com.yahoo.vespa.hosted.controller.api.integration.Contacts.Category.engineeringOwner;
-import static com.yahoo.vespa.hosted.controller.api.integration.Issues.IssueInfo.Status.done;
-import static com.yahoo.vespa.hosted.controller.api.integration.Issues.IssueInfo.Status.toDo;
import static com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobType.component;
import static com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobType.productionCorpUsEast1;
import static com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobType.stagingTest;
import static com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobType.systemTest;
import static com.yahoo.vespa.hosted.controller.maintenance.DeploymentIssueReporter.maxFailureAge;
-import static com.yahoo.vespa.hosted.controller.maintenance.DeploymentIssueReporter.maxInactivityAge;
-import static com.yahoo.vespa.hosted.controller.maintenance.DeploymentIssueReporter.terminalUser;
-import static com.yahoo.vespa.hosted.controller.maintenance.DeploymentIssueReporter.vespaOps;
+import static com.yahoo.vespa.hosted.controller.maintenance.DeploymentIssueReporter.maxInactivity;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
@@ -51,27 +39,18 @@ public class DeploymentIssueReporterTest {
private DeploymentTester tester;
private DeploymentIssueReporter reporter;
- private ContactsMock contacts;
- private PropertiesMock properties;
- private MockIssues issues;
+ private MockDeploymentIssues issues;
@Before
public void setup() {
tester = new DeploymentTester();
- contacts = new ContactsMock();
- properties = new PropertiesMock();
- issues = new MockIssues(tester.clock());
- reporter = new DeploymentIssueReporter(tester.controller(), contacts, properties, issues, Duration.ofMinutes(5),
- new JobControl(new MockCuratorDb()));
- }
-
- private List<IssueInfo> openIssuesFor(Application application) {
- return issues.fetchSimilarTo(reporter.deploymentIssueFrom(tester.controller().applications().require(application.id())));
+ issues = new MockDeploymentIssues();
+ reporter = new DeploymentIssueReporter(tester.controller(), issues, Duration.ofMinutes(5), new JobControl(new MockCuratorDb()));
}
@Test
public void testDeploymentFailureReporting() {
- // All applications deploy from unique SD projects.
+ // All applications deploy from unique build projects.
Long projectId1 = 10L;
Long projectId2 = 20L;
Long projectId3 = 30L;
@@ -79,11 +58,12 @@ public class DeploymentIssueReporterTest {
// Only the first two have propertyIds set now.
Long propertyId1 = 1L;
Long propertyId2 = 2L;
+ Long propertyId3 = 3L;
// Create and deploy one application for each of three tenants.
Application app1 = tester.createApplication("application1", "tenant1", projectId1, propertyId1);
Application app2 = tester.createApplication("application2", "tenant2", projectId2, propertyId2);
- Application app3 = tester.createApplication("application3", "tenant3", projectId3, null);
+ Application app3 = tester.createApplication("application3", "tenant3", projectId3, propertyId3);
// And then we need lots of successful applications, so we won't assume we just have a faulty Vespa out.
for (long i = 4; i <= 10; i++) {
@@ -93,18 +73,6 @@ public class DeploymentIssueReporterTest {
tester.deployAndNotify(app, applicationPackage, true, stagingTest);
tester.deployAndNotify(app, applicationPackage, true, productionCorpUsEast1);
}
-
- // Both the first tenants belong to the same JIRA queue. (Not sure if this is possible, but let's test it anyway.
- String jiraQueue = "PROJECT";
- properties.addClassification(propertyId1, jiraQueue);
- properties.addClassification(propertyId1, jiraQueue);
-
- // Only tenant1 has contacts listed in opsDb.
- UserContact
- alice = new UserContact("alice", "Alice", admin),
- bob = new UserContact("bob", "Robert", engineeringOwner);
- contacts.addContact(propertyId1, Arrays.asList(alice, bob));
-
// end of setup.
// NOTE: All maintenance should be idempotent within a small enough time interval, so maintain is called twice in succession throughout.
@@ -125,7 +93,7 @@ public class DeploymentIssueReporterTest {
reporter.maintain();
reporter.maintain();
- assertEquals("No deployments are detected as failing for a long time initially.", 0, issues.issues.size());
+ assertEquals("No deployments are detected as failing for a long time initially.", 0, issues.size());
// Advance to where deployment issues should be detected.
@@ -133,146 +101,103 @@ public class DeploymentIssueReporterTest {
reporter.maintain();
reporter.maintain();
- assertEquals("One issue is produced for app1.", 1, openIssuesFor(app1).size());
- assertEquals("No issues are produced for app2.", 0, openIssuesFor(app2).size());
- assertEquals("One issue is produced for app3.", 1, openIssuesFor(app3).size());
- assertTrue("The issue for app1 is stored in their JIRA queue.", openIssuesFor(app1).get(0).key().startsWith(jiraQueue));
- assertTrue("The issue for an application without propertyId is addressed to vespaOps.", openIssuesFor(app3).get(0).key().startsWith(vespaOps.queue()));
+ assertTrue("One issue is produced for app1.", issues.isOpenFor(app1.id()));
+ assertFalse("No issues are produced for app2.", issues.isOpenFor(app2.id()));
+ assertTrue("One issue is produced for app3.", issues.isOpenFor(app3.id()));
- // Verify idempotency of filing.
- reporter.maintain();
- reporter.maintain();
- assertEquals("No issues are re-filed when still open.", 2, issues.issues.size());
-
-
- // tenant3 closes their issue prematurely; see that we get a new filing.
- issues.complete(openIssuesFor(app3).get(0).id());
- assertEquals("The issue is removed (test of the tester, really...).", 0, openIssuesFor(app3).size());
+ // app3 closes their issue prematurely; see that it is refiled.
+ issues.closeFor(app3.id());
+ assertFalse("No issue is open for app3.", issues.isOpenFor(app3.id()));
reporter.maintain();
reporter.maintain();
- assertTrue("Issue is re-produced for app3, addressed correctly.", openIssuesFor(app3).get(0).key().startsWith(vespaOps.queue()));
+ assertTrue("Issue is re-filed for app3.", issues.isOpenFor(app3.id()));
// Some time passes; tenant1 leaves her issue unattended, while tenant3 starts work and updates the issue.
// app2 also has an intermittent failure; see that we detect this as a Vespa problem, and file an issue to ourselves.
tester.deployAndNotify(app2, applicationPackage, false, productionCorpUsEast1);
- tester.clock().advance(maxInactivityAge.plus(maxFailureAge));
- issues.comment(openIssuesFor(app3).get(0).id(), "We are trying to fix it!");
-
- reporter.maintain();
- reporter.maintain();
- assertEquals("The issue for app1 is escalated once.", alice.username(), openIssuesFor(app1).get(0).assignee().get());
-
+ tester.clock().advance(maxInactivity.plus(maxFailureAge));
+ issues.touchFor(app3.id());
+ assertFalse("We have no platform issues initially.", issues.platformIssue());
reporter.maintain();
reporter.maintain();
- assertEquals("We get an issue to vespaOps when more than 20% of applications have old failures.", 1,
- issues.fetchSimilarTo(reporter.manyFailingDeploymentsIssueFrom(Arrays.asList(
- tester.controller().applications().get(app1.id()).get(),
- tester.controller().applications().get(app2.id()).get(),
- tester.controller().applications().get(app3.id()).get()))).size());
- assertEquals("No issue is filed for app2 while Vespa is considered broken.", 0, openIssuesFor(app2).size());
+ assertEquals("The issue for app1 is escalated once.", 1, issues.escalationLevelFor(app1.id()));
+ assertTrue("We get a platform issue when more than 20% of applications are failing.", issues.platformIssue());
+ assertFalse("No issue is filed for app2 while Vespa is considered broken.", issues.isOpenFor(app2.id()));
// app3 fixes its problem, but the ticket is left open; see the resolved ticket is not escalated when another escalation period has passed.
tester.deployAndNotify(app2, applicationPackage, true, productionCorpUsEast1);
tester.deployAndNotify(app3, applicationPackage, true, productionCorpUsEast1);
- tester.clock().advance(maxInactivityAge.plus(Duration.ofDays(1)));
+ tester.clock().advance(maxInactivity.plus(Duration.ofDays(1)));
reporter.maintain();
reporter.maintain();
- assertEquals("The issue for app1 is escalated once more.", bob.username(), openIssuesFor(app1).get(0).assignee().get());
- assertEquals("The issue for app3 is still unassigned.", Optional.empty(), openIssuesFor(app3).get(0).assignee());
+ assertFalse("We no longer have a platform issue.", issues.platformIssue());
+ assertEquals("The issue for app1 is escalated once more.", 2, issues.escalationLevelFor(app1.id()));
+ assertEquals("The issue for app3 is not escalated.", 0, issues.escalationLevelFor(app3.id()));
- // app1 still does nothing with their issue; see the terminal user gets it in the end.
// app3 now has a new failure past max failure age; see that a new issue is filed.
tester.notifyJobCompletion(component, app3, true);
tester.deployAndNotify(app3, applicationPackage, true, systemTest);
tester.deployAndNotify(app3, applicationPackage, true, stagingTest);
tester.deployAndNotify(app3, applicationPackage, false, productionCorpUsEast1);
- tester.clock().advance(maxInactivityAge.plus(maxFailureAge));
+ tester.clock().advance(maxInactivity.plus(maxFailureAge));
reporter.maintain();
reporter.maintain();
- assertEquals("The issue for app1 is escalated to the terminal user.", terminalUser.username(), openIssuesFor(app1).get(0).assignee().get());
- assertEquals("A new issue is filed for app3.", 2, openIssuesFor(app3).size());
+ assertTrue("A new issue is filed for app3.", issues.isOpenFor(app3.id()));
}
- class MockIssues implements Issues {
- final Map<String, Issue> issues = new HashMap<>();
- final Map<String, IssueInfo> metas = new HashMap<>();
- final Map<String, Long> counters = new HashMap<>();
- Clock clock;
+ class MockDeploymentIssues extends LoggingDeploymentIssues {
- MockIssues(Clock clock) { this.clock = clock; }
+ Map<ApplicationId, IssueId> applicationIssues = new HashMap<>();
+ Map<IssueId, Integer> issueLevels = new HashMap<>();
- public void addWatcher(String jiraIssueId, String watcher) {
- touch(jiraIssueId);
+ MockDeploymentIssues() {
+ super(tester.clock());
}
- public void reassign(String jiraIssueId, String assignee) {
- metas.compute(jiraIssueId, (__, jiraIssueMeta) ->
- new IssueInfo(
- jiraIssueId,
- jiraIssueMeta.key(),
- clock.instant(),
- Optional.of(assignee),
- jiraIssueMeta.status()));
+ @Override
+ protected void escalateIssue(IssueId issueId) {
+ super.escalateIssue(issueId);
+ issueLevels.merge(issueId, 1, Integer::sum);
}
- public void comment(String jiraIssueId, String comment) {
- touch(jiraIssueId);
+ @Override
+ protected IssueId fileIssue(ApplicationId applicationId) {
+ IssueId issueId = super.fileIssue(applicationId);
+ applicationIssues.put(applicationId, issueId);
+ return issueId;
}
- public void update(String jiraIssueId, String description) {
- issues.compute(jiraIssueId, (__, issue) ->
- new Issue(issue.summary(), description, issue.classification().orElse(null)));
+ void closeFor(ApplicationId applicationId) {
+ issueUpdates.remove(applicationIssues.remove(applicationId));
}
- public String file(Issue issue) {
- String jiraIssueId = (issues.size() + 1L) + "";
- Long counter = counters.merge(issue.classification().get().queue(), 0L, (old, __) -> old + 1);
- String jiraIssueKey = issue.classification().get().queue() + '-' + counter;
- issues.put(jiraIssueId, issue);
- metas.put(jiraIssueId, new IssueInfo(jiraIssueId, jiraIssueKey, clock.instant(), null, toDo));
- return jiraIssueId;
+ void touchFor(ApplicationId applicationId) {
+ issueUpdates.put(applicationIssues.get(applicationId), tester.clock().instant());
}
- public IssueInfo fetch(String jiraIssueId) {
- return metas.get(jiraIssueId);
+ boolean isOpenFor(ApplicationId applicationId) {
+ return applicationIssues.containsKey(applicationId);
}
- public List<IssueInfo> fetchSimilarTo(Issue issue) {
- return issues.entrySet().stream()
- .filter(entry -> entry.getValue().summary().equals(issue.summary()))
- .map(Map.Entry::getKey)
- .map(metas::get)
- .filter(meta -> meta.status() != done)
- .collect(Collectors.toList());
+ int escalationLevelFor(ApplicationId applicationId) {
+ return issueLevels.getOrDefault(applicationIssues.get(applicationId), 0);
}
- private void complete(String jiraIssueId) {
- metas.compute(jiraIssueId, (__, jiraIssueMeta) ->
- new IssueInfo(
- jiraIssueId,
- jiraIssueMeta.key(),
- clock.instant(),
- jiraIssueMeta.assignee(),
- done));
+ int size() {
+ return issueUpdates.size();
}
- private void touch(String jiraIssueId) {
- metas.compute(jiraIssueId, (__, jiraIssueMeta) ->
- new IssueInfo(
- jiraIssueId,
- jiraIssueMeta.key(),
- clock.instant(),
- jiraIssueMeta.assignee(),
- jiraIssueMeta.status()));
+ boolean platformIssue() {
+ return platformIssue.get();
}
}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java
index 5c102b0d9df..e6c0ce9027d 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java
@@ -41,11 +41,9 @@ public class ControllerContainerTest {
" <component id='com.yahoo.vespa.hosted.controller.api.integration.dns.MemoryNameService'/>" +
" <component id='com.yahoo.vespa.hosted.controller.api.integration.entity.MemoryEntityService'/>" +
" <component id='com.yahoo.vespa.hosted.controller.api.integration.github.GitHubMock'/>" +
- " <component id='com.yahoo.vespa.hosted.controller.api.integration.jira.JiraMock'/>" +
" <component id='com.yahoo.vespa.hosted.controller.api.integration.routing.MemoryGlobalRoutingService'/>" +
- " <component id='com.yahoo.vespa.hosted.controller.api.integration.stubs.ContactsMock'/>" +
- " <component id='com.yahoo.vespa.hosted.controller.api.integration.stubs.LoggingIssues'/>" +
- " <component id='com.yahoo.vespa.hosted.controller.api.integration.stubs.PropertiesMock'/>" +
+ " <component id='com.yahoo.vespa.hosted.controller.api.integration.stubs.LoggingDeploymentIssues'/>" +
+ " <component id='com.yahoo.vespa.hosted.controller.api.integration.organization.MockOrganization'/>" +
" <component id='com.yahoo.vespa.hosted.controller.ConfigServerClientMock'/>" +
" <component id='com.yahoo.vespa.hosted.controller.ZoneRegistryMock'/>" +
" <component id='com.yahoo.vespa.hosted.controller.Controller'/>" +
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 1ac5dfeb58a..b36a88ca1d4 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
@@ -9,8 +9,11 @@ import com.yahoo.vespa.curator.Lock;
import com.yahoo.vespa.hosted.controller.Application;
import com.yahoo.vespa.hosted.controller.ConfigServerClientMock;
import com.yahoo.vespa.hosted.controller.api.identifiers.AthenzDomain;
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
import com.yahoo.vespa.hosted.controller.api.identifiers.UserId;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServerException;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.MockOrganization;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.User;
import com.yahoo.vespa.hosted.controller.application.ApplicationPackage;
import com.yahoo.vespa.hosted.controller.application.ClusterInfo;
import com.yahoo.vespa.hosted.controller.application.ClusterUtilization;
@@ -37,6 +40,8 @@ import java.io.UncheckedIOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -79,7 +84,7 @@ public class ApplicationApiTest extends ControllerContainerTest {
new File("cookiefreshness.json"));
// POST (add) a tenant without property ID
tester.assertResponse(request("/application/v4/tenant/tenant1",
- "{\"athensDomain\":\"domain1\", \"property\":\"property1\"}",
+ "{\"athensDomain\":\"domain1\", \"property\":\"property1\"}",
Request.Method.POST),
new File("tenant-without-applications.json"));
// PUT (modify) a tenant
@@ -101,6 +106,7 @@ public class ApplicationApiTest extends ControllerContainerTest {
// GET a tenant
tester.assertResponse(request("/application/v4/tenant/tenant1", "", Request.Method.GET),
new File("tenant-with-application.json"));
+
// GET tenant applications
tester.assertResponse(request("/application/v4/tenant/tenant1/application/", "", Request.Method.GET),
new File("application-list.json"));
@@ -236,6 +242,8 @@ public class ApplicationApiTest extends ControllerContainerTest {
// Add another Athens domain, so we can try to create more tenants
addTenantAthenzDomain("domain2", "mytenant"); // New domain to test tenant w/property ID
+ // Add property info for that property id, as well, in the mock organization.
+ addPropertyData((MockOrganization) controllerTester.controller().organization(), "1234");
// POST (add) a tenant with property ID
tester.assertResponse(request("/application/v4/tenant/tenant2",
"{\"athensDomain\":\"domain2\", \"property\":\"property2\", \"propertyId\":\"1234\"}",
@@ -286,6 +294,13 @@ public class ApplicationApiTest extends ControllerContainerTest {
controllerTester.controller().deconstruct();
}
+ private void addPropertyData(MockOrganization organization, String propertyIdValue) {
+ PropertyId propertyId = new PropertyId(propertyIdValue);
+ organization.addProperty(propertyId);
+ organization.setContactsFor(propertyId, Arrays.asList(Collections.singletonList(User.from("alice")),
+ Collections.singletonList(User.from("bob"))));
+ }
+
@Test
public void testDeployDirectly() throws Exception {
// Setup
@@ -755,4 +770,5 @@ public class ApplicationApiTest extends ControllerContainerTest {
}
}
}
+
}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/opsdb-tenant-with-id-without-applications.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/opsdb-tenant-with-id-without-applications.json
index 8de85754ab0..8acb4a045f3 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/opsdb-tenant-with-id-without-applications.json
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/opsdb-tenant-with-id-without-applications.json
@@ -5,5 +5,16 @@
"userGroup": "group1",
"applications": [
+ ],
+ "propertyUrl": "www.properties.tld/1234",
+ "contactsUrl": "www.contacts.tld/1234",
+ "issueCreationUrl": "www.issues.tld/1234",
+ "contacts": [
+ [
+ "alice"
+ ],
+ [
+ "bob"
+ ]
]
-} \ No newline at end of file
+}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/opsdb-tenant-with-new-id-without-applications.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/opsdb-tenant-with-new-id-without-applications.json
index 9f0a7ec603e..f00b3c5bb1a 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/opsdb-tenant-with-new-id-without-applications.json
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/opsdb-tenant-with-new-id-without-applications.json
@@ -6,4 +6,4 @@
"applications": [
]
-} \ No newline at end of file
+}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-list.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-list.json
index a9d9cd33ae8..d7ec9a738f2 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-list.json
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-list.json
@@ -8,4 +8,4 @@
},
"url": "http://localhost:8080/application/v4/tenant/tenant1"
}
-] \ No newline at end of file
+]
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-without-applications-with-id.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-without-applications-with-id.json
index 3deef01bb44..ede2413218d 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-without-applications-with-id.json
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-without-applications-with-id.json
@@ -5,5 +5,16 @@
"propertyId": "1234",
"applications": [
+ ],
+ "propertyUrl": "www.properties.tld/1234",
+ "contactsUrl": "www.contacts.tld/1234",
+ "issueCreationUrl": "www.issues.tld/1234",
+ "contacts": [
+ [
+ "alice"
+ ],
+ [
+ "bob"
+ ]
]
-} \ No newline at end of file
+}