api/models/pea.rb

Summary

Maintainability
A
2 hrs
Test Coverage
# A pea is a core concept for Peas, who would have thought?
#
# A pea is the same as a dyno in the Heroku paradigm. It is a self-contained instance of an
# application with all its dependencies. Most often it will respond to web requests. It is
# perfectly reasonsable for an app to have no more than one pea. A pea is a single Docker container
# that isolates the app's resources from the host machine.
#
# Regardless of the pea's purpose in life it is always passed the environment variable PORT=5000,
# which any web servers used by the app can inherit from. That port is then exposed by Docker to the
# host machine. The global Peas proxy, which could be located on a different machine, can then
# forward any web requests for the app to the relevant Docker container. The exposing of the port is
# done via a kind of port forwarding; the Docker container might expose itself to the host machine
# as port 46517, but then forward incoming connections internally to port 5000.
#
# Peas can just as well have no connection to the web, such as a worker process. A worker process
# runs exactly the same code but will not listen for web requests. Instead it might listen to a
# message queue such as Redis and carry out long-running jobs that might update values in a database
# shared by all peas associated with an app.
class Pea
  include Mongoid::Document
  include Peas::ModelWorker

  # The external port used to gain access to the container's internal port 5000. This port value
  # is randomly generated by Docker and is guarenteed not to clash with other ports on the host
  # machine
  field :port, type: String

  # Every pea must have a container, this is the unique Docker ID hash for that container
  field :docker_id, type: String

  # Every pea must have a process type such as 'web', 'worker', etc. Process types are arbitrary,
  # the only criteria is that the process type must exist as a line in the app's Procfile. If an app
  # doesn't have a Procfile in its project root then the Heroku buildpack responsible for building
  # the app will create a Procfile during the build process with default process types.
  field :process_type, type: String

  # The initial command to run.
  field :command, type: String

  # The identifying number for a process_type, eg; web.2 or worker.3
  field :process_number, type: Integer

  # A pea must belong to an app
  belongs_to :app

  # A pea lives in a pod
  belongs_to :pod

  validates_presence_of :app

  def initialize(attrs = nil)
    super
    @container = get_docker_container
  end

  # Assign the process a number representing how many processes there are of this type.
  # Eg; web.1, worker.2, etc
  after_create do |pea|
    number = pea.app.peas.where(process_type: pea.process_type).count
    pea.process_number = number
    pea.save!
  end

  # Before removing a pea from the database kill and remove the relevant app container
  before_destroy do
    worker(pod, block_until_complete: true).destroy_container if docker_id
  end

  # Default command to run
  def command
    if self[:command]
      self[:command]
    else
      # `/start` is unique to progrium/buildstep, it brings a process type, such as 'web', to life
      "/start #{process_type}"
    end
  end

  # Creates a docker container and the pea DB record representing it. Use instead of Pea.create()
  def self.spawn(properties, block_until_complete: true, parent_job_id: nil, &block)
    pea = Pea.create!(properties)
    pea.worker(
      :optimal_pod,
      block_until_complete: block_until_complete,
      parent_job_id: parent_job_id,
      &block
    ).spawn_container
    pea.reload
  end

  def spawn_container
    # Common properties
    properties = {
      'Cmd' => ['/bin/bash', '-c', command],
      # The base Docker image to use. In this case the prebuilt image created by the buildstep
      # process
      'Image' => app.name,
      'Name' => "pea::#{full_name}",
      'AttachStderr' => true
    }

    if process_type == 'web'
      properties.merge!(
        # Global environment variables to pass and make available to the app
        'Env' => ['PORT=5000'].concat(app.config_for_docker),
        # Expose port 5000 from inside the container to the host machine
        'ExposedPorts' => {
          '5000' => {}
        }
      )
    end

    if process_type == 'one-off' || process_type == 'console'
      properties.merge!(
        "OpenStdin" => true,
        "StdinOnce" => true,
        "Tty" => true
      )
    end
    # Create the container
    container = Docker::Container.create properties

    # If container doesn't have TTY access then start it straight away
    unless properties.fetch('Tty', false)
      container.start(
        # Takes each ExposedPort and forwards an external port to it. Eg; 46517 -> 5000
        'PublishAllPorts' => 'true'
      )
    end

    # What pod are we in right now?
    self.pod = Pod.find_by(hostname: Peas::POD_HOST)
    # Get the Docker ID so we can find it later
    self.docker_id = container.info['id']
    if process_type == 'web'
      # Find the randomly created external port that forwards to the internal 5000 port
      self.port = container.json['NetworkSettings']['Ports']['5000'].first['HostPort']
    end
    self.save! unless new_record?
    get_docker_container
  end

  # When a one-off pea is requested it needs to receive input from the user, either commands or keystrokes.
  # This input is brokered via the Rendevous (hat tip to Heroku) Switchboard command. The CLI can reach Switchboard,
  # and Switchboard can reach any pod, but the CLI can't reach any pod directly, thus the need for Rendevous
  # acting as an intermediary.
  def connect_to_rendevous
    socket = Peas::Switchboard.connection
    socket.puts "rendevous.#{docker_id}"
    begin
      docker.tap(&:start).attach(
        stdin: socket,
        tty: true,
        stdout: true,
        stderr: true,
        logs: true,
        stream: true
      ) do |chunk|
        socket.write chunk
      end
    ensure
      socket.close
      destroy
    end
  end

  # Because peas can be distributed across multiple machines and therefore this code can be run
  # across multiple machines, we need to make sure that certain methods are only ever run on the
  # host machine upon which the pea lives.
  def ensure_correct_host
    return if Peas::POD_HOST == pod.hostname
    raise "Attempt to interact with a pea (belonging to '#{pod.docker_id}') " \
      "not located in the current pod ('#{Peas.current_docker_host_id}')."
  end

  # Destroy the pea's container
  def destroy_container
    ensure_correct_host
    begin
      get_docker_container
      if docker
        # Stop whatever the container is doing
        docker.kill
        # Remove the container from existence
        docker.delete
      end
    rescue Docker::Error::NotFoundError
      Peas::API.logger.warn "Can't find pea's container, destroying DB object anyway"
    end
  end

  def get_docker_container
    # There'll certainly be no container if this pea hasn't even been saved to the DB yet
    return false unless persisted?

    ensure_correct_host
    begin
      @container = Docker::Container.get(docker_id) if docker_id
    rescue Docker::Error::NotFoundError
      false
    end
  end

  def docker
    get_docker_container
    @container
  end

  # Return whether an app container is running or not
  def running?
    return false unless docker
    docker.json['State']['Running']
  end

  # Human friendly string name. Eg; 'web.1'
  def name
    "#{process_type}.#{process_number}"
  end

  # Fuller name. Eg; 'web.1@node-js-sample'
  def full_name
    "#{name}@#{app.name}"
  end
end