diff --git a/elasticsearch/src/main/java/org/elasticsearch/xpack/prelert/PrelertPlugin.java b/elasticsearch/src/main/java/org/elasticsearch/xpack/prelert/PrelertPlugin.java index 69c8e742ff1..cdf5e643c0a 100644 --- a/elasticsearch/src/main/java/org/elasticsearch/xpack/prelert/PrelertPlugin.java +++ b/elasticsearch/src/main/java/org/elasticsearch/xpack/prelert/PrelertPlugin.java @@ -27,6 +27,7 @@ import org.elasticsearch.threadpool.FixedExecutorBuilder; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.watcher.ResourceWatcherService; import org.elasticsearch.xpack.prelert.action.DeleteJobAction; +import org.elasticsearch.xpack.prelert.action.DeleteListAction; import org.elasticsearch.xpack.prelert.action.DeleteModelSnapshotAction; import org.elasticsearch.xpack.prelert.action.GetBucketsAction; import org.elasticsearch.xpack.prelert.action.GetCategoriesDefinitionAction; @@ -74,6 +75,7 @@ import org.elasticsearch.xpack.prelert.job.usage.UsageReporter; import org.elasticsearch.xpack.prelert.rest.job.RestJobDataAction; import org.elasticsearch.xpack.prelert.rest.job.RestCloseJobAction; import org.elasticsearch.xpack.prelert.rest.job.RestFlushJobAction; +import org.elasticsearch.xpack.prelert.rest.list.RestDeleteListAction; import org.elasticsearch.xpack.prelert.rest.results.RestGetInfluencersAction; import org.elasticsearch.xpack.prelert.rest.job.RestDeleteJobAction; import org.elasticsearch.xpack.prelert.rest.job.RestGetJobsAction; @@ -197,6 +199,7 @@ public class PrelertPlugin extends Plugin implements ActionPlugin { RestOpenJobAction.class, RestGetListAction.class, RestPutListAction.class, + RestDeleteListAction.class, RestGetInfluencersAction.class, RestGetRecordsAction.class, RestGetBucketsAction.class, @@ -227,6 +230,7 @@ public class PrelertPlugin extends Plugin implements ActionPlugin { new ActionHandler<>(UpdateJobSchedulerStatusAction.INSTANCE, UpdateJobSchedulerStatusAction.TransportAction.class), new ActionHandler<>(GetListAction.INSTANCE, GetListAction.TransportAction.class), new ActionHandler<>(PutListAction.INSTANCE, PutListAction.TransportAction.class), + new ActionHandler<>(DeleteListAction.INSTANCE, DeleteListAction.TransportAction.class), new ActionHandler<>(GetBucketsAction.INSTANCE, GetBucketsAction.TransportAction.class), new ActionHandler<>(GetInfluencersAction.INSTANCE, GetInfluencersAction.TransportAction.class), new ActionHandler<>(GetRecordsAction.INSTANCE, GetRecordsAction.TransportAction.class), diff --git a/elasticsearch/src/main/java/org/elasticsearch/xpack/prelert/action/DeleteListAction.java b/elasticsearch/src/main/java/org/elasticsearch/xpack/prelert/action/DeleteListAction.java new file mode 100644 index 00000000000..c9a4e25f175 --- /dev/null +++ b/elasticsearch/src/main/java/org/elasticsearch/xpack/prelert/action/DeleteListAction.java @@ -0,0 +1,219 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.prelert.action; + +import org.elasticsearch.ResourceNotFoundException; +import org.elasticsearch.action.Action; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.action.delete.DeleteRequest; +import org.elasticsearch.action.delete.DeleteResponse; +import org.elasticsearch.action.delete.TransportDeleteAction; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.master.AcknowledgedRequest; +import org.elasticsearch.action.support.master.AcknowledgedResponse; +import org.elasticsearch.action.support.master.MasterNodeOperationRequestBuilder; +import org.elasticsearch.action.support.master.TransportMasterNodeAction; +import org.elasticsearch.client.ElasticsearchClient; +import org.elasticsearch.cluster.ClusterState; +import org.elasticsearch.cluster.block.ClusterBlockException; +import org.elasticsearch.cluster.block.ClusterBlockLevel; +import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.prelert.job.Detector; +import org.elasticsearch.xpack.prelert.job.Job; +import org.elasticsearch.xpack.prelert.job.messages.Messages; +import org.elasticsearch.xpack.prelert.job.metadata.PrelertMetadata; +import org.elasticsearch.xpack.prelert.lists.ListDocument; +import org.elasticsearch.xpack.prelert.utils.ExceptionsHelper; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +public class DeleteListAction extends Action { + + public static final DeleteListAction INSTANCE = new DeleteListAction(); + public static final String NAME = "cluster:admin/prelert/list/delete"; + + private DeleteListAction() { + super(NAME); + } + + @Override + public RequestBuilder newRequestBuilder(ElasticsearchClient client) { + return new RequestBuilder(client, this); + } + + @Override + public Response newResponse() { + return new Response(); + } + + public static class Request extends AcknowledgedRequest { + + public static final ParseField LIST_ID = new ParseField("list_id"); + + private String listId; + + Request() { + + } + + public Request(String listId) { + this.listId = ExceptionsHelper.requireNonNull(listId, LIST_ID.getPreferredName()); + } + + public String getListId() { + return listId; + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + @Override + public void readFrom(StreamInput in) throws IOException { + super.readFrom(in); + listId = in.readString(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(listId); + } + + @Override + public int hashCode() { + return Objects.hash(listId); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) { + return false; + } + Request other = (Request) obj; + return Objects.equals(listId, other.listId); + } + } + + public static class RequestBuilder extends MasterNodeOperationRequestBuilder { + + public RequestBuilder(ElasticsearchClient client, DeleteListAction action) { + super(client, action, new Request()); + } + } + + public static class Response extends AcknowledgedResponse { + + public Response(boolean acknowledged) { + super(acknowledged); + } + + private Response() {} + + @Override + public void readFrom(StreamInput in) throws IOException { + super.readFrom(in); + readAcknowledged(in); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + writeAcknowledged(out); + } + } + + public static class TransportAction extends TransportMasterNodeAction { + + private final TransportDeleteAction transportAction; + + // TODO these need to be moved to a settings object later. See #20 + private static final String PRELERT_INFO_INDEX = "prelert-int"; + + @Inject + public TransportAction(Settings settings, TransportService transportService, ClusterService clusterService, + ThreadPool threadPool, ActionFilters actionFilters, + IndexNameExpressionResolver indexNameExpressionResolver, + TransportDeleteAction transportAction) { + super(settings, DeleteListAction.NAME, transportService, clusterService, threadPool, actionFilters, + indexNameExpressionResolver, Request::new); + this.transportAction = transportAction; + } + + @Override + protected String executor() { + return ThreadPool.Names.SAME; + } + + @Override + protected Response newResponse() { + return new Response(); + } + + @Override + protected void masterOperation(Request request, ClusterState state, ActionListener listener) throws Exception { + + final String listId = request.getListId(); + PrelertMetadata currentPrelertMetadata = state.metaData().custom(PrelertMetadata.TYPE); + Map jobs = currentPrelertMetadata.getJobs(); + List currentlyUsedBy = new ArrayList<>(); + for (Job job : jobs.values()) { + List detectors = job.getAnalysisConfig().getDetectors(); + for (Detector detector : detectors) { + if (detector.extractReferencedLists().contains(listId)) { + currentlyUsedBy.add(job.getId()); + break; + } + } + } + if (!currentlyUsedBy.isEmpty()) { + throw ExceptionsHelper.conflictStatusException("Cannot delete List, currently used by jobs: " + + currentlyUsedBy); + } + + DeleteRequest deleteRequest = new DeleteRequest(PRELERT_INFO_INDEX, ListDocument.TYPE.getPreferredName(), listId); + transportAction.execute(deleteRequest, new ActionListener() { + @Override + public void onResponse(DeleteResponse deleteResponse) { + if (deleteResponse.status().equals(RestStatus.NOT_FOUND)) { + listener.onFailure(new ResourceNotFoundException("Could not delete list with ID [" + listId + + "] because it does not exist")); + } else { + listener.onResponse(new Response(true)); + } + } + + @Override + public void onFailure(Exception e) { + logger.error("Could not delete list with ID [" + listId + "]", e); + listener.onFailure(new IllegalStateException("Could not delete list with ID [" + listId + "]", e)); + } + }); + } + + @Override + protected ClusterBlockException checkBlock(Request request, ClusterState state) { + return state.blocks().globalBlockedException(ClusterBlockLevel.METADATA_WRITE); + } + } +} + diff --git a/elasticsearch/src/main/java/org/elasticsearch/xpack/prelert/rest/list/RestDeleteListAction.java b/elasticsearch/src/main/java/org/elasticsearch/xpack/prelert/rest/list/RestDeleteListAction.java new file mode 100644 index 00000000000..b27d659b3dd --- /dev/null +++ b/elasticsearch/src/main/java/org/elasticsearch/xpack/prelert/rest/list/RestDeleteListAction.java @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.prelert.rest.list; + +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.rest.BaseRestHandler; +import org.elasticsearch.rest.RestController; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.action.AcknowledgedRestListener; +import org.elasticsearch.xpack.prelert.PrelertPlugin; +import org.elasticsearch.xpack.prelert.action.DeleteListAction; +import org.elasticsearch.xpack.prelert.action.DeleteListAction.Request; +import org.elasticsearch.xpack.prelert.action.PutListAction; + +import java.io.IOException; + +public class RestDeleteListAction extends BaseRestHandler { + + private final DeleteListAction.TransportAction transportAction; + + @Inject + public RestDeleteListAction(Settings settings, RestController controller, DeleteListAction.TransportAction transportAction) { + super(settings); + this.transportAction = transportAction; + controller.registerHandler(RestRequest.Method.DELETE, + PrelertPlugin.BASE_PATH + "lists/{" + Request.LIST_ID.getPreferredName() + "}", this); + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient client) throws IOException { + Request request = new Request(restRequest.param(Request.LIST_ID.getPreferredName())); + return channel -> transportAction.execute(request, new AcknowledgedRestListener<>(channel)); + } + +} diff --git a/elasticsearch/src/test/resources/rest-api-spec/api/xpack.prelert.delete_list.json b/elasticsearch/src/test/resources/rest-api-spec/api/xpack.prelert.delete_list.json new file mode 100644 index 00000000000..5a04873453f --- /dev/null +++ b/elasticsearch/src/test/resources/rest-api-spec/api/xpack.prelert.delete_list.json @@ -0,0 +1,17 @@ +{ + "xpack.prelert.delete_list": { + "methods": [ "DELETE" ], + "url": { + "path": "/_xpack/prelert/lists/{list_id}", + "paths": [ "/_xpack/prelert/lists/{list_id}" ], + "parts": { + "list_id": { + "type" : "string", + "required" : true, + "description" : "The ID of the list to delete" + } + } + }, + "body": null + } +} diff --git a/elasticsearch/src/test/resources/rest-api-spec/test/list_crud.yaml b/elasticsearch/src/test/resources/rest-api-spec/test/list_crud.yaml index 61d0ba03b20..0f5f07d6bff 100644 --- a/elasticsearch/src/test/resources/rest-api-spec/test/list_crud.yaml +++ b/elasticsearch/src/test/resources/rest-api-spec/test/list_crud.yaml @@ -62,3 +62,65 @@ setup: { "items": ["abc", "xyz"] } + +--- +"Test delete in-use list": + - do: + xpack.prelert.put_job: + body: > + { + "job_id":"farequote2", + "description":"Analysis of response time by airline", + "analysis_config" : { + "bucket_span":3600, + "detectors" :[{"function":"mean","field_name":"airline", + "detector_rules": [ + { + "rule_conditions": [ + { + "condition_type": "categorical", + "value_list": "foo" + } + ] + } + ]}] + }, + "data_description" : { + "field_delimiter":",", + "time_field":"time", + "time_format":"yyyy-MM-dd HH:mm:ssX" + } + } + - do: + catch: conflict + xpack.prelert.delete_list: + list_id: "foo" + +--- +"Test non-existing list": + - do: + catch: missing + xpack.prelert.delete_list: + list_id: "does_not_exist" + +--- +"Test valid delete list": + + - do: + xpack.prelert.get_lists: + list_id: "foo" + + - match: { count: 1 } + - match: + lists.0: + id: "foo" + items: ["abc", "xyz"] + + - do: + xpack.prelert.delete_list: + list_id: "foo" + + - do: + catch: missing + xpack.prelert.get_lists: + list_id: "foo"