diff --git a/bin/dev b/bin/dev index 3eb6673..dfe16f2 100755 --- a/bin/dev +++ b/bin/dev @@ -12,6 +12,10 @@ gemfile do gem 'tty-command', '~> 0.10.1' gem 'tty-prompt' gem 'pastel' + gem 'tty-option' + gem 'aws-sdk-ecs' + gem 'aws-sdk-ssm' + gem 'nokogiri' end require_relative "../lib/state" @@ -36,10 +40,11 @@ Commands: deploy Deploy wrapper for fly setup_network Install systemd networking clean Clean the current project + console Launch a prod console for the current project HEREDOC - TOP_COMMANDS=%w{compose update post_update unset_docker_env mkcert provision_secrets clean deploy setup_network} + TOP_COMMANDS=%w{compose update post_update unset_docker_env mkcert provision_secrets clean deploy setup_network console} CONFIG_DIR = Pathname.new("~/.config/dev").expand_path SHARED_CONTAINERS_DIR = Pathname.new("/opt/shared_containers") @@ -186,8 +191,8 @@ HEREDOC def mkcert(*args) options = {} - OptionParser.new do |opts| - opts.on("--domain=DOMAIN") + OptionParser.new do |parser| + parser.on("--domain=DOMAIN") end.order_recognized!(*args, into: options) %w[mkcert cacerts certs].each do |dir| @@ -383,6 +388,13 @@ HEREDOC setup_network end + def console(*args) + require_relative "../lib/console" + cmd = Console.new + cmd.parse(*args) + cmd.run + end + private def state diff --git a/lib/console.rb b/lib/console.rb new file mode 100644 index 0000000..5331361 --- /dev/null +++ b/lib/console.rb @@ -0,0 +1,239 @@ +require_relative "state" + +class Console + include TTY::Option + include State::Methods + + usage do + program "dev" + end + + option :cluster do + required + desc "ECS cluster name" + long "--cluster string" + end + + option :task_family do + required + desc "ECS task definition family" + long "--task-family string" + end + + option :container_name do + required + desc "Container to target in the task definition" + long "--container-name string" + end + + option :log_level do + long "--log-level string" + desc "Sets the log level" + default :info + end + + flag :help do + short "-h" + long "--help" + desc "Print usage" + end + + flag :debug do + long "--debug" + desc "Shorthand to set the log level to DEBUG" + end + + def run + if params[:help] + print help + exit + elsif !params.valid? + puts params.errors.summary + puts + print help + exit + else + if params[:debug] + params[:log_level] = :debug + end + + interactive_console + end + end + + private + + def interactive_console + find_latest_task_def + start_task + connect_to_instance + ensure + stop_task + end + + def find_latest_task_def + resp = + ecs_client.describe_task_definition( + task_definition: params[:task_family] + ) + + @task_def = "#{params[:task_family]}:#{resp.task_definition.revision}" + + log.info "Using #{@task_def}" + end + + def start_task + overrides = { + container_overrides: [ + { + name: params[:container_name], + command: ["sleep", "infinity"] + } + ] + } + + whoami = `whoami` + + log.info "Starting task..." + + run_task_args = { + cluster: params[:cluster], + task_definition: @task_def, + network_configuration: { + awsvpc_configuration: { + subnets: awsvpc_private_subnet_ids, + security_groups: awsvpc_security_group_ids + }, + }, + overrides: overrides, + started_by: "user-console/#{whoami}", + propagate_tags: "TASK_DEFINITION", + } + + log.debug(run_task_args) + + resp = + ecs_client.run_task(run_task_args) + + log.debug(resp) + tasks = resp.tasks + + @task_arn = tasks.first.task_arn + + @container_instance_arn = tasks.first.container_instance_arn + printed = false + + while @container_instance_arn.nil? + if !printed + printed = true + puts "Waiting for ECS task to populate container instances..." + end + + resp = + ecs_client.describe_tasks( + tasks: [ + @task_arn + ] + ) + log.debug(resp) + + sleep 1 + @container_instance_arn = tasks.first.container_instance_arn + end + end + + def stop_task + return if @task_arn.nil? + + log.info "Stopping task..." + + resp = + ecs_client.stop_task( + cluster: params[:cluster], + task: @task_arn, + reason: "user-console/stop" + ) + + log.info "Done." + end + + def connect_to_instance + resp = + ecs_client.describe_container_instances( + cluster: params[:cluster], + container_instances: [@container_instance_arn] + ) + + instance_id = resp.container_instances.first.ec2_instance_id + + script = <<~SCRIPT + docker run -it --rm \ + --pull always \ + --net host \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e TASK_ARN=#{@task_arn} \ + -e CONTAINER_NAME=#{params[:container_name]} \ + -e DEBUG=#{ENV.fetch("DEBUG", "false")} \ + -e BASH_SHELL=#{ENV.fetch("BASH_SHELL", "false")} \ + -e TASK_STATUS=#{ENV.fetch("TASK_STATUS", "true")} \ + ryansch/console-helper:latest + SCRIPT + + args = [ + "ssh", + "-t", + instance_id, + "sudo", + "sheltie" + ] + + args += script.lines + + begin + run_it + .subprocess( + args, + ) + rescue Subprocess::NonZeroExit => e + puts e.message + rescue Interrupt + end + end + + def awsvpc_private_subnet_ids + resp = + ssm_client.get_parameter( + name: "/console/#{params[:cluster]}/private_subnet_ids" + ) + JSON.parse(resp.parameter.value) + end + + def awsvpc_security_group_ids + resp = + ssm_client.get_parameter( + name: "/console/#{params[:cluster]}/client_nodes_security_group_ids" + ) + JSON.parse(resp.parameter.value) + end + + def ecs_client + return @ecs_client if defined?(@ecs_client) + + @ecs_client = + Aws::ECS::Client.new + end + + def ssm_client + return @ssm_client if defined?(@ssm_client) + + @ssm_client = + Aws::SSM::Client.new + end + + def state + return @state if defined?(@state) + + @state = + State.new(log_level: params[:log_level]) + end +end