All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.graylog.plugins.sidecar.rest.resources.ConfigurationResource Maven / Gradle / Ivy

There is a newer version: 6.1.4
Show newest version
/*
 * Copyright (C) 2020 Graylog, Inc.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the Server Side Public License, version 1,
 * as published by MongoDB, Inc.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * Server Side Public License for more details.
 *
 * You should have received a copy of the Server Side Public License
 * along with this program. If not, see
 * .
 */
package org.graylog.plugins.sidecar.rest.resources;

import com.codahale.metrics.annotation.Timed;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Sets;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.graylog.plugins.sidecar.audit.SidecarAuditEventTypes;
import org.graylog.plugins.sidecar.permissions.SidecarRestPermissions;
import org.graylog.plugins.sidecar.rest.models.Collector;
import org.graylog.plugins.sidecar.rest.models.CollectorUpload;
import org.graylog.plugins.sidecar.rest.models.Configuration;
import org.graylog.plugins.sidecar.rest.models.ConfigurationSummary;
import org.graylog.plugins.sidecar.rest.models.Sidecar;
import org.graylog.plugins.sidecar.rest.requests.ConfigurationAssignment;
import org.graylog.plugins.sidecar.rest.requests.ConfigurationPreviewRequest;
import org.graylog.plugins.sidecar.rest.responses.CollectorUploadListResponse;
import org.graylog.plugins.sidecar.rest.responses.ConfigurationListResponse;
import org.graylog.plugins.sidecar.rest.responses.ConfigurationPreviewRenderResponse;
import org.graylog.plugins.sidecar.rest.responses.ConfigurationSidecarsResponse;
import org.graylog.plugins.sidecar.services.CollectorService;
import org.graylog.plugins.sidecar.services.ConfigurationService;
import org.graylog.plugins.sidecar.services.EtagService;
import org.graylog.plugins.sidecar.services.ImportService;
import org.graylog.plugins.sidecar.services.SidecarService;
import org.graylog.plugins.sidecar.template.RenderTemplateException;
import org.graylog2.audit.jersey.AuditEvent;
import org.graylog2.audit.jersey.NoAuditEvent;
import org.graylog2.database.PaginatedList;
import org.graylog2.plugin.rest.PluginRestResource;
import org.graylog2.plugin.rest.ValidationResult;
import org.graylog2.search.SearchQuery;
import org.graylog2.search.SearchQueryField;
import org.graylog2.search.SearchQueryParser;
import org.graylog2.shared.rest.resources.RestResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Inject;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.CacheControl;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.EntityTag;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import static com.google.common.base.MoreObjects.firstNonNull;
import static org.graylog2.shared.rest.documentation.generator.Generator.CLOUD_VISIBLE;

@Api(value = "Sidecar/Configurations", description = "Manage/Render collector configurations", tags = {CLOUD_VISIBLE})
@Path("/sidecar/configurations")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@RequiresAuthentication
public class ConfigurationResource extends RestResource implements PluginRestResource {
    private static final Logger LOG = LoggerFactory.getLogger(ConfigurationResource.class);

    // a file is created by the Sidecar based on the configuration name so we basically check for invalid paths here
    private static final Pattern VALID_NAME_PATTERN = Pattern.compile("^[^;*?\"<>|&]+$");

    private final ConfigurationService configurationService;
    private final SidecarService sidecarService;
    private final EtagService etagService;
    private final ImportService importService;
    private final CollectorService collectorService;
    private final SearchQueryParser searchQueryParser;
    private static final ImmutableMap SEARCH_FIELD_MAPPING = ImmutableMap.builder()
            .put("id", SearchQueryField.create(Configuration.FIELD_ID))
            .put("collector_id", SearchQueryField.create(Configuration.FIELD_COLLECTOR_ID))
            .put("name", SearchQueryField.create(Configuration.FIELD_NAME))
            .build();

    @Inject
    public ConfigurationResource(ConfigurationService configurationService,
                                 SidecarService sidecarService,
                                 EtagService etagService,
                                 ImportService importService,
                                 CollectorService collectorService) {
        this.configurationService = configurationService;
        this.sidecarService = sidecarService;
        this.etagService = etagService;
        this.importService = importService;
        this.collectorService = collectorService;
        this.searchQueryParser = new SearchQueryParser(Configuration.FIELD_NAME, SEARCH_FIELD_MAPPING);
    }

    @GET
    @RequiresPermissions(SidecarRestPermissions.CONFIGURATIONS_READ)
    @Produces(MediaType.APPLICATION_JSON)
    @ApiOperation(value = "List all configurations")
    public ConfigurationListResponse listConfigurations(@ApiParam(name = "page") @QueryParam("page") @DefaultValue("1") int page,
                                                        @ApiParam(name = "per_page") @QueryParam("per_page") @DefaultValue("50") int perPage,
                                                        @ApiParam(name = "query") @QueryParam("query") @DefaultValue("") String query,
                                                        @ApiParam(name = "sort",
                                                                         value = "The field to sort the result on",
                                                                         required = true,
                                                                         allowableValues = "name,id,collector_id")
                                                                     @DefaultValue(Configuration.FIELD_NAME) @QueryParam("sort") String sort,
                                                        @ApiParam(name = "order", value = "The sort direction", allowableValues = "asc, desc")
                                                                     @DefaultValue("asc") @QueryParam("order") String order) {
        final SearchQuery searchQuery = searchQueryParser.parse(query);
        final PaginatedList configurations = this.configurationService.findPaginated(searchQuery, page, perPage, sort, order);
        final long total = this.configurationService.count();
        final List result = configurations.stream()
                .map(ConfigurationSummary::create)
                .collect(Collectors.toList());

        return ConfigurationListResponse.create(query, configurations.pagination(), total, sort, order, result);
    }

    @GET
    @Path("/uploads")
    @RequiresPermissions(SidecarRestPermissions.CONFIGURATIONS_READ)
    @Produces(MediaType.APPLICATION_JSON)
    @ApiOperation(value = "List all uploaded configurations")
    public CollectorUploadListResponse listImports(@ApiParam(name = "page") @QueryParam("page") @DefaultValue("1") int page) {
        // sort by creation date, latest on top of the list
        final PaginatedList uploads = this.importService.findPaginated(page, 10, "created", "desc");
        final long total = this.importService.count();
        final List result = new ArrayList<>(uploads);

        return CollectorUploadListResponse.create(uploads.pagination(), total, result);
    }

    @GET
    @Path("/{id}")
    @RequiresPermissions(SidecarRestPermissions.CONFIGURATIONS_READ)
    @Produces(MediaType.APPLICATION_JSON)
    @ApiOperation(value = "Show configuration details")
    public Configuration getConfigurations(@ApiParam(name = "id", required = true)
                                           @PathParam("id") String id) {
        final Configuration configuration = this.configurationService.find(id);
        if (configuration == null) {
            throw new NotFoundException("Could not find Configuration <" + id + ">.");
        }
        return configuration;
    }

    @GET
    @Path("/{id}/sidecars")
    @RequiresPermissions({SidecarRestPermissions.CONFIGURATIONS_READ, SidecarRestPermissions.SIDECARS_READ})
    @Produces(MediaType.APPLICATION_JSON)
    @ApiOperation(value = "Show sidecars using the given configuration")
    public ConfigurationSidecarsResponse getConfigurationSidecars(@ApiParam(name = "id", required = true)
                                                                      @PathParam("id") String id) {
        final Configuration configuration = this.configurationService.find(id);
        if (configuration == null) {
            throw new NotFoundException("Could not find Configuration <" + id + ">.");
        }
        final List sidecarsWithConfiguration = sidecarService.all().stream()
                .filter(sidecar -> isConfigurationAssignedToSidecar(configuration.id(), sidecar))
                .map(Sidecar::id)
                .collect(Collectors.toList());
        return ConfigurationSidecarsResponse.create(configuration.id(), sidecarsWithConfiguration);
    }

    @POST
    @Path("/validate")
    @NoAuditEvent("Validation only")
    @RequiresPermissions(SidecarRestPermissions.CONFIGURATIONS_READ)
    @Produces(MediaType.APPLICATION_JSON)
    @ApiOperation(value = "Validates configuration parameters")
    public ValidationResult validateConfiguration(@Valid @ApiParam("configuration") Configuration toValidate) {
        return validate(toValidate);
    }

    @GET
    @Timed
    @Path("/render/{sidecarId}/{configurationId}")
    @Produces(MediaType.APPLICATION_JSON)
    @RequiresPermissions(SidecarRestPermissions.CONFIGURATIONS_READ)
    @ApiOperation(value = "Render configuration template")
    public Response renderConfiguration(@Context HttpHeaders httpHeaders,
                                        @ApiParam(name = "sidecarId", required = true)
                                        @PathParam("sidecarId") String sidecarId,
                                        @ApiParam(name = "configurationId", required = true)
                                        @PathParam("configurationId") String configurationId) throws RenderTemplateException, JsonProcessingException {
        String ifNoneMatch = httpHeaders.getHeaderString("If-None-Match");
        boolean etagCached = false;
        Response.ResponseBuilder builder = Response.noContent();

        // check if client is up-to-date with a known valid etag
        if (ifNoneMatch != null) {
            EntityTag etag = new EntityTag(ifNoneMatch.replaceAll("\"", ""));
            if (etagService.configurationsAreCached(etag.toString())) {
                etagCached = true;
                builder = Response.notModified();
                builder.tag(etag);
            }
        }

        // fetch configuration from database if client is outdated
        if (!etagCached) {
            Sidecar sidecar = sidecarService.findByNodeId(sidecarId);
            if (sidecar == null) {
                throw new NotFoundException("Couldn't find Sidecar by ID: " + sidecarId);
            }
            Configuration configuration = configurationService.find(configurationId);
            if (configuration == null) {
                throw new NotFoundException("Couldn't find configuration by ID: " + configurationId);
            }

            Configuration collectorConfiguration = this.configurationService.renderConfigurationForCollector(sidecar, configuration);

            // add new etag to cache
            EntityTag collectorConfigurationEtag = etagService.buildEntityTagForResponse(collectorConfiguration);
            builder = Response.ok(collectorConfiguration);
            builder.tag(collectorConfigurationEtag);
            etagService.registerConfiguration(collectorConfigurationEtag.toString());
        }

        // set cache control
        CacheControl cacheControl = new CacheControl();
        cacheControl.setNoTransform(true);
        cacheControl.setPrivate(true);
        builder.cacheControl(cacheControl);

        return builder.build();
    }

    @POST
    @Path("/render/preview")
    @Produces(MediaType.APPLICATION_JSON)
    @RequiresPermissions(SidecarRestPermissions.CONFIGURATIONS_READ)
    @ApiOperation(value = "Render preview of a configuration template")
    @NoAuditEvent("this is not changing any data")
    public ConfigurationPreviewRenderResponse renderConfiguration(@ApiParam(name = "JSON body", required = true)
                                                                  @Valid @NotNull ConfigurationPreviewRequest request) {
        try {
            String preview = this.configurationService.renderPreview(request.template());
            return ConfigurationPreviewRenderResponse.create(preview);
        } catch (RenderTemplateException e) {
            throw new BadRequestException("Could not render template preview: " + e.getMessage());
        }
    }

    @POST
    @RequiresPermissions(SidecarRestPermissions.CONFIGURATIONS_CREATE)
    @Produces(MediaType.APPLICATION_JSON)
    @ApiOperation(value = "Create new configuration")
    @AuditEvent(type = SidecarAuditEventTypes.CONFIGURATION_CREATE)
    public Response createConfiguration(@ApiParam(name = "JSON body", required = true)
                                             @Valid @NotNull Configuration request) {
        final Configuration configuration = configurationFromRequest(null, request);
        final ValidationResult validationResult = validate(configuration);
        if (validationResult.failed()) {
            return Response.status(Response.Status.BAD_REQUEST).entity(validationResult).build();
        }

        final Configuration config = configurationService.save(configuration);
        if (!config.tags().isEmpty()) {
            final String os = Optional.ofNullable(collectorService.find(request.collectorId()))
                    .map(Collector::nodeOperatingSystem).orElse("");
            sidecarService.findByTagsAndOS(config.tags(), os)
                    .map(Sidecar::nodeId)
                    .forEach(etagService::invalidateRegistration);
        }

        return Response.ok().entity(config).build();
    }

    @POST
    @Path("/{id}/{name}")
    @RequiresPermissions({SidecarRestPermissions.CONFIGURATIONS_READ, SidecarRestPermissions.CONFIGURATIONS_CREATE})
    @ApiOperation(value = "Copy a configuration")
    @AuditEvent(type = SidecarAuditEventTypes.CONFIGURATION_CLONE)
    public Response copyConfiguration(@ApiParam(name = "id", required = true)
                                      @PathParam("id") String id,
                                      @PathParam("name") String name) throws NotFoundException {
        final Configuration configuration = configurationService.copyConfiguration(id, name);
        final ValidationResult validationResult = validate(configuration);
        if (validationResult.failed()) {
            return Response.status(Response.Status.BAD_REQUEST).entity(validationResult).build();
        }
        configurationService.save(configuration);
        return Response.accepted().build();
    }

    @PUT
    @Path("/{id}")
    @RequiresPermissions(SidecarRestPermissions.CONFIGURATIONS_UPDATE)
    @Produces(MediaType.APPLICATION_JSON)
    @ApiOperation(value = "Update a configuration")
    @AuditEvent(type = SidecarAuditEventTypes.CONFIGURATION_UPDATE)
    public Response updateConfiguration(@ApiParam(name = "id", required = true)
                                             @PathParam("id") String id,
                                             @ApiParam(name = "JSON body", required = true)
                                             @Valid @NotNull Configuration request) {
        final Configuration previousConfiguration = configurationService.find(id);
        if (previousConfiguration == null) {
            throw new NotFoundException("Could not find Configuration <" + id + ">.");
        }

        // Only allow changing the associated collector ID if the configuration is not in use
        if (!previousConfiguration.collectorId().equals(request.collectorId())) {
            if (isConfigurationInUse(id)) {
                throw new BadRequestException("Configuration still in use, cannot change collector type.");
            }
        }

        final Configuration updatedConfiguration = configurationFromRequest(id, request);
        final ValidationResult validationResult = validate(updatedConfiguration);
        if (validationResult.failed()) {
            return Response.status(Response.Status.BAD_REQUEST).entity(validationResult).build();
        }
        etagService.invalidateAllConfigurations();

        if (! previousConfiguration.tags().equals(updatedConfiguration.tags())) {
            final Set tags = Sets.symmetricDifference(previousConfiguration.tags(), updatedConfiguration.tags());
            final String os = Optional.ofNullable(collectorService.find(request.collectorId()))
                    .map(Collector::nodeOperatingSystem).orElse("");
            sidecarService.findByTagsAndOS(tags, os)
                    .map(Sidecar::nodeId)
                    .forEach(etagService::invalidateRegistration);
        }

        return Response.ok().entity(configurationService.save(updatedConfiguration)).build();
    }

    @DELETE
    @Path("/{id}")
    @RequiresPermissions(SidecarRestPermissions.CONFIGURATIONS_DELETE)
    @Produces(MediaType.APPLICATION_JSON)
    @ApiOperation(value = "Delete a configuration")
    @AuditEvent(type = SidecarAuditEventTypes.CONFIGURATION_DELETE)
    public Response deleteConfiguration(@ApiParam(name = "id", required = true)
                                        @PathParam("id") String id) {
        if (isConfigurationInUse(id)) {
            throw new BadRequestException("Configuration still in use, cannot delete.");
        }

        int deleted = configurationService.delete(id);
        if (deleted == 0) {
            return Response.notModified().build();
        }
        etagService.invalidateAllConfigurations();
        return Response.accepted().build();
    }

    private ValidationResult validate(Configuration toValidate) {
        final Optional configurationOptional;
        final Configuration configuration;
        final ValidationResult validation = new ValidationResult();

        configurationOptional = Optional.ofNullable(configurationService.findByName(toValidate.name()));
        if (configurationOptional.isPresent()) {
            configuration = configurationOptional.get();
            if (!configuration.id().equals(toValidate.id())) {
                // a configuration exists with a different id, so the name is already in use, fail validation
                validation.addError("name", "Configuration \"" + toValidate.name() + "\" already exists");
            }
        }

        if (toValidate.name().isEmpty()) {
            validation.addError("name", "Configuration name cannot be empty.");
        } else if (!VALID_NAME_PATTERN.matcher(toValidate.name()).matches()) {
                validation.addError("name", "Configuration name can not include the following characters: ; * ? \" < > | &");
        }

        if (toValidate.collectorId().isEmpty()) {
            validation.addError("collector_id", "Associated collector ID cannot be empty.");
        }

        if (toValidate.color().isEmpty()) {
            validation.addError("color", "Collector color cannot be empty.");
        }

        if (toValidate.template().isEmpty()) {
            validation.addError("template", "Collector template cannot be empty.");
        }

        try {
            this.configurationService.renderPreview(toValidate.template());
        } catch (RenderTemplateException e) {
            validation.addError("template", "Template error: " + e.getMessage());
        }

        return validation;
    }

    private boolean isConfigurationInUse(String configurationId) {
        return sidecarService.all().stream().anyMatch(sidecar -> isConfigurationAssignedToSidecar(configurationId, sidecar));
    }

    private boolean isConfigurationAssignedToSidecar(String configurationId, Sidecar sidecar) {
        final List assignments = firstNonNull(sidecar.assignments(), new ArrayList<>());
        return assignments.stream().anyMatch(assignment -> assignment.configurationId().equals(configurationId));
    }

    private Configuration configurationFromRequest(String id, Configuration request) {
        Configuration configuration;
        if (id == null) {
            configuration = configurationService.fromRequest(request);
        } else {
            configuration = configurationService.fromRequest(id, request);
        }
        return configuration;
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy