cloudflare-r2-cli 1.0.0 → 1.2.0
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 +86 -2
- data/README.md +109 -36
- data/docs/ARCHITECTURE.md +177 -82
- data/docs/FEATURES.md +121 -5
- data/docs/SECURITY.md +4 -0
- data/lib/r2/cli.rb +234 -13
- data/lib/r2/content_type.rb +70 -0
- data/lib/r2/errors.rb +13 -0
- data/lib/r2/logging.rb +34 -0
- data/lib/r2/retry.rb +105 -0
- data/lib/r2/storage.rb +183 -24
- data/lib/r2/version.rb +1 -1
- data/lib/r2.rb +3 -0
- metadata +6 -11
- data/docs/DECISIONS.md +0 -30
- data/docs/DEVELOPMENT.md +0 -65
- data/docs/ROADMAP.md +0 -69
- data/docs/architecture/cli.md +0 -24
- data/docs/architecture/configuration.md +0 -24
- data/docs/architecture/errors.md +0 -25
- data/docs/architecture/storage.md +0 -46
- data/docs/architecture/testing.md +0 -48
- /data/{LICENCE → LICENSE} +0 -0
data/lib/r2/retry.rb
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2
|
|
4
|
+
# Automatic retries with exponential backoff.
|
|
5
|
+
#
|
|
6
|
+
# Transient failures, such as brief network instabilities, are retried a
|
|
7
|
+
# limited number of times. The wait between the attempts grows
|
|
8
|
+
# exponentially and is capped, so the remote service has time to recover
|
|
9
|
+
# without making the user wait indefinitely.
|
|
10
|
+
#
|
|
11
|
+
# Only the error classes informed as transient are retried; every other
|
|
12
|
+
# failure is raised immediately, preserving the domain error mapping.
|
|
13
|
+
module Retry
|
|
14
|
+
# Total number of attempts: the first execution plus the retries.
|
|
15
|
+
DEFAULT_MAX_ATTEMPTS = 3
|
|
16
|
+
|
|
17
|
+
# Wait applied after the first failed attempt, in seconds.
|
|
18
|
+
DEFAULT_BASE_DELAY = 0.5
|
|
19
|
+
|
|
20
|
+
# Upper limit of the wait between attempts, in seconds.
|
|
21
|
+
DEFAULT_MAX_DELAY = 5.0
|
|
22
|
+
|
|
23
|
+
# Waiting strategy used when nothing else is provided.
|
|
24
|
+
DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) }
|
|
25
|
+
|
|
26
|
+
# Description used in the diagnostics when the caller gives none.
|
|
27
|
+
DEFAULT_DESCRIPTION = "operation"
|
|
28
|
+
|
|
29
|
+
# Retry settings shared by the operations of a client.
|
|
30
|
+
class Policy
|
|
31
|
+
attr_reader :max_attempts, :base_delay, :max_delay
|
|
32
|
+
|
|
33
|
+
# @param max_attempts [Integer] total number of attempts
|
|
34
|
+
# @param base_delay [Float] wait after the first failure, in seconds
|
|
35
|
+
# @param max_delay [Float] upper limit of the wait, in seconds
|
|
36
|
+
def initialize(
|
|
37
|
+
max_attempts: DEFAULT_MAX_ATTEMPTS,
|
|
38
|
+
base_delay: DEFAULT_BASE_DELAY,
|
|
39
|
+
max_delay: DEFAULT_MAX_DELAY
|
|
40
|
+
)
|
|
41
|
+
@max_attempts = max_attempts
|
|
42
|
+
@base_delay = base_delay
|
|
43
|
+
@max_delay = max_delay
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Wait applied after the given failed attempt.
|
|
47
|
+
#
|
|
48
|
+
# @param attempt [Integer] number of the attempt that just failed
|
|
49
|
+
# @return [Float] seconds to wait before the next attempt
|
|
50
|
+
def delay_for(attempt)
|
|
51
|
+
[@base_delay * (2**(attempt - 1)), @max_delay].min
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Runs the block, retrying the transient failures it raises.
|
|
56
|
+
#
|
|
57
|
+
# @param policy [Policy] retry settings
|
|
58
|
+
# @param retry_on [Array<Class>] error classes considered transient
|
|
59
|
+
# @param logger [#debug, nil] logger used for diagnostics
|
|
60
|
+
# @param sleeper [#call, nil] waiting strategy used between attempts
|
|
61
|
+
# @param description [String, nil] operation description used in the diagnostics
|
|
62
|
+
# @yield the operation to run
|
|
63
|
+
# @return [Object] result of the block
|
|
64
|
+
# @raise [StandardError] error of the last attempt when the retries are exhausted
|
|
65
|
+
def self.call(policy: Policy.new, retry_on: [], logger: nil, sleeper: nil, description: nil)
|
|
66
|
+
attempt = 0
|
|
67
|
+
description ||= DEFAULT_DESCRIPTION
|
|
68
|
+
|
|
69
|
+
begin
|
|
70
|
+
attempt += 1
|
|
71
|
+
yield
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
raise unless transient?(e, retry_on) && attempt < policy.max_attempts
|
|
74
|
+
|
|
75
|
+
delay = policy.delay_for(attempt)
|
|
76
|
+
logger&.debug(retry_message(attempt, policy, description, e, delay))
|
|
77
|
+
(sleeper || DEFAULT_SLEEPER).call(delay)
|
|
78
|
+
retry
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Indicates whether an error is considered transient.
|
|
83
|
+
#
|
|
84
|
+
# @param error [StandardError] error raised by the operation
|
|
85
|
+
# @param retry_on [Array<Class>] error classes considered transient
|
|
86
|
+
# @return [Boolean] true when the failure may be retried
|
|
87
|
+
def self.transient?(error, retry_on)
|
|
88
|
+
retry_on.any? { |error_class| error.is_a?(error_class) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Builds the diagnostic message of a retry.
|
|
92
|
+
#
|
|
93
|
+
# @param attempt [Integer] number of the attempt that just failed
|
|
94
|
+
# @param policy [Policy] retry settings
|
|
95
|
+
# @param description [String] description of the operation
|
|
96
|
+
# @param error [StandardError] error raised by the attempt
|
|
97
|
+
# @param delay [Float] seconds to wait before the next attempt
|
|
98
|
+
# @return [String] message describing the retry
|
|
99
|
+
def self.retry_message(attempt, policy, description, error, delay)
|
|
100
|
+
"Retrying #{description} in #{delay}s (attempt #{attempt + 1} of " \
|
|
101
|
+
"#{policy.max_attempts}) after a transient failure: " \
|
|
102
|
+
"#{error.class}: #{error.message}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/r2/storage.rb
CHANGED
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "aws-sdk-s3"
|
|
4
|
+
require_relative "content_type"
|
|
4
5
|
require_relative "errors"
|
|
6
|
+
require_relative "logging"
|
|
7
|
+
require_relative "retry"
|
|
5
8
|
|
|
6
9
|
module R2
|
|
7
10
|
# Storage layer responsible for communicating with Cloudflare R2.
|
|
8
11
|
#
|
|
9
12
|
# Uses the `aws-sdk-s3` gem with the S3-compatible endpoint provided by
|
|
10
13
|
# the application configuration.
|
|
14
|
+
#
|
|
15
|
+
# Transient network failures are retried with exponential backoff
|
|
16
|
+
# (see `R2::Retry`), so brief instabilities do not fail the operation.
|
|
11
17
|
class Storage
|
|
18
|
+
# Error classes considered transient and therefore retried.
|
|
19
|
+
RETRYABLE_ERRORS = [Seahorse::Client::NetworkingError].freeze
|
|
20
|
+
|
|
12
21
|
# Initializes the storage with the configured credentials and bucket.
|
|
13
22
|
#
|
|
14
23
|
# @param config [Configuration] application configuration
|
|
15
|
-
|
|
24
|
+
# @param logger [#debug, nil] logger used for diagnostics
|
|
25
|
+
# @param retry_policy [Retry::Policy, nil] retry settings, defaults to the project policy
|
|
26
|
+
# @param sleeper [#call, nil] waiting strategy used between attempts
|
|
27
|
+
def initialize(config, logger: nil, retry_policy: nil, sleeper: nil)
|
|
16
28
|
@bucket = config.bucket
|
|
29
|
+
@logger = logger || R2::Logging::NullLogger.new
|
|
30
|
+
@retry_policy = retry_policy || Retry::Policy.new
|
|
31
|
+
@sleeper = sleeper
|
|
17
32
|
@s3 = Aws::S3::Client.new(
|
|
18
33
|
region: config.region,
|
|
19
34
|
access_key_id: config.access_key_id,
|
|
@@ -28,17 +43,33 @@ module R2
|
|
|
28
43
|
# Receives content already prepared by the layer that uses the storage
|
|
29
44
|
# and delivers it to Cloudflare R2.
|
|
30
45
|
#
|
|
46
|
+
# The content type is determined from the object key, unless an
|
|
47
|
+
# explicit value is given, so the stored object is served with the
|
|
48
|
+
# correct type instead of the generic binary type.
|
|
49
|
+
#
|
|
50
|
+
# When the content comes from a stream, it is rewound before every
|
|
51
|
+
# attempt, since a retry must read the content from the beginning.
|
|
52
|
+
#
|
|
31
53
|
# @param key [String] object key in the bucket
|
|
32
54
|
# @param body [IO, String] content of the object to upload
|
|
55
|
+
# @param content_type [String, nil] content type stored in the object metadata
|
|
33
56
|
# @raise [Errors::Error] if the operation fails
|
|
34
|
-
def upload(key:, body:)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
body
|
|
39
|
-
|
|
57
|
+
def upload(key:, body:, content_type: nil)
|
|
58
|
+
type = content_type || ContentType.for(key)
|
|
59
|
+
@logger.debug("Uploading object #{key.inspect} as #{type.inspect} to bucket #{@bucket.inspect}.")
|
|
60
|
+
with_retries(operation: "upload", key: key) do
|
|
61
|
+
body.rewind if body.respond_to?(:rewind)
|
|
62
|
+
@s3.put_object(
|
|
63
|
+
bucket: @bucket,
|
|
64
|
+
key: key,
|
|
65
|
+
body: body,
|
|
66
|
+
content_type: type
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
@logger.debug("Upload of object #{key.inspect} completed.")
|
|
70
|
+
nil
|
|
40
71
|
rescue StandardError => e
|
|
41
|
-
raise_storage_error(e)
|
|
72
|
+
raise_storage_error(e, operation: "upload", key: key)
|
|
42
73
|
end
|
|
43
74
|
|
|
44
75
|
# Deletes an object from the configured bucket.
|
|
@@ -49,45 +80,173 @@ module R2
|
|
|
49
80
|
# @param key [String] object key in the bucket
|
|
50
81
|
# @raise [Errors::Error] if the operation fails
|
|
51
82
|
def delete(key:)
|
|
52
|
-
@
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
83
|
+
@logger.debug("Deleting object #{key.inspect} from bucket #{@bucket.inspect}.")
|
|
84
|
+
with_retries(operation: "delete", key: key) do
|
|
85
|
+
@s3.delete_object(
|
|
86
|
+
bucket: @bucket,
|
|
87
|
+
key: key
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
@logger.debug("Deletion of object #{key.inspect} completed.")
|
|
91
|
+
nil
|
|
56
92
|
rescue StandardError => e
|
|
57
|
-
raise_storage_error(e)
|
|
93
|
+
raise_storage_error(e, operation: "delete", key: key)
|
|
58
94
|
end
|
|
59
95
|
|
|
60
96
|
# Lists the objects stored in the configured bucket.
|
|
61
97
|
#
|
|
98
|
+
# Every page of the listing is requested, so all the stored objects
|
|
99
|
+
# are returned regardless of the amount of keys in the bucket.
|
|
100
|
+
#
|
|
101
|
+
# @param prefix [String, nil] lists only the objects whose keys start with the prefix
|
|
62
102
|
# @return [Array<String>] keys of the stored objects
|
|
63
103
|
# @raise [Errors::Error] if the operation fails
|
|
64
|
-
def list
|
|
65
|
-
|
|
66
|
-
|
|
104
|
+
def list(prefix: nil)
|
|
105
|
+
@logger.debug("Listing objects in bucket #{@bucket.inspect} with prefix #{prefix.inspect}.")
|
|
106
|
+
keys = []
|
|
107
|
+
token = nil
|
|
108
|
+
|
|
109
|
+
loop do
|
|
110
|
+
response = list_page(token, prefix)
|
|
111
|
+
keys.concat(response.contents.map(&:key))
|
|
112
|
+
token = response.next_continuation_token
|
|
113
|
+
break if token.nil? || token.empty?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
@logger.debug("Found #{keys.size} object(s) in bucket #{@bucket.inspect}.")
|
|
117
|
+
keys
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
raise_storage_error(e, operation: "list")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Downloads an object from the configured bucket.
|
|
123
|
+
#
|
|
124
|
+
# The content is streamed directly to the destination file, so large
|
|
125
|
+
# objects do not need to be fully loaded into memory. Every attempt
|
|
126
|
+
# writes the file from the beginning, avoiding a partially written
|
|
127
|
+
# content when a retry is needed.
|
|
128
|
+
#
|
|
129
|
+
# @param key [String] object key in the bucket
|
|
130
|
+
# @param destination [String] local path where the content is written
|
|
131
|
+
# @return [String] destination path
|
|
132
|
+
# @raise [Errors::Error] if the operation fails
|
|
133
|
+
def download(key:, destination:)
|
|
134
|
+
@logger.debug("Downloading object #{key.inspect} to #{destination.inspect}.")
|
|
135
|
+
with_retries(operation: "download", key: key) do
|
|
136
|
+
File.open(destination, "wb") do |file|
|
|
137
|
+
@s3.get_object(bucket: @bucket, key: key, response_target: file)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
@logger.debug("Download of object #{key.inspect} completed.")
|
|
141
|
+
destination
|
|
67
142
|
rescue StandardError => e
|
|
68
|
-
raise_storage_error(e)
|
|
143
|
+
raise_storage_error(e, operation: "download", key: key, destination: destination)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Checks whether an object exists in the configured bucket.
|
|
147
|
+
#
|
|
148
|
+
# The object metadata is requested instead of the content, so the
|
|
149
|
+
# check is cheap regardless of the object size.
|
|
150
|
+
#
|
|
151
|
+
# @param key [String] object key in the bucket
|
|
152
|
+
# @return [Boolean] true when the object exists
|
|
153
|
+
# @raise [Errors::Error] if the operation fails
|
|
154
|
+
def exists?(key:)
|
|
155
|
+
@logger.debug("Checking whether object #{key.inspect} exists in bucket #{@bucket.inspect}.")
|
|
156
|
+
with_retries(operation: "exists", key: key) do
|
|
157
|
+
@s3.head_object(bucket: @bucket, key: key)
|
|
158
|
+
end
|
|
159
|
+
@logger.debug("Object #{key.inspect} was found in bucket #{@bucket.inspect}.")
|
|
160
|
+
true
|
|
161
|
+
rescue Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::NotFound
|
|
162
|
+
@logger.debug("Object #{key.inspect} was not found in bucket #{@bucket.inspect}.")
|
|
163
|
+
false
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
raise_storage_error(e, operation: "exists", key: key)
|
|
69
166
|
end
|
|
70
167
|
|
|
71
168
|
private
|
|
72
169
|
|
|
170
|
+
# Requests a single page of the object listing.
|
|
171
|
+
#
|
|
172
|
+
# @param token [String, nil] continuation token of the page
|
|
173
|
+
# @param prefix [String, nil] lists only the objects whose keys start with the prefix
|
|
174
|
+
# @return [Object] response of the listing operation
|
|
175
|
+
def list_page(token, prefix)
|
|
176
|
+
params = { bucket: @bucket }
|
|
177
|
+
params[:prefix] = prefix unless prefix.nil?
|
|
178
|
+
params[:continuation_token] = token unless token.nil?
|
|
179
|
+
|
|
180
|
+
with_retries(operation: "list") { @s3.list_objects_v2(**params) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Runs the given block retrying transient network failures.
|
|
184
|
+
#
|
|
185
|
+
# @param operation [String] operation being performed
|
|
186
|
+
# @param key [String, nil] object key involved in the operation
|
|
187
|
+
# @yield the request to run against the storage
|
|
188
|
+
# @return [Object] result of the block
|
|
189
|
+
def with_retries(operation:, key: nil, &)
|
|
190
|
+
R2::Retry.call(
|
|
191
|
+
policy: @retry_policy,
|
|
192
|
+
retry_on: RETRYABLE_ERRORS,
|
|
193
|
+
logger: @logger,
|
|
194
|
+
sleeper: @sleeper,
|
|
195
|
+
description: describe_operation(operation, key),
|
|
196
|
+
&
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Builds the description of an operation used in the diagnostics.
|
|
201
|
+
#
|
|
202
|
+
# @param operation [String] operation being performed
|
|
203
|
+
# @param key [String, nil] object key involved in the operation
|
|
204
|
+
# @return [String] operation description
|
|
205
|
+
def describe_operation(operation, key)
|
|
206
|
+
return "#{operation} on bucket #{@bucket}" if key.nil?
|
|
207
|
+
|
|
208
|
+
"#{operation} of #{key}"
|
|
209
|
+
end
|
|
210
|
+
|
|
73
211
|
# Converts storage layer errors into project domain errors, avoiding
|
|
74
212
|
# exposing internal details of the implementations.
|
|
75
213
|
#
|
|
76
214
|
# The original message is preserved when useful.
|
|
77
215
|
#
|
|
78
216
|
# @param error [StandardError] original error
|
|
217
|
+
# @param operation [String] operation being performed
|
|
218
|
+
# @param key [String, nil] object key involved in the operation
|
|
219
|
+
# @param destination [String, nil] destination path involved
|
|
79
220
|
# @raise [Errors::Error] subclass matching the cause of the error
|
|
80
|
-
def raise_storage_error(error)
|
|
221
|
+
def raise_storage_error(error, operation:, key: nil, destination: nil)
|
|
222
|
+
raise error if error.is_a?(Errors::Error)
|
|
223
|
+
|
|
224
|
+
@logger.debug("Operation #{operation} failed: #{error.class}: #{error.message}")
|
|
225
|
+
|
|
226
|
+
mapped = map_storage_error(error, key: key, destination: destination)
|
|
227
|
+
raise mapped unless mapped.nil?
|
|
228
|
+
|
|
229
|
+
raise Errors::StorageError, error.message
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Maps known storage failures to domain errors.
|
|
233
|
+
#
|
|
234
|
+
# @param error [StandardError] original error
|
|
235
|
+
# @param key [String, nil] object key involved in the operation
|
|
236
|
+
# @param destination [String, nil] destination path involved
|
|
237
|
+
# @return [Errors::Error, nil] mapped domain error, if recognized
|
|
238
|
+
def map_storage_error(error, key:, destination:)
|
|
81
239
|
case error
|
|
82
240
|
when Aws::Errors::MissingCredentialsError
|
|
83
|
-
|
|
84
|
-
"Missing or invalid credential environment variables."
|
|
241
|
+
Errors::ConfigurationError.new("Missing or invalid credential environment variables.")
|
|
85
242
|
when Aws::S3::Errors::NoSuchBucket
|
|
86
|
-
|
|
243
|
+
Errors::BucketNotFoundError.new("Bucket not found: #{@bucket}")
|
|
244
|
+
when Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::NotFound
|
|
245
|
+
Errors::ObjectNotFoundError.new("Object not found: #{key}")
|
|
87
246
|
when Seahorse::Client::NetworkingError
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
247
|
+
Errors::NetworkError.new(error.message)
|
|
248
|
+
when Errno::EACCES, Errno::EPERM
|
|
249
|
+
Errors::PermissionError.new("Permission denied to write the file: #{destination || key}")
|
|
91
250
|
end
|
|
92
251
|
end
|
|
93
252
|
end
|
data/lib/r2/version.rb
CHANGED
data/lib/r2.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cloudflare-r2-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rpzerosixcode
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: aws-sdk-s3
|
|
@@ -117,25 +117,20 @@ extensions: []
|
|
|
117
117
|
extra_rdoc_files: []
|
|
118
118
|
files:
|
|
119
119
|
- CHANGELOG.md
|
|
120
|
-
-
|
|
120
|
+
- LICENSE
|
|
121
121
|
- README.md
|
|
122
122
|
- Rakefile
|
|
123
123
|
- bin/r2
|
|
124
124
|
- docs/ARCHITECTURE.md
|
|
125
|
-
- docs/DECISIONS.md
|
|
126
|
-
- docs/DEVELOPMENT.md
|
|
127
125
|
- docs/FEATURES.md
|
|
128
|
-
- docs/ROADMAP.md
|
|
129
126
|
- docs/SECURITY.md
|
|
130
|
-
- docs/architecture/cli.md
|
|
131
|
-
- docs/architecture/configuration.md
|
|
132
|
-
- docs/architecture/errors.md
|
|
133
|
-
- docs/architecture/storage.md
|
|
134
|
-
- docs/architecture/testing.md
|
|
135
127
|
- lib/r2.rb
|
|
136
128
|
- lib/r2/cli.rb
|
|
137
129
|
- lib/r2/configuration.rb
|
|
130
|
+
- lib/r2/content_type.rb
|
|
138
131
|
- lib/r2/errors.rb
|
|
132
|
+
- lib/r2/logging.rb
|
|
133
|
+
- lib/r2/retry.rb
|
|
139
134
|
- lib/r2/storage.rb
|
|
140
135
|
- lib/r2/version.rb
|
|
141
136
|
homepage: https://github.com/rpzerosixcode/cloudflare-r2-cli
|
data/docs/DECISIONS.md
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# Decisions
|
|
2
|
-
|
|
3
|
-
This document records the main architectural and project-level decisions.
|
|
4
|
-
|
|
5
|
-
## Name
|
|
6
|
-
|
|
7
|
-
The publication name of the project is **`cloudflare-r2-cli`**.
|
|
8
|
-
|
|
9
|
-
For command usage, **`r2`** is used.
|
|
10
|
-
|
|
11
|
-
## Content
|
|
12
|
-
|
|
13
|
-
The project's public content is maintained in **English**.
|
|
14
|
-
|
|
15
|
-
## Changelog
|
|
16
|
-
|
|
17
|
-
Formal changelog maintenance starts with version **`1.0.0`** in
|
|
18
|
-
[CHANGELOG.md](../CHANGELOG.md).
|
|
19
|
-
|
|
20
|
-
## Versioning
|
|
21
|
-
|
|
22
|
-
The project follows **Semantic Versioning**, starting with version **`1.0.0`**.
|
|
23
|
-
|
|
24
|
-
## Dependency Injection
|
|
25
|
-
|
|
26
|
-
Dependencies should preferably be provided through **dependency injection**,
|
|
27
|
-
avoiding unnecessary coupling to concrete implementations.
|
|
28
|
-
|
|
29
|
-
The project does not use a dependency injection container. Dependencies are
|
|
30
|
-
provided directly by the components that require them.
|
data/docs/DEVELOPMENT.md
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
# Development
|
|
2
|
-
|
|
3
|
-
## Branches
|
|
4
|
-
|
|
5
|
-
The project uses two main branches:
|
|
6
|
-
|
|
7
|
-
* `develop`: development.
|
|
8
|
-
* `main`: stable version.
|
|
9
|
-
|
|
10
|
-
## Pull Requests
|
|
11
|
-
|
|
12
|
-
Changes between branches must be made through Pull Requests.
|
|
13
|
-
|
|
14
|
-
Pull Requests must be clear, objective and pass the required checks before merging.
|
|
15
|
-
|
|
16
|
-
## Commits
|
|
17
|
-
|
|
18
|
-
Commits must follow the **Conventional Commits** convention, using types such as:
|
|
19
|
-
|
|
20
|
-
* `feat`: new feature.
|
|
21
|
-
* `fix`: bug fix.
|
|
22
|
-
* `docs`: documentation change.
|
|
23
|
-
* `refactor`: refactoring without behavior change.
|
|
24
|
-
* `test`: creation or change of tests.
|
|
25
|
-
* `chore`: maintenance tasks.
|
|
26
|
-
|
|
27
|
-
## Continuous Integration
|
|
28
|
-
|
|
29
|
-
The project uses **GitHub Actions** to automatically validate changes on every
|
|
30
|
-
push to the `develop` and `main` branches and on Pull Requests.
|
|
31
|
-
|
|
32
|
-
The workflow defined in `.github/workflows/ci.yml` runs:
|
|
33
|
-
|
|
34
|
-
* **Lint** — RuboCop.
|
|
35
|
-
* **Tests** — unit, integration and E2E suites. The E2E scenarios are marked
|
|
36
|
-
as pending when the test credentials are not configured in the repository
|
|
37
|
-
secrets (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT`,
|
|
38
|
-
`R2_REGION` and `R2_TEST_BUCKET`).
|
|
39
|
-
* **Packaging** — gem build (`rake build`).
|
|
40
|
-
|
|
41
|
-
## Releases
|
|
42
|
-
|
|
43
|
-
Releases are published from tags in the `v*` format (for example, `v1.0.0`).
|
|
44
|
-
|
|
45
|
-
The workflow defined in `.github/workflows/release.yml`:
|
|
46
|
-
|
|
47
|
-
* validates the project (lint, tests and packaging);
|
|
48
|
-
* publishes the gem to RubyGems using the `RUBYGEMS_API_KEY` repository secret;
|
|
49
|
-
* creates a GitHub Release with the packed gem attached.
|
|
50
|
-
|
|
51
|
-
To release a new version:
|
|
52
|
-
|
|
53
|
-
1. Update the version in `lib/r2/version.rb` and the changelog in `CHANGELOG.md`.
|
|
54
|
-
2. Merge the changes into `main`.
|
|
55
|
-
3. Create and push the version tag:
|
|
56
|
-
```console
|
|
57
|
-
$ git tag v1.0.0
|
|
58
|
-
$ git push origin v1.0.0
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## Principles
|
|
62
|
-
|
|
63
|
-
Development must prioritize simplicity, organization and code maintenance.
|
|
64
|
-
|
|
65
|
-
Changes must remain aligned with the current scope of the project and its documentation.
|
data/docs/ROADMAP.md
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
# Roadmap
|
|
2
|
-
|
|
3
|
-
The roadmap tracks the planned evolution of the project.
|
|
4
|
-
|
|
5
|
-
## MVP
|
|
6
|
-
|
|
7
|
-
The MVP was completed through the phases below.
|
|
8
|
-
|
|
9
|
-
### Phase 1 — Initial Preparation
|
|
10
|
-
|
|
11
|
-
Initial structure and fundamental project definitions.
|
|
12
|
-
|
|
13
|
-
### Phase 2 — Features
|
|
14
|
-
|
|
15
|
-
Implementation of the essential MVP features.
|
|
16
|
-
|
|
17
|
-
#### Phase 2.1 — Upload
|
|
18
|
-
|
|
19
|
-
Implementation of the upload feature.
|
|
20
|
-
|
|
21
|
-
#### Phase 2.2 — Delete
|
|
22
|
-
|
|
23
|
-
Implementation of the delete feature.
|
|
24
|
-
|
|
25
|
-
#### Phase 2.3 — List
|
|
26
|
-
|
|
27
|
-
Implementation of the list feature.
|
|
28
|
-
|
|
29
|
-
### Phase 3 — Test Coverage
|
|
30
|
-
|
|
31
|
-
Implementation and expansion of the project's test coverage.
|
|
32
|
-
|
|
33
|
-
### Phase 4 — Refinement and Stabilization
|
|
34
|
-
|
|
35
|
-
Review, refinement and stabilization of the project.
|
|
36
|
-
|
|
37
|
-
- **Portability** — ensure the CLI works in different environments and operating systems.
|
|
38
|
-
- **Consistency** — review and standardize code, tests, messages and behaviors.
|
|
39
|
-
- **Error handling** — review exception handling and ensure clear, safe messages.
|
|
40
|
-
- **Security** — review settings and ensure sensitive information is not exposed.
|
|
41
|
-
- **Documentation** — review and update the public documentation according to the current state of the project.
|
|
42
|
-
- **Development context** — remove or isolate documentation exclusively related to the development process.
|
|
43
|
-
- **Packaging** — validate the build, installation and execution of the distributed package.
|
|
44
|
-
- **Continuous integration** — integrate the CI flow into the development process, ensuring automated execution of tests and checks.
|
|
45
|
-
- **Final validation** — run the full test suite and validate the project in a clean environment.
|
|
46
|
-
|
|
47
|
-
### Phase 5 — Release
|
|
48
|
-
|
|
49
|
-
Preparation and publication of the first stable version of the project.
|
|
50
|
-
|
|
51
|
-
- **Versioning** — adopt semantic versioning from `1.0.0`.
|
|
52
|
-
- **Changelog** — start formal changelog maintenance from `1.0.0`.
|
|
53
|
-
- **Documentation** — normalize the public documentation according to the stable version.
|
|
54
|
-
- **Development context** — remove or isolate development-specific documentation that is no longer relevant.
|
|
55
|
-
- **MVP context** — remove or update MVP-specific notes and references that no longer apply to the stable version.
|
|
56
|
-
- **Translation** — translate and standardize the project content to English.
|
|
57
|
-
- **Release validation** — validate the version, build and release artifacts before publication.
|
|
58
|
-
- **Publication** — publish the `cloudflare-r2-cli` package on RubyGems.
|
|
59
|
-
- **Post-release validation** — install the published package in a clean environment and confirm it works.
|
|
60
|
-
|
|
61
|
-
## Future Evolution
|
|
62
|
-
|
|
63
|
-
Possible next steps for the project:
|
|
64
|
-
|
|
65
|
-
- **Pagination and control of the number of returned objects** in the `list` command.
|
|
66
|
-
- **Additional configuration sources** such as files and command-line flags.
|
|
67
|
-
- **Multipart uploads** for large files.
|
|
68
|
-
|
|
69
|
-
> **Note:** The focus will remain a **minimally scalable base** and **essential features**, not optimizations.
|
data/docs/architecture/cli.md
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
# CLI
|
|
2
|
-
|
|
3
|
-
The CLI is responsible for interpreting the user's input, executing the
|
|
4
|
-
corresponding actions and presenting the results.
|
|
5
|
-
|
|
6
|
-
The `thor` gem is used to define and execute the commands.
|
|
7
|
-
|
|
8
|
-
## Responsibility
|
|
9
|
-
|
|
10
|
-
The CLI acts as a **minimal orchestrator**, coordinating the operations at a
|
|
11
|
-
high level.
|
|
12
|
-
|
|
13
|
-
## Boundaries
|
|
14
|
-
|
|
15
|
-
The CLI must not implement business rules, directly handle files or know
|
|
16
|
-
details of the implementations and services used.
|
|
17
|
-
|
|
18
|
-
The execution of the operations must be delegated to the responsible
|
|
19
|
-
components.
|
|
20
|
-
|
|
21
|
-
## Commands
|
|
22
|
-
|
|
23
|
-
The available commands and their behaviors are documented in
|
|
24
|
-
[FEATURES.md](../FEATURES.md).
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
# Configuration
|
|
2
|
-
|
|
3
|
-
The `Configuration` centralizes the application settings.
|
|
4
|
-
|
|
5
|
-
## Source
|
|
6
|
-
|
|
7
|
-
The settings are obtained directly from **environment variables**. The other
|
|
8
|
-
components must not access `ENV` directly.
|
|
9
|
-
|
|
10
|
-
## Variables
|
|
11
|
-
|
|
12
|
-
The required variables are `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`,
|
|
13
|
-
`R2_ENDPOINT` and `R2_BUCKET`.
|
|
14
|
-
|
|
15
|
-
The `R2_REGION` variable is optional and, when absent, uses the default value
|
|
16
|
-
`auto`, recommended for Cloudflare R2.
|
|
17
|
-
|
|
18
|
-
When a required variable is absent, the `Configuration` raises
|
|
19
|
-
`R2::Errors::ConfigurationError` with a message indicating the variable.
|
|
20
|
-
|
|
21
|
-
## Evolution
|
|
22
|
-
|
|
23
|
-
The source of the settings may be diversified in the future if a real need
|
|
24
|
-
arises.
|
data/docs/architecture/errors.md
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# Errors
|
|
2
|
-
|
|
3
|
-
Errors are centralized to standardize their handling and keep the application
|
|
4
|
-
behavior consistent.
|
|
5
|
-
|
|
6
|
-
## Hierarchy
|
|
7
|
-
|
|
8
|
-
All domain errors inherit from `R2::Errors::Error`:
|
|
9
|
-
|
|
10
|
-
* `ConfigurationError` — required configuration missing or invalid.
|
|
11
|
-
* `FileNotFoundError` — the given file does not exist.
|
|
12
|
-
* `InvalidFileError` — the given path is not a file.
|
|
13
|
-
* `PermissionError` — no permission to read the given file.
|
|
14
|
-
* `BucketNotFoundError` — the configured bucket does not exist.
|
|
15
|
-
* `NetworkError` — network failure while communicating with Cloudflare R2.
|
|
16
|
-
* `StorageError` — unclassified failure in the storage layer.
|
|
17
|
-
|
|
18
|
-
## Handling
|
|
19
|
-
|
|
20
|
-
The specific exceptions of the implementations are converted to the
|
|
21
|
-
`R2::Errors` hierarchy, avoiding exposing internal details of the libraries
|
|
22
|
-
and allowing consumers to catch the generic error or a specific error.
|
|
23
|
-
|
|
24
|
-
The CLI catches `R2::Errors::Error`, presents the message on the error output
|
|
25
|
-
and exits with a non-zero status code.
|