aboutsummaryrefslogtreecommitdiffstats
path: root/controller-server/src/main/java/com/yahoo
diff options
context:
space:
mode:
Diffstat (limited to 'controller-server/src/main/java/com/yahoo')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/AlreadyExistsException.java26
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Application.java276
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java541
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java273
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/NotExistsException.java32
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java238
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/ActivateResult.java37
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/ApplicationAlias.java57
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/InstanceEndpoints.java23
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/Tenant.java147
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/package-info.java8
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ApplicationList.java200
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ApplicationPackage.java75
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ApplicationRevision.java60
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Change.java90
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Deployment.java50
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentJobs.java333
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/JobStatus.java209
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/SourceRevision.java48
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ZipStreamReader.java63
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/package-info.java10
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/concurrent/Lock.java24
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/concurrent/Locks.java55
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/concurrent/TimeoutException.java15
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/BuildSystem.java34
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/DeploymentTrigger.java368
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/PolledBuildSystem.java100
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java67
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DelayedDeployer.java24
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentExpirer.java66
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentIssueReporter.java234
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/FailureRedeployer.java42
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/JobControl.java67
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/Maintainer.java80
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/MetricsReporter.java118
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/OutstandingChangeDeployer.java32
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/Upgrader.java94
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/VersionStatusUpdater.java33
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/package-info.java5
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/package-info.java10
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializer.java304
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ControllerDb.java74
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java201
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/JobQueueSerializer.java45
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MemoryControllerDb.java132
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MockCuratorDb.java18
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/PersistenceException.java19
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/StringSetSerializer.java44
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/package-info.java10
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ErrorResponse.java66
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/MessageResponse.java31
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Path.java109
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ResourceResponse.java42
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/RootHandler.java96
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/SlimeJsonResponse.java38
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/StringResponse.java26
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Uri.java64
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java1065
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationChefEnvironment.java43
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java164
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/DeployAuthorizer.java117
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/EmptyJsonResponse.java25
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/JacksonJsonResponse.java31
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/MultipartParser.java72
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ServiceApiResponse.java191
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java84
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/JobsResponse.java46
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/DeploymentApiHandler.java122
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlHeaders.java26
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlRequestFilter.java68
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlResponseFilter.java55
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/DummyFilter.java16
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/NTokenRequestFilter.java33
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/SetBouncerPassthruHeaderFilter.java27
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UnauthenticatedUserPrincipal.java44
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UserIdRequestFilter.java23
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/CreateSecurityContextFilter.java50
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/PropagateSecurityContextFilter.java31
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/package-info.java10
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/screwdriver/ScrewdriverApiHandler.java168
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/versions/DeploymentStatistics.java62
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/versions/VersionStatus.java182
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/versions/VespaVersion.java139
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/restapi/impl/StatusPageResource.java55
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/restapi/impl/package-info.java8
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/rotation/ControllerRotationRepository.java148
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/rotation/MemoryRotationRepository.java54
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/rotation/RotationRepository.java48
88 files changed, 8790 insertions, 0 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/AlreadyExistsException.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/AlreadyExistsException.java
new file mode 100644
index 00000000000..ffe7cb6ef67
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/AlreadyExistsException.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;
+
+import com.yahoo.vespa.hosted.controller.api.identifiers.Identifier;
+
+/**
+ * @author Tony Vaagenes
+ */
+public class AlreadyExistsException extends IllegalArgumentException {
+
+ /**
+ * Example message: Tenant 'myId' already exists.
+ *
+ * @param capitalizedType e.g. Tenant, Application
+ * @param id The id of the entity that didn't exist.
+ *
+ */
+ public AlreadyExistsException(String capitalizedType, String id) {
+ super(String.format("%s '%s' already exists", capitalizedType, id));
+ }
+
+ public AlreadyExistsException(Identifier identifier) {
+ this(identifier.capitalizedType(), identifier.id());
+ }
+
+}
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
new file mode 100644
index 00000000000..971438e008c
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Application.java
@@ -0,0 +1,276 @@
+// 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;
+
+import com.google.common.collect.ImmutableMap;
+import com.yahoo.component.Version;
+import com.yahoo.config.application.api.DeploymentSpec;
+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.application.ApplicationRevision;
+import com.yahoo.vespa.hosted.controller.application.Change;
+import com.yahoo.vespa.hosted.controller.application.Deployment;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobReport;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobType;
+
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * An instance of an application.
+ *
+ * This is immutable.
+ *
+ * @author bratseth
+ */
+public class Application {
+
+ private final ApplicationId id;
+ private final DeploymentSpec deploymentSpec;
+ private final ValidationOverrides validationOverrides;
+ private final Map<Zone, Deployment> deployments;
+ private final DeploymentJobs deploymentJobs;
+ private final Optional<Change> deploying;
+ private final boolean outstandingChange;
+
+ /** Creates an empty application */
+ public Application(ApplicationId id) {
+ this(id, DeploymentSpec.empty, ValidationOverrides.empty, ImmutableMap.of(), new DeploymentJobs(0L),
+ Optional.empty(), false); // TODO: Get rid of the 0
+ }
+
+ /** Used from persistence layer: Do not use */
+ public Application(ApplicationId id, DeploymentSpec deploymentSpec, ValidationOverrides validationOverrides,
+ List<Deployment> deployments,
+ DeploymentJobs deploymentJobs, Optional<Change> deploying, boolean outstandingChange) {
+ this(id, deploymentSpec, validationOverrides,
+ deployments.stream().collect(Collectors.toMap(d -> d.zone(), d -> d)),
+ deploymentJobs, deploying, outstandingChange);
+ }
+
+ private Application(ApplicationId id, DeploymentSpec deploymentSpec, ValidationOverrides validationOverrides,
+ Map<Zone, Deployment> deployments,
+ DeploymentJobs deploymentJobs, Optional<Change> deploying, boolean outstandingChange) {
+ Objects.requireNonNull(id, "id cannot be null");
+ Objects.requireNonNull(deploymentSpec, "deploymentSpec cannot be null");
+ Objects.requireNonNull(validationOverrides, "validationOverrides cannot be null");
+ Objects.requireNonNull(deployments, "deployments cannot be null");
+ Objects.requireNonNull(deploymentJobs, "deploymentJobs cannot be null");
+ Objects.requireNonNull(deploying, "deploying cannot be null");
+ this.id = id;
+ this.deploymentSpec = deploymentSpec;
+ this.validationOverrides = validationOverrides;
+ this.deployments = ImmutableMap.copyOf(deployments);
+ this.deploymentJobs = deploymentJobs;
+ this.deploying = deploying;
+ this.outstandingChange = outstandingChange;
+ }
+
+ public ApplicationId id() { return id; }
+
+ /**
+ * Returns the last deployed deployment spec of this application,
+ * or the empty deployment spec if it has never been deployed
+ */
+ public DeploymentSpec deploymentSpec() { return deploymentSpec; }
+
+ /**
+ * Returns the last deployed validation overrides of this application,
+ * or the empty validation overrides if it has never been deployed
+ * (or was deployed with an empty/missing validation overrides)
+ */
+ public ValidationOverrides validationOverrides() { return validationOverrides; }
+
+ /** Returns an immutable map of the current deployments of this */
+ public Map<Zone, Deployment> deployments() { return deployments; }
+
+ public DeploymentJobs deploymentJobs() { return deploymentJobs; }
+
+ /**
+ * Returns the change that is currently in the process of being deployed on this application,
+ * or empty if no change is currently being deployed.
+ */
+ public Optional<Change> deploying() { return deploying; }
+
+ /**
+ * Returns whether this has an outstanding change (in the source repository), which
+ * has currently not started deploying (because a deployment is (or was) already in progress
+ */
+ public boolean hasOutstandingChange() { return outstandingChange; }
+
+ /**
+ * Returns the oldest version this has deployed in a permanent zone (not test or staging),
+ * or empty version if it is not deployed anywhere
+ */
+ public Optional<Version> deployedVersion() {
+ return deployments().values().stream()
+ .filter(deployment -> isPermanent(deployment.zone().environment()))
+ .sorted(Comparator.comparing(Deployment::version))
+ .findFirst()
+ .map(Deployment::version);
+ }
+
+ /** The version that should be used to compile this application */
+ public Version compileVersion(Controller controller) {
+ return deployedVersion().orElse(controller.systemVersion());
+ }
+
+ public Application withProjectId(long projectId) {
+ 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 withJobCompletion(JobReport report, Instant notificationTime, Controller controller) {
+ return new Application(id,
+ deploymentSpec,
+ validationOverrides,
+ deployments,
+ deploymentJobs.withCompletion(report, notificationTime, controller),
+ deploying,
+ outstandingChange);
+ }
+
+ public Application withJobTriggering(JobType type, Instant triggerTime, Controller controller) {
+ return new Application(id,
+ deploymentSpec,
+ validationOverrides,
+ deployments,
+ deploymentJobs.withTriggering(type,
+ determineTriggerVersion(type, controller),
+ determineTriggerRevision(type, controller),
+ triggerTime),
+ deploying,
+ outstandingChange);
+ }
+
+ public Application with(Deployment deployment) {
+ Map<Zone, Deployment> deployments = new LinkedHashMap<>(this.deployments);
+ deployments.put(deployment.zone(), deployment);
+ return new Application(id, deploymentSpec, validationOverrides, deployments, deploymentJobs, deploying, outstandingChange);
+ }
+
+ public Application with(DeploymentJobs deploymentJobs) {
+ return new Application(id, deploymentSpec, validationOverrides, deployments, deploymentJobs, deploying, outstandingChange);
+ }
+
+ public Application withoutDeploymentIn(Zone zone) {
+ Map<Zone, Deployment> deployments = new LinkedHashMap<>(this.deployments);
+ deployments.remove(zone);
+ return new Application(id, deploymentSpec, validationOverrides, deployments, deploymentJobs, deploying, outstandingChange);
+ }
+
+ public Application withoutDeploymentJob(JobType jobType) {
+ DeploymentJobs deploymentJobs = this.deploymentJobs.without(jobType);
+ return new Application(id, deploymentSpec, validationOverrides, deployments, deploymentJobs, deploying, outstandingChange);
+ }
+
+ public Application with(DeploymentSpec deploymentSpec) {
+ return new Application(id, deploymentSpec, validationOverrides, deployments, deploymentJobs, deploying, outstandingChange);
+ }
+
+ public Application with(ValidationOverrides validationOverrides) {
+ return new Application(id, deploymentSpec, validationOverrides, deployments, deploymentJobs, deploying, outstandingChange);
+ }
+
+ public Application withDeploying(Optional<Change> deploying) {
+ return new Application(id, deploymentSpec, validationOverrides, deployments, deploymentJobs, deploying, outstandingChange);
+ }
+
+ public Application withOutstandingChange(boolean outstandingChange) {
+ return new Application(id, deploymentSpec, validationOverrides, deployments, deploymentJobs, deploying, outstandingChange);
+ }
+
+ private Version determineTriggerVersion(JobType jobType, Controller controller) {
+ Optional<Zone> zone = jobType.zone(controller.system());
+ if ( ! zone.isPresent()) // a sloppy test TODO: Fix
+ return controller.systemVersion();
+ return currentDeployVersion(controller, zone.get());
+ }
+
+ /** Returns the version a deployment to this zone should use for this application */
+ Version currentDeployVersion(Controller controller, Zone zone) {
+ if ( ! deploying().isPresent())
+ return currentVersion(controller, zone);
+ else if ( deploying().get() instanceof Change.ApplicationChange)
+ return currentVersion(controller, zone);
+ else
+ return ((Change.VersionChange) deploying().get()).version();
+ }
+
+ /** Returns the current version this application has, or if none; should use, in the given zone */
+ Version currentVersion(Controller controller, Zone zone) {
+ Deployment currentDeployment = deployments().get(zone);
+ if (currentDeployment != null) // Already deployed in this zone: Use that version
+ return currentDeployment.version();
+
+ return deployedVersion().orElse(controller.systemVersion());
+ }
+
+ private Optional<ApplicationRevision> determineTriggerRevision(JobType jobType, Controller controller) {
+ Optional<Zone> zone = jobType.zone(controller.system());
+ if ( ! zone.isPresent()) // a sloppy test TODO: Fix
+ return Optional.empty();
+ return currentDeployRevision(jobType.zone(controller.system()).get());
+ }
+
+ /** Returns the version a deployment to this zone should use for this application, or empty if we don't know */
+ Optional<ApplicationRevision> currentDeployRevision(Zone zone) {
+ if ( ! deploying().isPresent())
+ return currentRevision(zone);
+ else if ( deploying().get() instanceof Change.VersionChange)
+ return currentRevision(zone);
+ else
+ return ((Change.ApplicationChange)deploying().get()).revision();
+ }
+
+ /**
+ * Returns the current revision this application has, or if none; should use assuming no change,
+ * in the given zone. Empty if not known
+ */
+ Optional<ApplicationRevision> currentRevision(Zone zone) {
+ Deployment currentDeployment = deployments().get(zone);
+ if (currentDeployment != null) // Already deployed in this zone: Use that revision
+ return Optional.of(currentDeployment.revision());
+ return Optional.empty();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (! (o instanceof Application)) return false;
+
+ Application that = (Application) o;
+
+ return id.equals(that.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return id.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return "application '" + id + "'";
+ }
+
+ private boolean isPermanent(Environment environment) {
+ if (environment == Environment.dev) return false;
+ if (environment == Environment.perf) return false;
+ if (environment == Environment.test) return false;
+ if (environment == Environment.staging) return false;
+ return true;
+ }
+
+}
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
new file mode 100644
index 00000000000..51bf530ed4a
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java
@@ -0,0 +1,541 @@
+// 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;
+
+import com.google.common.collect.ImmutableSet;
+import com.yahoo.component.Version;
+import com.yahoo.config.application.api.ValidationId;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.config.provision.Zone;
+import com.yahoo.vespa.curator.Lock;
+import com.yahoo.vespa.hosted.controller.api.ActivateResult;
+import com.yahoo.vespa.hosted.controller.api.ApplicationAlias;
+import com.yahoo.vespa.hosted.controller.api.InstanceEndpoints;
+import com.yahoo.vespa.hosted.controller.api.Tenant;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.DeployOptions;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.EndpointStatus;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.GitRevision;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.ScrewdriverBuildJob;
+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.configserver.ConfigServerClient;
+import com.yahoo.vespa.hosted.controller.api.integration.configserver.NoInstanceException;
+import com.yahoo.vespa.hosted.controller.api.integration.dns.NameService;
+import com.yahoo.vespa.hosted.controller.api.integration.dns.Record;
+import com.yahoo.vespa.hosted.controller.api.integration.dns.RecordId;
+import com.yahoo.vespa.hosted.controller.api.integration.routing.RoutingEndpoint;
+import com.yahoo.vespa.hosted.controller.api.integration.routing.RoutingGenerator;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ZmsClient;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ZmsClientFactory;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ZmsException;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.NToken;
+import com.yahoo.vespa.hosted.controller.api.rotation.Rotation;
+import com.yahoo.vespa.hosted.controller.application.ApplicationPackage;
+import com.yahoo.vespa.hosted.controller.application.ApplicationRevision;
+import com.yahoo.vespa.hosted.controller.application.Change;
+import com.yahoo.vespa.hosted.controller.application.Deployment;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobReport;
+import com.yahoo.vespa.hosted.controller.application.JobStatus;
+import com.yahoo.vespa.hosted.controller.application.SourceRevision;
+import com.yahoo.vespa.hosted.controller.deployment.DeploymentTrigger;
+import com.yahoo.vespa.hosted.controller.maintenance.DeploymentExpirer;
+import com.yahoo.vespa.hosted.controller.persistence.ControllerDb;
+import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
+import com.yahoo.vespa.hosted.rotation.RotationRepository;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.Clock;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+/**
+ * A singleton owned by the Controller which contains the methods and state for controlling applications.
+ *
+ * @author bratseth
+ */
+public class ApplicationController {
+
+ private static final Logger log = Logger.getLogger(ApplicationController.class.getName());
+
+ /** The controller owning this */
+ private final Controller controller;
+
+ /** For permanent storage */
+ private final ControllerDb db;
+ /** For working memory storage and sharing between controllers */
+ private final CuratorDb curator;
+
+ private final RotationRepository rotationRepository;
+ private final ZmsClientFactory zmsClientFactory;
+ private final NameService nameService;
+ private final ConfigServerClient configserverClient;
+ private final RoutingGenerator routingGenerator;
+ private final Clock clock;
+
+ private final DeploymentTrigger deploymentTrigger;
+
+ ApplicationController(Controller controller, ControllerDb db, CuratorDb curator,
+ RotationRepository rotationRepository,
+ ZmsClientFactory zmsClientFactory,
+ NameService nameService, ConfigServerClient configserverClient,
+ RoutingGenerator routingGenerator, Clock clock) {
+ this.controller = controller;
+ this.db = db;
+ this.curator = curator;
+ this.rotationRepository = rotationRepository;
+ this.zmsClientFactory = zmsClientFactory;
+ this.nameService = nameService;
+ this.configserverClient = configserverClient;
+ this.routingGenerator = routingGenerator;
+ this.clock = clock;
+
+ this.deploymentTrigger = new DeploymentTrigger(controller, curator, clock);
+
+ for (Application application : db.listApplications()) {
+ try (Lock lock = lock(application.id())) {
+ Optional<Application> optionalApplication = db.getApplication(application.id()); // re-get inside lock
+ if ( ! optionalApplication.isPresent()) continue; // was removed since listing; ok
+ store(optionalApplication.get(), lock); // re-write all applications to update storage format
+ }
+ }
+ }
+
+ /** Returns the application with the given id, or null if it is not present */
+ public Optional<Application> get(ApplicationId id) {
+ return db.getApplication(id);
+ }
+
+ /**
+ * Returns the application with the given id
+ *
+ * @throws IllegalArgumentException if it does not exist
+ */
+ public Application require(ApplicationId id) {
+ return get(id).orElseThrow(() -> new IllegalArgumentException(id + " not found"));
+ }
+
+ /** Returns a snapshot of all applications */
+ public List<Application> asList() {
+ return db.listApplications();
+ }
+
+ /** Returns all applications of a tenant */
+ public List<Application> asList(TenantName tenant) {
+ return db.listApplications(new TenantId(tenant.value()));
+ }
+
+ /**
+ * Set the rotations marked as 'global' either 'in' or 'out of' service.
+ *
+ * @return The list of endpoints successfully alertered
+ * @throws IOException if rotation status cannot be updated
+ */
+ public List<String> setGlobalRotationStatus(DeploymentId deploymentId, EndpointStatus status) throws IOException {
+ List<String> rotations = new ArrayList<>();
+ for (RoutingEndpoint endpoint : routingGenerator.endpoints(deploymentId)) {
+ if (endpoint.isGlobal()) {
+ configserverClient.setGlobalRotationStatus(deploymentId, endpoint.getEndpoint(), status);
+ rotations.add(endpoint.getEndpoint());
+ }
+ }
+ return rotations;
+ }
+
+ /**
+ * Get the endpoint status for rotations marked as 'global'
+ *
+ * @return The list of endpoints successfully alertered
+ * @throws IOException if global rotation status cannot be determined
+ */
+ public Map<String, EndpointStatus> getGlobalRotationStatus(DeploymentId deploymentId) throws IOException {
+ Map<String, EndpointStatus> result = new HashMap<>();
+ for (RoutingEndpoint endpoint : routingGenerator.endpoints(deploymentId)) {
+ if (endpoint.isGlobal()) {
+ EndpointStatus status = configserverClient.getGlobalRotationStatus(deploymentId, endpoint.getEndpoint());
+ result.put(endpoint.getEndpoint(), status);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Creates a new application for an existing tenant.
+ *
+ * @throws IllegalArgumentException if the application already exists
+ */
+ public Application createApplication(ApplicationId id, Optional<NToken> token) {
+ if ( ! (id.instance().value().equals("default") || id.instance().value().startsWith("default-pr"))) // TODO: Support instances properly
+ throw new UnsupportedOperationException("Only the instance names 'default' and names starting with 'default-pr' are supported at the moment");
+ try (Lock lock = lock(id)) {
+ if (get(id).isPresent())
+ throw new IllegalArgumentException("An application with id '" + id + "' already exists");
+
+ com.yahoo.vespa.hosted.controller.api.identifiers.ApplicationId.validate(id.application().value());
+
+ Optional<Tenant> tenant = controller.tenants().tenant(new TenantId(id.tenant().value()));
+ if ( ! tenant.isPresent())
+ throw new IllegalArgumentException("Could not create '" + id + "': This tenant does not exist");
+ if (get(id).isPresent())
+ throw new IllegalArgumentException("Could not create '" + id + "': Application already exists");
+ if (get(dashToUnderscore(id)).isPresent()) // VESPA-1945
+ throw new IllegalArgumentException("Could not create '" + id + "': Application " + dashToUnderscore(id) + " already exists");
+ if (tenant.get().isAthensTenant() && ! token.isPresent())
+ throw new IllegalArgumentException("Could not create '" + id + "': No NToken provided");
+ if (tenant.get().isAthensTenant()) {
+ ZmsClient zmsClient = zmsClientFactory.createClientWithAuthorizedServiceToken(token.get());
+ try {
+ zmsClient.deleteApplication(tenant.get().getAthensDomain().get(),
+ new com.yahoo.vespa.hosted.controller.api.identifiers.ApplicationId(id.application().value()));
+ }
+ catch (ZmsException ignored) {
+ }
+ zmsClient.addApplication(tenant.get().getAthensDomain().get(),
+ new com.yahoo.vespa.hosted.controller.api.identifiers.ApplicationId(id.application().value()));
+ }
+ Application application = new Application(id);
+ store(application, lock);
+ log.info("Created " + application);
+ return application;
+ }
+ }
+
+ /** Deploys an application. If the application does not exist it is created. */
+ // TODO: Get rid of the options arg
+ public ActivateResult deployApplication(ApplicationId applicationId, com.yahoo.config.provision.Zone zone,
+ ApplicationPackage applicationPackage, DeployOptions options) {
+ try (Lock lock = lock(applicationId)) {
+ // Determine what we are doing
+ Application application = get(applicationId).orElse(new Application(applicationId));
+ DeploymentJobs.JobType jobType = DeploymentJobs.JobType.from(controller.zoneRegistry().system(), zone);
+ Version version = decideVersion(application, zone, options);
+ ApplicationRevision revision = toApplicationPackageRevision(applicationPackage, options.screwdriverBuildJob);
+
+ // Ensure that the deploying change is tested
+ // FIXME: For now only for non-self-triggering applications - VESPA-8418
+ if (!application.deploymentJobs().isSelfTriggering() && !zone.environment().isManuallyDeployed() && !application.deploymentJobs().isDeployableTo(zone.environment(), application.deploying())) {
+ throw new IllegalArgumentException("Rejecting deployment of " + application + " to " + zone +
+ " as pending " + application.deploying().get() +
+ " is untested");
+ }
+
+ // Don't update/store applicationpackage information when deploying previous application package (initial staging step)
+ if(! options.deployCurrentVersion) {
+ // Add missing information to application
+ application = application.with(applicationPackage.deploymentSpec());
+ application = application.with(applicationPackage.validationOverrides());
+ if (options.screwdriverBuildJob.isPresent() && options.screwdriverBuildJob.get().screwdriverId != null)
+ application = application.withProjectId(options.screwdriverBuildJob.get().screwdriverId.value());
+ if (application.deploying().isPresent() && application.deploying().get() instanceof Change.ApplicationChange)
+ application = application.withDeploying(Optional.of(Change.ApplicationChange.of(revision)));
+ if (!triggeredWith(revision, application, jobType) && !zone.environment().isManuallyDeployed() && jobType != null) {
+ // Triggering information is used to store which changes were made or attempted
+ // - For self-triggered applications we don't have any trigger information, so we add it here.
+ // - For all applications, we don't have complete control over which revision is actually built,
+ // so we update it here with what we actually triggered if necessary
+ application = application.with(application.deploymentJobs().withTriggering(jobType, version, Optional.of(revision), clock.instant()));
+ }
+
+ store(application, lock); // store missing information even if we fail deployment below
+
+ // Delete zones not listed in DeploymentSpec, if allowed
+ // We do this at deployment time to be able to return a validation failure message when necessary
+ application = deleteRemovedDeployments(application);
+
+ // Clean up deployment jobs that are no longer referenced by deployment spec
+ application = deleteUnreferencedDeploymentJobs(application);
+ }
+
+ // Carry out deployment
+ DeploymentId deploymentId = new DeploymentId(applicationId, zone);
+ ApplicationRotation rotationInDns = registerRotationInDns(deploymentId, getOrAssignRotation(deploymentId,
+ applicationPackage));
+ options = withVersion(version, options);
+ ConfigServerClient.PreparedApplication preparedApplication =
+ configserverClient.prepare(deploymentId, options, rotationInDns.cnames(), rotationInDns.rotations(), applicationPackage.zippedContent());
+ preparedApplication.activate();
+ application = application.with(new Deployment(zone, revision, version, clock.instant()));
+ store(application, lock);
+
+ return new ActivateResult(new RevisionId(applicationPackage.hash()), preparedApplication.messages(), preparedApplication.prepareResponse());
+ }
+ }
+
+ private Version decideVersion(Application application, Zone zone, DeployOptions options) {
+ if (options.deployCurrentVersion)
+ return application.currentVersion(controller, zone);
+
+ if (application.deploymentJobs().isSelfTriggering()) // legacy mode: let the client decide
+ return options.vespaVersion.map(Version::new).orElse(controller.systemVersion());
+
+ if ( ! application.deploying().isPresent() && ! zone.environment().isManuallyDeployed())
+ throw new IllegalArgumentException("Rejecting deployment of " + application + " to " + zone +
+ " as a deployment is not currently expected");
+
+ return application.currentDeployVersion(controller, zone);
+ }
+
+ private Application deleteRemovedDeployments(Application application) {
+ List<Deployment> deploymentsToRemove = application.deployments().values().stream()
+ .filter(deployment -> deployment.zone().environment() == Environment.prod)
+ .filter(deployment -> ! application.deploymentSpec().includes(deployment.zone().environment(),
+ Optional.of(deployment.zone().region())))
+ .collect(Collectors.toList());
+
+ if (deploymentsToRemove.isEmpty()) return application;
+
+ if ( ! application.validationOverrides().allows(ValidationId.deploymentRemoval, clock.instant()))
+ throw new IllegalArgumentException(ValidationId.deploymentRemoval.value() + ": " + application +
+ " is deployed in " +
+ deploymentsToRemove.stream()
+ .map(deployment -> deployment.zone().region().value())
+ .collect(Collectors.joining(", ")) +
+ ", but does not include " +
+ (deploymentsToRemove.size() > 1 ? "these zones" : "this zone") +
+ " in deployment.xml");
+
+ Application applicationWithRemoval = application;
+ for (Deployment deployment : deploymentsToRemove)
+ applicationWithRemoval = deactivate(applicationWithRemoval, deployment, false);
+ return applicationWithRemoval;
+ }
+
+ private Application deleteUnreferencedDeploymentJobs(Application application) {
+ for (DeploymentJobs.JobType job : application.deploymentJobs().jobStatus().keySet()) {
+ if (!job.isProduction()) {
+ continue;
+ }
+ Optional<Zone> zone = job.zone(controller.system());
+ if (!zone.isPresent()) {
+ continue;
+ }
+ if (!application.deploymentSpec().includes(zone.get().environment(), zone.map(Zone::region))) {
+ application = application.withoutDeploymentJob(job);
+ }
+ }
+ return application;
+ }
+
+ private boolean triggeredWith(ApplicationRevision revision, Application application, DeploymentJobs.JobType jobType) {
+ if (jobType == null) return false;
+ JobStatus status = application.deploymentJobs().jobStatus().get(jobType);
+ if (status == null) return false;
+ if ( ! status.lastTriggered().isPresent()) return false;
+ JobStatus.JobRun triggered = status.lastTriggered().get();
+ if ( ! triggered.revision().isPresent()) return false;
+ return triggered.revision().get().equals(revision);
+ }
+
+ private DeployOptions withVersion(Version version, DeployOptions options) {
+ return new DeployOptions(options.screwdriverBuildJob,
+ Optional.of(version),
+ options.ignoreValidationErrors,
+ options.deployCurrentVersion);
+ }
+
+ private ApplicationRevision toApplicationPackageRevision(ApplicationPackage applicationPackage,
+ Optional<ScrewdriverBuildJob> screwDriverBuildJob) {
+ if ( ! screwDriverBuildJob.isPresent())
+ return ApplicationRevision.from(applicationPackage.hash());
+
+ GitRevision gitRevision = screwDriverBuildJob.get().gitRevision;
+ if (gitRevision.repository == null || gitRevision.branch == null || gitRevision.commit == null)
+ return ApplicationRevision.from(applicationPackage.hash());
+
+ return ApplicationRevision.from(applicationPackage.hash(), new SourceRevision(gitRevision.repository.id(),
+ gitRevision.branch.id(),
+ gitRevision.commit.id()));
+ }
+
+ private ApplicationRotation registerRotationInDns(DeploymentId deploymentId, ApplicationRotation applicationRotation) {
+ ApplicationAlias alias = new ApplicationAlias(deploymentId.applicationId());
+ if (applicationRotation.rotations().isEmpty()) return applicationRotation;
+
+ Rotation rotation = applicationRotation.rotations().iterator().next(); // at this time there should be only one rotation assigned
+ String endpointName = alias.toString();
+ try {
+ Optional<Record> record = nameService.findRecord(Record.Type.CNAME, rotation.rotationName);
+ if (!record.isPresent()) {
+ RecordId recordId = nameService.createCname(endpointName, rotation.rotationName);
+ log.info("Registered mapping with record ID " + recordId.id() + ": " +
+ endpointName + " -> " + rotation.rotationName);
+ }
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Failed to register CNAME", e);
+ }
+ return new ApplicationRotation(Collections.singleton(endpointName), Collections.singleton(rotation));
+ }
+
+ private ApplicationRotation getOrAssignRotation(DeploymentId deploymentId, ApplicationPackage applicationPackage) {
+ if (deploymentId.zone().environment().equals(Environment.prod)) {
+ return new ApplicationRotation(Collections.emptySet(),
+ rotationRepository.getOrAssignRotation(deploymentId.applicationId(),
+ applicationPackage.deploymentSpec()));
+ } else {
+ return new ApplicationRotation(Collections.emptySet(),
+ Collections.emptySet());
+ }
+ }
+
+ /** Returns the endpoints of the deployment, or empty if obtaining them failed */
+ public Optional<InstanceEndpoints> getDeploymentEndpoints(DeploymentId deploymentId) {
+ try {
+ List<RoutingEndpoint> endpoints = routingGenerator.endpoints(deploymentId);
+ List<URI> endPointUrls = new ArrayList<>();
+ for (RoutingEndpoint endpoint : endpoints) {
+ try {
+ endPointUrls.add(new URI(endpoint.getEndpoint()));
+ } catch (URISyntaxException e) {
+ throw new RuntimeException("Routing generator returned illegal url's", e);
+ }
+ }
+ return Optional.of(new InstanceEndpoints(endPointUrls));
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Failed to get endpoint information for " + deploymentId, e);
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Deletes the application with this id
+ *
+ * @return the deleted application, or null if it did not exist
+ * @throws IllegalArgumentException if the application has deployments or the caller is not authorized
+ */
+ public Application deleteApplication(ApplicationId id, Optional<NToken> token) {
+ try (Lock lock = lock(id)) {
+ Optional<Application> application = get(id);
+ if ( ! application.isPresent()) return null;
+ if ( ! application.get().deployments().isEmpty())
+ throw new IllegalArgumentException("Could not delete '" + application + "': It has active deployments");
+
+ Tenant tenant = controller.tenants().tenant(new TenantId(id.tenant().value())).get();
+ if (tenant.isAthensTenant() && ! token.isPresent())
+ throw new IllegalArgumentException("Could not delete '" + application + "': No NToken provided");
+
+ // NB: Next 2 lines should have been one transaction
+ if (tenant.isAthensTenant())
+ zmsClientFactory.createClientWithAuthorizedServiceToken(token.get())
+ .deleteApplication(tenant.getAthensDomain().get(), new com.yahoo.vespa.hosted.controller.api.identifiers.ApplicationId(id.application().value()));
+ db.deleteApplication(id);
+
+ log.info("Deleted " + application.get());
+ return application.get();
+ }
+ }
+
+ public void setJiraIssueId(ApplicationId id, Optional<String> jiraIssueId) {
+ try (Lock lock = lock(id)) {
+ get(id).ifPresent(application -> store(application.withJiraIssueId(jiraIssueId), lock));
+ }
+ }
+
+ /**
+ * Replace any previous version of this application by this instance
+ *
+ * @param application the application version to store
+ * @param lock the lock held on this application since before modification started
+ */
+ @SuppressWarnings("unused") // lock is part of the signature to remind people to acquire it, not needed internally
+ public void store(Application application, Lock lock) {
+ db.store(application);
+ }
+
+ public void notifyJobCompletion(JobReport report) {
+ if ( ! get(report.applicationId()).isPresent()) {
+ log.log(Level.WARNING, "Ignoring completion of job of project '" + report.projectId() +
+ "': Unknown application '" + report.applicationId() + "'");
+ return;
+ }
+ deploymentTrigger.triggerFromCompletion(report);
+ }
+
+ // TODO: Collapse this method and the next
+ public void restart(DeploymentId deploymentId) {
+ try {
+ configserverClient.restart(deploymentId, Optional.empty());
+ }
+ catch (NoInstanceException e) {
+ throw new IllegalArgumentException("Could not restart " + deploymentId + ": No such deployment");
+ }
+ }
+ public void restartHost(DeploymentId deploymentId, Hostname hostname) {
+ try {
+ configserverClient.restart(deploymentId, Optional.of(hostname));
+ }
+ catch (NoInstanceException e) {
+ throw new IllegalArgumentException("Could not restart " + deploymentId + ": No such deployment");
+ }
+ }
+
+ public Application deactivate(Application application, Deployment deployment, boolean requireThatDeploymentHasExpired) {
+ try (Lock lock = lock(application.id())) {
+ // TODO: ignore no application errors for config server client,
+ // only return such errors from sherpa client.
+ if (requireThatDeploymentHasExpired && ! DeploymentExpirer.hasExpired(controller.zoneRegistry(), deployment,
+ clock.instant()))
+ return application;
+
+ try {
+ configserverClient.deactivate(new DeploymentId(application.id(), deployment.zone()));
+ }
+ catch (NoInstanceException e) {
+ // ok; already gone
+ }
+ application = application.withoutDeploymentIn(deployment.zone());
+ store(application, lock);
+ return application;
+ }
+ }
+
+ public DeploymentTrigger deploymentTrigger() { return deploymentTrigger; }
+
+ private ApplicationId dashToUnderscore(ApplicationId id) {
+ return ApplicationId.from(id.tenant().value(),
+ id.application().value().replaceAll("-", "_"),
+ id.instance().value());
+ }
+
+ public ConfigServerClient configserverClient() { return configserverClient; }
+
+ /**
+ * Returns a lock which provides exclusive rights to changing this application.
+ * Any operation which stores an application need to first acquire this lock, then read, modify
+ * and store the application, and finally release (close) the lock.
+ */
+ public Lock lock(ApplicationId application) {
+ return curator.lock(application, Duration.ofMinutes(10));
+ }
+
+ private static final class ApplicationRotation {
+
+ private final ImmutableSet<String> cnames;
+ private final ImmutableSet<Rotation> rotations;
+
+ public ApplicationRotation(Set<String> cnames, Set<Rotation> rotations) {
+ this.cnames = ImmutableSet.copyOf(cnames);
+ this.rotations = ImmutableSet.copyOf(rotations);
+ }
+
+ public Set<String> cnames() { return cnames; }
+ public Set<Rotation> rotations() { return rotations; }
+
+ }
+
+}
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
new file mode 100644
index 00000000000..dcb54f13e4b
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
@@ -0,0 +1,273 @@
+// 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;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.inject.Inject;
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.component.Version;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.config.provision.RegionName;
+import com.yahoo.config.provision.SystemName;
+import com.yahoo.vespa.hosted.controller.api.identifiers.AthensDomain;
+import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.Property;
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+import com.yahoo.vespa.hosted.controller.api.integration.MetricsService;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.Athens;
+import com.yahoo.vespa.hosted.controller.api.integration.chef.Chef;
+import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServerClient;
+import com.yahoo.vespa.hosted.controller.api.integration.cost.ApplicationCost;
+import com.yahoo.vespa.hosted.controller.api.integration.cost.Cost;
+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.routing.GlobalRoutingService;
+import com.yahoo.vespa.hosted.controller.api.integration.routing.RotationStatus;
+import com.yahoo.vespa.hosted.controller.api.integration.routing.RoutingGenerator;
+import com.yahoo.vespa.hosted.controller.api.integration.zone.ZoneRegistry;
+import com.yahoo.vespa.hosted.controller.common.NotFoundCheckedException;
+import com.yahoo.vespa.hosted.controller.persistence.ControllerDb;
+import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
+import com.yahoo.vespa.hosted.controller.versions.VersionStatus;
+import com.yahoo.vespa.hosted.controller.versions.VespaVersion;
+import com.yahoo.vespa.hosted.rotation.RotationRepository;
+import com.yahoo.vespa.serviceview.bindings.ApplicationView;
+
+import java.net.URI;
+import java.time.Clock;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.logging.Logger;
+
+/**
+ * API to the controller. This contains (currently: should contain) the object model of everything the
+ * controller cares about, mainly tenants and applications.
+ *
+ * As the controller runtime and Controller object are singletons, this instance can read from the object model
+ * in memory. However, all changes to the object model must be persisted in the controller db.
+ *
+ * All the individual model objects reachable from the Controller are immutable.
+ *
+ * Access to the controller is multithread safe, provided the locking methods are
+ * used when accessing, modifying and storing objects provided by the controller.
+ *
+ * @author bratseth
+ */
+public class Controller extends AbstractComponent {
+
+ private static final Logger log = Logger.getLogger(Controller.class.getName());
+
+ private final CuratorDb curator;
+ private final ApplicationController applicationController;
+ private final TenantController tenantController;
+
+ /**
+ * Status of Vespa versions across the system.
+ * This is expensive to maintain so that is done periodically by a maintenance job
+ */
+ private final AtomicReference<VersionStatus> versionStatus;
+
+ private final Clock clock;
+
+ private final RotationRepository rotationRepository;
+ private final GitHub gitHub;
+ private final EntityService entityService;
+ private final GlobalRoutingService globalRoutingService;
+ private final ZoneRegistry zoneRegistry;
+ private final Cost cost;
+ private final ConfigServerClient configServerClient;
+ private final MetricsService metricsService;
+ private final Chef chefClient;
+ private final Athens athens;
+
+ /**
+ * Creates a controller
+ *
+ * @param db the db storing persistent state
+ * @param curator the curator instance storing working state shared between controller instances
+ */
+ @Inject
+ public Controller(ControllerDb db, CuratorDb curator, RotationRepository rotationRepository,
+ GitHub gitHub, Jira jiraClient, EntityService entityService,
+ GlobalRoutingService globalRoutingService,
+ ZoneRegistry zoneRegistry, Cost cost, ConfigServerClient configServerClient,
+ MetricsService metricsService, NameService nameService,
+ RoutingGenerator routingGenerator, Chef chefClient, Athens athens) {
+ this(db, curator, rotationRepository,
+ gitHub, jiraClient, entityService, globalRoutingService, zoneRegistry,
+ cost, configServerClient, metricsService, nameService, routingGenerator, chefClient,
+ Clock.systemUTC(), athens);
+ }
+
+ public Controller(ControllerDb db, CuratorDb curator, RotationRepository rotationRepository,
+ GitHub gitHub, Jira jiraClient, EntityService entityService,
+ GlobalRoutingService globalRoutingService,
+ ZoneRegistry zoneRegistry, Cost cost, ConfigServerClient configServerClient,
+ MetricsService metricsService, NameService nameService,
+ RoutingGenerator routingGenerator, Chef chefClient, Clock clock, Athens athens) {
+ Objects.requireNonNull(db, "Controller db cannot be null");
+ 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(globalRoutingService, "GlobalRoutingService cannot be null");
+ Objects.requireNonNull(zoneRegistry, "ZoneRegistry cannot be null");
+ Objects.requireNonNull(cost, "Cost cannot be null");
+ Objects.requireNonNull(configServerClient, "ConfigServerClient cannot be null");
+ Objects.requireNonNull(metricsService, "MetricsService cannot be null");
+ Objects.requireNonNull(nameService, "NameService cannot be null");
+ Objects.requireNonNull(routingGenerator, "RoutingGenerator cannot be null");
+ Objects.requireNonNull(chefClient, "ChefClient cannot be null");
+ Objects.requireNonNull(clock, "Clock cannot be null");
+ Objects.requireNonNull(athens, "Athens cannot be null");
+
+ this.rotationRepository = rotationRepository;
+ this.curator = curator;
+ this.gitHub = gitHub;
+ this.entityService = entityService;
+ this.globalRoutingService = globalRoutingService;
+ this.zoneRegistry = zoneRegistry;
+ this.cost = cost;
+ this.configServerClient = configServerClient;
+ this.metricsService = metricsService;
+ this.chefClient = chefClient;
+ this.clock = clock;
+ this.athens = athens;
+
+ applicationController = new ApplicationController(this, db, curator, rotationRepository, athens.zmsClientFactory(),
+ nameService, configServerClient, routingGenerator, clock);
+ tenantController = new TenantController(this, db, curator, entityService);
+ versionStatus = new AtomicReference<>(VersionStatus.empty());
+ }
+
+ /** Returns the instance controlling tenants */
+ public TenantController tenants() { return tenantController; }
+
+ /** Returns the instance controlling applications */
+ public ApplicationController applications() { return applicationController; }
+
+ public List<AthensDomain> getDomainList(String prefix) {
+ return athens.unauthorizedZmsClient().getDomainList(prefix);
+ }
+
+ public Athens athens() {
+ return athens;
+ }
+
+ /**
+ * Fetch list of all active OpsDB properties.
+ *
+ * @return Hashed map with the property ID as key and property name as value
+ */
+ public Map<PropertyId, Property> fetchPropertyList() {
+ return entityService.listProperties();
+ }
+
+ public Clock clock() { return clock; }
+
+ public ApplicationCost getApplicationCost(com.yahoo.config.provision.ApplicationId application,
+ com.yahoo.config.provision.Zone zone)
+ throws NotFoundCheckedException {
+ return cost.getApplicationCost(zone.environment(), zone.region(), application);
+ }
+
+ public URI getElkUri(Environment environment, RegionName region, DeploymentId deploymentId) {
+ return elkUrl(zoneRegistry.getLogServerUri(environment, region), deploymentId);
+ }
+
+ public List<URI> getConfigServerUris(Environment environment, RegionName region) {
+ return zoneRegistry.getConfigServerUris(environment, region);
+ }
+
+ public ZoneRegistry zoneRegistry() { return zoneRegistry; }
+
+ private URI elkUrl(Optional<URI> kibanaHost, DeploymentId deploymentId) {
+ String kibanaQuery = "/#/discover?_g=()&_a=(columns:!(_source)," +
+ "index:'logstash-*',interval:auto," +
+ "query:(query_string:(analyze_wildcard:!t,query:'" +
+ "HV-tenant:%22" + deploymentId.applicationId().tenant().value() + "%22%20" +
+ "AND%20HV-application:%22" + deploymentId.applicationId().application().value() + "%22%20" +
+ "AND%20HV-region:%22" + deploymentId.zone().region().value() + "%22%20" +
+ "AND%20HV-instance:%22" + deploymentId.applicationId().instance().value() + "%22%20" +
+ "AND%20HV-environment:%22" + deploymentId.zone().environment().value() + "%22'))," +
+ "sort:!('@timestamp',desc))";
+
+ URI kibanaPath = URI.create(kibanaQuery);
+ if (kibanaHost.isPresent()) {
+ return kibanaHost.get().resolve(kibanaPath);
+ } else {
+ return null;
+ }
+ }
+
+ public Set<URI> getRotationUris(ApplicationId id) {
+ return rotationRepository.getRotationUris(id);
+ }
+
+ public Map<String, RotationStatus> getHealthStatus(String hostname) {
+ return globalRoutingService.getHealthStatus(hostname);
+ }
+
+ // TODO: Model the response properly
+ public JsonNode waitForConfigConvergence(DeploymentId deploymentId, long timeout) {
+ return configServerClient.waitForConfigConverge(deploymentId, timeout);
+ }
+
+ public ApplicationView getApplicationView(String tenantName, String applicationName, String instanceName, String environment, String region) {
+ return configServerClient.getApplicationView(tenantName, applicationName, instanceName, environment, region);
+ }
+
+ // TODO: Model the response properly
+ public Map<?,?> getServiceApiResponse(String tenantName, String applicationName, String instanceName, String environment, String region, String serviceName, String restPath) {
+ return configServerClient.getServiceApiResponse(tenantName, applicationName, instanceName, environment, region, serviceName, restPath);
+ }
+
+ // TODO: Model the response properly
+ // TODO: What is this
+ public JsonNode grabLog(DeploymentId deploymentId) {
+ return configServerClient.grabLog(deploymentId);
+ }
+
+ public GitHub gitHub() { return gitHub; }
+
+ /** Replace the current version status by a new one */
+ public void updateVersionStatus(VersionStatus newStatus) {
+ VersionStatus currentStatus = versionStatus();
+ if (newStatus.systemVersion().isPresent() &&
+ ! newStatus.systemVersion().equals(currentStatus.systemVersion())) {
+ log.info("Changing system version from " + printableVersion(currentStatus.systemVersion()) +
+ " to " + printableVersion(newStatus.systemVersion()));
+ curator.writeSystemVersion(newStatus.systemVersion().get().versionNumber());
+ }
+
+ this.versionStatus.set(newStatus);
+ }
+
+ /** Returns the latest known version status. Calling this is free but the status may be slightly out of date. */
+ public VersionStatus versionStatus() { return versionStatus.get(); }
+
+ /** Returns the current system version: The controller should drive towards running all applications on this version */
+ public Version systemVersion() { return curator.readSystemVersion(); }
+
+ public MetricsService metricsService() { return metricsService; }
+
+ public SystemName system() {
+ return zoneRegistry.system();
+ }
+
+ public Chef chefClient() {
+ return chefClient;
+ }
+
+ 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/NotExistsException.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/NotExistsException.java
new file mode 100644
index 00000000000..6a47957f27f
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/NotExistsException.java
@@ -0,0 +1,32 @@
+// 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;
+
+import com.yahoo.vespa.hosted.controller.api.identifiers.Identifier;
+
+/**
+ * An exception which indicates that a requested resource does not exist.
+ *
+ * @author Tony Vaagenes
+ */
+public class NotExistsException extends IllegalArgumentException {
+
+ public NotExistsException(String message) {
+ super(message);
+ }
+
+ /**
+ * Example message: Tenant 'myId' does not exist.
+ *
+ * @param capitalizedType e.g. Tenant, Application
+ * @param id The id of the entity that didn't exist.
+ *
+ */
+ public NotExistsException(String capitalizedType, String id) {
+ super(String.format("%s '%s' does not exist", capitalizedType, id));
+ }
+
+ public NotExistsException(Identifier id) {
+ this(id.capitalizedType(), id.id());
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java
new file mode 100644
index 00000000000..fafd0b04dd2
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java
@@ -0,0 +1,238 @@
+// 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;
+
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.vespa.curator.Lock;
+import com.yahoo.vespa.hosted.controller.api.Tenant;
+import com.yahoo.vespa.hosted.controller.api.identifiers.ApplicationId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.AthensDomain;
+import com.yahoo.vespa.hosted.controller.api.identifiers.Property;
+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.identifiers.UserGroup;
+import com.yahoo.vespa.hosted.controller.api.identifiers.UserId;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.NToken;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ZmsClient;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ZmsClientFactory;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ZmsException;
+import com.yahoo.vespa.hosted.controller.api.integration.entity.EntityService;
+import com.yahoo.vespa.hosted.controller.persistence.ControllerDb;
+import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
+import com.yahoo.vespa.hosted.controller.persistence.PersistenceException;
+
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+/**
+ * A singleton owned by the Controller which contains the methods and state for controlling applications.
+ *
+ * @author bratseth
+ */
+public class TenantController {
+
+ private static final Logger log = Logger.getLogger(TenantController.class.getName());
+
+ /** The controller owning this */
+ private final Controller controller;
+
+ /** For permanent storage */
+ private final ControllerDb db;
+
+ /** For working memory storage and sharing between controllers */
+ private final CuratorDb curator;
+
+ private final ZmsClientFactory zmsClientFactory;
+ private final EntityService entityService;
+
+ public TenantController(Controller controller, ControllerDb db, CuratorDb curator, EntityService entityService) {
+ this.controller = controller;
+ this.db = db;
+ this.curator = curator;
+ this.zmsClientFactory = controller.athens().zmsClientFactory();
+ this.entityService = entityService;
+ }
+
+ public List<Tenant> asList() {
+ return db.listTenants();
+ }
+
+ public List<Tenant> asList(UserId user) {
+ Set<UserGroup> userGroups = entityService.getUserGroups(user);
+ Set<AthensDomain> userDomains = new HashSet<>(zmsClientFactory.createClientWithServicePrincipal()
+ .getTenantDomainsForUser(controller.athens().principalFrom(user)));
+
+ Predicate<Tenant> hasUsersGroup = (tenant) -> tenant.getUserGroup().isPresent() && userGroups.contains(tenant.getUserGroup().get());
+ Predicate<Tenant> hasUsersDomain = (tenant) -> tenant.getAthensDomain().isPresent() && userDomains.contains(tenant.getAthensDomain().get());
+ Predicate<Tenant> isUserTenant = (tenant) -> tenant.getId().equals(user.toTenantId());
+
+ return asList().stream()
+ .filter(t -> hasUsersGroup.test(t) || hasUsersDomain.test(t) || isUserTenant.test(t))
+ .collect(Collectors.toList());
+ }
+
+ public Tenant createUserTenant(String userName) {
+ TenantId userTenantId = new UserId(userName).toTenantId();
+ try (Lock lock = lock(userTenantId)) {
+ Tenant tenant = Tenant.createUserTenant(userTenantId);
+ internalCreateTenant(tenant, Optional.empty());
+ return tenant;
+ }
+ }
+
+ /** Creates an Athens or OpsDb tenant. */
+ // TODO: Rename to createAthensTenant and move creation here when opsDbTenant creation is removed */
+ public void addTenant(Tenant tenant, Optional<NToken> token) {
+ try (Lock lock = lock(tenant.getId())) {
+ internalCreateTenant(tenant, token);
+ }
+ }
+
+ private void internalCreateTenant(Tenant tenant, Optional<NToken> token) {
+ TenantId.validate(tenant.getId().id());
+ if (tenant(tenant.getId()).isPresent())
+ throw new IllegalArgumentException("Tenant '" + tenant.getId() + "' already exists");
+ if (tenant(dashToUnderscore(tenant.getId())).isPresent())
+ throw new IllegalArgumentException("Could not create " + tenant + ": Tenant " + dashToUnderscore(tenant.getId()) + " already exists");
+ if (tenant.isAthensTenant() && ! token.isPresent())
+ throw new IllegalArgumentException("Could not create " + tenant + ": No NToken provided");
+
+ if (tenant.isAthensTenant()) {
+ AthensDomain domain = tenant.getAthensDomain().get();
+ Optional<Tenant> existingTenantWithDomain = tenantHaving(domain);
+ if (existingTenantWithDomain.isPresent())
+ throw new IllegalArgumentException("Could not create " + tenant + ": The Athens domain '" + domain +
+ "' is already connected to " + existingTenantWithDomain.get());
+ ZmsClient zmsClient = zmsClientFactory.createClientWithAuthorizedServiceToken(token.get());
+ try { zmsClient.deleteTenant(domain); } catch (ZmsException ignored) { }
+ zmsClient.createTenant(domain);
+ }
+ db.createTenant(tenant);
+ log.info("Created " + tenant);
+ }
+
+ /** Returns the tenant having the given Athens domain, or empty if none */
+ private Optional<Tenant> tenantHaving(AthensDomain domain) {
+ return asList().stream().filter(Tenant::isAthensTenant)
+ .filter(t -> t.getAthensDomain().get().equals(domain))
+ .findAny();
+ }
+
+ public Optional<Tenant> tenant(TenantId id) {
+ try {
+ return db.getTenant(id);
+ } catch (PersistenceException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public void updateTenant(Tenant updatedTenant, Optional<NToken> token) {
+ try (Lock lock = lock(updatedTenant.getId())) {
+ if ( ! tenant(updatedTenant.getId()).isPresent())
+ throw new IllegalArgumentException("Could not update " + updatedTenant + ": Tenant does not exist");
+ if (updatedTenant.isAthensTenant() && ! token.isPresent())
+ throw new IllegalArgumentException("Could not update " + updatedTenant + ": No NToken provided");
+
+ updateAthensDomain(updatedTenant, token);
+ db.updateTenant(updatedTenant);
+ log.info("Updated " + updatedTenant);
+ } catch (PersistenceException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private void updateAthensDomain(Tenant updatedTenant, Optional<NToken> token) {
+ Tenant existingTenant = tenant(updatedTenant.getId()).get();
+ if ( ! existingTenant.isAthensTenant()) return;
+
+ AthensDomain existingDomain = existingTenant.getAthensDomain().get();
+ AthensDomain newDomain = updatedTenant.getAthensDomain().get();
+ if (existingDomain.equals(newDomain)) return;
+ Optional<Tenant> existingTenantWithNewDomain = tenantHaving(newDomain);
+ if (existingTenantWithNewDomain.isPresent())
+ throw new IllegalArgumentException("Could not set domain of " + updatedTenant + " to '" + newDomain +
+ "':" + existingTenantWithNewDomain.get() + " already has this domain");
+
+ ZmsClient zmsClient = zmsClientFactory.createClientWithAuthorizedServiceToken(token.get());
+ zmsClient.createTenant(newDomain);
+ List<Application> applications = controller.applications().asList(TenantName.from(existingTenant.getId().id()));
+ applications.forEach(a -> zmsClient.addApplication(newDomain, new com.yahoo.vespa.hosted.controller.api.identifiers.ApplicationId(a.id().application().value())));
+ applications.forEach(a -> zmsClient.deleteApplication(existingDomain, new com.yahoo.vespa.hosted.controller.api.identifiers.ApplicationId(a.id().application().value())));
+ zmsClient.deleteTenant(existingDomain);
+ log.info("Updated Athens domain for " + updatedTenant + " from " + existingDomain + " to " + newDomain);
+ }
+
+ public void deleteTenant(TenantId id, Optional<NToken> token) {
+ try (Lock lock = lock(id)) {
+ if ( ! tenant(id).isPresent())
+ throw new NotExistsException(id); // TODO: Change exception and message
+ if ( ! controller.applications().asList(TenantName.from(id.id())).isEmpty())
+ throw new IllegalArgumentException("Could not delete tenant '" + id + "': This tenant has active applications");
+
+ Tenant tenant = tenant(id).get();
+ if (tenant.isAthensTenant() && ! token.isPresent())
+ throw new IllegalArgumentException("Could not delete tenant '" + id + "': No NToken provided");
+
+ try {
+ db.deleteTenant(id);
+ } catch (PersistenceException e) { // TODO: Don't allow these to leak out
+ throw new RuntimeException(e);
+ }
+ if (tenant.isAthensTenant())
+ zmsClientFactory.createClientWithAuthorizedServiceToken(token.get()).deleteTenant(tenant.getAthensDomain().get());
+ log.info("Deleted " + tenant);
+ }
+ }
+
+ public Tenant migrateTenantToAthens(TenantId tenantId,
+ AthensDomain tenantDomain,
+ PropertyId propertyId,
+ Property property,
+ NToken nToken) {
+ try (Lock lock = lock(tenantId)) {
+ Tenant existing = tenant(tenantId).orElseThrow(() -> new NotExistsException(tenantId));
+ if (existing.isAthensTenant()) return existing; // nothing to do
+ if (tenantHaving(tenantDomain).isPresent())
+ throw new IllegalArgumentException("Could not migrate " + existing + " to " + tenantDomain + ": " +
+ "This domain is already used by " + tenantHaving(tenantDomain).get());
+ if ( ! existing.isOpsDbTenant())
+ throw new IllegalArgumentException("Could not migrate " + existing + " to " + tenantDomain + ": " +
+ "Tenant is not currently an OpsDb tenant");
+
+ ZmsClient zmsClient = zmsClientFactory.createClientWithAuthorizedServiceToken(nToken);
+ zmsClient.createTenant(tenantDomain);
+ List<Application> applications = controller.applications().asList(TenantName.from(existing.getId().id()));
+ applications.forEach(a -> {
+ ApplicationId applicationId = new ApplicationId(a.id().application().value());
+ zmsClient.addApplication(tenantDomain, applicationId);
+ });
+ db.deleteTenant(tenantId);
+ Tenant tenant = Tenant.createAthensTenant(tenantId, tenantDomain, property, Optional.of(propertyId));
+ db.createTenant(tenant);
+ log.info("Migrated " + existing + " to Athens using " + tenantDomain);
+ return tenant;
+ }
+ catch (PersistenceException e) {
+ throw new RuntimeException("Failed migrating " + tenantId + " to Athens", e);
+ }
+ }
+
+ private TenantId dashToUnderscore(TenantId id) {
+ return new TenantId(id.id().replaceAll("-", "_"));
+ }
+
+ /**
+ * Returns a lock which provides exclusive rights to changing this tenant.
+ * Any operation which stores a tenant need to first acquire this lock, then read, modify
+ * and store the tenant, and finally release (close) the lock.
+ */
+ private Lock lock(TenantId tenant) {
+ return curator.lock(tenant, Duration.ofMinutes(10));
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/ActivateResult.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/ActivateResult.java
new file mode 100644
index 00000000000..1fb6a4a8582
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/ActivateResult.java
@@ -0,0 +1,37 @@
+// 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;
+
+import com.yahoo.vespa.hosted.controller.api.identifiers.RevisionId;
+import com.yahoo.vespa.hosted.controller.api.integration.configserver.Log;
+import com.yahoo.vespa.hosted.controller.api.integration.configserver.PrepareResponse;
+
+import java.util.List;
+
+/**
+ * @author Oyvind Gronnesby
+ */
+public class ActivateResult {
+
+ private final RevisionId revisionId;
+ private final List<Log> messages;
+ private final PrepareResponse prepareResponse;
+
+ public ActivateResult(RevisionId revisionId, List<Log> messages, PrepareResponse prepareResponse) {
+ this.revisionId = revisionId;
+ this.messages = messages;
+ this.prepareResponse = prepareResponse;
+ }
+
+ public RevisionId getRevisionId() {
+ return revisionId;
+ }
+
+ public List<Log> getMessages() {
+ return messages;
+ }
+
+ public PrepareResponse getPrepareResponse() {
+ return prepareResponse;
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/ApplicationAlias.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/ApplicationAlias.java
new file mode 100644
index 00000000000..a9e144a3227
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/ApplicationAlias.java
@@ -0,0 +1,57 @@
+// 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;
+
+import com.yahoo.config.provision.ApplicationId;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+/**
+ * A DNS alias for an application endpoint.
+ *
+ * @author smorgrav
+ */
+public class ApplicationAlias {
+
+ private static final String dnsSuffix = "global.vespa.yahooapis.com";
+
+ private final ApplicationId applicationId;
+
+ public ApplicationAlias(ApplicationId applicationId) {
+ this.applicationId = applicationId;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("%s.%s.%s",
+ toDns(applicationId.application().value()),
+ toDns(applicationId.tenant().value()),
+ dnsSuffix);
+ }
+
+ private String toDns(String id) {
+ return id.replace('_', '-');
+ }
+
+ public URI toHttpUri() {
+ try {
+ return new URI("http://" + this + ":4080/");
+ } catch(URISyntaxException use) {
+ throw new RuntimeException("Illegal URI syntax");
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ ApplicationAlias that = (ApplicationAlias) o;
+
+ return applicationId.equals(that.applicationId);
+ }
+
+ @Override
+ public int hashCode() { return applicationId.hashCode(); }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/InstanceEndpoints.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/InstanceEndpoints.java
new file mode 100644
index 00000000000..b9ed439eb8b
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/InstanceEndpoints.java
@@ -0,0 +1,23 @@
+// 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;
+
+import java.net.URI;
+import java.util.List;
+
+/**
+ * @author Tony Vaagenes
+ */
+public class InstanceEndpoints {
+
+ private final List<URI> containerEndpoints;
+
+ public InstanceEndpoints(List<URI> containerEndpoints) {
+ this.containerEndpoints = containerEndpoints;
+ }
+
+ public List<URI> getContainerEndpoints() {
+ return containerEndpoints;
+ }
+}
+
+
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
new file mode 100644
index 00000000000..325c40c24c8
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/Tenant.java
@@ -0,0 +1,147 @@
+// 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;
+
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.TenantType;
+import com.yahoo.vespa.hosted.controller.api.identifiers.AthensDomain;
+import com.yahoo.vespa.hosted.controller.api.identifiers.Property;
+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.identifiers.UserGroup;
+
+import java.util.Optional;
+
+/**
+ * @author smorgrav
+ */
+// TODO: Move this and everything it owns to com.yahoo.hosted.controller.Tenant and com.yahoo.hosted.controller.tenant.*
+public class Tenant {
+
+ private final TenantId id;
+ private final Optional<UserGroup> userGroup;
+ private final Optional<Property> property;
+ private final Optional<AthensDomain> athensDomain;
+ private final Optional<PropertyId> propertyId;
+
+ // TODO: Use factory methods. They're down at the bottom!
+ public Tenant(TenantId id, Optional<UserGroup> userGroup, Optional<Property> property, Optional<AthensDomain> athensDomain) {
+ this(id, userGroup, property, athensDomain, Optional.empty());
+ }
+
+ public Tenant(TenantId id, Optional<UserGroup> userGroup, Optional<Property> property, Optional<AthensDomain> athensDomain, 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(!athensDomain.isPresent(), "User tenant '%s' cannot have an athens domain.", id);
+ } else if (athensDomain.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(athensDomain.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(!athensDomain.isPresent(), "OpsDb tenant '%s' cannot have an athens domain.", id);
+ }
+ this.id = id;
+ this.userGroup = userGroup;
+ this.property = property;
+ this.athensDomain = athensDomain;
+ this.propertyId = propertyId; // TODO: Check validity after TODO@14. OpsDb tenants have this set in Sherpa, while athens tenants do not.
+ }
+
+ public boolean isAthensTenant() { return athensDomain.isPresent(); }
+ public boolean isOpsDbTenant() { return userGroup.isPresent();}
+
+ public TenantType tenantType() {
+ if (athensDomain.isPresent()) {
+ return TenantType.ATHENS;
+ } else if (id.isUser()) {
+ return TenantType.USER;
+ } else {
+ return TenantType.OPSDB;
+ }
+ }
+
+ public TenantId getId() {
+ return id;
+ }
+
+ public Optional<UserGroup> getUserGroup() {
+ return userGroup;
+ }
+
+ /** OpsDB property name of the tenant, or Optional.empty() if none is stored. */
+ public Optional<Property> getProperty() {
+ return property;
+ }
+
+ /** OpsDB property ID of the tenant. Not (yet) required, so returns Optional.empty() if none is stored. */
+ public Optional<PropertyId> getPropertyId() {
+ return propertyId;
+ }
+
+ public Optional<AthensDomain> getAthensDomain() {
+ return athensDomain;
+ }
+
+ private void require(boolean statement, String message, TenantId id) {
+ if (!statement) throw new IllegalArgumentException(String.format(message, id));
+ }
+
+ public static Tenant createAthensTenant(TenantId id, AthensDomain athensDomain, Property property, Optional<PropertyId> propertyId) {
+ if (id.isUser()) {
+ throw new IllegalArgumentException("Invalid id for non-user tenant: " + id);
+ }
+ return new Tenant(id, Optional.empty(), Optional.ofNullable(property),
+ Optional.ofNullable(athensDomain), propertyId);
+ }
+
+ public static Tenant createOpsDbTenant(TenantId id, UserGroup userGroup, Property property, Optional<PropertyId> propertyId) {
+ if (id.isUser()) {
+ throw new IllegalArgumentException("Invalid id for non-user tenant: " + id);
+ }
+ return new Tenant(id, Optional.ofNullable(userGroup), Optional.ofNullable(property), Optional.empty(), propertyId);
+ }
+
+ public static Tenant createOpsDbTenant(TenantId id, UserGroup userGroup, Property property) {
+ return createOpsDbTenant(id, userGroup, property, Optional.empty());
+ }
+
+ public static Tenant createUserTenant(TenantId id) {
+ if (!id.isUser()) {
+ throw new IllegalArgumentException("Invalid id for user tenant: " + id);
+ }
+ return new Tenant(id, Optional.empty(), Optional.empty(), Optional.empty());
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ Tenant tenant = (Tenant) o;
+
+ if (!id.equals(tenant.id)) return false;
+ if (!userGroup.equals(tenant.userGroup)) return false;
+ if (!property.equals(tenant.property)) return false;
+ if (!athensDomain.equals(tenant.athensDomain)) return false;
+ if (!propertyId.equals(tenant.propertyId)) return false;
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = id.hashCode();
+ result = 31 * result + userGroup.hashCode();
+ result = 31 * result + property.hashCode();
+ result = 31 * result + athensDomain.hashCode();
+ result = 31 * result + propertyId.hashCode();
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "tenant '" + id + "'";
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/package-info.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/package-info.java
new file mode 100644
index 00000000000..4b405f55e10
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/api/package-info.java
@@ -0,0 +1,8 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * @author Tony Vaagenes
+ */
+@ExportPackage
+package com.yahoo.vespa.hosted.controller.api;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ApplicationList.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ApplicationList.java
new file mode 100644
index 00000000000..3fcd285e0fc
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ApplicationList.java
@@ -0,0 +1,200 @@
+// 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.application;
+
+import com.google.common.collect.ImmutableList;
+import com.yahoo.component.Version;
+import com.yahoo.config.application.api.DeploymentSpec.UpgradePolicy;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.config.provision.RegionName;
+import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.ApplicationController;
+
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * A list of applications which can be filtered in various ways.
+ *
+ * @author bratseth
+ */
+public class ApplicationList {
+
+ private final ImmutableList<Application> list;
+
+ private ApplicationList(List<Application> applications) {
+ this.list = ImmutableList.copyOf(applications);
+ }
+
+ // ----------------------------------- Factories
+
+ public static ApplicationList from(List<Application> applications) {
+ return new ApplicationList(applications);
+ }
+
+ public static ApplicationList from(List<ApplicationId> ids, ApplicationController applications) {
+ return listOf(ids.stream().map(applications::require));
+ }
+
+ // ----------------------------------- Accessors
+
+ /** Returns the applications in this as an immutable list */
+ public List<Application> asList() { return list; }
+
+ public boolean isEmpty() { return list.isEmpty(); }
+
+ public int size() { return list.size(); }
+
+ // ----------------------------------- Filters
+
+ /** Returns the subset of applications which is currently upgrading to the given version */
+ public ApplicationList upgradingTo(Version version) {
+ return listOf(list.stream().filter(application -> isUpgradingTo(version, application)));
+ }
+
+ /** Returns the subset of applications which is currently upgrading to a version lower than the given version */
+ public ApplicationList upgradingToLowerThan(Version version) {
+ return listOf(list.stream().filter(application -> isUpgradingToLowerThan(version, application)));
+ }
+
+ /** Returns the subset of applications which is currently not upgrading to the given version */
+ public ApplicationList notUpgradingTo(Version version) {
+ return listOf(list.stream().filter(application -> ! isUpgradingTo(version, application)));
+ }
+
+ /** Returns the subset of applications which is currently not deploying a new application revision */
+ public ApplicationList notDeployingApplication() {
+ return listOf(list.stream().filter(application -> ! isDeployingApplicationChange(application)));
+ }
+
+ /** Returns the subset of applications which currently does not have any failing jobs */
+ public ApplicationList notFailing() {
+ return listOf(list.stream().filter(application -> ! application.deploymentJobs().hasFailures()));
+ }
+
+ /** Returns the subset of applications which currently does not have any failing jobs on the given version */
+ public ApplicationList notFailingOn(Version version) {
+ return listOf(list.stream().filter(application -> ! failingOn(version, application)));
+ }
+
+ /** Returns the subset of applications which have one or more deployment jobs failing for the current change */
+ public ApplicationList hasDeploymentFailures() {
+ return listOf(list.stream().filter(application -> application.deploying().isPresent() && application.deploymentJobs().failingOn(application.deploying().get())));
+ }
+
+ /** Returns the subset of applications which have at least one deployment */
+ public ApplicationList hasDeployment() {
+ return listOf(list.stream().filter(a -> !a.deployments().isEmpty()));
+ }
+
+ /** Returns the subset of applications that are currently deploying a change */
+ public ApplicationList isDeploying() {
+ return listOf(list.stream().filter(application -> application.deploying().isPresent()));
+ }
+
+ /** Returns the subset of applications which started failing after the given instant */
+ public ApplicationList startedFailingAfter(Instant instant) {
+ return listOf(list.stream().filter(application -> application.deploymentJobs().failingSince().isAfter(instant)));
+ }
+
+ /** Returns the subset of applications which has the given upgrade policy */
+ public ApplicationList with(UpgradePolicy policy) {
+ return listOf(list.stream().filter(a -> a.deploymentSpec().upgradePolicy() == policy));
+ }
+
+ /** Returns the subset of applications which does not have the given upgrade policy */
+ public ApplicationList without(UpgradePolicy policy) {
+ return listOf(list.stream().filter(a -> a.deploymentSpec().upgradePolicy() != policy));
+ }
+
+ /** Returns the subset of applications which have at least one deployment on a lower version than the given one */
+ public ApplicationList onLowerVersionThan(Version version) {
+ return listOf(list.stream()
+ .filter(a -> a.deployments().values().stream().anyMatch(d -> d.version().isBefore(version))));
+ }
+
+ /**
+ * Returns the subset of applications which are not pull requests:
+ * Pull requests changes the application instance name to default-pr[pull-request-number]
+ */
+ public ApplicationList notPullRequest() {
+ return listOf(list.stream().filter(a -> ! a.id().instance().value().startsWith("default-pr")));
+ }
+
+ // ----------------------------------- Sorting
+
+ /**
+ * Returns this list sorted by increasing deployed version.
+ * If multiple versions are deployed the oldest is used.
+ * Applications without any deployments are ordered first.
+ */
+ public ApplicationList byIncreasingDeployedVersion() {
+ return listOf(list.stream().sorted(Comparator.comparing(application -> application.deployedVersion().orElse(Version.emptyVersion))));
+ }
+
+ /** Returns the subset of applications which currently do not have any job in progress for the given change */
+ public ApplicationList notRunningJobFor(Change.VersionChange change) {
+ return listOf(list.stream().filter(a -> !hasRunningJob(a, change)));
+ }
+
+ /** Returns the subset of applications which currently do not have any job in progress */
+ public ApplicationList notRunningJob() {
+ return listOf(list.stream().filter(a -> !a.deploymentJobs().inProgress()));
+ }
+
+ /** Returns the subset of applications which has a job that started running before the given instant */
+ public ApplicationList jobRunningSince(Instant instant) {
+ return listOf(list.stream().filter(a -> a.deploymentJobs().runningSince()
+ .map(at -> at.isBefore(instant))
+ .orElse(false)));
+ }
+
+ /** Returns the subset of applications which deploys to given environment and region */
+ public ApplicationList deploysTo(Environment environment, RegionName region) {
+ return listOf(list.stream().filter(a -> a.deploymentSpec().includes(environment, Optional.of(region))));
+ }
+
+ // ----------------------------------- Internal helpers
+
+ private static boolean isUpgradingTo(Version version, Application application) {
+ if ( ! (application.deploying().isPresent()) ) return false;
+ if ( ! (application.deploying().get() instanceof Change.VersionChange) ) return false;
+ return ((Change.VersionChange)application.deploying().get()).version().equals(version);
+ }
+
+ private static boolean isUpgradingToLowerThan(Version version, Application application) {
+ if ( ! application.deploying().isPresent()) return false;
+ if ( ! (application.deploying().get() instanceof Change.VersionChange) ) return false;
+ return ((Change.VersionChange)application.deploying().get()).version().isBefore(version);
+ }
+
+ private static boolean isDeployingApplicationChange(Application application) {
+ if ( ! application.deploying().isPresent()) return false;
+ return application.deploying().get() instanceof Change.ApplicationChange;
+ }
+
+ private static boolean failingOn(Version version, Application application) {
+ for (JobStatus jobStatus : application.deploymentJobs().jobStatus().values())
+ if ( ! jobStatus.isSuccess() && jobStatus.lastCompleted().get().version().equals(version)) return true;
+ return false;
+ }
+
+ private static boolean hasRunningJob(Application application, Change.VersionChange change) {
+ return application.deploymentJobs().jobStatus().values().stream()
+ .filter(JobStatus::inProgress)
+ .filter(jobStatus -> jobStatus.lastTriggered().isPresent())
+ .map(jobStatus -> jobStatus.lastTriggered().get())
+ .anyMatch(jobRun -> jobRun.version().equals(change.version()));
+ }
+
+ /** Convenience converter from a stream to an ApplicationList */
+ private static ApplicationList listOf(Stream<Application> applications) {
+ ImmutableList.Builder<Application> b = new ImmutableList.Builder<>();
+ applications.forEach(b::add);
+ return new ApplicationList(b.build());
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ApplicationPackage.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ApplicationPackage.java
new file mode 100644
index 00000000000..6df8e901653
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ApplicationPackage.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.application;
+
+import com.yahoo.config.application.api.DeploymentSpec;
+import com.yahoo.config.application.api.ValidationOverrides;
+import org.apache.commons.codec.digest.DigestUtils;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * A representation of the content of an application package.
+ * Only the deployment.xml content can be accessed as anything other than compressed data.
+ * A package is identified by a hash of the content.
+ *
+ * This is immutable.
+ *
+ * @author bratseth
+ */
+public class ApplicationPackage {
+
+ private final String contentHash;
+ private final byte[] zippedContent;
+ private final DeploymentSpec deploymentSpec;
+ private final ValidationOverrides validationOverrides;
+
+ /**
+ * Creates an application package from its zipped content.
+ * This <b>assigns ownership</b> of the given byte array to this class:
+ * it must not be further changed by the caller.
+ */
+ public ApplicationPackage(byte[] zippedContent) {
+ Objects.requireNonNull(zippedContent, "The application package content cannot be null");
+ this.contentHash = DigestUtils.shaHex(zippedContent);
+ this.zippedContent = zippedContent;
+ this.deploymentSpec = extractFile("deployment.xml", zippedContent).map(DeploymentSpec::fromXml).orElse(DeploymentSpec.empty);
+ this.validationOverrides = extractFile("validation-overrides.xml", zippedContent).map(ValidationOverrides::fromXml).orElse(ValidationOverrides.empty);
+ }
+
+ /** Returns a hash of the content of this package */
+ public String hash() { return contentHash; }
+
+ /** Returns the content of this package. The content <b>must not</b> be modified. */
+ public byte[] zippedContent() { return zippedContent; }
+
+ /**
+ * Returns the deployment spec from the deployment.xml file of the package content.
+ * This is the DeploymentSpec.empty instance if this package does not contain a deployment.xml file.
+ */
+ public DeploymentSpec deploymentSpec() { return deploymentSpec; }
+
+ /**
+ * Returns the validation overrides from the validation-overrides.xml file of the package content.
+ * This is the ValidationOverrides.empty instance if this package does not contain a validation-overrides.xml file.
+ */
+ public ValidationOverrides validationOverrides() { return validationOverrides; }
+
+ private static Optional<Reader> extractFile(String fileName, byte[] zippedContent) {
+ try (ByteArrayInputStream stream = new ByteArrayInputStream(zippedContent)) {
+ ZipStreamReader reader = new ZipStreamReader(stream);
+ for (ZipStreamReader.ZipEntryWithContent entry : reader.entries())
+ if (entry.zipEntry().getName().equals(fileName) || entry.zipEntry().getName().equals("application/" + fileName)) // TODO: Remove application/ directory support
+ return Optional.of(new InputStreamReader(new ByteArrayInputStream(entry.content())));
+ return Optional.empty();
+ }
+ catch (IOException e) {
+ throw new IllegalArgumentException("Exception reading application package", e);
+ }
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ApplicationRevision.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ApplicationRevision.java
new file mode 100644
index 00000000000..1b875f28715
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ApplicationRevision.java
@@ -0,0 +1,60 @@
+// 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.application;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * An identifier of a particular revision (exact content) of an application package,
+ * optionally with information about the source of the package revision.
+ *
+ * @author bratseth
+ */
+public class ApplicationRevision {
+
+ private final String applicationPackageHash;
+
+ private final Optional<SourceRevision> source;
+
+ private ApplicationRevision(String applicationPackageHash, Optional<SourceRevision> source) {
+ Objects.requireNonNull(applicationPackageHash, "applicationPackageHash cannot be null");
+ this.applicationPackageHash = applicationPackageHash;
+ this.source = source;
+ }
+
+ /** Create an application package revision where there is no information about its source */
+ public static ApplicationRevision from(String applicationPackageHash) {
+ return new ApplicationRevision(applicationPackageHash, Optional.empty());
+ }
+
+ /** Create an application package revision with a source */
+ public static ApplicationRevision from(String applicationPackageHash, SourceRevision source) {
+ return new ApplicationRevision(applicationPackageHash, Optional.of(source));
+ }
+
+ /** Returns a unique, content-based identifier of an application package (a hash of the content) */
+ public String id() { return applicationPackageHash; }
+
+ /**
+ * Returns information about the source of this revision, or empty if the source is not know/defined
+ * (which is the case for command-line deployment from developers, but never for deployment jobs)
+ */
+ public Optional<SourceRevision> source() { return source; }
+
+ @Override
+ public int hashCode() { return applicationPackageHash.hashCode(); }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) return true;
+ if ( ! (other instanceof ApplicationRevision)) return false;
+ return this.applicationPackageHash.equals(((ApplicationRevision)other).applicationPackageHash);
+ }
+
+ @Override
+ public String toString() {
+ return "Application package revision '" + applicationPackageHash + "'" +
+ (source.isPresent() ? " with " + source.get() : "");
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Change.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Change.java
new file mode 100644
index 00000000000..596cbbebd45
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Change.java
@@ -0,0 +1,90 @@
+// 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.application;
+
+import com.yahoo.component.Version;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * A change to an application
+ *
+ * @author bratseth
+ */
+public abstract class Change {
+
+ /** A change to the application package revision of an application */
+ public static class ApplicationChange extends Change {
+
+ private final Optional<ApplicationRevision> revision;
+
+ private ApplicationChange(Optional<ApplicationRevision> revision) {
+ Objects.requireNonNull(revision, "revision cannot be null");
+ this.revision = revision;
+ }
+
+ /** The revision this changes to, or empty if not known yet */
+ public Optional<ApplicationRevision> revision() { return revision; }
+
+ @Override
+ public int hashCode() { return revision.hashCode(); }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) return true;
+ if ( ! (other instanceof ApplicationChange)) return false;
+ return ((ApplicationChange)other).revision.equals(this.revision);
+ }
+
+ /**
+ * Creates an application change which we don't know anything about.
+ * We are notified that a change has occurred by completion of the component job
+ * but do not get to know about what the change is until a subsequent deployment
+ * happens.
+ */
+ public static ApplicationChange unknown() {
+ return new ApplicationChange(Optional.empty());
+ }
+
+ public static ApplicationChange of(ApplicationRevision revision) {
+ return new ApplicationChange(Optional.of(revision));
+ }
+
+ @Override
+ public String toString() {
+ return "application change to " + revision.map(ApplicationRevision::toString).orElse("an unknown revision");
+ }
+
+ }
+
+ /** A change to the Vespa version running an application */
+ public static class VersionChange extends Change {
+
+ private final Version version;
+
+ public VersionChange(Version version) {
+ Objects.requireNonNull(version, "version cannot be null");
+ this.version = version;
+ }
+
+ /** The Vespa version this changes to */
+ public Version version() { return version; }
+
+ @Override
+ public int hashCode() { return version.hashCode(); }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) return true;
+ if ( ! (other instanceof VersionChange)) return false;
+ return ((VersionChange)other).version.equals(this.version);
+ }
+
+ @Override
+ public String toString() {
+ return "version change to " + version;
+ }
+
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Deployment.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Deployment.java
new file mode 100644
index 00000000000..75e0f82cdcf
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Deployment.java
@@ -0,0 +1,50 @@
+// 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.application;
+
+import com.yahoo.component.Version;
+import com.yahoo.config.provision.Zone;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * A deployment of an application in a particular zone.
+ *
+ * @author bratseth
+ */
+public class Deployment {
+
+ private final Zone zone;
+ private final ApplicationRevision revision;
+ private final Version version;
+ private final Instant deployTime;
+
+ public Deployment(Zone zone, ApplicationRevision revision, Version version, Instant deployTime) {
+ Objects.requireNonNull(zone, "zone cannot be null");
+ Objects.requireNonNull(revision, "revision cannot be null");
+ Objects.requireNonNull(version, "version cannot be null");
+ Objects.requireNonNull(deployTime, "deployTime cannot be null");
+ this.zone = zone;
+ this.revision = revision;
+ this.version = version;
+ this.deployTime = deployTime;
+ }
+
+ /** Returns the zone this was deployed to */
+ public Zone zone() { return zone; }
+
+ /** Returns the revision of the application which was deployed */
+ public ApplicationRevision revision() { return revision; }
+
+ /** Returns the Vespa version which was deployed */
+ public Version version() { return version; }
+
+ /** Returns the time this was deployed */
+ public Instant at() { return deployTime; }
+
+ @Override
+ public String toString() {
+ return "deployment to " + zone + " of " + revision + " on version " + version + " at " + deployTime;
+ }
+
+}
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
new file mode 100644
index 00000000000..d9256f94086
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentJobs.java
@@ -0,0 +1,333 @@
+// 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.application;
+
+import com.google.common.collect.ImmutableMap;
+import com.yahoo.component.Version;
+import com.yahoo.config.application.api.DeploymentSpec;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.Environment;
+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 java.time.Instant;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * Information about which deployment jobs an application should run and their current status.
+ * This is immutable.
+ *
+ * @author bratseth
+ */
+public class DeploymentJobs {
+
+ private final Optional<Long> projectId;
+ private final ImmutableMap<JobType, JobStatus> status;
+ private final Optional<String> jiraIssueId;
+ private final boolean selfTriggering; // TODO: Remove this when no projects are self-triggering.
+
+ /** Creates an empty set of deployment jobs */
+ public DeploymentJobs(long projectId) {
+ this(Optional.of(projectId), ImmutableMap.of(), Optional.empty(),true);
+ }
+
+ public DeploymentJobs(Optional<Long> projectId, Collection<JobStatus> jobStatusEntries, Optional<String> jiraIssueId, boolean selfTriggering) {
+ this(projectId, asMap(jobStatusEntries), jiraIssueId, selfTriggering);
+ }
+
+ private DeploymentJobs(Optional<Long> projectId, Map<JobType, JobStatus> status, Optional<String> jiraIssueId, boolean selfTriggering) {
+ Objects.requireNonNull(projectId, "projectId cannot be null");
+ Objects.requireNonNull(status, "status cannot be null");
+ Objects.requireNonNull(jiraIssueId, "jiraIssueId cannot be null");
+ this.projectId = projectId;
+ this.status = ImmutableMap.copyOf(status);
+ this.jiraIssueId = jiraIssueId;
+ this.selfTriggering = selfTriggering;
+ }
+
+ private static Map<JobType, JobStatus> asMap(Collection<JobStatus> jobStatusEntries) {
+ ImmutableMap.Builder<JobType, JobStatus> b = new ImmutableMap.Builder<>();
+ for (JobStatus jobStatusEntry : jobStatusEntries)
+ b.put(jobStatusEntry.type(), jobStatusEntry);
+ return b.build();
+ }
+
+ /** Return a new instance with the given completion */
+ public DeploymentJobs withCompletion(JobReport report, Instant notificationTime, Controller controller) {
+ Map<JobType, JobStatus> status = new LinkedHashMap<>(this.status);
+ status.compute(report.jobType(), (type, job) -> {
+ if (job == null) job = JobStatus.initial(report.jobType());
+ return job.withCompletion(report.jobError(), notificationTime, controller);
+ });
+ return new DeploymentJobs(Optional.of(report.projectId()), status, jiraIssueId, report.selfTriggering());
+ }
+
+ public DeploymentJobs withTriggering(DeploymentJobs.JobType jobType,
+ Version version,
+ Optional<ApplicationRevision> revision,
+ Instant triggerTime) {
+ Map<JobType, JobStatus> status = new LinkedHashMap<>(this.status);
+ status.compute(jobType, (type, job) -> {
+ if (job == null) job = JobStatus.initial(jobType);
+ return job.withTriggering(version, revision, triggerTime);
+ });
+ return new DeploymentJobs(projectId, status, jiraIssueId, selfTriggering);
+ }
+
+ public DeploymentJobs withProjectId(long projectId) {
+ return new DeploymentJobs(Optional.of(projectId), status, jiraIssueId, selfTriggering);
+ }
+
+ public DeploymentJobs withJiraIssueId(Optional<String> jiraIssueId) {
+ return new DeploymentJobs(projectId, status, jiraIssueId, selfTriggering);
+ }
+
+ public DeploymentJobs without(JobType job) {
+ Map<JobType, JobStatus> status = new HashMap<>(this.status);
+ status.remove(job);
+ return new DeploymentJobs(projectId, status, jiraIssueId, selfTriggering);
+ }
+
+ public DeploymentJobs asSelfTriggering(boolean selfTriggering) {
+ return new DeploymentJobs(projectId, status, jiraIssueId, selfTriggering);
+ }
+
+ /** Returns an immutable map of the status entries in this */
+ public Map<JobType, JobStatus> jobStatus() { return status; }
+
+ /** Returns whether this application's deployment jobs trigger each other, and should be left alone, or not. */
+ public boolean isSelfTriggering() { return selfTriggering; }
+
+ /** Returns whether this has some job status which is not a success */
+ public boolean hasFailures() {
+ return status.values().stream().anyMatch(jobStatus -> ! jobStatus.isSuccess());
+ }
+
+ /** Returns whether any job is currently in progress */
+ public boolean inProgress() {
+ return status.values().stream().anyMatch(JobStatus::inProgress);
+ }
+
+ /** Returns whether any job is failing for the given change */
+ public boolean failingOn(Change change) {
+ return status.values().stream().anyMatch(jobStatus -> !jobStatus.isSuccess() && jobStatus.lastCompletedFor(change));
+ }
+
+ /** Returns whether change can be deployed to the given environment */
+ public boolean isDeployableTo(Environment environment, Optional<Change> change) {
+ if (environment == null || !change.isPresent()) {
+ return true;
+ }
+ if (environment == Environment.staging) {
+ return isSuccessful(JobType.systemTest, change.get());
+ } else if (environment == Environment.prod) {
+ return isSuccessful(JobType.stagingTest, change.get());
+ }
+ return true; // other environments do not have any preconditions
+ }
+
+ /** Returns the oldest failingSince time of the jobs of this, or null if none are failing */
+ public Instant failingSince() {
+ Instant failingSince = null;
+ for (JobStatus jobStatus : jobStatus().values()) {
+ if (jobStatus.isSuccess()) continue;
+ if (failingSince == null || failingSince.isAfter(jobStatus.firstFailing().get().at()))
+ failingSince = jobStatus.firstFailing().get().at();
+ }
+ return failingSince;
+ }
+
+ /** Returns the time at which the oldest running job started */
+ public Optional<Instant> runningSince() {
+ return jobStatus().values().stream()
+ .filter(JobStatus::inProgress)
+ .sorted(Comparator.comparing(jobStatus -> jobStatus.lastTriggered().get().at()))
+ .map(jobStatus -> jobStatus.lastTriggered().get().at())
+ .findFirst();
+ }
+
+ /**
+ * Returns the id of the Screwdriver project running these deployment jobs
+ * - or empty when this is not known or does not exist.
+ * It is not known until the jobs have run once and reported back to the controller.
+ */
+ public Optional<Long> projectId() { return projectId; }
+
+ public Optional<String> jiraIssueId() { return jiraIssueId; }
+
+ private boolean isSuccessful(JobType jobType, Change change) {
+ return Optional.ofNullable(jobStatus().get(jobType))
+ .filter(JobStatus::isSuccess)
+ .filter(status -> status.lastCompletedFor(change))
+ .isPresent();
+ }
+
+ /** Job types that exist in the build system */
+ public enum JobType {
+
+ component("component"),
+ systemTest("system-test", zone(SystemName.cd, "test", "cd-us-central-1"), zone("test", "us-east-1")),
+ stagingTest("staging-test", zone(SystemName.cd, "staging", "cd-us-central-1"), zone("staging", "us-east-3")),
+ productionCorpUsEast1("production-corp-us-east-1", zone("prod", "corp-us-east-1")),
+ productionUsEast3("production-us-east-3", zone("prod", "us-east-3")),
+ productionUsWest1("production-us-west-1", zone("prod", "us-west-1")),
+ productionUsCentral1("production-us-central-1", zone("prod", "us-central-1")),
+ productionApNortheast1("production-ap-northeast-1", zone("prod", "ap-northeast-1")),
+ productionApNortheast2("production-ap-northeast-2", zone("prod", "ap-northeast-2")),
+ productionApSoutheast1("production-ap-southeast-1", zone("prod", "ap-southeast-1")),
+ productionEuWest1("production-eu-west-1", zone("prod", "eu-west-1")),
+ productionCdUsCentral1("production-cd-us-central-1", zone(SystemName.cd, "prod", "cd-us-central-1")),
+ productionCdUsCentral2("production-cd-us-central-2", zone(SystemName.cd, "prod", "cd-us-central-2"));
+
+ private final String id;
+ private final Map<SystemName, Zone> zones;
+
+ JobType(String id, Zone... zone) {
+ this.id = id;
+ Map<SystemName, Zone> zones = new HashMap<>();
+ for (Zone z : zone) {
+ if (zones.containsKey(z.system())) {
+ throw new IllegalArgumentException("A job can only map to a single zone per system");
+ }
+ zones.put(z.system(), z);
+ }
+ this.zones = Collections.unmodifiableMap(zones);
+ }
+
+ public String id() { return id; }
+
+ /** Returns the zone for this job in the given system, or empty if this job does not have a zone */
+ public Optional<Zone> zone(SystemName system) {
+ return Optional.ofNullable(zones.get(system));
+ }
+
+ /** Returns whether this is a production job */
+ public boolean isProduction() { return environment() == Environment.prod; }
+
+ /** Returns the environment of this job type, or null if it does not have an environment */
+ public Environment environment() {
+ switch (this) {
+ case component: return null;
+ case systemTest: return Environment.test;
+ case stagingTest: return Environment.staging;
+ default: return Environment.prod;
+ }
+ }
+
+ /** Returns the region of this job type, or null if it does not have a region */
+ public RegionName region(SystemName system) {
+ return zone(system).map(Zone::region).orElse(null);
+ }
+
+ public static JobType fromId(String id) {
+ switch (id) {
+ case "component" : return component;
+ case "system-test" : return systemTest;
+ case "staging-test" : return stagingTest;
+ case "production-corp-us-east-1" : return productionCorpUsEast1;
+ case "production-us-east-3" : return productionUsEast3;
+ case "production-us-west-1" : return productionUsWest1;
+ case "production-us-central-1" : return productionUsCentral1;
+ case "production-ap-northeast-1" : return productionApNortheast1;
+ case "production-ap-northeast-2" : return productionApNortheast2;
+ case "production-ap-southeast-1" : return productionApSoutheast1;
+ case "production-eu-west-1" : return productionEuWest1;
+ case "production-cd-us-central-1" : return productionCdUsCentral1;
+ case "production-cd-us-central-2" : return productionCdUsCentral2;
+ default : throw new IllegalArgumentException("Unknown job id '" + id + "'");
+ }
+ }
+
+ /** Returns the job type for the given zone, or null if none */
+ public static JobType from(SystemName system, com.yahoo.config.provision.Zone zone) {
+ for (JobType job : values()) {
+ Optional<com.yahoo.config.provision.Zone> jobZone = job.zone(system);
+ if (jobZone.isPresent() && jobZone.get().equals(zone))
+ return job;
+ }
+ return null;
+ }
+
+ /** Returns the job job type for the given environment and region or null if none */
+ public static JobType from(SystemName system, Environment environment, RegionName region) {
+ switch (environment) {
+ case test: return systemTest;
+ case staging: return stagingTest;
+ }
+ return from(system, new com.yahoo.config.provision.Zone(environment, region));
+ }
+
+ /** Returns the trigger order to use according to deployment spec */
+ public static List<JobType> triggerOrder(SystemName system, DeploymentSpec deploymentSpec) {
+ return deploymentSpec.zones().stream()
+ .map(declaredZone -> JobType.from(system, declaredZone.environment(),
+ declaredZone.region().orElse(null)))
+ .collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList));
+ }
+
+ private static Zone zone(SystemName system, String environment, String region) {
+ return new Zone(system, Environment.from(environment), RegionName.from(region));
+ }
+
+ private static Zone zone(String environment, String region) {
+ return new Zone(Environment.from(environment), RegionName.from(region));
+ }
+ }
+
+ /** A job report. This class is immutable. */
+ public static class JobReport {
+
+ private final ApplicationId applicationId;
+ private final JobType jobType;
+ private final long projectId;
+ private final long buildNumber;
+ private final Optional<JobError> jobError;
+ private final boolean selfTriggering;
+ private final boolean gitChanges;
+
+ public JobReport(ApplicationId applicationId, JobType jobType, long projectId, long buildNumber, Optional<JobError> jobError, boolean selfTriggering, boolean gitChanges) {
+ Objects.requireNonNull(applicationId, "ApplicationId can not be null.");
+ Objects.requireNonNull(jobType, "JobType can not be null.");
+
+ this.applicationId = applicationId;
+ this.projectId = projectId;
+ this.jobType = jobType;
+ this.buildNumber = buildNumber;
+ this.jobError = jobError;
+ this.selfTriggering = selfTriggering;
+ this.gitChanges = gitChanges;
+ }
+
+ public ApplicationId applicationId() { return applicationId; }
+ public JobType jobType() { return jobType; }
+ public long projectId() { return projectId; }
+ public long buildNumber() { return buildNumber; }
+ public boolean success() { return !jobError.isPresent(); }
+ public Optional<JobError> jobError() { return jobError; }
+ public boolean selfTriggering() { return selfTriggering; }
+ public boolean gitChanges() { return gitChanges; }
+
+ }
+
+ public enum JobError {
+ unknown,
+ outOfCapacity;
+
+ public static Optional<JobError> from(boolean success) {
+ return Optional.of(success)
+ .filter(b -> !b)
+ .map(ignored -> unknown);
+ }
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/JobStatus.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/JobStatus.java
new file mode 100644
index 00000000000..a30998d8517
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/JobStatus.java
@@ -0,0 +1,209 @@
+// 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.application;
+
+import com.yahoo.component.Version;
+import com.yahoo.vespa.hosted.controller.Controller;
+
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * The last known build status of a particular deployment job for a particular application.
+ * This is immutable.
+ *
+ * @author bratseth
+ */
+public class JobStatus {
+
+ private final DeploymentJobs.JobType type;
+
+ private final Optional<JobRun> lastTriggered;
+ private final Optional<JobRun> lastCompleted;
+ private final Optional<JobRun> firstFailing;
+ private final Optional<JobRun> lastSuccess;
+
+ private final Optional<DeploymentJobs.JobError> jobError;
+
+ /**
+ * Used by the persistence layer (only) to create a complete JobStatus instance.
+ * Other creation should be by using initial- and with- methods.
+ */
+ public JobStatus(DeploymentJobs.JobType type, Optional<DeploymentJobs.JobError> jobError,
+ Optional<JobRun> lastTriggered, Optional<JobRun> lastCompleted,
+ Optional<JobRun> firstFailing, Optional<JobRun> lastSuccess) {
+ Objects.requireNonNull(type, "jobType cannot be null");
+ Objects.requireNonNull(jobError, "jobError cannot be null");
+ Objects.requireNonNull(lastTriggered, "lastTriggered cannot be null");
+ Objects.requireNonNull(lastCompleted, "lastCompleted cannot be null");
+ Objects.requireNonNull(firstFailing, "firstFailing cannot be null");
+ Objects.requireNonNull(lastSuccess, "lastSuccess cannot be null");
+
+ this.type = type;
+ this.jobError = jobError;
+ this.lastTriggered = lastTriggered;
+ this.lastCompleted = lastCompleted;
+ this.firstFailing = firstFailing;
+ this.lastSuccess = lastSuccess;
+ }
+
+ /** Returns an empty job status */
+ public static JobStatus initial(DeploymentJobs.JobType type) {
+ return new JobStatus(type, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty());
+ }
+
+ public JobStatus withTriggering(Version version, Optional<ApplicationRevision> revision, Instant triggerTime) {
+ return new JobStatus(type, jobError, Optional.of(new JobRun(version, revision, triggerTime)),
+ lastCompleted, firstFailing, lastSuccess);
+ }
+
+ public JobStatus withCompletion(Optional<DeploymentJobs.JobError> jobError, Instant completionTime, Controller controller) {
+ Version version;
+ Optional<ApplicationRevision> revision;
+ if (type == DeploymentJobs.JobType.component) { // not triggered by us
+ version = controller.systemVersion();
+ revision = Optional.empty();
+ }
+ else if (! lastTriggered.isPresent()) {
+ throw new IllegalStateException("Got notified about completion of " + this +
+ ", but that has not been triggered nor deployed");
+
+ }
+ else {
+ version = lastTriggered.get().version();
+ revision = lastTriggered.get().revision();
+ }
+
+ JobRun thisCompletion = new JobRun(version, revision, completionTime);
+
+ Optional<JobRun> firstFailing = this.firstFailing;
+ if (jobError.isPresent() && ! this.firstFailing.isPresent())
+ firstFailing = Optional.of(thisCompletion);
+
+ Optional<JobRun> lastSuccess = this.lastSuccess;
+ if ( ! jobError.isPresent()) {
+ lastSuccess = Optional.of(thisCompletion);
+ firstFailing = Optional.empty();
+ }
+
+ return new JobStatus(type, jobError, lastTriggered, Optional.of(thisCompletion), firstFailing, lastSuccess);
+ }
+
+ public DeploymentJobs.JobType type() { return type; }
+
+ /** Returns true unless this job last completed with a failure */
+ public boolean isSuccess() { return ! jobError.isPresent(); }
+
+ /** The error of the last completion, or empty if the last run succeeded */
+ public Optional<DeploymentJobs.JobError> jobError() { return jobError; }
+
+ /** Returns true if job is in progress */
+ public boolean inProgress() {
+ if (!lastTriggered().isPresent()) {
+ return false;
+ }
+ if (!lastCompleted().isPresent()) {
+ return true;
+ }
+ return lastTriggered().get().at().isAfter(lastCompleted().get().at());
+ }
+
+ /**
+ * Returns the last triggering of this job, or empty if the controller has never triggered it
+ * and not seen a deployment for it
+ */
+ public Optional<JobRun> lastTriggered() { return lastTriggered; }
+
+ /** Returns the last completion of this job (whether failing or succeeding), or empty if it never completed */
+ public Optional<JobRun> lastCompleted() { return lastCompleted; }
+
+ /** Returns the run when this started failing, or empty if it is not currently failing */
+ public Optional<JobRun> firstFailing() { return firstFailing; }
+
+ /** Returns the run when this last succeeded, or empty if it has never succeeded */
+ public Optional<JobRun> lastSuccess() { return lastSuccess; }
+
+ /** Returns whether the job last completed for the given change */
+ public boolean lastCompletedFor(Change change) {
+ if (change instanceof Change.ApplicationChange) {
+ Change.ApplicationChange applicationChange = (Change.ApplicationChange) change;
+ return lastCompleted().isPresent() && lastCompleted().get().revision().equals(applicationChange.revision());
+ } else if (change instanceof Change.VersionChange) {
+ Change.VersionChange versionChange = (Change.VersionChange) change;
+ return lastCompleted().isPresent() && lastCompleted().get().version().equals(versionChange.version());
+ }
+ throw new IllegalArgumentException("Unexpected change: " + change.getClass());
+ }
+
+ @Override
+ public String toString() {
+ return "job status of " + type + "[ " +
+ "last triggered: " + lastTriggered.map(JobRun::toString).orElse("(never)") +
+ ", last completed: " + lastCompleted.map(JobRun::toString).orElse("(never)") +
+ ", first failing: " + firstFailing.map(JobRun::toString).orElse("(not failing)") +
+ ", lastSuccess: " + lastSuccess.map(JobRun::toString).orElse("(never)") + "]";
+ }
+
+ @Override
+ public int hashCode() { return Objects.hash(type, jobError, lastTriggered, lastCompleted, firstFailing, lastSuccess); }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if ( ! ( o instanceof JobStatus)) return false;
+ JobStatus other = (JobStatus)o;
+ return Objects.equals(type, other.type) &&
+ Objects.equals(jobError, other.jobError) &&
+ Objects.equals(lastTriggered, other.lastTriggered) &&
+ Objects.equals(lastCompleted, other.lastCompleted) &&
+ Objects.equals(firstFailing, other.firstFailing) &&
+ Objects.equals(lastSuccess, other.lastSuccess);
+ }
+
+ /** Information about a particular triggering or completion of a run of a job. This is immutable. */
+ public static class JobRun {
+
+ private final Version version;
+ private final Optional<ApplicationRevision> revision;
+ private final Instant at;
+
+ public JobRun(Version version, Optional<ApplicationRevision> revision, Instant at) {
+ Objects.requireNonNull(version, "version cannot be null");
+ Objects.requireNonNull(revision, "revision cannot be null");
+ Objects.requireNonNull(at, "at cannot be null");
+ this.version = version;
+ this.revision = revision;
+ this.at = at;
+ }
+
+ /** The Vespa version used on this run */
+ public Version version() { return version; }
+
+ /** The application revision used for this run, or empty when not known */
+ public Optional<ApplicationRevision> revision() { return revision; }
+
+ /** The time if this triggering or completion */
+ public Instant at() { return at; }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(version ,revision, at);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if ( ! (o instanceof JobRun)) return false;
+ JobRun other = (JobRun)o;
+ if ( ! Objects.equals(other.version, this.version)) return false;
+ if ( ! Objects.equals(this.revision, other.revision)) return false;
+ if ( ! Objects.equals(this.at, other.at)) return false;
+ return true;
+ }
+
+ @Override
+ public String toString() { return "job run of version " + version + " " + revision + " at " + at; }
+
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/SourceRevision.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/SourceRevision.java
new file mode 100644
index 00000000000..9c10e0dc153
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/SourceRevision.java
@@ -0,0 +1,48 @@
+// 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.application;
+
+import java.util.Objects;
+
+/**
+ * A revision in a source repository
+ *
+ * @author bratseth
+ */
+public class SourceRevision {
+
+ private final String repository;
+ private final String branch;
+ private final String commit;
+
+ public SourceRevision(String repository, String branch, String commit) {
+ Objects.requireNonNull(repository, "repository cannot be null");
+ Objects.requireNonNull(branch, "branch cannot be null");
+ Objects.requireNonNull(commit, "commit cannot be null");
+ this.repository = repository;
+ this.branch = branch;
+ this.commit = commit;
+ }
+
+ public String repository() { return repository; }
+ public String branch() { return branch; }
+ public String commit() { return commit; }
+
+ @Override
+ public int hashCode() { return Objects.hash(repository, branch, commit); }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if ( ! (o instanceof SourceRevision)) return false;
+
+ SourceRevision other = (SourceRevision)o;
+ return this.repository.equals(other.repository) &&
+ this.branch.equals(other.branch) &&
+ this.commit.equals(other.commit);
+ }
+
+ @Override
+ public String toString() { return "source revision of repository '" + repository +
+ "', branch '" + branch + "' with commit '" + commit + "'"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ZipStreamReader.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ZipStreamReader.java
new file mode 100644
index 00000000000..69c846f2562
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/ZipStreamReader.java
@@ -0,0 +1,63 @@
+// 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.application;
+
+import com.google.common.collect.ImmutableList;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+/**
+ * @author bratseth
+ */
+public class ZipStreamReader {
+
+ private final ImmutableList<ZipEntryWithContent> entries;
+
+ public ZipStreamReader(InputStream input) {
+ try (ZipInputStream zipInput = new ZipInputStream(input)) {
+ ImmutableList.Builder<ZipEntryWithContent> builder = new ImmutableList.Builder<>();
+ ZipEntry zipEntry;
+ while (null != (zipEntry = zipInput.getNextEntry()))
+ builder.add(new ZipEntryWithContent(zipEntry, readContent(zipInput)));
+ entries = builder.build();
+ }
+ catch (IOException e) {
+ throw new IllegalArgumentException("IO error reading zip content", e);
+ }
+ }
+
+ private byte[] readContent(ZipInputStream zipInput) {
+ try (ByteArrayOutputStream bis = new ByteArrayOutputStream()) {
+ byte[] buffer = new byte[2048];
+ int read;
+ while ( -1 != (read = zipInput.read(buffer)))
+ bis.write(buffer, 0, read);
+ return bis.toByteArray();
+ }
+ catch (IOException e) {
+ throw new IllegalArgumentException("Failed reading from zipped content", e);
+ }
+ }
+
+ public List<ZipEntryWithContent> entries() { return entries; }
+
+ public static class ZipEntryWithContent {
+
+ private final ZipEntry zipEntry;
+ private final byte[] content;
+
+ public ZipEntryWithContent(ZipEntry zipEntry, byte[] content) {
+ this.zipEntry = zipEntry;
+ this.content = content;
+ }
+
+ public ZipEntry zipEntry() { return zipEntry; }
+ public byte[] content() { return content; }
+
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/package-info.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/package-info.java
new file mode 100644
index 00000000000..4dbce299b5d
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/package-info.java
@@ -0,0 +1,10 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Core application model
+ *
+ * @author bratseth
+ */
+@ExportPackage
+package com.yahoo.vespa.hosted.controller.application;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/concurrent/Lock.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/concurrent/Lock.java
new file mode 100644
index 00000000000..df80fafd388
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/concurrent/Lock.java
@@ -0,0 +1,24 @@
+// 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.concurrent;
+
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * An acquired lock which is released on close
+ *
+ * @author bratseth
+ */
+public final class Lock implements AutoCloseable {
+
+ private final ReentrantLock wrappedLock;
+
+ Lock(ReentrantLock wrappedLock) {
+ this.wrappedLock = wrappedLock;
+ }
+
+ /** Releases this lock */
+ public void close() {
+ wrappedLock.unlock();
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/concurrent/Locks.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/concurrent/Locks.java
new file mode 100644
index 00000000000..6168812203a
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/concurrent/Locks.java
@@ -0,0 +1,55 @@
+// 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.concurrent;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Holds a map of locks indexed on keys of a given type.
+ * This is suitable in cases where exclusive access should be granted to any one of a set of keyed objects and
+ * there is a finite collection of keyed objects.
+ *
+ * The returned locks are reentrant (i.e the owning thread may call lock multiple times) and auto-closable.
+ *
+ * Typical use is
+ * <code>
+ * try (Lock lock = locks.lock(id)) {
+ * exclusive use of the object with key id
+ * }
+ * </code>
+ *
+ * @author bratseth
+ */
+public class Locks<TYPE> {
+
+ private final Map<TYPE, ReentrantLock> locks = new ConcurrentHashMap<>();
+
+ private final long timeoutMs;
+
+ public Locks(int timeout, TimeUnit timeoutUnit) {
+ timeoutMs = timeoutUnit.toMillis(timeout);
+ }
+
+ /**
+ * Locks key. This will block until the key is acquired.
+ * Users of this <b>must</b> close any lock acquired.
+ *
+ * @param key the key to lock
+ * @return the acquired lock
+ * @throws TimeoutException if the lock could not be acquired within the timeout
+ */
+ public Lock lock(TYPE key) {
+ try {
+ ReentrantLock lock = locks.computeIfAbsent(key, k -> new ReentrantLock(true));
+ boolean acquired = lock.tryLock(timeoutMs, TimeUnit.MILLISECONDS);
+ if ( ! acquired)
+ throw new TimeoutException("Timed out waiting for the lock to " + key);
+ return new Lock(lock);
+ } catch (InterruptedException e) {
+ throw new RuntimeException("Interrupted while waiting for lock of " + key);
+ }
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/concurrent/TimeoutException.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/concurrent/TimeoutException.java
new file mode 100644
index 00000000000..260761fa6ac
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/concurrent/TimeoutException.java
@@ -0,0 +1,15 @@
+// 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.concurrent;
+
+/**
+ * Throws on timeout
+ *
+ * @author bratseth
+ */
+public class TimeoutException extends RuntimeException {
+
+ public TimeoutException(String message) {
+ super(message);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/BuildSystem.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/BuildSystem.java
new file mode 100644
index 00000000000..15b3ef7fb83
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/BuildSystem.java
@@ -0,0 +1,34 @@
+// 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.deployment;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.vespa.hosted.controller.api.integration.BuildService.BuildJob;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobType;
+
+import java.util.List;
+
+/**
+ * @author jvenstad
+ * @author mpolden
+ */
+public interface BuildSystem {
+
+ /**
+ * Add a job for the given application to the build system
+ *
+ * @param application the application owning the job
+ * @param jobType the job type to be queued
+ * @param first whether the job should be added to the front of the queue
+ */
+ void addJob(ApplicationId application, JobType jobType, boolean first);
+
+ /** Remove and return a list of jobs which should be run now */
+ List<BuildJob> takeJobsToRun();
+
+ /** Get a list of all jobs currently waiting to run */
+ List<BuildJob> jobs();
+
+ /** Removes all queued jobs for the given application */
+ void removeJobs(ApplicationId applicationId);
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/DeploymentTrigger.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/DeploymentTrigger.java
new file mode 100644
index 00000000000..2bc219dde62
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/DeploymentTrigger.java
@@ -0,0 +1,368 @@
+// 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.deployment;
+
+import com.yahoo.config.application.api.DeploymentSpec;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.Zone;
+import com.yahoo.vespa.curator.Lock;
+import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.ApplicationController;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.application.Change;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobError;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobReport;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobType;
+import com.yahoo.vespa.hosted.controller.application.JobStatus;
+import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.logging.Logger;
+
+/**
+ * Responsible for scheduling deployment jobs in a build system and keeping
+ * Application.deploying() in sync with what is scheduled.
+ *
+ * This class is multithread safe.
+ *
+ * @author bratseth
+ */
+public class DeploymentTrigger {
+
+ private final static Logger log = Logger.getLogger(DeploymentTrigger.class.getName());
+
+ private final Controller controller;
+ private final Clock clock;
+ private final BuildSystem buildSystem;
+
+ public DeploymentTrigger(Controller controller, CuratorDb curator, Clock clock) {
+ Objects.requireNonNull(controller,"controller cannot be null");
+ Objects.requireNonNull(clock,"clock cannot be null");
+ this.controller = controller;
+ this.clock = clock;
+ this.buildSystem = new PolledBuildSystem(controller, curator);
+ }
+
+ //--- Start of methods which triggers deployment jobs -------------------------
+
+ /**
+ * Called each time a job completes (successfully or not) to cause triggering of one or more follow-up jobs
+ * (which may possibly the same job once over).
+ *
+ * @param report information about the job that just completed
+ */
+ public void triggerFromCompletion(JobReport report) {
+ try (Lock lock = applications().lock(report.applicationId())) {
+ Application application = applications().require(report.applicationId());
+ application = application.withJobCompletion(report, clock.instant(), controller);
+
+ // Handle successful first and last job
+ if (isFirstJob(report.jobType()) && report.success()) { // the first job tells us that a change occurred
+ if (application.deploying().isPresent() && ! application.deploymentJobs().hasFailures()) { // postpone until the current deployment is done
+ applications().store(application.withOutstandingChange(true), lock);
+ return;
+ }
+ else { // start a new change deployment
+ application = application.withDeploying(Optional.of(Change.ApplicationChange.unknown()));
+ }
+ } else if (isLastJob(report.jobType(), application) && report.success()) {
+ application = application.withDeploying(Optional.empty());
+ }
+
+ // Trigger next
+ if (report.success())
+ application = trigger(nextAfter(report.jobType(), application), application, report.jobType() + " completed successfully", lock);
+ else if (isCapacityConstrained(report.jobType()) && shouldRetryOnOutOfCapacity(application, report.jobType()))
+ application = trigger(report.jobType(), application, true, "Retrying due to out of capacity", lock);
+ else if (shouldRetryNow(application))
+ application = trigger(report.jobType(), application, "Retrying as job just started failing", lock);
+
+ applications().store(application, lock);
+ }
+ }
+
+ /**
+ * Called periodically to cause triggering of jobs in the background
+ */
+ public void triggerFailing(ApplicationId applicationId) {
+ try (Lock lock = applications().lock(applicationId)) {
+ Application application = applications().require(applicationId);
+ if (shouldRetryFromBeginning(application)) {
+ // failed for a long time: Discard existing change and restart from the component job
+ application = application.withDeploying(Optional.empty());
+ application = trigger(JobType.component, application, "Retrying failing deployment from beginning", lock);
+ applications().store(application, lock);
+ } else {
+ // retry the failed job (with backoff)
+ for (JobType jobType : JobType.triggerOrder(controller.system(), application.deploymentSpec())) { // retry the *first* failing job
+ JobStatus jobStatus = application.deploymentJobs().jobStatus().get(jobType);
+ if (isFailing(jobStatus)) {
+ if (shouldRetryNow(jobStatus)) {
+ application = trigger(jobType, application, "Retrying failing job", lock);
+ applications().store(application, lock);
+ }
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ /** Triggers jobs that have been delayed according to deployment spec */
+ public void triggerDelayed() {
+ for (Application application : applications().asList()) {
+ if ( ! application.deploying().isPresent() ) continue;
+ if (application.deploymentJobs().hasFailures()) continue;
+ if (application.deploymentJobs().inProgress()) continue;
+
+ Optional<JobStatus> lastSuccessfulJob = application.deploymentJobs().jobStatus().values()
+ .stream()
+ .filter(j -> j.lastSuccess().isPresent())
+ .sorted(Comparator.<JobStatus, Instant>comparing(j -> j.lastSuccess().get().at()).reversed())
+ .findFirst();
+ if ( ! lastSuccessfulJob.isPresent() ) continue;
+
+ // Trigger next
+ try (Lock lock = applications().lock(application.id())) {
+ application = applications().require(application.id());
+ application = trigger(nextAfter(lastSuccessfulJob.get().type(), application), application,
+ "Delayed by deployment spec", lock);
+ applications().store(application, lock);
+ }
+ }
+ }
+
+ /**
+ * Triggers a change of this application
+ *
+ * @param applicationId the application to trigger
+ * @throws IllegalArgumentException if this application already have an ongoing change
+ */
+ public void triggerChange(ApplicationId applicationId, Change change) {
+ try (Lock lock = applications().lock(applicationId)) {
+ Application application = applications().require(applicationId);
+ if (application.deploying().isPresent() && ! application.deploymentJobs().hasFailures())
+ throw new IllegalArgumentException("Could not upgrade " + application + ": A change is already in progress");
+ application = application.withDeploying(Optional.of(change));
+ if (change instanceof Change.ApplicationChange)
+ application = application.withOutstandingChange(false);
+ application = trigger(JobType.systemTest, application, "Deploying change", lock);
+ applications().store(application, lock);
+ }
+ }
+
+ /**
+ * Cancels any ongoing upgrade of the given application
+ *
+ * @param applicationId the application to trigger
+ */
+ public void cancelChange(ApplicationId applicationId) {
+ try (Lock lock = applications().lock(applicationId)) {
+ Application application = applications().require(applicationId);
+ buildSystem.removeJobs(application.id());
+ application = application.withDeploying(Optional.empty());
+ applications().store(application, lock);
+ }
+ }
+
+ //--- End of methods which triggers deployment jobs ----------------------------
+
+ private ApplicationController applications() { return controller.applications(); }
+
+ /** Returns the next job to trigger after this job, or null if none should be triggered */
+ private JobType nextAfter(JobType jobType, Application application) {
+ // Always trigger system test after component as deployment spec might not be available yet (e.g. if this is a
+ // new application with no previous deployments)
+ if (jobType == JobType.component) {
+ return JobType.systemTest;
+ }
+
+ // At this point we've at least deployed to system test, so deployment spec should be available
+ List<DeploymentSpec.DeclaredZone> zones = application.deploymentSpec().zones();
+ Optional<DeploymentSpec.DeclaredZone> zoneForJob = zoneForJob(application, jobType);
+ if (!zoneForJob.isPresent()) {
+ return null;
+ }
+ int zoneIndex = application.deploymentSpec().zones().indexOf(zoneForJob.get());
+
+ // This is last zone
+ if (zoneIndex == zones.size() - 1) {
+ return null;
+ }
+
+ // Skip next job if delay has not passed yet
+ Duration delay = delayAfter(application, zoneForJob.get());
+ Optional<Instant> lastSuccess = Optional.ofNullable(application.deploymentJobs().jobStatus().get(jobType))
+ .flatMap(JobStatus::lastSuccess)
+ .map(JobStatus.JobRun::at);
+ if (lastSuccess.isPresent() && lastSuccess.get().plus(delay).isAfter(clock.instant())) {
+ log.info(String.format("Delaying next job after %s of %s by %s", jobType, application, delay));
+ return null;
+ }
+
+ DeploymentSpec.DeclaredZone nextZone = application.deploymentSpec().zones().get(zoneIndex + 1);
+ return JobType.from(controller.system(), nextZone.environment(), nextZone.region().orElse(null));
+ }
+
+ private Duration delayAfter(Application application, DeploymentSpec.DeclaredZone zone) {
+ int stepIndex = application.deploymentSpec().steps().indexOf(zone);
+ if (stepIndex == -1 || stepIndex == application.deploymentSpec().steps().size() - 1) {
+ return Duration.ZERO;
+ }
+ Duration totalDelay = Duration.ZERO;
+ List<DeploymentSpec.Step> remainingSteps = application.deploymentSpec().steps()
+ .subList(stepIndex + 1, application.deploymentSpec().steps().size());
+ for (DeploymentSpec.Step step : remainingSteps) {
+ if (!(step instanceof DeploymentSpec.Delay)) {
+ break;
+ }
+ totalDelay = totalDelay.plus(((DeploymentSpec.Delay) step).duration());
+ }
+ return totalDelay;
+ }
+
+ private Optional<DeploymentSpec.DeclaredZone> zoneForJob(Application application, JobType jobType) {
+ return application.deploymentSpec()
+ .zones()
+ .stream()
+ .filter(z -> {
+ if (jobType.isProduction()) {
+ return z.matches(jobType.environment(),
+ Optional.ofNullable(jobType.region(controller.system())));
+ } else {
+ // Ignore region for test environments as it's not specified in deployment spec
+ return z.environment() == jobType.environment();
+ }
+ })
+ .findFirst();
+ }
+
+ private boolean isFirstJob(JobType jobType) {
+ return jobType == JobType.component;
+ }
+
+ private boolean isLastJob(JobType jobType, Application application) {
+ List<JobType> triggerOrder = JobType.triggerOrder(controller.system(), application.deploymentSpec());
+ return triggerOrder.isEmpty() || jobType.equals(triggerOrder.get(triggerOrder.size() - 1));
+ }
+
+ private boolean isFailing(JobStatus jobStatusOrNull) {
+ return jobStatusOrNull != null && !jobStatusOrNull.isSuccess();
+ }
+
+ private boolean isCapacityConstrained(JobType jobType) {
+ return jobType == JobType.stagingTest || jobType == JobType.systemTest;
+ }
+
+ private boolean shouldRetryFromBeginning(Application application) {
+ Instant eightHoursAgo = clock.instant().minus(Duration.ofHours(8));
+ Instant failingSince = application.deploymentJobs().failingSince();
+ if (failingSince != null && failingSince.isAfter(eightHoursAgo)) return false;
+
+ JobStatus componentJobStatus = application.deploymentJobs().jobStatus().get(JobType.component);
+ if (componentJobStatus == null) return true;
+ if ( ! componentJobStatus.lastCompleted().isPresent() ) return true;
+ return componentJobStatus.lastCompleted().get().at().isBefore(eightHoursAgo);
+ }
+
+ /** Decide whether the job should be triggered by the periodic trigger */
+ private boolean shouldRetryNow(JobStatus job) {
+ if (job.isSuccess()) return false;
+
+ if ( ! job.lastCompleted().isPresent()) return true; // Retry when we don't hear back
+
+ // Always retry if we haven't tried in 4 hours
+ if (job.lastCompleted().get().at().isBefore(clock.instant().minus(Duration.ofHours(4)))) return true;
+
+ // Wait for 10% of the time since it started failing
+ Duration aTenthOfFailTime = Duration.ofMillis( (clock.millis() - job.firstFailing().get().at().toEpochMilli()) / 10);
+ if (job.lastCompleted().get().at().isBefore(clock.instant().minus(aTenthOfFailTime))) return true;
+
+ return false;
+ }
+
+ /** Retry immediately only if this just started failing. Otherwise retry periodically */
+ private boolean shouldRetryNow(Application application) {
+ return application.deploymentJobs().failingSince().isAfter(clock.instant().minus(Duration.ofSeconds(10)));
+ }
+
+ /** Decide whether to retry due to capacity restrictions */
+ private boolean shouldRetryOnOutOfCapacity(Application application, JobType jobType) {
+ Optional<JobError> outOfCapacityError = Optional.ofNullable(application.deploymentJobs().jobStatus().get(jobType))
+ .flatMap(JobStatus::jobError)
+ .filter(e -> e.equals(JobError.outOfCapacity));
+
+ if ( ! outOfCapacityError.isPresent()) return false;
+
+ // Retry the job if it failed recently
+ return application.deploymentJobs().jobStatus().get(jobType).firstFailing().get().at()
+ .isAfter(clock.instant().minus(Duration.ofMinutes(15)));
+ }
+
+ /** Decide whether job type should be triggered according to deployment spec */
+ private boolean deploysTo(Application application, JobType jobType) {
+ Optional<Zone> zone = jobType.zone(controller.system());
+ if (zone.isPresent() && jobType.isProduction()) {
+ // Skip triggering of jobs for zones where the application should not be deployed
+ if (!application.deploymentSpec().includes(jobType.environment(), Optional.of(zone.get().region()))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Trigger a job for an application
+ *
+ * @param jobType the type of the job to trigger, or null to trigger nothing
+ * @param application the application to trigger the job for
+ * @param first whether to trigger the job before other jobs
+ * @param cause describes why the job is triggered
+ * @return the application in the triggered state, which *must* be stored by the caller
+ */
+ private Application trigger(JobType jobType, Application application, boolean first, String cause, Lock lock) {
+ if (jobType == null) return application; // previous was last job
+
+ // TODO: Remove when we can determine why this occurs
+ if (jobType != JobType.component && !application.deploying().isPresent()) {
+ log.warning(String.format("Want to trigger %s for %s with reason %s, but this application is not " +
+ "currently deploying a change",
+ jobType, application, cause));
+ return application;
+ }
+
+ if (!deploysTo(application, jobType)) {
+ return application;
+ }
+
+ if (!application.deploymentJobs().isDeployableTo(jobType.environment(), application.deploying())) {
+ log.warning(String.format("Want to trigger %s for %s with reason %s, but change is untested", jobType,
+ application, cause));
+ return application;
+ }
+
+ if (application.deploymentJobs().isSelfTriggering()) {
+ log.info("Not triggering " + jobType + " for self-triggering " + application);
+ return application;
+ }
+
+ log.info(String.format("Triggering %s for %s, %s: %s", jobType, application,
+ application.deploying().map(d -> "deploying " + d).orElse("restarted deployment"),
+ cause));
+ buildSystem.addJob(application.id(), jobType, first);
+
+ return application.withJobTriggering(jobType, clock.instant(), controller);
+ }
+
+ private Application trigger(JobType jobType, Application application, String cause, Lock lock) {
+ return trigger(jobType, application, false, cause, lock);
+ }
+
+ public BuildSystem buildSystem() { return buildSystem; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/PolledBuildSystem.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/PolledBuildSystem.java
new file mode 100644
index 00000000000..41adb4abe6a
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/PolledBuildSystem.java
@@ -0,0 +1,100 @@
+// 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.deployment;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.vespa.curator.Lock;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.integration.BuildService.BuildJob;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobType;
+import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.List;
+
+/**
+ * Stores a queue for each type of job, and offers jobs from each of these to a periodic
+ * polling mechanism which is responsible for triggering the offered jobs in an external build service.
+ *
+ * @author jvenstad
+ * @author mpolden
+ */
+public class PolledBuildSystem implements BuildSystem {
+
+ private final Controller controller;
+
+ private final CuratorDb curator;
+
+ public PolledBuildSystem(Controller controller, CuratorDb curator) {
+ this.controller = controller;
+ this.curator = curator;
+ }
+
+ @Override
+ public void addJob(ApplicationId application, JobType jobType, boolean first) {
+ try (Lock lock = curator.lockJobQueues()) {
+ Deque<ApplicationId> queue = curator.readJobQueue(jobType);
+ if ( ! queue.contains(application)) {
+ if (first) {
+ queue.addFirst(application);
+ } else {
+ queue.add(application);
+ }
+ }
+ curator.writeJobQueue(jobType, queue);
+ }
+ }
+
+ @Override
+ public List<BuildJob> jobs() {
+ return getJobs(false);
+ }
+
+ @Override
+ public List<BuildJob> takeJobsToRun() {
+ return getJobs(true);
+ }
+
+
+ @Override
+ public void removeJobs(ApplicationId application) {
+ try (Lock lock = curator.lockJobQueues()) {
+ for (JobType jobType : JobType.values()) {
+ Deque<ApplicationId> queue = curator.readJobQueue(jobType);
+ while (queue.remove(application)) {
+ // keep removing until not found
+ }
+ curator.writeJobQueue(jobType, queue);
+ }
+ }
+ }
+
+ private List<BuildJob> getJobs(boolean removeFromQueue) {
+ try (Lock lock = curator.lockJobQueues()) {
+ List<BuildJob> jobsToRun = new ArrayList<>();
+ for (JobType jobType : JobType.values()) {
+ Deque<ApplicationId> queue = curator.readJobQueue(jobType);
+ for (ApplicationId a : queue) {
+ ApplicationId application = removeFromQueue ? queue.poll() : a;
+ jobsToRun.add(new BuildJob(projectIdFor(application), jobType.id()));
+
+ // Return only one job at a time for capacity constrained queues
+ if (removeFromQueue && isCapacityConstrained(jobType)) break;
+ }
+ if (removeFromQueue)
+ curator.writeJobQueue(jobType, queue);
+ }
+ return Collections.unmodifiableList(jobsToRun);
+ }
+ }
+
+ private Long projectIdFor(ApplicationId applicationId) {
+ return controller.applications().require(applicationId).deploymentJobs().projectId().get();
+ }
+
+ private static boolean isCapacityConstrained(JobType jobType) {
+ return jobType == JobType.stagingTest || jobType == JobType.systemTest;
+ }
+
+}
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
new file mode 100644
index 00000000000..016ea66cb1a
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java
@@ -0,0 +1,67 @@
+// 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.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.chef.Chef;
+import com.yahoo.vespa.hosted.controller.maintenance.config.MaintainerConfig;
+
+import java.time.Duration;
+
+/**
+ * Maintenance jobs of the controller.
+ * Each maintenance job is a singleton instance of its implementing class, created and owned by this,
+ * and running its own dedicated thread.
+ *
+ * @author bratseth
+ */
+public class ControllerMaintenance extends AbstractComponent {
+
+ private final JobControl jobControl;
+
+ private final DeploymentExpirer deploymentExpirer;
+ private final DeploymentIssueReporter deploymentIssueReporter;
+ private final MetricsReporter metricsReporter;
+ private final FailureRedeployer failureRedeployer;
+ private final OutstandingChangeDeployer outstandingChangeDeployer;
+ private final VersionStatusUpdater versionStatusUpdater;
+ private final Upgrader upgrader;
+ private final DelayedDeployer delayedDeployer;
+
+ @SuppressWarnings("unused") // instantiated by Dependency Injection
+ public ControllerMaintenance(MaintainerConfig maintainerConfig, Controller controller,
+ JobControl jobControl, Metric metric, Chef chefClient,
+ Contacts contactsClient, Properties propertiesClient, Issues issuesClient) {
+ Duration maintenanceInterval = Duration.ofMinutes(maintainerConfig.intervalMinutes());
+ this.jobControl = jobControl;
+ deploymentExpirer = new DeploymentExpirer(controller, maintenanceInterval, jobControl);
+ deploymentIssueReporter = new DeploymentIssueReporter(controller, contactsClient, propertiesClient,
+ issuesClient, maintenanceInterval, jobControl);
+ metricsReporter = new MetricsReporter(controller, metric, chefClient, jobControl, controller.system());
+ failureRedeployer = new FailureRedeployer(controller, maintenanceInterval, jobControl);
+ outstandingChangeDeployer = new OutstandingChangeDeployer(controller, maintenanceInterval, jobControl);
+ versionStatusUpdater = new VersionStatusUpdater(controller, Duration.ofMinutes(3), jobControl);
+ upgrader = new Upgrader(controller, maintenanceInterval, jobControl);
+ delayedDeployer = new DelayedDeployer(controller, maintenanceInterval, jobControl);
+ }
+
+ /** Returns control of the maintenance jobs of this */
+ public JobControl jobControl() { return jobControl; }
+
+ @Override
+ public void deconstruct() {
+ deploymentExpirer.deconstruct();
+ deploymentIssueReporter.deconstruct();
+ metricsReporter.deconstruct();
+ failureRedeployer.deconstruct();
+ outstandingChangeDeployer.deconstruct();
+ versionStatusUpdater.deconstruct();
+ upgrader.deconstruct();
+ delayedDeployer.deconstruct();
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DelayedDeployer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DelayedDeployer.java
new file mode 100644
index 00000000000..cb09c41a034
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DelayedDeployer.java
@@ -0,0 +1,24 @@
+// 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.vespa.hosted.controller.Controller;
+
+import java.time.Duration;
+
+/**
+ * Maintenance job which triggers jobs that have been delayed according to the applications deployment spec.
+ *
+ * @author mpolden
+ */
+public class DelayedDeployer extends Maintainer {
+
+ public DelayedDeployer(Controller controller, Duration interval, JobControl jobControl) {
+ super(controller, interval, jobControl);
+ }
+
+ @Override
+ protected void maintain() {
+ controller().applications().deploymentTrigger().triggerDelayed();
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentExpirer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentExpirer.java
new file mode 100644
index 00000000000..eb44229e790
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentExpirer.java
@@ -0,0 +1,66 @@
+// 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.Environment;
+import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.integration.zone.ZoneRegistry;
+import com.yahoo.vespa.hosted.controller.application.Deployment;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.logging.Level;
+
+/**
+ * Expires instances in zones that have configured expiration using TimeToLive.
+ *
+ * @author mortent
+ * @author bratseth
+ */
+public class DeploymentExpirer extends Maintainer {
+
+ private final Clock clock;
+
+ public DeploymentExpirer(Controller controller, Duration interval, JobControl jobControl) {
+ this(controller, interval, Clock.systemUTC(), jobControl);
+ }
+
+ public DeploymentExpirer(Controller controller, Duration interval, Clock clock, JobControl jobControl) {
+ super(controller, interval, jobControl);
+ this.clock = clock;
+ }
+
+ @Override
+ protected void maintain() {
+ for (Application application : controller().applications().asList()) {
+ for (Deployment deployment : application.deployments().values()) {
+ if (deployment.zone().environment().equals(Environment.prod)) continue;
+
+ if (hasExpired(controller().zoneRegistry(), deployment, clock.instant()))
+ deactivate(application, deployment);
+ }
+ }
+ }
+
+ private void deactivate(Application application, Deployment deployment) {
+ try {
+ controller().applications().deactivate(application, deployment, true);
+ }
+ catch (Exception e) {
+ log.log(Level.WARNING, "Could not expire " + deployment + " of " + application, e);
+ }
+ }
+
+ public static boolean hasExpired(ZoneRegistry zoneRegistry, Deployment deployment, Instant now) {
+ return zoneRegistry.getDeploymentTimeToLive(deployment.zone().environment(), deployment.zone().region())
+ .map(duration -> getExpiration(deployment, duration))
+ .map(now::isAfter)
+ .orElse(false);
+ }
+
+ private static Instant getExpiration(Deployment instance, Duration ttl) {
+ return instance.at().plus(ttl);
+ }
+
+}
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
new file mode 100644
index 00000000000..90544a8ac30
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/DeploymentIssueReporter.java
@@ -0,0 +1,234 @@
+// 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.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.Tenant;
+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.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;
+
+/**
+ * Maintenance job which creates Jira issues for tenants when they have jobs which fails continuously
+ * and escalates issues which are not handled.
+ *
+ * @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);
+ static final UserContact terminalUser = new UserContact("frodelu", "Frode Lundgren", admin);
+
+ private final Contacts contacts;
+ private final Properties properties;
+ private final Issues issues;
+
+ DeploymentIssueReporter(Controller controller, Contacts contacts, Properties properties, Issues issues,
+ Duration maintenanceInterval, JobControl jobControl) {
+ super(controller, maintenanceInterval, jobControl);
+ this.contacts = contacts;
+ this.properties = properties;
+ this.issues = issues;
+ }
+
+ @Override
+ protected void maintain() {
+ maintainDeploymentIssues(controller().applications().asList());
+ escalateInactiveDeploymentIssues(controller().applications().asList());
+ }
+
+ /**
+ * 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<>();
+ for (Application application : applications)
+ if (failingSinceBefore(application.deploymentJobs(), controller().clock().instant().minus(maxFailureAge)))
+ failingApplications.add(application);
+ else
+ controller().applications().setJiraIssueId(application.id(), Optional.empty());
+
+ // TODO: Do this when version.confidence is BROKEN instead?
+ 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);
+ Classification applicationOwner = null;
+ try {
+ applicationOwner = jiraClassificationOf(ownerOf(application));
+ 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)));
+ else
+ fileFor(application, deploymentIssue.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);
+
+ if (jiraIssueId.isPresent())
+ issues.update(jiraIssueId.get(), issue.description());
+ else
+ issues.file(issue);
+ }
+
+ /** 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);
+ }));
+ }
+
+ /** 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);
+ }
+
+ // 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 String toUrl(ApplicationId applicationId) {
+ return controller().zoneRegistry().getDashboardUri().resolve("/apps" +
+ "/tenant/" + applicationId.tenant().value() +
+ "/application/" + applicationId.application().value()).toString();
+ }
+
+ 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.";
+ }
+
+ /** 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.";
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/FailureRedeployer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/FailureRedeployer.java
new file mode 100644
index 00000000000..9e8f902a8db
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/FailureRedeployer.java
@@ -0,0 +1,42 @@
+// 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.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.application.ApplicationList;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Attempts redeployment of failed jobs and deployments.
+ *
+ * @author bratseth
+ */
+public class FailureRedeployer extends Maintainer {
+
+ public FailureRedeployer(Controller controller, Duration interval, JobControl jobControl) {
+ super(controller, interval, jobControl);
+ }
+
+ @Override
+ public void maintain() {
+ ApplicationList applications = ApplicationList.from(controller().applications().asList()).isDeploying();
+ List<Application> toTrigger = new ArrayList<>();
+
+ // Applications with deployment failures for current change and no running jobs
+ toTrigger.addAll(applications.hasDeploymentFailures()
+ .notRunningJob()
+ .asList());
+
+ // Applications with jobs that have been in progress for more than 12 hours
+ Instant twelveHoursAgo = controller().clock().instant().minus(Duration.ofHours(12));
+ toTrigger.addAll(applications.jobRunningSince(twelveHoursAgo).asList());
+
+ toTrigger.forEach(application -> controller().applications().deploymentTrigger()
+ .triggerFailing(application.id()));
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/JobControl.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/JobControl.java
new file mode 100644
index 00000000000..e05612aaf57
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/JobControl.java
@@ -0,0 +1,67 @@
+// 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.vespa.curator.Lock;
+import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.logging.Logger;
+
+/**
+ * Provides status and control over running maintenance jobs.
+ * This is multithread safe.
+ *
+ * Job deactivation is stored in a local file.
+ *
+ * @author bratseth
+ */
+public class JobControl {
+
+ private static final Logger log = Logger.getLogger(JobControl.class.getName());
+
+ private final CuratorDb curator;
+
+ /** This is not stored in ZooKeeper as all nodes start all jobs */
+ private final Set<String> startedJobs = new ConcurrentSkipListSet<>();
+
+ /** Create a job control instance which persists activation changes to the default directory */
+ public JobControl(CuratorDb curator) {
+ this.curator = curator;
+ }
+
+ public CuratorDb curator() { return curator; }
+
+ /** Notifies this that a job was started */
+ public void started(String jobSimpleClassName) {
+ startedJobs.add(jobSimpleClassName);
+ }
+
+ /**
+ * Returns a snapshot of the set of jobs started on this system (whether deactivated or not).
+ * Each job is represented by its simple (omitting package) class name.
+ */
+ public Set<String> jobs() { return new HashSet<>(startedJobs); }
+
+ /** Returns an unmodifiable set containing the currently inactive jobs in this */
+ public Set<String> inactiveJobs() { return curator.readInactiveJobs(); }
+
+ /** Returns true if this job is not currently deactivated */
+ public boolean isActive(String jobSimpleClassName) {
+ return ! inactiveJobs().contains(jobSimpleClassName);
+ }
+
+ /** Set a job active or inactive */
+ public void setActive(String jobSimpleClassName, boolean active) {
+ try (Lock lock = curator.lockInactiveJobs()) {
+ Set<String> inactiveJobs = curator.readInactiveJobs();
+ if (active)
+ inactiveJobs.remove(jobSimpleClassName);
+ else
+ inactiveJobs.add(jobSimpleClassName);
+ curator.writeInactiveJobs(inactiveJobs);
+ }
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/Maintainer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/Maintainer.java
new file mode 100644
index 00000000000..9f9f0175230
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/Maintainer.java
@@ -0,0 +1,80 @@
+// 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.google.common.util.concurrent.UncheckedTimeoutException;
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.vespa.curator.Lock;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
+
+import java.time.Duration;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * A maintainer is some job which runs at a fixed interval to perform some maintenance task in the controller.
+ *
+ * @author bratseth
+ */
+public abstract class Maintainer extends AbstractComponent implements Runnable {
+
+ protected static final Logger log = Logger.getLogger(Maintainer.class.getName());
+
+ private final Controller controller;
+ private final Duration maintenanceInterval;
+ private final JobControl jobControl;
+ private final ScheduledExecutorService service;
+
+ public Maintainer(Controller controller, Duration interval, JobControl jobControl) {
+ this.controller = controller;
+ this.maintenanceInterval = interval;
+ this.jobControl = jobControl;
+
+ service = new ScheduledThreadPoolExecutor(1);
+ service.scheduleAtFixedRate(this, interval.toMillis(), interval.toMillis(), TimeUnit.MILLISECONDS);
+ jobControl.started(name());
+ }
+
+ protected Controller controller() { return controller; }
+
+ protected CuratorDb curator() { return jobControl.curator(); }
+
+ @Override
+ public void run() {
+ try {
+ if (jobControl.isActive(name())) {
+ try (Lock lock = jobControl.curator().lockMaintenanceJob(name())) {
+ maintain();
+ }
+ }
+ }
+ catch (UncheckedTimeoutException e) {
+ // another controller instance is running this job at the moment; ok
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, this + " failed. Will retry in " + maintenanceInterval.toMinutes() + " minutes", e);
+ }
+ }
+
+ @Override
+ public void deconstruct() {
+ this.service.shutdown();
+ }
+
+ /** Called once each time this maintenance job should run */
+ protected abstract void maintain();
+
+ public Duration maintenanceInterval() { return maintenanceInterval; }
+
+ public String name() { return this.getClass().getSimpleName(); }
+
+ /** Returns the name of this */
+ @Override
+ public final String toString() {
+ return name();
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/MetricsReporter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/MetricsReporter.java
new file mode 100644
index 00000000000..3d0cd284c55
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/MetricsReporter.java
@@ -0,0 +1,118 @@
+// 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.SystemName;
+import com.yahoo.jdisc.Metric;
+import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.integration.chef.AttributeMapping;
+import com.yahoo.vespa.hosted.controller.api.integration.chef.Chef;
+import com.yahoo.vespa.hosted.controller.api.integration.chef.rest.PartialNode;
+import com.yahoo.vespa.hosted.controller.api.integration.chef.rest.PartialNodeResult;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * @author mortent
+ */
+public class MetricsReporter extends Maintainer {
+
+ public static final String convergeMetric = "seconds.since.last.chef.convergence";
+ public static final String deploymentFailMetric = "deployment.failurePercentage";
+ private final Metric metric;
+ private final Chef chefClient;
+ private final Clock clock;
+ private final SystemName system;
+
+ public MetricsReporter(Controller controller, Metric metric, Chef chefClient, JobControl jobControl,
+ SystemName system) {
+ this(controller, metric, chefClient, Clock.systemUTC(), jobControl, system);
+ }
+
+ public MetricsReporter(Controller controller, Metric metric, Chef chefClient, Clock clock,
+ JobControl jobControl, SystemName system) {
+ super(controller, Duration.ofMinutes(1), jobControl); // use fixed rate for metrics
+ this.metric = metric;
+ this.chefClient = chefClient;
+ this.clock = clock;
+ this.system = system;
+ }
+
+ @Override
+ public void maintain() {
+ reportChefMetrics();
+ reportDeploymentMetrics();
+ }
+
+ private void reportChefMetrics() {
+ String query = "chef_environment:hosted*";
+ if (system == SystemName.cd) {
+ query += " AND hosted_system:" + system;
+ }
+ PartialNodeResult nodeResult = chefClient.partialSearchNodes(query,
+ Arrays.asList(
+ AttributeMapping.simpleMapping("fqdn"),
+ AttributeMapping.simpleMapping("ohai_time"),
+ AttributeMapping.deepMapping("tenant", Arrays.asList("hosted", "owner", "tenant")),
+ AttributeMapping.deepMapping("application", Arrays.asList("hosted", "owner", "application")),
+ AttributeMapping.deepMapping("instance", Arrays.asList("hosted", "owner", "instance")),
+ AttributeMapping.deepMapping("environment", Arrays.asList("hosted", "environment")),
+ AttributeMapping.deepMapping("region", Arrays.asList("hosted", "region")),
+ AttributeMapping.deepMapping("system", Arrays.asList("hosted", "system"))
+ ));
+
+ // The above search will return a correct list if the system is CD. However for main, it will
+ // return all nodes, since system==nil for main
+ keepNodesWithSystem(nodeResult, system);
+
+ Instant instant = clock.instant();
+ for (PartialNode node : nodeResult.rows) {
+ String hostname = node.getFqdn();
+ long secondsSinceConverge = Duration.between(Instant.ofEpochSecond(node.getOhaiTime().longValue()), instant).getSeconds();
+ Map<String, String> dimensions = new HashMap<>();
+ dimensions.put("host", hostname);
+ dimensions.put("system", node.getValue("system").orElse("main"));
+ Optional<String> environment = node.getValue("environment");
+ Optional<String> region = node.getValue("region");
+
+ if(environment.isPresent() && region.isPresent()) {
+ dimensions.put("zone", String.format("%s.%s", environment.get(), region.get()));
+ }
+
+ node.getValue("tenant").ifPresent(tenant -> dimensions.put("tenantName", tenant));
+ Optional<String> application = node.getValue("application");
+ if (application.isPresent()) {
+ dimensions.put("app",String.format("%s.%s", application.get(), node.getValue("instance").orElse("default")));
+ }
+ Metric.Context context = metric.createContext(dimensions);
+ metric.set(convergeMetric, secondsSinceConverge, context);
+ }
+ }
+
+ private void reportDeploymentMetrics() {
+ metric.set(deploymentFailMetric, deploymentFailRatio() * 100, metric.createContext(Collections.emptyMap()));
+ }
+
+ private double deploymentFailRatio() {
+ List<Application> applications = controller().applications().asList();
+ if (applications.isEmpty()) return 0;
+
+ return (double)applications.stream().filter(a -> a.deploymentJobs().hasFailures()).count() /
+ (double)applications.size();
+ }
+
+ private void keepNodesWithSystem(PartialNodeResult nodeResult, SystemName system) {
+ nodeResult.rows.removeIf(node -> !system.name().equals(node.getValue("system").orElse("main")));
+ }
+
+}
+
+
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/OutstandingChangeDeployer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/OutstandingChangeDeployer.java
new file mode 100644
index 00000000000..4485a603f61
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/OutstandingChangeDeployer.java
@@ -0,0 +1,32 @@
+// 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.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.application.ApplicationList;
+import com.yahoo.vespa.hosted.controller.application.Change;
+
+import java.time.Duration;
+
+/**
+ * Deploys application changes which have been postponed due to an ongoing upgrade
+ *
+ * @author bratseth
+ */
+public class OutstandingChangeDeployer extends Maintainer {
+
+ public OutstandingChangeDeployer(Controller controller, Duration interval, JobControl jobControl) {
+ super(controller, interval, jobControl);
+ }
+
+ @Override
+ protected void maintain() {
+ ApplicationList applications = ApplicationList.from(controller().applications().asList()).notPullRequest();
+ for (Application application : applications.asList()) {
+ if (application.hasOutstandingChange() && ! application.deploying().isPresent())
+ controller().applications().deploymentTrigger().triggerChange(application.id(),
+ Change.ApplicationChange.unknown());
+ }
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/Upgrader.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/Upgrader.java
new file mode 100644
index 00000000000..b3d75106d2f
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/Upgrader.java
@@ -0,0 +1,94 @@
+// 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.component.Version;
+import com.yahoo.component.Vtag;
+import com.yahoo.config.application.api.DeploymentSpec.UpgradePolicy;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.config.provision.RegionName;
+import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.application.ApplicationList;
+import com.yahoo.vespa.hosted.controller.application.Change;
+import com.yahoo.vespa.hosted.controller.versions.VespaVersion;
+import com.yahoo.yolean.Exceptions;
+
+import java.time.Duration;
+import java.util.Optional;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Maintenance job which schedules applications for Vespa version upgrade
+ *
+ * @author bratseth
+ */
+public class Upgrader extends Maintainer {
+
+ private static final Logger log = Logger.getLogger(Upgrader.class.getName());
+
+ public Upgrader(Controller controller, Duration interval, JobControl jobControl) {
+ super(controller, interval, jobControl);
+ }
+
+ /**
+ * Schedule application upgrades. Note that this implementation must be idempotent.
+ */
+ @Override
+ public void maintain() {
+ VespaVersion target = controller().versionStatus().version(controller().systemVersion());
+ if (target == null) return; // we don't have information about the current system version at this time
+
+ // TODO: Remove corp-prod special casing when corp-prod and main are upgraded at the same time
+ if (Vtag.currentVersion.isAfter(target.versionNumber())) {
+ upgrade(applications().deploysTo(Environment.prod, RegionName.from("corp-us-east-1")).with(UpgradePolicy.canary),
+ Vtag.currentVersion);
+ }
+
+ switch (target.confidence()) {
+ case broken:
+ log.info(String.format("Version %s is broken, cancelling all upgrades", target.versionNumber()));
+ cancelUpgradesOf(applications().upgradingTo(target.versionNumber())
+ .without(UpgradePolicy.canary)); // keep trying canaries
+ break;
+ case low:
+ upgrade(applications().with(UpgradePolicy.canary), target.versionNumber());
+ break;
+ case normal:
+ upgrade(applications().with(UpgradePolicy.defaultPolicy), target.versionNumber());
+ break;
+ case high:
+ upgrade(applications().with(UpgradePolicy.conservative), target.versionNumber());
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown version confidence " + target.confidence());
+ }
+ }
+
+ /** Returns a list of all applications */
+ private ApplicationList applications() { return ApplicationList.from(controller().applications().asList()); }
+
+ private void upgrade(ApplicationList applications, Version version) {
+ Change.VersionChange change = new Change.VersionChange(version);
+ cancelUpgradesOf(applications.upgradingToLowerThan(version));
+ applications = applications.notPullRequest(); // Pull requests are deployed as separate applications to test then deleted; No need to upgrade
+ applications = applications.onLowerVersionThan(version);
+ applications = applications.notDeployingApplication(); // wait with applications deploying an application change
+ applications = applications.notFailingOn(version); // try to upgrade only if it hasn't failed on this version
+ applications = applications.notRunningJobFor(change); // do not trigger multiple jobs simultaneously for same upgrade
+ for (Application application : applications.byIncreasingDeployedVersion().asList()) {
+ try {
+ controller().applications().deploymentTrigger().triggerChange(application.id(), change);
+ } catch (IllegalArgumentException e) {
+ log.log(Level.INFO, "Could not trigger change: " + Exceptions.toMessageString(e));
+ }
+ }
+ }
+
+ private void cancelUpgradesOf(ApplicationList applications) {
+ for (Application application : applications.asList()) {
+ controller().applications().deploymentTrigger().cancelChange(application.id());
+ }
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/VersionStatusUpdater.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/VersionStatusUpdater.java
new file mode 100644
index 00000000000..dea991bc653
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/VersionStatusUpdater.java
@@ -0,0 +1,33 @@
+// 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.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.versions.VersionStatus;
+
+import java.io.UncheckedIOException;
+import java.time.Duration;
+
+/**
+ * This maintenance job periodically updates the version status.
+ * Since the version status is expensive to compute and do not need to be perfectly up to date,
+ * we do not want to recompute it each time it is accessed.
+ *
+ * @author bratseth
+ */
+public class VersionStatusUpdater extends Maintainer {
+
+ public VersionStatusUpdater(Controller controller, Duration interval, JobControl jobControl) {
+ super(controller, interval, jobControl);
+ }
+
+ @Override
+ protected void maintain() {
+ try {
+ VersionStatus newStatus = VersionStatus.compute(controller());
+ controller().updateVersionStatus(newStatus);
+ } catch (UncheckedIOException e) {
+ log.warning("Failed to compute version status. This is likely a transient error: " + e.getMessage());
+ }
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/package-info.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/package-info.java
new file mode 100644
index 00000000000..14267807041
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/package-info.java
@@ -0,0 +1,5 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+package com.yahoo.vespa.hosted.controller.maintenance;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/package-info.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/package-info.java
new file mode 100644
index 00000000000..112e90e2cd7
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/package-info.java
@@ -0,0 +1,10 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * The root package of the controller
+ *
+ * @author bratseth
+ */
+@ExportPackage
+package com.yahoo.vespa.hosted.controller;
+
+import com.yahoo.osgi.annotation.ExportPackage;
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
new file mode 100644
index 00000000000..014c63a6779
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializer.java
@@ -0,0 +1,304 @@
+// 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.persistence;
+
+import com.yahoo.component.Version;
+import com.yahoo.config.application.api.DeploymentSpec;
+import com.yahoo.config.application.api.ValidationOverrides;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.config.provision.RegionName;
+import com.yahoo.config.provision.Zone;
+import com.yahoo.slime.ArrayTraverser;
+import com.yahoo.slime.Cursor;
+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.application.ApplicationRevision;
+import com.yahoo.vespa.hosted.controller.application.Change;
+import com.yahoo.vespa.hosted.controller.application.Deployment;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobError;
+import com.yahoo.vespa.hosted.controller.application.JobStatus;
+import com.yahoo.vespa.hosted.controller.application.SourceRevision;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Serializes applications to/from slime.
+ * This class is multithread safe.
+ *
+ * @author bratseth
+ */
+public class ApplicationSerializer {
+
+ // Application fields
+ private final String idField = "id";
+ private final String deploymentSpecField = "deploymentSpecField";
+ private final String validationOverridesField = "validationOverrides";
+ private final String deploymentsField = "deployments";
+ private final String deploymentJobsField = "deploymentJobs";
+ private final String deployingField = "deployingField";
+ private final String outstandingChangeField = "outstandingChangeField";
+
+ // Deployment fields
+ private final String zoneField = "zone";
+ private final String environmentField = "environment";
+ private final String regionField = "region";
+ private final String deployTimeField = "deployTime";
+ private final String applicationPackageRevisionField = "applicationPackageRevision";
+ private final String applicationPackageHashField = "applicationPackageHash";
+ private final String sourceRevisionField = "sourceRevision";
+ private final String repositoryField = "repositoryField";
+ private final String branchField = "branchField";
+ private final String commitField = "commitField";
+
+ // DeploymentJobs fields
+ private final String projectIdField = "projectId";
+ private final String jobStatusField = "jobStatus";
+ private final String jiraIssueIdField = "jiraIssueId";
+ private final String selfTriggeringField = "selfTriggering";
+
+ // JobStatus field
+ private final String jobTypeField = "jobType";
+ private final String errorField = "jobError";
+ private final String completionTimeField = "completionTime";
+ private final String failingSinceField = "failingSince";
+ private final String lastTriggeredField = "lastTriggered";
+ private final String lastCompletedField = "lastCompleted";
+ private final String firstFailingField = "firstFailing";
+ private final String lastSuccessField = "lastSuccess";
+
+ // JobRun fields
+ private final String versionField = "version";
+ private final String revisionField = "revision";
+ private final String atField = "at";
+
+ // ------------------ Serialization
+
+ public Slime toSlime(Application application) {
+ Slime slime = new Slime();
+ Cursor root = slime.setObject();
+ root.setString(idField, application.id().serializedForm());
+ root.setString(deploymentSpecField, application.deploymentSpec().xmlForm());
+ root.setString(validationOverridesField, application.validationOverrides().xmlForm());
+ deploymentsToSlime(application.deployments().values(), root.setArray(deploymentsField));
+ toSlime(application.deploymentJobs(), root.setObject(deploymentJobsField));
+ toSlime(application.deploying(), root);
+ root.setBool(outstandingChangeField, application.hasOutstandingChange());
+ return slime;
+ }
+
+ private void deploymentsToSlime(Collection<Deployment> deployments, Cursor array) {
+ for (Deployment deployment : deployments)
+ deploymentToSlime(deployment, array.addObject());
+ }
+
+ private void deploymentToSlime(Deployment deployment, Cursor object) {
+ zoneToSlime(deployment.zone(), object.setObject(zoneField));
+ object.setString(versionField, deployment.version().toString());
+ object.setLong(deployTimeField, deployment.at().toEpochMilli());
+ toSlime(deployment.revision(), object.setObject(applicationPackageRevisionField));
+ }
+
+ private void zoneToSlime(Zone zone, Cursor object) {
+ object.setString(environmentField, zone.environment().value());
+ object.setString(regionField, zone.region().value());
+ }
+
+ private void toSlime(ApplicationRevision applicationRevision, Cursor object) {
+ object.setString(applicationPackageHashField, applicationRevision.id());
+ if (applicationRevision.source().isPresent())
+ toSlime(applicationRevision.source().get(), object.setObject(sourceRevisionField));
+ }
+
+ private void toSlime(SourceRevision sourceRevision, Cursor object) {
+ object.setString(repositoryField, sourceRevision.repository());
+ object.setString(branchField, sourceRevision.branch());
+ object.setString(commitField, sourceRevision.commit());
+ }
+
+ private void toSlime(DeploymentJobs deploymentJobs, Cursor cursor) {
+ deploymentJobs.projectId().ifPresent(projectId -> cursor.setLong(projectIdField, projectId));
+ jobStatusToSlime(deploymentJobs.jobStatus().values(), cursor.setArray(jobStatusField));
+ deploymentJobs.jiraIssueId().ifPresent(jiraIssueId -> cursor.setString(jiraIssueIdField, jiraIssueId));
+ cursor.setBool(selfTriggeringField, deploymentJobs.isSelfTriggering());
+ }
+
+ private void jobStatusToSlime(Collection<JobStatus> jobStatuses, Cursor jobStatusArray) {
+ for (JobStatus jobStatus : jobStatuses)
+ toSlime(jobStatus, jobStatusArray.addObject());
+ }
+
+ private void toSlime(JobStatus jobStatus, Cursor object) {
+ object.setString(jobTypeField, jobStatus.type().id());
+ if (jobStatus.jobError().isPresent())
+ object.setString(errorField, jobStatus.jobError().get().name());
+
+ jobRunToSlime(jobStatus.lastTriggered(), object, lastTriggeredField);
+ jobRunToSlime(jobStatus.lastCompleted(), object, lastCompletedField);
+ jobRunToSlime(jobStatus.firstFailing(), object, firstFailingField);
+ jobRunToSlime(jobStatus.lastSuccess(), object, lastSuccessField);
+ }
+
+ private void jobRunToSlime(Optional<JobStatus.JobRun> jobRun, Cursor parent, String jobRunObjectName) {
+ if ( ! jobRun.isPresent()) return;
+ Cursor object = parent.setObject(jobRunObjectName);
+ object.setString(versionField, jobRun.get().version().toString());
+ if ( jobRun.get().revision().isPresent())
+ toSlime(jobRun.get().revision().get(), object.setObject(revisionField));
+ object.setLong(atField, jobRun.get().at().toEpochMilli());
+ }
+
+ private void toSlime(Optional<Change> deploying, Cursor parentObject) {
+ if ( ! deploying.isPresent()) return;
+
+ Cursor object = parentObject.setObject(deployingField);
+ if (deploying.get() instanceof Change.VersionChange)
+ object.setString(versionField, ((Change.VersionChange)deploying.get()).version().toString());
+ else if (((Change.ApplicationChange)deploying.get()).revision().isPresent())
+ toSlime(((Change.ApplicationChange)deploying.get()).revision().get(), object);
+ }
+
+ // ------------------ Deserialization
+
+ public Application fromSlime(Slime slime) {
+ Inspector root = slime.get();
+
+ ApplicationId id = ApplicationId.fromSerializedForm(root.field(idField).asString());
+ DeploymentSpec deploymentSpec = DeploymentSpec.fromXml(root.field(deploymentSpecField).asString());
+ ValidationOverrides validationOverrides = validationOverridesFromSlime(root.field(validationOverridesField));
+ List<Deployment> deployments = deploymentsFromSlime(root.field(deploymentsField));
+ DeploymentJobs deploymentJobs = deploymentJobsFromSlime(root.field(deploymentJobsField));
+ Optional<Change> deploying = changeFromSlime(root.field(deployingField));
+ boolean outstandingChange = root.field(outstandingChangeField).asBool();
+
+ return new Application(id, deploymentSpec, validationOverrides, deployments,
+ deploymentJobs, deploying, outstandingChange);
+ }
+
+ private ValidationOverrides validationOverridesFromSlime(Inspector field) {
+ if ( ! field.valid()) return ValidationOverrides.empty; // TODO: Remove this line (and inline function) after June 2017
+ return ValidationOverrides.fromXml(field.asString());
+ }
+
+ private List<Deployment> deploymentsFromSlime(Inspector array) {
+ List<Deployment> deployments = new ArrayList<>();
+ array.traverse((ArrayTraverser) (int i, Inspector item) -> deployments.add(deploymentFromSlime(item)));
+ return deployments;
+ }
+
+ private Deployment deploymentFromSlime(Inspector deploymentObject) {
+ return new Deployment(zoneFromSlime(deploymentObject.field(zoneField)),
+ applicationRevisionFromSlime(deploymentObject.field(applicationPackageRevisionField)).get(),
+ Version.fromString(deploymentObject.field(versionField).asString()),
+ Instant.ofEpochMilli(deploymentObject.field(deployTimeField).asLong()));
+ }
+
+ private Zone zoneFromSlime(Inspector object) {
+ return new Zone(Environment.from(object.field(environmentField).asString()),
+ RegionName.from(object.field(regionField).asString()));
+ }
+
+ private Optional<ApplicationRevision> applicationRevisionFromSlime(Inspector object) {
+ if ( ! object.valid()) return Optional.empty();
+ String applicationPackageHash = object.field(applicationPackageHashField).asString();
+ Optional<SourceRevision> sourceRevision = sourceRevisionFromSlime(object.field(sourceRevisionField));
+ return sourceRevision.isPresent() ? Optional.of(ApplicationRevision.from(applicationPackageHash, sourceRevision.get()))
+ : Optional.of(ApplicationRevision.from(applicationPackageHash));
+ }
+
+ private Optional<SourceRevision> sourceRevisionFromSlime(Inspector object) {
+ if ( ! object.valid()) return Optional.empty();
+ return Optional.of(new SourceRevision(object.field(repositoryField).asString(),
+ object.field(branchField).asString(),
+ object.field(commitField).asString()));
+ }
+
+ private DeploymentJobs deploymentJobsFromSlime(Inspector object) {
+ Optional<Long> projectId = optionalLong(object.field(projectIdField));
+ List<JobStatus> jobStatusList = jobStatusListFromSlime(object.field(jobStatusField));
+ Optional<String> jiraIssueKey = optionalString(object.field(jiraIssueIdField));
+ boolean selfTriggering = object.field(selfTriggeringField).asBool();
+
+ return new DeploymentJobs(projectId, jobStatusList, jiraIssueKey, selfTriggering);
+ }
+
+ private Optional<Change> changeFromSlime(Inspector object) {
+ if ( ! object.valid()) return Optional.empty();
+ Inspector versionFieldValue = object.field(versionField);
+ if (versionFieldValue.valid())
+ return Optional.of(new Change.VersionChange(Version.fromString(versionFieldValue.asString())));
+ else if (object.field(applicationPackageHashField).valid())
+ return Optional.of(Change.ApplicationChange.of(applicationRevisionFromSlime(object).get()));
+ else
+ return Optional.of(Change.ApplicationChange.unknown());
+ }
+
+ private List<JobStatus> jobStatusListFromSlime(Inspector array) {
+ List<JobStatus> jobStatusList = new ArrayList<>();
+ array.traverse((ArrayTraverser) (int i, Inspector item) -> {
+ // TODO: This zone has been removed. Remove after Aug 2017
+ String jobId = item.field(jobTypeField).asString();
+ if ("production-ap-aue-1".equals(jobId)) {
+ return;
+ }
+ jobStatusList.add(jobStatusFromSlime(item));
+ });
+ return jobStatusList;
+ }
+
+ private JobStatus jobStatusFromSlime(Inspector object) {
+ DeploymentJobs.JobType jobType = DeploymentJobs.JobType.fromId(object.field(jobTypeField).asString());
+
+ Optional<JobError> jobError = Optional.empty();
+ if (object.field(errorField).valid())
+ jobError = Optional.of(JobError.valueOf(object.field(errorField).asString()));
+
+ Inspector versionFieldValue = object.field(versionField);
+ if (versionFieldValue.valid()) { // TODO: Read legacy JobStatus content: Remove after June 2017
+ // Read stored information in old data model
+ Instant completionTime = Instant.ofEpochMilli(object.field(completionTimeField).asLong());
+ Optional<Instant> failingSinceTime = optionalLong(object.field(failingSinceField)).map(Instant::ofEpochMilli);
+ Optional<Instant> lastTriggeredTime = optionalLong(object.field(lastTriggeredField)).map(Instant::ofEpochMilli);
+ Version version = new Version(versionFieldValue.asString());
+
+ // Best-effort conversion to new data model
+ Optional<JobStatus.JobRun> lastTriggered = lastTriggeredTime.map(at -> new JobStatus.JobRun(version, Optional.empty(), at));
+ Optional<JobStatus.JobRun> lastCompleted = Optional.of(new JobStatus.JobRun(version, Optional.empty(), completionTime));
+ Optional<JobStatus.JobRun> firstFailing = failingSinceTime.map(at -> new JobStatus.JobRun(version, Optional.empty(), at));
+ Optional<JobStatus.JobRun> lastSuccess = Optional.of(new JobStatus.JobRun(version, Optional.empty(), completionTime));;
+
+ return new JobStatus(jobType, jobError,
+ lastTriggered, lastCompleted, firstFailing, lastSuccess);
+ }
+ else { // read current format
+ return new JobStatus(jobType, jobError,
+ jobRunFromSlime(object.field(lastTriggeredField)),
+ jobRunFromSlime(object.field(lastCompletedField)),
+ jobRunFromSlime(object.field(firstFailingField)),
+ jobRunFromSlime(object.field(lastSuccessField)));
+
+ }
+ }
+
+ private Optional<JobStatus.JobRun> jobRunFromSlime(Inspector object) {
+ if ( ! object.valid()) return Optional.empty();
+ return Optional.of(new JobStatus.JobRun(new Version(object.field(versionField).asString()),
+ applicationRevisionFromSlime(object.field(revisionField)),
+ Instant.ofEpochMilli(object.field(atField).asLong())));
+ }
+
+ private Optional<Long> optionalLong(Inspector field) {
+ return field.valid() ? Optional.of(field.asLong()) : Optional.empty();
+ }
+
+ private Optional<String> optionalString(Inspector field) {
+ return SlimeUtils.optionalString(field);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ControllerDb.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ControllerDb.java
new file mode 100644
index 00000000000..3fbfdd31808
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ControllerDb.java
@@ -0,0 +1,74 @@
+// 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.persistence;
+
+import com.google.common.base.Joiner;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.api.Tenant;
+import com.yahoo.vespa.hosted.controller.api.identifiers.Identifier;
+import com.yahoo.vespa.hosted.controller.api.identifiers.RotationId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Used to store the permanent data of the controller.
+ *
+ * @author Stian Kristoffersen
+ * @author bratseth
+ */
+public abstract class ControllerDb {
+
+ // --------- Tenants
+
+ public abstract void createTenant(Tenant tenant);
+
+ public abstract void updateTenant(Tenant tenant) throws PersistenceException;
+
+ public abstract void deleteTenant(TenantId tenantId) throws PersistenceException;
+
+ public abstract Optional<Tenant> getTenant(TenantId tenantId) throws PersistenceException;
+
+ public abstract List<Tenant> listTenants();
+
+ // --------- Applications
+
+ // ONLY call this from ApplicationController.store()
+ public abstract void store(Application application);
+
+ public abstract void deleteApplication(ApplicationId applicationId);
+
+ public abstract Optional<Application> getApplication(ApplicationId applicationId);
+
+ /** Returns all applications */
+ public abstract List<Application> listApplications();
+
+ /** Returns all applications of a tenant */
+ public abstract List<Application> listApplications(TenantId tenantId);
+
+ // --------- Rotations
+
+ public abstract Set<RotationId> getRotations();
+
+ public abstract Set<RotationId> getRotations(ApplicationId applicationId);
+
+ public abstract boolean assignRotation(RotationId rotationId, ApplicationId applicationId);
+
+ public abstract Set<RotationId> deleteRotations(ApplicationId applicationId);
+
+ /** Returns the given elements joined by dot "." */
+ protected String path(Identifier... elements) {
+ return Joiner.on(".").join(elements);
+ }
+
+ protected String path(String... elements) {
+ return Joiner.on(".").join(elements);
+ }
+
+ protected String path(ApplicationId applicationId) {
+ return applicationId.tenant().value() + "." + applicationId.application().value() + "." + applicationId.instance().value();
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
new file mode 100644
index 00000000000..5777636fa24
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
@@ -0,0 +1,201 @@
+// 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.persistence;
+
+import com.google.inject.Inject;
+import com.yahoo.cloud.config.ZookeeperServerConfig;
+import com.yahoo.component.Version;
+import com.yahoo.component.Vtag;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.path.Path;
+import com.yahoo.transaction.NestedTransaction;
+import com.yahoo.vespa.curator.Curator;
+import com.yahoo.vespa.curator.Lock;
+import com.yahoo.vespa.curator.mock.MockCurator;
+import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayDeque;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Curator backed database for storing working state shared between controller servers.
+ * This maps controller specific operations to general curator operations.
+ *
+ * @author bratseth
+ */
+public class CuratorDb {
+
+ private static final Logger log = Logger.getLogger(CuratorDb.class.getName());
+
+ private static final Path root = Path.fromString("/controller/v1");
+
+ private static final Duration defaultLockTimeout = Duration.ofMinutes(5);
+
+ private final StringSetSerializer stringSetSerializer = new StringSetSerializer();
+ private final JobQueueSerializer jobQueueSerializer = new JobQueueSerializer();
+
+ @SuppressWarnings("unused") // This server is used (only) from the curator instance of this over the network */
+ //private final ZooKeeperServer zooKeeperServer;
+ private final Curator curator;
+
+ /**
+ * All keys, to allow reentrancy.
+ * This will grow forever, but this should be too slow to be a problem.
+ */
+ private final ConcurrentHashMap<Path, Lock> locks = new ConcurrentHashMap<>();
+
+ /** Create a curator db which also set up a ZooKeeper server (such that this instance is both client and server) */
+ @Inject
+ public CuratorDb() {
+ // this.zooKeeperServer = new ZooKeeperServer(createZookeeperServerConfig());
+ // this.curator = new Curator("localhost:2281");
+ //this.zooKeeperServer = null;
+ this.curator = new MockCurator();
+ }
+
+ private static ZookeeperServerConfig createZookeeperServerConfig() {
+ ZookeeperServerConfig.Builder b = new ZookeeperServerConfig.Builder();
+ b.zooKeeperConfigFile("conf/zookeeper/controller-zookeeper.cfg");
+ b.dataDir("var/controller-zookeeper");
+ b.clientPort(2281);
+ b.myidFile("var/controller-zookeeper/myid");
+ b.myid(0);
+ ZookeeperServerConfig.Server.Builder server = new ZookeeperServerConfig.Server.Builder();
+ server.id(0);
+ server.hostname("localhost");
+ server.quorumPort(2282);
+ server.electionPort(2283);
+ b.server(server);
+ return new ZookeeperServerConfig(b);
+ }
+
+ /** Create a curator db which does not set uop a server, using the given Curator instance */
+ protected CuratorDb(Curator curator) {
+ //this.zooKeeperServer = null;
+ this.curator = curator;
+ }
+
+ // -------------- Locks --------------------------------------------------
+
+ public Lock lock(TenantId id, Duration timeout) {
+ return lock(lockPath(id), timeout);
+ }
+
+ public Lock lock(ApplicationId id, Duration timeout) {
+ return lock(lockPath(id), timeout);
+ }
+
+ /** Create a reentrant lock */
+ private Lock lock(Path path, Duration timeout) {
+ Lock lock = locks.computeIfAbsent(path, (pathArg) -> new Lock(pathArg.getAbsolute(), curator));
+ lock.acquire(timeout);
+ return lock;
+ }
+
+ public Lock lockInactiveJobs() {
+ return lock(root.append("locks").append("inactiveJobsLock"), defaultLockTimeout);
+ }
+
+ public Lock lockJobQueues() {
+ return lock(root.append("locks").append("jobQueuesLock"), defaultLockTimeout);
+ }
+
+ public Lock lockMaintenanceJob(String jobName) {
+ // Use a short timeout such that if maintenance jobs are started at about the same time on different nodes
+ // and the maintenance job takes a long time to complete, only one of the nodes will run the job
+ // in each maintenance interval
+ return lock(root.append("locks").append("maintenanceJobLocks").append(jobName), Duration.ofSeconds(1));
+ }
+
+ // -------------- Read and write --------------------------------------------------
+
+ public Version readSystemVersion() {
+ Optional<byte[]> data = curator.getData(systemVersionPath());
+ if (! data.isPresent() || data.get().length == 0) return Vtag.currentVersion;
+ return Version.fromString(new String(data.get(), StandardCharsets.UTF_8));
+ }
+
+ public void writeSystemVersion(Version version) {
+ NestedTransaction transaction = new NestedTransaction();
+ curator.set(systemVersionPath(), version.toString().getBytes(StandardCharsets.UTF_8));
+ transaction.commit();
+ }
+
+ public Set<String> readInactiveJobs() {
+ try {
+ Optional<byte[]> data = curator.getData(inactiveJobsPath());
+ if (! data.isPresent() || data.get().length == 0) return new HashSet<>(); // inactive jobs has never been written
+ return stringSetSerializer.fromJson(data.get());
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Error reading inactive jobs, deleting inactive state");
+ writeInactiveJobs(Collections.emptySet());
+ return new HashSet<>();
+ }
+ }
+
+ public void writeInactiveJobs(Set<String> inactiveJobs) {
+ NestedTransaction transaction = new NestedTransaction();
+ curator.set(inactiveJobsPath(), stringSetSerializer.toJson(inactiveJobs));
+ transaction.commit();
+ }
+
+ public Deque<ApplicationId> readJobQueue(DeploymentJobs.JobType jobType) {
+ try {
+ Optional<byte[]> data = curator.getData(jobQueuePath(jobType));
+ if (! data.isPresent() || data.get().length == 0) return new ArrayDeque<>(); // job queue has never been written
+ return jobQueueSerializer.fromJson(data.get());
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Error reading job queue, deleting inactive state");
+ writeInactiveJobs(Collections.emptySet());
+ return new ArrayDeque<>();
+ }
+ }
+
+ public void writeJobQueue(DeploymentJobs.JobType jobType, Deque<ApplicationId> queue) {
+ NestedTransaction transaction = new NestedTransaction();
+ curator.set(jobQueuePath(jobType), jobQueueSerializer.toJson(queue));
+ transaction.commit();
+ }
+
+ // -------------- Paths --------------------------------------------------
+
+ private Path systemVersionPath() {
+ return root.append("systemVersion");
+ }
+
+ private Path lockPath(TenantId tenant) {
+ Path lockPath = root.append("locks")
+ .append(tenant.id());
+ curator.create(lockPath);
+ return lockPath;
+ }
+
+ private Path lockPath(ApplicationId application) {
+ Path lockPath = root.append("locks")
+ .append(application.tenant().value())
+ .append(application.application().value())
+ .append(application.instance().value());
+ curator.create(lockPath);
+ return lockPath;
+ }
+
+ private Path inactiveJobsPath() {
+ return root.append("inactiveJobs");
+ }
+
+ private Path jobQueuePath(DeploymentJobs.JobType jobType) {
+ return root.append("jobQueues").append(jobType.name());
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/JobQueueSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/JobQueueSerializer.java
new file mode 100644
index 00000000000..5017624f286
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/JobQueueSerializer.java
@@ -0,0 +1,45 @@
+// 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.persistence;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.slime.ArrayTraverser;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Inspector;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.config.SlimeUtils;
+
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Serialization of a queue of ApplicationIds to/from Json bytes using Slime.
+ *
+ * The set is serialized as an array of string.
+ *
+ * @author bratseth
+ */
+public class JobQueueSerializer {
+
+ public byte[] toJson(Deque<ApplicationId> queue) {
+ try {
+ Slime slime = new Slime();
+ Cursor array = slime.setArray();
+ queue.forEach((id -> array.addString(id.serializedForm())));
+ return SlimeUtils.toJsonBytes(slime);
+ }
+ catch (IOException e) {
+ throw new RuntimeException("Serialization of a job queue failed", e);
+ }
+ }
+
+ public Deque<ApplicationId> fromJson(byte[] data) {
+ Inspector inspector = SlimeUtils.jsonToSlime(data).get();
+ Deque<ApplicationId> queue = new ArrayDeque<>();
+ inspector.traverse((ArrayTraverser) (index, value) -> queue.addLast(ApplicationId.fromSerializedForm(value.asString())));
+ return queue;
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MemoryControllerDb.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MemoryControllerDb.java
new file mode 100644
index 00000000000..37677a5e393
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MemoryControllerDb.java
@@ -0,0 +1,132 @@
+// 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.persistence;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.vespa.hosted.controller.AlreadyExistsException;
+import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.NotExistsException;
+import com.yahoo.vespa.hosted.controller.api.Tenant;
+import com.yahoo.vespa.hosted.controller.api.identifiers.RotationId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * A controller db implementation backed by in-memory structures. Useful for testing.
+ *
+ * @author Stian Kristoffersen
+ */
+public class MemoryControllerDb extends ControllerDb {
+
+ private Map<TenantId, Tenant> tenants = new HashMap<>();
+ private Map<String, Application> applications = new HashMap<>();
+ private Map<RotationId, ApplicationId> rotationAssignments = new HashMap<>();
+
+ @Override
+ public void createTenant(Tenant tenant) {
+ if (tenants.containsKey(tenant.getId())) {
+ throw new AlreadyExistsException(tenant.getId());
+ }
+ tenants.put(tenant.getId(), tenant);
+ }
+
+ @Override
+ public void updateTenant(Tenant tenant) {
+ if (!tenants.containsKey(tenant.getId())) {
+ throw new NotExistsException(tenant.getId());
+ }
+ tenants.put(tenant.getId(), tenant);
+ }
+
+ @Override
+ public void deleteTenant(TenantId tenantId) {
+ Object removed = tenants.remove(tenantId);
+ if (removed == null)
+ throw new NotExistsException(tenantId);
+ }
+
+ @Override
+ public Optional<Tenant> getTenant(TenantId tenantId) throws PersistenceException {
+ Optional<Tenant> tenant = Optional.ofNullable(tenants.get(tenantId));
+ if(tenant.isPresent()) {
+ Tenant t_noquota = tenant.get();
+ Tenant t_withquota = new Tenant(
+ t_noquota.getId(), t_noquota.getUserGroup(), t_noquota.getProperty(),
+ t_noquota.getAthensDomain(), t_noquota.getPropertyId());
+ return Optional.of(t_withquota);
+ } else {
+ return tenant;
+ }
+ }
+
+ @Override
+ public List<Tenant> listTenants() {
+ return new ArrayList<>(tenants.values());
+ }
+
+ @Override
+ public void store(Application application) {
+ applications.put(path(application.id()), application);
+ }
+
+ @Override
+ public void deleteApplication(ApplicationId applicationId) {
+ applications.remove(path(applicationId));
+ }
+
+ @Override
+ public Optional<Application> getApplication(ApplicationId applicationId) {
+ return Optional.ofNullable(applications.get(path(applicationId)));
+ }
+
+ @Override
+ public List<Application> listApplications() {
+ return new ArrayList<>(applications.values());
+ }
+
+ @Override
+ public List<Application> listApplications(TenantId tenantId) {
+ return applications.values().stream()
+ .filter(a -> a.id().tenant().value().equals(tenantId.id()))
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public Set<RotationId> getRotations() {
+ return rotationAssignments.keySet();
+ }
+
+ @Override
+ public Set<RotationId> getRotations(ApplicationId applicationId) {
+ return rotationAssignments.entrySet().stream()
+ .filter(entry -> entry.getValue().equals(applicationId))
+ .map(Map.Entry::getKey)
+ .collect(Collectors.toSet());
+ }
+
+ @Override
+ public boolean assignRotation(RotationId rotationId, ApplicationId applicationId) {
+ if (rotationAssignments.containsKey(rotationId)) {
+ return false;
+ } else {
+ rotationAssignments.put(rotationId, applicationId);
+ return true;
+ }
+ }
+
+ @Override
+ public Set<RotationId> deleteRotations(ApplicationId applicationId) {
+ Set<RotationId> rotations = getRotations(applicationId);
+ for (RotationId rotation : rotations) {
+ rotationAssignments.remove(rotation);
+ }
+ return rotations;
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MockCuratorDb.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MockCuratorDb.java
new file mode 100644
index 00000000000..5dc8ca0e545
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MockCuratorDb.java
@@ -0,0 +1,18 @@
+// 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.persistence;
+
+import com.yahoo.vespa.curator.mock.MockCurator;
+
+/**
+ * A curator db backed by a mock curator.
+ *
+ * @author bratseth
+ */
+@SuppressWarnings("unused") // injected
+public class MockCuratorDb extends CuratorDb {
+
+ public MockCuratorDb() {
+ super(new MockCurator());
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/PersistenceException.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/PersistenceException.java
new file mode 100644
index 00000000000..b963ecbfab9
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/PersistenceException.java
@@ -0,0 +1,19 @@
+// 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.persistence;
+
+/**
+ * Exception thrown by persistence layer.
+ *
+ * @author mpolden
+ */
+public class PersistenceException extends Exception {
+
+ public PersistenceException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public PersistenceException(Throwable cause) {
+ super(cause);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/StringSetSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/StringSetSerializer.java
new file mode 100644
index 00000000000..83715e16e8e
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/StringSetSerializer.java
@@ -0,0 +1,44 @@
+// 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.persistence;
+
+import com.yahoo.slime.ArrayTraverser;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Inspector;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.config.SlimeUtils;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Serialization of a set of strings to/from Json bytes using Slime.
+ *
+ * The set is serialized as an array of string.
+ *
+ * @author bratseth
+ */
+public class StringSetSerializer {
+
+ public byte[] toJson(Set<String> stringSet) {
+ try {
+ Slime slime = new Slime();
+ Cursor array = slime.setArray();
+ for (String element : stringSet)
+ array.addString(element);
+ return SlimeUtils.toJsonBytes(slime);
+ }
+ catch (IOException e) {
+ throw new RuntimeException("Serialization of a string set failed", e);
+ }
+
+ }
+
+ public Set<String> fromJson(byte[] data) {
+ Inspector inspector = SlimeUtils.jsonToSlime(data).get();
+ Set<String> stringSet = new HashSet<>();
+ inspector.traverse((ArrayTraverser) (index, name) -> stringSet.add(name.asString()));
+ return stringSet;
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/package-info.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/package-info.java
new file mode 100644
index 00000000000..87a14660fee
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/package-info.java
@@ -0,0 +1,10 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Persistence layer for the controller.
+ *
+ * @author bratseth
+ */
+@ExportPackage
+package com.yahoo.vespa.hosted.controller.persistence;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ErrorResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ErrorResponse.java
new file mode 100644
index 00000000000..a9643e21c00
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ErrorResponse.java
@@ -0,0 +1,66 @@
+// 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.restapi;
+
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServerException;
+import com.yahoo.yolean.Exceptions;
+
+import static com.yahoo.jdisc.Response.Status.BAD_REQUEST;
+import static com.yahoo.jdisc.Response.Status.FORBIDDEN;
+import static com.yahoo.jdisc.Response.Status.INTERNAL_SERVER_ERROR;
+import static com.yahoo.jdisc.Response.Status.METHOD_NOT_ALLOWED;
+import static com.yahoo.jdisc.Response.Status.NOT_FOUND;
+
+/**
+ * A HTTP JSON response containing an error code and a message
+ *
+ * @author bratseth
+ */
+public class ErrorResponse extends SlimeJsonResponse {
+
+ public enum errorCodes {
+ NOT_FOUND,
+ BAD_REQUEST,
+ FORBIDDEN,
+ METHOD_NOT_ALLOWED,
+ INTERNAL_SERVER_ERROR
+ }
+
+ public ErrorResponse(int statusCode, String errorType, String message) {
+ super(statusCode, asSlimeMessage(errorType, message));
+ }
+
+ private static Slime asSlimeMessage(String errorType, String message) {
+ Slime slime = new Slime();
+ Cursor root = slime.setObject();
+ root.setString("error-code", errorType);
+ root.setString("message", message);
+ return slime;
+ }
+
+ public static ErrorResponse notFoundError(String message) {
+ return new ErrorResponse(NOT_FOUND, errorCodes.NOT_FOUND.name(), message);
+ }
+
+ public static ErrorResponse internalServerError(String message) {
+ return new ErrorResponse(INTERNAL_SERVER_ERROR, errorCodes.INTERNAL_SERVER_ERROR.name(), message);
+ }
+
+ public static ErrorResponse badRequest(String message) {
+ return new ErrorResponse(BAD_REQUEST, errorCodes.BAD_REQUEST.name(), message);
+ }
+
+ public static ErrorResponse forbidden(String message) {
+ return new ErrorResponse(FORBIDDEN, errorCodes.FORBIDDEN.name(), message);
+ }
+
+ public static ErrorResponse methodNotAllowed(String message) {
+ return new ErrorResponse(METHOD_NOT_ALLOWED, errorCodes.METHOD_NOT_ALLOWED.name(), message);
+ }
+
+ public static ErrorResponse from(ConfigServerException e) {
+ return new ErrorResponse(BAD_REQUEST, e.getErrorCode().name(), Exceptions.toMessageString(e));
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/MessageResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/MessageResponse.java
new file mode 100644
index 00000000000..8b2f0e9f09d
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/MessageResponse.java
@@ -0,0 +1,31 @@
+// 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.restapi;
+
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.slime.JsonFormat;
+import com.yahoo.slime.Slime;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author bratseth
+ */
+public class MessageResponse extends HttpResponse {
+
+ private final Slime slime = new Slime();
+
+ public MessageResponse(String message) {
+ super(200);
+ slime.setObject().setString("message", message);
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ new JsonFormat(true).encode(stream, slime);
+ }
+
+ @Override
+ public String getContentType() { return "application/json"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Path.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Path.java
new file mode 100644
index 00000000000..c8c027d91c9
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Path.java
@@ -0,0 +1,109 @@
+// 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.restapi;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * A path which is able to match strings containing bracketed placeholders and return the
+ * values given at the placeholders.
+ *
+ * E.g a path /a/1/bar/fuz
+ * will match /a/{foo}/bar/{b}
+ * and return foo=1 and b=fuz
+ *
+ * Only full path elements may be placeholders, i.e /a{bar} is not interpreted as one.
+ *
+ * If the path spec ends with /{*}, it will match urls with any rest path.
+ * The rest path (not including the trailing slash) will be available as getRest().
+ *
+ * Note that for convenience in common use this has state which is changes as a side effect of each matches
+ * invocation. It is therefore for single thread use.
+ *
+ * @author bratseth
+ */
+public class Path {
+
+ // This path
+ private final String pathString;
+ private final String[] elements;
+
+ // Info about the last match
+ private final Map<String, String> values = new HashMap<>();
+ private String rest = "";
+
+ public Path(String path) {
+ this.pathString = path;
+ this.elements = path.split("/");
+ }
+
+ /**
+ * Returns whether this path matches the given template string.
+ * If the given template has placeholders, their values (accessible by get) are reset by calling this,
+ * whether or not the path matches the given template.
+ *
+ * This will NOT match empty path elements.
+ *
+ * @param pathSpec the path string to match to this
+ * @return true if the string matches, false otherwise
+ */
+ public boolean matches(String pathSpec) {
+ values.clear();
+ String[] specElements = pathSpec.split("/");
+ boolean matchPrefix = false;
+ if (specElements[specElements.length-1].equals("{*}")) {
+ matchPrefix = true;
+ specElements = Arrays.copyOf(specElements, specElements.length-1);
+ }
+
+ if (matchPrefix) {
+ if (this.elements.length < specElements.length) return false;
+ }
+ else { // match exact
+ if (this.elements.length != specElements.length) return false;
+ }
+
+ for (int i = 0; i < specElements.length; i++) {
+ if (specElements[i].startsWith("{") && specElements[i].endsWith("}")) // placeholder
+ values.put(specElements[i].substring(1, specElements[i].length()-1), elements[i]);
+ else if ( ! specElements[i].equals(this.elements[i]))
+ return false;
+ }
+
+ if (matchPrefix) {
+ StringBuilder rest = new StringBuilder();
+ for (int i = specElements.length; i < this.elements.length; i++)
+ rest.append(elements[i]).append("/");
+ if ( ! pathString.endsWith("/"))
+ rest.setLength(rest.length() -1);
+ this.rest = rest.toString();
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the value of the given template variable in the last path matched, or null
+ * if the previous matches call returned false or if this has not matched anything yet.
+ */
+ public String get(String placeholder) {
+ return values.get(placeholder);
+ }
+
+ /**
+ * Returns the rest of the last matched path.
+ * This is always the empty string (never null) unless the path spec ends with {*}
+ */
+ public String getRest() { return rest; }
+
+ /** Returns this path as a string */
+ public String asString() { return pathString; }
+
+ @Override
+ public String toString() {
+ return "path '" + Arrays.stream(elements).collect(Collectors.joining("/")) + "'";
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ResourceResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ResourceResponse.java
new file mode 100644
index 00000000000..550b47d8280
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ResourceResponse.java
@@ -0,0 +1,42 @@
+// 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.restapi;
+
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.JsonFormat;
+import com.yahoo.slime.Slime;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Returns a response containing an array of links to sub-resources
+ *
+ * @author bratseth
+ */
+public class ResourceResponse extends HttpResponse {
+
+ private final Slime slime = new Slime();
+
+ public ResourceResponse(HttpRequest request, String ... subResources) {
+ super(200);
+ Cursor resourceArray = slime.setObject().setArray("resources");
+ for (String subResource : subResources) {
+ Cursor resourceEntry = resourceArray.addObject();
+ resourceEntry.setString("url", new Uri(request.getUri())
+ .append(subResource)
+ .withTrailingSlash()
+ .toString());
+ }
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ new JsonFormat(true).encode(stream, slime);
+ }
+
+ @Override
+ public String getContentType() { return "application/json"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/RootHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/RootHandler.java
new file mode 100644
index 00000000000..9283b1c3018
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/RootHandler.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.restapi;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.logging.AccessLog;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
+import java.util.concurrent.Executor;
+
+/**
+ * Responds to requests for the root path of the controller by listing the available web service API's.
+ *
+ * FAQ:
+ * - Q: Why do we need this when the container provides a perfectly fine root response listing all handlers by default?
+ * - A: Because we also have Jersey API's and those are not included in the default response.
+ *
+ * @author Oyvind Gronnesby
+ * @author bratseth
+ */
+public class RootHandler extends LoggingRequestHandler {
+
+ public RootHandler(Executor executor, AccessLog accessLog) {
+ super(executor, accessLog);
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest httpRequest) {
+ final URI requestUri = httpRequest.getUri();
+ return new ControllerRootPathResponse(requestUri);
+ }
+
+ private static class ControllerRootPathResponse extends HttpResponse {
+
+ private final URI uri;
+
+ public ControllerRootPathResponse(URI uri) {
+ super(200);
+ this.uri = uri;
+ }
+
+ @Override
+ public void render(OutputStream outputStream) throws IOException {
+ ObjectMapper mapper = new ObjectMapper();
+ mapper.writeValue(outputStream, buildResponseObject());
+ }
+
+ @Override
+ public String getContentType() {
+ return "application/json";
+ }
+
+ private JsonNode buildResponseObject() {
+ ObjectNode output = new ObjectNode(JsonNodeFactory.instance);
+ ArrayNode services = output.putArray("services");
+
+ jerseyService(services, "provision", "/provision/v1/", "/provision/application.wadl");
+ jerseyService(services, "statuspage", "/statuspage/v1/", "/statuspage/application.wadl");
+ jerseyService(services, "zone", "/zone/v1/", "/zone/application.wadl");
+ jerseyService(services, "zone", "/zone/v2/", "/zone/application.wadl");
+ jerseyService(services, "cost", "/cost/v1/", "/cost/application.wadl");
+ handlerService(services, "application", "/application/v4/");
+ handlerService(services, "deployment", "/deployment/v1/");
+ handlerService(services, "screwdriver", "/screwdriver/v1/release/vespa");
+
+ return output;
+ }
+
+ private void jerseyService(ArrayNode parent, String name, String url, String wadl) {
+ ObjectNode service = parent.addObject();
+ service.put("name", name);
+ service.put("url", controllerUri(url));
+ service.put("wadl", controllerUri(wadl));
+ }
+
+ private void handlerService(ArrayNode parent, String name, String url) {
+ ObjectNode service = parent.addObject();
+ service.put("name", name);
+ service.put("url", controllerUri(url));
+ }
+
+ private String controllerUri(String path) {
+ return uri.resolve(path).toString();
+ }
+
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/SlimeJsonResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/SlimeJsonResponse.java
new file mode 100644
index 00000000000..81b07b81efb
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/SlimeJsonResponse.java
@@ -0,0 +1,38 @@
+// 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.restapi;
+
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.slime.JsonFormat;
+import com.yahoo.slime.Slime;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * A generic Json response using Slime for JSON encoding
+ *
+ * @author bratseth
+ */
+public class SlimeJsonResponse extends HttpResponse {
+
+ private final Slime slime;
+
+ public SlimeJsonResponse(Slime slime) {
+ super(200);
+ this.slime = slime;
+ }
+
+ public SlimeJsonResponse(int statusCode, Slime slime) {
+ super(statusCode);
+ this.slime = slime;
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ new JsonFormat(true).encode(stream, slime);
+ }
+
+ @Override
+ public String getContentType() { return "application/json"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/StringResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/StringResponse.java
new file mode 100644
index 00000000000..1fc30b7d880
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/StringResponse.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.restapi;
+
+import com.yahoo.container.jdisc.HttpResponse;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author bratseth
+ */
+public class StringResponse extends HttpResponse {
+
+ private final String message;
+
+ public StringResponse(String message) {
+ super(200);
+ this.message = message;
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ stream.write(message.getBytes("utf-8"));
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Uri.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Uri.java
new file mode 100644
index 00000000000..479e7434f9b
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Uri.java
@@ -0,0 +1,64 @@
+// 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.restapi;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+/**
+ * A Uri which provides convenience methods for creating various manipulated copies.
+ * This is immutable.
+ *
+ * @author bratseth
+ */
+public class Uri {
+
+ /** The URI instance wrapped by this */
+ private final URI uri;
+
+ public Uri(URI uri) {
+ this.uri = uri;
+ }
+
+ public Uri(String uri) {
+ try {
+ this.uri = new URI(uri);
+ }
+ catch (URISyntaxException e) {
+ throw new IllegalArgumentException("Invalid URI", e);
+ }
+ }
+
+ /** Returns a uri with the given path appended and all parameters removed */
+ public Uri append(String pathElement) {
+ return new Uri(withoutParameters().withTrailingSlash() + pathElement);
+ }
+
+ public Uri withoutParameters() {
+ int parameterStart = uri.toString().indexOf("?");
+ if (parameterStart < 0)
+ return new Uri(uri.toString());
+ else
+ return new Uri(uri.toString().substring(0, parameterStart));
+ }
+
+ public Uri withPath(String path) {
+ try {
+ return new Uri(new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(),
+ uri.getPort(), path, uri.getQuery(), uri.getFragment()));
+ }
+ catch (URISyntaxException e) {
+ throw new IllegalArgumentException("Could not add path '" + path + "' to " + this);
+ }
+ }
+
+ public Uri withTrailingSlash() {
+ if (toString().endsWith("/")) return this;
+ return new Uri(toString() + "/");
+ }
+
+ public URI toURI() { return uri; }
+
+ @Override
+ public String toString() { return uri.toString(); }
+
+}
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
new file mode 100644
index 00000000000..d701f3d57a0
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java
@@ -0,0 +1,1065 @@
+// 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.restapi.application;
+
+import com.google.common.base.Joiner;
+import com.yahoo.component.Version;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.ApplicationName;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.config.provision.RegionName;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.config.provision.Zone;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.io.IOUtils;
+import com.yahoo.log.LogLevel;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Inspector;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.config.SlimeUtils;
+import com.yahoo.vespa.curator.Lock;
+import com.yahoo.vespa.hosted.controller.AlreadyExistsException;
+import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.NotExistsException;
+import com.yahoo.vespa.hosted.controller.api.ActivateResult;
+import com.yahoo.vespa.hosted.controller.api.InstanceEndpoints;
+import com.yahoo.vespa.hosted.controller.api.Tenant;
+import com.yahoo.vespa.hosted.controller.api.application.v4.ApplicationResource;
+import com.yahoo.vespa.hosted.controller.api.application.v4.EnvironmentResource;
+import com.yahoo.vespa.hosted.controller.api.application.v4.TenantResource;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.DeployOptions;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.EndpointStatus;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.GitRevision;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.ScrewdriverBuildJob;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.configserverbindings.RefeedAction;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.configserverbindings.RestartAction;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.configserverbindings.ServiceInfo;
+import com.yahoo.vespa.hosted.controller.api.identifiers.AthensDomain;
+import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.GitBranch;
+import com.yahoo.vespa.hosted.controller.api.identifiers.GitCommit;
+import com.yahoo.vespa.hosted.controller.api.identifiers.GitRepository;
+import com.yahoo.vespa.hosted.controller.api.identifiers.Hostname;
+import com.yahoo.vespa.hosted.controller.api.identifiers.Property;
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.ScrewdriverId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.UserGroup;
+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.athens.AthensPrincipal;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.NToken;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ZmsException;
+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.cost.ApplicationCost;
+import com.yahoo.vespa.hosted.controller.api.integration.cost.CostJsonModelAdapter;
+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;
+import com.yahoo.vespa.hosted.controller.application.Change;
+import com.yahoo.vespa.hosted.controller.application.Deployment;
+import com.yahoo.vespa.hosted.controller.application.JobStatus;
+import com.yahoo.vespa.hosted.controller.application.SourceRevision;
+import com.yahoo.vespa.hosted.controller.common.NotFoundCheckedException;
+import com.yahoo.vespa.hosted.controller.restapi.ErrorResponse;
+import com.yahoo.vespa.hosted.controller.restapi.MessageResponse;
+import com.yahoo.vespa.hosted.controller.restapi.Path;
+import com.yahoo.vespa.hosted.controller.restapi.ResourceResponse;
+import com.yahoo.vespa.hosted.controller.restapi.SlimeJsonResponse;
+import com.yahoo.vespa.hosted.controller.restapi.StringResponse;
+import com.yahoo.vespa.hosted.controller.restapi.filter.SetBouncerPassthruHeaderFilter;
+import com.yahoo.vespa.serviceview.bindings.ApplicationView;
+import com.yahoo.yolean.Exceptions;
+
+import javax.ws.rs.BadRequestException;
+import javax.ws.rs.ForbiddenException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.Principal;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.logging.Level;
+
+/**
+ * This implements the application/v4 API which is used to deploy and manage applications
+ * on hosted Vespa.
+ *
+ * @author bratseth
+ */
+public class ApplicationApiHandler extends LoggingRequestHandler {
+
+ private final Controller controller;
+ private final Authorizer authorizer;
+
+ public ApplicationApiHandler(Executor executor, AccessLog accessLog, Controller controller, Authorizer authorizer) {
+ super(executor, accessLog);
+ this.controller = controller;
+ this.authorizer = authorizer;
+ }
+
+ @Override
+ public Duration getTimeout() {
+ return Duration.ofMinutes(20); // deploys may take a long time;
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest request) {
+ try {
+ switch (request.getMethod()) {
+ case GET: return handleGET(request);
+ case PUT: return handlePUT(request);
+ case POST: return handlePOST(request);
+ case DELETE: return handleDELETE(request);
+ case OPTIONS: return handleOPTIONS(request);
+ default: return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is not supported");
+ }
+ }
+ catch (ForbiddenException e) {
+ return ErrorResponse.forbidden(Exceptions.toMessageString(e));
+ }
+ catch (NotExistsException e) {
+ return ErrorResponse.notFoundError(Exceptions.toMessageString(e));
+ }
+ catch (IllegalArgumentException e) {
+ return ErrorResponse.badRequest(Exceptions.toMessageString(e));
+ }
+ catch (ConfigServerException e) {
+ return ErrorResponse.from(e);
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Unexpected error handling '" + request.getUri() + "'", e);
+ return ErrorResponse.internalServerError(Exceptions.toMessageString(e));
+ }
+ }
+
+ private HttpResponse handleGET(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/application/v4/")) return root(request);
+ if (path.matches("/application/v4/user")) return authenticatedUser(request);
+ if (path.matches("/application/v4/tenant")) return tenants(request);
+ if (path.matches("/application/v4/tenant-pipeline")) return tenantPipelines();
+ if (path.matches("/application/v4/athensDomain")) return athensDomains(request);
+ if (path.matches("/application/v4/property")) return properties(request);
+ if (path.matches("/application/v4/cookiefreshness")) return cookieFreshness(request);
+ if (path.matches("/application/v4/tenant/{tenant}")) return tenant(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application")) return applications(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return application(path.get("tenant"), path.get("application"), path, request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}")) return deployment(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/converge")) return waitForConvergence(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/service")) return services(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/service/{service}/{*}")) return service(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), path.get("service"), path.getRest(), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation")) return rotationStatus(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"));
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation/override"))
+ return getGlobalRotationOverride(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"));
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handlePUT(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/application/v4/user")) return createUser(request);
+ if (path.matches("/application/v4/tenant/{tenant}")) return updateTenant(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation/override"))
+ return setGlobalRotationOverride(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), false, request);
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handlePOST(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/application/v4/tenant/{tenant}/migrateTenantToAthens")) return migrateTenant(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}")) return createTenant(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return createApplication(path.get("tenant"), path.get("application"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/promote")) return promoteApplication(path.get("tenant"), path.get("application"));
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/deploying")) return deploy(path.get("tenant"), path.get("application"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}")) return deploy(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/deploy")) return deploy(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request); // legacy synonym of the above
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/restart")) return restart(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/log")) return log(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"));
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/promote")) return promoteApplicationDeployment(path.get("tenant"), path.get("application"), path.get("environment"), path.get("region"));
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handleDELETE(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/application/v4/tenant/{tenant}")) return deleteTenant(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return deleteApplication(path.get("tenant"), path.get("application"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}")) return deactivate(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"));
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation/override"))
+ return setGlobalRotationOverride(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), true, request);
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handleOPTIONS(HttpRequest request) {
+ // We implement this to avoid redirect loops on OPTIONS requests from browsers, but do not really bother
+ // spelling out the methods supported at each path, which we should
+ EmptyJsonResponse response = new EmptyJsonResponse();
+ response.headers().put("Allow", "GET,PUT,POST,DELETE,OPTIONS");
+ return response;
+ }
+
+ private HttpResponse root(HttpRequest request) {
+ return new ResourceResponse(request,
+ "user", "tenant", "tenant-pipeline", "athensDomain", "property", "cookiefreshness");
+ }
+
+ private HttpResponse authenticatedUser(HttpRequest request) {
+ String userIdString = request.getProperty("userOverride");
+ if (userIdString == null)
+ userIdString = userFrom(request)
+ .orElseThrow(() -> new ForbiddenException("You must be authenticated or specify userOverride"));
+ UserId userId = new UserId(userIdString);
+
+ List<Tenant> tenants = controller.tenants().asList(userId);
+
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+ response.setString("user", userId.id());
+ Cursor tenantsArray = response.setArray("tenants");
+ for (Tenant tenant : tenants)
+ tenantInTenantsListToSlime(tenant, request.getUri(), tenantsArray.addObject());
+ response.setBool("tenantExists", tenants.stream().map(Tenant::getId).anyMatch(id -> id.isTenantFor(userId)));
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse tenants(HttpRequest request) {
+ Slime slime = new Slime();
+ Cursor response = slime.setArray();
+ for (Tenant tenant : controller.tenants().asList())
+ tenantInTenantsListToSlime(tenant, request.getUri(), response.addObject());
+ return new SlimeJsonResponse(slime);
+ }
+
+ /** Lists the screwdriver project id for each application */
+ private HttpResponse tenantPipelines() {
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+ Cursor pipelinesArray = response.setArray("tenantPipelines");
+ for (Application application : controller.applications().asList()) {
+ if ( ! application.deploymentJobs().projectId().isPresent()) continue;
+
+ Cursor pipelineObject = pipelinesArray.addObject();
+ pipelineObject.setString("screwdriverId", String.valueOf(application.deploymentJobs().projectId().get()));
+ pipelineObject.setString("tenant", application.id().tenant().value());
+ pipelineObject.setString("application", application.id().application().value());
+ pipelineObject.setString("instance", application.id().instance().value());
+ }
+ response.setArray("brokenTenantPipelines"); // not used but may need to be present
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse athensDomains(HttpRequest request) {
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+ Cursor array = response.setArray("data");
+ for (AthensDomain athensDomain : controller.getDomainList(request.getProperty("prefix"))) {
+ array.addString(athensDomain.id());
+ }
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse properties(HttpRequest request) {
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+ Cursor array = response.setArray("properties");
+ for (Map.Entry<PropertyId, Property> entry : controller.fetchPropertyList().entrySet()) {
+ Cursor propertyObject = array.addObject();
+ propertyObject.setString("propertyid", entry.getKey().id());
+ propertyObject.setString("property", entry.getValue().id());
+ }
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse cookieFreshness(HttpRequest request) {
+ Slime slime = new Slime();
+ String passThruHeader = request.getHeader(SetBouncerPassthruHeaderFilter.BOUNCER_PASSTHRU_HEADER_FIELD);
+ slime.setObject().setBool("shouldRefreshCookie",
+ ! SetBouncerPassthruHeaderFilter.BOUNCER_PASSTHRU_COOKIE_OK.equals(passThruHeader));
+ return new SlimeJsonResponse(slime);
+ }
+
+ 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));
+ }
+
+ private HttpResponse applications(String tenantName, HttpRequest request) {
+ TenantName tenant = TenantName.from(tenantName);
+ Slime slime = new Slime();
+ Cursor array = slime.setArray();
+ for (Application application : controller.applications().asList(tenant))
+ toSlime(application, array.addObject(), request);
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse application(String tenantName, String applicationName, Path path, HttpRequest request) {
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+
+ com.yahoo.config.provision.ApplicationId applicationId = com.yahoo.config.provision.ApplicationId.from(tenantName, applicationName, "default");
+ Application application =
+ controller.applications().get(applicationId)
+ .orElseThrow(() -> new NotExistsException(applicationId + " not found"));
+
+ // Currently deploying change
+ if (application.deploying().isPresent()) {
+ Cursor deployingObject = response.setObject("deploying");
+ if (application.deploying().get() instanceof Change.VersionChange)
+ deployingObject.setString("version", ((Change.VersionChange)application.deploying().get()).version().toString());
+ else if (((Change.ApplicationChange)application.deploying().get()).revision().isPresent())
+ toSlime(((Change.ApplicationChange)application.deploying().get()).revision().get(), deployingObject.setObject("revision"));
+ }
+
+ // Deployment jobs
+ Cursor deploymentsArray = response.setArray("deploymentJobs");
+ for (JobStatus job : application.deploymentJobs().jobStatus().values()) {
+ Cursor jobObject = deploymentsArray.addObject();
+ jobObject.setString("type", job.type().id());
+ jobObject.setBool("success", job.isSuccess());
+
+ job.lastTriggered().ifPresent(jobRun -> toSlime(jobRun, jobObject.setObject("lastTriggered")));
+ job.lastCompleted().ifPresent(jobRun -> toSlime(jobRun, jobObject.setObject("lastCompleted")));
+ job.firstFailing().ifPresent(jobRun -> toSlime(jobRun, jobObject.setObject("firstFailing")));
+ job.lastSuccess().ifPresent(jobRun -> toSlime(jobRun, jobObject.setObject("lastSuccess")));
+ }
+
+ // Compile version. The version that should be used when building an application
+ response.setString("compileVersion", application.compileVersion(controller).toFullString());
+
+ // Rotations
+ Cursor globalRotationsArray = response.setArray("globalRotations");
+ Set<URI> rotations = controller.getRotationUris(applicationId);
+ Map<String, RotationStatus> rotationHealthStatus =
+ rotations.isEmpty() ? Collections.emptyMap() : controller.getHealthStatus(rotations.iterator().next().getHost());
+ for (URI rotation : rotations)
+ globalRotationsArray.addString(rotation.toString());
+
+ // Deployments
+ Cursor instancesArray = response.setArray("instances");
+ for (Deployment deployment : application.deployments().values()) {
+ Cursor deploymentObject = instancesArray.addObject();
+ deploymentObject.setString("environment", deployment.zone().environment().value());
+ deploymentObject.setString("region", deployment.zone().region().value());
+ deploymentObject.setString("instance", application.id().instance().value()); // pointless
+ if ( ! rotations.isEmpty())
+ setRotationStatus(deployment, rotationHealthStatus, deploymentObject);
+ deploymentObject.setString("url", withPath(path.asString() +
+ "/environment/" + deployment.zone().environment().value() +
+ "/region/" + deployment.zone().region().value() +
+ "/instance/" + application.id().instance().value(),
+ request.getUri()).toString());
+ }
+
+ // Metrics
+ try {
+ MetricsService.ApplicationMetrics metrics = controller.metricsService().getApplicationMetrics(applicationId);
+ Cursor metricsObject = response.setObject("metrics");
+ metricsObject.setDouble("queryServiceQuality", metrics.queryServiceQuality());
+ metricsObject.setDouble("writeServiceQuality", metrics.writeServiceQuality());
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Failed getting Yamas metrics", e);
+ }
+
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse deployment(String tenantName, String applicationName, String instanceName, String environment, String region, HttpRequest request) {
+ ApplicationId id = ApplicationId.from(tenantName, applicationName, instanceName);
+ Application application = controller.applications().get(id)
+ .orElseThrow(() -> new NotExistsException(id + " not found"));
+
+ DeploymentId deploymentId = new DeploymentId(application.id(),
+ new Zone(Environment.from(environment), RegionName.from(region)));
+
+ Deployment deployment = application.deployments().get(deploymentId.zone());
+ if (deployment == null)
+ throw new NotExistsException(application + " is not deployed in " + deploymentId.zone());
+
+ Optional<InstanceEndpoints> deploymentEndpoints = controller.applications().getDeploymentEndpoints(deploymentId);
+
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+ Cursor serviceUrlArray = response.setArray("serviceUrls");
+ if (deploymentEndpoints.isPresent()) {
+ for (URI uri : deploymentEndpoints.get().getContainerEndpoints())
+ serviceUrlArray.addString(uri.toString());
+ }
+
+ response.setString("nodes", withPath("/zone/v2/" + environment + "/" + region + "/nodes/v2/node/?&recursive=true&application=" + tenantName + "." + applicationName + "." + instanceName, request.getUri()).toString());
+
+ Environment env = Environment.from(environment);
+ RegionName regionName = RegionName.from(region);
+ URI elkUrl = controller.getElkUri(env, regionName, deploymentId);
+ if (elkUrl != null)
+ response.setString("elkUrl", elkUrl.toString());
+
+ response.setString("yamasUrl", monitoringSystemUri(deploymentId).toString());
+ response.setString("version", deployment.version().toFullString());
+ response.setString("revision", deployment.revision().id());
+ response.setLong("deployTimeEpochMs", deployment.at().toEpochMilli());
+ Optional<Duration> deploymentTimeToLive = controller.zoneRegistry().getDeploymentTimeToLive(Environment.from(environment), RegionName.from(region));
+ deploymentTimeToLive.ifPresent(duration -> response.setLong("expiryTimeEpochMs", deployment.at().plus(duration).toEpochMilli()));
+
+ application.deploymentJobs().projectId().ifPresent(i -> response.setString("screwdriverId", String.valueOf(i)));
+ sourceRevisionToSlime(deployment.revision().source(), response);
+
+ com.yahoo.config.provision.ApplicationId applicationId = com.yahoo.config.provision.ApplicationId.from(tenantName, applicationName, instanceName);
+ Zone zoneId = new Zone(Environment.from(environment), RegionName.from(region));
+
+ // Cost
+ try {
+ ApplicationCost appCost = controller.getApplicationCost(applicationId, zoneId);
+ Cursor costObject = response.setObject("cost");
+ CostJsonModelAdapter.toSlime(appCost, costObject);
+ } catch (NotFoundCheckedException nfce) {
+ log.log(Level.FINE, "Application cost data not found. " + nfce.getMessage());
+ }
+
+ // Metrics
+ try {
+ MetricsService.DeploymentMetrics metrics = controller.metricsService().getDeploymentMetrics(applicationId, zoneId);
+ Cursor metricsObject = response.setObject("metrics");
+ metricsObject.setDouble("queriesPerSecond", metrics.queriesPerSecond());
+ metricsObject.setDouble("writesPerSecond", metrics.writesPerSecond());
+ metricsObject.setDouble("documentCount", metrics.documentCount());
+ metricsObject.setDouble("queryLatencyMillis", metrics.queryLatencyMillis());
+ metricsObject.setDouble("writeLatencyMillis", metrics.writeLatencyMillis());
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Failed getting Yamas metrics", e);
+ }
+
+ return new SlimeJsonResponse(slime);
+ }
+
+ private void toSlime(ApplicationRevision revision, Cursor object) {
+ object.setString("hash", revision.id());
+ if (revision.source().isPresent())
+ sourceRevisionToSlime(revision.source(), object.setObject("source"));
+ }
+
+ private void sourceRevisionToSlime(Optional<SourceRevision> revision, Cursor object) {
+ if ( ! revision.isPresent()) return;
+ object.setString("gitRepository", revision.get().repository());
+ object.setString("gitBranch", revision.get().branch());
+ object.setString("gitCommit", revision.get().commit());
+ }
+
+ private URI monitoringSystemUri(DeploymentId deploymentId) {
+ return controller.zoneRegistry().getMonitoringSystemUri(deploymentId.zone().environment(),
+ deploymentId.zone().region(),
+ deploymentId.applicationId());
+ }
+
+ private HttpResponse setGlobalRotationOverride(String tenantName, String applicationName, String instanceName, String environment, String region, boolean inService, HttpRequest request) {
+
+ // Check if request is authorized
+ Optional<Tenant> existingTenant = controller.tenants().tenant(new TenantId(tenantName));
+ if (!existingTenant.isPresent())
+ return ErrorResponse.notFoundError("Tenant '" + tenantName + "' does not exist");
+
+ authorizer.throwIfUnauthorized(existingTenant.get().getId(), request);
+
+ // Decode payload (reason) and construct parameter to the configserver
+
+ Inspector requestData = toSlime(request.getData()).get();
+ String reason = mandatory("reason", requestData).asString();
+ String agent = authorizer.getUserId(request).toString();
+ long timestamp = controller.clock().instant().getEpochSecond();
+ EndpointStatus.Status status = inService ? EndpointStatus.Status.in : EndpointStatus.Status.out;
+ EndpointStatus endPointStatus = new EndpointStatus(status, reason, agent, timestamp);
+
+ // DeploymentId identifies the zone and application we are dealing with
+ DeploymentId deploymentId = new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName),
+ new Zone(Environment.from(environment), RegionName.from(region)));
+ try {
+ List<String> rotations = controller.applications().setGlobalRotationStatus(deploymentId, endPointStatus);
+ return new MessageResponse(String.format("Rotations %s successfully set to %s service", rotations.toString(), inService ? "in" : "out of"));
+ } catch (IOException e) {
+ return ErrorResponse.internalServerError("Unable to alter rotation status: " + e.getMessage());
+ }
+ }
+
+ private HttpResponse getGlobalRotationOverride(String tenantName, String applicationName, String instanceName, String environment, String region) {
+
+ DeploymentId deploymentId = new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName),
+ new Zone(Environment.from(environment), RegionName.from(region)));
+
+ Slime slime = new Slime();
+ Cursor c1 = slime.setObject().setArray("globalrotationoverride");
+ try {
+ Map<String, EndpointStatus> rotations = controller.applications().getGlobalRotationStatus(deploymentId);
+ for (String rotation : rotations.keySet()) {
+ EndpointStatus currentStatus = rotations.get(rotation);
+ c1.addString(rotation);
+ Cursor c2 = c1.addObject();
+ c2.setString("status", currentStatus.getStatus().name());
+ c2.setString("reason", currentStatus.getReason() == null ? "" : currentStatus.getReason());
+ c2.setString("agent", currentStatus.getAgent() == null ? "" : currentStatus.getAgent());
+ c2.setLong("timestamp", currentStatus.getEpoch());
+ }
+ } catch (IOException e) {
+ return ErrorResponse.internalServerError("Unable to get rotation status: " + e.getMessage());
+ }
+
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse rotationStatus(String tenantName, String applicationName, String instanceName, String environment, String region) {
+
+ ApplicationId applicationId = ApplicationId.from(tenantName, applicationName, instanceName);
+ Set<URI> rotations = controller.getRotationUris(applicationId);
+ if (rotations.isEmpty())
+ throw new NotExistsException("global rotation does not exist for '" + environment + "." + region + "'");
+
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+
+ Map<String, RotationStatus> rotationHealthStatus = controller.getHealthStatus(rotations.iterator().next().getHost());
+
+ for (String rotationEndpoint : rotationHealthStatus.keySet()) {
+ if (rotationEndpoint.contains(toDns(environment)) && rotationEndpoint.contains(toDns(region))) {
+ Cursor bcpStatusObject = response.setObject("bcpStatus");
+ bcpStatusObject.setString("rotationStatus", rotationHealthStatus.getOrDefault(rotationEndpoint, RotationStatus.UNKNOWN).name());
+ }
+ }
+
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse waitForConvergence(String tenantName, String applicationName, String instanceName, String environment, String region, HttpRequest request) {
+ return new JacksonJsonResponse(controller.waitForConfigConvergence(new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName),
+ new Zone(Environment.from(environment), RegionName.from(region))),
+ asLong(request.getProperty("timeout"), 1000)));
+ }
+
+ private HttpResponse services(String tenantName, String applicationName, String instanceName, String environment, String region, HttpRequest request) {
+ ApplicationView applicationView = controller.getApplicationView(tenantName, applicationName, instanceName, environment, region);
+ ServiceApiResponse response = new ServiceApiResponse(new Zone(Environment.from(environment), RegionName.from(region)),
+ new com.yahoo.config.provision.ApplicationId.Builder().tenant(tenantName).applicationName(applicationName).instanceName(instanceName).build(),
+ controller.getConfigServerUris(Environment.from(environment), RegionName.from(region)),
+ request.getUri());
+ response.setResponse(applicationView);
+ return response;
+ }
+
+ private HttpResponse service(String tenantName, String applicationName, String instanceName, String environment, String region, String serviceName, String restPath, HttpRequest request) {
+ Map<?,?> result = controller.getServiceApiResponse(tenantName, applicationName, instanceName, environment, region, serviceName, restPath);
+ ServiceApiResponse response = new ServiceApiResponse(new Zone(Environment.from(environment), RegionName.from(region)),
+ new com.yahoo.config.provision.ApplicationId.Builder().tenant(tenantName).applicationName(applicationName).instanceName(instanceName).build(),
+ controller.getConfigServerUris(Environment.from(environment), RegionName.from(region)),
+ request.getUri());
+ response.setResponse(result, serviceName, restPath);
+ return response;
+ }
+
+ private HttpResponse createUser(HttpRequest request) {
+ Optional<String> username = userFrom(request);
+ if ( ! username.isPresent() ) throw new ForbiddenException("Not authenticated.");
+
+ try {
+ controller.tenants().createUserTenant(username.get());
+ return new MessageResponse("Created user '" + username.get() + "'");
+ } catch (AlreadyExistsException e) {
+ // Ok
+ return new MessageResponse("User '" + username + "' already exists");
+ }
+ }
+
+ private HttpResponse updateTenant(String tenantName, HttpRequest request) {
+ Optional<Tenant> existingTenant = controller.tenants().tenant(new TenantId(tenantName));
+ if ( ! existingTenant.isPresent()) return ErrorResponse.notFoundError("Tenant '" + tenantName + "' does not exist");;
+
+ Inspector requestData = toSlime(request.getData()).get();
+
+ authorizer.throwIfUnauthorized(existingTenant.get().getId(), request);
+ Tenant updatedTenant;
+ switch (existingTenant.get().tenantType()) {
+ case USER: {
+ throw new BadRequestException("Cannot set property or OpsDB user group for user tenant");
+ }
+ case OPSDB: {
+ UserGroup userGroup = new UserGroup(mandatory("userGroup", requestData).asString());
+ updatedTenant = Tenant.createOpsDbTenant(new TenantId(tenantName),
+ userGroup,
+ new Property(mandatory("property", requestData).asString()),
+ optional("propertyId", requestData).map(PropertyId::new));
+ throwIfNotSuperUserOrPartOfOpsDbGroup(userGroup, request);
+ controller.tenants().updateTenant(updatedTenant, authorizer.getNToken(request));
+ break;
+ }
+ case ATHENS: {
+ if (requestData.field("userGroup").valid())
+ throw new BadRequestException("Cannot set OpsDB user group to Athens tenant");
+ updatedTenant = Tenant.createAthensTenant(new TenantId(tenantName),
+ new AthensDomain(mandatory("athensDomain", requestData).asString()),
+ new Property(mandatory("property", requestData).asString()),
+ optional("propertyId", requestData).map(PropertyId::new));
+ controller.tenants().updateTenant(updatedTenant, authorizer.getNToken(request));
+ break;
+ }
+ default: {
+ throw new BadRequestException("Unknown tenant type: " + existingTenant.get().tenantType());
+ }
+ }
+ return new SlimeJsonResponse(toSlime(updatedTenant, request, true));
+ }
+
+ private HttpResponse createTenant(String tenantName, HttpRequest request) {
+ if (new TenantId(tenantName).isUser())
+ return ErrorResponse.badRequest("Use User API to create user tenants.");
+
+ Inspector requestData = toSlime(request.getData()).get();
+
+ Tenant tenant = new Tenant(new TenantId(tenantName),
+ optional("userGroup", requestData).map(UserGroup::new),
+ optional("property", requestData).map(Property::new),
+ optional("athensDomain", requestData).map(AthensDomain::new),
+ optional("propertyId", requestData).map(PropertyId::new));
+ if (tenant.isOpsDbTenant())
+ throwIfNotSuperUserOrPartOfOpsDbGroup(new UserGroup(mandatory("userGroup", requestData).asString()), request);
+ if (tenant.isAthensTenant())
+ throwIfNotAthensDomainAdmin(new AthensDomain(mandatory("athensDomain", requestData).asString()), request);
+
+ controller.tenants().addTenant(tenant, authorizer.getNToken(request));
+ return new SlimeJsonResponse(toSlime(tenant, request, true));
+ }
+
+ private HttpResponse migrateTenant(String tenantName, HttpRequest request) {
+ TenantId tenantid = new TenantId(tenantName);
+ Inspector requestData = toSlime(request.getData()).get();
+ AthensDomain tenantDomain = new AthensDomain(mandatory("athensDomain", requestData).asString());
+ Property property = new Property(mandatory("property", requestData).asString());
+ PropertyId propertyId = new PropertyId(mandatory("propertyId", requestData).asString());
+
+ authorizer.throwIfUnauthorized(tenantid, request);
+ throwIfNotAthensDomainAdmin(tenantDomain, request);
+ NToken nToken = authorizer.getNToken(request)
+ .orElseThrow(() ->
+ new BadRequestException("The NToken for a domain admin is required to migrate tenant to Athens"));
+ Tenant tenant = controller.tenants().migrateTenantToAthens(tenantid, tenantDomain, propertyId, property, nToken);
+ return new SlimeJsonResponse(toSlime(tenant, request, true));
+ }
+
+ private HttpResponse createApplication(String tenantName, String applicationName, HttpRequest request) {
+ authorizer.throwIfUnauthorized(new TenantId(tenantName), request);
+ Application application;
+ try {
+ application = controller.applications().createApplication(com.yahoo.config.provision.ApplicationId.from(tenantName, applicationName, "default"), authorizer.getNToken(request));
+ }
+ catch (ZmsException e) { // TODO: Push conversion down
+ if (e.getCode() == com.yahoo.jdisc.Response.Status.FORBIDDEN)
+ throw new ForbiddenException("Not authorized to create application", e);
+ else
+ throw e;
+ }
+
+ Slime slime = new Slime();
+ toSlime(application, slime.setObject(), request);
+ return new SlimeJsonResponse(slime);
+ }
+
+ /** Trigger deployment of the last built application package, on a given version */
+ private HttpResponse deploy(String tenantName, String applicationName, HttpRequest request) {
+ ApplicationId id = ApplicationId.from(tenantName, applicationName, "default");
+ try (Lock lock = controller.applications().lock(id)) {
+ Application application = controller.applications().require(id);
+ if (application.deploying().isPresent())
+ throw new IllegalArgumentException("Can not start a deployment of " + application + " at this time: " +
+ application.deploying() + " is in progress");
+
+ Version version = decideDeployVersion(request);
+ if ( ! systemHasVersion(version))
+ throw new IllegalArgumentException("Cannot trigger deployment of version '" + version + "': " +
+ "Version is not active in this system. " +
+ "Active versions: " + controller.versionStatus().versions());
+
+ // Since we manually triggered it we don't want this to be self-triggering for the time being
+ controller.applications().store(application.with(application.deploymentJobs().asSelfTriggering(false)), lock);
+
+ controller.applications().deploymentTrigger().triggerChange(application.id(), new Change.VersionChange(version));
+ return new MessageResponse("Triggered deployment of " + application + " on version " + version);
+ }
+ }
+
+ private HttpResponse restart(String tenantName, String applicationName, String instanceName, String environment, String region, HttpRequest request) {
+ DeploymentId deploymentId = new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName),
+ new Zone(Environment.from(environment), RegionName.from(region)));
+ // TODO: Propagate all filters
+ if (request.getProperty("hostname") != null)
+ controller.applications().restartHost(deploymentId, new Hostname(request.getProperty("hostname")));
+ else
+ controller.applications().restart(deploymentId);
+
+ // TODO: Change to return JSON
+ return new StringResponse("Requested restart of " + path(TenantResource.API_PATH, tenantName,
+ ApplicationResource.API_PATH, applicationName,
+ EnvironmentResource.API_PATH, environment,
+ "region", region,
+ "instance", instanceName));
+ }
+
+ /**
+ * This returns and deletes recent error logs from this deployment, which is used by tenant deployment jobs to verify that
+ * the application is working. It is called for all production zones, also those in which the application is not present,
+ * and possibly before it is present, so failures are normal and expected.
+ */
+ private HttpResponse log(String tenantName, String applicationName, String instanceName, String environment, String region) {
+ try {
+ DeploymentId deploymentId = new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName),
+ new Zone(Environment.from(environment), RegionName.from(region)));
+ return new JacksonJsonResponse(controller.grabLog(deploymentId));
+ }
+ catch (RuntimeException e) {
+ Slime slime = new Slime();
+ slime.setObject();
+ return new SlimeJsonResponse(slime);
+ }
+ }
+
+ private HttpResponse deploy(String tenantName, String applicationName, String instanceName, String environment, String region, HttpRequest request) {
+ ApplicationId applicationId = ApplicationId.from(tenantName, applicationName, instanceName);
+ Zone zone = new Zone(Environment.from(environment), RegionName.from(region));
+
+ Map<String, byte[]> dataParts = new MultipartParser().parse(request);
+ if ( ! dataParts.containsKey("deployOptions"))
+ return ErrorResponse.badRequest("Missing required form part 'deployOptions'");
+ if ( ! dataParts.containsKey("applicationZip"))
+ return ErrorResponse.badRequest("Missing required form part 'applicationZip'");
+
+ Inspector deployOptions = SlimeUtils.jsonToSlime(dataParts.get("deployOptions")).get();
+
+ DeployAuthorizer deployAuthorizer = new DeployAuthorizer(controller.athens(), controller.zoneRegistry());
+ Tenant tenant = controller.tenants().tenant(new TenantId(tenantName)).orElseThrow(() -> new NotExistsException(new TenantId(tenantName)));
+ Principal principal = authorizer.getPrincipal(request);
+ if (principal instanceof AthensPrincipal) {
+ deployAuthorizer.throwIfUnauthorizedForDeploy(principal,
+ Environment.from(environment),
+ tenant,
+ applicationId);
+ } else { // In case of host-based principal
+ UserId userId = new UserId(principal.getName());
+ deployAuthorizer.throwIfUnauthorizedForDeploy(
+ Environment.from(environment),
+ userId,
+ tenant,
+ applicationId,
+ optional("screwdriverBuildJob", deployOptions).map(ScrewdriverId::new));
+ }
+
+
+ // TODO: get rid of the json object
+ DeployOptions deployOptionsJsonClass = new DeployOptions(screwdriverBuildJobFromSlime(deployOptions.field("screwdriverBuildJob")),
+ optional("vespaVersion", deployOptions).map(Version::new),
+ deployOptions.field("ignoreValidationErrors").asBool(),
+ deployOptions.field("deployCurrentVersion").asBool());
+ ActivateResult result = controller.applications().deployApplication(applicationId,
+ zone,
+ new ApplicationPackage(dataParts.get("applicationZip")),
+ deployOptionsJsonClass);
+ return new SlimeJsonResponse(toSlime(result, dataParts.get("applicationZip").length));
+ }
+
+ private HttpResponse deleteTenant(String tenantName, HttpRequest request) {
+ Optional<Tenant> tenant = controller.tenants().tenant(new TenantId(tenantName));
+ if ( ! tenant.isPresent()) return ErrorResponse.notFoundError("Could not delete tenant '" + tenantName + "': Tenant not found"); // NOTE: The Jersey implementation would silently ignore this
+
+ authorizer.throwIfUnauthorized(new TenantId(tenantName), request);
+ 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));
+ }
+
+ private HttpResponse deleteApplication(String tenantName, String applicationName, HttpRequest request) {
+ authorizer.throwIfUnauthorized(new TenantId(tenantName), request);
+
+ com.yahoo.config.provision.ApplicationId id = com.yahoo.config.provision.ApplicationId.from(tenantName, applicationName, "default");
+ Application deleted = controller.applications().deleteApplication(id, authorizer.getNToken(request));
+ if (deleted == null)
+ return ErrorResponse.notFoundError("Could not delete application '" + id + "': Application not found");
+ return new EmptyJsonResponse(); // TODO: Replicates current behavior but should return a message response instead
+ }
+
+ private HttpResponse deactivate(String tenantName, String applicationName, String instanceName, String environment, String region) {
+ Application application = controller.applications().require(ApplicationId.from(tenantName, applicationName, instanceName));
+
+ Zone zone = new Zone(Environment.from(environment), RegionName.from(region));
+ Deployment deployment = application.deployments().get(zone);
+ if (deployment == null)
+ return ErrorResponse.notFoundError("Could not deactivate: " + application + " is not deployed in " + zone);
+
+ controller.applications().deactivate(application, deployment, false);
+
+ // TODO: Change to return JSON
+ return new StringResponse("Deactivated " + path(TenantResource.API_PATH, tenantName,
+ ApplicationResource.API_PATH, applicationName,
+ EnvironmentResource.API_PATH, environment,
+ "region", region,
+ "instance", instanceName));
+ }
+
+ /**
+ * Promote application Chef environments. To be used by component jobs only
+ */
+ private HttpResponse promoteApplication(String tenantName, String applicationName) {
+ try{
+ ApplicationChefEnvironment chefEnvironment = new ApplicationChefEnvironment(controller.system());
+ String sourceEnvironment = chefEnvironment.systemChefEnvironment();
+ String targetEnvironment = chefEnvironment.applicationSourceEnvironment(TenantName.from(tenantName), ApplicationName.from(applicationName));
+ controller.chefClient().copyChefEnvironment(sourceEnvironment, targetEnvironment);
+ return new MessageResponse(String.format("Successfully copied environment %s to %s", sourceEnvironment, targetEnvironment));
+ } catch (Exception e) {
+ log.log(LogLevel.ERROR, String.format("Error during Chef copy environment. (%s.%s)", tenantName, applicationName), e);
+ return ErrorResponse.internalServerError("Unable to promote Chef environments for application");
+ }
+ }
+
+ /**
+ * Promote application Chef environments for jobs that deploy applications
+ */
+ private HttpResponse promoteApplicationDeployment(String tenantName, String applicationName, String environmentName, String regionName) {
+ try {
+ ApplicationChefEnvironment chefEnvironment = new ApplicationChefEnvironment(controller.system());
+ String sourceEnvironment = chefEnvironment.applicationSourceEnvironment(TenantName.from(tenantName), ApplicationName.from(applicationName));
+ String targetEnvironment = chefEnvironment.applicationTargetEnvironment(TenantName.from(tenantName), ApplicationName.from(applicationName), Environment.from(environmentName), RegionName.from(regionName));
+ controller.chefClient().copyChefEnvironment(sourceEnvironment, targetEnvironment);
+ return new MessageResponse(String.format("Successfully copied environment %s to %s", sourceEnvironment, targetEnvironment));
+ } catch (Exception e) {
+ log.log(LogLevel.ERROR, String.format("Error during Chef copy environment. (%s.%s %s.%s)", tenantName, applicationName, environmentName, regionName), e);
+ return ErrorResponse.internalServerError("Unable to promote Chef environments for application");
+ }
+ }
+
+ private Optional<String> userFrom(HttpRequest request) {
+ return authorizer.getPrincipalIfAny(request).map(Principal::getName);
+ }
+
+ private void toSlime(Tenant tenant, Cursor object, HttpRequest request, boolean listApplications) {
+ object.setString("type", tenant.tenantType().name());
+ tenant.getAthensDomain().ifPresent(a -> object.setString("athensDomain", a.id()));
+ tenant.getProperty().ifPresent(p -> object.setString("property", p.id()));
+ tenant.getPropertyId().ifPresent(p -> object.setString("propertyId", p.toString()));
+ tenant.getUserGroup().ifPresent(g -> object.setString("userGroup", g.id()));
+ Cursor applicationArray = object.setArray("applications");
+ if (listApplications) { // This cludge is needed because we call this after deleting the tenant. As this call makes another tenant lookup it will fail. TODO is to support lookup on tenant
+ for (Application application : controller.applications().asList(TenantName.from(tenant.getId().id()))) {
+ if (application.id().instance().isDefault()) // TODO: Skip non-default applications until supported properly
+ toSlime(application, applicationArray.addObject(), request);
+ }
+ }
+ }
+
+ // A tenant has different content when in a list ... antipattern, but not solvable before application/v5
+ private void tenantInTenantsListToSlime(Tenant tenant, URI requestURI, Cursor object) {
+ object.setString("tenant", tenant.getId().id());
+ Cursor metaData = object.setObject("metaData");
+ metaData.setString("type", tenant.tenantType().name());
+ tenant.getAthensDomain().ifPresent(a -> metaData.setString("athensDomain", a.id()));
+ tenant.getProperty().ifPresent(p -> metaData.setString("property", p.id()));
+ tenant.getUserGroup().ifPresent(g -> metaData.setString("userGroup", g.id()));
+ object.setString("url", withPath("/application/v4/tenant/" + tenant.getId().id(), requestURI).toString());
+ }
+
+ /** Returns a copy of the given URI with the host and port from the given URI and the path set to the given path */
+ private URI withPath(String newPath, URI uri) {
+ try {
+ return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), uri.getPort(), newPath, null, null);
+ }
+ catch (URISyntaxException e) {
+ throw new RuntimeException("Will not happen", e);
+ }
+ }
+
+ private void setRotationStatus(Deployment deployment, Map<String, RotationStatus> healthStatus, Cursor object) {
+ if ( ! deployment.zone().environment().equals(Environment.prod)) return;
+
+ Cursor bcpStatusObject = object.setObject("bcpStatus");
+ bcpStatusObject.setString("rotationStatus", findRotationStatus(deployment, healthStatus).name());
+ }
+
+ private RotationStatus findRotationStatus(Deployment deployment, Map<String, RotationStatus> healthStatus) {
+ for (String endpoint : healthStatus.keySet()) {
+ if (endpoint.contains(toDns(deployment.zone().environment().value())) &&
+ endpoint.contains(toDns(deployment.zone().region().value()))) {
+ return healthStatus.getOrDefault(endpoint, RotationStatus.UNKNOWN);
+ }
+ }
+
+ return RotationStatus.UNKNOWN;
+ }
+
+ private String toDns(String id) {
+ return id.replace('_', '-');
+ }
+
+ private long asLong(String valueOrNull, long defaultWhenNull) {
+ if (valueOrNull == null) return defaultWhenNull;
+ try {
+ return Long.parseLong(valueOrNull);
+ }
+ catch (NumberFormatException e) {
+ throw new IllegalArgumentException("Expected an integer but got '" + valueOrNull + "'");
+ }
+ }
+
+ private void toSlime(JobStatus.JobRun jobRun, Cursor object) {
+ object.setString("version", jobRun.version().toFullString());
+ jobRun.revision().ifPresent(revision -> toSlime(revision, object.setObject("revision")));
+ object.setLong("at", jobRun.at().toEpochMilli());
+ }
+
+ private Slime toSlime(InputStream jsonStream) {
+ try {
+ byte[] jsonBytes = IOUtils.readBytes(jsonStream, 1000 * 1000);
+ return SlimeUtils.jsonToSlime(jsonBytes);
+ } catch (IOException e) {
+ throw new RuntimeException();
+ }
+ }
+
+ private void throwIfNotSuperUserOrPartOfOpsDbGroup(UserGroup userGroup, HttpRequest request) {
+ UserId userId = authorizer.getUserId(request);
+ if (!authorizer.isSuperUser(request) && !authorizer.isGroupMember(userId, userGroup) ) {
+ throw new ForbiddenException(String.format("User '%s' is not super user or part of the OpsDB user group '%s'",
+ userId.id(), userGroup.id()));
+ }
+ }
+
+ private void throwIfNotAthensDomainAdmin(AthensDomain tenantDomain, HttpRequest request) {
+ UserId userId = authorizer.getUserId(request);
+ if ( ! authorizer.isAthensDomainAdmin(userId, tenantDomain)) {
+ throw new ForbiddenException(
+ String.format("The user '%s' is not admin in Athens domain '%s'", userId.id(), tenantDomain.id()));
+ }
+ }
+
+ private Inspector mandatory(String key, Inspector object) {
+ if ( ! object.field(key).valid())
+ throw new IllegalArgumentException("'" + key + "' is missing");
+ return object.field(key);
+ }
+
+ private Optional<String> optional(String key, Inspector object) {
+ return SlimeUtils.optionalString(object.field(key));
+ }
+
+ private static String path(Object... elements) {
+ return Joiner.on("/").join(elements);
+ }
+
+ private Slime toSlime(Tenant tenant, HttpRequest request, boolean listApplications) {
+ Slime slime = new Slime();
+ toSlime(tenant, slime.setObject(), request, listApplications);
+ return slime;
+ }
+
+ private void toSlime(Application application, Cursor object, HttpRequest request) {
+ object.setString("application", application.id().application().value());
+ object.setString("instance", application.id().instance().value());
+ object.setString("url", withPath("/application/v4/tenant/" + application.id().tenant().value() +
+ "/application/" + application.id().application().value(), request.getUri()).toString());
+ }
+
+ private Slime toSlime(ActivateResult result, long applicationZipSizeBytes) {
+ Slime slime = new Slime();
+ Cursor object = slime.setObject();
+ object.setString("revisionId", result.getRevisionId().id());
+ object.setLong("applicationZipSize", applicationZipSizeBytes);
+ Cursor logArray = object.setArray("prepareMessages");
+ if (result.getPrepareResponse().log != null) {
+ for (Log logMessage : result.getPrepareResponse().log) {
+ Cursor logObject = logArray.addObject();
+ logObject.setLong("time", logMessage.time);
+ logObject.setString("level", logMessage.level);
+ logObject.setString("message", logMessage.message);
+ }
+ }
+
+ Cursor changeObject = object.setObject("configChangeActions");
+
+ Cursor restartActionsArray = changeObject.setArray("restart");
+ for (RestartAction restartAction : result.getPrepareResponse().configChangeActions.restartActions) {
+ Cursor restartActionObject = restartActionsArray.addObject();
+ restartActionObject.setString("clusterName", restartAction.clusterName);
+ restartActionObject.setString("clusterType", restartAction.clusterType);
+ restartActionObject.setString("serviceType", restartAction.serviceType);
+ serviceInfosToSlime(restartAction.services, restartActionObject.setArray("services"));
+ stringsToSlime(restartAction.messages, restartActionObject.setArray("messages"));
+ }
+
+ Cursor refeedActionsArray = changeObject.setArray("refeed");
+ for (RefeedAction refeedAction : result.getPrepareResponse().configChangeActions.refeedActions) {
+ Cursor refeedActionObject = refeedActionsArray.addObject();
+ refeedActionObject.setString("name", refeedAction.name);
+ refeedActionObject.setBool("allowed", refeedAction.allowed);
+ refeedActionObject.setString("documentType", refeedAction.documentType);
+ refeedActionObject.setString("clusterName", refeedAction.clusterName);
+ serviceInfosToSlime(refeedAction.services, refeedActionObject.setArray("services"));
+ stringsToSlime(refeedAction.messages, refeedActionObject.setArray("messages"));
+ }
+ return slime;
+ }
+
+ private void serviceInfosToSlime(List<ServiceInfo> serviceInfoList, Cursor array) {
+ for (ServiceInfo serviceInfo : serviceInfoList) {
+ Cursor serviceInfoObject = array.addObject();
+ serviceInfoObject.setString("serviceName", serviceInfo.serviceName);
+ serviceInfoObject.setString("serviceType", serviceInfo.serviceType);
+ serviceInfoObject.setString("configId", serviceInfo.configId);
+ serviceInfoObject.setString("hostName", serviceInfo.hostName);
+ }
+ }
+
+ private void stringsToSlime(List<String> strings, Cursor array) {
+ for (String string : strings)
+ array.addString(string);
+ }
+
+ // TODO: get rid of the json object
+ private Optional<ScrewdriverBuildJob> screwdriverBuildJobFromSlime(Inspector object) {
+ if ( ! object.valid() ) return Optional.empty();
+ Optional<ScrewdriverId> screwdriverId = optional("screwdriverId", object).map(ScrewdriverId::new);
+ return Optional.of(new ScrewdriverBuildJob(screwdriverId.orElse(null),
+ gitRevisionFromSlime(object.field("gitRevision"))));
+ }
+
+ // TODO: get rid of the json object
+ private GitRevision gitRevisionFromSlime(Inspector object) {
+ return new GitRevision(optional("repository", object).map(GitRepository::new).orElse(null),
+ optional("branch", object).map(GitBranch::new).orElse(null),
+ optional("commit", object).map(GitCommit::new).orElse(null));
+ }
+
+ private String readToString(InputStream stream) {
+ Scanner scanner = new Scanner(stream).useDelimiter("\\A");
+ if ( ! scanner.hasNext()) return null;
+ return scanner.next();
+ }
+
+ private boolean systemHasVersion(Version version) {
+ return controller.versionStatus().versions().stream().anyMatch(v -> v.versionNumber().equals(version));
+ }
+
+ private Version decideDeployVersion(HttpRequest request) {
+ String requestVersion = readToString(request.getData());
+ if (requestVersion != null)
+ return new Version(requestVersion);
+ else
+ return controller.systemVersion();
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationChefEnvironment.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationChefEnvironment.java
new file mode 100644
index 00000000000..7c32e48e218
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationChefEnvironment.java
@@ -0,0 +1,43 @@
+// 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.restapi.application;
+
+import com.yahoo.config.provision.ApplicationName;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.config.provision.RegionName;
+import com.yahoo.config.provision.SystemName;
+import com.yahoo.config.provision.TenantName;
+
+/**
+ * Represents Chef environments for applications/deployments. Used for promotion of Chef environments
+ *
+ * @author mortent
+ */
+public class ApplicationChefEnvironment {
+
+ private final String systemChefEnvironment;
+ private final String systemSuffix;
+
+ public ApplicationChefEnvironment(SystemName system) {
+ if (system == SystemName.main) {
+ systemChefEnvironment = "hosted-verified-prod";
+ systemSuffix = "";
+ } else {
+ systemChefEnvironment = "hosted-infra-cd";
+ systemSuffix = "-cd";
+ }
+ }
+
+ public String systemChefEnvironment() {
+ return systemChefEnvironment;
+ }
+
+ public String applicationSourceEnvironment(TenantName tenantName, ApplicationName applicationName) {
+ // placeholder and component already used in legacy chef promotion
+ return String.format("hosted-instance%s_%s_%s_placeholder_component_default", systemSuffix, tenantName, applicationName);
+ }
+
+ public String applicationTargetEnvironment(TenantName tenantName, ApplicationName applicationName, Environment environment, RegionName regionName) {
+ return String.format("hosted-instance%s_%s_%s_%s_%s_default", systemSuffix, tenantName, applicationName, regionName, environment);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java
new file mode 100644
index 00000000000..8dff39779b9
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java
@@ -0,0 +1,164 @@
+// 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.restapi.application;
+
+import com.google.common.collect.ImmutableSet;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.Tenant;
+import com.yahoo.vespa.hosted.controller.api.identifiers.AthensDomain;
+import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.UserGroup;
+import com.yahoo.vespa.hosted.controller.api.identifiers.UserId;
+import com.yahoo.vespa.hosted.controller.api.integration.entity.EntityService;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ZmsClientFactory;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.AthensPrincipal;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.Athens;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.NToken;
+import com.yahoo.vespa.hosted.controller.common.ContextAttributes;
+import com.yahoo.vespa.hosted.controller.restapi.filter.NTokenRequestFilter;
+import com.yahoo.vespa.hosted.controller.restapi.filter.UnauthenticatedUserPrincipal;
+
+import javax.ws.rs.ForbiddenException;
+import javax.ws.rs.HttpMethod;
+import javax.ws.rs.core.SecurityContext;
+import java.security.Principal;
+import java.util.Optional;
+import java.util.Set;
+import java.util.logging.Logger;
+
+
+/**
+ * @author Stian Kristoffersen
+ * @author Tony Vaagenes
+ * @author bjorncs
+ */
+// TODO: Make this an interface
+public class Authorizer {
+
+ private static final Logger log = Logger.getLogger(Authorizer.class.getName());
+
+ // Must be kept in sync with bouncer filter configuration.
+ private static final String VESPA_HOSTED_ADMIN_ROLE = "10707.A";
+
+ private static final Set<UserId> SCREWDRIVER_USERS = ImmutableSet.of(new UserId("screwdrv"),
+ new UserId("screwdriver"),
+ new UserId("sdrvtest"),
+ new UserId("screwdriver-test"));
+
+ private final Controller controller;
+ private final ZmsClientFactory zmsClientFactory;
+ private final EntityService entityService;
+ private final Athens athens;
+
+ public Authorizer(Controller controller, EntityService entityService) {
+ this.controller = controller;
+ this.zmsClientFactory = controller.athens().zmsClientFactory();
+ this.entityService = entityService;
+ this.athens = controller.athens();
+ }
+
+ public void throwIfUnauthorized(TenantId tenantId, HttpRequest request) throws ForbiddenException {
+ if (isReadOnlyMethod(request.getMethod().name())) return;
+ if (isSuperUser(request)) return;
+
+ Optional<Tenant> tenant = controller.tenants().tenant(tenantId);
+ if ( ! tenant.isPresent()) return;
+
+ UserId userId = getUserId(request);
+ if (isTenantAdmin(userId, tenant.get())) return;
+
+ throw loggedForbiddenException("User " + userId + " does not have write access to tenant " + tenantId);
+ }
+
+ public UserId getUserId(HttpRequest request) {
+ String name = getPrincipal(request).getName();
+ if (name == null)
+ throw loggedForbiddenException("Not authorized: User name is null");
+ return new UserId(name);
+ }
+
+ /** Returns the principal or throws forbidden */ // TODO: Avoid REST exceptions
+ public Principal getPrincipal(HttpRequest request) {
+ return getPrincipalIfAny(request).orElseThrow(() -> Authorizer.loggedForbiddenException("User is not authenticated"));
+ }
+
+ /** Returns the principal if there is any */
+ public Optional<Principal> getPrincipalIfAny(HttpRequest request) {
+ return securityContextOf(request).map(SecurityContext::getUserPrincipal);
+ }
+
+ public Optional<NToken> getNToken(HttpRequest request) {
+ String nTokenHeader = (String)request.getJDiscRequest().context().get(NTokenRequestFilter.NTOKEN_HEADER);
+ return Optional.ofNullable(nTokenHeader).map(athens::nTokenFrom);
+ }
+
+ public boolean isSuperUser(HttpRequest request) {
+ // TODO Check membership of admin role in Vespa's Athens domain
+ return isMemberOfVespaBouncerGroup(request) || isScrewdriverPrincipal(athens, getPrincipal(request));
+ }
+
+ public static boolean isScrewdriverPrincipal(Athens athens, Principal principal) {
+ if (principal instanceof UnauthenticatedUserPrincipal) // Host-based authentication
+ return SCREWDRIVER_USERS.contains(new UserId(principal.getName()));
+ else if (principal instanceof AthensPrincipal)
+ return ((AthensPrincipal)principal).getDomain().equals(athens.screwdriverDomain());
+ else
+ return false;
+ }
+
+ private static ForbiddenException loggedForbiddenException(String message, Object... args) {
+ String formattedMessage = String.format(message, args);
+ log.info(formattedMessage);
+ return new ForbiddenException(formattedMessage);
+ }
+
+ private boolean isTenantAdmin(UserId userId, Tenant tenant) {
+ switch (tenant.tenantType()) {
+ case ATHENS:
+ return isAthensTenantAdmin(userId, tenant.getAthensDomain().get());
+ case OPSDB:
+ return isGroupMember(userId, tenant.getUserGroup().get());
+ case USER:
+ return isUserTenantOwner(tenant.getId(), userId);
+ }
+ throw new IllegalArgumentException("Unknown tenant type: " + tenant.tenantType());
+ }
+
+ private boolean isAthensTenantAdmin(UserId userId, AthensDomain tenantDomain) {
+ return zmsClientFactory.createClientWithServicePrincipal()
+ .hasTenantAdminAccess(athens.principalFrom(userId), tenantDomain);
+ }
+
+ public boolean isAthensDomainAdmin(UserId userId, AthensDomain tenantDomain) {
+ return zmsClientFactory.createClientWithServicePrincipal()
+ .isDomainAdmin(athens.principalFrom(userId), tenantDomain);
+ }
+
+ public boolean isGroupMember(UserId userId, UserGroup userGroup) {
+ return entityService.isGroupMember(userId, userGroup);
+ }
+
+ private static boolean isUserTenantOwner(TenantId tenantId, UserId userId) {
+ return tenantId.equals(userId.toTenantId());
+ }
+
+ public static boolean environmentRequiresAuthorization(Environment environment) {
+ return environment != Environment.dev && environment != Environment.perf;
+ }
+
+ private static boolean isReadOnlyMethod(String method) {
+ return method.equals(HttpMethod.GET) || method.equals(HttpMethod.HEAD) || method.equals(HttpMethod.OPTIONS);
+ }
+
+ private boolean isMemberOfVespaBouncerGroup(HttpRequest request) {
+ Optional<SecurityContext> securityContext = securityContextOf(request);
+ if ( ! securityContext.isPresent() ) throw Authorizer.loggedForbiddenException("User is not authenticated");
+ return securityContext.get().isUserInRole(Authorizer.VESPA_HOSTED_ADMIN_ROLE);
+ }
+
+ protected Optional<SecurityContext> securityContextOf(HttpRequest request) {
+ return Optional.ofNullable((SecurityContext)request.getJDiscRequest().context().get(ContextAttributes.SECURITY_CONTEXT_ATTRIBUTE));
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/DeployAuthorizer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/DeployAuthorizer.java
new file mode 100644
index 00000000000..5c7cdfdae0a
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/DeployAuthorizer.java
@@ -0,0 +1,117 @@
+// 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.restapi.application;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.vespa.hosted.controller.api.Tenant;
+import com.yahoo.vespa.hosted.controller.api.identifiers.AthensDomain;
+import com.yahoo.vespa.hosted.controller.api.identifiers.ScrewdriverId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.UserId;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ApplicationAction;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.Athens;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.AthensPrincipal;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ZmsException;
+import com.yahoo.vespa.hosted.controller.api.integration.zone.ZoneRegistry;
+import com.yahoo.vespa.hosted.controller.restapi.filter.UnauthenticatedUserPrincipal;
+
+import javax.ws.rs.ForbiddenException;
+import java.security.Principal;
+import java.util.Optional;
+import java.util.logging.Logger;
+
+import static com.yahoo.vespa.hosted.controller.restapi.application.Authorizer.environmentRequiresAuthorization;
+import static com.yahoo.vespa.hosted.controller.restapi.application.Authorizer.isScrewdriverPrincipal;
+
+/**
+ * @author bjorncs
+ * @author gjoranv
+ */
+public class DeployAuthorizer {
+
+ private static final Logger log = Logger.getLogger(DeployAuthorizer.class.getName());
+
+ private final Athens athens;
+ private final ZoneRegistry zoneRegistry;
+
+ public DeployAuthorizer(Athens athens, ZoneRegistry zoneRegistry) {
+ this.athens = athens;
+ this.zoneRegistry = zoneRegistry;
+ }
+
+ public void throwIfUnauthorizedForDeploy(Principal principal,
+ Environment environment,
+ Tenant tenant,
+ ApplicationId applicationId) {
+ if (athensCredentialsRequired(environment, tenant, applicationId, principal))
+ checkAthensCredentials(principal, tenant, applicationId);
+ }
+
+ // TODO: inline when deployment via ssh is removed
+ private boolean athensCredentialsRequired(Environment environment, Tenant tenant, ApplicationId applicationId, Principal principal) {
+ if (!environmentRequiresAuthorization(environment)) return false;
+
+ if (! isScrewdriverPrincipal(athens, principal))
+ throw loggedForbiddenException(
+ "Principal '%s' is not a screwdriver principal, and does not have deploy access to application '%s'",
+ principal.getName(), applicationId.toShortString());
+
+ return tenant.isAthensTenant();
+ }
+
+
+ // TODO: inline when deployment via ssh is removed
+ private void checkAthensCredentials(Principal principal, Tenant tenant, ApplicationId applicationId) {
+ AthensDomain domain = tenant.getAthensDomain().get();
+ if (! (principal instanceof AthensPrincipal))
+ throw loggedForbiddenException("Principal '%s' is not authenticated.", principal.getName());
+
+ AthensPrincipal athensPrincipal = (AthensPrincipal)principal;
+ if ( ! hasDeployAccessToAthensApplication(athensPrincipal, domain, applicationId))
+ throw loggedForbiddenException(
+ "Screwdriver principal '%1$s' does not have deploy access to '%2$s'. " +
+ "Either the application has not been created at " + zoneRegistry.getDashboardUri() + " or " +
+ "'%1$s' is not added to the application's deployer role in Athens domain '%3$s'.",
+ athensPrincipal, applicationId, tenant.getAthensDomain().get());
+ }
+
+ private static ForbiddenException loggedForbiddenException(String message, Object... args) {
+ String formattedMessage = String.format(message, args);
+ log.info(formattedMessage);
+ return new ForbiddenException(formattedMessage);
+ }
+
+ /**
+ * @deprecated Only usable for ssh. Use the method that takes Principal instead of UserId and screwdriverId.
+ */
+ @Deprecated
+ public void throwIfUnauthorizedForDeploy(Environment environment,
+ UserId userId,
+ Tenant tenant,
+ ApplicationId applicationId,
+ Optional<ScrewdriverId> optionalScrewdriverId) {
+
+ Principal principal = new UnauthenticatedUserPrincipal(userId.id());
+
+ if (athensCredentialsRequired(environment, tenant, applicationId, principal)) {
+ ScrewdriverId screwdriverId = optionalScrewdriverId.orElseThrow(
+ () -> loggedForbiddenException("Screwdriver id must be provided when deploying from Screwdriver."));
+ principal = athens.principalFrom(screwdriverId);
+ checkAthensCredentials(principal, tenant, applicationId);
+ }
+ }
+
+ private boolean hasDeployAccessToAthensApplication(AthensPrincipal principal, AthensDomain domain, ApplicationId applicationId) {
+ try {
+ return athens.zmsClientFactory().createClientWithServicePrincipal()
+ .hasApplicationAccess(
+ principal,
+ ApplicationAction.deploy,
+ domain,
+ new com.yahoo.vespa.hosted.controller.api.identifiers.ApplicationId(applicationId.application().value()));
+ } catch (ZmsException e) {
+ throw loggedForbiddenException(
+ "Failed to authorize deployment through Athens. If this problem persists, " +
+ "please create ticket at yo/vespa-support. (" + e.getMessage() + ")");
+ }
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/EmptyJsonResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/EmptyJsonResponse.java
new file mode 100644
index 00000000000..3e8d4182c42
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/EmptyJsonResponse.java
@@ -0,0 +1,25 @@
+// 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.restapi.application;
+
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.slime.Slime;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author bratseth
+ */
+public class EmptyJsonResponse extends HttpResponse {
+
+ public EmptyJsonResponse() {
+ super(200);
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException { }
+
+ @Override
+ public String getContentType() { return "application/json"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/JacksonJsonResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/JacksonJsonResponse.java
new file mode 100644
index 00000000000..cfd6feccf01
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/JacksonJsonResponse.java
@@ -0,0 +1,31 @@
+// 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.restapi.application;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yahoo.container.jdisc.HttpResponse;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author bratseth
+ */
+public class JacksonJsonResponse extends HttpResponse {
+
+ private final JsonNode node;
+
+ public JacksonJsonResponse(JsonNode node) {
+ super(200);
+ this.node = node;
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ new ObjectMapper().writeValue(stream, node);
+ }
+
+ @Override
+ public String getContentType() { return "application/json"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/MultipartParser.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/MultipartParser.java
new file mode 100644
index 00000000000..75f4ff68f1e
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/MultipartParser.java
@@ -0,0 +1,72 @@
+// 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.restapi.application;
+
+import com.yahoo.container.jdisc.HttpRequest;
+import org.apache.commons.fileupload.MultipartStream;
+import org.apache.commons.fileupload.ParameterParser;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Provides reading a multipart/form-data request type into a map of bytes for each part,
+ * indexed by the parts (form field) name.
+ *
+ * @author bratseth
+ */
+public class MultipartParser {
+
+ /**
+ * Parses the given multi-part request and returns all the parts indexed by their name.
+ *
+ * @throws IllegalArgumentException if this request is not a well-formed request with Content-Type multipart/form-data
+ */
+ public Map<String, byte[]> parse(HttpRequest request) {
+ try {
+ ParameterParser parameterParser = new ParameterParser();
+ Map<String, String> contentType = parameterParser.parse(request.getHeader("Content-Type"), ';');
+ if ( ! contentType.containsKey("multipart/form-data"))
+ throw new IllegalArgumentException("Expected a multipart message, but got Content-Type: " +
+ request.getHeader("Content-Type"));
+ String boundary = contentType.get("boundary");
+ if (boundary == null)
+ throw new IllegalArgumentException("Missing boundary property in Content-Type header");
+ MultipartStream multipartStream = new MultipartStream(request.getData(), boundary.getBytes(),
+ 1000 * 1000,
+ null);
+ boolean nextPart = multipartStream.skipPreamble();
+ Map<String, byte[]> parts = new HashMap<>();
+ while (nextPart) {
+ String[] headers = multipartStream.readHeaders().split("\r\n");
+ String contentDispositionContent = findContentDispositionHeader(headers);
+ if (contentDispositionContent == null)
+ throw new IllegalArgumentException("Missing Content-Disposition header in a multipart body part");
+ Map<String, String> contentDisposition = parameterParser.parse(contentDispositionContent, ';');
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ multipartStream.readBodyData(output);
+ parts.put(contentDisposition.get("name"), output.toByteArray());
+ nextPart = multipartStream.readBoundary();
+ }
+ return parts;
+ }
+ catch(MultipartStream.MalformedStreamException e) {
+ throw new IllegalArgumentException("Malformed multipart/form-data request", e);
+ }
+ catch(IOException e) {
+ throw new IllegalArgumentException("IO error reading multipart request " + request.getUri(), e);
+ }
+ }
+
+ private String findContentDispositionHeader(String[] headers) {
+ String contentDisposition = "Content-Disposition:";
+ for (String header : headers) {
+ if (header.length() < contentDisposition.length()) continue;
+ if ( ! header.substring(0, contentDisposition.length()).equalsIgnoreCase(contentDisposition)) continue;
+ return header.substring(contentDisposition.length() + 1);
+ }
+ return null;
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ServiceApiResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ServiceApiResponse.java
new file mode 100644
index 00000000000..6a448e475c5
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ServiceApiResponse.java
@@ -0,0 +1,191 @@
+// 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.restapi.application;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.Zone;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.JsonFormat;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.hosted.controller.restapi.Uri;
+import com.yahoo.vespa.serviceview.bindings.ApplicationView;
+import com.yahoo.vespa.serviceview.bindings.ClusterView;
+import com.yahoo.vespa.serviceview.bindings.ServiceView;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A response containing a service view for an application deployment.
+ * This does not define the API response but merely proxies the API response provided by Vespa, with URLs
+ * rewritten to include zone and application information allow proxying through the controller
+ *
+ * @author Steinar Knutsen
+ * @author bratseth
+ */
+class ServiceApiResponse extends HttpResponse {
+
+ private final Zone zone;
+ private final ApplicationId application;
+ private final List<URI> configServerURIs;
+ private final Slime slime;
+ private final Uri requestUri;
+
+ // Only set for one of the setResponse calls
+ private String serviceName = null;
+ private String restPath = null;
+
+ public ServiceApiResponse(Zone zone, ApplicationId application, List<URI> configServerURIs, URI requestUri) {
+ super(200);
+ this.zone = zone;
+ this.application = application;
+ this.configServerURIs = configServerURIs;
+ this.slime = new Slime();
+ this.requestUri = new Uri(requestUri).withoutParameters();
+ }
+
+ public void setResponse(ApplicationView applicationView) {
+ Cursor clustersArray = slime.setObject().setArray("clusters");
+ for (ClusterView clusterView : applicationView.clusters) {
+ Cursor clusterObject = clustersArray.addObject();
+ clusterObject.setString("name", clusterView.name);
+ clusterObject.setString("type", clusterView.type);
+ setNullableString("url", rewriteIfUrl(clusterView.url, requestUri), clusterObject);
+ Cursor servicesArray = clusterObject.setArray("services");
+ for (ServiceView serviceView : clusterView.services) {
+ Cursor serviceObject = servicesArray.addObject();
+ setNullableString("url", rewriteIfUrl(serviceView.url, requestUri), serviceObject);
+ serviceObject.setString("serviceType", serviceView.serviceType);
+ serviceObject.setString("serviceName", serviceView.serviceName);
+ serviceObject.setString("configId", serviceView.configId);
+ serviceObject.setString("host", serviceView.host);
+ }
+ }
+ }
+
+ public void setResponse(Map<?,?> responseData, String serviceName, String restPath) {
+ this.serviceName = serviceName;
+ this.restPath = restPath;
+ mapToSlime(responseData, slime.setObject());
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ new JsonFormat(true).encode(stream, slime);
+ }
+
+ @SuppressWarnings("unchecked")
+ private void mapToSlime(Map<?,?> data, Cursor object) {
+ for (Map.Entry<String, Object> entry : ((Map<String, Object>)data).entrySet())
+ fieldToSlime(entry.getKey(), entry.getValue(), object);
+ }
+
+ private void fieldToSlime(String key, Object value, Cursor object) {
+ if (value instanceof String) {
+ if (key.equals("url") || key.equals("link"))
+ value = rewriteIfUrl((String)value, generateLocalLinkPrefix(serviceName, restPath));
+ setNullableString(key, (String)value, object);
+ }
+ else if (value instanceof Integer) {
+ object.setLong(key, (int)value);
+ }
+ else if (value instanceof Long) {
+ object.setLong(key, (long)value);
+ }
+ else if (value instanceof Float) {
+ object.setDouble(key, (double)value);
+ }
+ else if (value instanceof Double) {
+ object.setDouble(key, (double)value);
+ }
+ else if (value instanceof List) {
+ listToSlime((List)value, object.setArray(key));
+ }
+ else if (value instanceof Map) {
+ mapToSlime((Map<?,?>)value, object.setObject(key));
+ }
+ }
+
+ private void listToSlime(List<?> list, Cursor array) {
+ for (Object entry : list)
+ entryToSlime(entry, array);
+ }
+
+ private void entryToSlime(Object entry, Cursor array) {
+ if (entry instanceof String)
+ addNullableString(rewriteIfUrl((String)entry, generateLocalLinkPrefix(serviceName, restPath)), array);
+ else if (entry instanceof Integer)
+ array.addLong((long)entry);
+ else if (entry instanceof Long)
+ array.addLong((long)entry);
+ else if (entry instanceof Float)
+ array.addDouble((double)entry);
+ else if (entry instanceof Double)
+ array.addDouble((double)entry);
+ else if (entry instanceof List)
+ listToSlime((List)entry, array.addArray());
+ else if (entry instanceof Map)
+ mapToSlime((Map)entry, array.addObject());
+ }
+
+ private String rewriteIfUrl(String urlOrAnyString, Uri requestUri) {
+ if (urlOrAnyString == null) return null;
+
+ String hostPattern = "(" +
+ String.join(
+ "|", configServerURIs.stream()
+ .map(URI::toString)
+ .map(s -> s.substring(0, s.length() -1))
+ .map(Pattern::quote)
+ .toArray(String[]::new))
+ + ")";
+
+ String remoteServicePath = "/serviceview/"
+ + "v1/tenant/" + application.tenant().value()
+ + "/application/" + application.application().value()
+ + "/environment/" + zone.environment().value()
+ + "/region/" + zone.region().value()
+ + "/instance/" + application.instance()
+ + "/service/";
+
+ Pattern remoteServiceResourcePattern = Pattern.compile("^(" + hostPattern + Pattern.quote(remoteServicePath) + ")");
+ Matcher matcher = remoteServiceResourcePattern.matcher(urlOrAnyString);
+
+ if (matcher.find()) {
+ String proxiedPath = urlOrAnyString.substring(matcher.group().length());
+ return requestUri.append(proxiedPath).toString();
+ } else {
+ return urlOrAnyString; // not a service url
+ }
+ }
+
+ private Uri generateLocalLinkPrefix(String identifier, String restPath) {
+ String proxiedPath = identifier + "/" + restPath;
+
+ if (this.requestUri.toString().endsWith(proxiedPath)) {
+ return new Uri(this.requestUri.toString().substring(0, this.requestUri.toString().length() - proxiedPath.length()));
+ } else {
+ throw new IllegalStateException("Expected the resource path '" + this.requestUri + "' to end with '" + proxiedPath + "'");
+ }
+ }
+
+ private void setNullableString(String key, String valueOrNull, Cursor receivingObject) {
+ if (valueOrNull == null)
+ receivingObject.setNix(key);
+ else
+ receivingObject.setString(key, valueOrNull);
+ }
+
+ private void addNullableString(String valueOrNull, Cursor receivingArray) {
+ if (valueOrNull == null)
+ receivingArray.addNix();
+ else
+ receivingArray.addString(valueOrNull);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java
new file mode 100644
index 00000000000..e02a31440ce
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java
@@ -0,0 +1,84 @@
+// 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.restapi.controller;
+
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.vespa.hosted.controller.maintenance.ControllerMaintenance;
+import com.yahoo.vespa.hosted.controller.restapi.ErrorResponse;
+import com.yahoo.vespa.hosted.controller.restapi.MessageResponse;
+import com.yahoo.vespa.hosted.controller.restapi.Path;
+import com.yahoo.vespa.hosted.controller.restapi.ResourceResponse;
+import com.yahoo.yolean.Exceptions;
+
+import java.util.concurrent.Executor;
+import java.util.logging.Level;
+
+/**
+ * This implements the controller/v1 API which provides operators with information about,
+ * and control over the Controller.
+ *
+ * @author bratseth
+ */
+public class ControllerApiHandler extends LoggingRequestHandler {
+
+ private final ControllerMaintenance maintenance;
+
+ public ControllerApiHandler(Executor executor, AccessLog accessLog, ControllerMaintenance maintenance) {
+ super(executor, accessLog);
+ this.maintenance = maintenance;
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest request) {
+ try {
+ switch (request.getMethod()) {
+ case GET: return handleGET(request);
+ case POST: return handlePOST(request);
+ case DELETE: return handleDELETE(request);
+ default: return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is not supported");
+ }
+ }
+ catch (IllegalArgumentException e) {
+ return ErrorResponse.badRequest(Exceptions.toMessageString(e));
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Unexpected error handling '" + request.getUri() + "'", e);
+ return ErrorResponse.internalServerError(Exceptions.toMessageString(e));
+ }
+ }
+
+ private HttpResponse handleGET(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/controller/v1/")) return root(request);
+ if (path.matches("/controller/v1/maintenance/")) return new JobsResponse(maintenance.jobControl());
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handlePOST(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/controller/v1/maintenance/inactive/{jobName}"))
+ return setActive(path.get("jobName"), false);
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handleDELETE(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/controller/v1/maintenance/inactive/{jobName}"))
+ return setActive(path.get("jobName"), true);
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse root(HttpRequest request) {
+ return new ResourceResponse(request, "maintenance");
+ }
+
+ private HttpResponse setActive(String jobName, boolean active) {
+ if ( ! maintenance.jobControl().jobs().contains(jobName))
+ return ErrorResponse.notFoundError("No job named '" + jobName + "'");
+ maintenance.jobControl().setActive(jobName, active);
+ return new MessageResponse((active ? "Re-activated" : "Deactivated" ) + " job '" + jobName + "'");
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/JobsResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/JobsResponse.java
new file mode 100644
index 00000000000..e7d1b3e0ed8
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/JobsResponse.java
@@ -0,0 +1,46 @@
+// 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.restapi.controller;
+
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.JsonFormat;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.hosted.controller.maintenance.JobControl;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * A response containing maintenance job status
+ *
+ * @author bratseth
+ */
+public class JobsResponse extends HttpResponse {
+
+ private final JobControl jobControl;
+
+ public JobsResponse(JobControl jobControl) {
+ super(200);
+ this.jobControl = jobControl;
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ Slime slime = new Slime();
+ Cursor root = slime.setObject();
+
+ Cursor jobArray = root.setArray("jobs");
+ for (String jobName : jobControl.jobs())
+ jobArray.addObject().setString("name", jobName);
+
+ Cursor inactiveArray = root.setArray("inactive");
+ for (String jobName : jobControl.inactiveJobs())
+ inactiveArray.addString(jobName);
+
+ new JsonFormat(true).encode(stream, slime);
+ }
+
+ @Override
+ public String getContentType() { return "application/json"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/DeploymentApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/DeploymentApiHandler.java
new file mode 100644
index 00000000000..affd679f2c2
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/DeploymentApiHandler.java
@@ -0,0 +1,122 @@
+// 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.restapi.deployment;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.versions.VespaVersion;
+import com.yahoo.vespa.hosted.controller.restapi.ErrorResponse;
+import com.yahoo.vespa.hosted.controller.restapi.SlimeJsonResponse;
+import com.yahoo.vespa.hosted.controller.restapi.Uri;
+import com.yahoo.vespa.hosted.controller.restapi.application.EmptyJsonResponse;
+import com.yahoo.vespa.hosted.controller.restapi.Path;
+import com.yahoo.yolean.Exceptions;
+
+import java.time.Instant;
+import java.util.Optional;
+import java.util.concurrent.Executor;
+import java.util.logging.Level;
+
+/**
+ * This implements the deployment/v1 API which provides information about the status of Vespa platform and
+ * application deployments.
+ *
+ * @author bratseth
+ */
+public class DeploymentApiHandler extends LoggingRequestHandler {
+
+ private final Controller controller;
+
+ public DeploymentApiHandler(Executor executor, AccessLog accessLog, Controller controller) {
+ super(executor, accessLog);
+ this.controller = controller;
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest request) {
+ try {
+ switch (request.getMethod()) {
+ case GET: return handleGET(request);
+ case OPTIONS: return handleOPTIONS();
+ default: return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is not supported");
+ }
+ }
+ catch (IllegalArgumentException e) {
+ return ErrorResponse.badRequest(Exceptions.toMessageString(e));
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Unexpected error handling '" + request.getUri() + "'", e);
+ return ErrorResponse.internalServerError(Exceptions.toMessageString(e));
+ }
+ }
+
+ private HttpResponse handleGET(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/deployment/v1/")) return root(request);
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handleOPTIONS() {
+ // We implement this to avoid redirect loops on OPTIONS requests from browsers, but do not really bother
+ // spelling out the methods supported at each path, which we should
+ EmptyJsonResponse response = new EmptyJsonResponse();
+ response.headers().put("Allow", "GET,OPTIONS");
+ return response;
+ }
+
+ private HttpResponse root(HttpRequest request) {
+ Slime slime = new Slime();
+ Cursor root = slime.setObject();
+ Cursor platformArray = root.setArray("versions");
+ for (VespaVersion version : controller.versionStatus().versions()) {
+ Cursor versionObject = platformArray.addObject();
+ versionObject.setString("version", version.versionNumber().toString());
+ versionObject.setString("confidence", version.confidence().name());
+ versionObject.setString("commit", version.releaseCommit());
+ versionObject.setLong("date", version.releasedAt().toEpochMilli());
+ versionObject.setBool("controllerVersion", version.isSelfVersion());
+ versionObject.setBool("systemVersion", version.isCurrentSystemVersion());
+
+ Cursor configServerArray = versionObject.setArray("configServers");
+ for (String configServerHostnames : version.configServerHostnames()) {
+ Cursor configServerObject = configServerArray.addObject();
+ configServerObject.setString("hostname", configServerHostnames);
+ }
+
+ Cursor failingArray = versionObject.setArray("failingApplications");
+ for (ApplicationId id : version.statistics().failing()) {
+ Optional<Application> application = controller.applications().get(id);
+ if ( ! application.isPresent()) continue; // deleted just now
+
+ Instant failingSince = application.get().deploymentJobs().failingSince();
+ if (failingSince == null) continue; // started working just now
+
+ Cursor applicationObject = failingArray.addObject();
+ toSlime(id, applicationObject, request);
+ applicationObject.setLong("failingSince", failingSince.toEpochMilli());
+ }
+
+ Cursor productionArray = versionObject.setArray("productionApplications");
+ for (ApplicationId id : version.statistics().production())
+ toSlime(id, productionArray.addObject(), request);
+ }
+ return new SlimeJsonResponse(slime);
+ }
+
+ private void toSlime(ApplicationId id, Cursor object, HttpRequest request) {
+ object.setString("tenant", id.tenant().value());
+ object.setString("application", id.application().value());
+ object.setString("instance", id.instance().value());
+ object.setString("url", new Uri(request.getUri()).withPath("/application/v4" +
+ "/tenant/" + id.tenant().value() +
+ "/application/" + id.application().value())
+ .toString());
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlHeaders.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlHeaders.java
new file mode 100644
index 00000000000..aea59c16cd5
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlHeaders.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.restapi.filter;
+
+import com.google.common.collect.ImmutableMap;
+
+import java.util.Map;
+
+import static java.util.concurrent.TimeUnit.DAYS;
+
+/**
+ * @author gv
+ */
+public interface AccessControlHeaders {
+
+ String CORS_PREFLIGHT_REQUEST_CACHE_TTL = Long.toString(DAYS.toSeconds(7));
+
+ String ALLOW_ORIGIN_HEADER = "Access-Control-Allow-Origin";
+
+ Map<String, String> ACCESS_CONTROL_HEADERS = ImmutableMap.of(
+ "Access-Control-Max-Age", CORS_PREFLIGHT_REQUEST_CACHE_TTL,
+ "Access-Control-Allow-Headers", "Origin,Content-Type,Accept,Yahoo-Principal-Auth",
+ "Access-Control-Allow-Methods", "OPTIONS,GET,PUT,DELETE,POST",
+ "Access-Control-Allow-Credentials", "true"
+ );
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlRequestFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlRequestFilter.java
new file mode 100644
index 00000000000..8dace5d56dc
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlRequestFilter.java
@@ -0,0 +1,68 @@
+// 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.restapi.filter;
+
+import com.google.inject.Inject;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpResponse;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+import com.yahoo.vespa.hosted.controller.restapi.filter.config.HttpAccessControlConfig;
+import com.yahoo.yolean.chain.After;
+import com.yahoo.yolean.chain.Before;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static com.yahoo.jdisc.http.HttpRequest.Method.OPTIONS;
+import static com.yahoo.vespa.hosted.controller.restapi.filter.AccessControlHeaders.ACCESS_CONTROL_HEADERS;
+import static com.yahoo.vespa.hosted.controller.restapi.filter.AccessControlHeaders.ALLOW_ORIGIN_HEADER;
+
+/**
+ * <p>
+ * This filter makes sure we respond as quickly as possible to CORS pre-flight requests
+ * which browsers transmit before the Hosted Vespa dashboard code is allowed to send a "real" request.
+ * </p>
+ * <p>
+ * An "Access-Control-Max-Age" header is added so that the browser will cache the result of this pre-flight request,
+ * further improving the responsiveness of the Hosted Vespa dashboard application.
+ * </p>
+ * <p>
+ * Runs after all standard security request filters, but before BouncerFilter, as the browser does not send
+ * credentials with pre-flight requests.
+ * </p>
+ *
+ * @author andreer
+ * @author gv
+ */
+@After({"InputValidationFilter","RemoteIPFilter", "DoNotTrackRequestFilter", "CookieDataRequestFilter"})
+@Before("BouncerFilter")
+public class AccessControlRequestFilter implements SecurityRequestFilter {
+ private final Set<String> allowedUrls;
+
+ @Inject
+ public AccessControlRequestFilter(HttpAccessControlConfig config) {
+ allowedUrls = Collections.unmodifiableSet(config.allowedUrls().stream().collect(Collectors.toSet()));
+ }
+
+ @Override
+ public void filter(DiscFilterRequest discFilterRequest, ResponseHandler responseHandler) {
+ String origin = discFilterRequest.getHeader("Origin");
+
+ if (!discFilterRequest.getMethod().equals(OPTIONS.name()))
+ return;
+
+ HttpResponse response = HttpResponse.newInstance(Response.Status.OK);
+
+ if (allowedUrls.contains(origin))
+ response.headers().add(ALLOW_ORIGIN_HEADER, origin);
+
+ ACCESS_CONTROL_HEADERS.forEach(
+ (name, value) -> response.headers().add(name, value));
+
+ ContentChannel cc = responseHandler.handleResponse(response);
+ cc.close(null);
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlResponseFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlResponseFilter.java
new file mode 100644
index 00000000000..c2ad31cd925
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlResponseFilter.java
@@ -0,0 +1,55 @@
+// 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.restapi.filter;
+
+import com.yahoo.jdisc.AbstractResource;
+import com.yahoo.jdisc.http.filter.DiscFilterResponse;
+import com.yahoo.jdisc.http.filter.RequestView;
+import com.yahoo.jdisc.http.filter.SecurityResponseFilter;
+import com.yahoo.vespa.hosted.controller.restapi.filter.config.HttpAccessControlConfig;
+
+import java.util.List;
+import java.util.Optional;
+
+import static com.yahoo.vespa.hosted.controller.restapi.filter.AccessControlHeaders.ACCESS_CONTROL_HEADERS;
+import static com.yahoo.vespa.hosted.controller.restapi.filter.AccessControlHeaders.ALLOW_ORIGIN_HEADER;
+
+/**
+ * @author gv
+ * @author Tony Vaagenes
+ */
+public class AccessControlResponseFilter extends AbstractResource implements SecurityResponseFilter {
+
+ private final List<String> allowedUrls;
+
+ public AccessControlResponseFilter(HttpAccessControlConfig config) {
+ allowedUrls = config.allowedUrls();
+ }
+
+ @Override
+ public void filter(DiscFilterResponse response, RequestView request) {
+ Optional<String> requestOrigin = request.getFirstHeader("Origin");
+
+ requestOrigin.ifPresent(
+ origin -> allowedUrls.stream()
+ .filter(allowedUrl -> matchesRequestOrigin(origin, allowedUrl))
+ .findAny()
+ .ifPresent(allowedOrigin -> setHeaderUnlessExists(response, ALLOW_ORIGIN_HEADER, allowedOrigin))
+ );
+ ACCESS_CONTROL_HEADERS.forEach((name, value) -> setHeaderUnlessExists(response, name, value));
+ }
+
+ private boolean matchesRequestOrigin(String requestOrigin, String allowedUrl) {
+ return allowedUrl.equals("*") || requestOrigin.startsWith(allowedUrl);
+ }
+
+ /**
+ * This is to avoid duplicating headers already set by the {@link AccessControlRequestFilter}.
+ * Currently (March 2016), this filter is invoked for OPTIONS requests to jdisc request handlers,
+ * even if the request filter has been invoked first. For jersey based APIs, this filter is NOT
+ * invoked in these cases.
+ */
+ private void setHeaderUnlessExists(DiscFilterResponse response, String name, String value) {
+ if (response.getHeader(name) == null)
+ response.setHeader(name, value);
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/DummyFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/DummyFilter.java
new file mode 100644
index 00000000000..7beb3f755ad
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/DummyFilter.java
@@ -0,0 +1,16 @@
+// 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.restapi.filter;
+
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+
+/**
+ * @author Stian Kristoffersen
+ */
+public class DummyFilter implements SecurityRequestFilter {
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler handler) {
+ /* Do nothing - a bug in JDisc prevents empty request chains */
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/NTokenRequestFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/NTokenRequestFilter.java
new file mode 100644
index 00000000000..0138d3ae65c
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/NTokenRequestFilter.java
@@ -0,0 +1,33 @@
+// 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.restapi.filter;
+
+import com.google.inject.Inject;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.Athens;
+import com.yahoo.yolean.chain.After;
+
+/**
+ * @author bjorncs
+ */
+@After("BouncerFilter")
+public class NTokenRequestFilter implements SecurityRequestFilter {
+
+ public static final String NTOKEN_HEADER = "com.yahoo.vespa.hosted.controller.restapi.filter.NTokenRequestFilter.ntoken";
+
+ private final Athens athens;
+
+ @Inject
+ public NTokenRequestFilter(Athens athens) {
+ this.athens = athens;
+ }
+
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler responseHandler) {
+ String nToken = request.getHeader(athens.principalTokenHeader());
+ if (nToken != null) {
+ request.setAttribute(NTOKEN_HEADER, nToken);
+ }
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/SetBouncerPassthruHeaderFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/SetBouncerPassthruHeaderFilter.java
new file mode 100644
index 00000000000..7ea98528a88
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/SetBouncerPassthruHeaderFilter.java
@@ -0,0 +1,27 @@
+// 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.restapi.filter;
+
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+import com.yahoo.yolean.chain.After;
+
+/**
+ * @author Stian Kristoffersen
+ */
+@After("BouncerFilter")
+public class SetBouncerPassthruHeaderFilter implements SecurityRequestFilter {
+
+ public static final String BOUNCER_PASSTHRU_ATTRIBUTE = "bouncer.bypassthru";
+ public static final String BOUNCER_PASSTHRU_COOKIE_OK = "1";
+ public static final String BOUNCER_PASSTHRU_HEADER_FIELD = "com.yahoo.hosted.vespa.bouncer.passthru";
+
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler handler) {
+ Object statusProperty = request.getAttribute(BOUNCER_PASSTHRU_ATTRIBUTE);
+ String status = Integer.toString((int)statusProperty);
+
+ request.addHeader(BOUNCER_PASSTHRU_HEADER_FIELD, status);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UnauthenticatedUserPrincipal.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UnauthenticatedUserPrincipal.java
new file mode 100644
index 00000000000..a88e881ce9d
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UnauthenticatedUserPrincipal.java
@@ -0,0 +1,44 @@
+// 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.restapi.filter;
+
+import java.security.Principal;
+import java.util.Objects;
+
+/**
+ * A principal for an unauthenticated user (typically from a trusted host).
+ * This principal should only be used in combination with machine authentication!
+ *
+ * @author bjorncs
+ */
+public class UnauthenticatedUserPrincipal implements Principal {
+ private final String username;
+
+ public UnauthenticatedUserPrincipal(String username) {
+ this.username = username;
+ }
+
+ @Override
+ public String getName() {
+ return username;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ UnauthenticatedUserPrincipal that = (UnauthenticatedUserPrincipal) o;
+ return Objects.equals(username, that.username);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(username);
+ }
+
+ @Override
+ public String toString() {
+ return "UnauthenticatedUserPrincipal{" +
+ "username='" + username + '\'' +
+ '}';
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UserIdRequestFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UserIdRequestFilter.java
new file mode 100644
index 00000000000..46df4d7a603
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UserIdRequestFilter.java
@@ -0,0 +1,23 @@
+// 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.restapi.filter;
+
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+import com.yahoo.vespa.hosted.controller.api.nonpublic.HeaderFields;
+import com.yahoo.yolean.chain.Before;
+
+/**
+ * Allows hosts using host-based authentication to set user ID.
+ *
+ * @author Tony Vaagenes
+ */
+@Before("CreateSecurityContextFilter")
+public class UserIdRequestFilter implements SecurityRequestFilter {
+
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler handler) {
+ String userName = request.getHeader(HeaderFields.USER_ID_HEADER_FIELD);
+ request.setUserPrincipal(new UnauthenticatedUserPrincipal(userName));
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/CreateSecurityContextFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/CreateSecurityContextFilter.java
new file mode 100644
index 00000000000..850130ca970
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/CreateSecurityContextFilter.java
@@ -0,0 +1,50 @@
+// 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.restapi.filter.securitycontext;
+
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+import com.yahoo.vespa.hosted.controller.common.ContextAttributes;
+import com.yahoo.yolean.chain.After;
+import com.yahoo.yolean.chain.Provides;
+
+import javax.ws.rs.core.SecurityContext;
+import java.security.Principal;
+
+/**
+ * Exposes the security information from the disc filter request
+ * by storing a security context in the request context.
+ *
+ * @author Tony Vaagenes
+ */
+@After("BouncerFilter")
+@Provides("SecurityContext")
+public class CreateSecurityContextFilter implements SecurityRequestFilter {
+
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler handler) {
+ request.setAttribute(ContextAttributes.SECURITY_CONTEXT_ATTRIBUTE,
+ new SecurityContext() {
+ @Override
+ public Principal getUserPrincipal() {
+ return request.getUserPrincipal();
+ }
+
+ @Override
+ public boolean isUserInRole(String role) {
+ return request.isUserInRole(role);
+ }
+
+ @Override
+ public boolean isSecure() {
+ return request.isSecure();
+ }
+
+ @Override
+ public String getAuthenticationScheme() {
+ throw new UnsupportedOperationException();
+ }
+ });
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/PropagateSecurityContextFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/PropagateSecurityContextFilter.java
new file mode 100644
index 00000000000..17c86e89362
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/PropagateSecurityContextFilter.java
@@ -0,0 +1,31 @@
+// 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.restapi.filter.securitycontext;
+
+import com.yahoo.vespa.hosted.controller.common.ContextAttributes;
+
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerRequestFilter;
+import javax.ws.rs.container.PreMatching;
+import javax.ws.rs.core.SecurityContext;
+import javax.ws.rs.ext.Provider;
+import java.io.IOException;
+
+/**
+ * Get the security context from the underlying Servlet request, and expose it to
+ * Jersey resources.
+ *
+ * @author Tony Vaagenes
+ */
+@PreMatching
+@Provider
+public class PropagateSecurityContextFilter implements ContainerRequestFilter {
+ @Override
+ public void filter(ContainerRequestContext requestContext) throws IOException {
+ SecurityContext securityContext =
+ (SecurityContext) requestContext.getProperty(ContextAttributes.SECURITY_CONTEXT_ATTRIBUTE);
+
+ if (securityContext != null) {
+ requestContext.setSecurityContext(securityContext);
+ }
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/package-info.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/package-info.java
new file mode 100644
index 00000000000..0b98599dbb0
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/package-info.java
@@ -0,0 +1,10 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Jersey requires that the package is exported to be able to instantiate the filter.
+ *
+ * @author Tony Vaagenes
+ */
+@ExportPackage
+package com.yahoo.vespa.hosted.controller.restapi.filter.securitycontext;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/screwdriver/ScrewdriverApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/screwdriver/ScrewdriverApiHandler.java
new file mode 100644
index 00000000000..a623e880c4c
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/screwdriver/ScrewdriverApiHandler.java
@@ -0,0 +1,168 @@
+// 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.restapi.screwdriver;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.io.IOUtils;
+import com.yahoo.jdisc.http.HttpRequest.Method;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Inspector;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.config.SlimeUtils;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.integration.BuildService.BuildJob;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobError;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobReport;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobType;
+import com.yahoo.vespa.hosted.controller.restapi.ErrorResponse;
+import com.yahoo.vespa.hosted.controller.restapi.SlimeJsonResponse;
+import com.yahoo.vespa.hosted.controller.restapi.StringResponse;
+import com.yahoo.vespa.hosted.controller.versions.VespaVersion;
+import com.yahoo.yolean.Exceptions;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.Executor;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * This implements a callback API from Screwdriver which lets deployment jobs notify the controller
+ * on completion.
+ *
+ * @author bratseth
+ */
+public class ScrewdriverApiHandler extends LoggingRequestHandler {
+
+ private final static Logger log = Logger.getLogger(ScrewdriverApiHandler.class.getName());
+
+ private final Controller controller;
+ // TODO: Remember to distinguish between PR jobs and component ones, by adding reports to the right jobs?
+
+ public ScrewdriverApiHandler(Executor executor, AccessLog accessLog, Controller controller) {
+ super(executor, accessLog);
+ this.controller = controller;
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest request) {
+ try {
+ Method method = request.getMethod();
+ String path = request.getUri().getPath();
+ switch (method) {
+ case GET: switch (path) {
+ case "/screwdriver/v1/release/vespa": return vespaVersion();
+ case "/screwdriver/v1/jobsToRun": return buildJobResponse(controller.applications().deploymentTrigger().buildSystem().jobs());
+ default: return ErrorResponse.notFoundError(String.format( "No '%s' handler at '%s'", method, path));
+ }
+ case POST: switch (path) {
+ case "/screwdriver/v1/jobreport": return handleJobReportPost(request);
+ default: return ErrorResponse.notFoundError(String.format( "No '%s' handler at '%s'", method, path));
+ }
+ case DELETE: switch (path) {
+ case "/screwdriver/v1/jobsToRun": return buildJobResponse(controller.applications().deploymentTrigger().buildSystem().takeJobsToRun());
+ default: return ErrorResponse.notFoundError(String.format( "No '%s' handler at '%s'", method, path));
+ }
+ default: return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is not supported");
+ }
+ } catch (IllegalArgumentException|IllegalStateException e) {
+ return ErrorResponse.badRequest(Exceptions.toMessageString(e));
+ } catch (RuntimeException e) {
+ log.log(Level.WARNING, "Unexpected error handling '" + request.getUri() + "'", e);
+ return ErrorResponse.internalServerError(Exceptions.toMessageString(e));
+ }
+ }
+
+ private HttpResponse vespaVersion() {
+ VespaVersion version = controller.versionStatus().version(controller.systemVersion());
+ if (version == null)
+ return ErrorResponse.notFoundError("Information about the current system version is not available at this time");
+
+ Slime slime = new Slime();
+ Cursor cursor = slime.setObject();
+ cursor.setString("version", version.versionNumber().toString());
+ cursor.setString("sha", version.releaseCommit());
+ cursor.setLong("date", version.releasedAt().toEpochMilli());
+ return new SlimeJsonResponse(slime);
+
+ }
+
+ private HttpResponse buildJobResponse(List<BuildJob> buildJobs) {
+ Slime slime = new Slime();
+ Cursor buildJobArray = slime.setArray();
+ for (BuildJob buildJob : buildJobs) {
+ Cursor buildJobObject = buildJobArray.addObject();
+ buildJobObject.setLong("projectId", buildJob.projectId());
+ buildJobObject.setString("jobName", buildJob.jobName());
+ }
+ return new SlimeJsonResponse(slime);
+ }
+
+ /**
+ * Parse a JSON blob of the form:
+ * {
+ * "tenant" : String
+ * "application" : String
+ * "instance" : String
+ * "jobName" : String
+ * "projectId" : long
+ * "buildNumber" : long
+ * "success" : boolean
+ * "selfTriggering": boolean
+ * "gitChanges" : boolean
+ * "vespaVersion" : String
+ * }
+ * and notify the controller of the report.
+ *
+ * @param request The JSON blob.
+ * @return 200
+ */
+ private HttpResponse handleJobReportPost(HttpRequest request) {
+ // TODO: buildNumber is unused now -- remove, or use.
+ // TODO: selfTriggering is unused now -- remove, or use.
+ // TODO: gitChanges is unused now -- remove, or use.
+ // Note: gitChanges is probably only useful for the component step, since it check the gir repo directly;
+ // for other jobs, the last component's git commit is what matters.
+ // TODO: ApplicationId (tenant, application, instance) is unused now -- remove, or use.
+
+ controller.applications().notifyJobCompletion(toJobReport(toSlime(request.getData()).get()));
+
+ return new StringResponse("ok");
+ }
+
+ private Slime toSlime(InputStream jsonStream) {
+ try {
+ byte[] jsonBytes = IOUtils.readBytes(jsonStream, 1000 * 1000);
+ return SlimeUtils.jsonToSlime(jsonBytes);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private JobReport toJobReport(Inspector report) {
+ Optional<JobError> jobError = Optional.empty();
+ if (report.field("jobError").valid()) {
+ jobError = Optional.of(JobError.valueOf(report.field("jobError").asString()));
+ } else if (report.field("success").valid()) { // TODO: Remove after May 2017
+ jobError = JobError.from(report.field("success").asBool());
+ }
+ return new JobReport(
+ ApplicationId.from(
+ report.field("tenant").asString(),
+ report.field("application").asString(),
+ report.field("instance").asString()),
+ JobType.fromId(report.field("jobName").asString()),
+ report.field("projectId").asLong(),
+ report.field("buildNumber").asLong(),
+ jobError,
+ report.field("selfTriggering").asBool(),
+ report.field("gitChanges").asBool()
+ );
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/versions/DeploymentStatistics.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/versions/DeploymentStatistics.java
new file mode 100644
index 00000000000..fbd1a74c12c
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/versions/DeploymentStatistics.java
@@ -0,0 +1,62 @@
+// 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.versions;
+
+import com.google.common.collect.ImmutableList;
+import com.yahoo.component.Version;
+import com.yahoo.config.provision.ApplicationId;
+
+import java.util.List;
+
+/**
+ * Statistics about deployments on a platform version. This is immutable.
+ *
+ * @author bratseth
+ */
+public class DeploymentStatistics {
+
+ private final Version version;
+ private final ImmutableList<ApplicationId> failing;
+ private final ImmutableList<ApplicationId> production;
+
+ private DeploymentStatistics(Version version,
+ List<ApplicationId> failingApplications, List<ApplicationId> production) {
+ this.version = version;
+ this.failing = ImmutableList.copyOf(failingApplications);
+ this.production = ImmutableList.copyOf(production);
+ }
+
+ /** Returns a statistics instance with the values as 0 */
+ public static DeploymentStatistics empty(Version version) {
+ return new DeploymentStatistics(version, ImmutableList.of(), ImmutableList.of());
+ }
+
+ /** Returns the version these statistics are for */
+ public Version version() { return version; }
+
+ /**
+ * Returns the applications which have at least one job (of any type) which fails on this version,
+ * excluding errors known to not be caused by this version
+ */
+ public List<ApplicationId> failing() { return failing; }
+
+ /** Returns the applications which have this version in production in at least one zone */
+ public List<ApplicationId> production() { return production; }
+
+ /** Returns a version of this with the given failing application added */
+ public DeploymentStatistics withFailing(ApplicationId application) {
+ return new DeploymentStatistics(version, add(application, failing), production);
+ }
+
+ /** Returns a version of this with the given production application added */
+ public DeploymentStatistics withProduction(ApplicationId application) {
+ return new DeploymentStatistics(version, failing, add(application, production));
+ }
+
+ private ImmutableList<ApplicationId> add(ApplicationId application, ImmutableList<ApplicationId> list) {
+ ImmutableList.Builder<ApplicationId> b = new ImmutableList.Builder<>();
+ b.addAll(list);
+ b.add(application);
+ return b.build();
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/versions/VersionStatus.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/versions/VersionStatus.java
new file mode 100644
index 00000000000..bef96014e79
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/versions/VersionStatus.java
@@ -0,0 +1,182 @@
+// 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.versions;
+
+import com.google.common.collect.ImmutableList;
+import com.yahoo.collections.ListMap;
+import com.yahoo.component.Version;
+import com.yahoo.component.Vtag;
+import com.yahoo.vespa.hosted.controller.api.integration.github.GitSha;
+import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.application.Deployment;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs;
+import com.yahoo.vespa.hosted.controller.application.JobStatus;
+
+import java.net.URI;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+/**
+ * Information about the current platform versions in use.
+ * The versions in use are the set of all versions running in current applications, versions
+ * of config servers in all zones, and the version of this controller itself.
+ *
+ * This is immutable.
+ *
+ * @author bratseth
+ */
+public class VersionStatus {
+
+ private static final Logger log = Logger.getLogger(VersionStatus.class.getName());
+
+ private static final String VESPA_REPO = "vespa-yahoo";
+ private static final String VESPA_REPO_OWNER = "vespa";
+
+ private final ImmutableList<VespaVersion> versions;
+
+ /** Create a version status. DO NOT USE: Public for testing only */
+ public VersionStatus(List<VespaVersion> versions) {
+ this.versions = ImmutableList.copyOf(versions);
+ }
+
+ /**
+ * Returns the current Vespa version of the system controlled by this,
+ * or empty if we have not currently determined what the system version is in this status.
+ */
+ public Optional<VespaVersion> systemVersion() {
+ return versions().stream().filter(VespaVersion::isCurrentSystemVersion).findAny();
+ }
+
+ /**
+ * Lists all currently active Vespa versions, with deployment statistics,
+ * sorted from lowest to highest version number.
+ * The returned list is immutable.
+ * Calling this is free, but the returned status is slightly out of date.
+ */
+ public List<VespaVersion> versions() { return versions; }
+
+ /** Returns the given version, or null if it is not present */
+ public VespaVersion version(Version version) {
+ return versions.stream().filter(v -> v.versionNumber().equals(version)).findFirst().orElse(null);
+ }
+
+ /** Create the empty version status */
+ public static VersionStatus empty() { return new VersionStatus(ImmutableList.of()); }
+
+ /** Create a full, updated version status. This is expensive and should be done infrequently */
+ public static VersionStatus compute(Controller controller) {
+ return compute(controller, Vtag.currentVersion);
+ }
+
+ /** Compute version status using the given current version. This is useful for testing. */
+ public static VersionStatus compute(Controller controller, Version currentVersion) {
+ ListMap<Version, String> configServerVersions = findConfigServerVersions(controller);
+
+ Set<Version> infrastructureVersions = new HashSet<>();
+ infrastructureVersions.add(currentVersion);
+ infrastructureVersions.addAll(configServerVersions.keySet());
+
+ // The system version is the oldest infrastructure version
+ Version systemVersion = infrastructureVersions.stream().sorted().findFirst().get();
+
+ Collection<DeploymentStatistics> deploymentStatistics = computeDeploymentStatistics(infrastructureVersions,
+ controller.applications().asList());
+ List<VespaVersion> versions = new ArrayList<>();
+
+ for (DeploymentStatistics statistics : deploymentStatistics) {
+ if (statistics.version().isEmpty()) continue;
+
+ try {
+ VespaVersion vespaVersion = createVersion(statistics,
+ statistics.version().equals(systemVersion),
+ configServerVersions.getList(statistics.version()),
+ controller);
+ versions.add(vespaVersion);
+ } catch (IllegalArgumentException e) {
+ log.log(Level.WARNING, "Unable to create VespaVersion for version " +
+ statistics.version().toFullString(), e);
+ }
+ }
+ Collections.sort(versions);
+
+ return new VersionStatus(versions);
+ }
+
+ private static ListMap<Version, String> findConfigServerVersions(Controller controller) {
+ List<URI> configServers = controller.zoneRegistry().zones().stream()
+ .flatMap(zone -> controller.getConfigServerUris(zone.environment(), zone.region()).stream())
+ .collect(Collectors.toList());
+
+ ListMap<Version, String> versions = new ListMap<>();
+ for (URI configServer : configServers)
+ versions.put(controller.applications().configserverClient().version(configServer), configServer.getHost());
+ return versions;
+ }
+
+ private static Collection<DeploymentStatistics> computeDeploymentStatistics(Set<Version> infrastructureVersions,
+ List<Application> applications) {
+ Map<Version, DeploymentStatistics> versionMap = new HashMap<>();
+
+ for (Version infrastructureVersion : infrastructureVersions)
+ versionMap.put(infrastructureVersion, DeploymentStatistics.empty(infrastructureVersion));
+
+ for (Application application : applications) {
+ DeploymentJobs jobs = application.deploymentJobs();
+
+ // Note that each version deployed on this application exists
+ for (Deployment deployment : application.deployments().values())
+ versionMap.computeIfAbsent(deployment.version(), DeploymentStatistics::empty);
+
+ // List versions which have failing jobs, and versions which are in production
+ // TODO: Don't count applications which started failing on an application change, not a version change
+
+ // Failing versions
+ Map<Version, List<JobStatus>> failingJobsByVersion = jobs.jobStatus().values().stream()
+ .filter(jobStatus -> jobStatus.lastCompleted().isPresent())
+ .filter(jobStatus -> jobStatus.jobError().isPresent())
+ .filter(jobStatus -> jobStatus.jobError().get() != DeploymentJobs.JobError.outOfCapacity)
+ .collect(Collectors.groupingBy(jobStatus -> jobStatus.lastCompleted().get().version()));
+ for (Version v : failingJobsByVersion.keySet()) {
+ versionMap.compute(v, (version, statistics) -> emptyIfMissing(version, statistics).withFailing(application.id()));
+ }
+
+ // Succeeding versions
+ Map<Version, List<JobStatus>> succeedingJobsByVersions = jobs.jobStatus().values().stream()
+ .filter(jobStatus -> jobStatus.lastSuccess().isPresent())
+ .filter(jobStatus -> jobStatus.type().isProduction())
+ .collect(Collectors.groupingBy(jobStatus -> jobStatus.lastSuccess().get().version()));
+ for (Version v : succeedingJobsByVersions.keySet()) {
+ versionMap.compute(v, (version, statistics) -> emptyIfMissing(version, statistics).withProduction(application.id()));
+ }
+ }
+ return versionMap.values();
+ }
+
+ private static DeploymentStatistics emptyIfMissing(Version version, DeploymentStatistics statistics) {
+ return statistics == null ? DeploymentStatistics.empty(version) : statistics;
+ }
+
+ private static VespaVersion createVersion(DeploymentStatistics statistics,
+ boolean isSystemVersion,
+ Collection<String> configServerHostnames,
+ Controller controller) {
+ GitSha gitSha = controller.gitHub().getCommit(VESPA_REPO_OWNER, VESPA_REPO, statistics.version().toFullString());
+ return new VespaVersion(statistics,
+ gitSha.sha, Instant.ofEpochMilli(gitSha.commit.author.date.getTime()),
+ isSystemVersion,
+ configServerHostnames,
+ controller);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/versions/VespaVersion.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/versions/VespaVersion.java
new file mode 100644
index 00000000000..ce5533bd0bc
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/versions/VespaVersion.java
@@ -0,0 +1,139 @@
+// 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.versions;
+
+import com.google.common.collect.ImmutableSet;
+import com.yahoo.component.Version;
+import com.yahoo.component.Vtag;
+import com.yahoo.config.application.api.DeploymentSpec.UpgradePolicy;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.application.ApplicationList;
+
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Set;
+
+/**
+ * Information about a particular Vespa version.
+ * VespaVersions are identified by their version number and ordered by increasing version numbers.
+ *
+ * This is immutable.
+ *
+ * @author bratseth
+ */
+public class VespaVersion implements Comparable<VespaVersion> {
+
+ private final String releaseCommit;
+ private final Instant releasedAt;
+ private final boolean isCurrentSystemVersion;
+ private final DeploymentStatistics statistics;
+ private final Confidence confidence;
+ private final ImmutableSet<String> configServerHostnames;
+
+ public VespaVersion(DeploymentStatistics statistics, String releaseCommit, Instant releasedAt,
+ boolean isCurrentSystemVersion, Collection<String> configServerHostnames,
+ Controller controller) {
+ this.statistics = statistics;
+ this.releaseCommit = releaseCommit;
+ this.releasedAt = releasedAt;
+ this.isCurrentSystemVersion = isCurrentSystemVersion;
+ this.configServerHostnames = ImmutableSet.copyOf(configServerHostnames);
+ this.confidence = deduceConfidenceFrom(statistics, controller, releasedAt);
+ }
+
+ private static Confidence deduceConfidenceFrom(DeploymentStatistics statistics,
+ Controller controller,
+ Instant releasedAt) {
+ // 'production on this': All deployment jobs upgrading to this version have completed without failure
+ ApplicationList productionOnThis = ApplicationList.from(statistics.production(), controller.applications())
+ .notUpgradingTo(statistics.version())
+ .notFailing();
+ ApplicationList failingOnThis = ApplicationList.from(statistics.failing(), controller.applications());
+ ApplicationList all = ApplicationList.from(controller.applications().asList())
+ .hasDeployment()
+ .notPullRequest();
+
+ // 'broken' if any Canary fails
+ if ( ! failingOnThis.with(UpgradePolicy.canary).isEmpty())
+ return Confidence.broken;
+
+ // 'broken' if 4 non-canary was broken by this, and that is at least 10% of all
+ int brokenByThisVersion = failingOnThis.without(UpgradePolicy.canary).startedFailingAfter(releasedAt).size();
+ if (brokenByThisVersion >= 4 && brokenByThisVersion >= productionOnThis.size() * 0.1)
+ return Confidence.broken;
+
+ // 'low' unless all canary applications are upgraded
+ if (productionOnThis.with(UpgradePolicy.canary).size() < all.with(UpgradePolicy.canary).size())
+ return Confidence.low;
+
+ // 'high' if 90% of all default upgrade applications upgraded
+ if (productionOnThis.with(UpgradePolicy.defaultPolicy).size() >=
+ all.with(UpgradePolicy.defaultPolicy).size() * 0.9)
+ return Confidence.high;
+
+ return Confidence.normal;
+ }
+
+ /** Returns the version number of this Vespa version */
+ public Version versionNumber() { return statistics.version(); }
+
+ /** Returns the sha of the release tag commit for this version in git */
+ public String releaseCommit() { return releaseCommit; }
+
+ /** Returns the time of the release commit */
+ public Instant releasedAt() { return releasedAt; }
+
+ /** Statistics about deployment of this version */
+ public DeploymentStatistics statistics() { return statistics; }
+
+ /** Returns whether this is the version currently running on this controller */
+ public boolean isSelfVersion() { return versionNumber().equals(Vtag.currentVersion); }
+
+ /**
+ * Returns whether this is the current version of the infrastructure of the system
+ * (i.e the lowest version across this controller and all config servers in all zones).
+ * A goal of the controller is to eventually (limited by safety and upgrade capacity) drive
+ * all applications to this version.
+ *
+ * Note that the self version may be higher than the current system version if
+ * all config servers are not yet upgraded to the version of this controller.
+ */
+ public boolean isCurrentSystemVersion() { return isCurrentSystemVersion; }
+
+ /** Returns the host names of the config servers (across all zones) which are currently of this version */
+ public Set<String> configServerHostnames() { return configServerHostnames; }
+
+ /** Returns the confidence we have in this versions suitability for production */
+ public Confidence confidence() { return confidence; }
+
+ @Override
+ public int compareTo(VespaVersion other) {
+ return this.versionNumber().compareTo(other.versionNumber());
+ }
+
+ @Override
+ public int hashCode() { return versionNumber().hashCode(); }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == this) return true;
+ if ( ! (other instanceof VespaVersion)) return false;
+ return ((VespaVersion)other).versionNumber().equals(this.versionNumber());
+ }
+
+ public enum Confidence {
+
+ /** This version has been proven defective */
+ broken,
+
+ /** We don't have sufficient evidence that this version is working */
+ low,
+
+ /** We have sufficient evidence that this version is working */
+ normal,
+
+ /** We have overwhelming evidence that this version is working */
+ high
+
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/restapi/impl/StatusPageResource.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/restapi/impl/StatusPageResource.java
new file mode 100644
index 00000000000..f5852b9dfcf
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/restapi/impl/StatusPageResource.java
@@ -0,0 +1,55 @@
+// 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.restapi.impl;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.inject.Inject;
+import com.yahoo.container.jaxrs.annotation.Component;
+import com.yahoo.vespa.hosted.controller.api.integration.security.KeyService;
+
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.ClientBuilder;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.UriBuilder;
+
+/**
+ * Proxies requests from controller to https://xxx.statuspage.io/api/v2/yyy.json?api_key=zzz[&amp;since=YYYY-MM-DDThh:mm[:ss]±hh:mm]
+ *
+ * @author andreer
+ */
+@Path("/v1/")
+@Produces(MediaType.APPLICATION_JSON)
+public class StatusPageResource implements com.yahoo.vespa.hosted.controller.api.statuspage.StatusPageResource {
+
+ private final Client client;
+ private final KeyService keyService;
+
+ @Inject
+ public StatusPageResource(@Component KeyService keyService) {
+ this(keyService, ClientBuilder.newClient());
+ }
+
+ protected StatusPageResource(KeyService keyService, Client client) {
+ this.keyService = keyService;
+ this.client = client;
+ }
+
+ protected UriBuilder statusPageURL(String page, String since) {
+ String[] secrets = keyService.getSecret("vespa_hosted.controller.statuspage_api_key").split(":");
+ UriBuilder uriBuilder = UriBuilder.fromUri("https://" + secrets[0] + ".statuspage.io/api/v2/" + page + ".json?api_key=" + secrets[1]);
+ if (since != null) {
+ uriBuilder.queryParam("since", since);
+ }
+
+ return uriBuilder;
+ }
+
+ @Override
+ public JsonNode statusPage(String page, String since) {
+ WebTarget target = client.target(statusPageURL(page, since));
+ return target.request().get(JsonNode.class);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/restapi/impl/package-info.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/restapi/impl/package-info.java
new file mode 100644
index 00000000000..dca8a22a313
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/restapi/impl/package-info.java
@@ -0,0 +1,8 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * @author Tony Vaagenes
+ */
+@ExportPackage
+package com.yahoo.vespa.hosted.restapi.impl;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/rotation/ControllerRotationRepository.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/rotation/ControllerRotationRepository.java
new file mode 100644
index 00000000000..9eef1dac70b
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/rotation/ControllerRotationRepository.java
@@ -0,0 +1,148 @@
+// 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.rotation;
+
+import com.yahoo.config.application.api.DeploymentSpec;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.log.LogLevel;
+import com.yahoo.metrics.simple.Gauge;
+import com.yahoo.metrics.simple.MetricReceiver;
+import com.yahoo.vespa.hosted.controller.api.identifiers.RotationId;
+import com.yahoo.vespa.hosted.controller.api.ApplicationAlias;
+import com.yahoo.vespa.hosted.controller.persistence.ControllerDb;
+import com.yahoo.vespa.hosted.controller.api.rotation.Rotation;
+import com.yahoo.vespa.hosted.rotation.config.RotationsConfig;
+import org.jetbrains.annotations.NotNull;
+
+import java.net.URI;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+/**
+ * A rotation repository.
+ *
+ * @author Oyvind Gronnesby
+ */
+// TODO: Fold this into ApplicationController+Application
+public class ControllerRotationRepository implements RotationRepository {
+
+ private static final Logger log = Logger.getLogger(ControllerRotationRepository.class.getName());
+
+ private static final String REMAINING_ROTATIONS_METRIC_NAME = "remaining_rotations";
+ private final Gauge remainingRotations;
+
+ private final ControllerDb controllerDb;
+ private final Map<RotationId, Rotation> rotationsMap;
+
+ public ControllerRotationRepository(RotationsConfig rotationConfig, ControllerDb controllerDb, MetricReceiver metricReceiver) {
+ this.controllerDb = controllerDb;
+ this.rotationsMap = buildRotationsMap(rotationConfig);
+ this.remainingRotations = metricReceiver.declareGauge(REMAINING_ROTATIONS_METRIC_NAME);
+ }
+
+ private static Map<RotationId, Rotation> buildRotationsMap(RotationsConfig rotationConfig) {
+ return rotationConfig.rotations().entrySet().stream()
+ .map(entry -> {
+ RotationId rotationId = new RotationId(entry.getKey());
+ return new Rotation(rotationId, entry.getValue().trim());
+ })
+ .collect(Collectors.toMap(
+ rotation -> rotation.rotationId,
+ rotation -> rotation
+ ));
+ }
+
+ @Override
+ @NotNull
+ public Set<Rotation> getOrAssignRotation(ApplicationId applicationId, DeploymentSpec deploymentSpec) {
+ reportRemainingRotations();
+
+ Set<RotationId> rotations = controllerDb.getRotations(applicationId);
+
+ if (rotations.size() > 1) {
+ log.warning(String.format("Application %s has %d > 1 rotation", applicationId, rotations.size()));
+ }
+
+ if (!rotations.isEmpty()) {
+ return rotations.stream()
+ .map(rotationsMap::get)
+ .collect(Collectors.toSet());
+ }
+
+ if( ! deploymentSpec.globalServiceId().isPresent()) {
+ return Collections.emptySet();
+ }
+
+ long productionZoneCount = deploymentSpec.zones().stream()
+ .filter(zone -> zone.deploysTo(Environment.prod))
+ .filter(zone -> ! isCorp(zone)) // Global rotations don't work for nodes in corp network
+ .count();
+
+ if (productionZoneCount >= 2) {
+ return assignRotation(applicationId);
+ }
+ else {
+ throw new IllegalArgumentException("global-service-id is set but less than 2 prod zones are defined");
+ }
+ }
+
+ private boolean isCorp(DeploymentSpec.DeclaredZone zone) {
+ return zone.region().isPresent() && zone.region().get().value().contains("corp");
+ }
+
+ @Override
+ @NotNull
+ public Set<URI> getRotationUris(ApplicationId applicationId) {
+ Set<RotationId> rotations = controllerDb.getRotations(applicationId);
+ if (rotations.isEmpty()) {
+ return Collections.emptySet();
+ }
+ else {
+ ApplicationAlias applicationAlias = new ApplicationAlias(applicationId);
+ return Collections.singleton(applicationAlias.toHttpUri());
+ }
+ }
+
+ private Set<Rotation> assignRotation(ApplicationId applicationId) {
+ Set<RotationId> availableRotations = availableRotations();
+ if (availableRotations.isEmpty()) {
+ String message = "Unable to assign global rotation to "
+ + applicationId + " - no rotations available";
+ log.info(message);
+ throw new RuntimeException(message);
+ }
+
+ for (RotationId rotationId : availableRotations) {
+ if (controllerDb.assignRotation(rotationId, applicationId)) {
+ log.info(String.format("Assigned rotation %s to application %s", rotationId, applicationId));
+ Rotation rotation = this.rotationsMap.get(rotationId);
+ return Collections.singleton(rotation);
+ }
+ }
+
+ log.info(String.format("Rotation: No rotations assigned with %s rotations available", availableRotations.size()));
+ return Collections.emptySet();
+ }
+
+ private Set<RotationId> availableRotations() {
+ Set<RotationId> assignedRotations = controllerDb.getRotations();
+ Set<RotationId> allRotations = new HashSet<>(rotationsMap.keySet());
+ allRotations.removeAll(assignedRotations);
+ return allRotations;
+ }
+
+ private void reportRemainingRotations() {
+ try {
+ int freeRotationsCount = availableRotations().size();
+ log.log(LogLevel.INFO, "Rotation: {0} global rotations remaining", freeRotationsCount);
+ remainingRotations.sample(freeRotationsCount);
+ } catch (Exception e) {
+ log.log(LogLevel.INFO, "Failed to report rotations metric", e);
+ }
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/rotation/MemoryRotationRepository.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/rotation/MemoryRotationRepository.java
new file mode 100644
index 00000000000..4e333f0268b
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/rotation/MemoryRotationRepository.java
@@ -0,0 +1,54 @@
+// 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.rotation;
+
+import com.google.common.collect.ImmutableSet;
+import com.yahoo.config.application.api.DeploymentSpec;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.RotationId;
+import com.yahoo.vespa.hosted.controller.api.rotation.Rotation;
+import org.jetbrains.annotations.NotNull;
+
+import java.net.URI;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/**
+ * A rotation repository backed by in-memory data structures
+ *
+ * @author bratseth
+ */
+public class MemoryRotationRepository implements RotationRepository {
+
+ private final Map<ApplicationId, Set<Rotation>> rotations = new HashMap<>();
+
+ @NotNull
+ @Override
+ public Set<Rotation> getOrAssignRotation(ApplicationId application, DeploymentSpec deploymentSpec) {
+ if (rotations.containsKey(application)) {
+ return rotations.get(application);
+ }
+ Set<Rotation> rotations = ImmutableSet.of(new Rotation(
+ new RotationId("generated-by-routing-service-" + UUID.randomUUID().toString()),
+ "fake-global-rotation-" + application.toShortString())
+ );
+ this.rotations.put(application, rotations);
+ return rotations;
+ }
+
+ @NotNull
+ @Override
+ public Set<URI> getRotationUris(ApplicationId applicationId) {
+ Set<Rotation> rotations = this.rotations.get(applicationId);
+ if (rotations == null) {
+ return Collections.emptySet();
+ }
+ return rotations.stream()
+ .map(rotation -> URI.create("http://" + rotation.rotationName))
+ .collect(Collectors.toSet());
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/rotation/RotationRepository.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/rotation/RotationRepository.java
new file mode 100644
index 00000000000..b1f7b33e58e
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/rotation/RotationRepository.java
@@ -0,0 +1,48 @@
+// 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.rotation;
+
+import com.yahoo.config.application.api.DeploymentSpec;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.vespa.hosted.controller.api.rotation.Rotation;
+import org.jetbrains.annotations.NotNull;
+
+import java.net.URI;
+import java.util.Set;
+
+/**
+ * A rotation repository assigns global rotations to Vespa applications. It does not take into account
+ * whether an application qualifies or not, but it assumes that each application should get only
+ * one.
+ *
+ * The list of rotations comes from the RotationsConfig, set in the controller's services.xml.
+ * Assignments are persisted with the RotationId as the primary key. When we assign the
+ * rotation to an application we try to put the mapping RotationId -&gt; Application. If a
+ * mapping already exists for that RotationId, the assignment will fail.
+ *
+ * @author Oyvind Gronnesby
+ */
+public interface RotationRepository {
+
+ // TODO: Change to use provision.ApplicationId
+ // TODO: Move the persistence into ControllerDb (done), and then collapse the 2 implementations and the interface into one
+
+ /**
+ * If any rotations are assigned to the application, these will be returned.
+ * If no rotations are assigned, assign one rotation to the application and return that.
+ *
+ * @param applicationId ID of the application to get or assign rotation for
+ * @param deploymentSpec Spec of current application being deployed
+ * @return Set of rotations assigned (may be empty)
+ */
+ @NotNull
+ Set<Rotation> getOrAssignRotation(ApplicationId applicationId, DeploymentSpec deploymentSpec);
+
+ /**
+ * Get the external visible rotation URIs for this application.
+ *
+ * @param applicationId ID of the application to get or assign rotation for
+ */
+ @NotNull
+ Set<URI> getRotationUris(ApplicationId applicationId);
+
+}