lexicon-common 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bb97bec1598709b8fefb46c28055ac8291edd138177995e08616de3b06dfbdb8
4
+ data.tar.gz: 20db194da73907714cb53ae861bd650ded613f71ef99ae1c93db370c8547c830
5
+ SHA512:
6
+ metadata.gz: aa5b4f83c9647382dabdaa4dd09250fd4e165476ca3984740763bd4b2cd8f54ac41e73379a5988391a3336d31e9928ca9332598216b102829dff1efe28ab14dd
7
+ data.tar.gz: 296628e759a78533b06160d653ab6e1299d0d1378964ac20d5350c7b83892d0b385b4d78f1569c1844591c014db2a0130f9249e423946e7d3e38095f28029f5a
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/lexicon/common/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lexicon-common'
7
+ spec.version = Lexicon::Common::VERSION
8
+ spec.authors = ['Ekylibre developers']
9
+ spec.email = ['dev@ekylibre.com']
10
+
11
+ spec.summary = "Common classes and services for Ekylibre's Lexicon"
12
+ spec.required_ruby_version = '>= 2.6.0'
13
+ spec.homepage = 'https://www.ekylibre.com'
14
+ spec.license = 'AGPL-3.0-only'
15
+
16
+ # Specify which files should be added to the gem when it is released.
17
+ spec.files = Dir.glob(%w[lib/**/*.rb resources/**/* *.gemspec])
18
+
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_dependency 'aws-sdk-s3', '~> 1.84'
22
+ spec.add_dependency 'colored', '~> 1.2'
23
+ spec.add_dependency 'json_schemer', '~> 0.2.16'
24
+ spec.add_dependency 'pg', '~> 1.2'
25
+ spec.add_dependency 'semantic', '~> 1.6'
26
+ spec.add_dependency 'zeitwerk', '~> 2.4'
27
+
28
+ spec.add_development_dependency 'bundler', '~> 2.0'
29
+ spec.add_development_dependency 'minitest', '~> 5.14'
30
+ spec.add_development_dependency 'rake', '~> 13.0'
31
+ spec.add_development_dependency 'rubocop', '1.11.0'
32
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # require 'lexicon/common'
4
+ require 'aws-sdk-s3'
5
+ require 'colored'
6
+ require 'logger'
7
+ require 'json_schemer'
8
+ require 'pg'
9
+ require 'semantic'
10
+ require 'zeitwerk'
11
+
12
+ # Require the common file as loading the version first through the gemspec prevents Zeitwerk to load it.
13
+ require_relative 'lexicon/common'
14
+
15
+ loader = Zeitwerk::Loader.for_gem
16
+ loader.ignore(__FILE__)
17
+ loader.setup
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ LEXICON_SCHEMA_RELATIVE_PATH = 'resources/lexicon.schema.json'
6
+ end
7
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Database
6
+ class Database
7
+ class << self
8
+ def connect(url)
9
+ new(PG.connect(url))
10
+ end
11
+ end
12
+
13
+ attr_writer :verbose
14
+ # @return [Array<String>]
15
+ attr_reader :search_path
16
+
17
+ def initialize(connection, verbose: false)
18
+ @connection = connection
19
+ @search_path = []
20
+ @verbose = verbose
21
+
22
+ disable_notices unless verbose
23
+ end
24
+
25
+ def verbose?
26
+ @verbose
27
+ end
28
+
29
+ def transaction(&block)
30
+ connection.transaction(&block)
31
+ end
32
+
33
+ def prepend_search_path(*parts, &block)
34
+ return if block.nil?
35
+
36
+ parts.each { |part| ensure_schema(part) }
37
+
38
+ with_search_path(*parts, *search_path) do
39
+ block.call
40
+ end
41
+ end
42
+
43
+ def on_empty_schema(base_path: [], &block)
44
+ schema = make_random_schema_name
45
+
46
+ prepend_search_path(schema, *base_path) do
47
+ block.call(schema)
48
+ end
49
+ ensure
50
+ drop_schema(schema, cascade: true)
51
+ end
52
+
53
+ def drop_schema(name, cascade: false)
54
+ cascade = if cascade
55
+ ' CASCADE'
56
+ else
57
+ ''
58
+ end
59
+
60
+ query <<~SQL
61
+ DROP SCHEMA "#{name}"#{cascade};
62
+ SQL
63
+ end
64
+
65
+ def make_random_schema_name(prefix = 'lex')
66
+ "#{prefix}_#{rand(0x100000000).to_s(36)}"
67
+ end
68
+
69
+ def query(sql, *params, **_options)
70
+ pp sql if verbose?
71
+ if params.any?
72
+ @connection.exec_params(sql, params)
73
+ else
74
+ @connection.exec(sql)
75
+ end
76
+ end
77
+
78
+ # @param [#to_s] name
79
+ def ensure_schema(name)
80
+ query(<<~SQL)
81
+ CREATE SCHEMA IF NOT EXISTS "#{name}";
82
+ SQL
83
+ end
84
+
85
+ def ensure_schema_empty(name)
86
+ query(<<~SQL)
87
+ DROP SCHEMA IF EXISTS #{name} CASCADE;
88
+ SQL
89
+
90
+ ensure_schema(name)
91
+ end
92
+
93
+ def copy_data(sql, &block)
94
+ put_data = ->(d) { @connection.put_copy_data(d) }
95
+ @connection.copy_data(sql) { block.call(put_data) }
96
+ end
97
+
98
+ private
99
+
100
+ # @return [PG::Connection]
101
+ attr_reader :connection
102
+
103
+ def disable_notices
104
+ query <<~SQL
105
+ SET client_min_messages TO WARNING;
106
+ SQL
107
+ end
108
+
109
+ def with_search_path(*path, &block)
110
+ return if block.nil?
111
+
112
+ begin
113
+ saved_path = @search_path
114
+ @search_path = path
115
+
116
+ query <<~SQL
117
+ SET search_path TO #{path.map { |part| "\"#{part}\"" }.join(', ')};
118
+ SQL
119
+
120
+ result = block.call
121
+
122
+ result
123
+ ensure
124
+ path = if saved_path.any?
125
+ saved_path.map { |part| "\"#{part}\"" }.join(', ')
126
+ else
127
+ '" "'
128
+ end
129
+
130
+ query <<~SQL
131
+ SET search_path TO #{path};
132
+ SQL
133
+
134
+ @search_path = saved_path
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Database
6
+ class Factory
7
+ attr_reader :verbose
8
+
9
+ def initialize(verbose: false)
10
+ @verbose = verbose
11
+ end
12
+
13
+ def new_instance(url:)
14
+ Database.new(PG.connect(url), verbose: @verbose)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Mixin
6
+ module ContainerAware
7
+ attr_accessor :container
8
+
9
+ # @param [Object] service
10
+ def get(service)
11
+ container.get(service)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,57 @@
1
+ module Lexicon
2
+ module Common
3
+ module Mixin
4
+ module Finalizable
5
+ def self.included(base)
6
+ class << base
7
+ alias_method :_new, :new
8
+
9
+ def new(*args, **options)
10
+ e = do_call(self, '_new', args, options)
11
+
12
+ ObjectSpace.define_finalizer(e, e.method(:_finalize))
13
+
14
+ e
15
+ end
16
+
17
+ # Empty Array and Hash splats are handled correctly starting ruby 2.7:
18
+ # if both args and kwargs are empty, no parameters are sent.
19
+ if ::Semantic::Version.new(RUBY_VERSION).satisfies?('>= 2.7.0')
20
+ private def do_call(obj, method, args, kwargs)
21
+ obj.send(method, *args, **kwargs)
22
+ end
23
+ else
24
+ private def do_call(obj, method, args, kwargs)
25
+ if args.empty? && kwargs.empty?
26
+ obj.send(method)
27
+ elsif args.empty?
28
+ obj.send(method, **kwargs)
29
+ elsif kwargs.empty?
30
+ obj.send(method, *args)
31
+ else
32
+ obj.send(method, *args, **kwargs)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def finalize
42
+ raise StandardError.new("Finalizer is not implemented in #{self.class.name}")
43
+ end
44
+
45
+ def _finalize(_id)
46
+ m = method(:finalize)
47
+
48
+ if !m.nil?
49
+ finalize
50
+ end
51
+ rescue StandardError => e
52
+ puts "Exception in finalizer: #{e.message}\n" + e.backtrace.join("\n")
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Mixin
6
+ module LoggerAware
7
+ # @return [Logger]
8
+ attr_accessor :logger
9
+
10
+ def log(*args, **options)
11
+ if !logger.nil?
12
+ logger.debug(*args, **options)
13
+ end
14
+ end
15
+
16
+ def log_error(error)
17
+ if error.nil?
18
+ log('Error (nil)')
19
+ else
20
+ log([error.message, *error.backtrace].join("\n"))
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Mixin
6
+ module SchemaNamer
7
+ protected
8
+
9
+ # @param [Semantic::Version] version
10
+ # @return [String]
11
+ def version_to_schema(version)
12
+ "lexicon__#{version.to_s.gsub('.', '_')}"
13
+ end
14
+
15
+ # @param [String] schema
16
+ # @return [Semantic::Version, nil]
17
+ def schema_to_version(schema)
18
+ Semantic::Version.new(schema.sub(/\Alexicon__/, '').gsub('_', '.'))
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Package
6
+ class DirectoryPackageLoader
7
+ include Mixin::LoggerAware
8
+
9
+ # @return [Pathname]
10
+ attr_reader :root_dir
11
+
12
+ # @param [Pathname] root_dir
13
+ # @param [JSONSchemer::Schema::Base] schema_validator
14
+ def initialize(root_dir, schema_validator:)
15
+ @root_dir = root_dir
16
+ @schema_validator = schema_validator
17
+ end
18
+
19
+ # @param [String] name
20
+ # @return [Package, nil]
21
+ def load_package(name)
22
+ package_dir = root_dir.join(name.to_s)
23
+
24
+ if package_dir.directory?
25
+ load_from_dir(package_dir)
26
+ else
27
+ nil
28
+ end
29
+ end
30
+
31
+ protected
32
+
33
+ def load_from_dir(dir)
34
+ # @type [Pathname]
35
+ spec_file = dir.join(Package::SPEC_FILE_NAME)
36
+ # @type [Pathname]
37
+ checksum_file = dir.join(Package::CHECKSUM_FILE_NAME)
38
+
39
+ if spec_file.exist? && checksum_file.exist?
40
+ json = JSON.parse(spec_file.read)
41
+
42
+ if @schema_validator.valid?(json)
43
+ version = Semantic::Version.new(json.fetch('version'))
44
+ file_sets = json.fetch('content').map do |id, values|
45
+ SourceFileSet.new(
46
+ id: id,
47
+ name: values.fetch('name'),
48
+ structure: values.fetch('structure'),
49
+ data: values.fetch('data', nil),
50
+ tables: values.fetch('tables', [])
51
+ )
52
+ end
53
+
54
+ Package.new(file_sets: file_sets, version: version, dir: dir, checksum_file: checksum_file, spec_file: spec_file)
55
+ else
56
+ log("Package at path #{dir} has invalid manifest")
57
+
58
+ nil
59
+ end
60
+ else
61
+ nil
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Package
6
+ class Package
7
+ SPEC_FILE_NAME = 'lexicon.json'
8
+ CHECKSUM_FILE_NAME = 'lexicon.sum'
9
+
10
+ # @return [Pathname]
11
+ attr_reader :checksum_file, :spec_file
12
+ # @return [Pathname]
13
+ attr_reader :dir
14
+ # @return [Array<SourceFileSet>]
15
+ attr_reader :file_sets
16
+ # @return [Semantic::Version]
17
+ attr_reader :version
18
+
19
+ # @param [Array<SourceFileSet>] file_sets
20
+ # @param [Pathname] dir
21
+ # @param [Pathname] checksum_file
22
+ # @param [Semantic::Version] version
23
+ def initialize(file_sets:, version:, dir:, checksum_file:, spec_file:)
24
+ @checksum_file = checksum_file
25
+ @dir = dir
26
+ @file_sets = file_sets
27
+ @spec_file = spec_file
28
+ @version = version
29
+ end
30
+
31
+ # @return [Boolean]
32
+ def valid?
33
+ checksum_file.exist? && dir.directory? && data_dir.directory? && all_sets_valid?
34
+ end
35
+
36
+ # @return [Array<Pathname>]
37
+ def structure_files
38
+ file_sets.map { |fs| dir.join(relative_structure_path(fs)) }
39
+ end
40
+
41
+ # @param [SourceFileSet] file_set
42
+ # @return [Pathname]
43
+ def data_path(file_set)
44
+ dir.join(relative_data_path(file_set))
45
+ end
46
+
47
+ # @return [Pathname]
48
+ def data_dir
49
+ dir.join('data')
50
+ end
51
+
52
+ # @return [Pathname, nil]
53
+ def relative_data_path(file_set)
54
+ if file_set.data_path.nil?
55
+ nil
56
+ else
57
+ data_dir.basename.join(file_set.data_path)
58
+ end
59
+ end
60
+
61
+ # @return [Pathname]
62
+ def relative_structure_path(file_set)
63
+ data_dir.basename.join(file_set.structure_path)
64
+ end
65
+
66
+ private
67
+
68
+ def all_sets_valid?
69
+ file_sets.all? do |set|
70
+ data_dir.join(set.structure_path).exist? && !set.data_path.nil? && data_dir.join(set.data_path).exist?
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Package
6
+ class PackageBuilder < Package
7
+ def initialize(version:, dir:)
8
+ super(
9
+ file_sets: [],
10
+ version: version,
11
+ dir: dir,
12
+ checksum_file: dir.join(CHECKSUM_FILE_NAME),
13
+ spec_file: dir.join(SPEC_FILE_NAME)
14
+ )
15
+
16
+ FileUtils.mkdir_p(data_dir)
17
+ end
18
+
19
+ # @param [String] id
20
+ # @param [String] name
21
+ # @param [Pathname] structure
22
+ # Takes ownership of the file (moves it to the correct folder)
23
+ # @param [Array<String>] tables
24
+ # @param [Pathname] data
25
+ # Takes ownership of the file (moves it to the correct folder)
26
+ # @param [String] data_ext
27
+ def add_file_set(id, name:, structure:, tables:, data: nil, data_ext: '.sql')
28
+ # @type [Pathname] structure_file_path
29
+ structure_file_path = data_dir.join(structure_file_name(id))
30
+ FileUtils.mv(structure.to_s, structure_file_path.to_s)
31
+
32
+ # @type [Pathname] data_file_path
33
+ data_name = if data.nil?
34
+ nil
35
+ else
36
+ dname = data_file_name(id, data_ext)
37
+ path = data_dir.join(dname)
38
+ FileUtils.mv(data, path)
39
+
40
+ dname
41
+ end
42
+
43
+ file_sets << SourceFileSet.new(
44
+ id: id,
45
+ name: name,
46
+ structure: structure_file_name(id),
47
+ data: data.nil? ? nil : data_name,
48
+ tables: tables
49
+ )
50
+ end
51
+
52
+ def as_package
53
+ Package.new(version: version, dir: dir, file_sets: file_sets, checksum_file: checksum_file, spec_file: spec_file)
54
+ end
55
+
56
+ private
57
+
58
+ def data_file_name(id, ext)
59
+ "#{id}#{ext}"
60
+ end
61
+
62
+ def structure_file_name(id)
63
+ "#{id}__structure.sql"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Package
6
+ class PackageIntegrityValidator
7
+ # @param [ShellExecutor] shell
8
+ def initialize(shell:)
9
+ @shell = shell
10
+ end
11
+
12
+ # @param [Package] package
13
+ # @return [Boolean]
14
+ def valid?(package)
15
+ integrity_states(package).values.all? { |v| v == true }
16
+ end
17
+
18
+ # @param [Package] package
19
+ # @return [Hash{String => Boolean}]
20
+ def integrity_states(package)
21
+ sumstr = shell.execute <<~BASH
22
+ (cd "#{package.dir}" && sha256sum -c #{package.checksum_file.basename} 2>/dev/null)
23
+ BASH
24
+
25
+ sumstr.scan(/(.*?): (.*?)\n/)
26
+ .to_h
27
+ .transform_values { |value| value == 'OK' }
28
+ end
29
+
30
+ protected
31
+
32
+ # @return [ShellExecutor]
33
+ attr_reader :shell
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Package
6
+ class SourceFileSet
7
+ attr_reader :id, :name, :structure_path, :data_path, :tables
8
+
9
+ # @param [String] id
10
+ # @param [String] name
11
+ # @param [String] structure
12
+ # @param [String] data
13
+ # @param [Array<String>] tables
14
+ def initialize(id:, name:, structure:, data:, tables:)
15
+ @id = id
16
+ @name = name
17
+ @structure_path = structure
18
+ @data_path = data
19
+ @tables = tables
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Production
6
+ class DatasourceLoader
7
+ include Mixin::SchemaNamer
8
+ # @param [ShellExecutor] shell
9
+ # @param [Database::Factory] database_factory
10
+ # @param [FileLoader] file_loader
11
+ # @param [String] database_url
12
+ def initialize(shell:, database_factory:, file_loader:, database_url:)
13
+ @shell = shell
14
+ @database_factory = database_factory
15
+ @file_loader = file_loader
16
+ @database_url = database_url
17
+ end
18
+
19
+ # @param [Package::Package] package
20
+ # @param [Array<String>, nil] only
21
+ # @param [Array<String>] without
22
+ # If nil, all datasets are loaded.
23
+ # If present, only listed datasets are loaded.
24
+ # Structures are ALWAYS loaded
25
+ def load_package(package, only: nil, without: [])
26
+ file_sets = if only.nil?
27
+ package.file_sets.select(&:data_path)
28
+ else
29
+ sets_by_name = package.file_sets.map { |fs| [fs.name, fs] }.to_h
30
+
31
+ missing, present = only.map { |name| [name, sets_by_name.fetch(name, nil)] }
32
+ .partition { |(_name, value)| value.nil? }
33
+
34
+ if missing.any?
35
+ puts "[ NOK ] Datasources #{missing.map(&:first).join(', ')} don't exist!"
36
+ return
37
+ end
38
+
39
+ present.map(&:second)
40
+ .select(&:data_path)
41
+ end
42
+
43
+ file_sets = file_sets.reject { |fs| without.include?(fs.name) }
44
+
45
+ load_structure_files(package.structure_files, schema: version_to_schema(package.version))
46
+
47
+ file_sets.map do |fs|
48
+ Thread.new do
49
+ puts "Loading #{fs.name}"
50
+ file_loader.load_file(package.data_path(fs))
51
+ puts '[ OK ] '.green + fs.name.yellow
52
+ end
53
+ end.each(&:join)
54
+
55
+ lock_tables(package)
56
+ end
57
+
58
+ private
59
+
60
+ # @return [Database::Factory]
61
+ attr_reader :database_factory
62
+ # @return [ShellExecutor]
63
+ attr_reader :shell
64
+ # @return [FileLoader]
65
+ attr_reader :file_loader
66
+ # @return [String]
67
+ attr_reader :database_url
68
+
69
+ def load_structure_files(files, schema:)
70
+ database = database_factory.new_instance(url: database_url)
71
+ database.prepend_search_path(schema) do
72
+ files.each do |file|
73
+ database.query(file.read)
74
+ end
75
+ end
76
+ end
77
+
78
+ # @param [Package::Package] package
79
+ def lock_tables(package)
80
+ database = database_factory.new_instance(url: database_url)
81
+
82
+ schema = version_to_schema(package.version)
83
+
84
+ database.prepend_search_path schema do
85
+ database.query <<~SQL
86
+ CREATE OR REPLACE FUNCTION #{schema}.deny_changes()
87
+ RETURNS TRIGGER
88
+ AS $$
89
+ BEGIN
90
+ RAISE EXCEPTION '% denied on % (master data)', TG_OP, TG_RELNAME;
91
+ END;
92
+ $$
93
+ LANGUAGE plpgsql;
94
+ SQL
95
+ package.file_sets.flat_map(&:tables).each do |table_name|
96
+ database.query <<~SQL
97
+ CREATE TRIGGER deny_changes
98
+ BEFORE INSERT
99
+ OR UPDATE
100
+ OR DELETE
101
+ OR TRUNCATE
102
+ ON #{schema}.#{table_name}
103
+ FOR EACH STATEMENT
104
+ EXECUTE PROCEDURE #{schema}.deny_changes()
105
+ SQL
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Production
6
+ class FileLoader
7
+ # @param [ShellExecutor] shell
8
+ # @param [String] database_url
9
+ def initialize(shell:, database_url:)
10
+ @shell = shell
11
+ @database_url = database_url
12
+ end
13
+
14
+ # @param [Pathname] data_file
15
+ # @return [Boolean]
16
+ def load_file(data_file)
17
+ if data_file.basename.to_s =~ /\.sql\z/
18
+ load_sql(data_file)
19
+ elsif data_file.basename.to_s =~ /\.sql\.gz\z/
20
+ load_archive(data_file)
21
+ else
22
+ raise StandardError.new("Unknown file type: #{data_file.basename}")
23
+ end
24
+ end
25
+
26
+ # @param [Pathname] archive
27
+ # @return [Boolean]
28
+ def load_archive(archive)
29
+ shell.execute <<~BASH
30
+ cat '#{archive}' | gzip -d | psql '#{database_url}'
31
+ BASH
32
+
33
+ true
34
+ end
35
+
36
+ # @param [Pathname] file
37
+ # @return [Boolean]
38
+ def load_sql(file)
39
+ shell.execute <<~BASH
40
+ echo psql '#{database_url}' < '#{file}'
41
+ BASH
42
+
43
+ true
44
+ end
45
+
46
+ private
47
+
48
+ # @return [String]
49
+ attr_reader :database_url
50
+ # @return [ShellExecutor]
51
+ attr_reader :shell
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Remote
6
+ class PackageDownloader < RemoteBase
7
+ # @param [S3Client] s3
8
+ # @param [Pathname] out_dir
9
+ # @param [DirectoryPackageLoader] package_loader
10
+ def initialize(s3:, out_dir:, package_loader:)
11
+ super(s3: s3)
12
+ @out_dir = out_dir
13
+ @package_loader = package_loader
14
+ end
15
+
16
+ # @param [Semantic::Version] version
17
+ # @return [Boolean]
18
+ def download(version)
19
+ bucket = version.to_s
20
+
21
+ if s3.bucket_exist?(bucket)
22
+ Dir.mktmpdir(nil, out_dir) do |tmp_dir|
23
+ tmp_dir = Pathname.new(tmp_dir)
24
+
25
+ s3.raw.get_object(
26
+ bucket: bucket,
27
+ key: Package::Package::SPEC_FILE_NAME,
28
+ response_target: tmp_dir.join(Package::Package::SPEC_FILE_NAME).to_s
29
+ )
30
+ s3.raw.get_object(
31
+ bucket: bucket,
32
+ key: Package::Package::CHECKSUM_FILE_NAME,
33
+ response_target: tmp_dir.join(Package::Package::CHECKSUM_FILE_NAME).to_s
34
+ )
35
+
36
+ package = package_loader.load_package(tmp_dir.basename.to_s)
37
+ if !package.nil?
38
+ puts "[ OK ] Found package with key #{version}, version is #{package.version}".green
39
+
40
+ FileUtils.mkdir_p package.data_dir
41
+
42
+ package.structure_files.map do |file|
43
+ Thread.new do
44
+ s3.raw.get_object(bucket: bucket, key: "data/#{file.basename.to_s}", response_target: file.to_s)
45
+ puts "[ OK ] Downloaded #{file.basename}".green
46
+ end
47
+ end.each(&:join)
48
+
49
+ package.file_sets.map do |fs|
50
+ Thread.new do
51
+ path = package.data_path(fs)
52
+ s3.raw.get_object(bucket: bucket, key: "data/#{path.basename.to_s}", response_target: path.to_s)
53
+ puts "[ OK ] Downloaded #{path.basename}".green
54
+ end
55
+ end.each(&:join)
56
+
57
+ dest_dir = out_dir.join(version.to_s)
58
+ FileUtils.mkdir_p(dest_dir)
59
+ tmp_dir.children.each do |child|
60
+ FileUtils.mv(child.to_s, dest_dir.join(child.basename).to_s)
61
+ end
62
+
63
+ true
64
+ else
65
+ puts "[ NOK ] The remote contains a bucket '#{version}' but it does not contains a valid package.".red
66
+
67
+ false
68
+ end
69
+ end
70
+ else
71
+ false
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ # @return [DirectoryPackageLoader]
78
+ attr_reader :package_loader
79
+ # @return [Pathname]
80
+ attr_reader :out_dir
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Remote
6
+ class PackageUploader < RemoteBase
7
+ include Mixin::LoggerAware
8
+
9
+ # @param [Package] package
10
+ # @return [Boolean]
11
+ def upload(package)
12
+ bucket_name = package.version.to_s
13
+ if !s3.bucket_exist?(bucket_name)
14
+ s3.create_bucket(bucket: bucket_name)
15
+ puts 'Uploading structures...'
16
+
17
+ upload_files(*package.structure_files, bucket: bucket_name, prefix: 'data')
18
+ puts '[ OK ] Structure uploaded.'.green
19
+
20
+ data_files = package.file_sets
21
+ .select(&:data_path)
22
+ .map { |fs| package.data_path(fs) }
23
+
24
+ upload_files(*data_files, bucket: bucket_name, prefix: 'data') do |path|
25
+ puts "[ OK ] #{path.basename}".green
26
+ end
27
+
28
+ upload_files(package.checksum_file, package.spec_file, bucket: bucket_name) do |path|
29
+ puts "[ OK ] #{path.basename}".green
30
+ end
31
+
32
+ true
33
+ else
34
+ false
35
+ end
36
+ rescue StandardError => e
37
+ log_error(e)
38
+
39
+ false
40
+ end
41
+
42
+ private
43
+
44
+ # @param [Array<Pathname>] files
45
+ #
46
+ # @yieldparam [Pathname] path
47
+ def upload_files(*files, bucket:, prefix: nil)
48
+ files.each do |path|
49
+ path.open do |f|
50
+ s3.put_object(bucket: bucket, key: [prefix, path.basename.to_s].compact.join('/'), body: f)
51
+ end
52
+ yield path if block_given?
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Remote
6
+ class RemoteBase
7
+ # @param [S3Client] s3
8
+ def initialize(s3:)
9
+ @s3 = s3
10
+ end
11
+
12
+ private
13
+
14
+ # @return [S3Client]
15
+ attr_reader :s3
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Remote
6
+ class S3Client
7
+ # @return [Aws::S3::Client]
8
+ attr_reader :raw
9
+
10
+ # @param [Aws::S3::Client] raw
11
+ def initialize(raw:)
12
+ @raw = raw
13
+ end
14
+
15
+ # @return [Array<Object>]
16
+ def ls(bucket)
17
+ raw.list_objects_v2(bucket: bucket)
18
+ .to_h
19
+ .fetch(:contents, [])
20
+ end
21
+
22
+ # @param [String] name
23
+ # @return [Boolean]
24
+ def bucket_exist?(name)
25
+ if raw.head_bucket(bucket: name)
26
+ true
27
+ else
28
+ false
29
+ end
30
+ rescue StandardError
31
+ false
32
+ end
33
+
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ module Schema
6
+ class ValidatorFactory
7
+ # @param [Pathname] schema_path
8
+ def initialize(schema_path)
9
+ @schema_path = schema_path
10
+ end
11
+
12
+ # @return [JSONSchemer::Schema]
13
+ def build
14
+ JSONSchemer.schema(schema_path)
15
+ end
16
+
17
+ private
18
+
19
+ # @return [Pathname]
20
+ attr_reader :schema_path
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lexicon
4
+ module Common
5
+ class ShellExecutor
6
+ include Mixin::Finalizable
7
+ include Mixin::LoggerAware
8
+
9
+ def initialize
10
+ @command_dir = Dir.mktmpdir
11
+ end
12
+
13
+ # @param [String] command
14
+ # @return [String]
15
+ def execute(command)
16
+ log(command.cyan)
17
+
18
+ cmd = Tempfile.new('command-', @command_dir)
19
+ cmd.write <<~BASH
20
+ #!/usr/bin/env bash
21
+ set -e
22
+
23
+ #{command}
24
+ BASH
25
+ cmd.close
26
+
27
+ `bash #{cmd.path}`
28
+ ensure
29
+ cmd.close
30
+ cmd.unlink
31
+ end
32
+
33
+ def finalize
34
+ if !@command_dir.nil?
35
+ FileUtils.rm_rf(@command_dir)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ module Lexicon
2
+ module Common
3
+ VERSION = '0.1.0'.freeze
4
+ end
5
+ end
@@ -0,0 +1,51 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://lexicon.ekylibre.dev/lexicon.schema.json",
4
+ "title": "Lexicon Package JSON Schema",
5
+ "description": "",
6
+ "type": "object",
7
+ "properties": {
8
+ "version": {
9
+ "description": "The version of the packaged version",
10
+ "type": "string",
11
+ "$comment": "Regex is from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string",
12
+ "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$"
13
+ },
14
+ "content": {
15
+ "type": "object",
16
+ "patternProperties": {
17
+ "^[a-z][a-z_]*$": {
18
+ "type": "object",
19
+ "properties": {
20
+ "name": {
21
+ "type": "string"
22
+ },
23
+ "structure": {
24
+ "type": "string"
25
+ },
26
+ "data": {
27
+ "type": "string"
28
+ },
29
+ "tables": {
30
+ "type": "array",
31
+ "items": {
32
+ "type": "string"
33
+ },
34
+ "additionalItems": false
35
+ }
36
+ },
37
+ "required": [
38
+ "name",
39
+ "structure",
40
+ "tables"
41
+ ]
42
+ }
43
+ },
44
+ "additionalProperties": false
45
+ }
46
+ },
47
+ "required": [
48
+ "version",
49
+ "content"
50
+ ]
51
+ }
metadata ADDED
@@ -0,0 +1,207 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lexicon-common
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ekylibre developers
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-03-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: aws-sdk-s3
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.84'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.84'
27
+ - !ruby/object:Gem::Dependency
28
+ name: colored
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: json_schemer
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.2.16
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.2.16
55
+ - !ruby/object:Gem::Dependency
56
+ name: pg
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: semantic
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.6'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.6'
83
+ - !ruby/object:Gem::Dependency
84
+ name: zeitwerk
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.4'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.4'
97
+ - !ruby/object:Gem::Dependency
98
+ name: bundler
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '2.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '2.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: minitest
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '5.14'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '5.14'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rake
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '13.0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '13.0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rubocop
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - '='
144
+ - !ruby/object:Gem::Version
145
+ version: 1.11.0
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - '='
151
+ - !ruby/object:Gem::Version
152
+ version: 1.11.0
153
+ description:
154
+ email:
155
+ - dev@ekylibre.com
156
+ executables: []
157
+ extensions: []
158
+ extra_rdoc_files: []
159
+ files:
160
+ - lexicon-common.gemspec
161
+ - lib/lexicon-common.rb
162
+ - lib/lexicon/common.rb
163
+ - lib/lexicon/common/database/database.rb
164
+ - lib/lexicon/common/database/factory.rb
165
+ - lib/lexicon/common/mixin/container_aware.rb
166
+ - lib/lexicon/common/mixin/finalizable.rb
167
+ - lib/lexicon/common/mixin/logger_aware.rb
168
+ - lib/lexicon/common/mixin/schema_namer.rb
169
+ - lib/lexicon/common/package/directory_package_loader.rb
170
+ - lib/lexicon/common/package/package.rb
171
+ - lib/lexicon/common/package/package_builder.rb
172
+ - lib/lexicon/common/package/package_integrity_validator.rb
173
+ - lib/lexicon/common/package/source_file_set.rb
174
+ - lib/lexicon/common/production/datasource_loader.rb
175
+ - lib/lexicon/common/production/file_loader.rb
176
+ - lib/lexicon/common/remote/package_downloader.rb
177
+ - lib/lexicon/common/remote/package_uploader.rb
178
+ - lib/lexicon/common/remote/remote_base.rb
179
+ - lib/lexicon/common/remote/s3_client.rb
180
+ - lib/lexicon/common/schema/validator_factory.rb
181
+ - lib/lexicon/common/shell_executor.rb
182
+ - lib/lexicon/common/version.rb
183
+ - resources/lexicon.schema.json
184
+ homepage: https://www.ekylibre.com
185
+ licenses:
186
+ - AGPL-3.0-only
187
+ metadata: {}
188
+ post_install_message:
189
+ rdoc_options: []
190
+ require_paths:
191
+ - lib
192
+ required_ruby_version: !ruby/object:Gem::Requirement
193
+ requirements:
194
+ - - ">="
195
+ - !ruby/object:Gem::Version
196
+ version: 2.6.0
197
+ required_rubygems_version: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - ">="
200
+ - !ruby/object:Gem::Version
201
+ version: '0'
202
+ requirements: []
203
+ rubygems_version: 3.0.3
204
+ signing_key:
205
+ specification_version: 4
206
+ summary: Common classes and services for Ekylibre's Lexicon
207
+ test_files: []