Class: Mxrb::Runtime::DatabaseWorkspace

Inherits:
Object
  • Object
show all
Defined in:
lib/mxrb/runtime/database_workspace.rb

Overview

Materializes a private PostgreSQL database for one MPR. The Mendix Runtime owns schema synchronization; analyst access uses a separate read-only role. rubocop:disable Metrics/ClassLength

Constant Summary collapse

POSTGRES_IMAGE =
'postgres:13-alpine'
DATABASE =
'mxrb'
OWNER =
'mxrb_runtime'
READER =
'mxrb_reader'
NATIVE_INPUT_DIRECTORIES =
%w[
  mprcontents deployment javasource javascriptsource userlib vendorlib
  resources widgets theme theme-cache themesource
].freeze

Instance Method Summary collapse

Constructor Details

#initialize(project_path, state_dir: nil, port: 55_432, runtime_port: 18_080, runner: nil, sleeper: nil) ⇒ DatabaseWorkspace

rubocop:disable Metrics/AbcSize, Metrics/ParameterLists

Raises:

  • (ArgumentError)


32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/mxrb/runtime/database_workspace.rb', line 32

def initialize(project_path, state_dir: nil, port: 55_432, runtime_port: 18_080, # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
               runner: nil, sleeper: nil)
  @project_path = File.expand_path(project_path)
  @project_id = Digest::SHA256.hexdigest(@project_path)[0, 12]
  @state_dir = File.expand_path(state_dir || default_state_dir)
  @port = Integer(port)
  @runtime_port = Integer(runtime_port)
  raise ArgumentError, 'database port must be between 1 and 65535' unless (1..65_535).cover?(@port)
  raise ArgumentError, 'runtime port must be between 1 and 65535' unless (1..65_535).cover?(@runtime_port)

  @runner = runner || method(:capture)
  @sleeper = sleeper || Kernel.method(:sleep)
  @plan = Toolchain.new(@project_path).plan
end

Instance Method Details

#admin_credentialsObject



152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/mxrb/runtime/database_workspace.rb', line 152

def admin_credentials
  unless File.file?(credentials_path)
    raise ToolchainError,
          "Runtime credentials not found; run `mxrb db up #{@project_path}` first"
  end

  values = JSON.parse(File.read(credentials_path))
  password = values.fetch('admin_password')
  AdminCredentials.new(admin_user_name, password, @state_dir, credentials_path)
rescue JSON::ParserError, KeyError => e
  raise ToolchainError, "invalid Runtime credentials file #{credentials_path}: #{e.message}"
end

#connection_urlObject



147
148
149
150
# File 'lib/mxrb/runtime/database_workspace.rb', line 147

def connection_url
  secret = credentials.fetch('reader_password')
  "postgresql://#{READER}:#{secret}@127.0.0.1:#{@port}/#{DATABASE}"
end

#destroy(confirm: false) ⇒ Object

Raises:

  • (ArgumentError)


67
68
69
70
71
72
73
74
75
76
# File 'lib/mxrb/runtime/database_workspace.rb', line 67

def destroy(confirm: false)
  raise ArgumentError, 'destroy requires explicit confirmation' unless confirm

  remove_container(runtime_container)
  remove_container(database_container)
  remove_owned_resource('volume', database_volume)
  remove_owned_resource('network', network_name)
  FileUtils.rm_rf(@state_dir)
  info(running: false)
end

#downObject



61
62
63
64
65
# File 'lib/mxrb/runtime/database_workspace.rb', line 61

def down
  stop_container(runtime_container)
  stop_container(database_container)
  info(running: false)
end

#explain(sql, analyze: false) ⇒ Object

Uses PostgreSQL's machine-readable planner output. ANALYZE is opt-in because it executes the SELECT, although the analyst role remains in a read-only transaction and cannot mutate application data.



115
116
117
118
119
120
121
122
# File 'lib/mxrb/runtime/database_workspace.rb', line 115

def explain(sql, analyze: false)
  statement = read_only_statement(sql)
  configure_reader!
  payload = JSON.parse(run_explain(statement, analyze).strip)
  Oql::PlanAnalyzer.new(indexes: index_catalog).analyze(payload, analyzed: analyze)
rescue JSON::ParserError => e
  raise ToolchainError, "PostgreSQL returned invalid EXPLAIN JSON: #{e.message}"
end

#index_advice(limit: 50) ⇒ Object



135
136
137
# File 'lib/mxrb/runtime/database_workspace.rb', line 135

def index_advice(limit: 50)
  Oql::IndexAdvisor.new.analyze(workload(limit:))
end

#query(sql, write: false) ⇒ Object

Keeping the transaction envelope next to role selection makes the read-only default auditable. rubocop:disable Metrics/MethodLength

Raises:

  • (ArgumentError)


85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/mxrb/runtime/database_workspace.rb', line 85

def query(sql, write: false)
  raise ArgumentError, 'SQL must not be empty' if sql.to_s.strip.empty?
  raise ArgumentError, 'SQL contains a NUL byte' if sql.include?("\0")

  configure_reader! unless write
  role = write ? OWNER : READER
  command = [
    'docker', 'exec', database_container,
    'psql', '--no-psqlrc', '--set', 'ON_ERROR_STOP=1',
    '--username', role, '--dbname', DATABASE
  ]
  command.concat(['--command', 'BEGIN READ ONLY']) unless write
  command.concat(['--command', sql])
  command.concat(['--command', 'COMMIT']) unless write
  run!(*command)
end

#query_rows(sql, params: {}) ⇒ Object

rubocop:enable Metrics/MethodLength



103
104
105
106
107
108
109
110
# File 'lib/mxrb/runtime/database_workspace.rb', line 103

def query_rows(sql, params: {})
  statement = read_only_statement(sql)
  statement, variables = bind_parameters(statement, params)

  configure_reader!
  output = run!(*query_rows_command(statement, variables))
  CSV.parse(output, headers: true).map(&:to_h).freeze
end

#shell_command(write: false) ⇒ Object



139
140
141
142
143
144
145
# File 'lib/mxrb/runtime/database_workspace.rb', line 139

def shell_command(write: false)
  role = write ? OWNER : READER
  [
    'docker', 'exec', '-it', database_container,
    'psql', '--no-psqlrc', '--username', role, '--dbname', DATABASE
  ].freeze
end

#statusObject



78
79
80
# File 'lib/mxrb/runtime/database_workspace.rb', line 78

def status
  info(running: container_running?(database_container) && container_running?(runtime_container))
end

#syncObject



59
# File 'lib/mxrb/runtime/database_workspace.rb', line 59

def sync = up(force_build: true)

#up(force_build: false) ⇒ Object



47
48
49
50
51
52
53
54
55
56
57
# File 'lib/mxrb/runtime/database_workspace.rb', line 47

def up(force_build: false)
  validate!
  prepare_state
  refresh_runtime_package(force_build)
  prepare_database!
  configure_reader!
  restart_runtime!
  wait_for_runtime!
  configure_reader!
  info(running: true)
end

#workload(limit: 20) ⇒ Object

Raises:

  • (ArgumentError)


124
125
126
127
128
129
130
131
132
133
# File 'lib/mxrb/runtime/database_workspace.rb', line 124

def workload(limit: 20)
  maximum = Integer(limit)
  raise ArgumentError, 'workload limit must be between 1 and 1000' unless (1..1000).cover?(maximum)

  Oql::WorkloadAnalyzer.new.analyze(
    query_rows: workload_queries(maximum),
    table_rows: workload_tables,
    index_rows: workload_indexes
  )
end