diff --git a/README.md b/README.md index c99efbe16..2c28f8c82 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,12 @@ Source code for all the REST resources can be found from package com.spotify.rea * Adds a new cluster to the service, and returns the newly added cluster object, if the operation was successful. +* DELETE /cluster/{cluster_name} + * Expected query parameters: *None* + * Delete a cluster object identified by the given "cluster_name" path parameter. + Cluster will get deleted only if there are no schedules or repair runs for the cluster, + or the request will fail. Delete repair runs and schedules first before calling this. + ## Repair Run Resource * GET /repair_run @@ -193,6 +199,13 @@ Source code for all the REST resources can be found from package com.spotify.rea Possible values for given state are: "PAUSED" or "RUNNING". * Starts, pauses, or resumes a repair run identified by the "id" path parameter. +* DELETE /repair_run/{id} + * Expected query parameters: + * *owner*: Owner name for the run. If the given owner does not match the stored owner, + the delete request will fail. + * Delete a repair run object identified by the given "id" path parameter. + Repair run and all the related repair segments will be deleted from the database. + ## Repair Schedule Resource * GET /repair_schedule @@ -218,3 +231,11 @@ Source code for all the REST resources can be found from package com.spotify.rea * *scheduleTriggerTime*: Defines the time for first scheduled trigger for the run. If you don't give this value, it will be next mid-night (UTC). Give date values in ISO format, e.g. "2015-02-11T01:00:00". (Optional) + +* DELETE /repair_schedule/{id} + * Expected query parameters: + * *owner*: Owner name for the schedule. If the given owner does not match the stored owner, + the delete request will fail. + * Delete a repair schedule object identified by the given "id" path parameter. + Repair schedule will get deleted only if there are no associated repair runs for the schedule. + Delete all the related repair runs before calling this endpoint. diff --git a/pom.xml b/pom.xml index 76c5ea5aa..b4ae9b949 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,10 @@ org.slf4j slf4j-log4j12 + + org.codehaus.jackson + * + diff --git a/src/main/java/com/spotify/reaper/core/RepairRun.java b/src/main/java/com/spotify/reaper/core/RepairRun.java index e4f19d8fe..5c3d6c3e5 100644 --- a/src/main/java/com/spotify/reaper/core/RepairRun.java +++ b/src/main/java/com/spotify/reaper/core/RepairRun.java @@ -120,7 +120,8 @@ public enum RunState { RUNNING, ERROR, DONE, - PAUSED + PAUSED, + DELETED } public static class Builder { diff --git a/src/main/java/com/spotify/reaper/core/RepairSchedule.java b/src/main/java/com/spotify/reaper/core/RepairSchedule.java index de8abb730..4f4293934 100644 --- a/src/main/java/com/spotify/reaper/core/RepairSchedule.java +++ b/src/main/java/com/spotify/reaper/core/RepairSchedule.java @@ -116,11 +116,11 @@ public Builder with() { } public enum State { - RUNNING, - PAUSED + ACTIVE, + PAUSED, + DELETED } - public static class Builder { public final long repairUnitId; diff --git a/src/main/java/com/spotify/reaper/resources/ClusterResource.java b/src/main/java/com/spotify/reaper/resources/ClusterResource.java index f4a3e331a..8301b5b5e 100644 --- a/src/main/java/com/spotify/reaper/resources/ClusterResource.java +++ b/src/main/java/com/spotify/reaper/resources/ClusterResource.java @@ -34,6 +34,7 @@ import java.util.Collections; import java.util.List; +import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; @@ -195,4 +196,40 @@ private Response viewKeyspace(Cluster cluster, String keyspaceName) { return Response.ok().entity(view).build(); } + /** + * Delete a Cluster object with given name. + * + * Cluster can be only deleted when it hasn't any RepairRun or RepairSchedule instances under it, + * i.e. you must delete all repair runs and schedules first. + * + * @param clusterName The name of the Cluster instance you are about to delete. + * @return The deleted RepairRun instance, with state overwritten to string "DELETED". + */ + @DELETE + @Path("/{cluster_name}") + public Response deleteCluster(@PathParam("cluster_name") String clusterName) { + LOG.info("delete cluster called with clusterName: {}", clusterName); + Optional clusterToDelete = context.storage.getCluster(clusterName); + if (!clusterToDelete.isPresent()) { + return Response.status(Response.Status.NOT_FOUND).entity( + "cluster with name \"" + clusterName + "\" not found").build(); + } + if (!context.storage.getRepairSchedulesForCluster(clusterName).isEmpty()) { + return Response.status(Response.Status.FORBIDDEN).entity( + "cluster with name \"" + clusterName + "\" cannot be deleted, as it " + + "has repair schedules").build(); + } + if (!context.storage.getRepairRunsForCluster(clusterName).isEmpty()) { + return Response.status(Response.Status.FORBIDDEN).entity( + "cluster with name \"" + clusterName + "\" cannot be deleted, as it " + + "has repair runs").build(); + } + Optional deletedCluster = context.storage.deleteCluster(clusterName); + if (deletedCluster.isPresent()) { + return Response.ok().entity(new ClusterStatus(deletedCluster.get())).build(); + } + return Response.serverError().entity("delete failed for schedule with name \"" + + clusterName + "\"").build(); + } + } diff --git a/src/main/java/com/spotify/reaper/resources/CommonTools.java b/src/main/java/com/spotify/reaper/resources/CommonTools.java index 5a515621c..a1ac37d12 100644 --- a/src/main/java/com/spotify/reaper/resources/CommonTools.java +++ b/src/main/java/com/spotify/reaper/resources/CommonTools.java @@ -85,6 +85,8 @@ private static List generateSegments(AppContext context, Cluster targ int segmentCount) throws ReaperException { List segments = null; + assert targetCluster.getPartitioner() != null : + "no partitioner for cluster: " + targetCluster.getName(); SegmentGenerator sg = new SegmentGenerator(targetCluster.getPartitioner()); Set seedHosts = targetCluster.getSeedHosts(); if (seedHosts.isEmpty()) { @@ -179,8 +181,9 @@ public static RepairSchedule storeNewRepairSchedule( Double intensity) throws ReaperException { RepairSchedule.Builder scheduleBuilder = - new RepairSchedule.Builder(repairUnit.getId(), RepairSchedule.State.RUNNING, daysBetween, - nextActivation, ImmutableList.of(), segments, repairParallelism, intensity, + new RepairSchedule.Builder(repairUnit.getId(), RepairSchedule.State.ACTIVE, daysBetween, + nextActivation, ImmutableList.of(), segments, + repairParallelism, intensity, DateTime.now()); scheduleBuilder.owner(owner); RepairSchedule newRepairSchedule = context.storage.addRepairSchedule(scheduleBuilder); @@ -243,6 +246,9 @@ public static RepairUnit getNewOrExistingRepairUnit(AppContext context, Cluster } public static String dateTimeToISO8601(DateTime dateTime) { + if (null == dateTime) { + return null; + } return ISODateTimeFormat.dateTimeNoMillis().print(dateTime); } diff --git a/src/main/java/com/spotify/reaper/resources/RepairRunResource.java b/src/main/java/com/spotify/reaper/resources/RepairRunResource.java index 2ce2bffc2..261f20024 100644 --- a/src/main/java/com/spotify/reaper/resources/RepairRunResource.java +++ b/src/main/java/com/spotify/reaper/resources/RepairRunResource.java @@ -41,6 +41,7 @@ import java.util.Set; import javax.annotation.Nullable; +import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.PUT; @@ -243,7 +244,13 @@ public Response modifyRunState( return Response.status(Response.Status.NOT_FOUND).entity(errMsg).build(); } - RepairRun.RunState newState = RepairRun.RunState.valueOf(state.get()); + RepairRun.RunState newState; + try { + newState = RepairRun.RunState.valueOf(state.get().toUpperCase()); + } catch (IllegalArgumentException ex) { + return Response.status(Response.Status.BAD_REQUEST.getStatusCode()) + .entity("invalid \"state\" argument: " + state.get()).build(); + } RepairRun.RunState oldState = repairRun.get().getRunState(); if (oldState == newState) { @@ -401,7 +408,7 @@ public Set splitStateParam(Optional state) { Iterable chunks = CommonTools.COMMA_SEPARATED_LIST_SPLITTER.split(state.get()); for (String chunk : chunks) { try { - RepairRun.RunState.valueOf(chunk); + RepairRun.RunState.valueOf(chunk.toUpperCase()); } catch (IllegalArgumentException e) { LOG.warn("Listing repair runs called with erroneous states: {}", state.get()); return null; @@ -413,4 +420,56 @@ public Set splitStateParam(Optional state) { } } + /** + * Delete a RepairRun object with given id. + * + * Repair run can be only deleted when it is not running. + * When Repair run is deleted, all the related RepairSegment instances will be deleted also. + * + * @param runId The id for the RepairRun instance to delete. + * @param owner The assigned owner of the deleted resource. Must match the stored one. + * @return The deleted RepairRun instance, with state overwritten to string "DELETED". + */ + @DELETE + @Path("/{id}") + public Response deleteRepairRun(@PathParam("id") Long runId, + @QueryParam("owner") Optional owner) { + LOG.info("delete repair run called with runId: {}, and owner: {}", runId, owner); + if (!owner.isPresent()) { + return Response.status(Response.Status.BAD_REQUEST).entity( + "required query parameter \"owner\" is missing").build(); + } + Optional runToDelete = context.storage.getRepairRun(runId); + if (!runToDelete.isPresent()) { + return Response.status(Response.Status.NOT_FOUND).entity( + "Repair run with id \"" + runId + "\" not found").build(); + } + if (runToDelete.get().getRunState() == RepairRun.RunState.RUNNING) { + return Response.status(Response.Status.FORBIDDEN).entity( + "Repair run with id \"" + runId + + "\" is currently running, and must be stopped before deleting").build(); + } + if (!runToDelete.get().getOwner().equalsIgnoreCase(owner.get())) { + return Response.status(Response.Status.FORBIDDEN).entity( + "Repair run with id \"" + runId + "\" is not owned by the user you defined: " + + owner.get()).build(); + } + if (context.storage.getSegmentAmountForRepairRun(runId, RepairSegment.State.RUNNING) > 0) { + return Response.status(Response.Status.FORBIDDEN).entity( + "Repair run with id \"" + runId + + "\" has a running segment, which must be waited to finish before deleting").build(); + } + // Need to get the RepairUnit before it's possibly deleted. + Optional unitPossiblyDeleted = + context.storage.getRepairUnit(runToDelete.get().getRepairUnitId()); + Optional deletedRun = context.storage.deleteRepairRun(runId); + if (deletedRun.isPresent()) { + RepairRunStatus repairRunStatus = new RepairRunStatus(deletedRun.get(), + unitPossiblyDeleted.get()); + return Response.ok().entity(repairRunStatus).build(); + } + return Response.serverError().entity("delete failed for repair run with id \"" + + runId + "\"").build(); + } + } diff --git a/src/main/java/com/spotify/reaper/resources/RepairScheduleResource.java b/src/main/java/com/spotify/reaper/resources/RepairScheduleResource.java index 2719af13d..c56dd59da 100644 --- a/src/main/java/com/spotify/reaper/resources/RepairScheduleResource.java +++ b/src/main/java/com/spotify/reaper/resources/RepairScheduleResource.java @@ -14,8 +14,8 @@ package com.spotify.reaper.resources; import com.google.common.base.Optional; - import com.google.common.collect.Lists; + import com.spotify.reaper.AppContext; import com.spotify.reaper.ReaperException; import com.spotify.reaper.core.Cluster; @@ -38,6 +38,7 @@ import java.util.List; import java.util.Set; +import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.PUT; @@ -170,11 +171,11 @@ public Response addRepairSchedule( } /** - * Modifies a state of the repair schedule.

Currently supports PAUSED -> RUNNING and - * RUNNING -> PAUSED. + * Modifies a state of the repair schedule.

Currently supports PAUSED -> ACTIVE and + * ACTIVE -> PAUSED. * - * @return OK if all goes well NOT_MODIFIED if new state is the same as the old one, and 501 - * (NOT_IMPLEMENTED) if transition is not supported. + * @return OK if all goes well NOT_MODIFIED if new state is the same as the old one, and 400 + * (BAD_REQUEST) if transition is not supported. */ @PUT @Path("/{id}") @@ -184,7 +185,7 @@ public Response modifyState( @QueryParam("state") Optional state) { LOG.info("modify repair schedule state called with: id = {}, state = {}", - repairScheduleId, state); + repairScheduleId, state); if (!state.isPresent()) { return Response.status(Response.Status.BAD_REQUEST.getStatusCode()) @@ -194,7 +195,7 @@ public Response modifyState( Optional repairSchedule = context.storage.getRepairSchedule(repairScheduleId); if (!repairSchedule.isPresent()) { return Response.status(Response.Status.NOT_FOUND).entity("repair schedule with id " - + repairScheduleId + " not found") + + repairScheduleId + " not found") .build(); } @@ -207,7 +208,13 @@ public Response modifyState( return Response.status(Response.Status.NOT_FOUND).entity(errMsg).build(); } - RepairSchedule.State newState = RepairSchedule.State.valueOf(state.get()); + RepairSchedule.State newState; + try { + newState = RepairSchedule.State.valueOf(state.get().toUpperCase()); + } catch (IllegalArgumentException ex) { + return Response.status(Response.Status.BAD_REQUEST.getStatusCode()) + .entity("invalid \"state\" argument: " + state.get()).build(); + } RepairSchedule.State oldState = repairSchedule.get().getState(); if (oldState == newState) { @@ -220,18 +227,18 @@ public Response modifyState( return resumeSchedule(repairSchedule.get(), repairUnit.get()); } else { String errMsg = String.format("Transition %s->%s not supported.", oldState.toString(), - newState.toString()); + newState.toString()); LOG.error(errMsg); return Response.status(Response.Status.BAD_REQUEST).entity(errMsg).build(); } } private static boolean isPausing(RepairSchedule.State oldState, RepairSchedule.State newState) { - return oldState == RepairSchedule.State.RUNNING && newState == RepairSchedule.State.PAUSED; + return oldState == RepairSchedule.State.ACTIVE && newState == RepairSchedule.State.PAUSED; } private static boolean isResuming(RepairSchedule.State oldState, RepairSchedule.State newState) { - return oldState == RepairSchedule.State.PAUSED && newState == RepairSchedule.State.RUNNING; + return oldState == RepairSchedule.State.PAUSED && newState == RepairSchedule.State.ACTIVE; } private Response pauseSchedule(RepairSchedule repairSchedule, RepairUnit repairUnit) { @@ -328,4 +335,47 @@ public Response listSchedules() { return Response.status(Response.Status.OK).entity(scheduleStatuses).build(); } + /** + * Delete a RepairSchedule object with given id. + * + * Repair schedule can only be deleted when it is not active, so you must stop it first. + * + * @param repairScheduleId The id for the RepairSchedule instance to delete. + * @param owner The assigned owner of the deleted resource. Must match the stored one. + * @return The deleted RepairSchedule instance, with state overwritten to string "DELETED". + */ + @DELETE + @Path("/{id}") + public Response deleteRepairSchedule(@PathParam("id") Long repairScheduleId, + @QueryParam("owner") Optional owner) { + LOG.info("delete repair schedule called with repairScheduleId: {}, and owner: {}", + repairScheduleId, owner); + if (!owner.isPresent()) { + return Response.status(Response.Status.BAD_REQUEST).entity( + "required query parameter \"owner\" is missing").build(); + } + Optional scheduleToDelete = context.storage.getRepairSchedule(repairScheduleId); + if (!scheduleToDelete.isPresent()) { + return Response.status(Response.Status.NOT_FOUND).entity( + "Repair schedule with id \"" + repairScheduleId + "\" not found").build(); + } + if (scheduleToDelete.get().getState() == RepairSchedule.State.ACTIVE) { + return Response.status(Response.Status.FORBIDDEN).entity( + "Repair schedule with id \"" + repairScheduleId + + "\" is currently running, and must be stopped before deleting").build(); + } + if (!scheduleToDelete.get().getOwner().equalsIgnoreCase(owner.get())) { + return Response.status(Response.Status.FORBIDDEN).entity( + "Repair schedule with id \"" + repairScheduleId + + "\" is not owned by the user you defined: " + owner.get()).build(); + } + Optional deletedSchedule = + context.storage.deleteRepairSchedule(repairScheduleId); + if (deletedSchedule.isPresent()) { + return Response.ok().entity(getRepairScheduleStatus(deletedSchedule.get())).build(); + } + return Response.serverError().entity("delete failed for schedule with id \"" + + repairScheduleId + "\"").build(); + } + } diff --git a/src/main/java/com/spotify/reaper/resources/view/RepairRunStatus.java b/src/main/java/com/spotify/reaper/resources/view/RepairRunStatus.java index 9565abc57..a880283bc 100644 --- a/src/main/java/com/spotify/reaper/resources/view/RepairRunStatus.java +++ b/src/main/java/com/spotify/reaper/resources/view/RepairRunStatus.java @@ -20,6 +20,7 @@ import com.spotify.reaper.resources.CommonTools; import org.joda.time.DateTime; +import org.joda.time.format.ISODateTimeFormat; import java.util.Collection; @@ -29,52 +30,58 @@ public class RepairRunStatus { @JsonProperty - private final String cause; + private String cause; @JsonProperty - private final String owner; + private String owner; @JsonProperty - private final long id; + private long id; @JsonProperty("cluster_name") - private final String clusterName; + private String clusterName; @JsonProperty("column_families") - private final Collection columnFamilies; + private Collection columnFamilies; @JsonProperty("keyspace_name") - private final String keyspaceName; + private String keyspaceName; @JsonProperty("run_state") - private final String runState; + private String runState; @JsonIgnore - private final DateTime creationTime; + private DateTime creationTime; @JsonIgnore - private final DateTime startTime; + private DateTime startTime; @JsonIgnore - private final DateTime endTime; + private DateTime endTime; @JsonIgnore - private final DateTime pauseTime; + private DateTime pauseTime; @JsonProperty - private final double intensity; + private double intensity; @JsonProperty("segment_count") - private final int segmentCount; + private int segmentCount; @JsonProperty("repair_parallelism") - private final String repairParallelism; + private String repairParallelism; @JsonProperty("segments_repaired") private int segmentsRepaired = 0; @JsonProperty("last_event") - private final String lastEvent; + private String lastEvent; + + /** + * Default public constructor Required for Jackson JSON parsing. + */ + public RepairRunStatus() { + } public RepairRunStatus(RepairRun repairRun, RepairUnit repairUnit) { this.id = repairRun.getId(); @@ -102,6 +109,13 @@ public String getCreationTimeISO8601() { return CommonTools.dateTimeToISO8601(creationTime); } + @JsonProperty("creation_time") + public void setCreationTimeISO8601(String dateStr) { + if (null != dateStr) { + creationTime = ISODateTimeFormat.dateTimeNoMillis().parseDateTime(dateStr); + } + } + @JsonProperty("start_time") public String getStartTimeISO8601() { if (startTime == null) { @@ -110,6 +124,13 @@ public String getStartTimeISO8601() { return CommonTools.dateTimeToISO8601(startTime); } + @JsonProperty("start_time") + public void setStartTimeISO8601(String dateStr) { + if (null != dateStr) { + startTime = ISODateTimeFormat.dateTimeNoMillis().parseDateTime(dateStr); + } + } + @JsonProperty("end_time") public String getEndTimeISO8601() { if (endTime == null) { @@ -118,6 +139,13 @@ public String getEndTimeISO8601() { return CommonTools.dateTimeToISO8601(endTime); } + @JsonProperty("end_time") + public void setEndTimeISO8601(String dateStr) { + if (null != dateStr) { + endTime = ISODateTimeFormat.dateTimeNoMillis().parseDateTime(dateStr); + } + } + @JsonProperty("pause_time") public String getPauseTimeISO8601() { if (pauseTime == null) { @@ -126,15 +154,138 @@ public String getPauseTimeISO8601() { return CommonTools.dateTimeToISO8601(pauseTime); } - public void setSegmentsRepaired(int segmentsRepaired) { - this.segmentsRepaired = segmentsRepaired; + @JsonProperty("pause_time") + public void setPauseTimeISO8601(String dateStr) { + if (null != dateStr) { + pauseTime = ISODateTimeFormat.dateTimeNoMillis().parseDateTime(dateStr); + } + } + + public String getCause() { + return cause; + } + + public void setCause(String cause) { + this.cause = cause; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; } public long getId() { - return this.id; + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getClusterName() { + return clusterName; + } + + public void setClusterName(String clusterName) { + this.clusterName = clusterName; + } + + public Collection getColumnFamilies() { + return columnFamilies; + } + + public void setColumnFamilies(Collection columnFamilies) { + this.columnFamilies = columnFamilies; + } + + public String getKeyspaceName() { + return keyspaceName; + } + + public void setKeyspaceName(String keyspaceName) { + this.keyspaceName = keyspaceName; } public String getRunState() { - return this.runState; + return runState; + } + + public void setRunState(String runState) { + this.runState = runState; + } + + public DateTime getCreationTime() { + return creationTime; + } + + public void setCreationTime(DateTime creationTime) { + this.creationTime = creationTime; + } + + public DateTime getStartTime() { + return startTime; + } + + public void setStartTime(DateTime startTime) { + this.startTime = startTime; + } + + public DateTime getEndTime() { + return endTime; + } + + public void setEndTime(DateTime endTime) { + this.endTime = endTime; + } + + public DateTime getPauseTime() { + return pauseTime; + } + + public void setPauseTime(DateTime pauseTime) { + this.pauseTime = pauseTime; + } + + public double getIntensity() { + return intensity; + } + + public void setIntensity(double intensity) { + this.intensity = intensity; + } + + public int getSegmentCount() { + return segmentCount; + } + + public void setSegmentCount(int segmentCount) { + this.segmentCount = segmentCount; + } + + public String getRepairParallelism() { + return repairParallelism; + } + + public void setRepairParallelism(String repairParallelism) { + this.repairParallelism = repairParallelism; + } + + public int getSegmentsRepaired() { + return segmentsRepaired; + } + + public void setSegmentsRepaired(int segmentsRepaired) { + this.segmentsRepaired = segmentsRepaired; + } + + public String getLastEvent() { + return lastEvent; + } + + public void setLastEvent(String lastEvent) { + this.lastEvent = lastEvent; } } diff --git a/src/main/java/com/spotify/reaper/resources/view/RepairScheduleStatus.java b/src/main/java/com/spotify/reaper/resources/view/RepairScheduleStatus.java index 91e11d5cb..3705ec020 100644 --- a/src/main/java/com/spotify/reaper/resources/view/RepairScheduleStatus.java +++ b/src/main/java/com/spotify/reaper/resources/view/RepairScheduleStatus.java @@ -13,8 +13,6 @@ */ package com.spotify.reaper.resources.view; -import com.google.common.annotations.VisibleForTesting; - import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.spotify.reaper.core.RepairSchedule; @@ -22,49 +20,56 @@ import com.spotify.reaper.resources.CommonTools; import org.joda.time.DateTime; +import org.joda.time.format.ISODateTimeFormat; import java.util.Collection; public class RepairScheduleStatus { - @JsonProperty - private final long id; + @JsonProperty() + private long id; - @JsonProperty - private final String owner; + @JsonProperty() + private String owner; @JsonProperty("cluster_name") - private final String clusterName; + private String clusterName; @JsonProperty("column_families") - private final Collection columnFamilies; + private Collection columnFamilies; @JsonProperty("keyspace_name") - private final String keyspaceName; + private String keyspaceName; - @JsonProperty("state") - private final String state; + @JsonProperty() + private String state; @JsonIgnore - private final DateTime creationTime; + private DateTime creationTime; @JsonIgnore - private final DateTime nextActivation; + private DateTime nextActivation; @JsonIgnore - private final DateTime pauseTime; + private DateTime pauseTime; - @JsonProperty - private final double intensity; + @JsonProperty() + private double intensity; @JsonProperty("segment_count") - private final int segmentCount; + private int segmentCount; @JsonProperty("repair_parallelism") - private final String repairParallelism; + private String repairParallelism; + + @JsonProperty("scheduled_days_between") + private int daysBetween; - @JsonProperty("schedule_days_between") - private final int daysBetween; + /** + * Default public constructor Required for Jackson JSON parsing. + */ + public RepairScheduleStatus() { + } public RepairScheduleStatus(RepairSchedule repairSchedule, RepairUnit repairUnit) { this.id = repairSchedule.getId(); @@ -82,36 +87,144 @@ public RepairScheduleStatus(RepairSchedule repairSchedule, RepairUnit repairUnit this.daysBetween = repairSchedule.getDaysBetween(); } + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getClusterName() { + return clusterName; + } + + public void setClusterName(String clusterName) { + this.clusterName = clusterName; + } + + public Collection getColumnFamilies() { + return columnFamilies; + } + + public void setColumnFamilies(Collection columnFamilies) { + this.columnFamilies = columnFamilies; + } + + public String getKeyspaceName() { + return keyspaceName; + } + + public void setKeyspaceName(String keyspaceName) { + this.keyspaceName = keyspaceName; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public DateTime getCreationTime() { + return creationTime; + } + + public void setCreationTime(DateTime creationTime) { + this.creationTime = creationTime; + } + + public DateTime getNextActivation() { + return nextActivation; + } + + public void setNextActivation(DateTime nextActivation) { + this.nextActivation = nextActivation; + } + + public DateTime getPauseTime() { + return pauseTime; + } + + public void setPauseTime(DateTime pauseTime) { + this.pauseTime = pauseTime; + } + + public double getIntensity() { + return intensity; + } + + public void setIntensity(double intensity) { + this.intensity = intensity; + } + + public int getSegmentCount() { + return segmentCount; + } + + public void setSegmentCount(int segmentCount) { + this.segmentCount = segmentCount; + } + + public String getRepairParallelism() { + return repairParallelism; + } + + public void setRepairParallelism(String repairParallelism) { + this.repairParallelism = repairParallelism; + } + + public int getDaysBetween() { + return daysBetween; + } + + public void setDaysBetween(int daysBetween) { + this.daysBetween = daysBetween; + } + @JsonProperty("creation_time") public String getCreationTimeISO8601() { - if (creationTime == null) { - return null; - } return CommonTools.dateTimeToISO8601(creationTime); } + @JsonProperty("creation_time") + public void setCreationTimeISO8601(String dateStr) { + if (null != dateStr) { + creationTime = ISODateTimeFormat.dateTimeNoMillis().parseDateTime(dateStr); + } + } + @JsonProperty("next_activation") public String getNextActivationISO8601() { - if (nextActivation == null) { - return null; - } return CommonTools.dateTimeToISO8601(nextActivation); } - @JsonProperty("pause_time") - public String getPauseTimeISO8601() { - if (pauseTime == null) { - return null; + @JsonProperty("next_activation") + public void setNextActivationISO8601(String dateStr) { + if (null != dateStr) { + nextActivation = ISODateTimeFormat.dateTimeNoMillis().parseDateTime(dateStr); } - return CommonTools.dateTimeToISO8601(pauseTime); } - public long getId() { - return this.id; + @JsonProperty("pause_time") + public String getPauseTimeISO8601() { + return CommonTools.dateTimeToISO8601(pauseTime); } - public String getState() { - return this.state; + @JsonProperty("pause_time") + public void setPauseTimeISO8601(String dateStr) { + if (null != dateStr) { + pauseTime = ISODateTimeFormat.dateTimeNoMillis().parseDateTime(dateStr); + } } } diff --git a/src/main/java/com/spotify/reaper/service/RepairRunner.java b/src/main/java/com/spotify/reaper/service/RepairRunner.java index 042248949..c61b0b9f1 100644 --- a/src/main/java/com/spotify/reaper/service/RepairRunner.java +++ b/src/main/java/com/spotify/reaper/service/RepairRunner.java @@ -65,9 +65,16 @@ public Long getCurrentlyRunningSegmentId() { */ @Override public void run() { - RepairRun repairRun = context.storage.getRepairRun(repairRunId).get(); + Optional repairRun = context.storage.getRepairRun(repairRunId); try { - RepairRun.RunState state = repairRun.getRunState(); + if (!repairRun.isPresent()) { + // this might happen if a run is deleted while paused etc. + LOG.warn("RepairRun \"" + repairRunId + "\" does not exist. Killing " + + "RepairRunner for this run instance."); + context.repairManager.removeRunner(this); + return; + } + RepairRun.RunState state = repairRun.get().getRunState(); LOG.debug("run() called for repair run #{} with run state {}", repairRunId, state); switch (state) { case NOT_STARTED: @@ -89,10 +96,12 @@ public void run() { LOG.error(e.toString()); LOG.error(Arrays.toString(e.getStackTrace())); e.printStackTrace(); - context.storage.updateRepairRun(repairRun.with() - .runState(RepairRun.RunState.ERROR) - .endTime(DateTime.now()) - .build(repairRun.getId())); + if (repairRun.isPresent()) { + context.storage.updateRepairRun(repairRun.get().with() + .runState(RepairRun.RunState.ERROR) + .endTime(DateTime.now()) + .build(repairRunId)); + } context.repairManager.removeRunner(this); } } diff --git a/src/main/java/com/spotify/reaper/service/SchedulingManager.java b/src/main/java/com/spotify/reaper/service/SchedulingManager.java index 7ddcad575..099e0f01a 100644 --- a/src/main/java/com/spotify/reaper/service/SchedulingManager.java +++ b/src/main/java/com/spotify/reaper/service/SchedulingManager.java @@ -19,8 +19,6 @@ import java.util.Timer; import java.util.TimerTask; -import javax.annotation.Nullable; - public class SchedulingManager extends TimerTask { private static final Logger LOG = LoggerFactory.getLogger(SchedulingManager.class); @@ -34,7 +32,7 @@ public static void start(AppContext context) { Timer timer = new Timer("SchedulingManagerTimer"); timer.schedule(schedulingManager, 1000, 1000 * 60); // activate once per minute } else { - LOG.warn("there is already one instance of SchedulingManager running, not starting new"); + LOG.warn("there is already one instance of SchedulingManager running, not starting new one"); } } @@ -44,24 +42,23 @@ public static void pauseRepairSchedule(AppContext context, RepairSchedule schedu .pauseTime(DateTime.now()) .build(schedule.getId()); if (!context.storage.updateRepairSchedule(updatedSchedule)) { - throw new RuntimeException("failed updating repair schedule " + updatedSchedule.getId()); + LOG.error("failed updating repair schedule " + updatedSchedule.getId()); } } public static void resumeRepairSchedule(AppContext context, RepairSchedule schedule) { RepairSchedule updatedSchedule = schedule.with() - .state(RepairSchedule.State.RUNNING) + .state(RepairSchedule.State.ACTIVE) .pauseTime(null) .build(schedule.getId()); if (!context.storage.updateRepairSchedule(updatedSchedule)) { - throw new RuntimeException("failed updating repair schedule " + updatedSchedule.getId()); + LOG.error("failed updating repair schedule " + updatedSchedule.getId()); } } private AppContext context; /* nextActivatedSchedule used for nicer logging only */ - @Nullable private RepairSchedule nextActivatedSchedule; private SchedulingManager(AppContext context) { @@ -80,10 +77,7 @@ public void run() { boolean anyRunStarted = false; for (RepairSchedule schedule : schedules) { lastId = schedule.getId(); - boolean runStarted = manageSchedule(schedule); - if (runStarted) { - anyRunStarted = true; - } + anyRunStarted = manageSchedule(schedule) || anyRunStarted; } if (!anyRunStarted && nextActivatedSchedule != null) { LOG.debug("not scheduling new repairs yet, next activation is '{}' for schedule id '{}'", @@ -113,7 +107,12 @@ private boolean manageSchedule(RepairSchedule schedule) throws ReaperException { LOG.info("Repair schedule '{}' is paused", schedule.getId()); startNewRun = false; } else { - repairUnit = context.storage.getRepairUnit(schedule.getRepairUnitId()).get(); + Optional fetchedUnit = context.storage.getRepairUnit(schedule.getRepairUnitId()); + if (!fetchedUnit.isPresent()) { + LOG.warn("RepairUnit with id " + schedule.getRepairUnitId() + " not found"); + return false; + } + repairUnit = fetchedUnit.get(); Collection repairRuns = context.storage.getRepairRunsForUnit(repairUnit); for (RepairRun repairRun : repairRuns) { RepairRun.RunState state = repairRun.getRunState(); diff --git a/src/main/java/com/spotify/reaper/storage/IStorage.java b/src/main/java/com/spotify/reaper/storage/IStorage.java index 77a4f7042..b0f24405b 100644 --- a/src/main/java/com/spotify/reaper/storage/IStorage.java +++ b/src/main/java/com/spotify/reaper/storage/IStorage.java @@ -21,7 +21,6 @@ import com.spotify.reaper.core.RepairSegment; import com.spotify.reaper.core.RepairUnit; import com.spotify.reaper.service.RingRange; -import com.spotify.reaper.service.SchedulingManager; import java.util.Collection; import java.util.Set; @@ -41,6 +40,15 @@ public interface IStorage { Optional getCluster(String clusterName); + /** + * Delete the Cluster instance identified by the given cluster name. Delete succeeds + * only if there are no repair runs for the targeted cluster. + * + * @param clusterName The name of the Cluster instance to delete. + * @return The deleted Cluster instance if delete succeeds, with state set to DELETED. + */ + Optional deleteCluster(String clusterName); + RepairRun addRepairRun(RepairRun.Builder repairRun); boolean updateRepairRun(RepairRun repairRun); @@ -53,6 +61,15 @@ public interface IStorage { Collection getRepairRunsWithState(RepairRun.RunState runState); + /** + * Delete the RepairRun instance identified by the given id, and delete also + * all the related repair segments. + * + * @param id The id of the RepairRun instance to delete, and all segments for it. + * @return The deleted RepairRun instance, if delete succeeds, with state set to DELETED. + */ + Optional deleteRepairRun(long id); + RepairUnit addRepairUnit(RepairUnit.Builder newRepairUnit); Optional getRepairUnit(long id); @@ -66,7 +83,7 @@ public interface IStorage { * @return Instance of a RepairUnit matching the parameters, or null if not found. */ Optional getRepairUnit(String cluster, String keyspace, - Set columnFamilyNames); + Set columnFamilyNames); void addRepairSegments(Collection newSegments, long runId); @@ -94,4 +111,13 @@ Optional getRepairUnit(String cluster, String keyspace, boolean updateRepairSchedule(RepairSchedule newRepairSchedule); + /** + * Delete the RepairSchedule instance identified by the given id. Related repair runs + * or other resources tied to the schedule will not be deleted. + * + * @param id The id of the RepairSchedule instance to delete. + * @return The deleted RepairSchedule instance, if delete succeeds, with state set to DELETED. + */ + Optional deleteRepairSchedule(long id); + } diff --git a/src/main/java/com/spotify/reaper/storage/MemoryStorage.java b/src/main/java/com/spotify/reaper/storage/MemoryStorage.java index 15eb603ca..5838c8bbb 100644 --- a/src/main/java/com/spotify/reaper/storage/MemoryStorage.java +++ b/src/main/java/com/spotify/reaper/storage/MemoryStorage.java @@ -85,6 +85,15 @@ public Optional getCluster(String clusterName) { return Optional.fromNullable(clusters.get(clusterName)); } + @Override + public Optional deleteCluster(String clusterName) { + if (getRepairSchedulesForCluster(clusterName).isEmpty() + && getRepairRunsForCluster(clusterName).isEmpty()) { + return Optional.fromNullable(clusters.remove(clusterName)); + } + return Optional.absent(); + } + @Override public RepairRun addRepairRun(RepairRun.Builder repairRun) { RepairRun newRepairRun = repairRun.build(REPAIR_RUN_ID.incrementAndGet()); @@ -140,6 +149,59 @@ public Collection getRepairRunsWithState(RepairRun.RunState runState) return foundRepairRuns; } + /** + * Delete a RepairUnit instance from Storage, but only if no run or schedule is referencing it. + * + * @param repairUnitId The RepairUnit instance id to delete. + * @return The deleted RepairUnit instance, if delete succeeded. + */ + private Optional deleteRepairUnit(long repairUnitId) { + RepairUnit deletedUnit = null; + boolean canDelete = true; + for (RepairRun repairRun : repairRuns.values()) { + if (repairRun.getRepairUnitId() == repairUnitId) { + canDelete = false; + break; + } + } + if (canDelete) { + for (RepairSchedule schedule : repairSchedules.values()) { + if (schedule.getRepairUnitId() == repairUnitId) { + canDelete = false; + break; + } + } + } + if (canDelete) { + deletedUnit = repairUnits.remove(repairUnitId); + repairUnitsByKey.remove(new RepairUnitKey(deletedUnit)); + } + return Optional.fromNullable(deletedUnit); + } + + private int deleteRepairSegmentsForRun(long runId) { + Map segmentsMap = repairSegmentsByRunId.remove(runId); + if (null != segmentsMap) { + for (RepairSegment segment : segmentsMap.values()) { + repairSegments.remove(segment.getId()); + } + } + return segmentsMap != null ? segmentsMap.size() : 0; + } + + @Override + public Optional deleteRepairRun(long id) { + RepairRun deletedRun = repairRuns.remove(id); + if (deletedRun != null) { + if (getSegmentAmountForRepairRun(id, RepairSegment.State.RUNNING) == 0) { + deleteRepairUnit(deletedRun.getRepairUnitId()); + deleteRepairSegmentsForRun(id); + deletedRun = deletedRun.with().runState(RepairRun.RunState.DELETED).build(id); + } + } + return Optional.fromNullable(deletedRun); + } + @Override public RepairUnit addRepairUnit(RepairUnit.Builder repairUnit) { Optional existing = @@ -149,10 +211,8 @@ public RepairUnit addRepairUnit(RepairUnit.Builder repairUnit) { } else { RepairUnit newRepairUnit = repairUnit.build(REPAIR_UNIT_ID.incrementAndGet()); repairUnits.put(newRepairUnit.getId(), newRepairUnit); - RepairUnitKey unitTables = new RepairUnitKey(newRepairUnit.getClusterName(), - newRepairUnit.getKeyspaceName(), - newRepairUnit.getColumnFamilies()); - repairUnitsByKey.put(unitTables, newRepairUnit); + RepairUnitKey unitKey = new RepairUnitKey(newRepairUnit); + repairUnitsByKey.put(unitKey, newRepairUnit); return newRepairUnit; } } @@ -164,8 +224,8 @@ public Optional getRepairUnit(long id) { @Override public Optional getRepairUnit(String cluster, String keyspace, Set tables) { - return Optional - .fromNullable(repairUnitsByKey.get(new RepairUnitKey(cluster, keyspace, tables))); + return Optional.fromNullable( + repairUnitsByKey.get(new RepairUnitKey(cluster, keyspace, tables))); } @Override @@ -295,12 +355,25 @@ public boolean updateRepairSchedule(RepairSchedule newRepairSchedule) { } } + @Override + public Optional deleteRepairSchedule(long id) { + RepairSchedule deletedSchedule = repairSchedules.remove(id); + if (deletedSchedule != null) { + deletedSchedule = deletedSchedule.with().state(RepairSchedule.State.DELETED).build(id); + } + return Optional.fromNullable(deletedSchedule); + } + public static class RepairUnitKey { public final String cluster; public final String keyspace; public final Set tables; + public RepairUnitKey(RepairUnit unit) { + this(unit.getClusterName(), unit.getKeyspaceName(), unit.getColumnFamilies()); + } + public RepairUnitKey(String cluster, String keyspace, Set tables) { this.cluster = cluster; this.keyspace = keyspace; diff --git a/src/main/java/com/spotify/reaper/storage/PostgresStorage.java b/src/main/java/com/spotify/reaper/storage/PostgresStorage.java index 2a83f9321..bace13098 100644 --- a/src/main/java/com/spotify/reaper/storage/PostgresStorage.java +++ b/src/main/java/com/spotify/reaper/storage/PostgresStorage.java @@ -35,6 +35,7 @@ import org.skife.jdbi.v2.DBI; import org.skife.jdbi.v2.Handle; +import org.skife.jdbi.v2.exceptions.DBIException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -86,6 +87,22 @@ public Optional getCluster(String clusterName) { return Optional.fromNullable(result); } + @Override + public Optional deleteCluster(String clusterName) { + Cluster result = null; + try (Handle h = jdbi.open()) { + IStoragePostgreSQL pg = getPostgresStorage(h); + Cluster clusterToDel = pg.getCluster(clusterName); + if (clusterToDel != null) { + int rowsDeleted = pg.deleteCluster(clusterName); + if (rowsDeleted > 0) { + result = clusterToDel; + } + } + } + return Optional.fromNullable(result); + } + @Override public boolean isStorageConnected() { String postgresVersion = null; @@ -171,6 +188,46 @@ public Collection getRepairRunsWithState(RepairRun.RunState runState) return result == null ? Lists.newArrayList() : result; } + @Override + public Optional deleteRepairRun(long id) { + RepairRun result = null; + Handle h = null; + try { + h = jdbi.open(); + h.begin(); + IStoragePostgreSQL pg = getPostgresStorage(h); + RepairRun runToDelete = pg.getRepairRun(id); + if (runToDelete != null) { + try { + pg.deleteRepairUnit(runToDelete.getRepairUnitId()); + } catch (DBIException ex) { + LOG.info("cannot delete RepairUnit with id " + runToDelete.getRepairUnitId()); + ex.printStackTrace(); + } + int segmentsRunning = pg.getSegmentAmountForRepairRun(id, RepairSegment.State.RUNNING); + if (segmentsRunning == 0) { + pg.deleteRepairSegmentsForRun(runToDelete.getId()); + result = runToDelete.with().runState(RepairRun.RunState.DELETED).build(id); + } else { + LOG.warn("not deleting RepairRun \"{}\" as it has segments running: {}", + id, segmentsRunning); + } + } + h.commit(); + } catch (DBIException ex) { + LOG.warn("DELETE failed", ex); + ex.printStackTrace(); + if (h != null) { + h.rollback(); + } + } finally { + if (h != null) { + h.close(); + } + } + return Optional.fromNullable(result); + } + @Override public RepairRun addRepairRun(RepairRun.Builder newRepairRun) { RepairRun result; @@ -354,4 +411,20 @@ public boolean updateRepairSchedule(RepairSchedule newRepairSchedule) { } return result; } + + @Override + public Optional deleteRepairSchedule(long id) { + RepairSchedule result = null; + try (Handle h = jdbi.open()) { + IStoragePostgreSQL pg = getPostgresStorage(h); + RepairSchedule scheduleToDel = pg.getRepairSchedule(id); + if (scheduleToDel != null) { + int rowsDeleted = pg.deleteRepairSchedule(scheduleToDel.getId()); + if (rowsDeleted > 0) { + result = scheduleToDel.with().state(RepairSchedule.State.DELETED).build(id); + } + } + } + return Optional.fromNullable(result); + } } diff --git a/src/main/java/com/spotify/reaper/storage/postgresql/IStoragePostgreSQL.java b/src/main/java/com/spotify/reaper/storage/postgresql/IStoragePostgreSQL.java index 46f5ce5c7..8f1b854af 100644 --- a/src/main/java/com/spotify/reaper/storage/postgresql/IStoragePostgreSQL.java +++ b/src/main/java/com/spotify/reaper/storage/postgresql/IStoragePostgreSQL.java @@ -50,6 +50,7 @@ public interface IStoragePostgreSQL { + ") VALUES (:name, :partitioner, :seedHosts)"; static final String SQL_UPDATE_CLUSTER = "UPDATE cluster SET partitioner = :partitioner, seed_hosts = :seedHosts WHERE name = :name"; + static final String SQL_DELETE_CLUSTER = "DELETE FROM cluster WHERE name = :name"; // RepairRun // @@ -77,6 +78,7 @@ public interface IStoragePostgreSQL { "SELECT " + SQL_REPAIR_RUN_ALL_FIELDS + " FROM repair_run WHERE state = :state"; static final String SQL_GET_REPAIR_RUNS_FOR_UNIT = "SELECT " + SQL_REPAIR_RUN_ALL_FIELDS + " FROM repair_run WHERE repair_unit_id = :unitId"; + static final String SQL_DELETE_REPAIR_RUN = "DELETE FROM repair_run WHERE id = :id"; // RepairUnit // @@ -93,6 +95,7 @@ public interface IStoragePostgreSQL { "SELECT " + SQL_REPAIR_UNIT_ALL_FIELDS + " FROM repair_unit " + "WHERE cluster_name = :clusterName AND keyspace_name = :keyspaceName " + "AND column_families @> :columnFamilies AND column_families <@ :columnFamilies"; + static final String SQL_DELETE_REPAIR_UNIT = "DELETE FROM repair_unit WHERE id = :id"; // RepairSegment // @@ -122,6 +125,8 @@ public interface IStoragePostgreSQL { "SELECT " + SQL_REPAIR_SEGMENT_ALL_FIELDS + " FROM repair_segment WHERE " + "run_id = :runId AND state = 0 AND start_token >= :startToken " + "AND end_token < :endToken ORDER BY fail_count ASC, start_token ASC LIMIT 1"; + static final String SQL_DELETE_REPAIR_SEGMENTS_FOR_RUN = + "DELETE FROM repair_segment WHERE run_id = :runId"; // RepairSchedule // @@ -147,6 +152,7 @@ public interface IStoragePostgreSQL { + "WHERE repair_schedule.repair_unit_id = repair_unit.id AND cluster_name = :clusterName"; static final String SQL_GET_ALL_REPAIR_SCHEDULES = "SELECT " + SQL_REPAIR_SCHEDULE_ALL_FIELDS + " FROM repair_schedule"; + static final String SQL_DELETE_REPAIR_SCHEDULE = "DELETE FROM repair_schedule WHERE id = :id"; // Utility methods // @@ -172,6 +178,9 @@ public interface IStoragePostgreSQL { @SqlUpdate(SQL_UPDATE_CLUSTER) public int updateCluster(@BindBean Cluster newCluster); + @SqlUpdate(SQL_DELETE_CLUSTER) + public int deleteCluster(@Bind("name") String clusterName); + @SqlQuery(SQL_GET_REPAIR_RUN) @Mapper(RepairRunMapper.class) public RepairRun getRepairRun(@Bind("id") long repairRunId); @@ -195,6 +204,9 @@ public interface IStoragePostgreSQL { @SqlUpdate(SQL_UPDATE_REPAIR_RUN) public int updateRepairRun(@BindBean RepairRun newRepairRun); + @SqlUpdate(SQL_DELETE_REPAIR_RUN) + public int deleteRepairRun(@Bind("id") long repairRunId); + @SqlQuery(SQL_GET_REPAIR_UNIT) @Mapper(RepairUnitMapper.class) public RepairUnit getRepairUnit(@Bind("id") long repairUnitId); @@ -209,6 +221,9 @@ public RepairUnit getRepairUnitByClusterAndTables(@Bind("clusterName") String cl @GetGeneratedKeys public long insertRepairUnit(@BindBean RepairUnit newRepairUnit); + @SqlUpdate(SQL_DELETE_REPAIR_UNIT) + public int deleteRepairUnit(@Bind("id") long repairUnitId); + @SqlBatch(SQL_INSERT_REPAIR_SEGMENT) @BatchChunkSize(500) public void insertRepairSegments(@BindBean Iterator newRepairSegments); @@ -236,6 +251,9 @@ public RepairSegment getNextFreeRepairSegmentOnRange(@Bind("runId") long runId, @Bind("startToken") BigInteger startToken, @Bind("endToken") BigInteger endToken); + @SqlUpdate(SQL_DELETE_REPAIR_SEGMENTS_FOR_RUN) + public int deleteRepairSegmentsForRun(@Bind("runId") long repairRunId); + @SqlQuery(SQL_GET_REPAIR_SCHEDULE) @Mapper(RepairScheduleMapper.class) public RepairSchedule getRepairSchedule(@Bind("id") long repairScheduleId); @@ -260,6 +278,9 @@ public Collection getRepairSchedulesForCluster( Collection getRepairRunIdsForCluster( @Bind("clusterName") String clusterName); + @SqlUpdate(SQL_DELETE_REPAIR_SCHEDULE) + public int deleteRepairSchedule(@Bind("id") long repairScheduleId); + @SqlQuery(SQL_SEGMENTS_AMOUNT_FOR_REPAIR_RUN) int getSegmentAmountForRepairRun( @Bind("runId") long runId, diff --git a/src/main/java/com/spotify/reaper/storage/postgresql/RepairScheduleMapper.java b/src/main/java/com/spotify/reaper/storage/postgresql/RepairScheduleMapper.java index e392f99ad..e96f93b7d 100644 --- a/src/main/java/com/spotify/reaper/storage/postgresql/RepairScheduleMapper.java +++ b/src/main/java/com/spotify/reaper/storage/postgresql/RepairScheduleMapper.java @@ -38,9 +38,17 @@ public RepairSchedule map(int index, ResultSet r, StatementContext ctx) throws S } else { runHistoryLong = new Long[0]; } + + String stateStr = r.getString("state"); + // For temporary backward compatibility reasons, supporting RUNNING state as ACTIVE. + if ("RUNNING".equalsIgnoreCase(stateStr)) { + stateStr = "ACTIVE"; + } + + RepairSchedule.State scheduleState = RepairSchedule.State.valueOf(stateStr); return new RepairSchedule.Builder( r.getLong("repair_unit_id"), - RepairSchedule.State.valueOf(r.getString("state")), + scheduleState, r.getInt("days_between"), RepairRunMapper.getDateTimeOrNull(r, "next_activation"), ImmutableList.copyOf(runHistoryLong), diff --git a/src/test/java/com/spotify/reaper/SimpleReaperClient.java b/src/test/java/com/spotify/reaper/SimpleReaperClient.java new file mode 100644 index 000000000..32ea606b4 --- /dev/null +++ b/src/test/java/com/spotify/reaper/SimpleReaperClient.java @@ -0,0 +1,115 @@ +package com.spotify.reaper; + +import com.google.common.base.Optional; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.spotify.reaper.resources.view.RepairRunStatus; +import com.spotify.reaper.resources.view.RepairScheduleStatus; +import com.sun.jersey.api.client.Client; +import com.sun.jersey.api.client.ClientResponse; +import com.sun.jersey.api.client.WebResource; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.URL; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +/** + * This is a simple client for testing usage, that calls the Reaper REST API + * and turns the resulting JSON into Reaper core entity instances. + */ +public class SimpleReaperClient { + + private static final Logger LOG = LoggerFactory.getLogger(SimpleReaperClient.class); + + private static Optional> EMPTY_PARAMS = Optional.absent(); + + public static ClientResponse doHttpCall(String httpMethod, String host, int port, String urlPath, + Optional> params) { + String reaperBase = "http://" + host.toLowerCase() + ":" + port + "/"; + URI uri; + try { + uri = new URL(new URL(reaperBase), urlPath).toURI(); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + Client client = new Client(); + WebResource resource = client.resource(uri); + LOG.info("calling (" + httpMethod + ") Reaper in resource: " + resource.getURI()); + if (params.isPresent()) { + for (Map.Entry entry : params.get().entrySet()) { + resource = resource.queryParam(entry.getKey(), entry.getValue()); + } + } + ClientResponse response; + if ("GET".equalsIgnoreCase(httpMethod)) { + response = resource.get(ClientResponse.class); + } else if ("POST".equalsIgnoreCase(httpMethod)) { + response = resource.post(ClientResponse.class); + } else if ("PUT".equalsIgnoreCase(httpMethod)) { + response = resource.put(ClientResponse.class); + } else if ("DELETE".equalsIgnoreCase(httpMethod)) { + response = resource.delete(ClientResponse.class); + } else if ("OPTIONS".equalsIgnoreCase(httpMethod)) { + response = resource.options(ClientResponse.class); + } else { + throw new RuntimeException("Invalid HTTP method: " + httpMethod); + } + return response; + } + + private static T parseJSON(String json, TypeReference ref) { + T parsed; + ObjectMapper mapper = new ObjectMapper(); + try { + parsed = mapper.readValue(json, ref); + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + return parsed; + } + + public static List parseRepairScheduleStatusListJSON(String json) { + return parseJSON(json, new TypeReference>() { + }); + } + + public static RepairScheduleStatus parseRepairScheduleStatusJSON(String json) { + return parseJSON(json, new TypeReference() { + }); + } + + public static List parseRepairRunStatusListJSON(String json) { + return parseJSON(json, new TypeReference>() { + }); + } + + public static RepairRunStatus parseRepairRunStatusJSON(String json) { + return parseJSON(json, new TypeReference() { + }); + } + + private String reaperHost; + private int reaperPort; + + public SimpleReaperClient(String reaperHost, int reaperPort) { + this.reaperHost = reaperHost; + this.reaperPort = reaperPort; + } + + public List getRepairSchedulesForCluster(String clusterName) { + ClientResponse response = doHttpCall("GET", reaperHost, reaperPort, + "/repair_schedule/cluster/" + clusterName, EMPTY_PARAMS); + assertEquals(200, response.getStatus()); + String responseData = response.getEntity(String.class); + return parseRepairScheduleStatusListJSON(responseData); + } + +} diff --git a/src/test/java/com/spotify/reaper/acceptance/AddClusterTest.java b/src/test/java/com/spotify/reaper/acceptance/AddClusterTest.java index 29321db3b..d4a0042dd 100644 --- a/src/test/java/com/spotify/reaper/acceptance/AddClusterTest.java +++ b/src/test/java/com/spotify/reaper/acceptance/AddClusterTest.java @@ -23,5 +23,5 @@ features = "classpath:com.spotify.reaper.acceptance/basic_reaper_functionality.feature" ) public class AddClusterTest { - + // Required only to get the Cucumber acceptance tests actually run. } diff --git a/src/test/java/com/spotify/reaper/acceptance/BasicSteps.java b/src/test/java/com/spotify/reaper/acceptance/BasicSteps.java index 460c4efed..6b7715b67 100644 --- a/src/test/java/com/spotify/reaper/acceptance/BasicSteps.java +++ b/src/test/java/com/spotify/reaper/acceptance/BasicSteps.java @@ -1,21 +1,27 @@ package com.spotify.reaper.acceptance; import com.google.common.base.Optional; +import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.spotify.reaper.AppContext; import com.spotify.reaper.ReaperException; +import com.spotify.reaper.SimpleReaperClient; import com.spotify.reaper.cassandra.JmxConnectionFactory; import com.spotify.reaper.cassandra.JmxProxy; -import com.spotify.reaper.cassandra.RepairStatusHandler; +import com.spotify.reaper.resources.CommonTools; +import com.spotify.reaper.resources.view.RepairRunStatus; +import com.spotify.reaper.resources.view.RepairScheduleStatus; import com.sun.jersey.api.client.ClientResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Arrays; +import java.math.BigInteger; +import java.util.List; import java.util.Map; +import java.util.Set; import javax.ws.rs.core.Response; @@ -28,6 +34,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -38,45 +45,73 @@ public class BasicSteps { private static final Logger LOG = LoggerFactory.getLogger(BasicSteps.class); + private static Optional> EMPTY_PARAMS = Optional.absent(); + + private static SimpleReaperClient client; + @Before - public static void setup() throws Exception { - LOG.info("running testing setup at @Before annotated method"); + public static void setup() { + // actual setup is done in setupReaperTestRunner step + } + + public static void setupReaperTestRunner() throws Exception { + LOG.info("setting up testing Reaper runner with {} seed hosts defined", + TestContext.TEST_CLUSTER_SEED_HOSTS.size()); AppContext context = new AppContext(); - context.jmxConnectionFactory = new JmxConnectionFactory() { - @Override - public JmxProxy connect(Optional handler, String host) - throws ReaperException { - JmxProxy jmx = mock(JmxProxy.class); - when(jmx.getClusterName()).thenReturn("testcluster"); - when(jmx.getKeyspaces()).thenReturn(Arrays.asList("testkeyspace", "system")); - when(jmx.getTableNamesForKeyspace("testkeyspace")).thenReturn( - Sets.newHashSet("testtable", "othertesttable")); - return jmx; + context.jmxConnectionFactory = mock(JmxConnectionFactory.class); + for (String seedHost : TestContext.TEST_CLUSTER_SEED_HOSTS.keySet()) { + String clusterName = TestContext.TEST_CLUSTER_SEED_HOSTS.get(seedHost); + Map> clusterKeyspaces = TestContext.TEST_CLUSTER_INFO.get(clusterName); + JmxProxy jmx = mock(JmxProxy.class); + when(jmx.getClusterName()).thenReturn(clusterName); + when(jmx.getPartitioner()).thenReturn("org.apache.cassandra.dht.RandomPartitioner"); + when(jmx.getKeyspaces()).thenReturn(Lists.newArrayList(clusterKeyspaces.keySet())); + when(jmx.getTokens()).thenReturn(Lists.newArrayList(new BigInteger("0"))); + for (String keyspace : clusterKeyspaces.keySet()) { + when(jmx.getTableNamesForKeyspace(keyspace)).thenReturn(clusterKeyspaces.get(keyspace)); } - }; + when(context.jmxConnectionFactory.connect(org.mockito.Matchers.any(), eq(seedHost))) + .thenReturn(jmx); + } ReaperTestJettyRunner.setup(context); + client = ReaperTestJettyRunner.getClient(); } public void callAndExpect(String httpMethod, String callPath, Optional> params, Response.Status expectedStatus, - Optional expectedDataInResponseData) { + Optional expectedDataInResponseData) throws ReaperException { ClientResponse response = ReaperTestJettyRunner.callReaper(httpMethod, callPath, params); String responseData = response.getEntity(String.class); LOG.info("Got response data: " + responseData); + assertEquals(expectedStatus.getStatusCode(), response.getStatus()); if (expectedDataInResponseData.isPresent()) { assertTrue("expected data not found from the response: " + expectedDataInResponseData.get(), null != responseData && responseData.contains(expectedDataInResponseData.get())); LOG.debug("Data \"" + expectedDataInResponseData.get() + "\" was found from response data"); } - assertEquals(expectedStatus.getStatusCode(), response.getStatus()); } @Given("^a reaper service is running$") public void a_reaper_service_is_running() throws Throwable { + setupReaperTestRunner(); callAndExpect("GET", "/ping", Optional.>absent(), Response.Status.OK, Optional.absent()); } + @Given("^cluster seed host \"([^\"]*)\" points to cluster with name \"([^\"]*)\"$") + public void cluster_seed_host_points_to_cluster_with_name(String seedHost, String clusterName) + throws Throwable { + TestContext.addSeedHostToClusterMapping(seedHost, clusterName); + } + + @Given("^cluster \"([^\"]*)\" has keyspace \"([^\"]*)\" with tables \"([^\"]*)\"$") + public void cluster_has_keyspace_with_tables(String clusterName, String keyspace, + String tablesListStr) throws Throwable { + Set tables = + Sets.newHashSet(CommonTools.COMMA_SEPARATED_LIST_SPLITTER.split(tablesListStr)); + TestContext.addClusterInfo(clusterName, keyspace, tables); + } + @Given("^that we are going to use \"([^\"]*)\" as cluster seed host$") public void that_we_are_going_to_use_as_cluster_seed_host(String seedHost) throws Throwable { TestContext.SEED_HOST = seedHost; @@ -117,18 +152,121 @@ public void a_new_daily_repair_schedule_is_added_for(String clusterName, String Map params = Maps.newHashMap(); params.put("clusterName", clusterName); params.put("keyspace", keyspace); - params.put("owner", "test_user"); + params.put("owner", TestContext.TEST_USER); params.put("intensity", "0.9"); params.put("scheduleDaysBetween", "1"); - callAndExpect("POST", "/repair_schedule", Optional.of(params), Response.Status.CREATED, - Optional.of("\"" + keyspace + "\"")); + ClientResponse response = + ReaperTestJettyRunner.callReaper("POST", "/repair_schedule", Optional.of(params)); + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + String responseData = response.getEntity(String.class); + RepairScheduleStatus schedule = SimpleReaperClient.parseRepairScheduleStatusJSON(responseData); + TestContext.LAST_MODIFIED_ID = schedule.getId(); } @And("^reaper has scheduled repair for cluster called \"([^\"]*)\"$") public void reaper_has_scheduled_repair_for_cluster_called(String clusterName) throws Throwable { callAndExpect("GET", "/repair_schedule/cluster/" + clusterName, - Optional.>absent(), Response.Status.OK, + EMPTY_PARAMS, Response.Status.OK, Optional.of("\"" + clusterName + "\"")); } + @And("^a second daily repair schedule is added for \"([^\"]*)\" and keyspace \"([^\"]*)\"$") + public void a_second_daily_repair_schedule_is_added_for_and_keyspace(String clusterName, + String keyspace) + throws Throwable { + LOG.info("add second daily repair schedule: {}/{}", clusterName, keyspace); + Map params = Maps.newHashMap(); + params.put("clusterName", clusterName); + params.put("keyspace", keyspace); + params.put("owner", TestContext.TEST_USER); + params.put("intensity", "0.8"); + params.put("scheduleDaysBetween", "1"); + ClientResponse response = + ReaperTestJettyRunner.callReaper("POST", "/repair_schedule", Optional.of(params)); + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + String responseData = response.getEntity(String.class); + RepairScheduleStatus schedule = SimpleReaperClient.parseRepairScheduleStatusJSON(responseData); + TestContext.LAST_MODIFIED_ID = schedule.getId(); + } + + @And("^reaper has (\\d+) scheduled repairs for cluster called \"([^\"]*)\"$") + public void reaper_has_scheduled_repairs_for_cluster_called(int repairAmount, + String clusterName) + throws Throwable { + List schedules = client.getRepairSchedulesForCluster(clusterName); + LOG.info("Got " + schedules.size() + " schedules"); + assertEquals(repairAmount, schedules.size()); + } + + @When("^the last added schedule is deleted for cluster called \"([^\"]*)\"$") + public void the_last_added_schedule_is_deleted_for_cluster_called(String clusterName) + throws Throwable { + LOG.info("pause last added repair schedule with id: {}", TestContext.LAST_MODIFIED_ID); + Map params = Maps.newHashMap(); + params.put("state", "paused"); + callAndExpect("PUT", "/repair_schedule/" + TestContext.LAST_MODIFIED_ID, + Optional.of(params), Response.Status.OK, Optional.of("\"" + clusterName + "\"")); + + LOG.info("delete last added repair schedule with id: {}", TestContext.LAST_MODIFIED_ID); + params.clear(); + params.put("owner", TestContext.TEST_USER); + callAndExpect("DELETE", "/repair_schedule/" + TestContext.LAST_MODIFIED_ID, + Optional.of(params), Response.Status.OK, Optional.of("\"" + clusterName + "\"")); + } + + @And("^deleting cluster called \"([^\"]*)\" fails$") + public void deleting_cluster_called_fails(String clusterName) throws Throwable { + callAndExpect("DELETE", "/cluster/" + clusterName, + EMPTY_PARAMS, Response.Status.FORBIDDEN, Optional.of("\"" + clusterName + "\"")); + } + + @And("^cluster called \"([^\"]*)\" is deleted$") + public void cluster_called_is_deleted(String clusterName) throws Throwable { + callAndExpect("DELETE", "/cluster/" + clusterName, + EMPTY_PARAMS, Response.Status.OK, Optional.of("\"" + clusterName + "\"")); + } + + @Then("^reaper has no cluster called \"([^\"]*)\" in storage$") + public void reaper_has_no_cluster_called_in_storage(String clusterName) throws Throwable { + callAndExpect("GET", "/cluster/" + clusterName, + Optional.>absent(), Response.Status.NOT_FOUND, + Optional.absent()); + } + + @And("^a new repair is added for \"([^\"]*)\" and keyspace \"([^\"]*)\"$") + public void a_new_repair_is_added_for_and_keyspace(String clusterName, String keyspace) + throws Throwable { + Map params = Maps.newHashMap(); + params.put("clusterName", clusterName); + params.put("keyspace", keyspace); + params.put("owner", TestContext.TEST_USER); + ClientResponse response = + ReaperTestJettyRunner.callReaper("POST", "/repair_run", Optional.of(params)); + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + String responseData = response.getEntity(String.class); + RepairRunStatus run = SimpleReaperClient.parseRepairRunStatusJSON(responseData); + TestContext.LAST_MODIFIED_ID = run.getId(); + } + + @Then("^reaper has (\\d+) repairs for cluster called \"([^\"]*)\"$") + public void reaper_has_repairs_for_cluster_called(int runAmount, String clusterName) + throws Throwable { + ClientResponse response = + ReaperTestJettyRunner.callReaper("GET", "/repair_run/cluster/" + clusterName, + EMPTY_PARAMS); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + String responseData = response.getEntity(String.class); + List runs = SimpleReaperClient.parseRepairRunStatusListJSON(responseData); + assertEquals(runAmount, runs.size()); + } + + @When("^the last added repair run is deleted for cluster called \"([^\"]*)\"$") + public void the_last_added_repair_run_is_deleted_for_cluster_called(String clusterName) + throws Throwable { + LOG.info("delete last added repair run with id: {}", TestContext.LAST_MODIFIED_ID); + Map params = Maps.newHashMap(); + params.put("owner", TestContext.TEST_USER); + callAndExpect("DELETE", "/repair_run/" + TestContext.LAST_MODIFIED_ID, + Optional.of(params), Response.Status.OK, Optional.of("\"" + clusterName + "\"")); + } } diff --git a/src/test/java/com/spotify/reaper/acceptance/ReaperTestJettyRunner.java b/src/test/java/com/spotify/reaper/acceptance/ReaperTestJettyRunner.java index 5758028e5..088f6c1ff 100644 --- a/src/test/java/com/spotify/reaper/acceptance/ReaperTestJettyRunner.java +++ b/src/test/java/com/spotify/reaper/acceptance/ReaperTestJettyRunner.java @@ -8,9 +8,8 @@ import com.spotify.reaper.AppContext; import com.spotify.reaper.ReaperApplication; import com.spotify.reaper.ReaperApplicationConfiguration; -import com.sun.jersey.api.client.Client; +import com.spotify.reaper.SimpleReaperClient; import com.sun.jersey.api.client.ClientResponse; -import com.sun.jersey.api.client.WebResource; import net.sourceforge.argparse4j.inf.Namespace; @@ -19,8 +18,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.net.URI; -import java.net.URL; import java.util.Map; import io.dropwizard.cli.ServerCommand; @@ -38,6 +35,7 @@ public class ReaperTestJettyRunner { private static final Logger LOG = LoggerFactory.getLogger(ReaperTestJettyRunner.class); private static ReaperTestJettyRunner runnerInstance; + private static SimpleReaperClient reaperClientInstance; public static void setup(AppContext testContext) throws Exception { if (runnerInstance == null) { @@ -45,7 +43,7 @@ public static void setup(AppContext testContext) throws Exception { LOG.info("initializing ReaperTestJettyRunner with config in path: " + testConfigPath); runnerInstance = new ReaperTestJettyRunner(testConfigPath, testContext); runnerInstance.start(); - // Stop the testing Reaper after tests are finished. + // Stop the testing Reaper service instance after tests are finished. Runtime.getRuntime().addShutdownHook(new Thread() { @Override public void run() { @@ -60,30 +58,15 @@ public void run() { public static ClientResponse callReaper(String httpMethod, String urlPath, Optional> params) { assert runnerInstance != null : "service not initialized, call setup() first"; - String reaperBase = "http://localhost:" + runnerInstance.getLocalPort() + "/"; - URI uri; - try { - uri = new URL(new URL(reaperBase), urlPath).toURI(); - } catch (Exception ex) { - throw new RuntimeException(ex); - } - Client client = new Client(); - WebResource resource = client.resource(uri); - LOG.info("calling reaper in resource: " + resource.getURI()); - if (params.isPresent()) { - for (Map.Entry entry : params.get().entrySet()) { - resource = resource.queryParam(entry.getKey(), entry.getValue()); - } - } - ClientResponse response; - if ("GET".equalsIgnoreCase(httpMethod)) { - response = resource.get(ClientResponse.class); - } else if ("POST".equalsIgnoreCase(httpMethod)) { - response = resource.post(ClientResponse.class); - } else { - throw new RuntimeException("Invalid HTTP method: " + httpMethod); + return SimpleReaperClient.doHttpCall(httpMethod, "localhost", runnerInstance.getLocalPort(), + urlPath, params); + } + + public static SimpleReaperClient getClient() { + if (reaperClientInstance == null) { + reaperClientInstance = new SimpleReaperClient("localhost", runnerInstance.getLocalPort()); } - return response; + return reaperClientInstance; } private final String configPath; diff --git a/src/test/java/com/spotify/reaper/acceptance/TestContext.java b/src/test/java/com/spotify/reaper/acceptance/TestContext.java index 6d61e0b93..a5b8a2bfc 100644 --- a/src/test/java/com/spotify/reaper/acceptance/TestContext.java +++ b/src/test/java/com/spotify/reaper/acceptance/TestContext.java @@ -1,7 +1,41 @@ package com.spotify.reaper.acceptance; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Helper class for holding acceptance test scenario state. + * Contains also methods for getting related resources for testing, like mocks etc. + */ public class TestContext { + public static String TEST_USER = "test_user"; public static String SEED_HOST; + /* Used for targeting an object accessed in last test step. */ + public static Long LAST_MODIFIED_ID; + + /* Testing cluster seed host mapped to cluster name. */ + public static Map TEST_CLUSTER_SEED_HOSTS = new HashMap<>(); + + /* Testing cluster name mapped to keyspace name mapped to tables list. */ + public static Map>> TEST_CLUSTER_INFO = new HashMap<>(); + + /** + * Adds testing cluster information for testing purposes. + * Used to create mocks and prepare testing access for added testing clusters. + */ + public static void addClusterInfo(String clusterName, String keyspace, Set tables) { + if (!TEST_CLUSTER_INFO.containsKey(clusterName)) { + TEST_CLUSTER_INFO.put(clusterName, new HashMap>()); + } + TEST_CLUSTER_INFO.get(clusterName).put(keyspace, tables); + } + + public static void addSeedHostToClusterMapping(String seedHost, String clusterName) { + TEST_CLUSTER_SEED_HOSTS.put(seedHost, clusterName); + } + + } diff --git a/src/test/java/com/spotify/reaper/resources/view/RepairScheduleStatusTest.java b/src/test/java/com/spotify/reaper/resources/view/RepairScheduleStatusTest.java new file mode 100644 index 000000000..ed9eb7cf1 --- /dev/null +++ b/src/test/java/com/spotify/reaper/resources/view/RepairScheduleStatusTest.java @@ -0,0 +1,53 @@ +package com.spotify.reaper.resources.view; + +import com.google.common.collect.Lists; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.spotify.reaper.SimpleReaperClient; +import com.spotify.reaper.core.RepairSchedule; + +import org.apache.cassandra.repair.RepairParallelism; +import org.joda.time.DateTime; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.junit.Assert.assertEquals; + +public class RepairScheduleStatusTest { + + private static final Logger LOG = LoggerFactory.getLogger(RepairScheduleStatusTest.class); + + @Test + public void testJacksonJSONParsing() throws Exception { + RepairScheduleStatus data = new RepairScheduleStatus(); + data.setClusterName("testCluster"); + data.setColumnFamilies(Lists.newArrayList()); + data.setCreationTime(DateTime.now().withMillis(0)); + data.setDaysBetween(2); + data.setId(1); + data.setIntensity(0.75); + data.setKeyspaceName("testKeyspace"); + data.setOwner("testuser"); + data.setRepairParallelism(RepairParallelism.PARALLEL.name()); + data.setState(RepairSchedule.State.ACTIVE.name()); + + ObjectMapper mapper = new ObjectMapper(); + String dataAsJson = mapper.writeValueAsString(data); + LOG.info("DATA: " + dataAsJson); + + RepairScheduleStatus dataAfter = SimpleReaperClient.parseRepairScheduleStatusJSON(dataAsJson); + + assertEquals(data.getClusterName(), dataAfter.getClusterName()); + assertEquals(data.getColumnFamilies(), dataAfter.getColumnFamilies()); + assertEquals(data.getCreationTime(), dataAfter.getCreationTime()); + assertEquals(data.getDaysBetween(), dataAfter.getDaysBetween()); + assertEquals(data.getId(), dataAfter.getId()); + assertEquals(data.getIntensity(), dataAfter.getIntensity(), 0.0); + assertEquals(data.getKeyspaceName(), dataAfter.getKeyspaceName()); + assertEquals(data.getRepairParallelism(), dataAfter.getRepairParallelism()); + assertEquals(data.getState(), dataAfter.getState()); + } + +} diff --git a/src/test/java/com/spotify/reaper/unit/core/ClusterTest.java b/src/test/java/com/spotify/reaper/unit/core/ClusterTest.java index fa968fa21..42fa5fc8b 100644 --- a/src/test/java/com/spotify/reaper/unit/core/ClusterTest.java +++ b/src/test/java/com/spotify/reaper/unit/core/ClusterTest.java @@ -13,11 +13,11 @@ */ package com.spotify.reaper.unit.core; -import com.spotify.reaper.core.Cluster; + import com.spotify.reaper.core.Cluster; -import org.junit.Test; + import org.junit.Test; -import static org.junit.Assert.assertEquals; + import static org.junit.Assert.assertEquals; public class ClusterTest { diff --git a/src/test/resources/com.spotify.reaper.acceptance/basic_reaper_functionality.feature b/src/test/resources/com.spotify.reaper.acceptance/basic_reaper_functionality.feature index ced2bd01b..75cd64fd6 100644 --- a/src/test/resources/com.spotify.reaper.acceptance/basic_reaper_functionality.feature +++ b/src/test/resources/com.spotify.reaper.acceptance/basic_reaper_functionality.feature @@ -1,17 +1,53 @@ Feature: Using Reaper to launch repairs + ## TODO: clean-up and split the scenarios to be more like Given -> When -> Then [-> But] + Background: - Given a reaper service is running + Given cluster seed host "127.0.0.1" points to cluster with name "test_cluster" + And cluster "test_cluster" has keyspace "test_keyspace" with tables "test_table1, test_table2" + And cluster "test_cluster" has keyspace "system" with tables "system_table1, system_table2" + And cluster seed host "127.0.0.2" points to cluster with name "other_cluster" + And cluster "other_cluster" has keyspace "other_keyspace" with tables "test_table3, test_table4, test_table5" + And cluster "other_cluster" has keyspace "system" with tables "system_table1, system_table2" + And a reaper service is running Scenario: Registering a cluster Given that we are going to use "127.0.0.1" as cluster seed host - And reaper has no cluster with name "testcluster" in storage + And reaper has no cluster with name "test_cluster" in storage When an add-cluster request is made to reaper - Then reaper has a cluster called "testcluster" in storage + Then reaper has a cluster called "test_cluster" in storage Scenario: Registering a scheduled repair - Given reaper has a cluster called "testcluster" in storage - And reaper has no scheduled repairs for "testcluster" - When a new daily repair schedule is added for "testcluster" and keyspace "testkeyspace" - Then reaper has a cluster called "testcluster" in storage - And reaper has scheduled repair for cluster called "testcluster" + Given reaper has a cluster called "test_cluster" in storage + And reaper has 0 scheduled repairs for cluster called "test_cluster" + When a new daily repair schedule is added for "test_cluster" and keyspace "test_keyspace" + Then reaper has a cluster called "test_cluster" in storage + And reaper has 1 scheduled repairs for cluster called "test_cluster" + + Scenario: Deleting a scheduled repair + Given reaper has a cluster called "test_cluster" in storage + And reaper has 1 scheduled repairs for cluster called "test_cluster" + When a new daily repair schedule is added for "test_cluster" and keyspace "test_keyspace" + And a second daily repair schedule is added for "test_cluster" and keyspace "system" + Then reaper has a cluster called "test_cluster" in storage + And reaper has 3 scheduled repairs for cluster called "test_cluster" + When the last added schedule is deleted for cluster called "test_cluster" + Then reaper has 2 scheduled repairs for cluster called "test_cluster" + And deleting cluster called "test_cluster" fails + + Scenario: Create a cluster and a repair run and delete them + Given reaper has no cluster with name "other_cluster" in storage + And that we are going to use "127.0.0.2" as cluster seed host + When an add-cluster request is made to reaper + Then reaper has a cluster called "other_cluster" in storage + And reaper has 0 scheduled repairs for cluster called "other_cluster" + When a new daily repair schedule is added for "other_cluster" and keyspace "other_keyspace" + Then reaper has 1 scheduled repairs for cluster called "other_cluster" + And deleting cluster called "other_cluster" fails + When the last added schedule is deleted for cluster called "other_cluster" + And a new repair is added for "other_cluster" and keyspace "system" + Then reaper has 1 repairs for cluster called "other_cluster" + And deleting cluster called "other_cluster" fails + When the last added repair run is deleted for cluster called "other_cluster" + And cluster called "other_cluster" is deleted + Then reaper has no cluster called "other_cluster" in storage