Skip to content

Commit 40c8f3d

Browse files
jkmasselclaude
andcommitted
Add delete_from_s3 action for S3 object deletion
Complement the existing `upload_to_s3` action with a `delete_from_s3` action that supports single-key deletion, bulk deletion by prefix, age-based filtering (`older_than_days`), and a `dry_run` preview mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 87883d2 commit 40c8f3d

3 files changed

Lines changed: 777 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ _None_
1010

1111
### New Features
1212

13-
_None_
13+
- Add `delete_from_s3` action to delete objects from an S3 bucket, complementing the existing `upload_to_s3` action. Supports single-key deletion, bulk deletion by prefix, age-based filtering via `older_than_days`, and a `dry_run` mode. [#xxx]
1414

1515
### Bug Fixes
1616

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
# frozen_string_literal: true
2+
3+
require 'fastlane/action'
4+
5+
module Fastlane
6+
module Actions
7+
class DeleteFromS3Action < Action
8+
def self.run(params)
9+
bucket = params[:bucket]
10+
key = params[:key]
11+
prefix = params[:prefix]
12+
older_than_days = params[:older_than_days]
13+
dry_run = params[:dry_run]
14+
silent = params[:silent]
15+
16+
UI.user_error!('You must provide either :key or :prefix') if key.nil? && prefix.nil?
17+
UI.user_error!('You cannot use :dry_run and :silent at the same time') if dry_run && silent
18+
19+
if prefix
20+
delete_by_prefix(bucket: bucket, prefix: prefix, older_than_days: older_than_days, dry_run: dry_run, silent: silent)
21+
else
22+
delete_by_key(bucket: bucket, key: key, older_than_days: older_than_days, dry_run: dry_run, silent: silent, fail_if_not_found: params[:fail_if_not_found])
23+
end
24+
end
25+
26+
def self.delete_by_key(bucket:, key:, older_than_days:, dry_run:, silent:, fail_if_not_found:)
27+
client = Aws::S3::Client.new
28+
metadata = head_object(client: client, bucket: bucket, key: key)
29+
30+
if metadata.nil?
31+
if fail_if_not_found
32+
UI.user_error!("File not found in S3 bucket #{bucket} at #{key}")
33+
else
34+
UI.important("File not found in S3 bucket #{bucket} at #{key}. Skipping deletion.") unless silent
35+
return nil
36+
end
37+
end
38+
39+
if older_than_days
40+
last_modified = metadata[:last_modified]
41+
cutoff = Time.now.utc - (older_than_days * 86_400)
42+
43+
if last_modified >= cutoff
44+
UI.message("Skipping #{key} - last modified #{last_modified.iso8601}, not older than #{older_than_days} days") unless silent
45+
return nil
46+
end
47+
48+
UI.message("#{key} last modified #{last_modified.iso8601}, older than #{older_than_days} days") unless silent
49+
end
50+
51+
if dry_run
52+
UI.important("Dry run: would delete #{key}") unless silent
53+
return key
54+
end
55+
56+
UI.message("Deleting #{key} from S3 bucket #{bucket}") unless silent
57+
58+
client.delete_object(bucket: bucket, key: key)
59+
60+
UI.success('Deletion complete') unless silent
61+
key
62+
rescue Aws::S3::Errors::ServiceError => e
63+
UI.crash!("Unable to delete file from S3: #{e.message}")
64+
end
65+
66+
def self.delete_by_prefix(bucket:, prefix:, older_than_days:, dry_run:, silent:)
67+
client = Aws::S3::Client.new
68+
cutoff = older_than_days ? Time.now.utc - (older_than_days * 86_400) : nil
69+
70+
UI.message("Listing objects in s3://#{bucket}/#{prefix}...") unless silent
71+
72+
to_delete = []
73+
skipped = 0
74+
continuation_token = nil
75+
76+
loop do
77+
response = client.list_objects_v2(
78+
bucket: bucket,
79+
prefix: prefix,
80+
continuation_token: continuation_token
81+
)
82+
83+
response.contents.each do |object|
84+
if cutoff && object.last_modified >= cutoff
85+
UI.message("Skipping #{object.key} - last modified #{object.last_modified.iso8601}, not older than #{older_than_days} days") unless silent
86+
skipped += 1
87+
next
88+
end
89+
90+
UI.message("Will delete #{object.key} (last modified #{object.last_modified.iso8601})") unless silent
91+
to_delete << object.key
92+
end
93+
94+
break unless response.is_truncated
95+
96+
continuation_token = response.next_continuation_token
97+
end
98+
99+
if to_delete.empty?
100+
message = if skipped.positive?
101+
"No objects eligible for deletion under prefix '#{prefix}' (#{skipped} skipped as too recent)"
102+
else
103+
"No objects found under prefix '#{prefix}'"
104+
end
105+
UI.message(message) unless silent
106+
return []
107+
end
108+
109+
if dry_run
110+
UI.important("Dry run: would delete #{to_delete.length} object(s), skipped #{skipped}") unless silent
111+
return to_delete
112+
end
113+
114+
UI.message("Deleting #{to_delete.length} object(s)...") unless silent
115+
delete_objects_in_batches(client: client, bucket: bucket, keys: to_delete, silent: silent)
116+
UI.success("Deleted #{to_delete.length} object(s) from s3://#{bucket}/#{prefix} (skipped #{skipped})") unless silent
117+
118+
to_delete
119+
rescue Aws::S3::Errors::ServiceError => e
120+
UI.crash!("Unable to delete files from S3: #{e.message}")
121+
end
122+
123+
def self.delete_objects_in_batches(client:, bucket:, keys:, silent: false)
124+
keys.each_slice(1000) do |batch|
125+
response = client.delete_objects(
126+
bucket: bucket,
127+
delete: {
128+
objects: batch.map { |key| { key: key } },
129+
quiet: true
130+
}
131+
)
132+
133+
next if response.errors.nil? || response.errors.empty?
134+
135+
response.errors.each do |error|
136+
UI.error("Failed to delete #{error.key}: #{error.code} - #{error.message}") unless silent
137+
end
138+
failed_keys = response.errors.map(&:key)
139+
succeeded = batch.reject { |key| failed_keys.include?(key) }
140+
UI.message("#{succeeded.length} object(s) in this batch were deleted successfully") unless succeeded.empty? || silent
141+
UI.user_error!("#{response.errors.length} object(s) failed to delete")
142+
end
143+
end
144+
145+
# Returns the head_object response hash, or nil if the object does not exist.
146+
def self.head_object(client:, bucket:, key:)
147+
client.head_object(bucket: bucket, key: key)
148+
rescue Aws::S3::Errors::NotFound
149+
nil
150+
end
151+
152+
def self.description
153+
'Deletes files from S3'
154+
end
155+
156+
def self.authors
157+
['Automattic']
158+
end
159+
160+
def self.return_value
161+
'The deleted key or nil (single-key mode), or an array of deleted keys (prefix mode, empty array if none matched). ' \
162+
'In dry-run mode, returns what would have been deleted.'
163+
end
164+
165+
def self.details
166+
<<~DETAILS
167+
Deletes objects from an S3 bucket, either by exact key or by key prefix.
168+
169+
In single-key mode (:key), deletes one object. In prefix mode (:prefix),
170+
lists all matching objects and batch-deletes them. Both modes support
171+
:older_than_days to only delete objects older than a given number of days.
172+
173+
Use :dry_run to preview what would be deleted without making changes.
174+
Use :silent to suppress per-object log output.
175+
DETAILS
176+
end
177+
178+
def self.example_code
179+
[
180+
'# Delete a single object
181+
delete_from_s3(bucket: "my-bucket", key: "path/to/file.zip")',
182+
'# Delete a single object only if older than 90 days
183+
delete_from_s3(bucket: "my-bucket", key: "path/to/file.zip", older_than_days: 90)',
184+
'# Delete all objects under a prefix older than 180 days
185+
delete_from_s3(bucket: "my-bucket", prefix: "trunk/", older_than_days: 180)',
186+
'# Dry run: see what would be deleted without deleting
187+
delete_from_s3(bucket: "my-bucket", prefix: "trunk/", older_than_days: 180, dry_run: true)',
188+
]
189+
end
190+
191+
def self.available_options
192+
[
193+
FastlaneCore::ConfigItem.new(
194+
key: :bucket,
195+
description: 'The bucket containing the file(s) to delete',
196+
optional: false,
197+
type: String,
198+
verify_block: proc { |bucket| UI.user_error!('You must provide a valid bucket name') if bucket.empty? }
199+
),
200+
FastlaneCore::ConfigItem.new(
201+
key: :key,
202+
description: 'The exact key of the object to delete. Mutually exclusive with :prefix',
203+
optional: true,
204+
conflicting_options: [:prefix],
205+
type: String,
206+
verify_block: proc { |key|
207+
next if key.is_a?(String) && !key.empty?
208+
209+
UI.user_error!('You must provide a valid key')
210+
}
211+
),
212+
FastlaneCore::ConfigItem.new(
213+
key: :prefix,
214+
description: 'A key prefix - all matching objects will be deleted. Mutually exclusive with :key',
215+
optional: true,
216+
conflicting_options: [:key],
217+
type: String,
218+
verify_block: proc { |prefix|
219+
next if prefix.is_a?(String) && !prefix.empty?
220+
221+
UI.user_error!('You must provide a valid prefix')
222+
}
223+
),
224+
FastlaneCore::ConfigItem.new(
225+
key: :older_than_days,
226+
description: 'Only delete objects whose last_modified is older than this many days',
227+
optional: true,
228+
type: Integer,
229+
verify_block: proc { |days| UI.user_error!('older_than_days must be a positive integer') unless days.positive? }
230+
),
231+
FastlaneCore::ConfigItem.new(
232+
key: :dry_run,
233+
description: 'When true, log what would be deleted without actually deleting',
234+
optional: true,
235+
default_value: false,
236+
type: Boolean
237+
),
238+
FastlaneCore::ConfigItem.new(
239+
key: :silent,
240+
description: 'When true, suppress per-object log messages',
241+
optional: true,
242+
default_value: false,
243+
type: Boolean
244+
),
245+
FastlaneCore::ConfigItem.new(
246+
key: :fail_if_not_found,
247+
description: 'Whether to fail with `user_error!` if the file does not exist (single-key mode only)',
248+
optional: true,
249+
default_value: true,
250+
type: Boolean
251+
),
252+
]
253+
end
254+
255+
def self.is_supported?(platform)
256+
true
257+
end
258+
end
259+
end
260+
end

0 commit comments

Comments
 (0)