Ctrl K
ComunidadGuías…

spec/support/docker_stack_helpers.rb

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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