appydave-tools 0.77.0 → 0.77.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/bin/dam +8 -0
- data/docs/planning/BACKLOG.md +9 -6
- data/docs/planning/batch-a-features/IMPLEMENTATION_PLAN.md +5 -5
- data/docs/planning/s3-operations-split/AGENTS.md +686 -0
- data/docs/planning/s3-operations-split/IMPLEMENTATION_PLAN.md +42 -0
- data/lib/appydave/tools/dam/s3_base.rb +310 -0
- data/lib/appydave/tools/dam/s3_operations.rb +15 -314
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools.rb +1 -0
- data/package.json +1 -1
- metadata +4 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# IMPLEMENTATION_PLAN.md — s3-operations-split
|
|
2
|
+
|
|
3
|
+
**Goal**: Split 1,021-line S3Operations into S3Base + 4 focused classes (S3Uploader, S3Downloader, S3StatusChecker, S3Archiver), with S3Operations becoming a thin delegation facade. Prepares for B007 parallelism.
|
|
4
|
+
**Started**: 2026-03-20
|
|
5
|
+
**Target**: 870 examples passing, rubocop 0, S3Operations ≤ 80 lines, each focused class standalone
|
|
6
|
+
|
|
7
|
+
## Summary
|
|
8
|
+
- Total: 5 | Complete: 0 | In Progress: 0 | Pending: 5 | Failed: 0
|
|
9
|
+
|
|
10
|
+
## Pending
|
|
11
|
+
- [ ] WU1-s3-base — Extract shared infrastructure into S3Base class; S3Operations inherits from it; all 870 tests pass with no public API change
|
|
12
|
+
- [ ] WU2-s3-uploader — Create S3Uploader < S3Base; move upload + helpers; S3Operations.upload delegates
|
|
13
|
+
- [ ] WU3-s3-downloader — Create S3Downloader < S3Base; move download + helpers; S3Operations.download delegates
|
|
14
|
+
- [ ] WU4-s3-status-checker — Create S3StatusChecker < S3Base; move status/calculate_sync_status/sync_timestamps + helpers; S3Operations delegates
|
|
15
|
+
- [ ] WU5-s3-archiver — Create S3Archiver < S3Base; move archive/cleanup/cleanup_local + helpers; S3Operations becomes thin facade; add s3_base require to lib/appydave/tools.rb
|
|
16
|
+
|
|
17
|
+
## In Progress
|
|
18
|
+
|
|
19
|
+
## Complete
|
|
20
|
+
|
|
21
|
+
## Failed / Needs Retry
|
|
22
|
+
|
|
23
|
+
## Notes & Decisions
|
|
24
|
+
|
|
25
|
+
### Architecture Decision: Inheritance + Delegation
|
|
26
|
+
- S3Base: shared infrastructure + shared helpers (no public operation methods)
|
|
27
|
+
- S3Uploader/S3Downloader/S3StatusChecker/S3Archiver each inherit S3Base
|
|
28
|
+
- S3Operations inherits S3Base (so send(:build_s3_key) etc. still work in existing specs)
|
|
29
|
+
and delegates its public methods to the appropriate sub-class
|
|
30
|
+
|
|
31
|
+
### Require Order in lib/appydave/tools.rb (add before existing s3_operations line)
|
|
32
|
+
```ruby
|
|
33
|
+
require 'appydave/tools/dam/s3_base'
|
|
34
|
+
require 'appydave/tools/dam/s3_uploader'
|
|
35
|
+
require 'appydave/tools/dam/s3_downloader'
|
|
36
|
+
require 'appydave/tools/dam/s3_status_checker'
|
|
37
|
+
require 'appydave/tools/dam/s3_archiver'
|
|
38
|
+
require 'appydave/tools/dam/s3_operations' # thin facade — keep existing line
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Wave size: 1 (sequential only — each WU modifies both a new file AND s3_operations.rb)
|
|
42
|
+
### kfix commit after each WU once tests + rubocop are green
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'digest'
|
|
6
|
+
require 'aws-sdk-s3'
|
|
7
|
+
|
|
8
|
+
module Appydave
|
|
9
|
+
module Tools
|
|
10
|
+
module Dam
|
|
11
|
+
# Shared infrastructure and helpers for S3 operations.
|
|
12
|
+
# All focused S3 operation classes inherit from this base.
|
|
13
|
+
class S3Base
|
|
14
|
+
attr_reader :brand_info, :brand, :project_id, :brand_path
|
|
15
|
+
|
|
16
|
+
# Directory patterns to exclude from archive/upload (generated/installable content)
|
|
17
|
+
EXCLUDE_PATTERNS = %w[
|
|
18
|
+
**/node_modules/**
|
|
19
|
+
**/.git/**
|
|
20
|
+
**/.next/**
|
|
21
|
+
**/dist/**
|
|
22
|
+
**/build/**
|
|
23
|
+
**/out/**
|
|
24
|
+
**/.cache/**
|
|
25
|
+
**/coverage/**
|
|
26
|
+
**/.turbo/**
|
|
27
|
+
**/.vercel/**
|
|
28
|
+
**/tmp/**
|
|
29
|
+
**/.DS_Store
|
|
30
|
+
**/*:Zone.Identifier
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
def initialize(brand, project_id, brand_info: nil, brand_path: nil, s3_client: nil)
|
|
34
|
+
@project_id = project_id
|
|
35
|
+
|
|
36
|
+
# Use injected dependencies or load from configuration
|
|
37
|
+
@brand_info = brand_info || load_brand_info(brand)
|
|
38
|
+
@brand = @brand_info.key # Use resolved brand key, not original input
|
|
39
|
+
@brand_path = brand_path || Config.brand_path(@brand)
|
|
40
|
+
@s3_client_override = s3_client # Store override but don't create client yet (lazy loading)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Lazy-load S3 client (only create when actually needed, not for dry-run)
|
|
44
|
+
def s3_client
|
|
45
|
+
@s3_client ||= @s3_client_override || create_s3_client(@brand_info)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build S3 key for a file
|
|
49
|
+
def build_s3_key(relative_path)
|
|
50
|
+
"#{brand_info.aws.s3_prefix}#{project_id}/#{relative_path}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Extract relative path from S3 key
|
|
54
|
+
def extract_relative_path(s3_key)
|
|
55
|
+
s3_key.sub("#{brand_info.aws.s3_prefix}#{project_id}/", '')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def load_brand_info(brand)
|
|
61
|
+
Appydave::Tools::Configuration::Config.configure
|
|
62
|
+
Appydave::Tools::Configuration::Config.brands.get_brand(brand)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Build project directory path respecting brand's projects_subfolder setting
|
|
66
|
+
def project_directory_path
|
|
67
|
+
if brand_info.settings.projects_subfolder && !brand_info.settings.projects_subfolder.empty?
|
|
68
|
+
File.join(brand_path, brand_info.settings.projects_subfolder, project_id)
|
|
69
|
+
else
|
|
70
|
+
File.join(brand_path, project_id)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Determine which AWS profile to use based on current user
|
|
75
|
+
# Priority: current user's default_aws_profile > brand's aws.profile
|
|
76
|
+
def determine_aws_profile(brand_info)
|
|
77
|
+
# Get current user from settings (if available)
|
|
78
|
+
begin
|
|
79
|
+
current_user_key = Appydave::Tools::Configuration::Config.settings.current_user
|
|
80
|
+
|
|
81
|
+
if current_user_key
|
|
82
|
+
# Look up current user's default AWS profile
|
|
83
|
+
users = Appydave::Tools::Configuration::Config.brands.data['users']
|
|
84
|
+
user_info = users[current_user_key]
|
|
85
|
+
|
|
86
|
+
return user_info['default_aws_profile'] if user_info && user_info['default_aws_profile']
|
|
87
|
+
end
|
|
88
|
+
rescue Appydave::Tools::Error
|
|
89
|
+
# Config not available (e.g., in test environment) - fall through to brand profile
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Fallback to brand's AWS profile
|
|
93
|
+
brand_info.aws.profile
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def create_s3_client(brand_info)
|
|
97
|
+
profile_name = determine_aws_profile(brand_info)
|
|
98
|
+
raise "AWS profile not configured for current user or brand '#{brand}'" if profile_name.nil? || profile_name.empty?
|
|
99
|
+
|
|
100
|
+
credentials = Aws::SharedCredentials.new(profile_name: profile_name)
|
|
101
|
+
|
|
102
|
+
# Configure SSL certificate handling
|
|
103
|
+
ssl_options = configure_ssl_options
|
|
104
|
+
|
|
105
|
+
Aws::S3::Client.new(
|
|
106
|
+
credentials: credentials,
|
|
107
|
+
region: brand_info.aws.region,
|
|
108
|
+
http_wire_trace: false,
|
|
109
|
+
**ssl_options
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def configure_ssl_options
|
|
114
|
+
return { ssl_verify_peer: false } if ENV['AWS_SDK_RUBY_SKIP_SSL_VERIFICATION'] == 'true'
|
|
115
|
+
|
|
116
|
+
{}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Calculate MD5 hash of a file
|
|
120
|
+
def file_md5(file_path)
|
|
121
|
+
# Use chunked reading for large files to avoid "Invalid argument @ io_fread" errors
|
|
122
|
+
puts " 🔍 Calculating MD5 for #{File.basename(file_path)}..." if ENV['DEBUG']
|
|
123
|
+
md5 = Digest::MD5.new
|
|
124
|
+
File.open(file_path, 'rb') do |file|
|
|
125
|
+
while (chunk = file.read(8192))
|
|
126
|
+
md5.update(chunk)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
result = md5.hexdigest
|
|
130
|
+
puts " ✓ MD5: #{result[0..7]}..." if ENV['DEBUG']
|
|
131
|
+
result
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
puts " ⚠️ Warning: Failed to calculate MD5 for #{File.basename(file_path)}: #{e.message}"
|
|
134
|
+
puts ' → Will upload without MD5 comparison'
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Get MD5 of file in S3 (from ETag)
|
|
139
|
+
def s3_file_md5(s3_path)
|
|
140
|
+
response = s3_client.head_object(
|
|
141
|
+
bucket: brand_info.aws.s3_bucket,
|
|
142
|
+
key: s3_path
|
|
143
|
+
)
|
|
144
|
+
response.etag.gsub('"', '')
|
|
145
|
+
rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::ServiceError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Check if an S3 ETag is from a multipart upload
|
|
150
|
+
# Multipart ETags have format: "hash-partcount" (e.g., "d41d8cd98f00b204e9800998ecf8427e-5")
|
|
151
|
+
def multipart_etag?(etag)
|
|
152
|
+
return false if etag.nil?
|
|
153
|
+
|
|
154
|
+
etag.include?('-')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Compare local file with S3 file, handling multipart ETags
|
|
158
|
+
# Returns: :synced, :modified, or :unknown
|
|
159
|
+
# For multipart uploads, falls back to size comparison since MD5 won't match
|
|
160
|
+
def compare_files(local_file:, s3_etag:, s3_size:)
|
|
161
|
+
return :unknown unless File.exist?(local_file)
|
|
162
|
+
return :unknown if s3_etag.nil?
|
|
163
|
+
|
|
164
|
+
local_size = File.size(local_file)
|
|
165
|
+
|
|
166
|
+
if multipart_etag?(s3_etag)
|
|
167
|
+
# Multipart upload - MD5 comparison won't work, use size
|
|
168
|
+
# Size match is a reasonable proxy for "unchanged" in this context
|
|
169
|
+
local_size == s3_size ? :synced : :modified
|
|
170
|
+
else
|
|
171
|
+
# Standard upload - use MD5 comparison
|
|
172
|
+
local_md5 = file_md5(local_file)
|
|
173
|
+
return :unknown if local_md5.nil?
|
|
174
|
+
|
|
175
|
+
local_md5 == s3_etag ? :synced : :modified
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Get S3 file size from path (for upload comparison)
|
|
180
|
+
def s3_file_size(s3_path)
|
|
181
|
+
response = s3_client.head_object(
|
|
182
|
+
bucket: brand_info.aws.s3_bucket,
|
|
183
|
+
key: s3_path
|
|
184
|
+
)
|
|
185
|
+
response.content_length
|
|
186
|
+
rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::ServiceError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def format_duration(seconds)
|
|
191
|
+
if seconds < 60
|
|
192
|
+
"#{seconds.round(1)}s"
|
|
193
|
+
elsif seconds < 3600
|
|
194
|
+
minutes = (seconds / 60).floor
|
|
195
|
+
secs = (seconds % 60).round
|
|
196
|
+
"#{minutes}m #{secs}s"
|
|
197
|
+
else
|
|
198
|
+
hours = (seconds / 3600).floor
|
|
199
|
+
minutes = ((seconds % 3600) / 60).floor
|
|
200
|
+
"#{hours}h #{minutes}m"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def format_time_ago(seconds)
|
|
205
|
+
return 'just now' if seconds < 60
|
|
206
|
+
|
|
207
|
+
minutes = seconds / 60
|
|
208
|
+
return "#{minutes.round} minute#{'s' if minutes > 1}" if minutes < 60
|
|
209
|
+
|
|
210
|
+
hours = minutes / 60
|
|
211
|
+
return "#{hours.round} hour#{'s' if hours > 1}" if hours < 24
|
|
212
|
+
|
|
213
|
+
days = hours / 24
|
|
214
|
+
return "#{days.round} day#{'s' if days > 1}" if days < 7
|
|
215
|
+
|
|
216
|
+
weeks = days / 7
|
|
217
|
+
return "#{weeks.round} week#{'s' if weeks > 1}" if weeks < 4
|
|
218
|
+
|
|
219
|
+
months = days / 30
|
|
220
|
+
return "#{months.round} month#{'s' if months > 1}" if months < 12
|
|
221
|
+
|
|
222
|
+
years = days / 365
|
|
223
|
+
"#{years.round} year#{'s' if years > 1}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# List files in S3 for a project
|
|
227
|
+
def list_s3_files
|
|
228
|
+
prefix = build_s3_key('')
|
|
229
|
+
|
|
230
|
+
response = s3_client.list_objects_v2(
|
|
231
|
+
bucket: brand_info.aws.s3_bucket,
|
|
232
|
+
prefix: prefix
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return [] unless response.contents
|
|
236
|
+
|
|
237
|
+
response.contents.map do |obj|
|
|
238
|
+
{
|
|
239
|
+
'Key' => obj.key,
|
|
240
|
+
'Size' => obj.size,
|
|
241
|
+
'ETag' => obj.etag,
|
|
242
|
+
'LastModified' => obj.last_modified
|
|
243
|
+
}
|
|
244
|
+
end
|
|
245
|
+
rescue Aws::S3::Errors::ServiceError
|
|
246
|
+
[]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Get full S3 file info including timestamp
|
|
250
|
+
def get_s3_file_info(s3_key)
|
|
251
|
+
response = s3_client.head_object(
|
|
252
|
+
bucket: brand_info.aws.s3_bucket,
|
|
253
|
+
key: s3_key
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
{
|
|
257
|
+
'Key' => s3_key,
|
|
258
|
+
'Size' => response.content_length,
|
|
259
|
+
'ETag' => response.etag,
|
|
260
|
+
'LastModified' => response.last_modified
|
|
261
|
+
}
|
|
262
|
+
rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::ServiceError
|
|
263
|
+
nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# List local files in staging directory
|
|
267
|
+
def list_local_files(staging_dir)
|
|
268
|
+
return {} unless Dir.exist?(staging_dir)
|
|
269
|
+
|
|
270
|
+
files = Dir.glob("#{staging_dir}/**/*").select { |f| File.file?(f) }
|
|
271
|
+
|
|
272
|
+
files.each_with_object({}) do |file, hash|
|
|
273
|
+
relative_path = file.sub("#{staging_dir}/", '')
|
|
274
|
+
hash[relative_path] = file
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Human-readable file size
|
|
279
|
+
def file_size_human(bytes)
|
|
280
|
+
if bytes < 1024
|
|
281
|
+
"#{bytes} B"
|
|
282
|
+
elsif bytes < 1024 * 1024
|
|
283
|
+
"#{(bytes / 1024.0).round(1)} KB"
|
|
284
|
+
elsif bytes < 1024 * 1024 * 1024
|
|
285
|
+
"#{(bytes / (1024.0 * 1024)).round(1)} MB"
|
|
286
|
+
else
|
|
287
|
+
"#{(bytes / (1024.0 * 1024 * 1024)).round(2)} GB"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Check if path should be excluded (generated/installable content)
|
|
292
|
+
def excluded_path?(relative_path)
|
|
293
|
+
EXCLUDE_PATTERNS.any? do |pattern|
|
|
294
|
+
# Extract directory/file name from pattern (remove **)
|
|
295
|
+
excluded_name = pattern.gsub('**/', '').chomp('/**')
|
|
296
|
+
path_segments = relative_path.split('/')
|
|
297
|
+
|
|
298
|
+
if excluded_name.include?('*')
|
|
299
|
+
# Pattern with wildcards - use fnmatch on filename
|
|
300
|
+
File.fnmatch(excluded_name, File.basename(relative_path))
|
|
301
|
+
else
|
|
302
|
+
# Check if any path segment matches the excluded name
|
|
303
|
+
path_segments.include?(excluded_name)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|