active_postgres 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +23 -0
- data/README.md +158 -0
- data/exe/activepostgres +5 -0
- data/lib/active_postgres/cli.rb +157 -0
- data/lib/active_postgres/cluster_deployment_flow.rb +85 -0
- data/lib/active_postgres/component_resolver.rb +24 -0
- data/lib/active_postgres/components/base.rb +38 -0
- data/lib/active_postgres/components/core.rb +158 -0
- data/lib/active_postgres/components/extensions.rb +99 -0
- data/lib/active_postgres/components/monitoring.rb +55 -0
- data/lib/active_postgres/components/pgbackrest.rb +94 -0
- data/lib/active_postgres/components/pgbouncer.rb +137 -0
- data/lib/active_postgres/components/repmgr.rb +651 -0
- data/lib/active_postgres/components/ssl.rb +86 -0
- data/lib/active_postgres/configuration.rb +190 -0
- data/lib/active_postgres/connection_pooler.rb +429 -0
- data/lib/active_postgres/credentials.rb +17 -0
- data/lib/active_postgres/deployment_flow.rb +154 -0
- data/lib/active_postgres/error_handler.rb +185 -0
- data/lib/active_postgres/failover.rb +83 -0
- data/lib/active_postgres/generators/active_postgres/install_generator.rb +186 -0
- data/lib/active_postgres/health_checker.rb +244 -0
- data/lib/active_postgres/installer.rb +114 -0
- data/lib/active_postgres/log_sanitizer.rb +67 -0
- data/lib/active_postgres/logger.rb +125 -0
- data/lib/active_postgres/performance_tuner.rb +246 -0
- data/lib/active_postgres/rails/database_config.rb +174 -0
- data/lib/active_postgres/rails/migration_guard.rb +25 -0
- data/lib/active_postgres/railtie.rb +28 -0
- data/lib/active_postgres/retry_helper.rb +80 -0
- data/lib/active_postgres/rollback_manager.rb +140 -0
- data/lib/active_postgres/secrets.rb +86 -0
- data/lib/active_postgres/ssh_executor.rb +288 -0
- data/lib/active_postgres/standby_deployment_flow.rb +122 -0
- data/lib/active_postgres/validator.rb +143 -0
- data/lib/active_postgres/version.rb +3 -0
- data/lib/active_postgres.rb +67 -0
- data/lib/tasks/postgres.rake +855 -0
- data/lib/tasks/rolling_update.rake +258 -0
- data/lib/tasks/rotate_credentials.rake +193 -0
- data/templates/pg_hba.conf.erb +47 -0
- data/templates/pgbackrest.conf.erb +43 -0
- data/templates/pgbouncer.ini.erb +55 -0
- data/templates/postgresql.conf.erb +157 -0
- data/templates/repmgr.conf.erb +40 -0
- metadata +224 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
require 'English'
|
|
2
|
+
module ActivePostgres
|
|
3
|
+
class Secrets
|
|
4
|
+
attr_reader :config
|
|
5
|
+
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
@cache = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def resolve(secret_key)
|
|
12
|
+
return @cache[secret_key] if @cache.key?(secret_key)
|
|
13
|
+
|
|
14
|
+
secret_value = config.secrets_config[secret_key]
|
|
15
|
+
return nil unless secret_value
|
|
16
|
+
|
|
17
|
+
resolved = resolve_secret_value(secret_value)
|
|
18
|
+
@cache[secret_key] = resolved
|
|
19
|
+
resolved
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def resolve_all
|
|
23
|
+
config.secrets_config.keys.each_with_object({}) do |key, result|
|
|
24
|
+
result[key] = resolve(key)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cache_to_files(directory = '.secrets')
|
|
29
|
+
require 'fileutils'
|
|
30
|
+
|
|
31
|
+
FileUtils.mkdir_p(directory)
|
|
32
|
+
|
|
33
|
+
resolve_all.each do |key, value|
|
|
34
|
+
file_path = File.join(directory, key)
|
|
35
|
+
File.write(file_path, value)
|
|
36
|
+
File.chmod(0o600, file_path)
|
|
37
|
+
puts "Cached #{key} to #{file_path}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
puts "\n✓ Secrets cached to #{directory}/"
|
|
41
|
+
puts "Add to .gitignore: #{directory}/"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def resolve_secret_value(value)
|
|
47
|
+
case value
|
|
48
|
+
when /^rails_credentials:(.+)$/
|
|
49
|
+
# Rails credentials: rails_credentials:postgres.superuser_password
|
|
50
|
+
key_path = ::Regexp.last_match(1)
|
|
51
|
+
fetch_from_rails_credentials(key_path)
|
|
52
|
+
when /^\$\((.+)\)$/
|
|
53
|
+
# Command execution: $(op read "op://...")
|
|
54
|
+
execute_command(::Regexp.last_match(1))
|
|
55
|
+
when /^\$([A-Z_][A-Z0-9_]*)$/
|
|
56
|
+
# Environment variable: $POSTGRES_PASSWORD
|
|
57
|
+
ENV.fetch(::Regexp.last_match(1), nil)
|
|
58
|
+
when /^env:(.+)$/
|
|
59
|
+
# Explicit env var: env:DATABASE_PASSWORD
|
|
60
|
+
ENV.fetch(::Regexp.last_match(1), nil)
|
|
61
|
+
else
|
|
62
|
+
# Literal value
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fetch_from_rails_credentials(key_path)
|
|
68
|
+
return nil unless Credentials.available?
|
|
69
|
+
|
|
70
|
+
keys = key_path.split('.').map(&:to_sym)
|
|
71
|
+
Rails.application.credentials.dig(*keys)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def execute_command(command)
|
|
75
|
+
# Preserve RAILS_ENV if set
|
|
76
|
+
env_prefix = ENV['RAILS_ENV'] ? "RAILS_ENV=#{ENV['RAILS_ENV']} " : ''
|
|
77
|
+
full_command = "#{env_prefix}#{command}"
|
|
78
|
+
|
|
79
|
+
result = `#{full_command}`.strip
|
|
80
|
+
|
|
81
|
+
raise Error, "Failed to execute secret command: #{command} (exit status: #{$CHILD_STATUS.exitstatus})" unless $CHILD_STATUS.success?
|
|
82
|
+
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
require 'sshkit'
|
|
2
|
+
require 'sshkit/dsl'
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module ActivePostgres
|
|
6
|
+
class SSHExecutor
|
|
7
|
+
include SSHKit::DSL
|
|
8
|
+
|
|
9
|
+
attr_reader :config
|
|
10
|
+
|
|
11
|
+
def initialize(config, quiet: false)
|
|
12
|
+
@config = config
|
|
13
|
+
@quiet = quiet
|
|
14
|
+
setup_sshkit
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def quiet?
|
|
18
|
+
@quiet
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def execute_on_host(host, &)
|
|
22
|
+
on("#{config.user}@#{host}", &)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def execute_on_primary(&)
|
|
26
|
+
execute_on_host(config.primary_host, &)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def execute_on_standbys(&)
|
|
30
|
+
hosts = config.standby_hosts.map { |h| "#{config.user}@#{h}" }
|
|
31
|
+
on(hosts, in: :parallel, &)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def execute_on_all_hosts(&)
|
|
35
|
+
hosts = config.all_hosts.map { |h| "#{config.user}@#{h}" }
|
|
36
|
+
on(hosts, in: :parallel, &)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def install_postgres(host, version = 18)
|
|
40
|
+
execute_on_host(host) do
|
|
41
|
+
info "Installing PostgreSQL #{version}..."
|
|
42
|
+
|
|
43
|
+
if test('[ -f /etc/apt/sources.list.d/pgdg.list ]')
|
|
44
|
+
execute :sudo, 'rm', '-f',
|
|
45
|
+
'/etc/apt/sources.list.d/pgdg.list'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
execute :sudo, 'apt-get', 'update', '-qq'
|
|
49
|
+
execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', '-qq', 'gnupg', 'wget',
|
|
50
|
+
'lsb-release', 'locales'
|
|
51
|
+
|
|
52
|
+
info 'Generating locales...'
|
|
53
|
+
execute :sudo, 'locale-gen', 'en_US.UTF-8'
|
|
54
|
+
execute :sudo, 'update-locale', 'LANG=en_US.UTF-8'
|
|
55
|
+
|
|
56
|
+
# Check for any installed PostgreSQL server packages
|
|
57
|
+
if test('command -v pg_lsclusters')
|
|
58
|
+
existing_clusters = capture(:pg_lsclusters, '-h').split("\n")
|
|
59
|
+
installed_versions = existing_clusters.map { |line| line.split[0].to_i }.uniq.sort
|
|
60
|
+
|
|
61
|
+
# Check if we have different versions installed
|
|
62
|
+
other_versions = installed_versions - [version]
|
|
63
|
+
|
|
64
|
+
if other_versions.any?
|
|
65
|
+
info "Found PostgreSQL version(s) #{other_versions.join(', ')}, cleaning up for fresh PostgreSQL #{version} install..."
|
|
66
|
+
|
|
67
|
+
# Stop all PostgreSQL services
|
|
68
|
+
execute :sudo, 'systemctl', 'stop', 'postgresql' if test('systemctl is-active postgresql')
|
|
69
|
+
|
|
70
|
+
# Remove all PostgreSQL packages
|
|
71
|
+
execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'remove', '--purge', '-y', '-qq', 'postgresql*'
|
|
72
|
+
execute :sudo, 'apt-get', 'autoremove', '-y', '-qq'
|
|
73
|
+
|
|
74
|
+
# Clean up OLD version directories only (preserve target version SSL certs, etc.)
|
|
75
|
+
other_versions.each do |old_version|
|
|
76
|
+
execute :sudo, 'rm', '-rf', "/etc/postgresql/#{old_version}"
|
|
77
|
+
execute :sudo, 'rm', '-rf', "/var/lib/postgresql/#{old_version}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
execute :sudo, 'rm', '-f', '/etc/apt/sources.list.d/pgdg.list'
|
|
81
|
+
execute :sudo, 'rm', '-f', '/usr/share/keyrings/postgresql-archive-keyring.gpg'
|
|
82
|
+
|
|
83
|
+
info 'Cleanup complete'
|
|
84
|
+
elsif installed_versions.include?(version)
|
|
85
|
+
info "PostgreSQL #{version} already installed, skipping cleanup"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
info 'Ensuring PostgreSQL GPG key is present...'
|
|
90
|
+
execute :wget, '--quiet', '-O', '/tmp/pgdg.asc', 'https://www.postgresql.org/media/keys/ACCC4CF8.asc'
|
|
91
|
+
execute :sudo, 'gpg', '--dearmor', '--yes', '-o', '/usr/share/keyrings/postgresql-archive-keyring.gpg',
|
|
92
|
+
'/tmp/pgdg.asc'
|
|
93
|
+
execute :rm, '/tmp/pgdg.asc'
|
|
94
|
+
|
|
95
|
+
info 'Configuring PostgreSQL apt repository...'
|
|
96
|
+
pgdg_repo = "'echo \"deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] " \
|
|
97
|
+
'http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > ' \
|
|
98
|
+
"/etc/apt/sources.list.d/pgdg.list'"
|
|
99
|
+
execute :sudo, 'sh', '-c', pgdg_repo
|
|
100
|
+
|
|
101
|
+
execute :sudo, 'apt-get', 'update', '-qq'
|
|
102
|
+
execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', '-qq', "postgresql-#{version}",
|
|
103
|
+
"postgresql-client-#{version}"
|
|
104
|
+
|
|
105
|
+
execute :sudo, 'systemctl', 'enable', 'postgresql'
|
|
106
|
+
execute :sudo, 'systemctl', 'start', 'postgresql' unless test('systemctl is-active postgresql')
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def upload_file(host, content, remote_path, mode: '644', owner: nil)
|
|
111
|
+
execute_on_host(host) do
|
|
112
|
+
temp_file = "/tmp/#{File.basename(remote_path)}"
|
|
113
|
+
upload! StringIO.new(content), temp_file
|
|
114
|
+
|
|
115
|
+
execute :sudo, 'mv', temp_file, remote_path
|
|
116
|
+
execute :sudo, 'chown', owner, remote_path if owner
|
|
117
|
+
execute :sudo, 'chmod', mode, remote_path
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def ensure_postgres_user(host)
|
|
122
|
+
postgres_user = config.postgres_user
|
|
123
|
+
|
|
124
|
+
execute_on_host(host) do
|
|
125
|
+
execute :sudo, 'groupadd', '--system', postgres_user unless test(:getent, 'group', postgres_user)
|
|
126
|
+
|
|
127
|
+
unless test(:id, postgres_user)
|
|
128
|
+
execute :sudo, 'useradd', '--system', '--home', '/var/lib/postgresql',
|
|
129
|
+
'--shell', '/bin/bash', '--gid', postgres_user, '--create-home', postgres_user
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def postgres_running?(host)
|
|
135
|
+
result = false
|
|
136
|
+
execute_on_host(host) do
|
|
137
|
+
# Check if any PostgreSQL cluster is online using pg_lsclusters
|
|
138
|
+
# This works regardless of whether it's the generic postgresql service
|
|
139
|
+
# or a specific postgresql@version-main service
|
|
140
|
+
clusters = begin
|
|
141
|
+
capture(:sudo, 'pg_lsclusters', '2>/dev/null')
|
|
142
|
+
rescue StandardError
|
|
143
|
+
''
|
|
144
|
+
end
|
|
145
|
+
result = clusters.include?('online')
|
|
146
|
+
end
|
|
147
|
+
result
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def restart_postgres(host, version = nil)
|
|
151
|
+
execute_on_host(host) do
|
|
152
|
+
if version
|
|
153
|
+
begin
|
|
154
|
+
execute :sudo, 'pg_ctlcluster', version.to_s, 'main', 'restart'
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
error "Failed to restart PostgreSQL cluster #{version}/main"
|
|
157
|
+
info 'Checking systemd logs...'
|
|
158
|
+
logs = begin
|
|
159
|
+
capture(:sudo, 'journalctl', '-xeu', "postgresql@#{version}-main", '-n', '50',
|
|
160
|
+
'--no-pager')
|
|
161
|
+
rescue StandardError
|
|
162
|
+
'Could not get systemd logs'
|
|
163
|
+
end
|
|
164
|
+
info logs
|
|
165
|
+
info 'Checking PostgreSQL logs...'
|
|
166
|
+
pg_logs = begin
|
|
167
|
+
capture(:sudo, 'tail', '-100',
|
|
168
|
+
"/var/log/postgresql/postgresql-#{version}-main.log")
|
|
169
|
+
rescue StandardError
|
|
170
|
+
'Could not get PostgreSQL logs'
|
|
171
|
+
end
|
|
172
|
+
info pg_logs
|
|
173
|
+
info 'Checking cluster status...'
|
|
174
|
+
cluster_status = begin
|
|
175
|
+
capture(:sudo, 'pg_lsclusters')
|
|
176
|
+
rescue StandardError
|
|
177
|
+
'Could not get cluster status'
|
|
178
|
+
end
|
|
179
|
+
info cluster_status
|
|
180
|
+
raise e
|
|
181
|
+
end
|
|
182
|
+
else
|
|
183
|
+
execute :sudo, 'systemctl', 'restart', 'postgresql'
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def stop_postgres(host)
|
|
189
|
+
execute_on_host(host) do
|
|
190
|
+
execute :sudo, 'systemctl', 'stop', 'postgresql'
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def get_postgres_status(host)
|
|
195
|
+
result = nil
|
|
196
|
+
postgres_user = config.postgres_user
|
|
197
|
+
execute_on_host(host) do
|
|
198
|
+
result = capture(:sudo, '-u', postgres_user, 'psql', '-c', 'SELECT version();')
|
|
199
|
+
end
|
|
200
|
+
result
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def run_sql(host, sql)
|
|
204
|
+
result = nil
|
|
205
|
+
postgres_user = config.postgres_user
|
|
206
|
+
execute_on_host(host) do
|
|
207
|
+
# Use a temporary file to avoid shell escaping issues with special characters
|
|
208
|
+
temp_file = "/tmp/query_#{SecureRandom.hex(8)}.sql"
|
|
209
|
+
upload! StringIO.new(sql), temp_file
|
|
210
|
+
execute :chmod, '644', temp_file
|
|
211
|
+
|
|
212
|
+
begin
|
|
213
|
+
result = capture(:sudo, '-u', postgres_user, 'psql', '-t', '-f', temp_file)
|
|
214
|
+
ensure
|
|
215
|
+
execute :rm, '-f', temp_file
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
result
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def ensure_cluster_exists(host, version)
|
|
222
|
+
execute_on_host(host) do
|
|
223
|
+
data_dir = "/var/lib/postgresql/#{version}/main"
|
|
224
|
+
|
|
225
|
+
if test(:sudo, 'test', '-d', data_dir)
|
|
226
|
+
info "PostgreSQL #{version}/main cluster already exists, skipping creation"
|
|
227
|
+
else
|
|
228
|
+
info 'Creating PostgreSQL cluster...'
|
|
229
|
+
execute :sudo, 'pg_createcluster', version.to_s, 'main', '--start'
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def recreate_cluster(host, version)
|
|
235
|
+
execute_on_host(host) do
|
|
236
|
+
info 'Ensuring clean cluster state...'
|
|
237
|
+
begin
|
|
238
|
+
execute :sudo, 'systemctl', 'stop', 'postgresql'
|
|
239
|
+
rescue StandardError
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
begin
|
|
243
|
+
execute :sudo, 'pg_dropcluster', '--stop', version.to_s, 'main'
|
|
244
|
+
rescue StandardError
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
begin
|
|
248
|
+
execute :sudo, 'rm', '-rf', "/etc/postgresql/#{version}/main"
|
|
249
|
+
rescue StandardError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
begin
|
|
253
|
+
execute :sudo, 'rm', '-rf', "/var/lib/postgresql/#{version}/main"
|
|
254
|
+
rescue StandardError
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
info 'Creating fresh PostgreSQL cluster...'
|
|
259
|
+
execute :sudo, 'pg_createcluster', version.to_s, 'main'
|
|
260
|
+
execute :sudo, 'systemctl', 'start', 'postgresql'
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
private
|
|
265
|
+
|
|
266
|
+
def setup_sshkit
|
|
267
|
+
if @quiet
|
|
268
|
+
SSHKit.config.output_verbosity = ::Logger::FATAL
|
|
269
|
+
SSHKit.config.format = :blackhole
|
|
270
|
+
else
|
|
271
|
+
SSHKit.config.output_verbosity = ::Logger::INFO
|
|
272
|
+
SSHKit.config.format = :pretty
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
return unless File.exist?(config.ssh_key)
|
|
276
|
+
|
|
277
|
+
SSHKit::Backend::Netssh.configure do |ssh|
|
|
278
|
+
ssh.ssh_options = {
|
|
279
|
+
keys: [config.ssh_key],
|
|
280
|
+
keys_only: true,
|
|
281
|
+
forward_agent: false,
|
|
282
|
+
auth_methods: ['publickey'],
|
|
283
|
+
verify_host_key: :never
|
|
284
|
+
}
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module ActivePostgres
|
|
2
|
+
class StandbyDeploymentFlow < DeploymentFlow
|
|
3
|
+
attr_reader :standby_host
|
|
4
|
+
|
|
5
|
+
def initialize(config, standby_host:, **)
|
|
6
|
+
super(config, **)
|
|
7
|
+
@standby_host = standby_host
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def operation_name
|
|
13
|
+
"Standby Setup: #{standby_host}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def print_targets
|
|
17
|
+
logger.info "Primary: #{config.primary_host}"
|
|
18
|
+
logger.info "Standby: #{standby_host}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate_specific_requirements
|
|
22
|
+
abort "❌ #{standby_host} is not configured as a standby in config/postgres.yml" unless config.standby_hosts.include?(standby_host)
|
|
23
|
+
|
|
24
|
+
return if config.component_enabled?(:repmgr)
|
|
25
|
+
|
|
26
|
+
abort '❌ repmgr component must be enabled to setup standbys'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run_preflight_checks
|
|
30
|
+
super
|
|
31
|
+
|
|
32
|
+
check_if_standby_already_deployed
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def check_if_standby_already_deployed
|
|
36
|
+
logger.info "\nChecking if standby is already deployed..."
|
|
37
|
+
|
|
38
|
+
postgres_running = ssh_executor.postgres_running?(standby_host)
|
|
39
|
+
|
|
40
|
+
return unless postgres_running
|
|
41
|
+
|
|
42
|
+
logger.warn "⚠️ PostgreSQL is already running on #{standby_host}"
|
|
43
|
+
logger.warn ' This deployment will DROP and RECREATE the database cluster'
|
|
44
|
+
logger.warn ' All data on this standby will be LOST and re-cloned from primary'
|
|
45
|
+
puts ''
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
logger.warn "Could not check if standby is deployed: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def list_deployment_steps
|
|
51
|
+
logger.info " • Install PostgreSQL #{config.version} packages on #{standby_host}"
|
|
52
|
+
logger.info " • Clone data from primary: #{config.primary_host}"
|
|
53
|
+
logger.info ' • Register standby with repmgr cluster'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def list_warnings
|
|
57
|
+
logger.info "\n⚠️ Primary database will NOT be touched"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def deploy_components
|
|
61
|
+
deploy_ssl if config.component_enabled?(:ssl)
|
|
62
|
+
deploy_core
|
|
63
|
+
deploy_repmgr
|
|
64
|
+
deploy_optional_components
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def deploy_ssl
|
|
68
|
+
logger.task('Setting up SSL on standby') do
|
|
69
|
+
component = Components::SSL.new(config, ssh_executor, secrets)
|
|
70
|
+
register_rollback('SSL', component)
|
|
71
|
+
component.install_on_standby(standby_host)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def deploy_core
|
|
76
|
+
logger.task('Installing PostgreSQL packages on standby') do
|
|
77
|
+
component = Components::Core.new(config, ssh_executor, secrets)
|
|
78
|
+
component.install_packages_only(standby_host)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def deploy_repmgr
|
|
83
|
+
logger.task('Setting up repmgr and cloning from primary') do
|
|
84
|
+
component = Components::Repmgr.new(config, ssh_executor, secrets)
|
|
85
|
+
register_rollback('repmgr', component)
|
|
86
|
+
component.setup_standby_only(standby_host)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def deploy_optional_components
|
|
91
|
+
deploy_pgbouncer if config.component_enabled?(:pgbouncer)
|
|
92
|
+
deploy_monitoring if config.component_enabled?(:monitoring)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def deploy_pgbouncer
|
|
96
|
+
logger.task('Setting up pgbouncer on standby') do
|
|
97
|
+
component = Components::PgBouncer.new(config, ssh_executor, secrets)
|
|
98
|
+
component.install_on_standby(standby_host) if component.respond_to?(:install_on_standby)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def deploy_monitoring
|
|
103
|
+
logger.task('Setting up monitoring on standby') do
|
|
104
|
+
component = Components::Monitoring.new(config, ssh_executor, secrets)
|
|
105
|
+
component.install_on_standby(standby_host) if component.respond_to?(:install_on_standby)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def register_rollback(component_name, component)
|
|
110
|
+
rollback_manager.register("Uninstall #{component_name} on #{standby_host}", host: standby_host) do
|
|
111
|
+
component.uninstall
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
logger.warn "Failed to uninstall #{component_name} on #{standby_host}: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def list_next_steps
|
|
118
|
+
logger.info ' 1. Check cluster status: active_postgres status'
|
|
119
|
+
logger.info ' 2. Verify replication: Check repmgr cluster show'
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
module ActivePostgres
|
|
2
|
+
class Validator
|
|
3
|
+
attr_reader :config, :ssh_executor, :errors, :warnings
|
|
4
|
+
|
|
5
|
+
def initialize(config, ssh_executor)
|
|
6
|
+
@config = config
|
|
7
|
+
@ssh_executor = ssh_executor
|
|
8
|
+
@errors = []
|
|
9
|
+
@warnings = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Run all validation checks before installation
|
|
13
|
+
def validate_all
|
|
14
|
+
puts 'Running pre-flight validation checks...'
|
|
15
|
+
|
|
16
|
+
validate_configuration
|
|
17
|
+
validate_ssh_connectivity
|
|
18
|
+
validate_network_connectivity
|
|
19
|
+
validate_system_requirements
|
|
20
|
+
|
|
21
|
+
if errors.any?
|
|
22
|
+
puts "\n❌ Validation failed with #{errors.count} error(s):"
|
|
23
|
+
errors.each { |error| puts " - #{error}" }
|
|
24
|
+
return false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if warnings.any?
|
|
28
|
+
puts "\n⚠️ Found #{warnings.count} warning(s):"
|
|
29
|
+
warnings.each { |warning| puts " - #{warning}" }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
puts "✅ All validation checks passed!\n"
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def validate_configuration
|
|
39
|
+
puts ' Checking configuration...'
|
|
40
|
+
|
|
41
|
+
# Validate primary host
|
|
42
|
+
errors << 'Primary host not configured' unless config.primary_host
|
|
43
|
+
|
|
44
|
+
# Validate PostgreSQL version
|
|
45
|
+
errors << "PostgreSQL version must be 12 or higher (got: #{config.version})" unless config.version && config.version >= 12
|
|
46
|
+
|
|
47
|
+
# Validate repmgr setup
|
|
48
|
+
if config.component_enabled?(:repmgr)
|
|
49
|
+
errors << 'repmgr enabled but no standby hosts configured' unless config.standby_hosts&.any?
|
|
50
|
+
|
|
51
|
+
replication_host = config.primary_replication_host
|
|
52
|
+
if replication_host == config.primary_host
|
|
53
|
+
warnings << "Primary is using '#{config.primary_host}' for replication traffic. Set private_ip for isolated networks."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
config.standby_hosts.each do |host|
|
|
57
|
+
standby_replication_host = config.replication_host_for(host)
|
|
58
|
+
next unless standby_replication_host == host
|
|
59
|
+
|
|
60
|
+
warnings << "Standby #{host} is using its SSH host for replication. Provide private_ip if it differs."
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Validate SSL configuration
|
|
65
|
+
return unless config.component_enabled?(:ssl)
|
|
66
|
+
|
|
67
|
+
ssl_config = config.component_config(:ssl)
|
|
68
|
+
return unless ssl_config['certificate_mode'] == 'custom'
|
|
69
|
+
|
|
70
|
+
warnings << 'Custom SSL certificates configured - ensure they are available'
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_ssh_connectivity
|
|
74
|
+
puts ' Checking SSH connectivity...'
|
|
75
|
+
|
|
76
|
+
all_hosts = [config.primary_host] + config.standby_hosts
|
|
77
|
+
|
|
78
|
+
all_hosts.each do |host|
|
|
79
|
+
ssh_executor.execute_on_host(host) do
|
|
80
|
+
test(:echo, 'SSH connection test')
|
|
81
|
+
end
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
errors << "Cannot connect to #{host} via SSH: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_network_connectivity
|
|
88
|
+
puts ' Checking network connectivity...'
|
|
89
|
+
|
|
90
|
+
return unless config.component_enabled?(:repmgr)
|
|
91
|
+
|
|
92
|
+
primary_replication_host = config.primary_replication_host
|
|
93
|
+
return unless primary_replication_host
|
|
94
|
+
|
|
95
|
+
# Capture errors and warnings before entering SSH block
|
|
96
|
+
validator_errors = errors
|
|
97
|
+
validator_warnings = warnings
|
|
98
|
+
|
|
99
|
+
# Check if standbys can reach primary via the preferred replication network
|
|
100
|
+
config.standby_hosts.each do |host|
|
|
101
|
+
ssh_executor.execute_on_host(host) do
|
|
102
|
+
can_ping = test(:ping, '-c', '1', '-W', '2', primary_replication_host)
|
|
103
|
+
validator_errors << "Standby #{host} cannot reach the primary over #{primary_replication_host}" unless can_ping
|
|
104
|
+
end
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
validator_warnings << "Could not test private network connectivity from #{host}: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_system_requirements
|
|
111
|
+
puts ' Checking system requirements...'
|
|
112
|
+
|
|
113
|
+
all_hosts = [config.primary_host] + config.standby_hosts
|
|
114
|
+
|
|
115
|
+
# Capture errors and warnings before entering SSH block
|
|
116
|
+
validator_errors = errors
|
|
117
|
+
validator_warnings = warnings
|
|
118
|
+
|
|
119
|
+
all_hosts.each do |host|
|
|
120
|
+
ssh_executor.execute_on_host(host) do
|
|
121
|
+
# Check if running Debian/Ubuntu
|
|
122
|
+
unless test('[ -f /etc/debian_version ]')
|
|
123
|
+
validator_errors << "#{host} is not running Debian/Ubuntu"
|
|
124
|
+
next
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Check available disk space (require at least 10GB)
|
|
128
|
+
# Check /var or root filesystem (since /var/lib/postgresql may not exist yet)
|
|
129
|
+
disk_space_output = capture(:df, '-BG', '/var', '|', :tail, '-1', '|', :awk, "'{print $4}'")
|
|
130
|
+
disk_space = disk_space_output.gsub('G', '').to_i
|
|
131
|
+
validator_warnings << "#{host} has less than 10GB free disk space (#{disk_space}GB available)" if disk_space < 10
|
|
132
|
+
|
|
133
|
+
# Check if postgres user already exists
|
|
134
|
+
if test(:id, 'postgres')
|
|
135
|
+
validator_warnings << "#{host} already has a 'postgres' user - this is expected if PostgreSQL was previously installed"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
validator_warnings << "Could not check system requirements on #{host}: #{e.message}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|