diff options
Diffstat (limited to 'controller-server/src/main/java/com/yahoo')
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[&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 -> 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); + +} |