# End-to-end coverage of the restore path: a real BackupRunner produces a # tar from a live PostgreSQL + InfluxDB stack, then RestoreRunner tears # the stack down, wipes the data directories and rebuilds it from the # tar. Because the rebuild starts Postgres on an empty data dir, the # image runs initdb plus its bootstrap server — the exact scenario the # TCP probe in restore.sh guards against. Without that probe the first # psql call would race the bootstrap and die with "Connection refused". # # Tagged :integration by its spec/integration/ location. Slow: pulls # Postgres, InfluxDB and docker:cli images, runs a real backup and a # real restore (which itself starts the stack twice). RSpec.describe RestoreRunner, :docker_stack do let(:data_path) { Rails.root.join("tmp/restore-itest#{ENV.fetch('TEST_ENV_NUMBER', nil)}").to_s } let(:influxdb_image) { DockerImages.current(:INFLUXDB) } before do skip_without_docker allow(Rails.configuration).to receive(:data_path).and_return(data_path) FileUtils.mkdir_p(data_path) write_config! Export::Builder.new(Configuration.current).write! compose_down!(data_path) # clear leftovers from a previous interrupted run end after do compose_down!(data_path) clear_data_path!(data_path, cleanup_image: described_class::IMAGE) end shared_examples 'a restore from a freshly initialized data directory' do it 'rebuilds the stack from the backup tar and restores both seeded markers' do Orchestration::Runner.start('postgresql', 'influxdb') wait_for_postgresql_ready! wait_for_healthy!('influxdb') create_production_database! seed_marker_row! seed_influx_point! filename = produce_backup_tar! expect(filename).not_to be_nil, 'no backup tar was produced' described_class.start(filename) wait_for_container_exit!(described_class::CONTAINER_NAME, timeout: 360) aggregate_failures do expect(restore_error_message).to be_nil, "restore failed: #{restore_error_message}" # Restore re-initialises postgres on an empty data dir — verify the # production database came back with its seeded row, proving the # restore script got past the bootstrap-server race. wait_for_postgresql_ready! expect(marker_rows).to eq(['restored']) wait_for_healthy!('influxdb') expect(influx_marker_values).to eq(['42']) end end end describe '.start (real restore against a freshly initialized data directory)' do it_behaves_like 'a restore from a freshly initialized data directory' # Legacy InfluxDB images ship an influx CLI without `--operator-token`. context 'with a legacy InfluxDB image' do let(:influxdb_image) { 'influxdb:2.8-alpine' } it_behaves_like 'a restore from a freshly initialized data directory' end end # --- helpers --- def seed_influx_point! _stdout, stderr, code = Orchestration::Runner.compose_exec( 'influxdb', 'influx', 'write', *influx_auth_args, '--bucket', influx_env('INFLUX_BUCKET'), 'restore_marker value=42' ) raise "influx write failed: #{stderr}" unless code&.zero? end def influx_marker_values flux = %(from(bucket: "#{influx_env('INFLUX_BUCKET')}") |> range(start: -1d) ) \ '|> filter(fn: (r) => r._measurement == "restore_marker") |> keep(columns: ["_value"])' stdout, stderr, code = Orchestration::Runner.compose_exec( 'influxdb', 'influx', 'query', *influx_auth_args, '--raw', flux ) raise "influx query failed: #{stderr}" unless code&.zero? lines = stdout.lines.map(&:strip).reject { |line| line.empty? || line.start_with?('#') } header, *rows = lines.map { |line| line.split(',') } rows.map { |row| row[header.index('_value')] } end def influx_auth_args ['--org', influx_env('INFLUX_ORG'), '--token', influx_env('INFLUX_ADMIN_TOKEN')] end def influx_env(key) Env.load[key] end # Minimal HELIOS configuration with a local backup destination — # Export::Builder.ensure_defaults! materializes postgresql, influxdb, # redis, dashboard (passwords, tokens, images). No publish_port, so # the stack does not clash with other dev containers. # The sensor is what the databases exist for: without one, Export::Builder # leaves postgresql, influxdb, redis and the dashboard out of the stack (see # Configuration#dashboard_required?). def write_config! data = { 'backup' => { 'destination' => 'local' }, 'influxdb' => { 'image' => influxdb_image }, 'sensors' => { 'house_power' => { 'source' => 'external', 'measurement' => 'm', 'field' => 'f' } } } FileUtils.mkdir_p(File.dirname(Configuration.path)) File.write(Configuration.path, YAML.dump(data)) Current.reset end # backup.sh dumps `solectrus_production` — the database the SOLECTRUS # dashboard creates at runtime. The dashboard is not part of this test, # so the database is created explicitly. def create_production_database! _stdout, stderr, code = Orchestration::Runner.compose_exec( 'postgresql', 'psql', '-U', 'postgres', '-c', 'CREATE DATABASE solectrus_production' ) raise "creating solectrus_production failed: #{stderr}" unless code&.zero? end # Marker the restore must bring back — distinct enough that an empty # restored cluster would obviously fail the assertion. def seed_marker_row! psql_in_production!('CREATE TABLE restore_marker (name text)') psql_in_production!("INSERT INTO restore_marker (name) VALUES ('restored')") end def marker_rows stdout, stderr, code = Orchestration::Runner.compose_exec( 'postgresql', 'psql', '-U', 'postgres', '-d', 'solectrus_production', '-tAc', 'SELECT name FROM restore_marker ORDER BY name' ) raise "marker query failed: #{stderr}" unless code&.zero? stdout.split("\n").map(&:strip).reject(&:empty?) end def psql_in_production!(sql) _stdout, stderr, code = Orchestration::Runner.compose_exec( 'postgresql', 'psql', '-U', 'postgres', '-d', 'solectrus_production', '-c', sql ) raise "SQL failed (#{sql}): #{stderr}" unless code&.zero? end def wait_for_healthy!(service, timeout: 180) deadline = Time.current + timeout loop do Orchestration::Container.invalidate_cache container = Orchestration::Container.find(service) return container if container&.healthy? if Time.current > deadline raise "#{service} did not become healthy within #{timeout}s\n#{healthy_diagnostics(service)}" end sleep 2 end end # On a first-time start the postgres image runs a temporary bootstrap server # that listens on the Unix socket only. The container healthcheck # (`pg_isready` over that socket) can flip to "healthy" against the throwaway # server, so a query racing it dies with "Connection refused". A TCP probe # stays negative until the real server is up. def wait_for_postgresql_ready!(timeout: 180) wait_for_healthy!('postgresql', timeout:) deadline = Time.current + timeout until postgresql_accepting_tcp_connections? raise "postgresql did not accept TCP connections within #{timeout}s" if Time.current > deadline sleep 1 end end def postgresql_accepting_tcp_connections? _stdout, _stderr, code = Orchestration::Runner.compose_exec( 'postgresql', 'pg_isready', '-h', '127.0.0.1', '-U', 'postgres', '-q' ) code&.zero? end # Runs a real backup to completion (handling the async pull + launch) and # returns the produced tar filename, so the restore has something to chew on. def produce_backup_tar! BackupRunner.start wait_for_backup_launch! wait_for_container_exit!(BackupRunner::CONTAINER_NAME) BackupRepository.detect_completion! expect(BackupRepository.error_message).to be_nil, "backup failed: #{BackupRepository.error_message}" produced_backup_filename end # BackupRunner pulls the image + launches its container on a background # thread; wait for that thread to finish so the container exists before we # poll for its exit. def wait_for_backup_launch!(timeout: 240) deadline = Time.current + timeout until Time.current > deadline return true unless BackupRunner.preparing? sleep 0.5 end raise "backup preparing thread did not finish within #{timeout}s" end # The detached runner uses `--rm`, so its disappearance from `docker ps` # marks the script done. def wait_for_container_exit!(name, timeout: 240) deadline = Time.current + timeout until Time.current > deadline running, = Open3.capture2('docker', 'ps', '-aq', '--filter', "name=#{name}") return true if running.strip.empty? sleep 0.5 end raise "#{name} did not exit within #{timeout}s" end def produced_backup_filename File.basename(Dir.glob(File.join(data_path, 'helios', 'backups', 'solectrus-backup-*.tar')).first.to_s).presence end def restore_error_message BackupRepository.storage.read_error_file(RestoreRunner::ERROR_FILENAME) end # Container state plus its last log lines — surfaced in the failure # message so a CI timeout is debuggable without a re-run. def healthy_diagnostics(service) container = Orchestration::Container.find(service) state = if container "status=#{container.status} health=#{container.health_status.inspect}" else 'container not found' end ps, = Open3.capture2e('docker', 'ps', '-a') logs, = container ? Open3.capture2e('docker', 'logs', '--tail', '40', container.name) : ['', nil] "[#{service}] #{state}\n--- docker ps -a ---\n#{ps}\n--- #{service} logs ---\n#{logs}" end end