diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0c061c06c56..a309f5c8b47 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,7 +8,7 @@ updates: labels: - automation - skip-changelog - - Team:Elastic-Agent + - Team:Elastic-Agent-Control-Plane groups: otel-dependencies: exclude-patterns: @@ -24,3 +24,15 @@ updates: reviewers: - "elastic/elastic-agent-control-plane" open-pull-requests-limit: 10 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + labels: + - automation + - skip-changelog + - Team:Elastic-Agent-Control-Plane + reviewers: + - "elastic/elastic-agent-control-plane" + open-pull-requests-limit: 10 diff --git a/.github/workflows/bump-agent-versions.yml b/.github/workflows/bump-agent-versions.yml index b517178d0f3..31cbb4f6e7d 100644 --- a/.github/workflows/bump-agent-versions.yml +++ b/.github/workflows/bump-agent-versions.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: 1.21 diff --git a/.github/workflows/bump-golang.yml b/.github/workflows/bump-golang.yml index ebeb1a74026..2cc62fe2b6b 100644 --- a/.github/workflows/bump-golang.yml +++ b/.github/workflows/bump-golang.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Updatecli in the runner - uses: updatecli/updatecli-action@9a37c7e35598d7b37d8e7568b40ed9538112be01 # v0.76.1 + uses: updatecli/updatecli-action@fa41baa922561b436c449de1a0bd1f5bd768248c # v0.76.1 - name: Run Updatecli in Apply mode run: updatecli apply --config .github/updatecli-bump-golang.yml diff --git a/.github/workflows/fragment-in-pr.yml b/.github/workflows/fragment-in-pr.yml index ea98842804c..def5e8a1cd4 100644 --- a/.github/workflows/fragment-in-pr.yml +++ b/.github/workflows/fragment-in-pr.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 + - uses: actions/setup-go@v5 with: go-version-file: .go-version - name: check pr-has-fragment diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 33cac15330a..c5b63874dfe 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -20,12 +20,12 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 + - uses: actions/setup-go@v5 with: go-version-file: .go-version - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v6 with: # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version version: v1.55.2 @@ -39,8 +39,5 @@ jobs: # into fixing all linting issues in the whole file instead. args: --timeout=30m --whole-files - # Optional: if set to true then the action will use pre-installed Go. - skip-go-installation: true - # Optional: show only new issues if it's a pull request. The default value is `false`. only-new-issues: true diff --git a/.github/workflows/post-dependabot.yml b/.github/workflows/post-dependabot.yml index 8542b41ae9c..38575166533 100644 --- a/.github/workflows/post-dependabot.yml +++ b/.github/workflows/post-dependabot.yml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version-file: .go-version diff --git a/changelog/fragments/1714505172-Send-fleet-server-component-output-under-bootstrap-key.yaml b/changelog/fragments/1714505172-Send-fleet-server-component-output-under-bootstrap-key.yaml new file mode 100644 index 00000000000..0e439823baa --- /dev/null +++ b/changelog/fragments/1714505172-Send-fleet-server-component-output-under-bootstrap-key.yaml @@ -0,0 +1,36 @@ +# Kind can be one of: +# - breaking-change: a change to previously-documented behavior +# - deprecation: functionality that is being removed in a later release +# - bug-fix: fixes a problem in a previous version +# - enhancement: extends functionality but does not break or fix existing behavior +# - feature: new functionality +# - known-issue: problems that we are aware of in a given version +# - security: impacts on the security of a product or a user’s deployment. +# - upgrade: important information for someone upgrading from a prior version +# - other: does not fit into any of the other categories +kind: enhancement + +# Change summary; a 80ish characters long description of the change. +summary: Fleet Server component now uses policy output configuration to communicate with Elasticsearch + +# Long description; in case the summary is not enough to describe the change +# this field accommodate a description without length limits. +# NOTE: This field will be rendered only for breaking-change and known-issue kinds at the moment. +description: | + Alter how elatic-agent passes the fleet-server output component so that the policy's output is used. + In cases where fleet-server encounters an error when trying to use the policy's output it will use + the configuration specified during enrollment as a fallback. In cases where it uses the fallback + the policy's output is periodically retested and used if it's successful. + +# Affected component; a word indicating the component this changeset affects. +component: + +# PR URL; optional; the PR number that added the changeset. +# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added. +# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number. +# Please provide it if you are adding a fragment for a different PR. +pr: https://github.com/elastic/elastic-agent/pull/4643 + +# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of). +# If not present is automatically filled by the tooling with the issue linked to the PR number. +issue: https://github.com/elastic/elastic-agent/issue/2784 diff --git a/docs/fleet-server-bootstrap.asciidoc b/docs/fleet-server-bootstrap.asciidoc index 1dc818f38d2..239122953ff 100644 --- a/docs/fleet-server-bootstrap.asciidoc +++ b/docs/fleet-server-bootstrap.asciidoc @@ -88,3 +88,61 @@ its API key to use for communication. The new `fleet.yml` still includes the `fl but this time the `fleet.server.bootstrap: false` is set. . `enroll` command then either restarts the running Elatic Agent daemon if one was running from Step 2, or it stops the spawned `run` subprocess and returns. + +=== Elasticsearch output + +The options passed that are used to specify fleet-server initially connects to elasticsearch are: + +- `--fleet-server-es` +- `--fleet-server-es-ca` +- `--fleet-server-es-ca-trusted-fingerprint` +- `--fleet-server-es-insecure` +- `--fleet-server-es-cert` +- `--fleet-server-es-cert-key` +- `--fleet-server-es-service-token` +- `--fleet-server-es-service-token-path` +- `--proxy-url` +- `--proxy-disabled` +- `--proxy-header` + +These options are always passed under a `bootstrap` attribute in the output when elastic-agent is passing config to fleet-server. +When the fleet-server recieves an output block, it will inject any keys that are missing from the top level output but are specified in the `bootstrap` block +After injecting the keys from bootstrap, fleet-server will test connecting the Elasticsearch with the output. +If the test fails, the values under the `bootstrap` attribute are used as the output and fleet-server will periodically retest the output in case the error was caused by a temporary network issue. +Note that if `--fleet-server-es-insecure` is specified, and the output in the policy contains one or more CA, or a CA fingerprint, the `--fleet-server-es-insecure` flag is ignored. + +An example of this sequence is sequence is: + +1) elastic-agent starts fleet-server and sends an output block that looks similar to: +```yaml +output: + bootstrap: + service_token: VALUE + hosts: ["HOST"] +``` + +2) fleet-server injects attributes into the top level from bootstrap if they are missing, resulting in +```yaml +output: + service_token: VALUE + hosts: ["HOST"] +``` + +3) fleet-server connects to Elasticsearch with the output block +4) elastic-agent enrolls and recieves its policy +5) elastic-agent sends configuration generated from the policy to fleet-server, this may result in the output as follows: +```yaml +output: + hosts: ["HOST", "HOST2"] + bootstrap: + service_token: VALUE + hosts: ["HOST"] +``` + +6) fleet-server will inject missing values resulting in: +```yaml +output: + service_token: VALUE + hosts: ["HOST", "HOST2"] +``` + 7) fleet-server tests and uses the resulting output block. diff --git a/internal/pkg/agent/application/fleet_server_bootstrap.go b/internal/pkg/agent/application/fleet_server_bootstrap.go index 51ac633e972..3483afa789d 100644 --- a/internal/pkg/agent/application/fleet_server_bootstrap.go +++ b/internal/pkg/agent/application/fleet_server_bootstrap.go @@ -65,10 +65,13 @@ func FleetServerComponentModifier(serverCfg *configuration.FleetServerConfig) co } else { for j, unit := range comp.Units { if unit.Type == client.UnitTypeOutput && unit.Config.Type == elasticsearch { - unitCfgMap, err := toMapStr(unit.Config.Source.AsMap(), &serverCfg.Output.Elasticsearch) + unitCfgMap, err := toMapStr(unit.Config.Source.AsMap()) if err != nil { return nil, err } + if err := addBootstrapCfg(unitCfgMap, &serverCfg.Output.Elasticsearch); err != nil { + return nil, err + } fixOutputMap(unitCfgMap) unitCfg, err := component.ExpectedConfig(unitCfgMap) if err != nil { @@ -100,6 +103,19 @@ func FleetServerComponentModifier(serverCfg *configuration.FleetServerConfig) co } } +// addBootrapCfg will transform the passed configuration.Elasticsearch to a map and add it to dst under the bootstrap key. +func addBootstrapCfg(dst map[string]interface{}, es *configuration.Elasticsearch) error { + if es == nil { + return fmt.Errorf("fleet-server bootstrap output config is undefined") + } + mp, err := toMapStr(es) + if err != nil { + return err + } + dst["bootstrap"] = mp + return nil +} + // InjectFleetConfigComponentModifier The modifier that injects the fleet configuration for the components // that need to be able to connect to fleet server. func InjectFleetConfigComponentModifier(fleetCfg *configuration.FleetAgentConfig, agentInfo info.Agent) coordinator.ComponentsModifier { diff --git a/internal/pkg/agent/application/fleet_server_bootstrap_test.go b/internal/pkg/agent/application/fleet_server_bootstrap_test.go index 1870b3dada7..c0b23a13830 100644 --- a/internal/pkg/agent/application/fleet_server_bootstrap_test.go +++ b/internal/pkg/agent/application/fleet_server_bootstrap_test.go @@ -74,6 +74,87 @@ func TestFleetServerComponentModifier_NoServerConfig(t *testing.T) { } } +func TestFleetServerComponentModifier(t *testing.T) { + tests := []struct { + name string + source map[string]interface{} + expect map[string]interface{} + }{{ + name: "empty output component", + source: map[string]interface{}{}, + expect: map[string]interface{}{ + "bootstrap": map[string]interface{}{ + "protocol": "https", + "hosts": []interface{}{"elasticsearch:9200"}, + "service_token": "example-token", + }, + }, + }, { + name: "output component provided", + source: map[string]interface{}{ + "protocol": "http", + "hosts": []interface{}{"elasticsearch:9200", "host:9200"}, + }, + expect: map[string]interface{}{ + "protocol": "http", + "hosts": []interface{}{"elasticsearch:9200", "host:9200"}, + "bootstrap": map[string]interface{}{ + "protocol": "https", + "hosts": []interface{}{"elasticsearch:9200"}, + "service_token": "example-token", + }, + }, + }} + cfg := &configuration.FleetServerConfig{ + Output: configuration.FleetServerOutputConfig{ + Elasticsearch: configuration.Elasticsearch{ + Protocol: "https", + Hosts: []string{"elasticsearch:9200"}, + ServiceToken: "example-token", + }, + }, + } + modifier := FleetServerComponentModifier(cfg) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + src, err := structpb.NewStruct(tc.source) + require.NoError(t, err) + comps, err := modifier([]component.Component{{ + InputSpec: &component.InputRuntimeSpec{ + InputType: "fleet-server", + }, + Units: []component.Unit{{ + Type: client.UnitTypeOutput, + Config: &proto.UnitExpectedConfig{ + Type: "elasticsearch", + Source: src, + }, + }}, + }}, nil) + require.NoError(t, err) + + require.Len(t, comps, 1) + require.Len(t, comps[0].Units, 1) + res := comps[0].Units[0].Config.Source.AsMap() + for k, v := range tc.expect { + val, ok := res[k] + require.Truef(t, ok, "expected %q to be in output unit config", k) + if mp, ok := v.(map[string]interface{}); ok { + rMap, ok := val.(map[string]interface{}) + require.Truef(t, ok, "expected %q to be map[string]interface{} was %T", k, val) + for kk, vv := range mp { + assert.Contains(t, rMap, kk) + assert.Equal(t, rMap[kk], vv) + } + } else { + assert.Equal(t, v, val) + } + } + }) + } +} + func TestInjectFleetConfigComponentModifier(t *testing.T) { fleetConfig := &configuration.FleetAgentConfig{ Enabled: true, diff --git a/internal/pkg/agent/configuration/fleet_server.go b/internal/pkg/agent/configuration/fleet_server.go index 7e1290df758..f71e753f432 100644 --- a/internal/pkg/agent/configuration/fleet_server.go +++ b/internal/pkg/agent/configuration/fleet_server.go @@ -32,7 +32,14 @@ type FleetServerOutputConfig struct { Elasticsearch Elasticsearch `config:"elasticsearch" yaml:"elasticsearch"` } -// Elasticsearch is the configuration for elasticsearch. +// Elasticsearch is the configuration for fleet-server's connection to elasticsearch. +// Note that these keys may be injected into policy output by fleet-server. +// The following TLS options may be set in bootstrap: +// - VerificationMode +// - CAs +// - CATrustedFingerprint +// - CertificateConfig.Certificate AND CertificateConfig.Key +// If an attribute is added to this struct, or another TLS attribute is passed ensure that it is handled as part of the bootstrap config handler in fleet-server/internal/pkg/server/agent.go type Elasticsearch struct { Protocol string `config:"protocol" yaml:"protocol"` Hosts []string `config:"hosts" yaml:"hosts"` diff --git a/internal/pkg/otel/run.go b/internal/pkg/otel/run.go index b86bd3f663a..fc7cc5f4b45 100644 --- a/internal/pkg/otel/run.go +++ b/internal/pkg/otel/run.go @@ -53,18 +53,19 @@ func newSettings(version string, configPaths []string) (*otelcol.CollectorSettin Description: buildDescription, Version: version, } - converterSet := confmap.ConverterSettings{} configProviderSettings := otelcol.ConfigProviderSettings{ ResolverSettings: confmap.ResolverSettings{ URIs: configPaths, - Providers: makeMapProvidersMap( - fileprovider.NewWithSettings(confmap.ProviderSettings{}), - envprovider.NewWithSettings(confmap.ProviderSettings{}), - yamlprovider.NewWithSettings(confmap.ProviderSettings{}), - httpprovider.NewWithSettings(confmap.ProviderSettings{}), - httpsprovider.NewWithSettings(confmap.ProviderSettings{}), - ), - Converters: []confmap.Converter{expandconverter.New(converterSet)}, + ProviderFactories: []confmap.ProviderFactory{ + fileprovider.NewFactory(), + envprovider.NewFactory(), + yamlprovider.NewFactory(), + httpprovider.NewFactory(), + httpsprovider.NewFactory(), + }, + ConverterFactories: []confmap.ConverterFactory{ + expandconverter.NewFactory(), + }, }, } provider, err := otelcol.NewConfigProvider(configProviderSettings) @@ -81,11 +82,3 @@ func newSettings(version string, configPaths []string) (*otelcol.CollectorSettin DisableGracefulShutdown: true, }, nil } - -func makeMapProvidersMap(providers ...confmap.Provider) map[string]confmap.Provider { - ret := make(map[string]confmap.Provider, len(providers)) - for _, provider := range providers { - ret[provider.Scheme()] = provider - } - return ret -} diff --git a/internal/pkg/otel/run_test.go b/internal/pkg/otel/run_test.go new file mode 100644 index 00000000000..a8b31613baf --- /dev/null +++ b/internal/pkg/otel/run_test.go @@ -0,0 +1,96 @@ +// 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 otel + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/otelcol" +) + +func TestStartCollector(t *testing.T) { + testCases := []struct { + configFile string + expectedErrorMessage string + }{ + { + configFile: "all-components.yml", + expectedErrorMessage: "", // empty string means no error is expected + }, + { + configFile: "nonexistent-component.yml", + expectedErrorMessage: `error decoding 'extensions': unknown type: "zpages"`, + }, + } + + for _, tc := range testCases { + t.Run(tc.configFile, func(t *testing.T) { + configFiles := getConfigFiles(tc.configFile) + settings, err := newSettings("test", configFiles) + require.NoError(t, err) + + collector, err := otelcol.NewCollector(*settings) + require.NoError(t, err) + require.NotNil(t, collector) + + wg := startCollector(context.Background(), t, collector, tc.expectedErrorMessage) + + if tc.expectedErrorMessage == "" { + assert.Eventually(t, func() bool { + return otelcol.StateRunning == collector.GetState() + }, 2*time.Second, 200*time.Millisecond) + } + collector.Shutdown() + wg.Wait() + assert.Equal(t, otelcol.StateClosed, collector.GetState()) + }) + } +} + +// getConfigFiles returns a collection of config file paths for the collector to use. +// In the simplest scenario, the collection will contains only one path. +// In case there is an operating system-specific override file found, it will be added to the collection. +// E.g. if the input file name is `all-components.yml` and a file named `all-components.windows.yml` exists, +// the config path collection will have two elements on Windows, and only one element on other OSes. +// Use `darwin` for MacOS, `linux` for Linux and `windows` for Windows. +func getConfigFiles(configFileName string) []string { + // Add base file to the collection. + baseFilePath := filepath.Join(".", "testdata", configFileName) + configFiles := []string{"file:" + baseFilePath} + + // Check if an os-specific override file exists; if it does, add it to the collection. + overrideFileName := strings.TrimSuffix(configFileName, filepath.Ext(configFileName)) + "." + runtime.GOOS + filepath.Ext(configFileName) + overrideFilePath := filepath.Join(".", "testdata", overrideFileName) + if _, err := os.Stat(overrideFilePath); err == nil { + configFiles = append(configFiles, "file:"+overrideFilePath) + } + + return configFiles +} + +func startCollector(ctx context.Context, t *testing.T, col *otelcol.Collector, expectedErrorMessage string) *sync.WaitGroup { + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + err := col.Run(ctx) + if expectedErrorMessage == "" { + require.NoError(t, err) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedErrorMessage) + } + }() + return wg +} diff --git a/internal/pkg/otel/testdata/all-components.windows.yml b/internal/pkg/otel/testdata/all-components.windows.yml new file mode 100644 index 00000000000..d5348d7171d --- /dev/null +++ b/internal/pkg/otel/testdata/all-components.windows.yml @@ -0,0 +1,3 @@ +exporters: + file: + path: ${env:TMP}\file-exporter-output.json \ No newline at end of file diff --git a/internal/pkg/otel/testdata/all-components.yml b/internal/pkg/otel/testdata/all-components.yml new file mode 100644 index 00000000000..32b0571e7d9 --- /dev/null +++ b/internal/pkg/otel/testdata/all-components.yml @@ -0,0 +1,87 @@ +exporters: + debug: + elasticsearch: + endpoints: + - localhost:9200 + file: + path: /tmp/file-exporter-output.json + otlp: + endpoint: localhots:4317 + +extensions: + memory_limiter: + check_interval: 5s + limit_mib: 100 + +processors: + attributes: + actions: + - action: insert + key: key1 + value: value1 + batch: + filter: + resource: + attributes: + - action: insert + key: key1 + value: value1 + transform: + +receivers: + filelog: + include: + - /filelog/path + otlp: + protocols: + grpc: + http: + +service: + extensions: + - memory_limiter + pipelines: + logs: + exporters: + - debug + - elasticsearch + - file + - otlp + processors: + - attributes + - batch + - filter + - resource + - transform + receivers: + - filelog + - otlp + + metrics: + exporters: + - debug + - file + - otlp + processors: + - attributes + - batch + - filter + - resource + - transform + receivers: + - otlp + + traces: + exporters: + - debug + - elasticsearch + - file + - otlp + processors: + - attributes + - batch + - filter + - resource + - transform + receivers: + - otlp diff --git a/internal/pkg/otel/testdata/nonexistent-component.yml b/internal/pkg/otel/testdata/nonexistent-component.yml new file mode 100644 index 00000000000..420fcfef4b3 --- /dev/null +++ b/internal/pkg/otel/testdata/nonexistent-component.yml @@ -0,0 +1,20 @@ +receivers: + otlp: + protocols: + grpc: + +exporters: + debug: + +extensions: + zpages: + +service: + extensions: + - zpages + pipelines: + logs: + exporters: + - debug + receivers: + - otlp diff --git a/internal/pkg/otel/testdata/otel.yml b/internal/pkg/otel/testdata/otel.yml index 1d5daf48a9c..bba68b878ca 100644 --- a/internal/pkg/otel/testdata/otel.yml +++ b/internal/pkg/otel/testdata/otel.yml @@ -22,4 +22,4 @@ service: receivers: [filelog] processors: [resource] exporters: - - debug \ No newline at end of file + - debug diff --git a/pkg/testing/fixture_install.go b/pkg/testing/fixture_install.go index 385a967aad7..05e40cd2b38 100644 --- a/pkg/testing/fixture_install.go +++ b/pkg/testing/fixture_install.go @@ -56,6 +56,30 @@ func (e EnrollOpts) toCmdArgs() []string { return args } +type FleetBootstrapOpts struct { + ESHost string // --fleet-server-es + ServiceToken string // --fleet-server-service-token + Policy string // --fleet-server-policy + Port int // --fleet-server-port +} + +func (f FleetBootstrapOpts) toCmdArgs() []string { + var args []string + if f.ESHost != "" { + args = append(args, "--fleet-server-es", f.ESHost) + } + if f.ServiceToken != "" { + args = append(args, "--fleet-server-service-token", f.ServiceToken) + } + if f.Policy != "" { + args = append(args, "--fleet-server-policy", f.Policy) + } + if f.Port > 0 { + args = append(args, "--fleet-server-port", fmt.Sprintf("%d", f.Port)) + } + return args +} + // InstallOpts specifies the options for the install command type InstallOpts struct { BasePath string // --base-path @@ -68,6 +92,7 @@ type InstallOpts struct { Privileged bool // inverse of --unprivileged (as false is the default) EnrollOpts + FleetBootstrapOpts } func (i InstallOpts) toCmdArgs(operatingSystem string) ([]string, error) { @@ -95,6 +120,7 @@ func (i InstallOpts) toCmdArgs(operatingSystem string) ([]string, error) { } args = append(args, i.EnrollOpts.toCmdArgs()...) + args = append(args, i.FleetBootstrapOpts.toCmdArgs()...) return args, nil } diff --git a/pkg/testing/tools/estools/elasticsearch.go b/pkg/testing/tools/estools/elasticsearch.go index fd0f6a68d3c..b722350c411 100644 --- a/pkg/testing/tools/estools/elasticsearch.go +++ b/pkg/testing/tools/estools/elasticsearch.go @@ -13,6 +13,8 @@ import ( "strconv" "strings" + "github.com/google/uuid" + "github.com/elastic/elastic-agent-libs/mapstr" "github.com/elastic/elastic-transport-go/v8/elastictransport" "github.com/elastic/go-elasticsearch/v8/esapi" @@ -201,6 +203,35 @@ func CreateAPIKey(ctx context.Context, client elastictransport.Interface, req AP return parsed, nil } +func CreateServiceToken(ctx context.Context, client elastictransport.Interface, service string) (string, error) { + req := esapi.SecurityCreateServiceTokenRequest{ + Namespace: "elastic", + Service: service, + Name: uuid.New().String(), // FIXME(michel-laterman): We need to specify a random name until an upstream issue is fixed: https://github.com/elastic/go-elasticsearch/issues/861 + } + resp, err := req.Do(ctx, client) + if err != nil { + return "", fmt.Errorf("error creating service token: %w", err) + } + defer resp.Body.Close() + resultBuf, err := handleResponseRaw(resp) + if err != nil { + return "", fmt.Errorf("error handling HTTP response: %w", err) + } + + var parsed struct { + Token struct { + Value string `json:"value"` + } `json:"token"` + } + err = json.Unmarshal(resultBuf, &parsed) + if err != nil { + return "", fmt.Errorf("error unmarshaling json response: %w", err) + } + return parsed.Token.Value, nil + +} + // FindMatchingLogLines returns any logs with message fields that match the given line func FindMatchingLogLines(ctx context.Context, client elastictransport.Interface, namespace, line string) (Documents, error) { return FindMatchingLogLinesWithContext(ctx, client, namespace, line) diff --git a/testing/integration/fleet-server.json b/testing/integration/fleet-server.json new file mode 100644 index 00000000000..1daaa97c8dd --- /dev/null +++ b/testing/integration/fleet-server.json @@ -0,0 +1,38 @@ +{ + "id": "3434b864-d135-4d03-a944-29ee7ad61ddd", + "version": "WzMwNywxXQ==", + "name": "fleet_server-1", + "namespace": "", + "description": "", + "package": { + "name": "fleet_server", + "title": "Fleet Server", + "version": "1.5.0" + }, + "enabled": true, + "inputs": [ + { + "type": "fleet-server", + "policy_template": "fleet_server", + "enabled": true, + "streams": [], + "vars": { + "max_agents": { + "type": "integer" + }, + "max_connections": { + "type": "integer" + }, + "custom": { + "value": "", + "type": "yaml" + } + } + } + ], + "revision": 1, + "created_at": "2024-05-22T16:13:09.177Z", + "created_by": "system", + "updated_at": "2024-05-22T16:13:09.177Z", + "updated_by": "system" +} diff --git a/testing/integration/fleetserver_test.go b/testing/integration/fleetserver_test.go new file mode 100644 index 00000000000..0c3c377c612 --- /dev/null +++ b/testing/integration/fleetserver_test.go @@ -0,0 +1,166 @@ +// 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. + +//go:build integration + +package integration + +import ( + "context" + "crypto/tls" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/elastic/elastic-agent-libs/kibana" + atesting "github.com/elastic/elastic-agent/pkg/testing" + "github.com/elastic/elastic-agent/pkg/testing/define" + "github.com/elastic/elastic-agent/pkg/testing/tools" + "github.com/elastic/elastic-agent/pkg/testing/tools/estools" + "github.com/elastic/elastic-agent/pkg/testing/tools/testcontext" +) + +func fleetPolicy() kibana.AgentPolicy { + policyUUID := uuid.New().String() + + return kibana.AgentPolicy{ + ID: "test-fleet-policy-" + policyUUID, + Name: "test-fleet-policy-" + policyUUID, + Namespace: "default", + Description: "Test fleet policy " + policyUUID, + } +} + +func TestInstallFleetServerBootstrap(t *testing.T) { + info := define.Require(t, define.Requirements{ + Group: FleetPrivileged, + Stack: &define.Stack{}, + Sudo: true, + Local: false, + }) + + ctx, cancel := testcontext.WithDeadline(t, context.Background(), time.Now().Add(10*time.Minute)) + defer cancel() + + // Get path to Elastic Agent executable + fixture, err := define.NewFixtureFromLocalBuild(t, define.Version()) + require.NoError(t, err) + err = fixture.Prepare(ctx) + require.NoError(t, err) + + t.Log("Ensure base path is clean") + var defaultBasePath string + switch runtime.GOOS { + case "darwin": + defaultBasePath = `/Library` + case "linux": + defaultBasePath = `/opt` + case "windows": + defaultBasePath = `C:\Program Files` + } + + topPath := filepath.Join(defaultBasePath, "Elastic", "Agent") + err = os.RemoveAll(topPath) + require.NoError(t, err, "failed to remove %q. The test requires this path not to exist.") + + t.Log("Create fleet-server policy...") + policyResp, err := info.KibanaClient.CreatePolicy(ctx, fleetPolicy()) + require.NoError(t, err, "failed creating policy") + policy := policyResp.AgentPolicy + _, err = tools.InstallPackageFromDefaultFile(ctx, info.KibanaClient, "fleet-server", "1.5.0", "fleet-server.json", uuid.New().String(), policy.ID) + require.NoError(t, err, "failed creating fleet-server integration") + + t.Log("Get fleet-server service token...") + serviceToken, err := estools.CreateServiceToken(ctx, info.ESClient, "fleet-server") + require.NoError(t, err, "failed creating service token") + + esHost, ok := os.LookupEnv("ELASTICSEARCH_HOST") + require.True(t, ok, "environment var ELASTICSEARCH_HOST is empty") + u, err := url.Parse(esHost) + require.NoError(t, err, "could not parse %q as a URL", esHost) + if u.Port() == "" { + switch u.Scheme { + case "": + u.Host += ":80" + u.Scheme = "http" + case "http": + u.Host += ":80" + case "https": + u.Host += ":443" + default: + require.Failf(t, "elasticsearch host has unknown scheme: %s", u.Scheme) + } + esHost = u.String() + } + + t.Logf("fleet-server will enroll with es host: %q", esHost) + + // Run `elastic-agent install` with fleet-server bootstrap options. + // We use `--force` to prevent interactive execution. + opts := &atesting.InstallOpts{ + Force: true, + Privileged: true, + FleetBootstrapOpts: atesting.FleetBootstrapOpts{ + ESHost: esHost, + ServiceToken: serviceToken, + Policy: policy.ID, + Port: 8220, + }, + } + out, err := fixture.Install(ctx, opts) + if err != nil { + t.Logf("Install output: %s", out) + require.NoError(t, err, "unable to install elastic-agent with fleet-server bootstrap options") + } + + // checkInstallSuccess(t, fixture, topPath, true) // FIXME fails to build if this is uncommented, but the method is part of install_test.go + t.Run("check agent package version", testAgentPackageVersion(ctx, fixture, true)) + + // elastic-agent will self sign a cert to use with fleet-server if one is not passed + // in order to interact with the API we need to ignore the cert. + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + fleetOK := false + for i := 0; i < 10; i++ { + t.Log("Checking fleet-server status") + resp, err := client.Get("https://localhost:8220/api/status") + if err != nil { + t.Logf("fleet-server status check returned error: %v, retry in 10s...", err) + time.Sleep(10 * time.Second) + continue + } + if resp.StatusCode == http.StatusOK { + fleetOK = true + break + } + t.Logf("fleet-server status check returned incorrect status: %d, retry in 10s", resp.StatusCode) + time.Sleep(10 * time.Second) + continue + } + require.True(t, fleetOK, "expected fleet-server /api/status to return 200") + + // Make sure uninstall from within the topPath fails on Windows + if runtime.GOOS == "windows" { + cwd, err := os.Getwd() + require.NoErrorf(t, err, "GetWd failed: %s", err) + err = os.Chdir(topPath) + require.NoErrorf(t, err, "Chdir to topPath failed: %s", err) + t.Cleanup(func() { + _ = os.Chdir(cwd) + }) + out, err = fixture.Uninstall(ctx, &atesting.UninstallOpts{Force: true}) + require.Error(t, err, "uninstall should have failed") + require.Containsf(t, string(out), "uninstall must be run from outside the installed path", "expected error string not found in: %s err: %s", out, err) + } +}