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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +23 -0
  3. data/README.md +158 -0
  4. data/exe/activepostgres +5 -0
  5. data/lib/active_postgres/cli.rb +157 -0
  6. data/lib/active_postgres/cluster_deployment_flow.rb +85 -0
  7. data/lib/active_postgres/component_resolver.rb +24 -0
  8. data/lib/active_postgres/components/base.rb +38 -0
  9. data/lib/active_postgres/components/core.rb +158 -0
  10. data/lib/active_postgres/components/extensions.rb +99 -0
  11. data/lib/active_postgres/components/monitoring.rb +55 -0
  12. data/lib/active_postgres/components/pgbackrest.rb +94 -0
  13. data/lib/active_postgres/components/pgbouncer.rb +137 -0
  14. data/lib/active_postgres/components/repmgr.rb +651 -0
  15. data/lib/active_postgres/components/ssl.rb +86 -0
  16. data/lib/active_postgres/configuration.rb +190 -0
  17. data/lib/active_postgres/connection_pooler.rb +429 -0
  18. data/lib/active_postgres/credentials.rb +17 -0
  19. data/lib/active_postgres/deployment_flow.rb +154 -0
  20. data/lib/active_postgres/error_handler.rb +185 -0
  21. data/lib/active_postgres/failover.rb +83 -0
  22. data/lib/active_postgres/generators/active_postgres/install_generator.rb +186 -0
  23. data/lib/active_postgres/health_checker.rb +244 -0
  24. data/lib/active_postgres/installer.rb +114 -0
  25. data/lib/active_postgres/log_sanitizer.rb +67 -0
  26. data/lib/active_postgres/logger.rb +125 -0
  27. data/lib/active_postgres/performance_tuner.rb +246 -0
  28. data/lib/active_postgres/rails/database_config.rb +174 -0
  29. data/lib/active_postgres/rails/migration_guard.rb +25 -0
  30. data/lib/active_postgres/railtie.rb +28 -0
  31. data/lib/active_postgres/retry_helper.rb +80 -0
  32. data/lib/active_postgres/rollback_manager.rb +140 -0
  33. data/lib/active_postgres/secrets.rb +86 -0
  34. data/lib/active_postgres/ssh_executor.rb +288 -0
  35. data/lib/active_postgres/standby_deployment_flow.rb +122 -0
  36. data/lib/active_postgres/validator.rb +143 -0
  37. data/lib/active_postgres/version.rb +3 -0
  38. data/lib/active_postgres.rb +67 -0
  39. data/lib/tasks/postgres.rake +855 -0
  40. data/lib/tasks/rolling_update.rake +258 -0
  41. data/lib/tasks/rotate_credentials.rake +193 -0
  42. data/templates/pg_hba.conf.erb +47 -0
  43. data/templates/pgbackrest.conf.erb +43 -0
  44. data/templates/pgbouncer.ini.erb +55 -0
  45. data/templates/postgresql.conf.erb +157 -0
  46. data/templates/repmgr.conf.erb +40 -0
  47. metadata +224 -0
@@ -0,0 +1,99 @@
1
+ module ActivePostgres
2
+ module Components
3
+ class Extensions < Base
4
+ EXTENSION_PACKAGES = {
5
+ 'pgvector' => 'postgresql-{version}-pgvector',
6
+ 'postgis' => 'postgresql-{version}-postgis-3',
7
+ 'pg_trgm' => nil, # Built-in, no package needed
8
+ 'hstore' => nil, # Built-in
9
+ 'uuid-ossp' => nil, # Built-in
10
+ 'ltree' => nil, # Built-in
11
+ 'citext' => nil, # Built-in
12
+ 'unaccent' => nil, # Built-in
13
+ 'pg_stat_statements' => nil, # Built-in
14
+ 'timescaledb' => 'timescaledb-2-postgresql-{version}',
15
+ 'citus' => 'postgresql-{version}-citus-12.1',
16
+ 'pg_partman' => 'postgresql-{version}-partman'
17
+ }.freeze
18
+
19
+ def install
20
+ extensions_config = config.component_config(:extensions)
21
+ return unless extensions_config[:enabled]
22
+
23
+ extensions = extensions_config[:list] || []
24
+ return if extensions.empty?
25
+
26
+ puts 'Installing PostgreSQL extensions...'
27
+
28
+ install_on_primary(extensions)
29
+ install_on_standbys(extensions) if config.standbys.any?
30
+ end
31
+
32
+ def uninstall
33
+ puts 'Extensions uninstall not implemented (extensions remain in database)'
34
+ end
35
+
36
+ private
37
+
38
+ def install_on_primary(extensions)
39
+ host = config.primary_host
40
+ version = config.version
41
+ db_name = config.secrets_config['database_name'] || 'postgres'
42
+ postgres_user = config.postgres_user
43
+
44
+ puts " Installing extensions on primary (#{host})..."
45
+
46
+ packages_to_install = []
47
+ extensions.each do |ext_name|
48
+ package = EXTENSION_PACKAGES[ext_name]
49
+ next unless package
50
+
51
+ package = package.gsub('{version}', version.to_s)
52
+ packages_to_install << package
53
+ end
54
+
55
+ ssh_executor.execute_on_host(host) do
56
+ unless packages_to_install.empty?
57
+ execute :sudo, 'apt-get', 'update', '-qq'
58
+ execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', '-qq',
59
+ *packages_to_install
60
+ end
61
+
62
+ extensions.each do |ext_name|
63
+ sql = "CREATE EXTENSION IF NOT EXISTS #{ext_name};"
64
+ begin
65
+ execute :sudo, '-u', postgres_user, 'psql', '-d', db_name, '-c', sql
66
+ rescue StandardError
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ def install_on_standbys(extensions)
74
+ version = config.version
75
+
76
+ config.standby_hosts.each do |host|
77
+ puts " Installing extensions on standby (#{host})..."
78
+
79
+ packages_to_install = []
80
+ extensions.each do |ext_name|
81
+ package = EXTENSION_PACKAGES[ext_name]
82
+ next unless package
83
+
84
+ package = package.gsub('{version}', version.to_s)
85
+ packages_to_install << package
86
+ end
87
+
88
+ ssh_executor.execute_on_host(host) do
89
+ unless packages_to_install.empty?
90
+ execute :sudo, 'apt-get', 'update', '-qq'
91
+ execute :sudo, 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', '-qq',
92
+ *packages_to_install
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,55 @@
1
+ module ActivePostgres
2
+ module Components
3
+ class Monitoring < Base
4
+ def install
5
+ puts 'Installing postgres_exporter for monitoring...'
6
+
7
+ config.all_hosts.each do |host|
8
+ install_on_host(host)
9
+ end
10
+ end
11
+
12
+ def uninstall
13
+ puts 'Uninstalling postgres_exporter...'
14
+
15
+ config.all_hosts.each do |host|
16
+ ssh_executor.execute_on_host(host) do
17
+ execute :sudo, 'systemctl', 'stop', 'postgres_exporter'
18
+ execute :sudo, 'systemctl', 'disable', 'postgres_exporter'
19
+ end
20
+ end
21
+ end
22
+
23
+ def restart
24
+ puts 'Restarting postgres_exporter...'
25
+
26
+ config.all_hosts.each do |host|
27
+ ssh_executor.execute_on_host(host) do
28
+ execute :sudo, 'systemctl', 'restart', 'postgres_exporter'
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def install_on_host(host)
36
+ puts " Installing postgres_exporter on #{host}..."
37
+
38
+ monitoring_config = config.component_config(:monitoring)
39
+ exporter_port = monitoring_config[:exporter_port] || 9187
40
+
41
+ # Download and install postgres_exporter
42
+ ssh_executor.execute_on_host(host) do
43
+ # Install via package manager or download binary
44
+ execute :sudo, 'apt-get', 'install', '-y', '-qq', 'prometheus-postgres-exporter'
45
+
46
+ # Enable and start
47
+ execute :sudo, 'systemctl', 'enable', 'prometheus-postgres-exporter'
48
+ execute :sudo, 'systemctl', 'start', 'prometheus-postgres-exporter'
49
+ end
50
+
51
+ puts " Metrics available at: http://#{host}:#{exporter_port}/metrics"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,94 @@
1
+ module ActivePostgres
2
+ module Components
3
+ class PgBackRest < Base
4
+ def install
5
+ puts 'Installing pgBackRest for backups...'
6
+
7
+ install_on_host(config.primary_host)
8
+ end
9
+
10
+ def uninstall
11
+ puts 'Uninstalling pgBackRest...'
12
+
13
+ ssh_executor.execute_on_host(config.primary_host) do
14
+ execute :sudo, 'apt-get', 'remove', '-y', 'pgbackrest'
15
+ end
16
+ end
17
+
18
+ def restart
19
+ puts "pgBackRest is a backup tool and doesn't run as a service."
20
+ end
21
+
22
+ def run_backup(type = 'full')
23
+ puts "Running #{type} backup..."
24
+ postgres_user = config.postgres_user
25
+
26
+ ssh_executor.execute_on_host(config.primary_host) do
27
+ execute :sudo, '-u', postgres_user, 'pgbackrest', '--stanza=main', "--type=#{type}", 'backup'
28
+ end
29
+ end
30
+
31
+ def run_restore(backup_id)
32
+ puts "Restoring from backup #{backup_id}..."
33
+ postgres_user = config.postgres_user
34
+
35
+ ssh_executor.execute_on_host(config.primary_host) do
36
+ # Stop PostgreSQL
37
+ execute :sudo, 'systemctl', 'stop', 'postgresql'
38
+
39
+ # Restore
40
+ execute :sudo, '-u', postgres_user, 'pgbackrest', '--stanza=main', "--set=#{backup_id}", 'restore'
41
+
42
+ # Start PostgreSQL
43
+ execute :sudo, 'systemctl', 'start', 'postgresql'
44
+ end
45
+ end
46
+
47
+ def list_backups
48
+ puts 'Available backups:'
49
+ postgres_user = config.postgres_user
50
+
51
+ ssh_executor.execute_on_host(config.primary_host) do
52
+ execute :sudo, '-u', postgres_user, 'pgbackrest', 'info'
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def install_on_host(host)
59
+ puts " Installing pgBackRest on #{host}..."
60
+
61
+ pgbackrest_config = config.component_config(:pgbackrest)
62
+ postgres_user = config.postgres_user
63
+ secrets_obj = secrets
64
+ _ = pgbackrest_config # Used in ERB template
65
+ _ = secrets_obj # Used in ERB template
66
+
67
+ # Install package
68
+ ssh_executor.execute_on_host(host) do
69
+ execute :sudo, 'apt-get', 'install', '-y', '-qq', 'pgbackrest'
70
+ end
71
+
72
+ # Upload configuration
73
+ upload_template(host, 'pgbackrest.conf.erb', '/etc/pgbackrest.conf', binding, mode: '644')
74
+
75
+ ssh_executor.execute_on_host(host) do
76
+ execute :sudo, 'rm', '-rf', '/var/lib/pgbackrest', '||', 'true'
77
+ execute :sudo, 'mkdir', '-p', '/var/lib/pgbackrest'
78
+ execute :sudo, 'chown', "#{postgres_user}:#{postgres_user}", '/var/lib/pgbackrest'
79
+ execute :sudo, 'chmod', '750', '/var/lib/pgbackrest'
80
+
81
+ execute :sudo, 'rm', '-rf', '/var/log/pgbackrest', '||', 'true'
82
+ execute :sudo, 'mkdir', '-p', '/var/log/pgbackrest'
83
+ execute :sudo, 'chown', "#{postgres_user}:#{postgres_user}", '/var/log/pgbackrest'
84
+
85
+ execute :sudo, 'rm', '-rf', '/var/spool/pgbackrest', '||', 'true'
86
+ execute :sudo, 'mkdir', '-p', '/var/spool/pgbackrest'
87
+ execute :sudo, 'chown', "#{postgres_user}:#{postgres_user}", '/var/spool/pgbackrest'
88
+
89
+ execute :sudo, '-u', postgres_user, 'pgbackrest', '--stanza=main', 'stanza-create'
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,137 @@
1
+ module ActivePostgres
2
+ module Components
3
+ class PgBouncer < Base
4
+ def install
5
+ puts 'Installing PgBouncer for connection pooling...'
6
+
7
+ # Install on primary (can also install on standbys if needed)
8
+ install_on_host(config.primary_host)
9
+ end
10
+
11
+ def uninstall
12
+ puts 'Uninstalling PgBouncer...'
13
+
14
+ ssh_executor.execute_on_host(config.primary_host) do
15
+ execute :sudo, 'systemctl', 'stop', 'pgbouncer'
16
+ execute :sudo, 'apt-get', 'remove', '-y', 'pgbouncer'
17
+ end
18
+ end
19
+
20
+ def restart
21
+ puts 'Restarting PgBouncer...'
22
+
23
+ ssh_executor.execute_on_host(config.primary_host) do
24
+ execute :sudo, 'systemctl', 'restart', 'pgbouncer'
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def install_on_host(host)
31
+ puts " Installing PgBouncer on #{host}..."
32
+
33
+ # Get user config
34
+ user_config = config.component_config(:pgbouncer)
35
+
36
+ # Calculate optimal pool settings based on PostgreSQL max_connections
37
+ max_connections = get_postgres_max_connections(host)
38
+ optimal_pool = ConnectionPooler.calculate_optimal_pool_sizes(max_connections)
39
+
40
+ # Merge: user config overrides calculated settings
41
+ pgbouncer_config = optimal_pool.merge(user_config)
42
+ _ = pgbouncer_config # Used in ERB template
43
+
44
+ puts " Calculated pool settings for max_connections=#{max_connections}"
45
+
46
+ # Install package
47
+ ssh_executor.execute_on_host(host) do
48
+ execute :sudo, 'apt-get', 'install', '-y', '-qq', 'pgbouncer'
49
+ end
50
+
51
+ # Upload configuration
52
+ upload_template(host, 'pgbouncer.ini.erb', '/etc/pgbouncer/pgbouncer.ini', binding, mode: '644')
53
+
54
+ # Create userlist with postgres superuser and app user
55
+ create_userlist(host)
56
+
57
+ # Enable and start
58
+ ssh_executor.execute_on_host(host) do
59
+ execute :sudo, 'systemctl', 'enable', 'pgbouncer'
60
+ execute :sudo, 'systemctl', 'restart', 'pgbouncer'
61
+ end
62
+ end
63
+
64
+ def get_postgres_max_connections(host)
65
+ # Try to get max_connections from running PostgreSQL
66
+ postgres_user = config.postgres_user
67
+ max_conn = nil
68
+
69
+ ssh_executor.execute_on_host(host) do
70
+ result = capture(:sudo, '-u', postgres_user, 'psql', '-t', '-c', "'SHOW max_connections;'").strip
71
+ max_conn = result.to_i if result && !result.empty?
72
+ rescue StandardError
73
+ # PostgreSQL might not be running yet
74
+ end
75
+
76
+ # Fall back to config value or default
77
+ max_conn || config.component_config(:core).dig(:postgresql, :max_connections) || 100
78
+ end
79
+
80
+ def create_userlist(host)
81
+ puts ' Creating PgBouncer userlist with database users...'
82
+
83
+ postgres_user = config.postgres_user
84
+ app_user = config.app_user
85
+ users_to_add = [postgres_user, (app_user if app_user != postgres_user)].compact
86
+ pgbouncer = self
87
+
88
+ ssh_executor.execute_on_host(host) do
89
+ backend = self
90
+ userlist_entries = users_to_add.filter_map { |user| pgbouncer.send(:fetch_user_hash, backend, user, postgres_user) }
91
+ pgbouncer.send(:write_userlist_file, backend, userlist_entries)
92
+ end
93
+ end
94
+
95
+ def fetch_user_hash(backend, user, postgres_user)
96
+ sql = build_user_hash_sql(user)
97
+
98
+ backend.upload! StringIO.new(sql), '/tmp/get_user_hash.sql'
99
+ backend.execute :chmod, '644', '/tmp/get_user_hash.sql'
100
+ user_hash = backend.capture(:sudo, '-u', postgres_user, 'psql', '-t', '-f', '/tmp/get_user_hash.sql').strip
101
+ backend.execute :rm, '-f', '/tmp/get_user_hash.sql'
102
+
103
+ if user_hash && !user_hash.empty?
104
+ puts " ✓ Added #{user} to PgBouncer userlist"
105
+ user_hash
106
+ else
107
+ warn " ⚠ User #{user} not found in PostgreSQL - create it first"
108
+ nil
109
+ end
110
+ rescue StandardError => e
111
+ warn " ⚠ Warning: Could not get password hash for #{user}: #{e.message}"
112
+ nil
113
+ end
114
+
115
+ def build_user_hash_sql(user)
116
+ <<~SQL.strip
117
+ SELECT concat('"', rolname, '" "', rolpassword, '"')
118
+ FROM pg_authid
119
+ WHERE rolname = '#{user}'
120
+ SQL
121
+ end
122
+
123
+ def write_userlist_file(backend, userlist_entries)
124
+ if userlist_entries.any?
125
+ userlist_content = "#{userlist_entries.join("\n")}\n"
126
+ backend.upload! StringIO.new(userlist_content), '/tmp/userlist.txt'
127
+ backend.execute :sudo, 'mv', '/tmp/userlist.txt', '/etc/pgbouncer/userlist.txt'
128
+ backend.execute :sudo, 'chmod', '640', '/etc/pgbouncer/userlist.txt'
129
+ backend.execute :sudo, 'chown', 'postgres:postgres', '/etc/pgbouncer/userlist.txt'
130
+ puts " ✓ Created userlist with #{userlist_entries.size} user(s)"
131
+ else
132
+ warn ' Warning: No users added to userlist - connections may fail'
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end