spec/support/docker_stack_helpers.rb
Rama develop · réplica de lectura del repositorio original.
# 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