diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 100af8725aa5..beb9c103d085 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -49,6 +49,7 @@ https://github.com/elastic/beats/compare/v7.0.0-alpha2...master[Check the HEAD d *Affecting all Beats* - Enforce validation for the Central Management access token. {issue}9621[9621] +- Allow to unenroll a Beat from the UI. {issue}9452[9452] *Auditbeat* diff --git a/x-pack/libbeat/management/api/configuration.go b/x-pack/libbeat/management/api/configuration.go index b5a48747b2ae..3dd851391f86 100644 --- a/x-pack/libbeat/management/api/configuration.go +++ b/x-pack/libbeat/management/api/configuration.go @@ -9,6 +9,8 @@ import ( "net/http" "reflect" + "errors" + "github.com/elastic/beats/libbeat/common/reload" "github.com/gofrs/uuid" @@ -16,6 +18,8 @@ import ( "github.com/elastic/beats/libbeat/common" ) +var errConfigurationNotFound = errors.New("no configuration found, you need to enroll your Beat") + // ConfigBlock stores a piece of config from central management type ConfigBlock struct { Raw map[string]interface{} @@ -58,7 +62,11 @@ func (c *Client) Configuration(accessToken string, beatUUID uuid.UUID, configOK } `json:"configuration_blocks"` }{} url := fmt.Sprintf("/api/beats/agent/%s/configuration?validSetting=%t", beatUUID, configOK) - _, err := c.request("GET", url, nil, headers, &resp) + statusCode, err := c.request("GET", url, nil, headers, &resp) + if statusCode == http.StatusNotFound { + return nil, errConfigurationNotFound + } + if err != nil { return nil, err } @@ -88,3 +96,8 @@ func ConfigBlocksEqual(a, b ConfigBlocks) bool { return reflect.DeepEqual(a, b) } + +// IsConfigurationNotFound returns true if the configuration was not found. +func IsConfigurationNotFound(err error) bool { + return err == errConfigurationNotFound +} diff --git a/x-pack/libbeat/management/api/configuration_test.go b/x-pack/libbeat/management/api/configuration_test.go index f6a3d03aa88d..4371e00238fe 100644 --- a/x-pack/libbeat/management/api/configuration_test.go +++ b/x-pack/libbeat/management/api/configuration_test.go @@ -169,3 +169,24 @@ func TestConfigBlocksEqual(t *testing.T) { }) } } + +func TestUnEnroll(t *testing.T) { + beatUUID, err := uuid.NewV4() + if err != nil { + t.Fatalf("error while generating Beat UUID: %v", err) + } + + server, client := newServerClientPair(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check correct path is used + assert.Equal(t, "/api/beats/agent/"+beatUUID.String()+"/configuration", r.URL.Path) + + // Check enrollment token is correct + assert.Equal(t, "thisismyenrollmenttoken", r.Header.Get("kbn-beats-access-token")) + + http.NotFound(w, r) + })) + defer server.Close() + + _, err = client.Configuration("thisismyenrollmenttoken", beatUUID, false) + assert.True(t, IsConfigurationNotFound(err)) +} diff --git a/x-pack/libbeat/management/cache.go b/x-pack/libbeat/management/cache.go index 530e523ddf00..abf7ba218adf 100644 --- a/x-pack/libbeat/management/cache.go +++ b/x-pack/libbeat/management/cache.go @@ -61,3 +61,8 @@ func (c *Cache) Save() error { // move temporary file into final location return file.SafeFileRotate(path, tempFile) } + +// HasConfig returns true if configs are cached. +func (c *Cache) HasConfig() bool { + return len(c.Configs) > 0 +} diff --git a/x-pack/libbeat/management/cache_test.go b/x-pack/libbeat/management/cache_test.go new file mode 100644 index 000000000000..364618ad5a02 --- /dev/null +++ b/x-pack/libbeat/management/cache_test.go @@ -0,0 +1,38 @@ +// 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 management + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/elastic/beats/x-pack/libbeat/management/api" +) + +func TestHasConfig(t *testing.T) { + tests := map[string]struct { + configs api.ConfigBlocks + expected bool + }{ + "with config": { + configs: api.ConfigBlocks{ + api.ConfigBlocksWithType{Type: "metricbeat "}, + }, + expected: true, + }, + "without config": { + configs: api.ConfigBlocks{}, + expected: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + cache := Cache{Configs: test.configs} + assert.Equal(t, test.expected, cache.HasConfig()) + }) + } +} diff --git a/x-pack/libbeat/management/manager.go b/x-pack/libbeat/management/manager.go index 81aff95bc5d9..b94179ca4198 100644 --- a/x-pack/libbeat/management/manager.go +++ b/x-pack/libbeat/management/manager.go @@ -174,8 +174,17 @@ func (cm *ConfigManager) worker() { func (cm *ConfigManager) fetch() bool { cm.logger.Debug("Retrieving new configurations from Kibana") configs, err := cm.client.Configuration(cm.config.AccessToken, cm.beatUUID, cm.cache.ConfigOK) + + if api.IsConfigurationNotFound(err) { + if cm.cache.HasConfig() { + cm.logger.Error("Disabling all running configuration because no configurations were found for this Beat, the endpoint returned a 404 or the beat is not enrolled with central management") + cm.cache.Configs = api.ConfigBlocks{} + } + return true + } + if err != nil { - cm.logger.Errorf("error retriving new configurations, will use cached ones: %s", err) + cm.logger.Errorf("error retrieving new configurations, will use cached ones: %s", err) return false } diff --git a/x-pack/libbeat/management/manager_test.go b/x-pack/libbeat/management/manager_test.go index 39c3a1f9e51c..c4f3f4987f94 100644 --- a/x-pack/libbeat/management/manager_test.go +++ b/x-pack/libbeat/management/manager_test.go @@ -165,3 +165,71 @@ func TestRemoveItems(t *testing.T) { manager.Stop() os.Remove(paths.Resolve(paths.Data, "management.yml")) } + +func responseText(s string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, s) + } +} + +func TestUnEnroll(t *testing.T) { + registry := reload.NewRegistry() + id, err := uuid.NewV4() + if err != nil { + t.Fatalf("error while generating id: %v", err) + } + accessToken := "footoken" + reloadable := reloadable{ + reloaded: make(chan *reload.ConfigWithMeta, 1), + } + registry.MustRegister("test.blocks", &reloadable) + + mux := http.NewServeMux() + i := 0 + responses := []http.HandlerFunc{ // Initial load + responseText(`{"configuration_blocks":[{"type":"test.blocks","config":{"module":"apache2"}}]}`), + http.NotFound, + } + mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + responses[i](w, r) + i++ + })) + + server := httptest.NewServer(mux) + + c, err := api.ConfigFromURL(server.URL) + if err != nil { + t.Fatal(err) + } + + config := &Config{ + Enabled: true, + Period: 100 * time.Millisecond, + Kibana: c, + AccessToken: accessToken, + } + + manager, err := NewConfigManagerWithConfig(config, registry, id) + if err != nil { + t.Fatal(err) + } + + manager.Start() + + // On first reload we will get apache2 module + config1 := <-reloadable.reloaded + assert.Equal(t, &reload.ConfigWithMeta{ + Config: common.MustNewConfigFrom(map[string]interface{}{ + "module": "apache2", + }), + }, config1) + + // Get a nil config, even if the block is not part of the payload + config2 := <-reloadable.reloaded + var nilConfig *reload.ConfigWithMeta + assert.Equal(t, nilConfig, config2) + + // Cleanup + manager.Stop() + os.Remove(paths.Resolve(paths.Data, "management.yml")) +}