kamal-backup 0.5.2 → 1.0.0.rc1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 341913561478a45fc1dd88b376daae6977a180eba43739963c6e9055d5c064ab
4
- data.tar.gz: 69a572c55cc9b33fcd0ab0a3918111cf7c3e26f3519d01de855c2fa73de08b2d
3
+ metadata.gz: 8e351aa7e872cd48344295084005c0cdb418169affa209b6f6c3b704372e96d5
4
+ data.tar.gz: ee83efba870cf740e9d95898e62191611ddaa8153711d8030c90045166d224e3
5
5
  SHA512:
6
- metadata.gz: af86080b3f6524990cd0df83bed996370e4cff186aaea4338e8ab9e9ddfb595313d58b96dd4f9fc0a17b758ff8869a7fd836840370bfe76b94dcad5d195b7455
7
- data.tar.gz: 257939e10db7512ea4f12d0bd23d8ba472a41c6987ef974cc69c978653491b0e4b107a7e312fb34431745a5b855dae6919a58e8cdd626e8277c9276030a5a6d6
6
+ metadata.gz: 739282c586403339edda3f475190355400f639c0c1eba0b8145531f964a8be6815689f6252a8d4d3f9fb7db42efd3413f1188ff0243503d15d27379bf32c4998
7
+ data.tar.gz: 6abaadf21b62c4d106b92fbce3c0f703d4bb42d03cc6e86c47f9eb90e24942beda433a98c6f05acfb3557da3323ff551a85ad7b63fe6a0db484a06a3813f650f
data/README.md CHANGED
@@ -125,7 +125,7 @@ bundle exec kamal-backup evidence
125
125
 
126
126
  - **Scheduled backups:** the accessory runs continuously and backs up on `backup.schedule`.
127
127
  - **Database and Active Storage coverage:** database dumps plus file-backed Active Storage files from mounted volumes.
128
- - **Restic underneath:** encrypted, deduplicated snapshots in S3-compatible storage, over SFTP, in a restic REST server, or in a filesystem repository.
128
+ - **Restic underneath:** encrypted, deduplicated snapshots in native restic backends or any rclone remote; the accessory includes both rclone and an SSH client.
129
129
  - **Local restores:** inspect production data safely in your local Rails app.
130
130
  - **Restore drills:** restore into scratch production-side targets, run verification commands, and record the result.
131
131
  - **Security review evidence:** `kamal-backup evidence` prints redacted JSON with latest snapshots, `kamal-backup check` results, drills, retention, and tool versions.
@@ -20,6 +20,8 @@ module KamalBackup
20
20
  dump_binary,
21
21
  '--single-transaction',
22
22
  '--quick',
23
+ '--skip-comments',
24
+ '--no-tablespaces',
23
25
  '--routines',
24
26
  '--triggers',
25
27
  '--events'
@@ -28,6 +30,17 @@ module KamalBackup
28
30
  CommandSpec.new(argv: argv, env: password_env(connection))
29
31
  end
30
32
 
33
+ def restore_to_current(restic, snapshot, filename)
34
+ reset_database(current_connection)
35
+ super
36
+ end
37
+
38
+ def restore_to_scratch(restic, snapshot, filename, target:)
39
+ validate_scratch_restore_target(target)
40
+ reset_database(current_connection.merge(database: target))
41
+ restic.pipe_dump_to_command(snapshot, filename, scratch_restore_command(target))
42
+ end
43
+
31
44
  def current_restore_command
32
45
  connection = current_connection
33
46
  argv = [client_binary] + connection_args(connection)
@@ -53,6 +66,68 @@ module KamalBackup
53
66
 
54
67
  private
55
68
 
69
+ DATABASE_OBJECTS_SQL = <<~SQL
70
+ SELECT 'VIEW', HEX(TABLE_NAME)
71
+ FROM information_schema.VIEWS
72
+ WHERE TABLE_SCHEMA = DATABASE()
73
+ UNION ALL
74
+ SELECT CASE WHEN TABLE_TYPE = 'SEQUENCE' THEN 'SEQUENCE' ELSE 'TABLE' END, HEX(TABLE_NAME)
75
+ FROM information_schema.TABLES
76
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE <> 'VIEW'
77
+ UNION ALL
78
+ SELECT ROUTINE_TYPE, HEX(ROUTINE_NAME)
79
+ FROM information_schema.ROUTINES
80
+ WHERE ROUTINE_SCHEMA = DATABASE()
81
+ UNION ALL
82
+ SELECT 'EVENT', HEX(EVENT_NAME)
83
+ FROM information_schema.EVENTS
84
+ WHERE EVENT_SCHEMA = DATABASE()
85
+ SQL
86
+ private_constant :DATABASE_OBJECTS_SQL
87
+
88
+ def reset_database(connection)
89
+ objects = database_objects(connection)
90
+ statements = ['SET FOREIGN_KEY_CHECKS=0;']
91
+ %w[VIEW TABLE SEQUENCE PROCEDURE FUNCTION EVENT].each do |type|
92
+ objects.fetch(type, []).each do |name|
93
+ statements << "DROP #{type} IF EXISTS #{quote_identifier(name)};"
94
+ end
95
+ end
96
+
97
+ Command.capture(
98
+ CommandSpec.new(argv: client_argv(connection), env: password_env(connection)),
99
+ redactor: redactor,
100
+ input: statements.join("\n")
101
+ )
102
+ rescue CommandError => e
103
+ raise CommandError.new(
104
+ "failed to reset the MySQL database before restoring: #{e.message}",
105
+ command: e.command,
106
+ stderr: e.stderr
107
+ )
108
+ end
109
+
110
+ def database_objects(connection)
111
+ command = CommandSpec.new(
112
+ argv: client_argv(
113
+ connection,
114
+ options: ['--batch', '--skip-column-names', '--raw', '--execute', DATABASE_OBJECTS_SQL]
115
+ ),
116
+ env: password_env(connection)
117
+ )
118
+ output = Command.capture(command, redactor: redactor, log_output: false).stdout
119
+ output.lines.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |line, objects|
120
+ type, hex_name = line.chomp.split("\t", 2)
121
+ next if type.to_s.empty? || hex_name.to_s.empty?
122
+
123
+ objects[type] << [hex_name].pack('H*')
124
+ end
125
+ end
126
+
127
+ def quote_identifier(value)
128
+ "`#{value.to_s.gsub('`', '``')}`"
129
+ end
130
+
56
131
  def validate_scratch_restore_target(target)
57
132
  raise ConfigurationError, 'scratch database must differ from the current MySQL database' if current_connection.fetch(:database) == target
58
133
 
@@ -92,6 +167,9 @@ module KamalBackup
92
167
 
93
168
  def parse_url(url)
94
169
  uri = URI.parse(url)
170
+ supported_schemes = %w[mysql mysql2 mariadb]
171
+ raise ConfigurationError, 'DATABASE_URL must use mysql://, mysql2://, or mariadb://' unless supported_schemes.include?(uri.scheme)
172
+
95
173
  database = uri.path.to_s.sub(%r{\A/}, '')
96
174
  raise ConfigurationError, "database name is missing in #{uri.scheme} DATABASE_URL" if database.empty?
97
175
 
@@ -114,6 +192,10 @@ module KamalBackup
114
192
  args
115
193
  end
116
194
 
195
+ def client_argv(connection, options: [])
196
+ [client_binary] + connection_args(connection) + options + [connection.fetch(:database)]
197
+ end
198
+
117
199
  def password_env(connection)
118
200
  connection[:password] ? { 'MYSQL_PWD' => connection[:password] } : {}
119
201
  end
@@ -30,11 +30,12 @@ module KamalBackup
30
30
  end
31
31
 
32
32
  def dump_command
33
- argv = %w[pg_dump --format=custom --no-owner --no-privileges]
34
- CommandSpec.new(argv: argv, env: current_connection)
33
+ connection = current_connection
34
+ argv = [postgres_binary('pg_dump', connection)] + %w[--format=custom --no-owner --no-privileges]
35
+ CommandSpec.new(argv: argv, env: connection)
35
36
  end
36
37
 
37
- # Replace the target schema before restoring.
38
+ # Replace the target's user schemas before restoring.
38
39
  #
39
40
  # pg_restore --clean emits DROP TABLE for each object in the dump, but
40
41
  # PostgreSQL refuses to drop a table another table still references, and
@@ -45,22 +46,31 @@ module KamalBackup
45
46
  # exists", the COPYs never running, and the foreign keys rejected because
46
47
  # the tables they point at are still empty.
47
48
  #
48
- # Dropping the schema first sidesteps the ordering problem entirely. This
49
- # only runs for restore-to-current, which already means "replace this
50
- # database"; scratch restores target a separate database and are
51
- # untouched.
49
+ # Dropping every non-system schema first sidesteps the ordering problem
50
+ # entirely and removes target-only custom schemas as well as public.
51
+ # Current and scratch restores both promise that the target represents
52
+ # the selected snapshot, so both paths reset their target schema.
52
53
  def restore_to_current(restic, snapshot, filename)
53
- reset_current_schema
54
+ reset_schema(current_connection)
54
55
  result = super
55
56
  ensure_restore_reported_no_errors(result)
56
57
  result
57
58
  end
58
59
 
60
+ def restore_to_scratch(restic, snapshot, filename, target:)
61
+ validate_scratch_restore_target(target)
62
+ reset_schema(current_connection.merge('PGDATABASE' => target))
63
+ result = restic.pipe_dump_to_command(snapshot, filename, scratch_restore_command(target))
64
+ ensure_restore_reported_no_errors(result)
65
+ result
66
+ end
67
+
59
68
  def current_restore_command
60
69
  connection = current_connection
61
70
  database = connection.fetch('PGDATABASE')
62
71
 
63
- argv = %w[pg_restore --clean --if-exists --no-owner --no-privileges --dbname]
72
+ argv = [postgres_binary('pg_restore', connection)] +
73
+ %w[--clean --if-exists --no-owner --no-privileges --exit-on-error --dbname]
64
74
  argv << database
65
75
  CommandSpec.new(argv: argv, env: connection)
66
76
  end
@@ -68,7 +78,8 @@ module KamalBackup
68
78
  def scratch_restore_command(target)
69
79
  connection = current_connection.merge('PGDATABASE' => target)
70
80
 
71
- argv = %w[pg_restore --clean --if-exists --no-owner --no-privileges --dbname]
81
+ argv = [postgres_binary('pg_restore', connection)] +
82
+ %w[--clean --if-exists --no-owner --no-privileges --exit-on-error --dbname]
72
83
  argv << target
73
84
  CommandSpec.new(argv: argv, env: connection)
74
85
  end
@@ -100,20 +111,71 @@ module KamalBackup
100
111
  )
101
112
  end
102
113
 
103
- def reset_current_schema
104
- connection = current_connection
105
- argv = ['psql', '--quiet', '--no-psqlrc', '--set', 'ON_ERROR_STOP=1', '--command', RESET_SCHEMA_SQL]
114
+ def reset_schema(connection)
115
+ argv = [postgres_binary('psql', connection), '--quiet', '--no-psqlrc', '--set', 'ON_ERROR_STOP=1',
116
+ '--command', RESET_SCHEMAS_SQL]
106
117
  Command.capture(CommandSpec.new(argv: argv, env: connection), redactor: redactor)
107
118
  rescue CommandError => e
108
119
  raise CommandError.new(
109
- "failed to reset the public schema before restoring: #{e.message}",
120
+ "failed to reset PostgreSQL schemas before restoring: #{e.message}",
110
121
  command: e.command,
111
122
  stderr: e.stderr
112
123
  )
113
124
  end
114
125
 
115
- RESET_SCHEMA_SQL = 'DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;'
116
- private_constant :RESET_SCHEMA_SQL
126
+ RESET_SCHEMAS_SQL = <<~SQL
127
+ DO $$
128
+ DECLARE
129
+ schema_name text;
130
+ BEGIN
131
+ FOR schema_name IN
132
+ SELECT nspname
133
+ FROM pg_namespace
134
+ WHERE nspname !~ '^pg_'
135
+ AND nspname <> 'information_schema'
136
+ LOOP
137
+ EXECUTE format('DROP SCHEMA %I CASCADE', schema_name);
138
+ END LOOP;
139
+ END
140
+ $$;
141
+ CREATE SCHEMA public;
142
+ SQL
143
+ private_constant :RESET_SCHEMAS_SQL
144
+
145
+ def postgres_binary(name, connection)
146
+ root = value('KAMAL_BACKUP_POSTGRES_CLIENT_ROOT')
147
+ return name if root.to_s.empty?
148
+
149
+ major = postgres_server_major(connection)
150
+ binary = File.join(root, major.to_s, 'bin', name)
151
+ return binary if File.executable?(binary)
152
+
153
+ supported = Dir[File.join(root, '*', 'bin', name)].filter_map do |path|
154
+ path.split(File::SEPARATOR)[-3] if File.executable?(path)
155
+ end.sort
156
+ raise ConfigurationError,
157
+ "PostgreSQL #{major} is not supported by this accessory image; available client versions: #{supported.join(', ')}"
158
+ end
159
+
160
+ def postgres_server_major(connection)
161
+ @postgres_server_majors ||= {}
162
+ key = connection.values_at('PGHOST', 'PGPORT', 'PGUSER', 'PGDATABASE', 'PGSERVICE')
163
+ @postgres_server_majors[key] ||= begin
164
+ probe = value('KAMAL_BACKUP_POSTGRES_PROBE_BIN') || 'psql'
165
+ result = Command.capture(
166
+ CommandSpec.new(
167
+ argv: [probe, '--no-psqlrc', '--tuples-only', '--no-align', '--command', 'SHOW server_version_num'],
168
+ env: connection
169
+ ),
170
+ redactor: redactor,
171
+ log: false
172
+ )
173
+ version_number = Integer(result.stdout.strip, 10)
174
+ version_number / 10_000
175
+ rescue ArgumentError
176
+ raise ConfigurationError, 'could not determine the PostgreSQL server version'
177
+ end
178
+ end
117
179
 
118
180
  def validate_scratch_restore_target(target)
119
181
  raise ConfigurationError, 'scratch database must differ from the current PostgreSQL database' if current_connection.fetch('PGDATABASE') == target
@@ -32,12 +32,12 @@ module KamalBackup
32
32
  end
33
33
 
34
34
  def restore_to_current(restic, snapshot, filename)
35
- restic.write_dump_to_path(snapshot, filename, sqlite_source)
35
+ restore_database(restic, snapshot, filename, target: sqlite_source)
36
36
  end
37
37
 
38
38
  def restore_to_scratch(restic, snapshot, filename, target:)
39
39
  validate_scratch_restore_target(target)
40
- restic.write_dump_to_path(snapshot, filename, target)
40
+ restore_database(restic, snapshot, filename, target: target)
41
41
  end
42
42
 
43
43
  def dump_command
@@ -67,6 +67,31 @@ module KamalBackup
67
67
  def sqlite_literal(value)
68
68
  "'#{value.to_s.gsub("'", "''")}'"
69
69
  end
70
+
71
+ def restore_database(restic, snapshot, filename, target:)
72
+ Tempfile.create(['kamal-backup-restore-', '.sqlite3']) do |tempfile|
73
+ tempfile.close
74
+ restic.write_dump_to_path(snapshot, filename, tempfile.path)
75
+ validate_database_file(tempfile.path)
76
+ FileUtils.mkdir_p(File.dirname(File.expand_path(target)))
77
+ Command.capture(
78
+ CommandSpec.new(argv: ['sqlite3', '-bail', target, ".restore #{sqlite_literal(tempfile.path)}"]),
79
+ redactor: redactor
80
+ )
81
+ validate_database_file(target)
82
+ end
83
+ end
84
+
85
+ def validate_database_file(path)
86
+ result = Command.capture(
87
+ CommandSpec.new(argv: ['sqlite3', '-bail', path, 'PRAGMA quick_check;']),
88
+ redactor: redactor,
89
+ log_output: false
90
+ )
91
+ return if result.stdout.strip == 'ok'
92
+
93
+ raise ConfigurationError, "SQLite integrity check failed for #{path}"
94
+ end
70
95
  end
71
96
  end
72
97
  end
@@ -84,7 +84,8 @@ module KamalBackup
84
84
  mysql_dump: version_for(['mariadb-dump', '--version'], ['mysqldump', '--version']),
85
85
  mysql_client: version_for(['mariadb', '--version'], ['mysql', '--version']),
86
86
  sqlite3: version_for(['sqlite3', '--version']),
87
- restic: version_for(%w[restic version])
87
+ restic: version_for(%w[restic version]),
88
+ rclone: version_for(%w[rclone version])
88
89
  }
89
90
  end
90
91
 
@@ -2,8 +2,8 @@
2
2
 
3
3
  module KamalBackup
4
4
  class Redactor
5
- SECRET_KEY_PATTERN = /(pass|password|secret|token|key|credential|authorization)/i
6
- SENSITIVE_KEY_PATTERN = /(?:pass|password|secret|token|key|credential|authorization)|\A(?:user|username|pguser|.*_user|.*_username)\z/i
5
+ SECRET_KEY_PATTERN = /(?:pass|password|secret|token|key|credential|authorization)|(?:\A|_)pwd(?:\z|_)/i
6
+ SENSITIVE_KEY_PATTERN = /(?:pass|password|secret|token|key|credential|authorization)|(?:\A|_)pwd(?:\z|_)|\A(?:user|username|pguser|.*_user|.*_username)\z/i
7
7
  REDACTED = '[REDACTED]'
8
8
 
9
9
  def initialize(secret_values: [], env: ENV)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KamalBackup
4
- VERSION = '0.5.2'
4
+ VERSION = '1.0.0.rc1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kamal-backup
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 1.0.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - crmne