Ctrl K
ComunidadGuías…

app/jobs/compose_job.rb

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

Archivo original ↗Preguntar a KiBot
class ComposeJob < ApplicationJob
  queue_as :default

  def perform(action, service_name = nil)
    action = action.to_sym
    Orchestration::UpdatePause.pause!(:service_update) if pauses_updates?(action, service_name)
    rebuild_stack if applies_config?(action)
    remove_errored_containers(action)
    clear_errors(action, service_name)
    execute_action(action, service_name)
    @deploy_succeeded = true
  rescue Orchestration::Runner::CommandError => e
    logger.error("ComposeJob failed: #{e.message}")
    store_errors(action, service_name, e)
  ensure
    clear_pending_operations(action, service_name)
    broadcast_results(action, service_name)
    # Last: the pending flag it just cleared is what tells UpdatePause this
    # recreate is still in flight.
    Orchestration::UpdatePause.resume_if_idle! if pauses_updates?(action, service_name)
  end

  private

  # A recreate tears the container down and builds it back up from the
  # compose.yaml this job has just rewritten. An automatic update landing in
  # that window either kills HELIOS mid-job — leaving the service down, since
  # nothing would run the `up` — or rebuilds a container from the spec it is
  # still running, which is the one compose.yaml no longer describes.
  #
  # Only :recreate. The batch actions bring Watchtower up or down themselves,
  # and start/stop have no down-then-up window to protect. Watchtower's own
  # recreate is excluded too: it comes straight back, so the pause would leave
  # nothing but a marker for the sweep to clean up.
  def pauses_updates?(action, service_name)
    action == :recreate && service_name != Orchestration::UpdatePause::SERVICE
  end

  def rebuild_stack
    Export::Builder.new(Configuration.current).write!
  end

  def execute_action(action, service_name)
    case action
    when :up then Orchestration::Runner.up
    when :down then Orchestration::Runner.down
    when :start then Orchestration::Runner.start(*Array(service_name))
    when :stop then Orchestration::Runner.stop(service_name)
    when :recreate then Orchestration::Runner.recreate(service_name)
    when :self_recreate then Orchestration::SelfUpdate.call
    when :self_converge then Orchestration::SelfConverge.call
    else raise ArgumentError, "Unknown compose action: #{action}"
    end
  end

  # --- Error handling ---

  def store_errors(action, service_name, error)
    if batch_action?(action)
      store_batch_errors(error)
    elsif service_name
      affected = extract_affected_service(error) || service_name
      Orchestration::ErrorStore.set(affected, extract_error_details(error))

      if affected != service_name
        Orchestration::ErrorStore.set(
          service_name,
          dependency_error_message(affected),
        )
      end
    end
  end

  def store_batch_errors(error)
    error_message = extract_error_details(error)
    affected_service_name =
      extract_affected_service(error) || extract_service_from_image(error)

    all_services.each do |compose_service|
      service_error =
        service_error_for(compose_service, affected_service_name, error_message)
      if service_error
        Orchestration::ErrorStore.set(compose_service.name, service_error)
      end
    end
  end

  def clear_errors(action, service_name)
    if batch_action?(action)
      Orchestration::ErrorStore.clear_all
    elsif service_name
      Orchestration::ErrorStore.clear(service_name)
    end
  end

  # Clear pending operation flags before the final broadcast so the
  # broadcast renders the row with its real status (running/healthy/…),
  # not the spinner that the controller put up at click time.
  def clear_pending_operations(action, service_name)
    if batch_action?(action)
      Orchestration::PendingOperations.clear_all
    elsif service_name
      Orchestration::PendingOperations.clear(service_name)
    end
  end

  # --- Broadcasting results ---

  def broadcast_results(action, service_name)
    Orchestration::Container.invalidate_cache
    update_deployed_hashes(action, service_name)
    broadcast_affected_services(action, service_name)
    Orchestration::StackStatus.refresh!
  rescue StandardError => e
    logger.error("ComposeJob broadcast failed: #{e.class}: #{e.message}")
  end

  # Re-baseline only what the action actually (re)created:
  #   - batch :up recreates the whole stack → baseline all services
  #   - a single-service action recreates only that service → baseline just it,
  #     so a pending restart on an untouched service (e.g. dashboard waiting for
  #     a newly added sensor) is not silently cleared
  def update_deployed_hashes(action, service_name)
    unless @deploy_succeeded && applies_config?(action)
      return Orchestration::AffectedServices.invalidate_config_hashes
    end

    if batch_action?(action)
      Orchestration::AffectedServices.store_deployed_hashes!
    elsif service_name
      Orchestration::AffectedServices.update_deployed_hash!(service_name)
    else
      Orchestration::AffectedServices.invalidate_config_hashes
    end
  end

  def broadcast_affected_services(action, service_name)
    if batch_action?(action)
      all_services.each { |s| broadcast_service(s.name) }
    elsif service_name
      broadcast_service(service_name)
    end
  end

  def broadcast_service(service_name)
    container = Orchestration::Container.find(service_name)
    compose_service = compose_file.services.find(service_name)
    error_message = Orchestration::ErrorStore.get(service_name)

    Orchestration::ServiceBroadcaster.broadcast_row(
      service_name,
      container:,
      compose_service:,
      error_message:,
    )
  end

  # --- Helpers ---

  # :self_converge brings up every service, the same reach as :up. It hands
  # the run to a helper container and returns, so what follows here runs while
  # that helper is still working and this process is about to end.
  def batch_action?(action)
    %i[up down self_converge].include?(action)
  end

  # Actions that apply the current compose.yaml config to containers.
  # `stop`/`down` do not create or recreate containers, so storing
  # hashes after them would incorrectly mark pending changes as deployed.
  def applies_config?(action)
    %i[up start recreate self_recreate self_converge].include?(action)
  end

  # Remove containers that previously failed, so Docker Compose creates fresh
  # ones. A failed `up` leaves the container in "Created" state; restarting it
  # via `docker compose up` becomes a `docker start`, which — unlike a fresh
  # create+start — silently comes up WITHOUT the published host port when that
  # port is still taken, making a blocked service look healthy. Tearing the
  # failed container down first forces a clean recreate so a real port conflict
  # re-surfaces as an error.
  #
  # Every errored service is cleaned, not just the one named: `up <service>`
  # (re)starts the service AND its dependencies, so a port-blocked `influxdb`
  # would otherwise be silently revived when `dashboard` is started. A service
  # carrying a stored error is never cleanly running (a successful operation
  # clears its error), so removing its container is always safe.
  def remove_errored_containers(action)
    return unless cleanup_action?(action)

    Orchestration::ErrorStore.each_key do |name|
      Orchestration::Runner.stop(name)
    rescue Orchestration::Runner::CommandError
      nil
    end
  end

  # Actions that bring containers up and therefore must start from a clean,
  # non-errored state. `:recreate` shares the same dependency hazard as `:up`
  # and `:start`, since its internal `up` revives dependency containers too.
  def cleanup_action?(action)
    %i[up start recreate self_converge].include?(action)
  end

  def compose_file
    @compose_file ||= Compose.load
  end

  def all_services
    compose_file.services.reject(&:helios?)
  end

  def service_error_for(compose_service, affected_service_name, error_message)
    if affected_service_name.nil? ||
       compose_service.name == affected_service_name
      error_message
    elsif compose_service.depends_on.key?(affected_service_name)
      dependency_error_message(affected_service_name)
    end
  end

  def extract_error_details(error)
    last_line = error.stdout.to_s.lines.last&.strip.presence
    return 'Unknown error' unless last_line

    condense_docker_error(last_line)
  end

  # Docker wraps the real cause in boilerplate that overflows the status
  # tooltip, e.g.
  #   "Error response from daemon: failed to set up container networking:
  #    driver failed ... on endpoint foo (bdea3c...): Bind for 0.0.0.0:8086
  #    failed: port is already allocated"
  # Keep just the part after the container id, and otherwise drop the generic
  # daemon prefix, so only the essential message reaches the UI.
  def condense_docker_error(message)
    if (match = message.match(/.*\([0-9a-f]{12,}\)\s*:\s*(.+)\z/))
      return match[1].strip
    end

    message.sub(/\AError response from daemon:\s*/, '')
  end

  def dependency_error_message(dependency_name)
    display_name =
      compose_file.services.find(dependency_name)&.display_name ||
      dependency_name
    I18n.t('services.errors.blocked_by_dependency', dependency: display_name)
  end

  def extract_service_from_image(error)
    output = error.stdout.to_s
    compose_file.services.each do |service|
      return service.name if output.include?(service.image.to_s)
    end
    nil
  end

  def extract_affected_service(error)
    output = error.stdout.to_s.lines.grep_v(/^\s+(?:Container|Network)\s/).join

    compose_file.services.each do |service|
      return service.name if output.include?("-#{service.name}-")
    end
    nil
  end
end