s3_migration 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +107 -0
- data/docs/migrating-files-to-s3.md +74 -0
- data/lib/s3_migration/auditor.rb +42 -0
- data/lib/s3_migration/file_lock.rb +21 -0
- data/lib/s3_migration/file_migrator.rb +148 -0
- data/lib/s3_migration/manifest.rb +49 -0
- data/lib/s3_migration/migrate_file_set_job.rb +22 -0
- data/lib/s3_migration/migrate_files_job.rb +28 -0
- data/lib/s3_migration/progress.rb +44 -0
- data/lib/s3_migration/quarantine.rb +154 -0
- data/lib/s3_migration/railtie.rb +8 -0
- data/lib/s3_migration/source_storage.rb +45 -0
- data/lib/s3_migration/storage_report.rb +38 -0
- data/lib/s3_migration/stored_checksum.rb +35 -0
- data/lib/s3_migration/tasks.rb +186 -0
- data/lib/s3_migration/upload_cleanup.rb +42 -0
- data/lib/s3_migration/upload_watch.rb +29 -0
- data/lib/s3_migration/version.rb +5 -0
- data/lib/s3_migration.rb +75 -0
- data/lib/tasks/s3_migration.rake +48 -0
- metadata +114 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module S3Migration
|
|
4
|
+
# Handles the disk copy of one manifest entry after migration: move it into <storage>/.quarantine
|
|
5
|
+
# (a rename on the same volume), restore it, and later purge old quarantined files. Every action
|
|
6
|
+
# is driven by manifest entries, so it is limited to the entries the manifest scope selects.
|
|
7
|
+
# All actions are dry runs unless apply: true.
|
|
8
|
+
class Quarantine
|
|
9
|
+
DIRECTORY = '.quarantine'
|
|
10
|
+
|
|
11
|
+
def self.base_path
|
|
12
|
+
SourceStorage.base_path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Deletes the quarantined copies of the files in the manifest, and nothing else.
|
|
16
|
+
# @return [Array<Integer>] [files purged (or that would be), files too recent]
|
|
17
|
+
def self.purge(apply:, min_age:, base: base_path, manifest: Manifest.new)
|
|
18
|
+
raise ArgumentError, "min_age must be positive, got #{min_age.inspect}" unless min_age.to_f.positive?
|
|
19
|
+
|
|
20
|
+
old, recent = quarantined_files(manifest, base).partition { |file| Time.current - File.mtime(file) >= min_age }
|
|
21
|
+
old.each { |file| delete_with_empty_parents(file, base) } if apply
|
|
22
|
+
[old.size, recent.size]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.quarantined_files(manifest, base)
|
|
26
|
+
manifest.each_entry.filter_map { |entry| quarantine_path(entry, base) }.select { |file| File.file?(file) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.disk_path(entry, base)
|
|
30
|
+
candidate = SourceStorage.path_for(entry[:old_id])
|
|
31
|
+
candidate if candidate.start_with?("#{base}/")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.quarantine_path(entry, base)
|
|
35
|
+
path = disk_path(entry, base)
|
|
36
|
+
File.join(base, DIRECTORY, path.delete_prefix("#{base}/")) if path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.delete_with_empty_parents(file, base)
|
|
40
|
+
root = File.join(base, DIRECTORY)
|
|
41
|
+
File.delete(file)
|
|
42
|
+
dir = File.dirname(file)
|
|
43
|
+
while dir.start_with?("#{root}/") && Dir.empty?(dir)
|
|
44
|
+
Dir.rmdir(dir)
|
|
45
|
+
dir = File.dirname(dir)
|
|
46
|
+
end
|
|
47
|
+
rescue Errno::ENOENT, Errno::ENOTEMPTY
|
|
48
|
+
nil # another purge got there first
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(entry, apply: false, verify_sha: true, base: nil, s3_storage: nil)
|
|
52
|
+
@entry = entry
|
|
53
|
+
@apply = apply
|
|
54
|
+
@verify_sha = verify_sha
|
|
55
|
+
@base = base || self.class.base_path
|
|
56
|
+
@s3_storage = s3_storage || Valkyrie::StorageAdapter.find(:repository_s3)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Symbol] :quarantined, :already_quarantined, :disk_copy_missing, :outside_base,
|
|
60
|
+
# :database_changed or :s3_mismatch
|
|
61
|
+
def quarantine
|
|
62
|
+
return :outside_base unless path
|
|
63
|
+
return (File.exist?(destination) ? :already_quarantined : :disk_copy_missing) unless File.exist?(path)
|
|
64
|
+
return :database_changed unless current_identifier == entry[:new_id]
|
|
65
|
+
return :s3_mismatch unless s3_matches_manifest?
|
|
66
|
+
|
|
67
|
+
quarantine_file if apply
|
|
68
|
+
:quarantined
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Puts the disk copy and the original identifier back, then removes the S3 upload and manifest entry
|
|
72
|
+
# nothing refers to any more. The disk copy is checked against the manifest first.
|
|
73
|
+
# @return [Symbol] :rolled_back, :skipped, :disk_copy_gone, :disk_copy_mismatch or :outside_base
|
|
74
|
+
def rollback
|
|
75
|
+
return :outside_base unless path
|
|
76
|
+
return :skipped unless current_identifier == entry[:new_id]
|
|
77
|
+
return :disk_copy_gone unless File.exist?(path) || File.exist?(destination)
|
|
78
|
+
return :disk_copy_mismatch unless disk_copy_matches_manifest?
|
|
79
|
+
|
|
80
|
+
restore if apply
|
|
81
|
+
:rolled_back
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
attr_reader :entry, :apply, :verify_sha, :base, :s3_storage
|
|
87
|
+
|
|
88
|
+
def manifest
|
|
89
|
+
@manifest ||= Manifest.new(storage: s3_storage)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def path
|
|
93
|
+
self.class.disk_path(entry, base)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def destination
|
|
97
|
+
self.class.quarantine_path(entry, base)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def current
|
|
101
|
+
@current ||= Hyrax.query_service.find_by(id: entry[:file_metadata_id])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def current_identifier
|
|
105
|
+
current.file_identifier.to_s
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def s3_matches_manifest?
|
|
109
|
+
s3_file = s3_storage.find_by(id: entry[:new_id])
|
|
110
|
+
return false unless s3_file.size.to_i == entry[:size]
|
|
111
|
+
|
|
112
|
+
!verify_sha || s3_file.checksum(digests: [Digest::SHA256.new]).first == entry[:sha256]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def disk_copy_matches_manifest?
|
|
116
|
+
file = File.exist?(path) ? path : destination
|
|
117
|
+
File.size(file) == entry[:size] && (!verify_sha || Digest::SHA256.file(file).hexdigest == entry[:sha256])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def quarantine_file
|
|
121
|
+
FileUtils.touch(path) # a rename keeps the old mtime, which purge reads as the time quarantined
|
|
122
|
+
move(path, destination)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def move(from, to)
|
|
126
|
+
FileUtils.mkdir_p(File.dirname(to))
|
|
127
|
+
FileUtils.mv(from, to)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def restore
|
|
131
|
+
move(destination, path) unless File.exist?(path)
|
|
132
|
+
current.file_identifier = Valkyrie::ID.new(entry[:old_id])
|
|
133
|
+
Hyrax.persister.save(resource: current)
|
|
134
|
+
discard_upload
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The rollback has already succeeded, so a failure here only leaves an unused upload behind.
|
|
138
|
+
def discard_upload
|
|
139
|
+
upload_cleanup.call(entry[:new_id])
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
log_discard_failure(e)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def upload_cleanup
|
|
145
|
+
UploadCleanup.new(file_set_id: entry[:file_set_id], file_metadata_id: entry[:file_metadata_id],
|
|
146
|
+
s3_storage:, manifest:)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def log_discard_failure(error)
|
|
150
|
+
Hyrax.logger.warn("S3Migration: rolled back #{entry[:file_set_id]} but could not remove " \
|
|
151
|
+
"#{entry[:new_id]}: #{error.class}: #{error.message}")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module S3Migration
|
|
4
|
+
# The Valkyrie storage adapter whose files are moved to S3. Only a plain Valkyrie::Storage::Disk is
|
|
5
|
+
# supported: versioned disk keeps versions and deletion markers that a copy to S3 would lose.
|
|
6
|
+
module SourceStorage
|
|
7
|
+
class Unsupported < StandardError; end
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
attr_writer :adapter_name
|
|
11
|
+
|
|
12
|
+
# @return [Symbol] the name the source adapter is registered under (default :disk)
|
|
13
|
+
def adapter_name
|
|
14
|
+
@adapter_name ||= :disk
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Valkyrie::Storage::Disk]
|
|
18
|
+
# @raise [Unsupported] when no such adapter is registered, or it is not a plain disk adapter
|
|
19
|
+
def adapter
|
|
20
|
+
found = Valkyrie::StorageAdapter.find(adapter_name)
|
|
21
|
+
return found if found.is_a?(Valkyrie::Storage::Disk)
|
|
22
|
+
|
|
23
|
+
raise Unsupported, "The :#{adapter_name} storage adapter is a #{found.class}. Only Valkyrie::Storage::Disk " \
|
|
24
|
+
'(disk:// ids) can be migrated; see docs/migrating-files-to-s3.md.'
|
|
25
|
+
rescue Valkyrie::StorageAdapter::AdapterNotFoundError
|
|
26
|
+
raise Unsupported, "No Valkyrie storage adapter is registered as :#{adapter_name} (registered: " \
|
|
27
|
+
"#{Valkyrie::StorageAdapter.storage_adapters.keys.join(', ')}). Set " \
|
|
28
|
+
'S3Migration::SourceStorage.adapter_name to the plain disk adapter that holds the files.'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param identifier [#to_s] a file identifier
|
|
32
|
+
def handles?(identifier)
|
|
33
|
+
adapter.handles?(id: Valkyrie::ID.new(identifier.to_s))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def path_for(identifier)
|
|
37
|
+
adapter.file_path(identifier)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def base_path
|
|
41
|
+
adapter.base_path.to_s
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module S3Migration
|
|
4
|
+
# Tallies the files by use (original, thumbnail, ...) and storage scheme.
|
|
5
|
+
class StorageReport
|
|
6
|
+
# @param progress [S3Migration::Progress, nil] ticked once per file set
|
|
7
|
+
# @return [Hash{Array<String>=>Array<Integer>}] { [use, scheme] => [file count, bytes] }
|
|
8
|
+
def self.call(progress: nil)
|
|
9
|
+
totals = Hash.new { |hash, key| hash[key] = [0, 0] }
|
|
10
|
+
S3Migration.each_file_set do |file_set|
|
|
11
|
+
progress&.tick
|
|
12
|
+
add_files(totals, files_for(file_set))
|
|
13
|
+
end
|
|
14
|
+
totals
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.add_files(totals, files)
|
|
18
|
+
totals[['(file record missing)', '-']][0] += 1 unless files
|
|
19
|
+
files&.each { |file| add_file(totals, file) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.add_file(totals, file)
|
|
23
|
+
count_and_bytes = totals[[use_for(file), file.file_identifier.to_s[/\A[a-z]+(?=:)/].to_s]]
|
|
24
|
+
count_and_bytes[0] += 1
|
|
25
|
+
count_and_bytes[1] += file.recorded_size.first.to_i
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.files_for(file_set)
|
|
29
|
+
Hyrax.custom_queries.find_files(file_set:)
|
|
30
|
+
rescue Valkyrie::Persistence::ObjectNotFoundError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.use_for(file)
|
|
35
|
+
Array(file.pcdm_use).map { |uri| uri.to_s.split('#').last }.join(',')
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module S3Migration
|
|
4
|
+
# The checksum Hyrax recorded for a file at characterization (FileMetadata#checksum). FITS reports
|
|
5
|
+
# a bare MD5 hex string; an explicit urn:<algorithm>: prefix is also understood. Anything else
|
|
6
|
+
# (unknown prefix, wrong length) is treated as no checksum rather than as a mismatch.
|
|
7
|
+
class StoredChecksum
|
|
8
|
+
ALGORITHMS = { 'md5' => [Digest::MD5, 32], 'sha1' => [Digest::SHA1, 40], 'sha256' => [Digest::SHA256, 64] }.freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :algorithm, :digest
|
|
11
|
+
|
|
12
|
+
# @return [StoredChecksum, nil] nil when there is no usable checksum
|
|
13
|
+
def self.for(original)
|
|
14
|
+
match = /\A(?:urn:(?<algorithm>md5|sha1|sha256):)?(?<digest>\h+)\z/i.match(original.checksum.first.to_s.strip)
|
|
15
|
+
return unless match
|
|
16
|
+
|
|
17
|
+
algorithm = match[:algorithm]&.downcase || 'md5'
|
|
18
|
+
digest = match[:digest].downcase
|
|
19
|
+
new(algorithm, digest) if digest.length == ALGORITHMS.fetch(algorithm).last
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(algorithm, digest)
|
|
23
|
+
@algorithm = algorithm
|
|
24
|
+
@digest = digest
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def digest_class
|
|
28
|
+
ALGORITHMS.fetch(algorithm).first
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_s
|
|
32
|
+
"#{algorithm}:#{digest}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module S3Migration
|
|
4
|
+
# The behaviour behind the s3_migration:* rake tasks. The environment and output are injected so it can be tested.
|
|
5
|
+
class Tasks
|
|
6
|
+
# Raised where a task should stop with a message instead of running.
|
|
7
|
+
class Abort < StandardError; end
|
|
8
|
+
|
|
9
|
+
ACTED_ON = %i[quarantined rolled_back].freeze
|
|
10
|
+
|
|
11
|
+
# Runs one task for its rake wrapper, turning an Abort into a rake abort.
|
|
12
|
+
def self.run(name)
|
|
13
|
+
new.public_send(name)
|
|
14
|
+
rescue Abort => e
|
|
15
|
+
abort e.message
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(env: ENV, out: $stdout)
|
|
19
|
+
@env = env
|
|
20
|
+
@out = out
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def migrate
|
|
24
|
+
require_storage!
|
|
25
|
+
dry_run = flag('DRY_RUN')
|
|
26
|
+
return enqueue unless dry_run || flag('INLINE')
|
|
27
|
+
|
|
28
|
+
out.puts "[DRY RUN] No changes will be made.\n\n" if dry_run
|
|
29
|
+
migrate_inline(dry_run:)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def verify
|
|
33
|
+
counts = Hash.new(0)
|
|
34
|
+
each_original(counts) do |file_set, original|
|
|
35
|
+
guarded(counts, file_set, :problem) { audit_counted?(counts, file_set, original) }
|
|
36
|
+
end
|
|
37
|
+
out.puts "\nOK: #{counts[:ok]} Problems: #{counts[:problem]} " \
|
|
38
|
+
"Not yet in S3: #{counts[:not_in_s3]} No original: #{counts[:no_original]}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def report
|
|
42
|
+
out.puts 'use | scheme | files | GB'
|
|
43
|
+
StorageReport.call(progress: Progress.new).sort.each do |(use, scheme), (count, bytes)|
|
|
44
|
+
out.puts "#{use} | #{scheme} | #{count} | #{(bytes / 1e9).round(2)}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def quarantine
|
|
49
|
+
require_storage!
|
|
50
|
+
out.puts "[DRY RUN] Nothing will be moved. Set APPLY=true to move.\n\n" unless flag('APPLY')
|
|
51
|
+
each_entry('Quarantine', :quarantine)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rollback
|
|
55
|
+
require_storage!
|
|
56
|
+
out.puts "[DRY RUN] Nothing will change. Set APPLY=true to roll back.\n\n" unless flag('APPLY')
|
|
57
|
+
each_entry('Rolling back', :rollback)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def purge
|
|
61
|
+
require_storage!
|
|
62
|
+
apply = flag('APPLY')
|
|
63
|
+
out.puts "[DRY RUN] Nothing will be deleted. Set APPLY=true to delete.\n\n" unless apply
|
|
64
|
+
purged, kept = Quarantine.purge(apply:, min_age: min_age_days.days)
|
|
65
|
+
out.puts "#{apply ? 'Deleted' : 'Would delete'}: #{purged} Too recent: #{kept}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
attr_reader :env, :out
|
|
71
|
+
|
|
72
|
+
def flag(name, default: 'false')
|
|
73
|
+
ActiveModel::Type::Boolean.new.cast(env.fetch(name, default))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def limit
|
|
77
|
+
env['LIMIT']&.to_i
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def min_age_days
|
|
81
|
+
days = Float(env.fetch('MIN_AGE_DAYS', '14'), exception: false)
|
|
82
|
+
unless days&.positive? && days.finite?
|
|
83
|
+
raise Abort,
|
|
84
|
+
"MIN_AGE_DAYS must be a positive number, got #{env['MIN_AGE_DAYS'].inspect}."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
days
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def require_storage!
|
|
91
|
+
unless Valkyrie::StorageAdapter.storage_adapters.key?(:repository_s3)
|
|
92
|
+
raise Abort,
|
|
93
|
+
'Set up the :repository_s3 Valkyrie storage adapter first (see docs/migrating-files-to-s3.md).'
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
SourceStorage.adapter
|
|
97
|
+
rescue SourceStorage::Unsupported => e
|
|
98
|
+
raise Abort, e.message
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def enqueue
|
|
102
|
+
MigrateFilesJob.perform_later(limit:)
|
|
103
|
+
out.puts 'Enqueued S3Migration::MigrateFilesJob'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def tally(counts)
|
|
107
|
+
counts.map { |status, count| "#{status}: #{count}" }.join(' ')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def each_original(counts, &)
|
|
111
|
+
progress = Progress.new(detail: -> { tally(counts) })
|
|
112
|
+
on_error = lambda do |file_set, error|
|
|
113
|
+
counts[:no_original] += 1
|
|
114
|
+
out.puts " NO_ORIGINAL #{file_set.id} - #{missing_original_detail(file_set, error)}"
|
|
115
|
+
end
|
|
116
|
+
S3Migration.each_original(limit:, progress:, on_error:, &)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def missing_original_detail(file_set, error)
|
|
120
|
+
if file_set.try(:file_ids).blank?
|
|
121
|
+
'file set has no files'
|
|
122
|
+
elsif error.is_a?(Valkyrie::Persistence::ObjectNotFoundError)
|
|
123
|
+
'original file record not found'
|
|
124
|
+
else
|
|
125
|
+
"#{error.class}: #{error.message}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def migrate_inline(dry_run:)
|
|
130
|
+
counts = Hash.new(0)
|
|
131
|
+
each_original(counts) do |file_set, original|
|
|
132
|
+
guarded(counts, file_set, :error) { migrate_counted?(counts, file_set, original, dry_run:) }
|
|
133
|
+
end
|
|
134
|
+
out.puts "\nExamined #{counts.values.sum} file sets. #{tally(counts)}".strip
|
|
135
|
+
out.puts 'No file sets were found; check the model names and query services.' if counts.empty?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Runs the block for one file set; a StandardError is counted under key and printed, and the run goes on.
|
|
139
|
+
def guarded(counts, file_set, key)
|
|
140
|
+
yield
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
counts[key] += 1
|
|
143
|
+
out.puts " #{key.to_s.upcase} #{file_set.id} - #{e.class}: #{e.message}"
|
|
144
|
+
true
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def audit_counted?(counts, file_set, original)
|
|
148
|
+
result = Auditor.new(original:).call
|
|
149
|
+
counts[result.status] += 1
|
|
150
|
+
return false if result.status == :not_in_s3
|
|
151
|
+
|
|
152
|
+
out.puts " PROBLEM #{file_set.id} - #{result.problems.join('; ')}" if result.status == :problem
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def migrate_counted?(counts, file_set, original, dry_run:)
|
|
157
|
+
result = FileMigrator.new(file_set:, original:, dry_run:).call
|
|
158
|
+
counts[result.status] += 1
|
|
159
|
+
return false if result.status == :already_migrated
|
|
160
|
+
|
|
161
|
+
out.puts " #{result.status.to_s.upcase} #{file_set.id} #{result.message}".rstrip
|
|
162
|
+
true
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def each_entry(label, action)
|
|
166
|
+
counts = Hash.new(0)
|
|
167
|
+
progress = Progress.new(detail: -> { tally(counts) })
|
|
168
|
+
Manifest.new.each_entry do |entry|
|
|
169
|
+
progress.tick
|
|
170
|
+
process_entry(entry, counts, label, action)
|
|
171
|
+
end
|
|
172
|
+
out.puts "\n#{tally(counts)}".strip
|
|
173
|
+
out.puts 'No manifest entries found.' if counts.empty?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def process_entry(entry, counts, label, action)
|
|
177
|
+
status = Quarantine.new(entry, apply: flag('APPLY'),
|
|
178
|
+
verify_sha: flag('VERIFY_SHA', default: 'true')).public_send(action)
|
|
179
|
+
counts[status] += 1
|
|
180
|
+
out.puts " #{label} #{entry[:file_set_id]}" if ACTED_ON.include?(status)
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
counts[:error] += 1
|
|
183
|
+
out.puts " ERROR #{entry[:file_set_id]} - #{e.class}: #{e.message}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module S3Migration
|
|
4
|
+
# Removes an upload the database does not point at, along with the manifest entry that names it.
|
|
5
|
+
class UploadCleanup
|
|
6
|
+
def initialize(file_set_id:, file_metadata_id:, s3_storage:, manifest:)
|
|
7
|
+
@file_set_id = file_set_id.to_s
|
|
8
|
+
@file_metadata_id = file_metadata_id
|
|
9
|
+
@s3_storage = s3_storage
|
|
10
|
+
@manifest = manifest
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The save can raise after committing (a timeout, say), so the database decides, not the exception.
|
|
14
|
+
# @param upload_id [Valkyrie::ID, String] the S3 identifier to discard
|
|
15
|
+
def call(upload_id)
|
|
16
|
+
upload_id = Valkyrie::ID.new(upload_id.to_s)
|
|
17
|
+
return if referenced?(upload_id)
|
|
18
|
+
|
|
19
|
+
s3_storage.delete(id: upload_id)
|
|
20
|
+
forget_entry(upload_id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :file_set_id, :file_metadata_id, :s3_storage, :manifest
|
|
26
|
+
|
|
27
|
+
# When the database cannot be read, keep the upload: the manifest entry still points at it.
|
|
28
|
+
def referenced?(upload_id)
|
|
29
|
+
Hyrax.query_service.find_by(id: file_metadata_id).file_identifier == upload_id
|
|
30
|
+
rescue StandardError
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Only an entry naming this upload goes; another process's entry for the file set stays. Failing here
|
|
35
|
+
# must not hide the error that sent the migration to cleanup.
|
|
36
|
+
def forget_entry(upload_id)
|
|
37
|
+
manifest.delete(file_set_id) if manifest.read(file_set_id)&.dig(:new_id) == upload_id.to_s
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
Hyrax.logger.warn("S3Migration: could not remove the manifest entry for #{file_set_id}: #{e.class}: #{e.message}")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module S3Migration
|
|
4
|
+
# Remembers which objects exist under a file set's prefix before an upload. valkyrie-shrine checks the copy
|
|
5
|
+
# inside upload, so an upload that fails verification has already written its object, under a key the caller
|
|
6
|
+
# never receives; this finds and removes it. Only safe while FileLock keeps other jobs from uploading for the
|
|
7
|
+
# same file set.
|
|
8
|
+
class UploadWatch
|
|
9
|
+
def initialize(s3_storage:, file_set_id:)
|
|
10
|
+
shrine = s3_storage.shrine
|
|
11
|
+
@bucket = shrine.bucket
|
|
12
|
+
@prefix = "#{[shrine.prefix, file_set_id].compact.join('/')}/"
|
|
13
|
+
@before = keys
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Removes the objects created since the snapshot. It never raises: it runs while another error is propagating.
|
|
17
|
+
def discard_new
|
|
18
|
+
@bucket.objects(prefix: @prefix).each { |object| object.delete unless @before.include?(object.key) }
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
Hyrax.logger.warn("S3Migration: could not remove objects left under #{@prefix}: #{e.class}: #{e.message}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def keys
|
|
26
|
+
@bucket.objects(prefix: @prefix).map(&:key)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/s3_migration.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'valkyrie/shrine' # Shrine and the S3 adapter's errors are used here; do not rely on the app requiring them
|
|
4
|
+
require 'zeitwerk'
|
|
5
|
+
|
|
6
|
+
loader = Zeitwerk::Loader.for_gem
|
|
7
|
+
loader.ignore("#{__dir__}/tasks")
|
|
8
|
+
loader.setup
|
|
9
|
+
|
|
10
|
+
# Moves original files from the disk storage adapter to the S3 repository adapter
|
|
11
|
+
# (see docs/migrating-files-to-s3.md).
|
|
12
|
+
module S3Migration
|
|
13
|
+
class << self
|
|
14
|
+
# Hosts that partition one repository into several (e.g. multitenant apps) can set a callable
|
|
15
|
+
# returning a label for the current partition; manifest entries are then kept per label, so
|
|
16
|
+
# the quarantine and rollback tasks only see the current partition's files.
|
|
17
|
+
attr_writer :scope_provider
|
|
18
|
+
|
|
19
|
+
def scope_provider
|
|
20
|
+
@scope_provider ||= -> {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def scope
|
|
24
|
+
scope_provider.call.presence
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Yields every file set stored in the repository, once. Lists each non-Wings query service directly,
|
|
28
|
+
# once per name Hyrax registers for file sets (Hyrax::ModelRegistry.file_set_classes, which includes
|
|
29
|
+
# Hyrax.config.file_set_model). The composite query service converts the model it is given, so apps
|
|
30
|
+
# that store file sets under the legacy "FileSet" name get nothing back when asked through it.
|
|
31
|
+
def each_file_set
|
|
32
|
+
return enum_for(:each_file_set) unless block_given?
|
|
33
|
+
|
|
34
|
+
seen = Set.new
|
|
35
|
+
query_services.each do |service|
|
|
36
|
+
Hyrax::ModelRegistry.file_set_classes.each do |model|
|
|
37
|
+
service.find_all_of_model(model:).each { |file_set| yield file_set if seen.add?(file_set.id.to_s) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Yields [file_set, original file] for each file set that has an original. A file set whose original
|
|
43
|
+
# cannot be loaded (for example a dangling file record) goes to on_error and is skipped, so one bad
|
|
44
|
+
# record does not stop the run.
|
|
45
|
+
def each_original(limit: nil, progress: nil, on_error: nil)
|
|
46
|
+
return enum_for(:each_original, limit:, progress:, on_error:) unless block_given?
|
|
47
|
+
|
|
48
|
+
done = 0
|
|
49
|
+
each_file_set do |file_set|
|
|
50
|
+
break if limit && done >= limit
|
|
51
|
+
|
|
52
|
+
progress&.tick
|
|
53
|
+
original = original_for(file_set, on_error:)
|
|
54
|
+
next unless original
|
|
55
|
+
|
|
56
|
+
done += 1 if yield(file_set, original)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def original_for(file_set, on_error: nil)
|
|
61
|
+
Hyrax.custom_queries.find_original_file(file_set:)
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
on_error&.call(file_set, e)
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def query_services
|
|
68
|
+
service = Hyrax.query_service
|
|
69
|
+
services = service.respond_to?(:services) ? service.services : [service]
|
|
70
|
+
services.reject { |inner| defined?(Wings::Valkyrie::QueryService) && inner.is_a?(Wings::Valkyrie::QueryService) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
require 's3_migration/railtie' if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Thin wrappers: the work lives in S3Migration::Tasks. See docs/migrating-files-to-s3.md.
|
|
4
|
+
namespace :s3_migration do
|
|
5
|
+
desc <<~DESC
|
|
6
|
+
Move original files from the plain disk storage adapter (disk:// ids) to the :repository_s3 adapter. By default this
|
|
7
|
+
enqueues jobs (one per file set; set S3_MIGRATION_QUEUE to use a dedicated queue). Each upload is
|
|
8
|
+
verified by SHA-256 read-back, and against the stored characterization checksum, before the identifier
|
|
9
|
+
is switched; the disk copy is never touched.
|
|
10
|
+
Env: DRY_RUN=true runs inline and only reports; INLINE=true runs inline for real; LIMIT=<n> files.
|
|
11
|
+
DESC
|
|
12
|
+
task migrate: :environment do
|
|
13
|
+
S3Migration::Tasks.run(:migrate)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
desc <<~DESC
|
|
17
|
+
Read-only audit of originals already in S3: object readable, recorded size and stored checksum match.
|
|
18
|
+
Get zero problems before deleting anything from disk. Env: LIMIT=<n> files.
|
|
19
|
+
DESC
|
|
20
|
+
task verify: :environment do
|
|
21
|
+
S3Migration::Tasks.run(:verify)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
desc 'Read-only report of file storage by file use and scheme (disk, shrine, ...).'
|
|
25
|
+
task report: :environment do
|
|
26
|
+
S3Migration::Tasks.run(:report)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
desc <<~DESC
|
|
30
|
+
Move the disk copies of manifested files to <storage>/.quarantine after re-checking the database
|
|
31
|
+
and the S3 object against the manifest. Dry run unless APPLY=true. VERIFY_SHA=false skips the hash.
|
|
32
|
+
DESC
|
|
33
|
+
task quarantine: :environment do
|
|
34
|
+
S3Migration::Tasks.run(:quarantine)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
desc 'Undo a migration from the manifest (restores the disk copy and id, removes the unused upload). ' \
|
|
38
|
+
'Dry run unless APPLY=true.'
|
|
39
|
+
task rollback: :environment do
|
|
40
|
+
S3Migration::Tasks.run(:rollback)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc 'Permanently delete the quarantined copies of manifested files older than MIN_AGE_DAYS (default 14). ' \
|
|
44
|
+
'Dry run unless APPLY=true.'
|
|
45
|
+
task purge: :environment do
|
|
46
|
+
S3Migration::Tasks.run(:purge)
|
|
47
|
+
end
|
|
48
|
+
end
|