multi_compress 0.6.0 → 0.6.1

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/GET_STARTED.md +1 -1
  4. data/README.md +1 -1
  5. data/THIRD_PARTY_NOTICES.md +1 -1
  6. data/ext/multi_compress/multi_compress.c +230 -77
  7. data/ext/multi_compress/vendor/.vendored +1 -1
  8. data/ext/multi_compress/vendor/brotli/c/common/version.h +2 -2
  9. data/ext/multi_compress/vendor/brotli/c/dec/decode.c +59 -30
  10. data/ext/multi_compress/vendor/brotli/c/include/brotli/decode.h +1 -3
  11. data/lib/multi_compress/active_record.rb +11 -5
  12. data/lib/multi_compress/codec.rb +164 -94
  13. data/lib/multi_compress/database/api.rb +42 -0
  14. data/lib/multi_compress/database/decoding.rb +42 -0
  15. data/lib/multi_compress/database/dictionary.rb +72 -0
  16. data/lib/multi_compress/database/encoding.rb +31 -0
  17. data/lib/multi_compress/database/header.rb +71 -0
  18. data/lib/multi_compress/database/operations.rb +73 -0
  19. data/lib/multi_compress/database/validation.rb +95 -0
  20. data/lib/multi_compress/database.rb +10 -282
  21. data/lib/multi_compress/db_deployment/bundle_archiver.rb +70 -0
  22. data/lib/multi_compress/db_deployment/bundle_metadata.rb +56 -0
  23. data/lib/multi_compress/db_deployment/bundle_stager.rb +62 -0
  24. data/lib/multi_compress/db_deployment/commands.rb +309 -0
  25. data/lib/multi_compress/db_deployment/deployment_bundle.rb +80 -0
  26. data/lib/multi_compress/db_deployment/deterministic_tar_writer.rb +76 -0
  27. data/lib/multi_compress/db_deployment/dictionary_registry.rb +244 -0
  28. data/lib/multi_compress/db_deployment/readable_view.rb +112 -0
  29. data/lib/multi_compress/db_deployment/readable_view_options.rb +51 -0
  30. data/lib/multi_compress/db_deployment/sql_identifiers.rb +45 -0
  31. data/lib/multi_compress/db_deployment.rb +10 -844
  32. data/lib/multi_compress/reader/buffer_operations.rb +57 -0
  33. data/lib/multi_compress/reader/read_operations.rb +67 -0
  34. data/lib/multi_compress/reader.rb +97 -0
  35. data/lib/multi_compress/stream_io.rb +13 -0
  36. data/lib/multi_compress/version.rb +1 -1
  37. data/lib/multi_compress/writer.rb +85 -0
  38. data/lib/multi_compress.rb +42 -333
  39. metadata +25 -6
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiCompress
4
+ module DBDeployment
5
+ class BundleArchiver
6
+ DEFAULT_EPOCH = 315_619_200
7
+ attr_reader :output, :root_name
8
+ private :output, :root_name
9
+
10
+ def initialize(output, root_name)
11
+ @output = output
12
+ @root_name = root_name
13
+ end
14
+
15
+ def write!(temporary_directory)
16
+ temporary_output = temporary_path
17
+ FileUtils.mkdir_p(File.dirname(output))
18
+ write_archive(temporary_output, temporary_directory)
19
+ File.rename(temporary_output, output)
20
+ ensure
21
+ File.delete(temporary_output) if defined?(temporary_output) && File.exist?(temporary_output)
22
+ end
23
+
24
+ private
25
+
26
+ def temporary_path
27
+ File.join(File.dirname(output), ".#{File.basename(output)}.#{Process.pid}.#{SecureRandom.hex(8)}.tmp")
28
+ end
29
+
30
+ def write_archive(path, temporary_directory)
31
+ File.open(path, "wb", 0o644) do |file|
32
+ Zlib::GzipWriter.wrap(file) do |gzip|
33
+ gzip.mtime = archive_epoch
34
+ DeterministicTarWriter.new(gzip, archive_epoch) do |tar|
35
+ add_tree(tar, File.join(temporary_directory, root_name), root_name)
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ def archive_epoch
42
+ value = ENV.fetch("SOURCE_DATE_EPOCH", nil)
43
+ value&.match?(/\A\d+\z/) ? value.to_i : DEFAULT_EPOCH
44
+ end
45
+
46
+ def add_tree(tar, directory, archive_path)
47
+ tar.mkdir(archive_path, mode(directory))
48
+ Dir.children(directory).sort.each { |entry| add_entry(tar, directory, archive_path, entry) }
49
+ end
50
+
51
+ def add_entry(tar, directory, archive_path, entry)
52
+ source = File.join(directory, entry)
53
+ destination = File.join(archive_path, entry)
54
+ return add_tree(tar, source, destination) if File.directory?(source)
55
+
56
+ add_file(tar, source, destination)
57
+ end
58
+
59
+ def add_file(tar, source, destination)
60
+ tar.add_file_simple(destination, mode(source), File.size(source)) do |destination_io|
61
+ File.open(source, "rb") { |source_io| IO.copy_stream(source_io, destination_io) }
62
+ end
63
+ end
64
+
65
+ def mode(path)
66
+ File.stat(path).mode & 0o777
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiCompress
4
+ module DBDeployment
5
+ class BundleMetadata
6
+ attr_reader :target
7
+ private :target
8
+
9
+ def initialize(target)
10
+ @target = target
11
+ end
12
+
13
+ def write!(bundle_root)
14
+ write_manifest(bundle_root, manifest_files(bundle_root))
15
+ write_checksums(bundle_root)
16
+ end
17
+
18
+ private
19
+
20
+ def manifest_files(root)
21
+ regular_files(root).reject { |path| %w[manifest.json SHA256SUMS].include?(File.basename(path)) }
22
+ .sort.map { |path| file_record(root, path) }
23
+ end
24
+
25
+ def file_record(root, path)
26
+ { "path" => relative_to(root, path), "sha256" => Digest::SHA256.file(path).hexdigest }
27
+ end
28
+
29
+ def write_manifest(root, files)
30
+ manifest = {
31
+ "bundle_format_version" => BUNDLE_FORMAT_VERSION,
32
+ "gem" => { "name" => "multi_compress", "version" => MultiCompress::VERSION },
33
+ "database_target" => target,
34
+ "database_envelope" => FORMAT,
35
+ "files" => files
36
+ }
37
+ File.write(File.join(root, "manifest.json"), "#{JSON.pretty_generate(manifest)}\n")
38
+ end
39
+
40
+ def write_checksums(root)
41
+ checksums = regular_files(root).reject { |path| File.basename(path) == "SHA256SUMS" }.sort.map do |path|
42
+ "#{Digest::SHA256.file(path).hexdigest} #{relative_to(root, path)}"
43
+ end
44
+ File.write(File.join(root, "SHA256SUMS"), "#{checksums.join("\n")}\n")
45
+ end
46
+
47
+ def regular_files(root)
48
+ Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).select { |path| File.file?(path) }
49
+ end
50
+
51
+ def relative_to(root, path)
52
+ Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiCompress
4
+ module DBDeployment
5
+ class BundleStager
6
+ attr_reader :root, :target
7
+ private :root, :target
8
+
9
+ IGNORED_ENTRIES = [".DS_Store", ".git", "build", "test"].freeze
10
+
11
+ def initialize(root, target)
12
+ @root = root
13
+ @target = target
14
+ end
15
+
16
+ def stage!(bundle_root)
17
+ target.fetch(:source_paths).each { |relative| copy_path!(relative, bundle_root) }
18
+ stage_template(bundle_root)
19
+ end
20
+
21
+ private
22
+
23
+ def stage_template(bundle_root)
24
+ template = target.fetch(:template_path)
25
+ Dir.children(File.join(root, template)).sort.each do |entry|
26
+ next if ignored?(entry)
27
+
28
+ copy_path!(File.join(template, entry), bundle_root, destination: entry)
29
+ end
30
+ end
31
+
32
+ def copy_path!(relative, bundle_root, destination: relative)
33
+ source = File.join(root, relative)
34
+ raise Error, "package source is missing: #{relative}" unless File.exist?(source)
35
+
36
+ destination_path = File.join(bundle_root, destination)
37
+ return copy_directory(source, relative, destination_path, destination, bundle_root) if File.directory?(source)
38
+
39
+ copy_file(source, destination_path)
40
+ end
41
+
42
+ def copy_directory(source, relative, destination_path, destination, bundle_root)
43
+ FileUtils.mkdir_p(destination_path)
44
+ Dir.children(source).sort.each do |entry|
45
+ next if ignored?(entry)
46
+
47
+ copy_path!(File.join(relative, entry), bundle_root, destination: File.join(destination, entry))
48
+ end
49
+ end
50
+
51
+ def copy_file(source, destination)
52
+ FileUtils.mkdir_p(File.dirname(destination))
53
+ FileUtils.copy_file(source, destination)
54
+ File.chmod(File.stat(source).mode & 0o777, destination)
55
+ end
56
+
57
+ def ignored?(entry)
58
+ IGNORED_ENTRIES.include?(entry)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiCompress
4
+ module DBDeployment
5
+ module CommandHelpers
6
+ private
7
+
8
+ def validated_target!(argv, targets)
9
+ target = argv.shift
10
+ raise OptionParser::MissingArgument, "TARGET must be postgres or mysql" if target.nil?
11
+ raise OptionParser::InvalidArgument, "TARGET must be postgres or mysql" unless targets.include?(target)
12
+
13
+ target
14
+ end
15
+
16
+ def show_help(parser)
17
+ puts parser
18
+ 0
19
+ end
20
+
21
+ def write_sql(sql, output)
22
+ if output
23
+ write_text_atomically(output, sql)
24
+ puts File.expand_path(output)
25
+ else
26
+ $stdout.write(sql)
27
+ end
28
+ end
29
+
30
+ def write_text_atomically(path, contents)
31
+ directory = File.dirname(File.expand_path(path))
32
+ FileUtils.mkdir_p(directory)
33
+ temporary = File.join(directory, ".multi-compress-view-#{Process.pid}-#{SecureRandom.hex(8)}")
34
+ File.open(temporary, "wb", 0o644) { |file| file.write(contents) }
35
+ File.rename(temporary, path)
36
+ ensure
37
+ File.delete(temporary) if defined?(temporary) && File.exist?(temporary)
38
+ end
39
+
40
+ def add_value_options(parser, options, definitions)
41
+ definitions.each do |key, flag, description|
42
+ parser.on("--#{flag} NAME", description) { |value| options[key] = value }
43
+ end
44
+ end
45
+ end
46
+
47
+ class CLI
48
+ COMMANDS = {
49
+ "package" => "PackageCommand",
50
+ "view" => "ViewCommand",
51
+ "registry" => "RegistryCommand"
52
+ }.freeze
53
+ HELP_COMMANDS = ["help", "--help", "-h", nil].freeze
54
+
55
+ def self.run(argv)
56
+ new.run(argv)
57
+ end
58
+
59
+ def run(argv)
60
+ dispatch(argv)
61
+ rescue OptionParser::ParseError, Error => e
62
+ report_error(e, 2)
63
+ rescue SystemCallError, IOError => e
64
+ report_error(e, 1)
65
+ end
66
+
67
+ private
68
+
69
+ def dispatch(argv)
70
+ command = argv.shift
71
+ return show_help if HELP_COMMANDS.include?(command)
72
+
73
+ command_class = DBDeployment.const_get(
74
+ COMMANDS.fetch(command) { raise OptionParser::InvalidOption, "unknown db command: #{command.inspect}" },
75
+ false
76
+ )
77
+ command_class.new.run(argv)
78
+ end
79
+
80
+ def report_error(error, status)
81
+ warn "multi_compress db: #{error.message}"
82
+ status
83
+ end
84
+
85
+ def show_help
86
+ puts help
87
+ 0
88
+ end
89
+
90
+ def help
91
+ <<~TEXT
92
+ Usage:
93
+ multi_compress db package postgres [--output PATH] [--force]
94
+ multi_compress db package mysql [--output PATH] [--force]
95
+
96
+ multi_compress db view postgres --table SCHEMA.TABLE --column COLUMN \\
97
+ --view SCHEMA.VIEW --columns ID,CREATED_AT [--as PAYLOAD] \\
98
+ [--extension-schema multi_compress] [--output PATH]
99
+
100
+ multi_compress db view mysql --table SCHEMA.TABLE --column COLUMN \\
101
+ --view SCHEMA.VIEW --columns ID,CREATED_AT [--as PAYLOAD] [--output PATH]
102
+
103
+ `package` creates a self-contained source bundle for the DBA. The DBA
104
+ extracts it on the database host and runs `make doctor`, `sudo make
105
+ install`, then `make enable DB=...`.
106
+
107
+ `view` emits a safe readable-view definition so DBeaver users query
108
+ decoded text instead of the compressed bytea/BLOB column. Supply
109
+ --dictionary-table and --dictionary-id-column for MCDB2.
110
+
111
+ `registry` emits append-only MCDB2 dictionary-registry DDL. Dictionary
112
+ bytes are application data and are never placed in a DBA reader bundle.
113
+ Supplying --payload-table, --payload-column and
114
+ --payload-dictionary-id-column also emits the MCDB2 header/FK
115
+ consistency trigger for that source table.
116
+ TEXT
117
+ end
118
+ end
119
+
120
+ class PackageCommand
121
+ include CommandHelpers
122
+
123
+ TARGETS = %w[postgres mysql].freeze
124
+
125
+ def run(argv)
126
+ target = validated_target!(argv, TARGETS)
127
+ options = { output: nil, force: false }
128
+ parser = option_parser(target, options)
129
+ parser.parse!(argv)
130
+ return show_help(options[:help]) if options[:help]
131
+
132
+ raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
133
+
134
+ write_bundle(target, options)
135
+ 0
136
+ end
137
+
138
+ private
139
+
140
+ def option_parser(target, options)
141
+ OptionParser.new do |parser|
142
+ parser.banner = "Usage: multi_compress db package #{target} [OPTIONS]"
143
+ parser.on("-o", "--output PATH", "write the deployment archive to PATH") do |value|
144
+ options[:output] = value
145
+ end
146
+ parser.on("-f", "--force", "overwrite an existing archive") { options[:force] = true }
147
+ parser.on("-h", "--help", "show this help") { options[:help] = parser }
148
+ end
149
+ end
150
+
151
+ def write_bundle(target, options)
152
+ output = options[:output] || "multi_compress-#{target}-#{MultiCompress::VERSION}.tar.gz"
153
+ DeploymentBundle.new(target, output, force: options[:force]).write!
154
+ puts File.expand_path(output)
155
+ end
156
+ end
157
+
158
+ class SqlCommand
159
+ include CommandHelpers
160
+
161
+ def run(argv)
162
+ target = validated_target!(argv, self.class::TARGETS)
163
+ options = default_options
164
+ parser = option_parser(target, options)
165
+ parser.parse!(argv)
166
+ return show_help(options[:help]) if options[:help]
167
+
168
+ raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
169
+
170
+ write_sql(build_sql(target, options), options[:output])
171
+ 0
172
+ end
173
+
174
+ private
175
+
176
+ def build_sql(_target, _options)
177
+ raise NotImplementedError
178
+ end
179
+ end
180
+
181
+ class ViewCommand < SqlCommand
182
+ TARGETS = %w[postgres mysql].freeze
183
+ IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
184
+ DICTIONARY_OPTION_DEFINITIONS = [
185
+ [:dictionary_table, "dictionary-table", "MCDB2 immutable dictionary registry table"],
186
+ [:dictionary_id_column, "dictionary-id-column", "MCDB2 FK column on source table"],
187
+ [:registry_id_column, "registry-id-column", "registry PK (default: id)"],
188
+ [:dictionary_sha256_column, "dictionary-sha256-column", "registry SHA-256 column (default: sha256)"],
189
+ [:dictionary_bytes_column, "dictionary-bytes-column", "registry dictionary bytes column (default: bytes)"]
190
+ ].map(&:freeze).freeze
191
+
192
+ private_constant :DICTIONARY_OPTION_DEFINITIONS
193
+
194
+ private
195
+
196
+ def build_sql(target, options)
197
+ ReadableView.new(target, options).to_sql
198
+ end
199
+
200
+ def default_options
201
+ {
202
+ columns: [], as: "payload", output: nil, extension_schema: "multi_compress",
203
+ dictionary_table: nil, dictionary_id_column: nil, registry_id_column: "id",
204
+ dictionary_sha256_column: "sha256", dictionary_bytes_column: "bytes"
205
+ }
206
+ end
207
+
208
+ def option_parser(target, options)
209
+ OptionParser.new do |parser|
210
+ parser.banner = view_usage(target)
211
+ add_source_options(parser, options)
212
+ add_dictionary_options(parser, options)
213
+ add_view_output_options(parser, target, options)
214
+ end
215
+ end
216
+
217
+ def view_usage(target)
218
+ "Usage: multi_compress db view #{target} --table SCHEMA.TABLE --column COLUMN " \
219
+ "--view SCHEMA.VIEW --columns ID,CREATED_AT [OPTIONS]"
220
+ end
221
+
222
+ def add_source_options(parser, options)
223
+ parser.on("--table NAME", "source table, optionally schema-qualified") { |value| options[:table] = value }
224
+ parser.on("--column NAME", "compressed bytea/BLOB column") { |value| options[:column] = value }
225
+ parser.on("--view NAME", "destination view, optionally schema-qualified") { |value| options[:view] = value }
226
+ parser.on("--columns LIST", "comma-separated plain columns to expose") do |value|
227
+ options[:columns] = value.split(",").map(&:strip)
228
+ end
229
+ parser.on("--as NAME", "decoded column name (default: payload)") { |value| options[:as] = value }
230
+ end
231
+
232
+ def add_dictionary_options(parser, options)
233
+ add_value_options(parser, options, DICTIONARY_OPTION_DEFINITIONS)
234
+ end
235
+
236
+ def add_view_output_options(parser, target, options)
237
+ if target == "postgres"
238
+ parser.on("--extension-schema NAME", "extension schema (default: multi_compress)") do |value|
239
+ options[:extension_schema] = value
240
+ end
241
+ end
242
+ parser.on("-o", "--output PATH", "write SQL to PATH instead of stdout") { |value| options[:output] = value }
243
+ parser.on("-h", "--help", "show this help") { options[:help] = parser }
244
+ end
245
+ end
246
+
247
+ class RegistryCommand < SqlCommand
248
+ TARGETS = %w[postgres mysql].freeze
249
+ TABLE_OPTION_DEFINITIONS = [
250
+ [:table, "table", "versions table (default: mcdb_dictionary_versions)"],
251
+ [:heads_table, "heads-table", "heads table (default: mcdb_dictionary_heads)"],
252
+ [:payload_table, "payload-table", "MCDB2 source table in this schema/database"],
253
+ [:payload_column, "payload-column", "MCDB2 compressed envelope column on --payload-table"],
254
+ [:payload_dictionary_id_column, "payload-dictionary-id-column",
255
+ "MCDB2 dictionary FK column on --payload-table"]
256
+ ].map(&:freeze).freeze
257
+
258
+ private_constant :TABLE_OPTION_DEFINITIONS
259
+
260
+ private
261
+
262
+ def build_sql(target, options)
263
+ DictionaryRegistry.new(target, options).to_sql
264
+ end
265
+
266
+ def default_options
267
+ { output: nil, extension_schema: "multi_compress", table: "mcdb_dictionary_versions",
268
+ heads_table: "mcdb_dictionary_heads" }
269
+ end
270
+
271
+ def option_parser(target, options)
272
+ OptionParser.new do |parser|
273
+ parser.banner = "Usage: multi_compress db registry #{target} [OPTIONS]"
274
+ add_target_options(parser, target, options)
275
+ add_registry_table_options(parser, options)
276
+ add_registry_output_options(parser, options)
277
+ end
278
+ end
279
+
280
+ def add_target_options(parser, target, options)
281
+ return add_postgres_options(parser, options) if target == "postgres"
282
+
283
+ parser.on("--database NAME", "application database") { |value| options[:schema] = value }
284
+ end
285
+
286
+ def add_postgres_options(parser, options)
287
+ parser.on("--schema NAME", "application schema") { |value| options[:schema] = value }
288
+ parser.on("--owner ROLE", "NOLOGIN owner for append-only dictionary versions") do |value|
289
+ options[:owner] = value
290
+ end
291
+ parser.on("--migration-role ROLE", "role allowed to insert dictionary versions") do |value|
292
+ options[:migration_role] = value
293
+ end
294
+ parser.on("--extension-schema NAME", "extension schema (default: multi_compress)") do |value|
295
+ options[:extension_schema] = value
296
+ end
297
+ end
298
+
299
+ def add_registry_table_options(parser, options)
300
+ add_value_options(parser, options, TABLE_OPTION_DEFINITIONS)
301
+ end
302
+
303
+ def add_registry_output_options(parser, options)
304
+ parser.on("-o", "--output PATH", "write SQL to PATH instead of stdout") { |value| options[:output] = value }
305
+ parser.on("-h", "--help", "show this help") { options[:help] = parser }
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "multi_compress/db_deployment/bundle_stager"
4
+ require "multi_compress/db_deployment/bundle_metadata"
5
+ require "multi_compress/db_deployment/bundle_archiver"
6
+
7
+ module MultiCompress
8
+ module DBDeployment
9
+ class DeploymentBundle
10
+ attr_reader :target, :output, :force, :root_name
11
+ private :target, :output, :force, :root_name
12
+
13
+ DEFAULT_ARCHIVE_EPOCH = 315_619_200
14
+
15
+ TARGETS = {
16
+ "postgres" => {
17
+ source_paths: %w[
18
+ db_core
19
+ postgres_extension
20
+ ext/multi_compress/vendor/zstd
21
+ docs/database-envelope-v1.md
22
+ docs/database-envelope-v2.md
23
+ docs/rfcs/0002-mcdb2-dictionaries.md
24
+ LICENSE.txt
25
+ THIRD_PARTY_NOTICES.md
26
+ ],
27
+ template_path: "db_deployment/postgres"
28
+ },
29
+ "mysql" => {
30
+ source_paths: %w[
31
+ db_core
32
+ mysql_udf
33
+ ext/multi_compress/vendor/zstd
34
+ docs/database-envelope-v1.md
35
+ docs/database-envelope-v2.md
36
+ docs/rfcs/0002-mcdb2-dictionaries.md
37
+ LICENSE.txt
38
+ THIRD_PARTY_NOTICES.md
39
+ ],
40
+ template_path: "db_deployment/mysql"
41
+ }
42
+ }.freeze
43
+
44
+ def initialize(target, output, force:)
45
+ @target = target
46
+ @output = File.expand_path(output)
47
+ @force = force
48
+ @root_name = "multi_compress-#{target}-#{MultiCompress::VERSION}"
49
+ end
50
+
51
+ def write!
52
+ validate_sources!
53
+ validate_output!
54
+ Dir.mktmpdir("multi-compress-db-package") { |directory| build_bundle(directory) }
55
+ end
56
+
57
+ private
58
+
59
+ def validate_sources!
60
+ return if File.directory?(ROOT)
61
+
62
+ raise Error, "deployment sources are unavailable from #{ROOT}"
63
+ end
64
+
65
+ def validate_output!
66
+ return unless File.exist?(output) && !force
67
+
68
+ raise Error, "#{output} already exists (use --force to overwrite)"
69
+ end
70
+
71
+ def build_bundle(directory)
72
+ bundle_root = File.join(directory, root_name)
73
+ FileUtils.mkdir_p(bundle_root)
74
+ BundleStager.new(ROOT, TARGETS.fetch(target)).stage!(bundle_root)
75
+ BundleMetadata.new(target).write!(bundle_root)
76
+ BundleArchiver.new(output, root_name).write!(directory)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiCompress
4
+ module DBDeployment
5
+ class DeterministicTarWriter < Gem::Package::TarWriter
6
+ attr_reader :archive_epoch
7
+ private :archive_epoch
8
+
9
+ def self.new(io, epoch)
10
+ writer = allocate
11
+ writer.send(:initialize, io, epoch)
12
+ return writer unless block_given?
13
+
14
+ begin
15
+ yield writer
16
+ ensure
17
+ writer.close
18
+ end
19
+
20
+ nil
21
+ end
22
+
23
+ def initialize(io, epoch)
24
+ @archive_epoch = epoch
25
+ super(io)
26
+ end
27
+
28
+ def add_file_simple(name, mode, size)
29
+ check_closed
30
+ name, prefix = split_name(name)
31
+ write_file_header(name, prefix, mode, size)
32
+ write_file_body(size) { |output| yield output if block_given? }
33
+ self
34
+ end
35
+
36
+ def mkdir(name, mode)
37
+ check_closed
38
+ name, prefix = split_name(name)
39
+ @io.write(directory_header(name, prefix, mode))
40
+ self
41
+ end
42
+
43
+ private
44
+
45
+ def write_file_header(name, prefix, mode, size)
46
+ header = Gem::Package::TarHeader.new(
47
+ name: name,
48
+ mode: mode,
49
+ size: size,
50
+ prefix: prefix,
51
+ mtime: archive_epoch
52
+ ).to_s
53
+ @io.write(header)
54
+ end
55
+
56
+ def write_file_body(size)
57
+ output = BoundedStream.new(@io, size)
58
+ yield output
59
+ @io.write("\0" * (size - output.written))
60
+ padding = (512 - (size % 512)) % 512
61
+ @io.write("\0" * padding)
62
+ end
63
+
64
+ def directory_header(name, prefix, mode)
65
+ Gem::Package::TarHeader.new(
66
+ name: name,
67
+ mode: mode,
68
+ typeflag: "5",
69
+ size: 0,
70
+ prefix: prefix,
71
+ mtime: archive_epoch
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end