lexicon-common 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []