Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Service arbitrary commands #15

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
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
Loading