diff --git a/lib/cove/cli/service.rb b/lib/cove/cli/service.rb index 67cb056..c206b67 100644 --- a/lib/cove/cli/service.rb +++ b/lib/cove/cli/service.rb @@ -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 diff --git a/lib/cove/command/builder.rb b/lib/cove/command/builder.rb index 07cce54..8be7c29 100644 --- a/lib/cove/command/builder.rb +++ b/lib/cove/command/builder.rb @@ -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) @@ -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 diff --git a/lib/cove/command/docker/container/run.rb b/lib/cove/command/docker/container/run.rb index e4c3e77..e0b6d5b 100644 --- a/lib/cove/command/docker/container/run.rb +++ b/lib/cove/command/docker/container/run.rb @@ -3,8 +3,8 @@ 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? @@ -12,8 +12,16 @@ def self.build(image:, name: nil, remove: false, detach: true, interactive: fals 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 diff --git a/lib/cove/instance.rb b/lib/cove/instance.rb index c5918b5..8767322 100644 --- a/lib/cove/instance.rb +++ b/lib/cove/instance.rb @@ -20,6 +20,7 @@ def initialize(package, index) @index = index end + # @return [String] def name "#{package.service_name}-#{package.role_name}-#{version}-#{index}" end @@ -27,7 +28,8 @@ def name # @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 diff --git a/lib/cove/invocation/service_run.rb b/lib/cove/invocation/service_run.rb new file mode 100644 index 0000000..c4cc7db --- /dev/null +++ b/lib/cove/invocation/service_run.rb @@ -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] + 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] + # @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] + 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] + 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 diff --git a/lib/cove/on_demand_instance.rb b/lib/cove/on_demand_instance.rb new file mode 100644 index 0000000..17ef8f7 --- /dev/null +++ b/lib/cove/on_demand_instance.rb @@ -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] The command to run in the container + attr_reader :command + # @return [Array] The port mapping to run in the container + attr_reader :ports + # @return [Array] 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] 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 diff --git a/spec/cove/cli/service_spec.rb b/spec/cove/cli/service_spec.rb index b7aadb6..81e6a80 100644 --- a/spec/cove/cli/service_spec.rb +++ b/spec/cove/cli/service_spec.rb @@ -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 diff --git a/spec/cove/command/docker/container/run_spec.rb b/spec/cove/command/docker/container/run_spec.rb index de9372f..3714f16 100644 --- a/spec/cove/command/docker/container/run_spec.rb +++ b/spec/cove/command/docker/container/run_spec.rb @@ -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", @@ -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", @@ -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 diff --git a/spec/cove/invocation/service_run_spec.rb b/spec/cove/invocation/service_run_spec.rb new file mode 100644 index 0000000..48dc1b4 --- /dev/null +++ b/spec/cove/invocation/service_run_spec.rb @@ -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