Skip to content

Commit e3ddd67

Browse files
Fix: Implement backup_to_bucket_ and delete_after_backup for aws-s3 polling mode (#49734)
* Fix: Implement backup_to_bucket_ and delete_after_backup for aws-s3 polling mode * add changelog * fix linting errors * Remove status update in every failure
1 parent c149371 commit e3ddd67

3 files changed

Lines changed: 196 additions & 9 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# REQUIRED
2+
# Kind can be one of:
3+
# - breaking-change: a change to previously-documented behavior
4+
# - deprecation: functionality that is being removed in a later release
5+
# - bug-fix: fixes a problem in a previous version
6+
# - enhancement: extends functionality but does not break or fix existing behavior
7+
# - feature: new functionality
8+
# - known-issue: problems that we are aware of in a given version
9+
# - security: impacts on the security of a product or a user’s deployment.
10+
# - upgrade: important information for someone upgrading from a prior version
11+
# - other: does not fit into any of the other categories
12+
kind: bug-fix
13+
14+
# REQUIRED for all kinds
15+
# Change summary; a 80ish characters long description of the change.
16+
summary: Fix aws-s3 input not performing backup_to_bucket and delete_after_backup in polling mode
17+
18+
# REQUIRED for breaking-change, deprecation, known-issue
19+
# Long description; in case the summary is not enough to describe the change
20+
# this field accommodate a description without length limits.
21+
# description:
22+
23+
# REQUIRED for breaking-change, deprecation, known-issue
24+
# impact:
25+
26+
# REQUIRED for breaking-change, deprecation, known-issue
27+
# action:
28+
29+
# REQUIRED for all kinds
30+
# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc.
31+
component: filebeat
32+
33+
# AUTOMATED
34+
# OPTIONAL to manually add other PR URLs
35+
# PR URL: A link the PR that added the changeset.
36+
# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
37+
# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
38+
# Please provide it if you are adding a fragment for a different PR.
39+
# pr: https://github.com/owner/repo/1234
40+
41+
# AUTOMATED
42+
# OPTIONAL to manually add other issue URLs
43+
# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
44+
# If not present is automatically filled by the tooling with the issue linked to the PR number.
45+
issue: https://github.com/elastic/beats/issues/46672

x-pack/filebeat/input/awss3/s3_input.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,18 @@ func (in *s3PollerInput) workerLoop(ctx context.Context, workChan <-chan state)
235235
state.Stored = true
236236
}
237237

238+
finalize := objHandler.FinalizeS3Object
239+
238240
// Add the cleanup handling to the acks helper
239241
acks.Add(publishCount, func() {
242+
// Finalize the object (backup/delete) after all events are ACKed.
243+
// Only successfully processed objects are finalized.
244+
if state.Stored {
245+
if err := finalize(); err != nil {
246+
in.log.Errorf("failed finalizing S3 object key %q in bucket %q: %v", state.Key, state.Bucket, err)
247+
}
248+
}
249+
240250
err := in.registry.AddState(state)
241251
if err != nil {
242252
in.log.Errorf("saving completed object state: %v", err.Error())

x-pack/filebeat/input/awss3/s3_test.go

Lines changed: 141 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ package awss3
66

77
import (
88
"context"
9+
"io"
10+
"strings"
911
"testing"
1012
"time"
1113

1214
"github.com/elastic/beats/v7/libbeat/beat"
1315
"github.com/elastic/elastic-agent-libs/logp"
16+
"github.com/elastic/elastic-agent-libs/logp/logptest"
1417
"github.com/elastic/elastic-agent-libs/monitoring"
1518

1619
"github.com/aws/aws-sdk-go-v2/aws"
@@ -139,7 +142,7 @@ func TestS3Poller(t *testing.T) {
139142
BucketListPrefix: listPrefix,
140143
RegionName: "region",
141144
}
142-
log := logp.NewLogger(inputName)
145+
log := logptest.NewTestingLogger(t, inputName)
143146
poller := &s3PollerInput{
144147
log: log,
145148
config: cfg,
@@ -156,6 +159,120 @@ func TestS3Poller(t *testing.T) {
156159
poller.runPoll(ctx)
157160
})
158161

162+
t.Run("Poll finalizes objects after ACK (backup + delete)", func(t *testing.T) {
163+
store := openTestStatestore()
164+
165+
ctx, cancel := context.WithTimeout(context.Background(), 3*testTimeout)
166+
defer cancel()
167+
168+
ctrl, ctx := gomock.WithContext(ctx, t)
169+
defer ctrl.Finish()
170+
171+
mockAPI := NewMockS3API(ctrl)
172+
mockPager := NewMockS3Pager(ctrl)
173+
pipeline := newFakePipeline()
174+
175+
backupDone := make(chan struct{})
176+
deleteDone := make(chan struct{})
177+
178+
mockAPI.EXPECT().
179+
ListObjectsPaginator(gomock.Eq(bucket), gomock.Eq(listPrefix), gomock.Any()).
180+
Times(1).
181+
DoAndReturn(func(_, _, _ string) s3Pager {
182+
return mockPager
183+
})
184+
185+
// Poller listing loop calls HasMorePages before and after NextPage.
186+
hasMoreCalls := 0
187+
mockPager.EXPECT().
188+
HasMorePages().
189+
Times(2).
190+
DoAndReturn(func() bool {
191+
hasMoreCalls++
192+
return hasMoreCalls == 1
193+
})
194+
195+
mockPager.EXPECT().
196+
NextPage(gomock.Any()).
197+
Times(1).
198+
DoAndReturn(func(_ context.Context, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
199+
return &s3.ListObjectsV2Output{
200+
Contents: []types.Object{
201+
{
202+
ETag: aws.String("etag1"),
203+
Key: aws.String("key1"),
204+
LastModified: aws.Time(time.Now()),
205+
},
206+
},
207+
}, nil
208+
})
209+
210+
mockAPI.EXPECT().
211+
GetObject(gomock.Any(), gomock.Eq(""), gomock.Eq(bucket), gomock.Eq("key1")).
212+
Times(1).
213+
DoAndReturn(func(_ context.Context, _ string, _ string, _ string) (*s3.GetObjectOutput, error) {
214+
// A simple text payload that produces at least one event.
215+
return &s3.GetObjectOutput{
216+
Body: io.NopCloser(strings.NewReader("hello\n")),
217+
}, nil
218+
})
219+
220+
mockAPI.EXPECT().
221+
CopyObject(gomock.Any(), gomock.Eq(""), gomock.Eq(bucket), gomock.Eq("backup-bucket"), gomock.Eq("key1"), gomock.Eq("processed/key1")).
222+
Times(1).
223+
DoAndReturn(func(_ context.Context, _ string, _ string, _ string, _ string, _ string) (*s3.CopyObjectOutput, error) {
224+
close(backupDone)
225+
return &s3.CopyObjectOutput{}, nil
226+
})
227+
228+
mockAPI.EXPECT().
229+
DeleteObject(gomock.Any(), gomock.Eq(""), gomock.Eq(bucket), gomock.Eq("key1")).
230+
Times(1).
231+
DoAndReturn(func(_ context.Context, _ string, _ string, _ string) (*s3.DeleteObjectOutput, error) {
232+
close(deleteDone)
233+
return &s3.DeleteObjectOutput{}, nil
234+
})
235+
236+
backupCfg := backupConfig{
237+
NonAWSBackupToBucketName: "backup-bucket",
238+
BackupToBucketPrefix: "processed/",
239+
Delete: true,
240+
}
241+
242+
cfg := config{
243+
NumberOfWorkers: 1,
244+
BucketListInterval: pollInterval,
245+
BucketARN: bucket,
246+
BucketListPrefix: listPrefix,
247+
BackupConfig: backupCfg,
248+
}
249+
log := logptest.NewTestingLogger(t, inputName)
250+
251+
s3ObjProc := newS3ObjectProcessorFactory(nil, mockAPI, nil, backupCfg, logp.NewNopLogger())
252+
registry, err := newStateRegistry(nil, store, listPrefix, cfg.LexicographicalOrdering, cfg.LexicographicalLookbackKeys)
253+
require.NoError(t, err, "registry creation must succeed")
254+
255+
poller := &s3PollerInput{
256+
log: log,
257+
config: cfg,
258+
s3: mockAPI,
259+
pipeline: pipeline,
260+
s3ObjectHandler: s3ObjProc,
261+
registry: registry,
262+
provider: "provider",
263+
metrics: newInputMetrics(monitoring.NewRegistry(), 0, logp.NewNopLogger()),
264+
filterProvider: newFilterProvider(&cfg),
265+
strategy: newPollingStrategy(cfg.LexicographicalOrdering, log),
266+
status: &statusReporterHelperMock{},
267+
}
268+
269+
poller.runPoll(ctx)
270+
271+
// Finalization happens asynchronously after ACK; wait until it completes.
272+
waitForChannel(t, backupDone, 3*testTimeout)
273+
waitForChannel(t, deleteDone, 3*testTimeout)
274+
})
275+
159276
t.Run("restart bucket scan after paging errors", func(t *testing.T) {
160277
// Change the restart limit to 2 consecutive errors, so the test doesn't
161278
// take too long to run
@@ -284,7 +401,7 @@ func TestS3Poller(t *testing.T) {
284401
RegionName: "region",
285402
}
286403

287-
log := logp.NewLogger(inputName)
404+
log := logptest.NewTestingLogger(t, inputName)
288405
poller := &s3PollerInput{
289406
log: log,
290407
config: config{
@@ -390,7 +507,7 @@ func TestS3Poller(t *testing.T) {
390507
LexicographicalOrdering: true,
391508
LexicographicalLookbackKeys: 100,
392509
}
393-
log := logp.NewLogger(inputName)
510+
log := logptest.NewTestingLogger(t, inputName)
394511
poller := &s3PollerInput{
395512
log: log,
396513
config: cfg,
@@ -431,7 +548,7 @@ func TestS3Poller(t *testing.T) {
431548
ListObjectsPaginator(gomock.Eq(bucket), gomock.Eq(""), gomock.Eq("")).
432549
Times(1).
433550
DoAndReturn(func(_, _, startAfterKey string) s3Pager {
434-
require.Equal(t, "", startAfterKey)
551+
require.Empty(t, startAfterKey)
435552
return mockPager
436553
})
437554

@@ -479,7 +596,7 @@ func TestS3Poller(t *testing.T) {
479596
LexicographicalOrdering: true,
480597
LexicographicalLookbackKeys: 100,
481598
}
482-
log := logp.NewLogger(inputName)
599+
log := logptest.NewTestingLogger(t, inputName)
483600
poller := &s3PollerInput{
484601
log: log,
485602
config: cfg,
@@ -517,7 +634,7 @@ func TestS3Poller(t *testing.T) {
517634
ListObjectsPaginator(gomock.Eq(bucket), gomock.Eq(""), gomock.Eq("")).
518635
Times(1).
519636
DoAndReturn(func(_, _, startAfterKey string) s3Pager {
520-
require.Equal(t, "", startAfterKey)
637+
require.Empty(t, startAfterKey)
521638
return mockPager
522639
})
523640

@@ -564,7 +681,7 @@ func TestS3Poller(t *testing.T) {
564681
RegionName: "region",
565682
LexicographicalOrdering: false,
566683
}
567-
log := logp.NewLogger(inputName)
684+
log := logptest.NewTestingLogger(t, inputName)
568685
poller := &s3PollerInput{
569686
log: log,
570687
config: cfg,
@@ -702,7 +819,7 @@ func TestS3Poller(t *testing.T) {
702819
BucketListPrefix: "",
703820
RegionName: "region",
704821
}
705-
log := logp.NewLogger(inputName)
822+
log := logptest.NewTestingLogger(t, inputName)
706823
poller := &s3PollerInput{
707824
log: log,
708825
config: cfg,
@@ -728,9 +845,19 @@ func TestS3Poller(t *testing.T) {
728845
})
729846
}
730847

848+
func waitForChannel(t *testing.T, ch <-chan struct{}, timeout time.Duration) {
849+
t.Helper()
850+
select {
851+
case <-ch:
852+
return
853+
case <-time.After(timeout):
854+
t.Fatal("timed out waiting for async finalization")
855+
}
856+
}
857+
731858
func Test_S3StateHandling(t *testing.T) {
732859
bucket := "bucket"
733-
logger := logp.NewLogger(inputName)
860+
logger := logptest.NewTestingLogger(t, inputName)
734861
fixedTimeNow := time.Now()
735862

736863
tests := []struct {
@@ -933,6 +1060,11 @@ func Test_S3StateHandling(t *testing.T) {
9331060
eventCallback(beat.Event{})
9341061
return nil
9351062
})
1063+
// FinalizeS3Object is called inside the ACK callback for every successfully
1064+
// stored object (state.Stored == true). The tests here use an empty
1065+
// backupConfig so the real implementation is a no-op, but the mock still
1066+
// needs an expectation to avoid "unexpected call" panics.
1067+
mockS3ObjectHandler.EXPECT().FinalizeS3Object().AnyTimes().Return(nil)
9361068

9371069
store := openTestStatestore()
9381070
s3Registry, err := newStateRegistry(logger, store, "", false, 0)

0 commit comments

Comments
 (0)