api/models/pea.rb
# 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