diff --git a/changelog/fragments/1745609163-journalctl-on-all-docker-variants.yaml b/changelog/fragments/1745609163-journalctl-on-all-docker-variants.yaml new file mode 100644 index 00000000000..b9b6eeb8ba4 --- /dev/null +++ b/changelog/fragments/1745609163-journalctl-on-all-docker-variants.yaml @@ -0,0 +1,35 @@ +# 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: bug-fix + +# Change summary; a 80ish characters long description of the change. +summary: | + Ship journalctl in the elastic-agent, elastic-agent-complete, and + elastic-otel-collector Docker images to enable reading journald + logs. Journalctl is not present on *-slim and all Wolfi images. + +# 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: + +# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc. +component: elastic-agent + +# 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/7995 + +# 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/beats/issues/44040 diff --git a/dev-tools/packaging/settings.go b/dev-tools/packaging/settings.go index 0b31ee59458..367b0826999 100644 --- a/dev-tools/packaging/settings.go +++ b/dev-tools/packaging/settings.go @@ -13,7 +13,6 @@ import ( "slices" "text/template" - "github.com/magefile/mage/mg" "gopkg.in/yaml.v3" "github.com/elastic/elastic-agent/dev-tools/mage/pkgcommon" @@ -219,9 +218,6 @@ func parsePackageSettings(r io.Reader) (*packagesConfig, error) { return nil, fmt.Errorf("unmarshalling package spec yaml: %w", err) } - if mg.Verbose() { - log.Printf("Read packages config: %+v", packagesConf) - } return packagesConf, nil } diff --git a/dev-tools/packaging/templates/docker/Dockerfile.elastic-agent.tmpl b/dev-tools/packaging/templates/docker/Dockerfile.elastic-agent.tmpl index 014cb7397d3..8b667b6587a 100644 --- a/dev-tools/packaging/templates/docker/Dockerfile.elastic-agent.tmpl +++ b/dev-tools/packaging/templates/docker/Dockerfile.elastic-agent.tmpl @@ -78,14 +78,6 @@ FROM {{ .from }} ENV BEAT_SETUID_AS={{ .user }} -{{- if (and (contains .from "redhat/ubi") (contains .from "-minimal")) }} -RUN for iter in {1..10}; do \ - microdnf update -y && \ - microdnf install -y tar gzip findutils shadow-utils ca-certificates gawk libcap xz && \ - microdnf clean all && \ - exit_code=0 && break || exit_code=$? && echo "microdnf error: retry $iter in 10s" && sleep 10; done; (exit $exit_code) -{{- end }} - {{- if contains .from "wolfi" }} RUN for iter in {1..10}; do \ apk fix && \ @@ -95,6 +87,24 @@ RUN for iter in {1..10}; do \ (exit $exit_code) {{- end }} +# Add Systemd only to: elastic-agent, elastic-agent-complete, ubi, and otel collector. +# Systemd is not added to any of the wolfi or slim images/variants +{{- if (not (contains .from "wolfi")) }} + {{- if (or (contains .Variant "basic") (contains .Variant "ubi") (contains .Variant "elastic-otel-collector") (contains .Variant "complete")) }} + RUN for iter in {1..10}; do \ + microdnf update -y && \ + microdnf install -y tar gzip findutils shadow-utils ca-certificates gawk libcap xz systemd && \ + microdnf clean all && \ + exit_code=0 && break || exit_code=$? && echo "microdnf error: retry $iter in 10s" && sleep 10; done; (exit $exit_code) + {{- else }} + RUN for iter in {1..10}; do \ + microdnf update -y && \ + microdnf install -y tar gzip findutils shadow-utils ca-certificates gawk libcap xz && \ + microdnf clean all && \ + exit_code=0 && break || exit_code=$? && echo "microdnf error: retry $iter in 10s" && sleep 10; done; (exit $exit_code) + {{- end }} +{{- end }} + LABEL \ org.label-schema.build-date="{{ date }}" \ org.label-schema.schema-version="1.0" \ @@ -247,7 +257,7 @@ RUN for iter in {1..10}; do \ microdnf -y install fontconfig freetype cairo glib2 gtk3 pango xorg-x11-fonts-misc xorg-x11-fonts-Type1 \ at-spi2-atk atk at-spi2-core alsa-lib cups-libs dbus-libs libdrm mesa-libEGL mesa-libgbm nspr nss libX11 \ libX11-xcb libxcb libXcomposite libXdamage libXext libXfixes libXrandr libxkbcommon libxshmfence glib2 \ - dbus-glib libicu mesa-libGL unzip iptables systemd && \ + dbus-glib libicu mesa-libGL unzip iptables && \ mkdir -p /usr/share/fonts/google-noto && \ curl -LO https://noto-website-2.storage.googleapis.com/pkgs/NotoSansCJKjp-hinted.zip && \ unzip NotoSansCJKjp-hinted.zip -d /usr/share/fonts/google-noto && \ diff --git a/magefile.go b/magefile.go index 12c01c0334e..5fe0eeced24 100644 --- a/magefile.go +++ b/magefile.go @@ -2351,6 +2351,16 @@ func (Integration) Kubernetes(ctx context.Context) error { return integRunner(ctx, "testing/integration", false, "") } +// KubernetesSingle runs a single Kubernetes integration test +func (Integration) KubernetesSingle(ctx context.Context, testName string) error { + // invoke integration tests + if err := os.Setenv("TEST_GROUPS", "kubernetes"); err != nil { + return err + } + + return integRunner(ctx, "testing/integration", false, testName) +} + // KubernetesMatrix runs a matrix of kubernetes integration tests func (Integration) KubernetesMatrix(ctx context.Context) error { // invoke integration tests diff --git a/testing/integration/journald_test.go b/testing/integration/journald_test.go new file mode 100644 index 00000000000..11aa045d4ae --- /dev/null +++ b/testing/integration/journald_test.go @@ -0,0 +1,227 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +//go:build integration + +package integration + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/e2e-framework/klient/k8s" + + "github.com/elastic/elastic-agent-libs/testing/estools" + "github.com/elastic/elastic-agent/pkg/testing/define" + "github.com/elastic/go-elasticsearch/v8" +) + +func TestKubernetesJournaldInput(t *testing.T) { + info := define.Require(t, define.Requirements{ + Stack: &define.Stack{}, + Local: false, + Sudo: false, + OS: []define.OS{ + {Type: define.Kubernetes, DockerVariant: "basic"}, + {Type: define.Kubernetes, DockerVariant: "complete"}, + }, + Group: define.Kubernetes, + }) + + agentConfigYAML, err := os.ReadFile(filepath.Join("testdata", "journald-input.yml")) + require.NoError(t, err, "failed to read journald input template") + + ctx := context.Background() + kCtx := k8sGetContext(t, info) + + schedulableNodeCount, err := k8sSchedulableNodeCount(ctx, kCtx) + require.NoError(t, err, "error at getting schedulable node count") + require.NotZero(t, schedulableNodeCount, "no schedulable Kubernetes nodes found") + + namespace := kCtx.getNamespace(t) + hostPathType := corev1.HostPathDirectory + + steps := []k8sTestStep{ + k8sStepCreateNamespace(), + k8sStepDeployKustomize( + agentK8SKustomize, + "elastic-agent-standalone", + k8sKustomizeOverrides{ + agentContainerExtraEnv: []corev1.EnvVar{ + { + Name: "ELASTICSEARCH_USERNAME", + Value: os.Getenv("ELASTICSEARCH_USERNAME"), + }, + { + Name: "ELASTICSEARCH_PASSWORD", + Value: os.Getenv("ELASTICSEARCH_PASSWORD"), + }, + { + Name: "EA_POLICY_NAMESPACE", + Value: namespace, + }, + }, + agentContainerVolumeMounts: []corev1.VolumeMount{ + { + Name: "journald-mount", + MountPath: "/opt/journald", + ReadOnly: true, + }, + }, + agentPodVolumes: []corev1.Volume{ + { + Name: "journald-mount", + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: "/run/log/journal", + Type: &hostPathType, + }, + }, + }, + }, + }, + func(obj k8s.Object) { + // update the configmap to use the journald input + switch objWithType := obj.(type) { + case *corev1.ConfigMap: + _, ok := objWithType.Data["agent.yml"] + if ok { + objWithType.Data["agent.yml"] = string(agentConfigYAML) + } + } + + }), + k8sStepCheckAgentStatus( + "app=elastic-agent-standalone", + schedulableNodeCount, + "elastic-agent-standalone", + map[string]bool{ + "journald": true, + }), + } + + journaldTest( + t, + info.ESClient, + kCtx, + steps, + fmt.Sprintf("logs-generic-%s", namespace), + "input.type", + "journald") +} + +func TestKubernetesJournaldInputOtel(t *testing.T) { + info := define.Require(t, define.Requirements{ + Stack: &define.Stack{}, + Local: false, + Sudo: false, + OS: []define.OS{ + {Type: define.Kubernetes, DockerVariant: "elastic-otel-collector"}, + }, + Group: define.Kubernetes, + }) + + otelConfigYAML, err := os.ReadFile(filepath.Join("testdata", "journald-otel.yml")) + require.NoError(t, err, "failed to read journald input template") + + kCtx := k8sGetContext(t, info) + namespace := kCtx.getNamespace(t) + hostPathType := corev1.HostPathDirectory + + steps := []k8sTestStep{ + k8sStepCreateNamespace(), + k8sStepDeployKustomize( + agentK8SKustomize, + "elastic-agent-standalone", + k8sKustomizeOverrides{ + agentContainerArgs: []string{"--config", "/etc/elastic-agent/agent.yml"}, + agentContainerExtraEnv: []corev1.EnvVar{ + { + Name: "EA_POLICY_NAMESPACE", + Value: namespace, + }, + { + Name: "ES_API_KEY_ENCODED", + Value: kCtx.esEncodedAPIKey, + }, + }, + agentContainerVolumeMounts: []corev1.VolumeMount{ + { + Name: "journald-mount", + MountPath: "/opt/journal", + ReadOnly: true, + }, + }, + agentPodVolumes: []corev1.Volume{ + { + Name: "journald-mount", + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: "/run/log/journal", + Type: &hostPathType, + }, + }, + }, + }, + }, + func(obj k8s.Object) { + // update the configmap to use the journald input + switch objWithType := obj.(type) { + case *corev1.ConfigMap: + _, ok := objWithType.Data["agent.yml"] + if ok { + objWithType.Data["agent.yml"] = string(otelConfigYAML) + } + } + }), + } + + journaldTest( + t, + info.ESClient, + kCtx, + steps, + fmt.Sprintf("logs-generic.otel-%s", namespace), + "body.structured.input.type", + "journald") +} + +func journaldTest( + t *testing.T, + esClient *elasticsearch.Client, + kCtx k8sContext, + steps []k8sTestStep, + index, field, value string) { + t.Helper() + + ctx := context.Background() + testNamespace := kCtx.getNamespace(t) + + for _, step := range steps { + step(t, ctx, kCtx, testNamespace) + } + + // Check if the context was cancelled or timed out + if ctx.Err() != nil { + t.Errorf("context error: %v", ctx.Err()) + } + + // Query the index and filter by the input type + docs := findESDocs(t, func() (estools.Documents, error) { + return estools.GetLogsForIndexWithContext( + ctx, + esClient, + index, + map[string]any{ + field: value, + }, + ) + }) + require.NotEmpty(t, docs, "expected logs to be found in Elasticsearch") +} diff --git a/testing/integration/kubernetes_agent_standalone_test.go b/testing/integration/kubernetes_agent_standalone_test.go index 82e0b61862a..1e1da6f4664 100644 --- a/testing/integration/kubernetes_agent_standalone_test.go +++ b/testing/integration/kubernetes_agent_standalone_test.go @@ -900,7 +900,7 @@ func getAgentComponentState(status atesting.AgentStatusOutput, componentName str // k8sDumpPods creates an archive that contains logs of all pods in the given namespace and kube-system to the given target directory func k8sDumpPods(t *testing.T, ctx context.Context, client klient.Client, testName string, namespace string, targetDir string, testStartTime time.Time) { // Create the tar file - archivePath := filepath.Join(targetDir, fmt.Sprintf("%s.tar.gz", namespace)) + archivePath := filepath.Join(targetDir, fmt.Sprintf("%s.tar", namespace)) tarFile, err := os.Create(archivePath) if err != nil { t.Logf("failed to create archive at path %q", archivePath) @@ -1314,8 +1314,14 @@ type k8sContext struct { createdAt time.Time } -// getNamespace returns a unique namespace for the current test +// getNamespace returns a unique namespace on every call. +// If K8S_TESTS_NAMESPACE is set, then its value is returned, +// otherwise a unique namespace is generated. func (k k8sContext) getNamespace(t *testing.T) string { + if ns := os.Getenv("K8S_TESTS_NAMESPACE"); ns != "" { + return ns + } + nsUUID, err := uuid.NewV4() if err != nil { t.Fatalf("error generating namespace UUID: %v", err) @@ -1382,8 +1388,8 @@ func k8sGetContext(t *testing.T, info *define.Info) k8sContext { err = os.MkdirAll(testLogsBasePath, 0o755) require.NoError(t, err, "failed to create test logs directory") - esHost := os.Getenv("ELASTICSEARCH_HOST") - require.NotEmpty(t, esHost, "ELASTICSEARCH_HOST must be set") + esHost, err := getESHost() + require.NoError(t, err, "cannot parse ELASTICSEARCH_HOST") esAPIKey, err := generateESAPIKey(info.ESClient, info.Namespace) require.NoError(t, err, "failed to generate ES API key") @@ -1443,6 +1449,8 @@ type k8sKustomizeOverrides struct { agentContainerExtraEnv []corev1.EnvVar agentContainerArgs []string agentContainerMemoryLimit string + agentContainerVolumeMounts []corev1.VolumeMount + agentPodVolumes []corev1.Volume } // k8sStepDeployKustomize renders a kustomize manifest and deploys it. Also, it tries to @@ -1469,6 +1477,8 @@ func k8sStepDeployKustomize(kustomizePath string, containerName string, override k8sKustomizeAdjustObjects(objects, namespace, containerName, func(container *corev1.Container) { + container.VolumeMounts = append(container.VolumeMounts, overrides.agentContainerVolumeMounts...) + // set agent image container.Image = kCtx.agentImage // set ImagePullPolicy to "Never" to avoid pulling the image @@ -1512,8 +1522,7 @@ func k8sStepDeployKustomize(kustomizePath string, containerName string, override } if overrides.agentContainerArgs != nil { - // drop arguments overriding default config - container.Args = []string{} + container.Args = overrides.agentContainerArgs } }, func(pod *corev1.PodSpec) { @@ -1528,6 +1537,7 @@ func k8sStepDeployKustomize(kustomizePath string, containerName string, override } } } + pod.Volumes = append(pod.Volumes, overrides.agentPodVolumes...) }) t.Cleanup(func() { diff --git a/testing/integration/testdata/journald-input.yml b/testing/integration/testdata/journald-input.yml new file mode 100644 index 00000000000..398a278ce0c --- /dev/null +++ b/testing/integration/testdata/journald-input.yml @@ -0,0 +1,22 @@ +outputs: + default: + type: elasticsearch + hosts: + - ${ES_HOST} + username: ${ELASTICSEARCH_USERNAME} + password: ${ELASTICSEARCH_PASSWORD} + +agent: + monitoring: + enabled: false + +inputs: + - id: journald + log_level: debug + type: journald + data_stream: + namespace: "${EA_POLICY_NAMESPACE}" + streams: + - id: journald-input-id + paths: + - "/opt/journald/*/*" diff --git a/testing/integration/testdata/journald-otel.yml b/testing/integration/testdata/journald-otel.yml new file mode 100644 index 00000000000..12bd65aac2b --- /dev/null +++ b/testing/integration/testdata/journald-otel.yml @@ -0,0 +1,34 @@ +receivers: + filebeatreceiver: + filebeat: + inputs: + - type: journald + id: journald-input + paths: + - /opt/journal/*/* + output: + otelconsumer: + logging: + level: debug + selectors: + - '*' + +processors: + resource: + attributes: + - key: data_stream.namespace + action: insert + value: "${EA_POLICY_NAMESPACE}" + +exporters: + elasticsearch: + endpoint: "${ES_HOST}" + api_key: "${ES_API_KEY_ENCODED}" + +service: + pipelines: + logs: + receivers: [filebeatreceiver] + processors: [resource] + exporters: + - elasticsearch