# Lifecycle helpers shared by integration specs that drive a real Docker # Compose stack rooted at a per-spec `data_path`. module DockerStackHelpers # All stack-driving specs share one fixed Compose project name # (`Orchestration::PROJECT_NAME`), fixed container names and fixed host # ports — so two of them must never run concurrently, even though # `turbo_tests` spreads spec files across parallel workers. Specs tagged # `:docker_stack` grab this cross-process file lock for the duration of # each example, serialising stack access without serialising the rest of # the suite. STACK_LOCK_PATH = Rails.root.join('tmp/integration-docker-stack.lock').to_s # Data paths emptied during the run. They must survive between examples (see # clear_data_path!), so the now-empty directories are removed once the suite # is over. Without this, a spec whose data path carries a random suffix # leaves one empty directory behind per run. CLEARED_PATHS = Set.new def self.with_stack_lock File.open(STACK_LOCK_PATH, File::RDWR | File::CREAT, 0o644) do |lock| lock.flock(File::LOCK_EX) yield end end # Tears down the compose stack rooted at `data_path`. Idempotent — a no-op # when no compose file has been written yet (e.g. a clean first run). def compose_down!(data_path) return unless File.exist?(Compose.path) system( 'docker', 'compose', '-f', Compose.path, '--project-directory', data_path, 'down', '-v', '--remove-orphans', out: File::NULL, err: File::NULL ) end # Empties `data_path` but keeps the directory itself and the directories # below `helios/`, which hold the runners' bind-mount sources. Docker Desktop # shares the host directory into the VM by path: once a directory that holds # a bind-mount source is deleted and created again, the VM keeps seeing the # gone one. `-v` then fails with "error while creating mount source path # ...: no such file or directory", and `--mount` with "bind source path does # not exist", even though the path exists on the host. Linux binds the path # directly and does not care, so this only ever failed locally, never in CI. # The database directories go completely, as their images initialize only # an empty one. # # Deleting the files succeeds wherever the bind mount maps the container's # files to the host user (e.g. Docker Desktop). Only when files survive or # a directory cannot be entered — on Linux, where database files stay # root-owned — fall back to emptying the directory from inside a container. # `cleanup_image` only needs a `find` binary. def clear_data_path!(data_path, cleanup_image:) data_path = disposable!(data_path) CLEARED_PATHS << data_path delete_files(data_path) return unless leftovers?(data_path) system( 'docker', 'run', '--rm', '--entrypoint', 'find', '-v', "#{data_path}:/cleanup", cleanup_image, '/cleanup', '-mindepth', '1', '-delete', out: File::NULL, err: File::NULL ) FileUtils.rm_rf(children_of(data_path)) end private # Guards every deletion in this module. `data_path` reaches it from a spec's # `let`, and a spec that forgets to stub it, or stubs it too late, hands the # fallback to rm_rf instead. In development that fallback is `stack/`, the # live local stack. Only a path below `tmp/` is disposable, so anything else # raises. def disposable!(data_path) resolved = File.expand_path(data_path.to_s) scratch = File.expand_path(Rails.root.join('tmp').to_s) return resolved if resolved.start_with?("#{scratch}/") && resolved != scratch raise ArgumentError, "refusing to clear #{resolved.inspect}: only paths below #{scratch}/ are disposable" end def delete_files(data_path) children_of(data_path).each do |child| next FileUtils.rm_rf(child) unless File.basename(child) == 'helios' && real_directory?(child) FileUtils.rm_f(entries_below(child).reject { |path| real_directory?(path) }) end end def leftovers?(data_path) entries_below(data_path).any? do |path| !real_directory?(path) || !File.readable?(path) || !File.executable?(path) end end def entries_below(data_path) Dir.glob('**/*', File::FNM_DOTMATCH, base: data_path).map { |entry| File.join(data_path, entry) } end def real_directory?(path) File.directory?(path) && !File.symlink?(path) end def children_of(data_path) return [] unless Dir.exist?(data_path) Dir.children(data_path).map { |child| File.join(data_path, child) } end end RSpec.configure do |config| config.include DockerStackHelpers # `around` wraps the example together with its before/after hooks, so the # lock spans the whole stack lifecycle (teardown of leftovers, start, # assertions, teardown). config.around(:each, :docker_stack) do |example| DockerStackHelpers.with_stack_lock { example.run } end config.after(:suite) do DockerStackHelpers::CLEARED_PATHS.each { |path| FileUtils.rm_rf(path) } end end