Skip to content
22 changes: 22 additions & 0 deletions lib/cove/cli/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,28 @@ def logs(service_role)
# on the host(s).
Kernel.exit(0)
end

desc "run SERVICE with COMMANDS", "Run a container with custom commands for SERVICE"
option :role, type: :string
option :host, type: :string
def run_custom(service_name, command)
service = Cove.registry.services[service_name]
command = command.split

role = if options[:role]
Cove.registry.roles_for_service(service).bsearch { |x| x.name == options[:role] }
else
Cove.registry.roles_for_service(service).first
end

host = if options[:host]
Cove.registry.hosts[options[:host]]
else
role.hosts.first
end

Cove::Invocation::ServiceRun.new(registry: Cove.registry, service: service, custom_cmd: command, role: role, host: host).invoke
end
end
end
end
10 changes: 8 additions & 2 deletions lib/cove/command/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ def self.start_container(*containers)
[:docker, "container", "start", *containers.flatten]
end

# @param [String] container The name or id of the container to start
# return [Array] The command to start the container and attach the standard input, output, and error streams
def self.start_container_and_attach(container)
[:docker, "container", "start", "--attach", "-i", container]
end

# @param [String] containers The name or id of the container(s) to stop
# @param [Integer] time
def self.stop_container(*containers, time: nil)
Expand All @@ -20,8 +26,8 @@ def self.delete_container(*containers)

# @param [Cove::DesiredContainer] config
# @return [Array] The command to create the container
def self.create_container(config)
Docker::Container::Create.build(image: config.image, name: config.name, labels: config.labels, command: config.command, environment_files: config.environment_files, ports: config.ports, mounts: config.mounts)
def self.create_container(config, remove: false, interactive: false)
Docker::Container::Create.build(image: config.image, name: config.name, remove: remove, interactive: interactive, labels: config.labels, command: config.command, environment_files: config.environment_files, ports: config.ports, mounts: config.mounts)
end

# @param [String] image The image to pull
Expand Down
16 changes: 12 additions & 4 deletions lib/cove/command/docker/container/run.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@ module Command
module Docker
module Container
class Run
def self.build(image:, name: nil, remove: false, detach: true, interactive: false, labels: {}, command: [], ports: [], extra_arguments: [])
builder = [:docker, "container", "run"]
def self.build(image:, name: nil, remove: false, detach: true, interactive: false, labels: {}, command: [], ports: [], mounts: [], environment_files: [], extra_arguments: [])
builder = ["docker", "container", "run"]

builder += ["--name", name] if name.present?

Array(ports).each do |port_mapping|
builder += ["--publish", port_mapping["source"].to_s + ":" + port_mapping["target"].to_s]
end

Hash(labels).each do |key, value|
builder += ["--label", "#{key}=#{value}"]
Array(mounts).each do |mount|
builder += ["--mount", "type=volume,source=\"#{mount["source"]}\",target=\"#{mount["target"]}\""]
end

Array(labels).each do |label|
builder += ["--label", label]
end

Array(environment_files).each do |environment_file|
builder += ["--env-file", environment_file]
end

builder << "--detach" if detach
Expand Down
4 changes: 3 additions & 1 deletion lib/cove/instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@ def initialize(package, index)
@index = index
end

# @return [String]
def name
"#{package.service_name}-#{package.role_name}-#{version}-#{index}"
end

# @return [Cove::EntityLabels] The labels of the container
def labels
package.labels.merge({
"cove.index" => index.to_s
"cove.index" => index.to_s,
"cove.type" => "deployed"
})
end

Expand Down
68 changes: 68 additions & 0 deletions lib/cove/invocation/service_run.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
module Cove
module Invocation
class ServiceRun
include SSHKit::DSL

# @return [Cove::Registry]
attr_reader :registry
# @return [Cove::Service]
attr_reader :service
# @return [Array<String>]
attr_reader :custom_cmd
# @return [Cove::Role]
attr_reader :role
# @return [Cove::Host]
attr_reader :host

# @param registry [Cove::Registry]
# @param service [Cove::Service]
# @param custom_cmd [Array<String>]
# @param role [Cove::Role]
# @param host [Cove::Host]
def initialize(registry:, service:, custom_cmd:, role:, host:)
@registry = registry
@service = service
@custom_cmd = custom_cmd
@role = role
@host = host
end

# @return nil
def invoke
Cove.output.puts "service: #{service.name}, role: #{role.name}, host: #{host.name}, commands: #{custom_cmd}."
deployment = Cove::Deployment.new(role)
instance_on_demand = Cove::OnDemandInstance.new(deployment, custom_cmd)
desired_container = Cove::DesiredContainer.from(instance_on_demand)

create_cmd = create_cmd(desired_container)
start_cmd = start_cmd(desired_container.name)

on(host.sshkit_host) do
Steps::EnsureEnvironmentFileExists.call(self, deployment)
Steps::PullImage.call(self, deployment)
info "Creating container #{desired_container.name}"
execute(*create_cmd)
end

run_locally do
info "Starting container #{desired_container.name}"
Kernel.exec(*start_cmd)
end
end

private

# @param desired_container [Cove::DesiredContainer]
# @return [Array<String>]
def create_cmd(desired_container)
Cove::Command::Builder.create_container(desired_container, remove: true, interactive: true).map(&:to_s)
end

# @param container_name [String]
# @return [Array<String>]
def start_cmd(container_name)
(["ssh", "-t", host.ssh_destination_string] + Cove::Command::Builder.start_container_and_attach(container_name)).map(&:to_s)
end
end
end
end
45 changes: 45 additions & 0 deletions lib/cove/on_demand_instance.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module Cove
class OnDemandInstance
# @return [Cove::Deployment]
attr_reader :deployment
# @return [Integer]
attr_reader :index
# @return [Cove::Role]
delegate :role, to: :deployment
# @return [Cove::Service]
delegate :service, to: :role
# @return [String] The version of the deployment
delegate :version, to: :deployment
# @return [Array<String>] The command to run in the container
attr_reader :command
# @return [Array<Hash>] The port mapping to run in the container
attr_reader :ports
# @return [Array<Hash>] The volumes to mount to the container
delegate :mounts, to: :role
# @return [String] The image of the container
delegate :image, to: :role
# @return [Cove::EntityLabels] The labels of the container
delegate :labels, to: :deployment

# @param deployment [Cove::Deployment] The deployment the container is part of
# @param command [Array<String>] The custom command to run in the container
def initialize(deployment, command)
@deployment = deployment
@command = command
@ports = []
@index = 1
end

# @return [String]
def name
"#{service.name}-#{role.name}-#{version}-run-#{SecureRandom.hex(3)}"
end

# @return [Cove::EntityLabels] The labels of the container
def labels
deployment.labels.merge({
"cove.type" => "on-demand"
})
end
end
end
33 changes: 33 additions & 0 deletions spec/cove/cli/service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,37 @@
described_class.new.invoke(:up, ["nginx"])
end
end

describe "#run_custom" do
it "runs a container with a custom command" do
Cove.init(config: "spec/fixtures/configs/basic/")
service = Cove.registry.services["nginx"]
role = Cove.registry.roles_for_service(service).first
host = role.hosts.first

expect(Cove::Invocation::ServiceRun).to receive(:new).with(
registry: Cove.registry,
service: service,
custom_cmd: ["echo", "hello"],
role: role,
host: host
) { double(invoke: nil) }
described_class.new.invoke(:run_custom, ["nginx"], ["echo hello"])
end
it "runs a container with a custom command with a specified host" do
Cove.init(config: "spec/fixtures/configs/basic/")
service = Cove.registry.services["nginx"]
role = Cove.registry.roles_for_service(service).first
host = role.hosts.second

expect(Cove::Invocation::ServiceRun).to receive(:new).with(
registry: Cove.registry,
service: service,
custom_cmd: ["echo", "hello"],
role: role,
host: host
) { double(invoke: nil) }
described_class.new.invoke(:run_custom, ["nginx", "echo hello"], host: "host2")
end
end
end
18 changes: 16 additions & 2 deletions spec/cove/command/docker/container/run_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
it "returns the expected command" do
expect(described_class.build(image: "hello-world", name: "my-container")).to eq(
[
:docker,
"docker",
"container",
"run",
"--name", "my-container",
Expand All @@ -18,7 +18,7 @@
it "returns the expected command" do
expect(described_class.build(image: "hello-world", name: "my-container", ports: [{"type" => "port", "source" => 8080, "target" => 80}])).to eq(
[
:docker,
"docker",
"container",
"run",
"--name", "my-container",
Expand All @@ -28,5 +28,19 @@
]
)
end

it "returns the expected command" do
expect(described_class.build(image: "hello-world", remove: true, detach: false, interactive: true, command: ["echo", "hello"])).to eq(
[
"docker",
"container",
"run",
"--rm",
"-it",
"hello-world",
"echo", "hello"
]
)
end
end
end
36 changes: 36 additions & 0 deletions spec/cove/invocation/service_run_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
RSpec.describe Cove::Invocation::ServiceRun do
describe "#invoke" do
it "should create and start a container with a custom command" do
custom_cmd = ["echo", "hello"]
registry, service, role, host = setup_environment(service_name: "test", role_name: "web", image: "app:latest", command: ["ping", "8.8.8.8"], ports: [{"type" => "port", "source" => 8080, "target" => 80}], mounts: [{"type" => "volume", "source" => "my-volume", "target" => "/data"}])
deployment = Cove::Deployment.new(role)
instance_on_demand = Cove::OnDemandInstance.new(deployment, custom_cmd)
allow(SecureRandom).to receive(:hex).with(3).and_return("abc123")

stubs = []
desired_container = Cove::DesiredContainer.from(instance_on_demand)

stubs << stub_command(/docker image pull app:latest/).with_exit_status(0)
stubs << stub_command(/mkdir -p \/var\/cove\/env\/#{service.name}\/#{role.name}/)
stubs << stub_command(/.* docker container create .* #{desired_container.name}.* --mount type=volume,source=my-volume,target=\/data .* --rm -it .* echo hello/).with_exit_status(0)
stubs << stub_upload("/var/cove/env/#{service.name}/#{role.name}/#{deployment.version}.env")

expect(Kernel).to receive(:exec).with("ssh", "-t", "1.1.1.1", "docker", "container", "start", "--attach", "-i", "#{desired_container.name}")

invocation = described_class.new(registry: registry, service: service, custom_cmd: custom_cmd, role: role, host: host)

invocation.invoke

stubs.each { |stub| expect(stub).to have_been_invoked }
end

def setup_environment(service_name: "test", role_name: "web", image: "app:latest", container_count: 1, command: [], ports: [], mounts: [])
host = Cove::Host.new(name: "1.1.1.1")
service = Cove::Service.new(name: service_name, image: image)
role = Cove::Role.new(name: role_name, service: service, hosts: [host], container_count: container_count, command: command, ports: ports, mounts: mounts)
registry = Cove::Registry.build(hosts: [host], services: [service], roles: [role])

[registry, service, role, host]
end
end
end