diff --git a/README.md b/README.md index 3b5338d..0e85604 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,7 @@ After that we can type commands without `dip` prefix. For example: *any-args compose *any-compose-arg up -build -down +ktl *any-kubectl-arg provision ``` @@ -81,10 +80,11 @@ Also, you can check out examples at the top. ```yml # Required minimum dip version -version: '7.1' +version: '7.5' environment: COMPOSE_EXT: development + STAGE: "staging" compose: files: @@ -93,6 +93,9 @@ compose: - docker/docker-compose.$DIP_OS.yml project_name: bear +kubectl: + namespace: rocket-$STAGE + interaction: shell: description: Open the Bash shell in app's container @@ -142,6 +145,22 @@ interaction: default_args: db_dev command: psql -h pg -U postgres + k: + description: Run commands in Kubernetes cluster + pod: svc/rocket-app:app-container + entrypoint: /env-entrypoint + subcommands: + bash: + description: Get a shell to the running container + command: /bin/bash + rails: + description: Run Rails commands + command: bundle exec rails + kafka-topics: + description: Manage Kafka topics + pod: svc/rocket-kafka + command: kafka-topics.sh --zookeeper zookeeper:2181 + setup_key: description: Copy key service: app @@ -201,7 +220,13 @@ returned is `/app/sub-project-dir`. Run commands defined within the `interaction` section of dip.yml -By default, a command will be executed using [`docker compose`](https://docs.docker.com/compose/install/) command. If you are still using `docker-compose` binary (i.e., prior to Compose V2 changes), a command would be run through it. You can disable using of Compose V2 by passing an environment variable `DIP_COMPOSE_V2=false dip run`. +A command will be executed by specified runner. Dip has three types of them: + +- `docker-compose` runner — used when the `service` option is defined. +- `kubectl` runner — used when the `pod` option is defined. +- `local` runner — used when the previous ones are not defined. + +If you are still using `docker-compose` binary (i.e., prior to Compose V2 changes), a command would be run through it. You can disable using of Compose V2 by passing an environment variable `DIP_COMPOSE_V2=false dip run`. ```sh dip run rails c @@ -254,7 +279,7 @@ Run commands each by each from `provision` section of dip.yml ### dip compose -Run docker-compose commands that are configured according to the application's dip.yml : +Run docker-compose commands that are configured according to the application's dip.yml: ```sh dip compose COMMAND [OPTIONS] @@ -262,6 +287,16 @@ dip compose COMMAND [OPTIONS] dip compose up -d redis ``` +### dip ktl + +Run kubectl commands that are configured according to the application's dip.yml: + +```sh +dip ktl COMMAND [OPTIONS] + +STAGE=some dip ktl get pods +``` + ### dip ssh Runs ssh-agent container based on https://github.com/whilp/ssh-agent with your ~/.ssh/id_rsa. diff --git a/lib/dip/cli.rb b/lib/dip/cli.rb index 4646de1..bec7422 100644 --- a/lib/dip/cli.rb +++ b/lib/dip/cli.rb @@ -32,7 +32,7 @@ def start(argv) end end - stop_on_unknown_option! :run + stop_on_unknown_option! :run, :ktl desc "version", "dip version" def version @@ -82,7 +82,13 @@ def down(*argv) end end - desc "run [OPTIONS] CMD [ARGS]", "Run configured command in a docker-compose service. `run` prefix may be omitted" + desc "ktl CMD [OPTIONS]", "Run kubectl commands" + def ktl(*argv) + require_relative "commands/kubectl" + Dip::Commands::Kubectl.new(*argv).execute + end + + desc "run [OPTIONS] CMD [ARGS]", "Run configured command (`run` prefix may be omitted)" method_option :publish, aliases: "-p", type: :string, repeatable: true, desc: "Publish a container's port(s) to the host" method_option :help, aliases: "-h", type: :boolean, desc: "Display usage information" @@ -91,7 +97,11 @@ def run(*argv) invoke :help, ["run"] else require_relative "commands/run" - Dip::Commands::Run.new(*argv, publish: options[:publish]).execute + + Dip::Commands::Run.new( + *argv, + **options.to_h.transform_keys!(&:to_sym) + ).execute end end diff --git a/lib/dip/commands/kubectl.rb b/lib/dip/commands/kubectl.rb new file mode 100644 index 0000000..f3f841f --- /dev/null +++ b/lib/dip/commands/kubectl.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "../command" + +module Dip + module Commands + class Kubectl < Dip::Command + attr_reader :argv, :config + + def initialize(*argv) + @argv = argv + @config = ::Dip.config.kubectl || {} + end + + def execute + k_argv = cli_options + argv + + exec_program("kubectl", k_argv) + end + + private + + def cli_options + %i[namespace].flat_map do |name| + next unless (value = config[name]) + next unless value.is_a?(String) + + value = ::Dip.env.interpolate(value).delete_suffix("-") + ["--#{name.to_s.tr("_", "-")}", value] + end.compact + end + end + end +end diff --git a/lib/dip/commands/run.rb b/lib/dip/commands/run.rb index 6ff1a2b..23c4f9f 100644 --- a/lib/dip/commands/run.rb +++ b/lib/dip/commands/run.rb @@ -4,13 +4,17 @@ require_relative "../../../lib/dip/run_vars" require_relative "../command" require_relative "../interaction_tree" -require_relative "compose" +require_relative "runners/local_runner" +require_relative "runners/docker_compose_runner" +require_relative "runners/kubectl_runner" + +require_relative "kubectl" module Dip module Commands class Run < Dip::Command - def initialize(cmd, *argv, publish: nil) - @publish = publish + def initialize(cmd, *argv, **options) + @options = options @command, @argv = InteractionTree .new(Dip.config.interaction) @@ -22,75 +26,25 @@ def initialize(cmd, *argv, publish: nil) end def execute - if command[:service].nil? - exec_program(command[:command], get_args, shell: command[:shell]) - else - Dip::Commands::Compose.new( - command[:compose][:method], - *compose_arguments, - shell: command[:shell] - ).execute - end + lookup_runner + .new(command, argv, **options) + .execute end private - attr_reader :command, :argv, :publish - - def compose_arguments - compose_argv = command[:compose][:run_options].dup - - if command[:compose][:method] == "run" - compose_argv.concat(run_vars) - compose_argv.concat(published_ports) - compose_argv << "--rm" - end - - compose_argv << command.fetch(:service) - - unless (cmd = command[:command]).empty? - if command[:shell] - compose_argv << cmd - else - compose_argv.concat(cmd.shellsplit) - end - end - - compose_argv.concat(get_args) - - compose_argv - end - - def run_vars - run_vars = Dip::RunVars.env - return [] unless run_vars - - run_vars.map { |k, v| ["-e", "#{k}=#{Shellwords.escape(v)}"] }.flatten - end - - def published_ports - if publish.respond_to?(:each) - publish.map { |p| "--publish=#{p}" } - else - [] - end - end + attr_reader :command, :argv, :options - def get_args - if argv.any? - if command[:shell] - [argv.shelljoin] - else - Array(argv) - end - elsif !(default_args = command[:default_args]).empty? - if command[:shell] - default_args.shellsplit - else - Array(default_args) - end + def lookup_runner + if (runner = command[:runner]) + camelized_runner = runner.split("_").collect(&:capitalize).join + Runners.const_get("#{camelized_runner}Runner") + elsif command[:service] + Runners::DockerComposeRunner + elsif command[:pod] + Runners::KubectlRunner else - [] + Runners::LocalRunner end end end diff --git a/lib/dip/commands/runners/base.rb b/lib/dip/commands/runners/base.rb new file mode 100644 index 0000000..7ba1c0a --- /dev/null +++ b/lib/dip/commands/runners/base.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Dip + module Commands + module Runners + class Base + def initialize(command, argv, **options) + @command = command + @argv = argv + @options = options + end + + def execute + raise NotImplementedError + end + + private + + attr_reader :command, :argv, :options + + def command_args + if argv.any? + if command[:shell] + [argv.shelljoin] + else + Array(argv) + end + elsif !(default_args = command[:default_args]).empty? + if command[:shell] + default_args.shellsplit + else + Array(default_args) + end + else + [] + end + end + end + end + end +end diff --git a/lib/dip/commands/runners/docker_compose_runner.rb b/lib/dip/commands/runners/docker_compose_runner.rb new file mode 100644 index 0000000..3360090 --- /dev/null +++ b/lib/dip/commands/runners/docker_compose_runner.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require_relative "base" +require_relative "../compose" + +module Dip + module Commands + module Runners + class DockerComposeRunner < Base + def execute + Commands::Compose.new( + command[:compose][:method], + *compose_arguments, + shell: command[:shell] + ).execute + end + + private + + def compose_arguments + compose_argv = command[:compose][:run_options].dup + + if command[:compose][:method] == "run" + compose_argv.concat(run_vars) + compose_argv.concat(published_ports) + compose_argv << "--rm" + end + + compose_argv << command.fetch(:service) + + unless (cmd = command[:command]).empty? + if command[:shell] + compose_argv << cmd + else + compose_argv.concat(cmd.shellsplit) + end + end + + compose_argv.concat(command_args) + + compose_argv + end + + def run_vars + run_vars = Dip::RunVars.env + return [] unless run_vars + + run_vars.map { |k, v| ["-e", "#{k}=#{Shellwords.escape(v)}"] }.flatten + end + + def published_ports + publish = options[:publish] + + if publish.respond_to?(:each) + publish.map { |p| "--publish=#{p}" } + else + [] + end + end + end + end + end +end diff --git a/lib/dip/commands/runners/kubectl_runner.rb b/lib/dip/commands/runners/kubectl_runner.rb new file mode 100644 index 0000000..aeb5d19 --- /dev/null +++ b/lib/dip/commands/runners/kubectl_runner.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "base" +require_relative "../kubectl" + +module Dip + module Commands + module Runners + class KubectlRunner < Base + def execute + Commands::Kubectl.new(*kubectl_arguments).execute + end + + private + + def kubectl_arguments + argv = ["exec", "--tty", "--stdin"] + + pod, container = command.fetch(:pod).split(":") + argv.push("--container", container) unless container.nil? + argv.push(pod, "--") + + unless (entrypoint = command[:entrypoint]).nil? + argv << entrypoint + end + argv << command.fetch(:command) + argv.concat(command_args) + + argv + end + end + end + end +end diff --git a/lib/dip/commands/runners/local_runner.rb b/lib/dip/commands/runners/local_runner.rb new file mode 100644 index 0000000..8996a03 --- /dev/null +++ b/lib/dip/commands/runners/local_runner.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative "base" +require_relative "../../command" + +module Dip + module Commands + module Runners + class LocalRunner < Base + def execute + Dip::Command.exec_program( + command[:command], + command_args, + shell: command[:shell] + ) + end + end + end + end +end diff --git a/lib/dip/config.rb b/lib/dip/config.rb index 65419cf..d44db92 100644 --- a/lib/dip/config.rb +++ b/lib/dip/config.rb @@ -16,6 +16,7 @@ class Config CONFIG_DEFAULTS = { environment: {}, compose: {}, + kubectl: {}, interation: {}, provision: [] }.freeze @@ -94,7 +95,7 @@ def to_h config end - %i[environment compose interaction provision].each do |key| + %i[environment compose kubectl interaction provision].each do |key| define_method(key) do config[key] || (raise config_missing_error(key)) end diff --git a/lib/dip/interaction_tree.rb b/lib/dip/interaction_tree.rb index 50221ec..3f9f318 100644 --- a/lib/dip/interaction_tree.rb +++ b/lib/dip/interaction_tree.rb @@ -58,7 +58,10 @@ def expand(name, entry, tree: {}) def build_command(entry) { description: entry[:description], + runner: entry[:runner], service: entry[:service], + pod: entry[:pod], + entrypoint: entry[:entrypoint], command: entry[:command].to_s.strip, shell: entry.fetch(:shell, true), default_args: entry[:default_args].to_s.strip, diff --git a/spec/lib/dip/commands/kubectl_spec.rb b/spec/lib/dip/commands/kubectl_spec.rb new file mode 100644 index 0000000..738cabe --- /dev/null +++ b/spec/lib/dip/commands/kubectl_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "shellwords" +require "dip/cli" +require "dip/commands/kubectl" + +describe Dip::Commands::Kubectl do + let(:cli) { Dip::CLI } + + context "when execute without extra arguments" do + before { cli.start "ktl get pods".shellsplit } + + it { expected_exec("kubectl", ["get", "pods"]) } + end + + context "when execute with arguments" do + before { cli.start "ktl exec app -- ls -l".shellsplit } + + it { expected_exec("kubectl", ["exec", "app", "--", "ls", "-l"]) } + end + + context "when config contains namespace", config: true do + let(:config) { {kubectl: {namespace: "rocket"}} } + + before { cli.start "ktl get pods".shellsplit } + + it { expected_exec("kubectl", ["--namespace", "rocket", "get", "pods"]) } + end + + context "when config contains namespace with env vars", config: true, env: true do + let(:config) { {kubectl: {namespace: "rocket-${STAGE}"}} } + let(:env) { {"STAGE" => "test"} } + + before { cli.start "ktl get pods".shellsplit } + + it { expected_exec("kubectl", ["--namespace", "rocket-test", "get", "pods"]) } + end + + context "when config contains namespace with empty env vars", config: true, env: true do + let(:config) { {kubectl: {namespace: "rocket-${STAGE}"}} } + let(:env) { {"STAGE" => ""} } + + before { cli.start "ktl get pods".shellsplit } + + it { expected_exec("kubectl", ["--namespace", "rocket", "get", "pods"]) } + end +end diff --git a/spec/lib/dip/commands/run_spec.rb b/spec/lib/dip/commands/runners/docker_compose_runner_spec.rb similarity index 92% rename from spec/lib/dip/commands/run_spec.rb rename to spec/lib/dip/commands/runners/docker_compose_runner_spec.rb index f77e663..bd89893 100644 --- a/spec/lib/dip/commands/run_spec.rb +++ b/spec/lib/dip/commands/runners/docker_compose_runner_spec.rb @@ -4,33 +4,18 @@ require "dip/cli" require "dip/commands/run" -describe Dip::Commands::Run, config: true do +describe Dip::Commands::Runners::DockerComposeRunner, config: true do let(:config) { {interaction: commands} } let(:commands) do { bash: {service: "app"}, bash_shell: {service: "app", command: "bash", shell: false}, - rails: {service: "app", command: "rails"}, - psql: {service: "postgres", command: "psql -h postgres", default_args: "db_dev"}, - setup: {command: "./bin/setup", default_args: "all"} + rails: {runner: "docker_compose", service: "app", command: "rails"}, + psql: {service: "postgres", command: "psql -h postgres", default_args: "db_dev"} } end let(:cli) { Dip::CLI } - context "when run command on host" do - context "when using default args" do - before { cli.start "run setup".shellsplit } - - it { expected_exec("./bin/setup", ["all"]) } - end - - context "when args are provided" do - before { cli.start "run setup db".shellsplit } - - it { expected_exec("./bin/setup", ["db"]) } - end - end - context "when run bash command" do before { cli.start "run bash".shellsplit } diff --git a/spec/lib/dip/commands/runners/kubectl_runner_spec.rb b/spec/lib/dip/commands/runners/kubectl_runner_spec.rb new file mode 100644 index 0000000..58147f8 --- /dev/null +++ b/spec/lib/dip/commands/runners/kubectl_runner_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "shellwords" +require "dip/cli" +require "dip/commands/run" + +describe Dip::Commands::Runners::KubectlRunner, config: true do + let(:config) { {interaction: commands} } + let(:commands) do + { + bash: {pod: "app", command: "/usr/bin/bash"}, + bundle: {runner: "kubectl", pod: "app:cont", command: "bundle"}, + rails: { + pod: "app", + entrypoint: "/entrypoint", + command: "rails" + }, + psql: {pod: "app", command: "psql -h postgres", default_args: "db_dev"} + } + end + let(:cli) { Dip::CLI } + + context "when run bash command" do + before { cli.start "run bash".shellsplit } + + it { expected_exec("kubectl", ["exec", "--tty", "--stdin", "app", "--", "/usr/bin/bash"]) } + end + + context "when run shorthanded bash command" do + before { cli.start ["bash"] } + + it { expected_exec("kubectl", ["exec", "--tty", "--stdin", "app", "--", "/usr/bin/bash"]) } + end + + context "when run psql command without db name" do + before { cli.start "run psql".shellsplit } + + it { expected_exec("kubectl", ["exec", "--tty", "--stdin", "app", "--", "psql", "-h", "postgres", "db_dev"]) } + end + + context "when run psql command with db name" do + before { cli.start "run psql db_test".shellsplit } + + it { expected_exec("kubectl", ["exec", "--tty", "--stdin", "app", "--", "psql", "-h", "postgres", "db_test"]) } + end + + context "when run rails command" do + before { cli.start "run rails".shellsplit } + + it { expected_exec("kubectl", ["exec", "--tty", "--stdin", "app", "--", "/entrypoint", "rails"]) } + end + + context "when run rails command with subcommand" do + before { cli.start "run rails console".shellsplit } + + it { expected_exec("kubectl", ["exec", "--tty", "--stdin", "app", "--", "/entrypoint", "rails", "console"]) } + end + + context "when run rails command with arguments" do + before { cli.start "run rails g migration add_index --force".shellsplit } + + it { expected_exec("kubectl", ["exec", "--tty", "--stdin", "app", "--", "/entrypoint", "rails", "g", "migration", "add_index", "--force"]) } + end + + context "when run with specific container" do + before { cli.start "bundle".shellsplit } + + it { expected_exec("kubectl", ["exec", "--tty", "--stdin", "--container", "cont", "app", "--", "bundle"]) } + end + + context "when config with namespace" do + let(:config) do + { + environment: { + "STAGE" => "" + }, + kubectl: { + namespace: "appspace-${STAGE}" + }, + interaction: commands + } + end + + before { cli.start "run rails server".shellsplit } + + it { expected_exec("kubectl", ["--namespace", "appspace", "exec", "--tty", "--stdin", "app", "--", "/entrypoint", "rails", "server"]) } + end +end diff --git a/spec/lib/dip/commands/runners/local_runner_spec.rb b/spec/lib/dip/commands/runners/local_runner_spec.rb new file mode 100644 index 0000000..0de9045 --- /dev/null +++ b/spec/lib/dip/commands/runners/local_runner_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "shellwords" +require "dip/cli" +require "dip/commands/run" + +describe Dip::Commands::Runners::LocalRunner, config: true do + let(:config) { {interaction: commands} } + let(:commands) do + { + setup: {command: "./bin/setup", default_args: "all"} + } + end + let(:cli) { Dip::CLI } + + context "when using default args" do + before { cli.start "run setup".shellsplit } + + it { expected_exec("./bin/setup", ["all"]) } + end + + context "when args are provided" do + before { cli.start "run setup db".shellsplit } + + it { expected_exec("./bin/setup", ["db"]) } + end +end