app/features-json/build_json_controller.rb

Summary

Maintainability
A
1 hr
Test Coverage
require_relative "api_controller"
require_relative "../features/build_runner/remote_runner"
require_relative "./view_models/build_summary_view_model"
require_relative "./view_models/build_view_model"

require "faye/websocket"
Faye::WebSocket.load_adapter("thin")

module FastlaneCI
  # Controller for providing all data relating to builds
  class BuildJSONController < APIController
    HOME = "/data/projects/:project_id/build"

    def self.build_url(project_id:, build_number:)
      return "/project/#{project_id}/build/#{build_number}"
    end

    get "#{HOME}/:build_number" do |project_id, build_number|
      build_view_model = BuildViewModel.new(build: current_build)

      json(build_view_model)
    end

    post "#{HOME}/:build_number/rebuild" do |project_id, build_number|
      # TODO: We're not using `build_number` anywhere here
      # Seems like we make use of just the `sha` value, if so, maybe the `build_number`
      # shouldn't be here?

      # passing a specific sha is optional, so this might be nil
      current_sha = params[:sha] if params[:sha].to_s.length > 0

      project = current_project

      # Create random folder for checkout, prefixed with `manual_build`
      # or use the current_sha with the number of times we made a re-run for this commit.
      sha_or_uuid = (current_sha || SecureRandom.uuid).to_s
      if current_sha
        sha_build_count = Dir[File.join(File.expand_path(project.local_repo_path), "*#{current_sha}*")].count
        checkout_folder = File.join(
          File.expand_path(project.local_repo_path),
          "manual_build_#{sha_or_uuid}_#{sha_build_count}"
        )
      else
        checkout_folder = File.join(File.expand_path(project.local_repo_path), "manual_build_#{sha_or_uuid}")
      end

      # TODO: This should probably be hidden in a service
      repo = FastlaneCI::GitRepo.new(
        git_config: project.repo_config,
        local_folder: checkout_folder,
        provider_credential: current_user_provider_credential,
        notification_service: FastlaneCI::Services.notification_service
      )
      current_sha ||= repo.most_recent_commit.sha
      manual_triggers_allowed = project.job_triggers.any? do |trigger|
        trigger.type == FastlaneCI::JobTrigger::TRIGGER_TYPE[:manual]
      end

      unless manual_triggers_allowed
        json_error!(
          error_message: "Cannot build. There is no manual build trigger, for this branch" \
          "associated with this project",
          error_code: 403
        )
        return
      end

      branch_to_trigger = "master" # TODO: how/where do we get the default branch

      git_fork_config = GitForkConfig.new(
        sha: current_sha,
        branch: branch_to_trigger,
        clone_url: project.repo_config.git_url
        # we don't need to pass a `ref`, as the sha and branch is all we need
      )

      build_runner = RemoteRunner.new(
        project: project,
        git_fork_config: git_fork_config,
        trigger: project.find_triggers_of_type(trigger_type: :manual).first,
        github_service: FastlaneCI::GitHubService.new(provider_credential: current_user_provider_credential)
      )
      Services.build_runner_service.add_build_runner(build_runner: build_runner)

      build_summary_view_model = BuildSummaryViewModel.new(build: build_runner.current_build)
      json(build_summary_view_model)
    end

    get "#{HOME}/:build_number/log.ws" do |project_id, build_number|
      halt(415, "unsupported media type") unless Faye::WebSocket.websocket?(request.env)
      ws = Faye::WebSocket.new(request.env, nil, { ping: 30 })

      ws.on(:open) do |event|
        logger.debug([:open, ws.object_id])

        ## handle the case that the build completes, and runner.log is saved.
        current_project = FastlaneCI::Services.project_service.project_by_id(project_id)
        current_build = current_project.builds.find { |b| b.number == build_number.to_i }

        if current_build
          build_log_artifact = current_build.artifacts.find do |current_artifact|
            # We can improve the detection in the future, to actually mark an artifact as "default output"
            current_artifact.type.include?("log") && current_artifact.reference.end_with?("runner.log")
          end

          if build_log_artifact
            logger.debug("streaming back artifact: #{build_log_artifact.reference}")
            File.open(build_log_artifact.reference, "r") do |file|
              file.each_line do |line|
                ws.send(line.chomp)
              end
            end
            ws.close(1000, "runner complete.")
            next
          end
        end

        ## if we have no runner.log, then check to see if the build_runner is still working.

        current_build_runner = Services.build_runner_service.find_build_runner(
          project_id: project_id,
          build_number: build_number.to_i
        )

        if current_build_runner.nil?
          ws.close(1000, "no runner found for project #{project_id} and build #{build_number}.")
          next
        end

        # if the build runner has already completed, we can close the connection, and do not proceed
        if current_build_runner.completed?
          ws.close(1000, "runner complete.")
          next
        end

        # once the build runner completes, close the websocket connection.
        current_build_runner.on_complete do
          ws.close(1000, "runner complete.")
        end

        # subscribe the current socket to events from the remote_runner
        # as soon as a subscriber is returned, they will receive all historical items as well.
        @subscriber = current_build_runner.subscribe do |_topic, payload|
          ws.send(JSON.dump(payload))
        end
      end

      ws.on(:close) do |event|
        logger.debug([:close, ws.object_id, event.code, event.reason])

        current_build_runner = Services.build_runner_service.find_build_runner(
          project_id: project_id,
          build_number: build_number.to_i
        )
        next if current_build_runner.nil?

        current_build_runner.unsubscribe(@subscriber)

        Services.build_runner_service.remove_build_runner(build_runner: current_build_runner)
      end

      # Return async Rack response
      return ws.rack_response
    end

    def current_build
      current_build = current_project.builds.find { |b| b.number == params[:build_number].to_i }
      if current_build.nil?
        json_error!(
          error_message: "Can't find build with ID #{params[:build_number]} for project #{params[:project_id]}",
          error_key: "Build.Missing",
          error_code: 404
        )
      end

      return current_build
    end

    def current_project
      current_project = FastlaneCI::Services.project_service.project_by_id(params[:project_id])
      unless current_project
        json_error!(
          error_message: "Can't find project with ID #{params[:project_id]}",
          error_key: "Project.Missing",
          error_code: 404
        )
      end

      return current_project
    end
  end
end