├── .coveralls.yml ├── .rspec ├── examples ├── crash.rb ├── sample.ini └── hello_sinatra.rb ├── spec ├── invoker │ ├── power │ │ ├── setup_spec.rb │ │ ├── port_finder_spec.rb │ │ ├── config_spec.rb │ │ ├── http_response_spec.rb │ │ ├── http_parser_spec.rb │ │ ├── web_sockets_spec.rb │ │ ├── balancer_spec.rb │ │ ├── url_rewriter_spec.rb │ │ └── setup │ │ │ ├── osx_setup_spec.rb │ │ │ └── linux_setup_spec.rb │ ├── reactor_spec.rb │ ├── cli │ │ ├── pinger_spec.rb │ │ └── tail_watcher_spec.rb │ ├── cli_spec.rb │ ├── ipc │ │ ├── message │ │ │ └── list_response_spec.rb │ │ ├── unix_client_spec.rb │ │ ├── dns_check_command_spec.rb │ │ ├── message_spec.rb │ │ └── client_handler_spec.rb │ ├── daemon_spec.rb │ ├── command_worker_spec.rb │ ├── invoker_spec.rb │ ├── event │ │ └── manager_spec.rb │ ├── process_manager_spec.rb │ ├── commander_spec.rb │ └── config_spec.rb └── spec_helper.rb ├── lib ├── invoker │ ├── power │ │ ├── power.rb │ │ ├── setup │ │ │ ├── distro │ │ │ │ ├── opensuse.rb │ │ │ │ ├── redhat.rb │ │ │ │ ├── debian.rb │ │ │ │ ├── arch.rb │ │ │ │ ├── ubuntu.rb │ │ │ │ └── base.rb │ │ │ ├── files │ │ │ │ ├── socat_invoker.service │ │ │ │ └── invoker_forwarder.sh.erb │ │ │ ├── linux_setup.rb │ │ │ └── osx_setup.rb │ │ ├── powerup.rb │ │ ├── templates │ │ │ ├── 400.html │ │ │ ├── 404.html │ │ │ └── 503.html │ │ ├── dns.rb │ │ ├── url_rewriter.rb │ │ ├── port_finder.rb │ │ ├── config.rb │ │ ├── http_parser.rb │ │ ├── http_response.rb │ │ ├── setup.rb │ │ └── balancer.rb │ ├── ipc │ │ ├── message │ │ │ ├── tail_response.rb │ │ │ └── list_response.rb │ │ ├── ping_command.rb │ │ ├── list_command.rb │ │ ├── add_http_command.rb │ │ ├── reload_command.rb │ │ ├── remove_command.rb │ │ ├── add_command.rb │ │ ├── tail_command.rb │ │ ├── dns_check_command.rb │ │ ├── server.rb │ │ ├── client_handler.rb │ │ ├── base_command.rb │ │ ├── unix_client.rb │ │ └── message.rb │ ├── logger.rb │ ├── cli │ │ ├── question.rb │ │ ├── pinger.rb │ │ ├── tail.rb │ │ └── tail_watcher.rb │ ├── errors.rb │ ├── dns_cache.rb │ ├── reactor.rb │ ├── version.rb │ ├── ipc.rb │ ├── reactor │ │ └── reader.rb │ ├── command_worker.rb │ ├── process_printer.rb │ ├── parsers │ │ ├── procfile.rb │ │ └── config.rb │ ├── event │ │ └── manager.rb │ ├── daemon.rb │ ├── commander.rb │ ├── cli.rb │ └── process_manager.rb └── invoker.rb ├── bin └── invoker ├── TODO ├── Dockerfile ├── .gitignore ├── Gemfile ├── Rakefile ├── .rubocop.yml ├── MIT-LICENSE ├── readme.md ├── .travis.yml ├── contrib └── completion │ ├── invoker-completion.zsh │ └── invoker-completion.bash ├── invoker.gemspec └── CHANGELOG.md /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /examples/crash.rb: -------------------------------------------------------------------------------- 1 | puts "Starting this process" 2 | exit(-1) 3 | -------------------------------------------------------------------------------- /spec/invoker/power/setup_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Setup" do 4 | end 5 | -------------------------------------------------------------------------------- /lib/invoker/power/power.rb: -------------------------------------------------------------------------------- 1 | require "invoker/power/http_response" 2 | require "invoker/power/dns" 3 | require "invoker/power/balancer" 4 | -------------------------------------------------------------------------------- /spec/invoker/reactor_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Invoker::Reactor do 4 | describe "writing to socket" do 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /examples/sample.ini: -------------------------------------------------------------------------------- 1 | [rails] 2 | directory = ./examples 3 | command = ruby hello_sinatra.rb -p $PORT 4 | 5 | [crash] 6 | directory = ./examples 7 | command = ruby crash.rb 8 | -------------------------------------------------------------------------------- /bin/invoker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | invoker_lib_path = File.expand_path('../../lib', __FILE__) 4 | $:.unshift(invoker_lib_path) 5 | require "invoker" 6 | 7 | Invoker::CLI.start(ARGV) 8 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | Todos for current release 2 | 3 | * Implement port support via config file 4 | * Fix setup command and make it eaiser for people who have pow configured. 5 | * Possibly add support for foreman? 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.3 2 | 3 | MAINTAINER hemant@codemancers.com 4 | 5 | RUN apt-get update && apt-get -y install dnsmasq socat 6 | 7 | CMD cd /invoker && bundle install --path vendor/ && bundle exec rake spec 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | invoker.ini 2 | config/ 3 | *.gem 4 | pkg/ 5 | tags 6 | .rvmrc 7 | vendor/ 8 | _site 9 | .bundle 10 | coverage 11 | invoker_profile/ 12 | *.pid 13 | .ruby-version 14 | Gemfile.lock 15 | .overcommit.yml 16 | Procfile 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'coveralls', '>= 0.8', require: false 6 | gem 'simplecov', require: false 7 | gem 'websocket-eventmachine-server', require: false 8 | gem 'websocket-eventmachine-client', require: false 9 | -------------------------------------------------------------------------------- /lib/invoker/ipc/message/tail_response.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | module Message 4 | class TailResponse < Base 5 | include Serialization 6 | message_attributes :tail_line 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/invoker/ipc/ping_command.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | class PingCommand < BaseCommand 4 | def run_command(message_object) 5 | pong = Invoker::IPC::Message::Pong.new(status: 'pong') 6 | send_data(pong) 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/invoker/power/setup/distro/opensuse.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module Power 3 | module Distro 4 | class Opensuse < Base 5 | def install_required_software 6 | system("zypper install -l dnsmasq socat") 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/invoker/power/setup/distro/redhat.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module Power 3 | module Distro 4 | class Redhat < Base 5 | def install_required_software 6 | system("yum --assumeyes install dnsmasq socat") 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/invoker/power/setup/distro/debian.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module Power 3 | module Distro 4 | class Debian < Base 5 | def install_required_software 6 | system("apt-get --assume-yes install dnsmasq socat") 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/invoker/ipc/list_command.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | class ListCommand < BaseCommand 4 | def run_command(message_object) 5 | list_response = Invoker.commander.process_list 6 | send_data(list_response) 7 | true 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/invoker/power/setup/files/socat_invoker.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Socat port forwarding service 3 | After=network.target 4 | Documentation=man:socat(1) 5 | 6 | [Service] 7 | ExecStart=/usr/bin/invoker_forwarder.sh 8 | Restart=on-success 9 | 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /lib/invoker/ipc/add_http_command.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | class AddHttpCommand < BaseCommand 4 | def run_command(message_object) 5 | Invoker.dns_cache.add(message_object.process_name, message_object.port, message_object.ip) 6 | true 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/invoker/logger.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | class Logger 3 | def self.puts(message) 4 | return if ENV["INVOKER_TESTS"] 5 | $stdout.puts(message) 6 | end 7 | 8 | def self.print(message) 9 | return if ENV["INVOKER_TESTS"] 10 | $stdout.print(message) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/invoker/ipc/reload_command.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | class ReloadCommand < BaseCommand 4 | def run_command(message_object) 5 | Invoker.commander.on_next_tick(message_object) do |reload_message| 6 | restart_process(reload_message) 7 | end 8 | true 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/invoker/ipc/remove_command.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | class RemoveCommand < BaseCommand 4 | def run_command(message_object) 5 | Invoker.commander.on_next_tick(message_object) do |remove_message| 6 | stop_process(remove_message) 7 | end 8 | true 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/invoker/ipc/add_command.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | class AddCommand < BaseCommand 4 | def run_command(message_object) 5 | Invoker.commander.on_next_tick(message_object.process_name) do |process_name| 6 | start_process_by_name(process_name) 7 | end 8 | true 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/invoker/ipc/tail_command.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | class TailCommand < BaseCommand 4 | def run_command(message_object) 5 | Invoker::Logger.puts("Adding #{message_object.process_names.inspect}") 6 | Invoker.tail_watchers.add(message_object.process_names, client_socket) 7 | false 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /examples/hello_sinatra.rb: -------------------------------------------------------------------------------- 1 | # myapp.rb 2 | require 'sinatra' 3 | 4 | get '/' do 5 | 'Hello world!' 6 | end 7 | 8 | get "/emacs" do 9 | redirect to("/vim") 10 | end 11 | 12 | get "/vim" do 13 | "vim rules" 14 | end 15 | 16 | 17 | post '/foo' do 18 | puts request.env 19 | "done" 20 | end 21 | 22 | 23 | post "/api/v1/datapoints" do 24 | puts request.env 25 | "done" 26 | end 27 | -------------------------------------------------------------------------------- /lib/invoker/cli/question.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | class CLI::Question 3 | def self.agree(question_text) 4 | $stdout.print(question_text) 5 | answer = $stdin.gets 6 | answer.strip! 7 | if answer =~ /\Ay(?:es)?|no?\Z/i 8 | answer =~ /\Ay(?:es)?\Z/i 9 | else 10 | $stdout.puts "Please enter 'yes' or 'no'." 11 | agree(question_text) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/invoker/power/port_finder_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "PortFinder" do 4 | before do 5 | @port_finder = Invoker::Power::PortFinder.new() 6 | @port_finder.find_ports 7 | end 8 | 9 | it "should find a http port" do 10 | expect(@port_finder.http_port).not_to be_nil 11 | end 12 | 13 | it "should find a dns port" do 14 | expect(@port_finder.dns_port).not_to be_nil 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new 5 | 6 | task :default => :spec 7 | task :test => :spec 8 | 9 | current_directory = File.expand_path(File.dirname(__FILE__)) 10 | 11 | desc "run specs inside docker" 12 | task :docker_spec do 13 | system("docker build -t invoker-ruby . ") 14 | puts "********** Building image is done **********" 15 | system("docker run --name invoker-rspec --rm -v #{current_directory}:/invoker:z -t invoker-ruby") 16 | end 17 | -------------------------------------------------------------------------------- /lib/invoker/power/setup/files/invoker_forwarder.sh.erb: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | KillJobs() { 4 | for job in $(jobs -p); do 5 | kill -s SIGTERM $job > /dev/null 2>&1 || (sleep 10 && kill -9 $job > /dev/null 2>&1 &) 6 | done 7 | } 8 | 9 | # Whatever you need to clean here 10 | trap KillJobs SIGINT SIGTERM EXIT 11 | 12 | /usr/bin/socat TCP-LISTEN:80,reuseaddr,fork TCP:0.0.0.0:<%= http_port %>& 13 | pid1=$! 14 | /usr/bin/socat TCP-LISTEN:443,reuseaddr,fork TCP:0.0.0.0:<%= https_port %>& 15 | pid2=$! 16 | wait $pid1 $pid2 17 | wait $pid1 $pid2 18 | -------------------------------------------------------------------------------- /lib/invoker/errors.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module Errors 3 | class ToomanyOpenConnections < StandardError; end 4 | class ProcessTerminated < StandardError 5 | attr_accessor :message, :ready_fd 6 | def initialize(ready_fd, message) 7 | @ready_fd = ready_fd 8 | @message = message 9 | end 10 | end 11 | 12 | class NoValidPortFound < StandardError; end 13 | class InvalidConfig < StandardError; end 14 | class InvalidFile < StandardError; end 15 | class ClientDisconnected < StandardError; end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/invoker/cli/pinger.rb: -------------------------------------------------------------------------------- 1 | require "timeout" 2 | 3 | module Invoker 4 | class CLI::Pinger 5 | attr_accessor :unix_client 6 | def initialize(unix_client) 7 | @unix_client = unix_client 8 | end 9 | 10 | def invoker_running? 11 | response = send_ping_and_read_response 12 | response && response.status == 'pong' 13 | end 14 | 15 | private 16 | 17 | def send_ping_and_read_response 18 | Timeout.timeout(2) { unix_client.send_and_receive('ping') } 19 | rescue Timeout::Error 20 | nil 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/invoker/power/setup/distro/arch.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module Power 3 | module Distro 4 | class Arch < Base 5 | def install_required_software 6 | system("pacman -S --needed --noconfirm dnsmasq socat") 7 | system("mkdir -p /etc/dnsmasq.d") 8 | unless File.open("/etc/dnsmasq.conf").each_line.any? { |line| line.chomp == "conf-dir=/etc/dnsmasq.d" } 9 | File.open("/etc/dnsmasq.conf", "a") {|f| f.write("conf-dir=/etc/dnsmasq.d") } 10 | end 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/invoker/ipc/dns_check_command.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | class DnsCheckCommand < BaseCommand 4 | def run_command(message_object) 5 | process_detail = Invoker.dns_cache[message_object.process_name] 6 | 7 | dns_check_response = Invoker::IPC::Message::DnsCheckResponse.new( 8 | process_name: message_object.process_name, 9 | port: process_detail ? process_detail['port'] : nil, 10 | ip: process_detail && process_detail['ip'] ? process_detail['ip'] : '0.0.0.0' 11 | ) 12 | send_data(dns_check_response) 13 | true 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/invoker/dns_cache.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | class DNSCache 3 | attr_accessor :dns_data 4 | 5 | def initialize(config) 6 | self.dns_data = {} 7 | @dns_mutex = Mutex.new 8 | Invoker.config.processes.each do |process| 9 | if process.port 10 | dns_data[process.label] = { 'port' => process.port } 11 | end 12 | end 13 | end 14 | 15 | def [](process_name) 16 | @dns_mutex.synchronize { dns_data[process_name] } 17 | end 18 | 19 | def add(name, port, ip = nil) 20 | @dns_mutex.synchronize { dns_data[name] = { 'port' => port, 'ip' => ip } } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/invoker/power/config_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Invoker Power configuration", fakefs: true do 4 | describe "#create" do 5 | it "should create a config file given a hash" do 6 | FileUtils.mkdir_p(inv_conf_dir) 7 | config = Invoker::Power::Config.create( 8 | dns_port: 1200, http_port: 1201, ipfw_rule_number: 010 9 | ) 10 | expect(File.exist?(Invoker::Power::Config.config_file)).to be_truthy 11 | 12 | config = Invoker::Power::Config.load_config() 13 | expect(config.dns_port).to eq(1200) 14 | expect(config.http_port).to eq(1201) 15 | expect(config.ipfw_rule_number).to eq(010) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/invoker/ipc/server.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | 3 | module Invoker 4 | module IPC 5 | class Server 6 | SOCKET_PATH = "/tmp/invoker" 7 | def initialize 8 | @open_clients = [] 9 | Socket.unix_server_loop(SOCKET_PATH) do |sock, client_addrinfo| 10 | Thread.new { process_client(sock) } 11 | end 12 | end 13 | 14 | def clean_old_socket 15 | if File.exist?(SOCKET_PATH) 16 | FileUtils.rm(SOCKET_PATH, :force => true) 17 | end 18 | end 19 | 20 | def process_client(client_socket) 21 | client = Invoker::IPC::ClientHandler.new(client_socket) 22 | client.read_and_execute 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/invoker/power/powerup.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | # power is really a stupid pun on pow. 3 | module Power 4 | class Powerup 5 | def self.fork_and_start 6 | powerup = new() 7 | fork { powerup.run } 8 | end 9 | 10 | def run 11 | require "invoker/power/power" 12 | EM.epoll 13 | EM.run { 14 | trap("TERM") { stop } 15 | trap("INT") { stop } 16 | if Invoker.darwin? 17 | DNS.new.run(listen: DNS.server_ports) 18 | end 19 | Balancer.run 20 | } 21 | end 22 | 23 | def stop 24 | Invoker::Logger.puts "Terminating Proxy/Server" 25 | EventMachine.stop 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/invoker/cli/pinger_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Invoker::CLI::Pinger do 4 | let(:unix_client) { Invoker::IPC::UnixClient.new } 5 | let(:pinger) { Invoker::CLI::Pinger.new(unix_client) } 6 | let(:pong) { MM::Pong.new(status: 'pong') } 7 | 8 | context "If Invoker is running" do 9 | it "should return true" do 10 | unix_client.expects(:send_and_receive).returns(pong) 11 | expect(pinger.invoker_running?).to be_truthy 12 | end 13 | end 14 | 15 | context "if Invoker is not running" do 16 | it "should return false" do 17 | unix_client.expects(:send_and_receive).returns(nil) 18 | unix_client.expects(:abort).never 19 | expect(pinger.invoker_running?).to be_falsey 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Excludes: 3 | - db/** 4 | 5 | HashSyntax: 6 | Description: 'Use either hash rocket or 1.9 styled hashes.' 7 | Enabled: false 8 | 9 | LineLength: 10 | Max: 100 11 | 12 | # Disable Certain Tests 13 | 14 | Documentation: 15 | Description: 'Document classes and non-namespace modules.' 16 | Enabled: false 17 | 18 | StringLiterals: 19 | Description: 'Checks if uses of quotes match the configured preference.' 20 | Enabled: false 21 | 22 | Encoding: 23 | Description: 'Use UTF-8 as the source file encoding.' 24 | Enabled: false 25 | 26 | SignalException: 27 | Description: 'Do not enforce use of fail when raising exceptions.' 28 | # Valid values are: semantic, only_raise and only_fail 29 | EnforcedStyle: only_raise 30 | -------------------------------------------------------------------------------- /spec/invoker/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Invoker::CLI do 4 | describe "default start command" do 5 | it "should use default if no other command specified" do 6 | Invoker::CLI.any_instance.expects(:start).with("dummy") 7 | Invoker::CLI.start(["dummy"]) 8 | end 9 | 10 | it "should use proper command if it exists" do 11 | Invoker::CLI.any_instance.expects(:list) 12 | Invoker::CLI.start(["list"]) 13 | end 14 | 15 | it "should list version" do 16 | Invoker::CLI.any_instance.expects(:version) 17 | Invoker::CLI.start(["-v"]) 18 | end 19 | end 20 | 21 | describe "stop command" do 22 | it "should stop the daemon" do 23 | Invoker.daemon.expects(:stop).once 24 | Invoker::CLI.start(["stop"]) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/invoker/ipc/message/list_response_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe MM::ListResponse do 4 | context "serializing a response" do 5 | let(:process_array) do 6 | [ 7 | { shell_command: 'foo', process_name: 'foo', dir: '/tmp', pid: 100, 8 | port: 9000 }, 9 | { shell_command: 'bar', process_name: 'bar', dir: '/tmp', pid: 200, 10 | port: 9001 } 11 | ] 12 | end 13 | 14 | let(:message) { MM::ListResponse.new(processes: process_array) } 15 | 16 | it "should prepare proper json" do 17 | json_hash = message.as_json 18 | expect(json_hash[:type]).to eql "list_response" 19 | expect(json_hash[:processes].length).to eql 2 20 | expect(json_hash[:processes][0]).to be_a(Hash) 21 | expect(json_hash[:processes][1]).to be_a(Hash) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/invoker/ipc/client_handler.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | class ClientHandler 4 | attr_accessor :client_socket 5 | def initialize(client_socket) 6 | @client_socket = client_socket 7 | end 8 | 9 | def read_and_execute 10 | client_handler, message_object = read_incoming_command 11 | client_socket.close if client_handler.run_command(message_object) 12 | rescue StandardError => error 13 | Invoker::Logger.puts error.message 14 | Invoker::Logger.puts error.backtrace 15 | client_socket.close 16 | end 17 | 18 | private 19 | 20 | def read_incoming_command 21 | message_object = Invoker::IPC.message_from_io(client_socket) 22 | [message_object.command_handler_klass.new(client_socket), message_object] 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/invoker/ipc/base_command.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | class BaseCommand 4 | attr_accessor :client_socket 5 | def initialize(client_socket) 6 | @client_socket = client_socket 7 | end 8 | 9 | def send_data(message_object) 10 | client_socket.write(message_object.encoded_message) 11 | end 12 | 13 | # Invoke the command that actual processes incoming message 14 | # returning true from this message means, command has been processed 15 | # and client socket can be closed. returning false means, it is a 16 | # long running command and socket should not be closed immediately 17 | # @param [Invoker::IPC::Message] incoming message 18 | # @return [Boolean] true or false 19 | def run_command(message_object) 20 | raise "Not implemented" 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/invoker/power/templates/400.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Invoker 6 | 32 | 33 | 34 |
35 |

Bad request

36 |
37 |

Invoker couldn't understand the request

38 |
39 | 40 | 41 | -------------------------------------------------------------------------------- /lib/invoker/reactor.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | class Reactor 3 | attr_accessor :reader 4 | 5 | def initialize 6 | @reader = Invoker::Reactor::Reader.new 7 | end 8 | 9 | def watch_for_read(fd) 10 | reader.watch_for_read(fd) 11 | end 12 | 13 | # Writes data to client socket and raises error if errors 14 | # while writing 15 | def send_data(socket, data) 16 | socket.write(data) 17 | rescue 18 | raise Invoker::Errors::ClientDisconnected 19 | end 20 | 21 | def monitor_for_fd_events 22 | ready_read_fds, _ , _ = select(*options_for_select) 23 | 24 | if ready_read_fds && !ready_read_fds.empty? 25 | reader.handle_read_event(ready_read_fds) 26 | end 27 | end 28 | 29 | private 30 | 31 | def options_for_select 32 | [reader.read_array, [], [], 0.05] 33 | end 34 | end 35 | end 36 | 37 | require "invoker/reactor/reader" 38 | -------------------------------------------------------------------------------- /spec/invoker/daemon_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Invoker::Daemon do 4 | let(:daemon) { Invoker::Daemon.new} 5 | 6 | describe "#start" do 7 | context "when daemon is aleady running" do 8 | it "exits without any error" do 9 | daemon.expects(:running?).returns(true) 10 | begin 11 | daemon.start 12 | rescue SystemExit => e 13 | expect(e.status).to be(0) 14 | end 15 | end 16 | end 17 | 18 | context "when daemon is not running" do 19 | it "starts the daemon" do 20 | daemon.expects(:dead?).returns(false) 21 | daemon.expects(:running?).returns(false) 22 | daemon.expects(:daemonize) 23 | daemon.start 24 | end 25 | end 26 | end 27 | 28 | describe "#stop" do 29 | it "stops the daemon" do 30 | daemon.expects(:kill_process) 31 | daemon.stop 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/invoker/power/templates/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Invoker 6 | 32 | 33 | 34 |
35 |

Application not found

36 |
37 |

Invoker could not find the application. Please check the configuration file.

38 |

39 | 40 | 41 | -------------------------------------------------------------------------------- /lib/invoker/power/templates/503.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Invoker 6 | 32 | 33 | 34 |
35 |

Application not running

36 |
37 |

Invoker did not get any response. Please check if the application is running.

38 |

39 | 40 | 41 | -------------------------------------------------------------------------------- /lib/invoker/cli/tail.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | class CLI::Tail 3 | attr_accessor :process_names 4 | def initialize(process_names) 5 | verify_process_name(process_names) 6 | @process_names = process_names 7 | @unix_socket = Invoker::IPC::UnixClient.new 8 | end 9 | 10 | def run 11 | socket = @unix_socket.send_and_wait('tail', process_names: process_names) 12 | trap('INT') { socket.close } 13 | loop do 14 | message = read_next_line(socket) 15 | break unless message 16 | puts message.tail_line 17 | end 18 | end 19 | 20 | private 21 | 22 | def verify_process_name(process_names) 23 | if process_names.empty? 24 | abort("Tail command requires one or more process name") 25 | end 26 | end 27 | 28 | def read_next_line(socket) 29 | Invoker::IPC.message_from_io(socket) 30 | rescue 31 | nil 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/invoker/ipc/unix_client_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Invoker::IPC::UnixClient do 4 | let(:unix_client) { described_class.new } 5 | let(:socket) { StringIO.new } 6 | 7 | describe "serializing a " do 8 | it "list request should work" do 9 | unix_client.expects(:open_client_socket).yields(socket) 10 | unix_client.send_command("list") 11 | 12 | expect(socket.string).to match(/list/) 13 | end 14 | 15 | it "add request should work" do 16 | unix_client.expects(:open_client_socket).yields(socket) 17 | unix_client.send_command("add", process_name: "hello") 18 | 19 | expect(socket.string).to match(/hello/) 20 | end 21 | end 22 | 23 | describe ".send_command" do 24 | it "calls the send_command instance method" do 25 | Invoker::IPC::UnixClient.any_instance.expects(:send_command).once 26 | Invoker::IPC::UnixClient.send_command("list") 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/invoker/cli/tail_watcher.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | # This class defines sockets which are open for watching log files 3 | class CLI::TailWatcher 4 | attr_accessor :tail_watchers 5 | 6 | def initialize 7 | @tail_mutex = Mutex.new 8 | @tail_watchers = Hash.new { |h, k| h[k] = [] } 9 | end 10 | 11 | def [](process_name) 12 | @tail_mutex.synchronize { tail_watchers[process_name] } 13 | end 14 | 15 | def add(names, socket) 16 | @tail_mutex.synchronize do 17 | names.each { |name| tail_watchers[name] << socket } 18 | end 19 | end 20 | 21 | def remove(name, socket) 22 | @tail_mutex.synchronize do 23 | client_list = tail_watchers[name] 24 | client_list.delete(socket) 25 | purge(name, socket) if client_list.empty? 26 | end 27 | end 28 | 29 | def purge(name, socket) 30 | tail_watchers.delete(name) 31 | Invoker.close_socket(socket) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/invoker/power/dns.rb: -------------------------------------------------------------------------------- 1 | require "logger" 2 | require 'rubydns' 3 | 4 | module Invoker 5 | module Power 6 | class DNS < RubyDNS::Server 7 | def self.server_ports 8 | [ 9 | [:udp, '127.0.0.1', Invoker.config.dns_port], 10 | [:tcp, '127.0.0.1', Invoker.config.dns_port] 11 | ] 12 | end 13 | 14 | def initialize 15 | @logger = ::Logger.new($stderr) 16 | @logger.level = ::Logger::FATAL 17 | end 18 | 19 | def process(name, resource_class, transaction) 20 | if name_matches?(name) && resource_class_matches?(resource_class) 21 | transaction.respond!("127.0.0.1") 22 | else 23 | transaction.fail!(:NXDomain) 24 | end 25 | end 26 | 27 | private 28 | 29 | def resource_class_matches?(resource_class) 30 | resource_class == Resolv::DNS::Resource::IN::A 31 | end 32 | 33 | def name_matches?(name) 34 | name =~ /.*\.#{Invoker.config.tld}/ 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2014 Hemant Kumar 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /spec/invoker/power/http_response_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "tempfile" 3 | 4 | describe Invoker::Power::HttpResponse do 5 | before do 6 | @http_response = Invoker::Power::HttpResponse.new() 7 | end 8 | 9 | it "should allow user to send a file" do 10 | begin 11 | file = Tempfile.new("error.html") 12 | file_content = "Error message" 13 | file.write(file_content) 14 | file.close 15 | 16 | @http_response.use_file_as_body(file.path) 17 | expect(@http_response.body).to eq(file_content) 18 | expect(@http_response.http_string).to include(file_content) 19 | ensure 20 | file.unlink 21 | end 22 | end 23 | 24 | it "should allow user to set headers" do 25 | @http_response["Content-Type"] = "text/html" 26 | expect(@http_response.header["Content-Type"]).to eq("text/html") 27 | expect(@http_response.http_string).to include("Content-Type") 28 | end 29 | 30 | it "should allow user to set status" do 31 | @http_response.status = 503 32 | expect(@http_response.http_string).to include(Invoker::Power::HttpResponse::STATUS_MAPS[503]) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "simplecov" 2 | require 'coveralls' 3 | require 'fakefs/spec_helpers' 4 | 5 | SimpleCov.formatter = Coveralls::SimpleCov::Formatter 6 | SimpleCov.start do 7 | add_filter "/spec/" 8 | end 9 | 10 | require "invoker" 11 | require "invoker/power/power" 12 | MM = Invoker::IPC::Message 13 | 14 | RSpec.configure do |config| 15 | config.run_all_when_everything_filtered = true 16 | config.filter_run :focus 17 | config.mock_with :mocha 18 | config.include FakeFS::SpecHelpers, fakefs: true 19 | 20 | # Run specs in random order to surface order dependencies. If you find an 21 | # order dependency and want to debug it, you can fix the order by providing 22 | # the seed, which is printed after each run. 23 | # --seed 1234 24 | config.order = 'random' 25 | end 26 | 27 | ENV["INVOKER_TESTS"] = "true" 28 | 29 | def invoker_commander 30 | Invoker.commander ||= mock 31 | end 32 | 33 | def invoker_dns_cache 34 | Invoker.dns_cache ||= mock 35 | end 36 | 37 | def inv_conf_dir 38 | File.join(ENV['HOME'], '.invoker') 39 | end 40 | 41 | def inv_conf_file 42 | File.join(inv_conf_dir, 'config') 43 | end 44 | -------------------------------------------------------------------------------- /lib/invoker/ipc/message/list_response.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | module Message 4 | class ListResponse < Base 5 | include Serialization 6 | message_attributes :processes 7 | def initialize(options) 8 | self.processes = [] 9 | process_array = options[:processes] || options['processes'] 10 | process_array.each do |process_hash| 11 | processes << Process.new(process_hash) 12 | end 13 | end 14 | 15 | def self.from_workers(workers) 16 | process_array = [] 17 | Invoker.config.processes.each do |process| 18 | worker_attrs = { 19 | shell_command: process.cmd, 20 | process_name: process.label, 21 | dir: process.dir, 22 | port: process.port 23 | } 24 | if worker = workers[process.label] 25 | worker_attrs.update(pid: worker.pid) 26 | end 27 | process_array << worker_attrs 28 | end 29 | 30 | new(processes: process_array) 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/invoker/ipc/dns_check_command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Invoker::IPC::DnsCheckCommand do 4 | let(:client_socket) { StringIO.new } 5 | let(:client) { Invoker::IPC::ClientHandler.new(client_socket) } 6 | 7 | describe "dns check for valid process" do 8 | let(:message_object) { MM::DnsCheck.new(process_name: 'lolbro') } 9 | it "should response with dns check response" do 10 | invoker_dns_cache.expects(:[]).returns('port' => 9000) 11 | client_socket.string = message_object.encoded_message 12 | 13 | client.read_and_execute 14 | 15 | dns_check_response = client_socket.string 16 | expect(dns_check_response).to match(/9000/) 17 | end 18 | end 19 | 20 | describe "dns check for invalid process" do 21 | let(:message_object) { MM::DnsCheck.new(process_name: 'foo') } 22 | it "should response with dns check response" do 23 | invoker_dns_cache.expects(:[]).returns('port' => nil) 24 | client_socket.string = message_object.encoded_message 25 | 26 | client.read_and_execute 27 | 28 | dns_check_response = client_socket.string 29 | expect(dns_check_response).to match(/null/) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Invoker is a gem for managing processes in development environment. 2 | 3 | [![Build Status](https://travis-ci.org/code-mancers/invoker.svg)](https://travis-ci.org/code-mancers/invoker) 4 | [![Code Climate](https://codeclimate.com/github/code-mancers/invoker.svg)](https://codeclimate.com/github/code-mancers/invoker) 5 | [![Coverage Status](https://coveralls.io/repos/code-mancers/invoker/badge.svg)](https://coveralls.io/r/code-mancers/invoker) 6 | 7 | ## Usage ## 8 | 9 | First we need to install `invoker` gem to get command line utility called `invoker`, we can do that via: 10 | 11 | gem install invoker 12 | 13 | Currently it only works with Ruby 2.0, 2.1, 2.2, and 2.3. 14 | 15 | ## Manual ## 16 | 17 | Information about configuring and using Invoker can be found on - [Invoker Website](https://invoker.c9s.dev/) 18 | 19 | Invoker documentation is maintained via `Jekyll` and hosted on `github`. If you would like to fix an error 20 | or update something - please submit a pull request against `gh-pages` branch of `Invoker`. 21 | 22 | ## Bug reports and Feature requests 23 | 24 | Please use [Github Issue Tracker](https://github.com/code-mancers/invoker/issues) for feature requests or bug reports. 25 | -------------------------------------------------------------------------------- /lib/invoker/power/url_rewriter.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module Power 3 | class UrlRewriter 4 | def select_backend_config(complete_path) 5 | possible_matches = extract_host_from_domain(complete_path) 6 | exact_match = nil 7 | possible_matches.each do |match| 8 | if match 9 | exact_match = dns_check(process_name: match) 10 | break if exact_match.port 11 | end 12 | end 13 | exact_match 14 | end 15 | 16 | def extract_host_from_domain(complete_path) 17 | matching_strings = [] 18 | tld_match_regex.map do |regexp| 19 | if (match_result = complete_path.match(regexp)) 20 | matching_strings << match_result[1] 21 | end 22 | end 23 | matching_strings.uniq 24 | end 25 | 26 | private 27 | 28 | def tld_match_regex 29 | tld = Invoker.config.tld 30 | [/([\w.-]+)\.#{tld}(\:\d+)?$/, /([\w-]+)\.#{tld}(\:\d+)?$/] 31 | end 32 | 33 | def dns_check(dns_args) 34 | Invoker::IPC::UnixClient.send_command("dns_check", dns_args) do |dns_response| 35 | dns_response 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/invoker/version.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | class Version 3 | include Comparable 4 | attr_reader :major, :minor, :patch 5 | 6 | def initialize(number) 7 | t_major, t_minor, t_patch = number.split('.') 8 | @major = t_major.to_i 9 | @minor = t_minor.to_i 10 | @patch = t_patch.to_i 11 | end 12 | 13 | def to_a 14 | [major, minor, patch].compact 15 | end 16 | 17 | def <=>(version) 18 | (major.to_i <=> version.major.to_i).nonzero? || 19 | (minor.to_i <=> version.minor.to_i).nonzero? || 20 | patch.to_i <=> version.patch.to_i 21 | end 22 | 23 | def matches?(operator, number) 24 | version = Version.new(number) 25 | self == version 26 | 27 | return self == version if operator == '=' 28 | return self > version if operator == '>' 29 | return self < version if operator == '<' 30 | return version <= self && version.next > self if operator == '~>' 31 | end 32 | 33 | def next 34 | next_splits = to_a 35 | 36 | if next_splits.length == 1 37 | next_splits[0] += 1 38 | else 39 | next_splits[-2] += 1 40 | next_splits[-1] = 0 41 | end 42 | 43 | Version.new(next_splits.join('.')) 44 | end 45 | end 46 | VERSION = "2.0.0" 47 | end 48 | -------------------------------------------------------------------------------- /spec/invoker/power/http_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Invoker::Power::HttpParser do 4 | let(:parser) { Invoker::Power::HttpParser.new('https') } 5 | 6 | describe "complete message received" do 7 | before { parser.reset } 8 | it "should call header received with full header" do 9 | @header = nil 10 | parser.on_headers_complete { |header| @header = header } 11 | parser << "HTTP/1.1 200 OK\r\n" 12 | parser << "Content-Type: text/plain;charset=utf-8\r\n" 13 | parser << "Content-Length: 5\r\n" 14 | parser << "Connection: close\r\n\r\n" 15 | parser << "hello" 16 | 17 | expect(@header['Content-Type']).to eql "text/plain;charset=utf-8" 18 | end 19 | 20 | it "should return complete message with x_forwarded added" do 21 | complete_message = nil 22 | parser.on_message_complete { |message| complete_message = message } 23 | parser.on_headers_complete { |header| @header = header } 24 | parser << "HTTP/1.1 200 OK\r\n" 25 | parser << "Content-Type: text/plain;charset=utf-8\r\n" 26 | parser << "Content-Length: 5\r\n" 27 | parser << "Connection: close\r\n\r\n" 28 | parser << "hello" 29 | expect(complete_message).to match(/X_FORWARDED_PROTO:/i) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/invoker/power/port_finder.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module Power 3 | class PortFinder 4 | STARTING_PORT = 23400 5 | attr_accessor :dns_port, :http_port, :starting_port, :https_port 6 | def initialize 7 | @starting_port = STARTING_PORT 8 | @ports = [] 9 | @dns_port = nil 10 | @http_port = nil 11 | end 12 | 13 | def find_ports 14 | STARTING_PORT.upto(STARTING_PORT + 100) do |port| 15 | break if @ports.size > 3 16 | if check_if_port_is_open(port) 17 | @ports << port 18 | else 19 | next 20 | end 21 | end 22 | @dns_port = @ports[0] 23 | @http_port = @ports[1] 24 | @https_port = @ports[2] 25 | end 26 | 27 | private 28 | 29 | def check_if_port_is_open(port) 30 | socket_flag = true 31 | sockets = nil 32 | begin 33 | sockets = Socket.tcp_server_sockets(port) 34 | socket_flag = false if sockets.size <= 1 35 | rescue Errno::EADDRINUSE 36 | socket_flag = false 37 | end 38 | sockets && close_socket_pairs(sockets) 39 | socket_flag 40 | end 41 | 42 | def close_socket_pairs(sockets) 43 | sockets.each { |s| s.close } 44 | rescue 45 | nil 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/invoker/cli/tail_watcher_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Invoker::CLI::TailWatcher do 4 | let(:tail_watcher) { Invoker::CLI::TailWatcher.new } 5 | 6 | describe "Adding processes to watch list" do 7 | it "should allow add" do 8 | tail_watcher.add(["rails"], "socket") 9 | expect(tail_watcher.tail_watchers).to_not be_empty 10 | expect(tail_watcher["rails"]).to eql ["socket"] 11 | end 12 | end 13 | 14 | describe "removing processes from watch list" do 15 | context "when process has only one watcher" do 16 | before do 17 | tail_watcher.add(["rails"], "socket") 18 | end 19 | it "should remove and purge process watch list" do 20 | expect(tail_watcher.tail_watchers).to_not be_empty 21 | tail_watcher.remove("rails", "socket") 22 | expect(tail_watcher.tail_watchers).to be_empty 23 | end 24 | end 25 | context "when process multiple watchers" do 26 | before do 27 | tail_watcher.add(["rails"], "socket") 28 | tail_watcher.add(["rails"], "socket2") 29 | end 30 | 31 | it "should remove only related socket" do 32 | expect(tail_watcher.tail_watchers).to_not be_empty 33 | tail_watcher.remove("rails", "socket") 34 | expect(tail_watcher.tail_watchers).to_not be_empty 35 | expect(tail_watcher["rails"]).to eql ["socket2"] 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/invoker/ipc.rb: -------------------------------------------------------------------------------- 1 | require "invoker/ipc/base_command" 2 | require 'invoker/ipc/message' 3 | require 'invoker/ipc/add_command' 4 | require 'invoker/ipc/add_http_command' 5 | require 'invoker/ipc/client_handler' 6 | require 'invoker/ipc/dns_check_command' 7 | require 'invoker/ipc/list_command' 8 | require 'invoker/ipc/remove_command' 9 | require 'invoker/ipc/server' 10 | require "invoker/ipc/reload_command" 11 | require 'invoker/ipc/tail_command' 12 | require 'invoker/ipc/unix_client' 13 | require "invoker/ipc/ping_command" 14 | 15 | module Invoker 16 | module IPC 17 | INITIAL_PACKET_SIZE = 9 18 | def self.message_from_io(io) 19 | json_size = io.read(INITIAL_PACKET_SIZE) 20 | json_string = io.read(json_size.to_i) 21 | ruby_object_hash = JSON.parse(json_string) 22 | command_name = camelize(ruby_object_hash['type']) 23 | command_klass = Invoker::IPC::Message.const_get(command_name) 24 | command_klass.new(ruby_object_hash) 25 | end 26 | 27 | # Taken from Rails without inflection support 28 | def self.camelize(term) 29 | string = term.to_s 30 | string = string.sub(/^[a-z\d]*/) { $&.capitalize } 31 | string.gsub!(/(?:_|(\/))([a-z\d]*)/i) { "#{$1}#{$2.capitalize}" } 32 | string.gsub!('/', '::') 33 | string 34 | end 35 | 36 | def self.underscore(term) 37 | word = term.to_s.gsub('::', '/') 38 | word.gsub!(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2') 39 | word.gsub!(/([a-z\d])([A-Z])/,'\1_\2') 40 | word.tr!("-", "_") 41 | word.downcase! 42 | word 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/invoker/power/setup/distro/ubuntu.rb: -------------------------------------------------------------------------------- 1 | require "invoker/power/setup/distro/debian" 2 | 3 | module Invoker 4 | module Power 5 | module Distro 6 | class Ubuntu < Debian 7 | def using_systemd_resolved? 8 | return @_using_systemd_resolved if defined?(@_using_systemd_resolved) 9 | @_using_systemd_resolved = system("systemctl is-active --quiet systemd-resolved") 10 | end 11 | 12 | def install_required_software 13 | if using_systemd_resolved? 14 | # Don't install dnsmasq if Ubuntu version uses systemd-resolved for DNS because they conflict 15 | system("apt-get --assume-yes install socat") 16 | else 17 | super 18 | end 19 | end 20 | 21 | def install_packages 22 | using_systemd_resolved? ? "socat" : super 23 | end 24 | 25 | def install_other 26 | using_systemd_resolved? ? nil : super 27 | end 28 | 29 | def resolver_file 30 | using_systemd_resolved? ? nil : super 31 | end 32 | 33 | def tld 34 | using_systemd_resolved? ? 'localhost' : @tld 35 | end 36 | 37 | def get_user_confirmation? 38 | if using_systemd_resolved? && tld != 'localhost' 39 | Invoker::Logger.puts("Ubuntu installations using systemd-resolved (typically Ubuntu 17+) only support the .localhost domain, so your tld setting (or the default) will be ignored.".colorize(:yellow)) 40 | end 41 | super 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/invoker/command_worker_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Command Worker" do 4 | let(:pipe_end) { StringIO.new } 5 | let(:command_worker) { Invoker::CommandWorker.new('rails', pipe_end, 100, :red) } 6 | 7 | describe "converting workers hash to json" do 8 | before do 9 | @workers = {} 10 | @workers["foo"] = Invoker::CommandWorker.new("foo", 89, 1023, "red") 11 | @workers["bar"] = Invoker::CommandWorker.new("bar", 99, 1024, "blue") 12 | end 13 | 14 | it "should print json" do 15 | expect(@workers.values.map {|worker| worker.to_h }.to_json).not_to be_empty 16 | end 17 | end 18 | 19 | describe "sending json responses" do 20 | before do 21 | @socket = StringIO.new 22 | Invoker.tail_watchers = Invoker::CLI::TailWatcher.new 23 | Invoker.tail_watchers.add(['rails'], @socket) 24 | end 25 | 26 | after do 27 | Invoker.tail_watchers = nil 28 | end 29 | 30 | context "when there is a error encoding the message" do 31 | it "should send nothing to the socket" do 32 | MM::TailResponse.any_instance.expects(:encoded_message).raises(StandardError, "encoding error") 33 | command_worker.receive_line('hello_world') 34 | expect(@socket.string).to be_empty 35 | end 36 | end 37 | 38 | context "when there is successful delivery" do 39 | it "should return json data to client if tail watchers" do 40 | command_worker.receive_line('hello_world') 41 | expect(@socket.string).to match(/hello_world/) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: shell 2 | 3 | services: 4 | - docker 5 | 6 | before_install: 7 | - docker run -v $PWD:/src "$DISTRO":latest /bin/sh -c "$INSTALL && gem install bundler --no-document --no-format-execut able && cd /src && bundle install && DISTRO=$DISTRO bundle exec rspec --tag docker" 8 | 9 | env: 10 | - DISTRO=archlinux INSTALL='pacman -Sy --noconfirm base-devel libffi ruby && PATH="$PATH:$(ruby -e "puts Gem.user_dir")/bin"' 11 | - DISTRO=debian INSTALL="apt-get update && apt-get install -y build-essential ruby ruby-dev" 12 | - DISTRO=fedora INSTALL='dnf install -y gcc-c++ make redhat-rpm-config ruby ruby-devel' 13 | - DISTRO=linuxmintd/mint20-amd64 INSTALL="apt-get update && apt-get install -y build-essential ruby ruby-dev" 14 | - DISTRO=manjarolinux/base INSTALL='pacman -Sy --noconfirm base-devel libffi ruby && PATH="$PATH:$(ruby -e "puts Gem.user_dir")/bin"' 15 | - DISTRO=opensuse/leap INSTALL='zypper install -y gcc-c++ make ruby ruby-devel' 16 | - DISTRO=opensuse/tumbleweed INSTALL='zypper install -y gcc-c++ make ruby ruby-devel' 17 | - DISTRO=ubuntu INSTALL="apt-get update && apt-get install -y build-essential ruby ruby-dev" 18 | 19 | jobs: 20 | include: 21 | - &shared 22 | language: ruby 23 | rvm: ruby # Latest stable version 24 | script: bundle exec rspec --tag ~docker # Don't run specs with docker: true 25 | cache: bundler 26 | # Clear out values that were set above specifically for docker runs 27 | env: 28 | before_install: 29 | services: 30 | - <<: *shared 31 | os: linux 32 | - <<: *shared 33 | os: osx 34 | -------------------------------------------------------------------------------- /spec/invoker/ipc/message_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Invoker::IPC::Message do 4 | describe "test equality of objects" do 5 | context "for simple messages" do 6 | let(:message) { MM::Add.new(process_name: 'foo') } 7 | 8 | it "object should be reported same if same value" do 9 | m2 = MM::Add.new(process_name: 'foo') 10 | expect(message).to eql m2 11 | end 12 | 13 | it "should report objects to be not eql if differnt value" do 14 | m2 = MM::Add.new(process_name: 'bar') 15 | expect(message).to_not eql m2 16 | end 17 | end 18 | 19 | context "for nested messages" do 20 | let(:process_array) do 21 | [ 22 | { shell_command: 'foo', process_name: 'foo', dir: '/tmp', pid: 100, 23 | port: 9000 }, 24 | { shell_command: 'bar', process_name: 'bar', dir: '/tmp', pid: 200, 25 | port: 9001 } 26 | ] 27 | end 28 | 29 | let(:message) { MM::ListResponse.new(processes: process_array) } 30 | 31 | it "should report eql for eql objects" do 32 | m2 = MM::ListResponse.new(processes: process_array) 33 | expect(message).to eql m2 34 | end 35 | 36 | it "should report not equal for different objects" do 37 | another_process_array = [ 38 | { shell_command: 'baz', process_name: 'foo', dir: '/tmp', pid: 100, 39 | port: 9000 }, 40 | { shell_command: 'bar', process_name: 'bar', dir: '/tmp', pid: 200, 41 | port: 9001 } 42 | ] 43 | 44 | m2 = MM::ListResponse.new(processes: another_process_array) 45 | expect(message).to_not eql m2 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/invoker/power/web_sockets_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # Full integration test. Start a server, and client. Let client interact with 4 | # server do a ping-pong. Client checks whether ping pong is successful or not. 5 | # Also, mock rewriter so that it returns valid port for request proxying. 6 | # - Server will run on port 28080. 7 | # - Balancer will run on port 28081 proxying to 28080 8 | # - Client will connect to 28081 performing ping-pong 9 | 10 | def websocket_server 11 | require 'websocket-eventmachine-server' 12 | 13 | EM.run do 14 | WebSocket::EventMachine::Server.start(host: "0.0.0.0", port: 28080) do |ws| 15 | ws.onerror { |e| p e } 16 | ws.onmessage { ws.send "pong" } 17 | end 18 | 19 | EM.add_timer(2) { EM.stop } 20 | end 21 | end 22 | 23 | def websocket_client 24 | require 'websocket-eventmachine-client' 25 | 26 | @message = "" 27 | 28 | EM.run do 29 | ws = WebSocket::EventMachine::Client.connect(uri: 'ws://0.0.0.0:28081') 30 | ws.onerror { |e| p e } 31 | ws.onopen { ws.send("ping") } 32 | ws.onmessage { |m, _| @message = m } 33 | 34 | EM.add_timer(2) do 35 | expect(@message).to eq "pong" 36 | EM.stop 37 | end 38 | end 39 | end 40 | 41 | 42 | describe 'Web sockets support' do 43 | it 'can ping pong via balancer' do 44 | dns_response = Struct.new(:port, :ip).new(28080, "0.0.0.0") 45 | Invoker::Power::UrlRewriter.any_instance 46 | .stubs(:select_backend_config) 47 | .returns(dns_response) 48 | 49 | EM.run do 50 | EM.start_server("0.0.0.0", 28081, EM::ProxyServer::Connection, {}) do |conn| 51 | Invoker::Power::Balancer.new(conn, "http").install_callbacks 52 | end 53 | 54 | fork { websocket_server } 55 | fork { websocket_client } 56 | EM.add_timer(3) { EM.stop } 57 | end 58 | 59 | Process.waitall 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/invoker/reactor/reader.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | class Reactor::Reader 3 | attr_accessor :read_array 4 | 5 | def initialize 6 | @read_array = [] 7 | end 8 | 9 | def watch_for_read(socket) 10 | @read_array << socket 11 | end 12 | 13 | def handle_read_event(read_ready_fds) 14 | ready_fds = read_ready_fds.flatten.compact 15 | ready_fds.each { |ready_fd| process_read(ready_fd) } 16 | end 17 | 18 | private 19 | 20 | def process_read(ready_fd) 21 | command_worker = Invoker.commander.get_worker_from_fd(ready_fd) 22 | begin 23 | data = read_data(ready_fd) 24 | send_data_to_worker(data, command_worker) 25 | rescue Invoker::Errors::ProcessTerminated 26 | remove_from_read_monitoring(command_worker, ready_fd) 27 | end 28 | end 29 | 30 | def send_data_to_worker(data, command_worker) 31 | if command_worker 32 | command_worker.receive_data(data) 33 | else 34 | Invoker::Logger.puts("No reader found for incoming data") 35 | end 36 | end 37 | 38 | def remove_from_read_monitoring(command_worker, ready_fd) 39 | if command_worker 40 | read_array.delete(command_worker.pipe_end) 41 | command_worker.unbind 42 | else 43 | read_array.delete(ready_fd) 44 | end 45 | rescue StandardError => error 46 | Invoker::Logger.puts(error.message) 47 | Invoker::Logger.puts(error.backtrace) 48 | end 49 | 50 | def read_data(ready_fd) 51 | sock_data = [] 52 | begin 53 | while(t_data = ready_fd.read_nonblock(64)) 54 | sock_data << t_data 55 | end 56 | rescue Errno::EAGAIN 57 | return sock_data.join 58 | rescue Errno::EWOULDBLOCK 59 | return sock_data.join 60 | rescue 61 | raise Invoker::Errors::ProcessTerminated.new(ready_fd,sock_data.join) 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/invoker/ipc/client_handler_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Invoker::IPC::ClientHandler do 4 | let(:client_socket) { StringIO.new } 5 | let(:client) { Invoker::IPC::ClientHandler.new(client_socket) } 6 | 7 | describe "add command" do 8 | let(:message_object) { MM::Add.new(process_name: 'foo') } 9 | it "should run if read from socket" do 10 | invoker_commander.expects(:on_next_tick).with("foo") 11 | client_socket.string = message_object.encoded_message 12 | 13 | client.read_and_execute 14 | end 15 | end 16 | 17 | describe "remove command" do 18 | it "with specific signal" do 19 | message_object = MM::Remove.new(process_name: 'foo', signal: 'INT') 20 | invoker_commander.expects(:on_next_tick) 21 | client_socket.string = message_object.encoded_message 22 | 23 | client.read_and_execute 24 | end 25 | 26 | it "with default signal" do 27 | message_object = MM::Remove.new(process_name: 'foo') 28 | invoker_commander.expects(:on_next_tick) 29 | client_socket.string = message_object.encoded_message 30 | 31 | client.read_and_execute 32 | end 33 | end 34 | 35 | describe "add_http command" do 36 | let(:message_object) { MM::AddHttp.new(process_name: 'foo', port: 9000)} 37 | it "adds the process name and port to dns cache" do 38 | invoker_dns_cache.expects(:add).with('foo', 9000, nil) 39 | client_socket.string = message_object.encoded_message 40 | 41 | client.read_and_execute 42 | end 43 | end 44 | 45 | describe "add_http command with optional ip" do 46 | let(:message_object) { MM::AddHttp.new(process_name: 'foo', port: 9000, ip: '192.168.0.1')} 47 | it "adds the process name, port and host ip to dns cache" do 48 | invoker_dns_cache.expects(:add).with('foo', 9000, '192.168.0.1') 49 | client_socket.string = message_object.encoded_message 50 | 51 | client.read_and_execute 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /contrib/completion/invoker-completion.zsh: -------------------------------------------------------------------------------- 1 | #compdef invoker 2 | 3 | # ZSH completion function for Invoker 4 | # 5 | # Drop this file somewhere in your $fpath 6 | # and rename it _invoker 7 | # 8 | # The recommended way to install this script is to copy to '~/.zsh/_invoker' 9 | # and then add the following to your ~/.zshrc file: 10 | # 11 | # fpath=(~/.zsh $fpath) 12 | # 13 | # You may also need to force rebuild 'zcompdump': 14 | # 15 | # rm -f ~/.zcompdump*; compinit 16 | 17 | local curcontext="$curcontext" state line ret=1 18 | 19 | _arguments -C \ 20 | '1: :->cmds' \ 21 | '*:: :->args' && ret=0 22 | 23 | case $state in 24 | cmds) 25 | _values 'invoker command' \ 26 | 'add[Add a program to Invoker server]' \ 27 | 'add_http[Add an external process to Invoker DNS server]' \ 28 | 'help[Describe available commands or one specific command]' \ 29 | 'list[List all running processes]' \ 30 | 'reload[Reload a process managed by Invoker]' \ 31 | 'remove[Stop a process managed by Invoker]' \ 32 | 'setup[Run Invoker setup]' \ 33 | 'start[Start Invoker server]' \ 34 | 'stop[Stop Invoker daemon]' \ 35 | 'tail[Tail a particular process]' \ 36 | 'uninstall[Uninstall Invoker and all installed files]' \ 37 | 'version[Print Invoker version]' 38 | ret=0 39 | ;; 40 | 41 | args) 42 | case $line[1] in 43 | help) 44 | if (( CURRENT == 2 )); then 45 | _values 'commands' \ 46 | 'add' 'add_http' 'list' 'reload' 'remove' 'setup' 'start' 'stop' 'tail' 'uninstall' 'version' 47 | fi 48 | ret=0 49 | ;; 50 | 51 | start) 52 | _arguments \ 53 | '(-d --daemon)'{-d,--daemon}'[Daemonize the server into the background]' \ 54 | '(--port)--port[Port series to be used for starting rack servers]' \ 55 | '1:config file:_path_files' 56 | ret=0 57 | ;; 58 | esac 59 | ;; 60 | esac 61 | 62 | return ret 63 | -------------------------------------------------------------------------------- /spec/invoker/power/balancer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Invoker::Power::Balancer do 4 | before do 5 | @http_connection = mock("connection") 6 | @balancer = Invoker::Power::Balancer.new(@http_connection, "http") 7 | end 8 | 9 | context "when Host field is not capitalized" do 10 | before(:all) do 11 | @original_invoker_config = Invoker.config 12 | end 13 | 14 | def mock_invoker_tld_as(domain) 15 | Invoker.config = mock 16 | Invoker.config.stubs(:tld).returns(domain) 17 | end 18 | 19 | after(:all) do 20 | Invoker.config = @original_invoker_config 21 | end 22 | 23 | it "should not return 400 when host is lowercase" do 24 | headers = { 'host' => 'somehost.com' } 25 | mock_invoker_tld_as('test') 26 | @http_connection.expects(:send_data).with() { |value| value =~ /404 Not Found/i } 27 | @http_connection.expects(:close_connection_after_writing) 28 | @balancer.headers_received(headers) 29 | end 30 | 31 | it "should not return 400 when host is written as HoSt" do 32 | headers = { 'HoSt' => 'somehost.com' } 33 | mock_invoker_tld_as('test') 34 | @http_connection.expects(:send_data).with() { |value| value =~ /404 Not Found/i } 35 | @http_connection.expects(:close_connection_after_writing) 36 | @balancer.headers_received(headers) 37 | end 38 | end 39 | 40 | context "when Host field is missing in the request" do 41 | it "should return 400 as response when Host is missing" do 42 | headers = {} 43 | @http_connection.expects(:send_data).with() { |value| value =~ /400 Bad Request/i } 44 | @balancer.headers_received(headers) 45 | end 46 | 47 | it "should return 400 as response when Host is empty" do 48 | headers = { 'Host' => '' } 49 | @http_connection.expects(:send_data).with() { |value| value =~ /400 Bad Request/i } 50 | @balancer.headers_received(headers) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/invoker/command_worker.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | class CommandWorker 3 | attr_accessor :command_label, :pipe_end, :pid, :color 4 | 5 | def initialize(command_label, pipe_end, pid, color) 6 | @command_label = command_label 7 | @pipe_end = pipe_end 8 | @pid = pid 9 | @color = color 10 | end 11 | 12 | # Copied verbatim from Eventmachine code 13 | def receive_data data 14 | (@buf ||= '') << data 15 | 16 | while @buf.slice!(/(.*?)\r?\n/) 17 | receive_line($1) 18 | end 19 | end 20 | 21 | def unbind 22 | Invoker::Logger.print(".") 23 | end 24 | 25 | # Print the lines received over the network 26 | def receive_line(line) 27 | tail_watchers = Invoker.tail_watchers[@command_label] 28 | color_line = "#{@command_label.colorize(color)} : #{line}" 29 | plain_line = "#{@command_label} : #{line}" 30 | if Invoker.nocolors? 31 | Invoker::Logger.puts plain_line 32 | else 33 | Invoker::Logger.puts color_line 34 | end 35 | if tail_watchers && !tail_watchers.empty? 36 | json_encoded_tail_response = tail_response(color_line) 37 | if json_encoded_tail_response 38 | tail_watchers.each { |tail_socket| send_data(tail_socket, json_encoded_tail_response) } 39 | end 40 | end 41 | end 42 | 43 | def to_h 44 | { command_label: command_label, pid: pid.to_s } 45 | end 46 | 47 | def send_data(socket, data) 48 | socket.write(data) 49 | rescue 50 | Invoker::Logger.puts "Removing #{@command_label} watcher #{socket} from list" 51 | Invoker.tail_watchers.remove(@command_label, socket) 52 | end 53 | 54 | private 55 | 56 | # Encode current line as json and send the response. 57 | def tail_response(line) 58 | tail_response = Invoker::IPC::Message::TailResponse.new(tail_line: line) 59 | tail_response.encoded_message 60 | rescue 61 | nil 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /invoker.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | GEM_NAME = "invoker" 4 | 5 | lib = File.expand_path("../lib", __FILE__) 6 | $: << lib unless $:.include?(lib) 7 | 8 | require "invoker/version" 9 | 10 | Gem::Specification.new do |s| 11 | s.name = GEM_NAME 12 | s.version = Invoker::VERSION 13 | 14 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 15 | s.required_ruby_version = '>= 3.0.0' 16 | s.authors = ["Hemant Kumar", "Amitava Basak"] 17 | s.description = %q{Something small for process management} 18 | s.email = %q{hemant@codemancers.com} 19 | 20 | s.files = Dir.glob("lib/**/*") 21 | s.test_files = Dir.glob("spec/**/*") 22 | s.executables = Dir.glob("bin/*").map{ |f| File.basename(f) } 23 | s.require_paths = ["lib"] 24 | 25 | s.homepage = %q{https://invoker.c9s.dev} 26 | s.licenses = ["MIT"] 27 | s.require_paths = ["lib"] 28 | s.summary = %q{Something small for Process management} 29 | 30 | s.metadata = { 31 | "bug_tracker_uri" => "https://github.com/code-mancers/invoker/issues", 32 | "changelog_uri" => "https://github.com/code-mancers/invoker/blob/master/CHANGELOG.md", 33 | "documentation_uri" => "https://invoker.c9s.dev/", 34 | "source_code_uri" => "https://github.com/code-mancers/invoker/tree/v#{Invoker::VERSION}", 35 | } 36 | 37 | s.add_dependency("thor", ">= 0.19", "< 2") 38 | s.add_dependency("colorize", "~> 0.8.1") 39 | s.add_dependency("iniparse", "~> 1.1") 40 | s.add_dependency("formatador", "~> 0.2") 41 | s.add_dependency("eventmachine", "~> 1.0.4") 42 | s.add_dependency("em-proxy", "~> 0.1") 43 | s.add_dependency("rubydns", "~> 0.8.5") 44 | s.add_dependency("uuid", "~> 2.3") 45 | s.add_dependency("http-parser-lite", "~> 1.0") 46 | s.add_dependency("dotenv", "~> 2.0", "!= 2.3.0", "!= 2.4.0") 47 | s.add_development_dependency("rspec", "~> 3.0") 48 | s.add_development_dependency("mocha") 49 | s.add_development_dependency("rake") 50 | s.add_development_dependency('fakefs') 51 | end 52 | -------------------------------------------------------------------------------- /lib/invoker/power/config.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | module Invoker 4 | module Power 5 | # Save and Load Invoker::Power config 6 | class ConfigExists < StandardError; end 7 | 8 | class Config 9 | def self.has_config? 10 | File.exist?(config_file) 11 | end 12 | 13 | def self.create(options = {}) 14 | if has_config? 15 | raise ConfigExists, "Config file already exists at location #{config_file}" 16 | end 17 | config = new(options) 18 | config.save 19 | end 20 | 21 | def self.delete 22 | if File.exist?(config_file) 23 | File.delete(config_file) 24 | end 25 | end 26 | 27 | def self.config_file 28 | File.join(Invoker.home, ".invoker", "config") 29 | end 30 | 31 | def self.config_dir 32 | File.join(Invoker.home, ".invoker") 33 | end 34 | 35 | def initialize(options = {}) 36 | @config = options 37 | end 38 | 39 | def self.load_config 40 | config_hash = File.open(config_file, "r") { |fl| YAML.load(fl) } 41 | new(config_hash) 42 | end 43 | 44 | def dns_port=(dns_port) 45 | @config[:dns_port] = dns_port 46 | end 47 | 48 | def http_port=(http_port) 49 | @config[:http_port] = http_port 50 | end 51 | 52 | def https_port=(https_port) 53 | @config[:https_port] = https_port 54 | end 55 | 56 | def ipfw_rule_number=(ipfw_rule_number) 57 | @config[:ipfw_rule_number] = ipfw_rule_number 58 | end 59 | 60 | def dns_port; @config[:dns_port]; end 61 | def http_port; @config[:http_port]; end 62 | def ipfw_rule_number; @config[:ipfw_rule_number]; end 63 | def https_port; @config[:https_port]; end 64 | 65 | def tld 66 | @config[:tld] || Invoker.default_tld 67 | end 68 | 69 | def save 70 | File.open(self.class.config_file, "w") do |fl| 71 | YAML.dump(@config, fl) 72 | end 73 | self 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/invoker/process_printer.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | class ProcessPrinter 3 | MAX_COLUMN_WIDTH = 40 4 | attr_accessor :list_response 5 | 6 | def initialize(list_response) 7 | self.list_response = list_response 8 | end 9 | 10 | def print_table 11 | hash_with_colors = [] 12 | list_response.processes.each do |process| 13 | if process.pid 14 | hash_with_colors << colorize_hash(process, "green") 15 | else 16 | hash_with_colors << colorize_hash(process, "light_black") 17 | end 18 | end 19 | Formatador.display_compact_table(hash_with_colors) 20 | end 21 | 22 | def print_raw_text 23 | list_response.processes.each do |process| 24 | Formatador.display_line("[bold]Process Name : #{process.process_name}[/]") 25 | Formatador.indent { 26 | Formatador.display_line("Dir : #{process.dir}") 27 | if process.pid 28 | Formatador.display_line("PID : #{process.pid}") 29 | else 30 | Formatador.display_line("PID : Not Running") 31 | end 32 | Formatador.display_line("Port : #{process.port}") 33 | Formatador.display_line("Command : #{process.shell_command}") 34 | } 35 | end 36 | end 37 | 38 | private 39 | 40 | def colorize_hash(process, color) 41 | hash_with_colors = {} 42 | 43 | hash_with_colors['dir'] = colored_string(process.dir, color) 44 | hash_with_colors['pid'] = colored_string(process.pid || 'Not Running', color) 45 | hash_with_colors['port'] = colored_string(process.port, color) 46 | hash_with_colors['shell_command'] = colored_string(process.shell_command, color) 47 | hash_with_colors['process_name'] = colored_string(process.process_name, color) 48 | hash_with_colors 49 | end 50 | 51 | def colored_string(string, color) 52 | string = string.to_s 53 | if string.length > MAX_COLUMN_WIDTH 54 | string = "#{string[0..MAX_COLUMN_WIDTH]}.." 55 | end 56 | "[#{color}]#{string}[/]" 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/invoker/power/url_rewriter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Invoker::Power::UrlRewriter do 4 | let(:rewriter) { Invoker::Power::UrlRewriter.new } 5 | 6 | before(:all) do 7 | @original_invoker_config = Invoker.config 8 | end 9 | 10 | def mock_invoker_tld_as(domain) 11 | Invoker.config = mock 12 | Invoker.config.stubs(:tld).returns(domain) 13 | end 14 | 15 | after(:all) do 16 | Invoker.config = @original_invoker_config 17 | end 18 | 19 | context "matching domain part of incoming request" do 20 | before(:each) do 21 | mock_invoker_tld_as("test") 22 | end 23 | 24 | it "should match foo.test" do 25 | match = rewriter.extract_host_from_domain("foo.test") 26 | expect(match).to_not be_empty 27 | 28 | matching_string = match[0] 29 | expect(matching_string).to eq("foo") 30 | end 31 | 32 | it "should match foo.test:1080" do 33 | match = rewriter.extract_host_from_domain("foo.test:1080") 34 | expect(match).to_not be_empty 35 | 36 | matching_string = match[0] 37 | expect(matching_string).to eq("foo") 38 | end 39 | 40 | it "should match emacs.bar.test" do 41 | match = rewriter.extract_host_from_domain("emacs.bar.test") 42 | expect(match).to_not be_empty 43 | 44 | expect(match[0]).to eq("emacs.bar") 45 | expect(match[1]).to eq("bar") 46 | end 47 | 48 | it "should match hello-world.test" do 49 | match = rewriter.extract_host_from_domain("hello-world.test") 50 | expect(match).to_not be_nil 51 | 52 | expect(match[0]).to eq("hello-world") 53 | end 54 | 55 | context 'user sets up a custom top level domain' do 56 | before(:each) do 57 | mock_invoker_tld_as("local") 58 | end 59 | 60 | it 'should match domain part of incoming request correctly' do 61 | match = rewriter.extract_host_from_domain("foo.local") 62 | expect(match).to_not be_empty 63 | 64 | matching_string = match[0] 65 | expect(matching_string).to eq("foo") 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/invoker/ipc/unix_client.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | class UnixClient 4 | def send_command(command, message = {}) 5 | message_object = get_message_object(command, message) 6 | open_client_socket do |socket| 7 | send_json_message(socket, message_object) 8 | socket.flush 9 | if block_given? 10 | response_object = Invoker::IPC.message_from_io(socket) 11 | yield response_object 12 | end 13 | end 14 | end 15 | 16 | def send_and_receive(command, message = {}) 17 | response = nil 18 | message_object = get_message_object(command, message) 19 | open_client_socket(false) do |socket| 20 | send_json_message(socket, message_object) 21 | socket.flush 22 | response = Invoker::IPC.message_from_io(socket) 23 | end 24 | response 25 | end 26 | 27 | def send_and_wait(command, message = {}) 28 | begin 29 | socket = Socket.unix(Invoker::IPC::Server::SOCKET_PATH) 30 | rescue 31 | abort("Invoker does not seem to be running".colorize(:red)) 32 | end 33 | message_object = get_message_object(command, message) 34 | send_json_message(socket, message_object) 35 | socket.flush 36 | socket 37 | end 38 | 39 | def self.send_command(command, message_arguments = {}, &block) 40 | new.send_command(command, message_arguments, &block) 41 | end 42 | 43 | private 44 | 45 | def get_message_object(command, message_arguments) 46 | Invoker::IPC::Message.const_get(Invoker::IPC.camelize(command)).new(message_arguments) 47 | end 48 | 49 | def open_client_socket(abort_if_not_running = true) 50 | Socket.unix(Invoker::IPC::Server::SOCKET_PATH) { |socket| yield socket } 51 | rescue 52 | abort_if_not_running && abort("Invoker does not seem to be running".colorize(:red)) 53 | end 54 | 55 | def send_json_message(socket, message_object) 56 | socket.write(message_object.encoded_message) 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/invoker/power/http_parser.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module Power 3 | class HttpParser 4 | attr_accessor :host, :parser, :protocol 5 | 6 | def initialize(protocol) 7 | @protocol = protocol 8 | @parser = HTTP::Parser.new 9 | @header = {} 10 | initialize_message_content 11 | parser.on_headers_complete { complete_headers_received } 12 | parser.on_header_field { |field_name| @last_key = field_name } 13 | parser.on_header_value { |field_value| header_value_received(field_value) } 14 | 15 | parser.on_message_complete { complete_message_received } 16 | end 17 | 18 | # define a callback for invoking when complete header is parsed 19 | def on_headers_complete(&block) 20 | @on_headers_complete_callback = block 21 | end 22 | 23 | def header_value_received(value) 24 | @header[@last_key] = value 25 | end 26 | 27 | # define a callback to invoke when a full http message is received 28 | def on_message_complete(&block) 29 | @on_message_complete_callback = block 30 | end 31 | 32 | def reset 33 | @header = {} 34 | initialize_message_content 35 | parser.reset 36 | end 37 | 38 | def << data 39 | @full_message.write(data) 40 | parser << data 41 | end 42 | 43 | private 44 | 45 | def complete_message_received 46 | full_message_string = @full_message.string.dup 47 | if full_message_string =~ /\r\n\r\n/ 48 | full_message_string.sub!(/\r\n\r\n/, "\r\nX_FORWARDED_PROTO: #{protocol}\r\n\r\n") 49 | end 50 | if @on_message_complete_callback 51 | @on_message_complete_callback.call(full_message_string) 52 | end 53 | end 54 | 55 | def initialize_message_content 56 | @full_message = StringIO.new 57 | @full_message.set_encoding('ASCII-8BIT') 58 | end 59 | 60 | # gets invoker when complete header is received 61 | def complete_headers_received 62 | if @on_headers_complete_callback 63 | @on_headers_complete_callback.call(@header) 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /contrib/completion/invoker-completion.bash: -------------------------------------------------------------------------------- 1 | # BASH completion function for Invoker 2 | 3 | # source it from bashrc 4 | # dependencies: 5 | # 1) netcat 6 | # 2) find 7 | 8 | check_open_port() 9 | { 10 | local port=$1 11 | if [[ $(which nc) ]]; then 12 | local open=$(nc -z -w2 localhost $port > /dev/null; echo $?) 13 | if [[ "$open" == "1" ]]; then 14 | COMPREPLY=( $(compgen -W "${port}" -- ${cur}) ) 15 | else 16 | check_open_port $(($port+1)) 17 | fi 18 | fi 19 | } 20 | _invoker() 21 | { 22 | local cur prev opts 23 | COMPREPLY=() 24 | cur="${COMP_WORDS[COMP_CWORD]}" 25 | prev="${COMP_WORDS[COMP_CWORD-1]}" 26 | opts="add add_http help list reload remove setup" 27 | opts="$opts start stop tail uninstall version" 28 | 29 | case "${prev}" in 30 | add | add_http | list | reload | remove | setup | stop | tail \ 31 | | uninstall | version) 32 | COMPREPLY=() 33 | ;; 34 | -d | --daemon | --no-daemon) 35 | local extra_opts=("--port") 36 | COMPREPLY=( $(compgen -W "${extra_opts}" -- ${cur}) ) 37 | ;; 38 | --port) 39 | # auto-suggest port 40 | check_open_port 9000 41 | ;; 42 | help) 43 | # Show opts again, but only once; don't infinitely recurse 44 | local prev2="${COMP_WORDS[COMP_CWORD-2]}" 45 | if [ "$prev2" == "help" ]; then 46 | COMPREPLY=() 47 | else 48 | COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) 49 | fi 50 | ;; 51 | start) 52 | local filename=$(find . -type f -name "*.ini") 53 | if [[ $filename ]]; then 54 | COMPREPLY=( $(compgen -W "${filename}" -- ${cur}) ) 55 | else 56 | COMPREPLY=() 57 | fi 58 | ;; 59 | *.ini) 60 | local start_opts="-d --daemon --no-daemon --port" 61 | COMPREPLY=( $(compgen -W "${start_opts}" -- ${cur}) ) 62 | ;; 63 | invoker) 64 | COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) 65 | ;; 66 | esac 67 | 68 | return 0 69 | } 70 | complete -F _invoker invoker 71 | -------------------------------------------------------------------------------- /spec/invoker/invoker_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Invoker" do 4 | describe "#darwin?" do 5 | it "should return true on osx" do 6 | Invoker.expects(:ruby_platform).returns("x86_64-darwin12.4.0") 7 | expect(Invoker.darwin?).to be_truthy 8 | end 9 | 10 | it "should return false on linux" do 11 | Invoker.expects(:ruby_platform).returns("i686-linux") 12 | expect(Invoker.darwin?).to be_falsey 13 | end 14 | end 15 | 16 | describe "#can_run_balancer?", fakefs: true do 17 | before { FileUtils.mkdir_p(Invoker::Power::Config.config_dir) } 18 | it "should return false if setup command was not run" do 19 | expect(Invoker.can_run_balancer?).to be_falsey 20 | end 21 | 22 | it "should return true if setup was run properly" do 23 | File.open(Invoker::Power::Config.config_file, "w") {|fl| 24 | fl.write("hello") 25 | } 26 | expect(Invoker.can_run_balancer?).to be_truthy 27 | end 28 | 29 | it "should not print warning if setup is not run when flag is false" do 30 | Invoker::Logger.expects(:puts).never() 31 | Invoker.can_run_balancer?(false) 32 | end 33 | end 34 | 35 | describe "#setup_config_location" do 36 | before do 37 | Dir.stubs(:home).returns('/tmp') 38 | @config_location = File.join('/tmp', '.invoker') 39 | FileUtils.rm_rf(@config_location) 40 | end 41 | 42 | context "when the old config file does not exist" do 43 | it "creates the new config directory" do 44 | Invoker.setup_config_location 45 | expect(Dir.exist?(@config_location)).to be_truthy 46 | end 47 | end 48 | 49 | context "when the old config file exists" do 50 | before do 51 | File.open(@config_location, 'w') do |file| 52 | file.write('invoker config') 53 | end 54 | end 55 | 56 | it "moves the file to the new directory" do 57 | Invoker.setup_config_location 58 | expect(Dir.exist?(@config_location)).to be_truthy 59 | new_config_file = File.join(@config_location, 'config') 60 | expect(File.exist?(new_config_file)).to be_truthy 61 | expect(File.read(new_config_file)).to match('invoker config') 62 | end 63 | end 64 | end 65 | 66 | describe "#home" do 67 | it "should return home directory using etc module" do 68 | expect(Invoker.home).to eql ENV['HOME'] 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/invoker/parsers/procfile.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module Parsers 3 | # rip off from foreman 4 | class Procfile 5 | # Initialize a Procfile 6 | # 7 | # @param [String] filename (nil) An optional filename to read from 8 | # 9 | def initialize(filename=nil) 10 | @entries = [] 11 | load(filename) if filename 12 | end 13 | 14 | # Yield each +Procfile+ entry in order 15 | # 16 | def entries 17 | return @entries unless block_given? 18 | @entries.each do |(name, command)| 19 | yield name, command 20 | end 21 | end 22 | 23 | # Retrieve a +Procfile+ command by name 24 | # 25 | # @param [String] name The name of the Procfile entry to retrieve 26 | # 27 | def [](name) 28 | @entries.detect { |n,c| name == n }.last 29 | end 30 | 31 | # Create a +Procfile+ entry 32 | # 33 | # @param [String] name The name of the +Procfile+ entry to create 34 | # @param [String] command The command of the +Procfile+ entry to create 35 | # 36 | def []=(name, command) 37 | delete name 38 | @entries << [name, command] 39 | end 40 | 41 | # Remove a +Procfile+ entry 42 | # 43 | # @param [String] name The name of the +Procfile+ entry to remove 44 | # 45 | def delete(name) 46 | @entries.reject! { |n,c| name == n } 47 | end 48 | 49 | # Load a Procfile from a file 50 | # 51 | # @param [String] filename The filename of the +Procfile+ to load 52 | # 53 | def load(filename) 54 | @entries.replace parse(filename) 55 | end 56 | 57 | # Save a Procfile to a file 58 | # 59 | # @param [String] filename Save the +Procfile+ to this file 60 | # 61 | def save(filename) 62 | File.open(filename, 'w') do |file| 63 | file.puts self.to_s 64 | end 65 | end 66 | 67 | # Get the +Procfile+ as a +String+ 68 | # 69 | def to_s 70 | @entries.map do |name, command| 71 | [ name, command ].join(": ") 72 | end.join("\n") 73 | end 74 | 75 | private 76 | 77 | def parse(filename) 78 | File.read(filename).gsub("\r\n","\n").split("\n").map do |line| 79 | if line =~ /^([A-Za-z0-9_-]+):\s*(.+)$/ 80 | [$1, $2] 81 | end 82 | end.compact 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/invoker/event/manager_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Invoker::Event::Manager do 4 | describe "Run scheduled events" do 5 | before do 6 | @event_manager = Invoker::Event::Manager.new() 7 | end 8 | 9 | it "should run matched events" do 10 | @event_manager.schedule_event("foo", :exit) { 'exit foo' } 11 | @event_manager.trigger("foo", :exit) 12 | 13 | @event_manager.run_scheduled_events do |event| 14 | expect(event.block.call).to eq("exit foo") 15 | end 16 | 17 | expect(@event_manager.scheduled_events).to be_empty 18 | expect(@event_manager.triggered_events).to be_empty 19 | end 20 | 21 | it "should remove triggrered and scheduld events on run" do 22 | @event_manager.schedule_event("foo", :exit) { 'exit foo' } 23 | @event_manager.schedule_event("bar", :entry) { "entry bar"} 24 | @event_manager.trigger("foo", :exit) 25 | @event_manager.trigger("baz", :exit) 26 | 27 | @event_manager.run_scheduled_events do |event| 28 | expect(event.block.call).to eq("exit foo") 29 | end 30 | 31 | expect(@event_manager.scheduled_events).not_to be_empty 32 | expect(@event_manager.triggered_events).not_to be_empty 33 | 34 | baz_containing_event = @event_manager.triggered_events.map(&:command_label) 35 | expect(baz_containing_event).to include("baz") 36 | 37 | bar_containing_scheduled_event = @event_manager.scheduled_events.keys 38 | expect(bar_containing_scheduled_event).to include("bar") 39 | end 40 | 41 | it "should handle multiple events for same command" do 42 | @event_manager.schedule_event("foo", :exit) { 'exit foo' } 43 | @event_manager.schedule_event("foo", :entry) { "entry bar"} 44 | @event_manager.trigger("foo", :exit) 45 | 46 | @event_manager.run_scheduled_events { |event| } 47 | 48 | 49 | @event_manager.schedule_event("foo", :exit) { 'exit foo' } 50 | @event_manager.trigger("foo", :exit) 51 | 52 | expect(@event_manager.scheduled_events).not_to be_empty 53 | expect(@event_manager.triggered_events).not_to be_empty 54 | end 55 | 56 | it "should not run unmatched events" do 57 | @event_manager.schedule_event("bar", :entry) { "entry bar"} 58 | @event_manager.trigger("foo", :exit) 59 | 60 | events_ran = false 61 | @event_manager.run_scheduled_events do |event| 62 | events_ran = true 63 | end 64 | expect(events_ran).to eql false 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/invoker/power/http_response.rb: -------------------------------------------------------------------------------- 1 | require 'time' 2 | 3 | module Invoker 4 | module Power 5 | class HttpResponse 6 | STATUS_MAPS = { 7 | 200 => "OK", 8 | 201 => "Created", 9 | 202 => "Accepted", 10 | 204 => "No Content", 11 | 205 => "Reset Content", 12 | 206 => "Partial Content", 13 | 301 => "Moved Permanently", 14 | 302 => "Found", 15 | 304 => "Not Modified", 16 | 400 => "Bad Request", 17 | 401 => "Unauthorized", 18 | 402 => "Payment Required", 19 | 403 => "Forbidden", 20 | 404 => "Not Found", 21 | 411 => "Length Required", 22 | 500 => "Internal Server Error", 23 | 501 => "Not Implemented", 24 | 502 => "Bad Gateway", 25 | 503 => "Service Unavailable", 26 | 504 => "Gateway Timeout" 27 | } 28 | 29 | HTTP_HEADER_FIELDS = [ 30 | 'Cache-Control', 'Connection', 'Date', 31 | 'Pragma', 'Trailer', 'Transfer-Encoding', 32 | 'Accept-Ranges', 'Age', 'Etag', 33 | 'Server', 'Location', 'Allow', 34 | 'Content-Encoding', 'Content-Language', 'Content-Location', 35 | 'Content-MD5', 'Content-Range', 36 | 'Content-Type', 'Expires', 37 | 'Last-Modified', 'extension-header' 38 | ] 39 | 40 | attr_accessor :header, :body, :status 41 | 42 | def initialize 43 | @header = {} 44 | header['Server'] = "Invoker #{Invoker::VERSION}" 45 | header['Date'] = Time.now.httpdate 46 | @status = 200 47 | @body = "" 48 | end 49 | 50 | def []=(key, value) 51 | header[key] = value 52 | end 53 | 54 | def use_file_as_body(file_name) 55 | if file_name && File.exist?(file_name) 56 | file_content = File.read(file_name) 57 | self.body = file_content 58 | else 59 | raise Invoker::Errors::InvalidFile, "Invalid file as body" 60 | end 61 | end 62 | 63 | def http_string 64 | final_string = [] 65 | final_string << "HTTP/1.1 #{status} #{STATUS_MAPS[status]}" 66 | 67 | if header['Transfer-Encoding'].nil? && body.empty? 68 | header['Content-Length'] = body.length 69 | end 70 | 71 | HTTP_HEADER_FIELDS.each do |key| 72 | if value = header[key] 73 | final_string << "#{key}: #{value}" 74 | end 75 | end 76 | 77 | final_string.join("\r\n") + "\r\n\r\n" + body 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/invoker/power/setup.rb: -------------------------------------------------------------------------------- 1 | require "eventmachine" 2 | 3 | module Invoker 4 | module Power 5 | class Setup 6 | attr_accessor :port_finder, :tld 7 | 8 | def self.install(tld) 9 | selected_installer_klass = installer_klass 10 | selected_installer_klass.new(tld).install 11 | end 12 | 13 | def self.uninstall 14 | if Invoker::Power::Config.has_config? 15 | power_config = Invoker::Power::Config.load_config 16 | selected_installer_klass = installer_klass 17 | selected_installer_klass.new(power_config.tld).uninstall_invoker 18 | end 19 | end 20 | 21 | def self.installer_klass 22 | if Invoker.darwin? 23 | Invoker::Power::OsxSetup 24 | else 25 | Invoker::Power::LinuxSetup 26 | end 27 | end 28 | 29 | def initialize(tld) 30 | if tld !~ /^[a-z]+$/ 31 | Invoker::Logger.puts("Please specify valid tld".colorize(:red)) 32 | exit(1) 33 | end 34 | self.tld = tld 35 | end 36 | 37 | def install 38 | if check_if_setup_can_run? 39 | setup_invoker 40 | else 41 | Invoker::Logger.puts("The setup has been already run.".colorize(:red)) 42 | end 43 | self 44 | end 45 | 46 | def drop_to_normal_user 47 | EventMachine.set_effective_user(ENV["SUDO_USER"]) 48 | end 49 | 50 | def find_open_ports 51 | port_finder.find_ports() 52 | end 53 | 54 | def port_finder 55 | @port_finder ||= Invoker::Power::PortFinder.new() 56 | end 57 | 58 | def check_if_setup_can_run? 59 | !File.exist?(Invoker::Power::Config.config_file) 60 | end 61 | 62 | def create_config_file 63 | Invoker.setup_config_location 64 | config = build_power_config 65 | Invoker::Power::Config.create(config) 66 | end 67 | 68 | # Builds and returns power config hash. Override this method in subclasses if necessary. 69 | def build_power_config 70 | config = { 71 | http_port: port_finder.http_port, 72 | https_port: port_finder.https_port, 73 | tld: tld 74 | } 75 | config 76 | end 77 | 78 | def remove_resolver_file 79 | return if resolver_file.nil? 80 | begin 81 | safe_remove_file(resolver_file) 82 | rescue Errno::EACCES 83 | Invoker::Logger.puts("Running uninstall requires root access, please rerun it with sudo".colorize(:red)) 84 | raise 85 | end 86 | end 87 | 88 | def safe_remove_file(file) 89 | File.delete(file) if File.exist?(file) 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/invoker/power/setup/distro/base.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module Power 3 | module Distro 4 | class Base 5 | SOCAT_SHELLSCRIPT = "/usr/bin/invoker_forwarder.sh" 6 | SOCAT_SYSTEMD = "/etc/systemd/system/socat_invoker.service" 7 | RESOLVER_DIR = "/etc/dnsmasq.d" 8 | attr_accessor :tld 9 | 10 | def resolver_file 11 | File.join(RESOLVER_DIR, "#{tld}-tld") 12 | end 13 | 14 | def self.distro_installer(tld) 15 | if distro.start_with? "Arch Linux", "Manjaro Linux" 16 | require "invoker/power/setup/distro/arch" 17 | Arch.new(tld) 18 | elsif distro.start_with? "Debian" 19 | require "invoker/power/setup/distro/debian" 20 | Debian.new(tld) 21 | elsif distro.start_with? "Fedora" 22 | require "invoker/power/setup/distro/redhat" 23 | Redhat.new(tld) 24 | elsif distro.start_with? "Linux Mint", "Ubuntu" 25 | require "invoker/power/setup/distro/ubuntu" 26 | Ubuntu.new(tld) 27 | elsif distro.start_with? "openSUSE" 28 | require "invoker/power/setup/distro/opensuse" 29 | Opensuse.new(tld) 30 | else 31 | raise "Your selected distro is not supported by Invoker" 32 | end 33 | end 34 | 35 | def self.distro 36 | @distro ||= if File.exist?('/etc/os-release') 37 | File.read('/etc/os-release').each_line do |line| 38 | parsed_line = line.chomp.tr('"', '').split('=') 39 | break parsed_line[1] if parsed_line[0] == 'NAME' 40 | end 41 | else 42 | raise "File /etc/os-release doesn't exist or not Linux" 43 | end 44 | end 45 | 46 | def initialize(tld) 47 | self.tld = tld 48 | end 49 | 50 | # Install required software 51 | def install_required_software 52 | raise "Unimplemented" 53 | end 54 | 55 | def restart_services 56 | system("systemctl enable socat_invoker.service") 57 | system("systemctl enable dnsmasq") 58 | system("systemctl start socat_invoker.service") 59 | system("systemctl restart dnsmasq") 60 | end 61 | 62 | def install_packages 63 | "dnsmasq and socat" 64 | end 65 | 66 | def install_other 67 | " a local resolver for .#{tld} domain and" 68 | end 69 | 70 | def get_user_confirmation? 71 | Invoker::Logger.puts("Invoker is going to install #{install_packages} on this machine."\ 72 | " It is also going to install#{install_other} a socat service"\ 73 | " which will forward all local requests on port 80 and 443 to another port") 74 | Invoker::Logger.puts("If you still want to proceed with installation, press y.") 75 | Invoker::CLI::Question.agree("Proceed with installation (y/n) : ") 76 | end 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/invoker/power/setup/linux_setup.rb: -------------------------------------------------------------------------------- 1 | require "invoker/power/setup/distro/base" 2 | require 'erb' 3 | require 'fileutils' 4 | 5 | module Invoker 6 | module Power 7 | class LinuxSetup < Setup 8 | attr_accessor :distro_installer 9 | 10 | def setup_invoker 11 | initialize_distro_installer 12 | if distro_installer.get_user_confirmation? 13 | find_open_ports 14 | distro_installer.install_required_software 15 | install_resolver 16 | install_port_forwarder 17 | distro_installer.restart_services 18 | drop_to_normal_user 19 | create_config_file 20 | else 21 | Invoker::Logger.puts("Invoker is not configured to serve from subdomains".colorize(:red)) 22 | end 23 | self 24 | end 25 | 26 | def uninstall_invoker 27 | system("systemctl disable socat_invoker.service") 28 | system("systemctl stop socat_invoker.service") 29 | system("rm #{Invoker::Power::Distro::Base::SOCAT_SYSTEMD}") 30 | system("rm #{Invoker::Power::Distro::Base::SOCAT_SHELLSCRIPT}") 31 | initialize_distro_installer 32 | remove_resolver_file 33 | drop_to_normal_user 34 | Invoker::Power::Config.delete 35 | end 36 | 37 | def build_power_config 38 | config = super 39 | config[:tld] = distro_installer.tld 40 | config 41 | end 42 | 43 | def resolver_file 44 | distro_installer.resolver_file 45 | end 46 | 47 | def forwarder_script 48 | File.join(File.dirname(__FILE__), "files/invoker_forwarder.sh.erb") 49 | end 50 | 51 | def socat_unit 52 | File.join(File.dirname(__FILE__), "files/socat_invoker.service") 53 | end 54 | 55 | private 56 | 57 | def initialize_distro_installer 58 | @distro_installer ||= Invoker::Power::Distro::Base.distro_installer(tld) 59 | end 60 | 61 | def install_resolver 62 | return if resolver_file.nil? 63 | File.open(resolver_file, "w") do |fl| 64 | fl.write(resolver_file_content) 65 | end 66 | end 67 | 68 | def install_port_forwarder 69 | install_forwarder_script(port_finder.http_port, port_finder.https_port) 70 | install_systemd_unit 71 | end 72 | 73 | def resolver_file_content 74 | content =<<-EOD 75 | local=/#{tld}/ 76 | address=/#{tld}/127.0.0.1 77 | EOD 78 | content 79 | end 80 | 81 | def install_forwarder_script(http_port, https_port) 82 | script_template = File.read(forwarder_script) 83 | renderer = ERB.new(script_template) 84 | script_output = renderer.result(binding) 85 | File.open(Invoker::Power::Distro::Base::SOCAT_SHELLSCRIPT, "w") do |fl| 86 | fl.write(script_output) 87 | end 88 | system("chmod +x #{Invoker::Power::Distro::Base::SOCAT_SHELLSCRIPT}") 89 | end 90 | 91 | def install_systemd_unit 92 | FileUtils.cp(socat_unit, Invoker::Power::Distro::Base::SOCAT_SYSTEMD) 93 | system("chmod 644 #{Invoker::Power::Distro::Base::SOCAT_SYSTEMD}") 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/invoker/event/manager.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module Event 3 | class Manager 4 | attr_accessor :scheduled_events, :triggered_events 5 | 6 | def initialize 7 | @scheduled_events = Hash.new {|h,k| h[k] = [] } 8 | @triggered_events = [] 9 | @trigger_mutex = Mutex.new() 10 | end 11 | 12 | # Trigger an event. The event is not triggered immediately, but is just scheduled to be 13 | # triggered. 14 | # 15 | # @param command_label [String] Command for which event should be triggered 16 | # @param event_name [Symbol, nil] The optional event name 17 | def trigger(command_label, event_name = nil) 18 | @trigger_mutex.synchronize do 19 | triggered_events << OpenStruct.new( 20 | :command_label => command_label, 21 | :event_name => event_name) 22 | end 23 | end 24 | 25 | # Schedule an Event. The event will only trigger when a scheduled event matches 26 | # a triggered event. 27 | # 28 | # @param command_label [String] Command for which the event should be triggered 29 | # @param event_name [String, nil] Optional event name 30 | # @param block The block to execute when event actually triggers 31 | def schedule_event(command_label, event_name = nil, &block) 32 | @trigger_mutex.synchronize do 33 | scheduled_events[command_label] << OpenStruct.new(:event_name => event_name, :block => block) 34 | end 35 | end 36 | 37 | # On next iteration of event loop, this method is called and we try to match 38 | # scheduled events with events that were triggered. 39 | def run_scheduled_events 40 | filtered_events_by_name_and_command = [] 41 | 42 | triggered_events.each_with_index do |triggered_event, index| 43 | matched_events = scheduled_events[triggered_event.command_label] 44 | if matched_events && !matched_events.empty? 45 | filtered_events_by_name_and_command, unmatched_events = 46 | filter_matched_events(matched_events, triggered_event) 47 | triggered_events[index] = nil 48 | remove_scheduled_event(unmatched_events, triggered_event.command_label) 49 | end 50 | end 51 | triggered_events.compact! 52 | 53 | filtered_events_by_name_and_command.each {|event| yield event } 54 | end 55 | 56 | private 57 | def filter_matched_events(matched_events, event) 58 | matched_filtered_events = [] 59 | 60 | matched_events.each_with_index do |matched_event, index| 61 | if !event.event_name || event.event_name == matched_event.event_name 62 | matched_filtered_events << matched_event 63 | matched_events[index] = nil 64 | end 65 | end 66 | [matched_filtered_events, matched_events.compact] 67 | end 68 | 69 | def remove_scheduled_event(matched_events, command_label) 70 | if !matched_events || matched_events.empty? 71 | scheduled_events.delete(command_label) 72 | else 73 | scheduled_events[command_label] = matched_events 74 | end 75 | end 76 | 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/invoker/daemon.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | # rip off from borg 3 | # https://github.com/code-mancers/borg/blob/master/lib/borg/borg_daemon.rb 4 | class Daemon 5 | attr_reader :process_name 6 | 7 | def initialize 8 | @process_name = 'invoker' 9 | end 10 | 11 | def start 12 | if running? 13 | Invoker::Logger.puts "Invoker daemon is already running" 14 | exit(0) 15 | elsif dead? 16 | File.delete(pid_file) if File.exist?(pid_file) 17 | end 18 | Invoker::Logger.puts "Running Invoker daemon" 19 | daemonize 20 | end 21 | 22 | def stop 23 | kill_process 24 | end 25 | 26 | def pid_file 27 | File.join(Invoker.home, ".invoker", "#{process_name}.pid") 28 | end 29 | 30 | def pid 31 | File.read(pid_file).strip.to_i 32 | end 33 | 34 | def log_file 35 | File.join(Invoker.home, ".invoker", "#{process_name}.log") 36 | end 37 | 38 | def daemonize 39 | if fork 40 | sleep(2) 41 | exit(0) 42 | else 43 | Process.setsid 44 | File.open(pid_file, "w") do |file| 45 | file.write(Process.pid.to_s) 46 | end 47 | Invoker::Logger.puts "Invoker daemon log is available at #{log_file}" 48 | redirect_io(log_file) 49 | $0 = process_name 50 | end 51 | end 52 | 53 | def kill_process 54 | pgid = Process.getpgid(pid) 55 | Process.kill('-TERM', pgid) 56 | File.delete(pid_file) if File.exist?(pid_file) 57 | Invoker::Logger.puts "Stopped Invoker daemon" 58 | end 59 | 60 | def process_running? 61 | Process.kill(0, pid) 62 | true 63 | rescue Errno::ESRCH 64 | false 65 | end 66 | 67 | def status 68 | @status ||= check_process_status 69 | end 70 | 71 | def pidfile_exists? 72 | File.exist?(pid_file) 73 | end 74 | 75 | def running? 76 | status == 0 77 | end 78 | 79 | # pidfile exists but process isn't running 80 | def dead? 81 | status == 1 82 | end 83 | 84 | private 85 | 86 | def check_process_status 87 | if pidfile_exists? && process_running? 88 | 0 89 | elsif pidfile_exists? # but not process_running 90 | 1 91 | else 92 | 3 93 | end 94 | end 95 | 96 | def redirect_io(logfile_name = nil) 97 | redirect_file_to_target($stdin) 98 | redirect_stdout(logfile_name) 99 | redirect_stderr 100 | end 101 | 102 | def redirect_stderr 103 | redirect_file_to_target($stderr, $stdout) 104 | $stderr.sync = true 105 | end 106 | 107 | def redirect_stdout(logfile_name) 108 | if logfile_name 109 | begin 110 | $stdout.reopen logfile_name, "a" 111 | $stdout.sync = true 112 | rescue StandardError 113 | redirect_file_to_target($stdout) 114 | end 115 | else 116 | redirect_file_to_target($stdout) 117 | end 118 | end 119 | 120 | def redirect_file_to_target(file, target = "/dev/null") 121 | begin 122 | file.reopen(target) 123 | rescue; end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v2.0.0 2 | * Updated to make compliant for > Ruby 3.0, updated http-parser-lite reference, fixed exists? vs exist? deprecation. (https://github.com/codemancers/invoker/pull/250) 3 | 4 | # v1.5.8 5 | * Add `--wait` / `-w` option to `invoker list` to stream updates instead of printing once & exiting (https://github.com/code-mancers/invoker/pull/239) 6 | * Make `Host` header check case-insensitive (https://github.com/code-mancers/invoker/pull/241) 7 | 8 | # v1.5.7 9 | * Enable Manjaro Linux support 10 | * Fix setup on Ubuntu w/ systemd-resolved (https://github.com/code-mancers/invoker/pull/233) 11 | * Ensure same color is re-used for next started process, which is helpful to maintain consistency when using `reload` command (https://github.com/code-mancers/invoker/pull/230) 12 | * Add `install` as an alias of `setup` command (https://github.com/code-mancers/invoker/pull/232) 13 | * Change default process sleep duration to 0 (https://github.com/code-mancers/invoker/pull/231) 14 | * Add `restart` as an alias of `reload` command (https://github.com/code-mancers/invoker/pull/229) 15 | * Remove facter dependency (https://github.com/code-mancers/invoker/pull/236) 16 | * Relax dotenv dependency version restriction (https://github.com/code-mancers/invoker/pull/237) 17 | * Relax thor dependency version restriction (https://github.com/code-mancers/invoker/pull/238) 18 | 19 | # v1.5.6 20 | * Change default tld from .dev to .test (https://github.com/code-mancers/invoker/pull/208) 21 | 22 | # v1.5.5 23 | * Fix high cpu usage when process managed by Invoker crashes and Invoker doesn't read from its socket.(https://github.com/code-mancers/invoker/pull/198) 24 | * Allow users to specify custom ssl certificate and key (https://github.com/code-mancers/invoker/pull/199) 25 | * Remove rainbow dependency and migrate to colorize 26 | 27 | # v1.5.4 28 | * Add support for running Invoker build in SELinux environments (https://github.com/code-mancers/invoker/pull/188) 29 | * Add an option to print process listing in raw format. This enables us to see complete process list (https://github.com/code-mancers/invoker/pull/193) 30 | * Fix colors in console output (https://github.com/code-mancers/invoker/pull/192) 31 | * Add a new option to optionally disable colors in log when starting invoker (#196) 32 | * Handle TERM and INT signals when stopping invoker. (#196) 33 | 34 | ## v1.5.3 35 | 36 | * Always capture STDOUT/STDERR of process to invoker's log file, if invoker is daemonized. (https://github.com/code-mancers/invoker/pull/186) 37 | * Add a command for filtering all logs for a process. (https://github.com/code-mancers/invoker/pull/186) 38 | * Prefer Procfile.dev to Procfile (https://github.com/code-mancers/invoker/pull/183) 39 | * Downgrade Rainbow version to prevent compilation errors in certain environments (https://github.com/code-mancers/invoker/pull/180) 40 | * Non existant PWD environment variable may cause errors while starting a process (https://github.com/code-mancers/invoker/pull/179) 41 | * Implement support for specifying process ordering. This allows user to be explicit about 42 | order in which Invoker should start processes from ini file (https://github.com/code-mancers/invoker/pull/174) 43 | * Return correct version of Invoker in HTTP header (https://github.com/code-mancers/invoker/pull/173) 44 | -------------------------------------------------------------------------------- /lib/invoker/commander.rb: -------------------------------------------------------------------------------- 1 | require "io/console" 2 | require 'pty' 3 | require "json" 4 | require "dotenv" 5 | require "forwardable" 6 | 7 | module Invoker 8 | class Commander 9 | attr_accessor :reactor, :process_manager 10 | attr_accessor :event_manager, :runnables, :thread_group 11 | extend Forwardable 12 | 13 | def_delegators :@process_manager, :start_process_by_name, :stop_process 14 | def_delegators :@process_manager, :restart_process, :get_worker_from_fd, :process_list 15 | 16 | def_delegators :@event_manager, :schedule_event, :trigger 17 | def_delegator :@reactor, :watch_for_read 18 | 19 | def initialize 20 | @thread_group = ThreadGroup.new 21 | @runnable_mutex = Mutex.new 22 | 23 | @event_manager = Invoker::Event::Manager.new 24 | @runnables = [] 25 | 26 | @reactor = Invoker::Reactor.new 27 | @process_manager = Invoker::ProcessManager.new 28 | Thread.abort_on_exception = true 29 | end 30 | 31 | # Start the invoker process supervisor. This method starts a unix server 32 | # in separate thread that listens for incoming commands. 33 | def start_manager 34 | verify_process_configuration 35 | daemonize_app if Invoker.daemonize? 36 | install_interrupt_handler 37 | unix_server_thread = Thread.new { Invoker::IPC::Server.new } 38 | @thread_group.add(unix_server_thread) 39 | process_manager.run_power_server 40 | Invoker.config.autorunnable_processes.each do |process_info| 41 | process_manager.start_process(process_info) 42 | Logger.puts("Starting process - #{process_info.label} waiting for #{process_info.sleep_duration} seconds...") 43 | sleep(process_info.sleep_duration) 44 | end 45 | at_exit { process_manager.kill_workers } 46 | start_event_loop 47 | end 48 | 49 | def on_next_tick(*args, &block) 50 | @runnable_mutex.synchronize do 51 | @runnables << OpenStruct.new(:args => args, :block => block) 52 | end 53 | end 54 | 55 | def run_runnables 56 | @runnables.each do |runnable| 57 | instance_exec(*runnable.args, &runnable.block) 58 | end 59 | @runnables = [] 60 | end 61 | 62 | private 63 | 64 | def verify_process_configuration 65 | if !Invoker.config.processes || Invoker.config.processes.empty? 66 | raise Invoker::Errors::InvalidConfig.new("No processes configured in config file") 67 | end 68 | end 69 | 70 | def start_event_loop 71 | loop do 72 | reactor.monitor_for_fd_events 73 | run_runnables 74 | run_scheduled_events 75 | end 76 | end 77 | 78 | def run_scheduled_events 79 | event_manager.run_scheduled_events do |event| 80 | event.block.call 81 | end 82 | end 83 | 84 | def install_interrupt_handler 85 | Signal.trap("INT") { 86 | Invoker::Logger.puts("Stopping invoker") 87 | process_manager.kill_workers 88 | exit(0) 89 | } 90 | Signal.trap("TERM") { 91 | Invoker::Logger.puts("Stopping invoker") 92 | process_manager.kill_workers 93 | exit(0) 94 | } 95 | end 96 | 97 | def daemonize_app 98 | Invoker.daemon.start 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /spec/invoker/power/setup/osx_setup_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Invoker::Power::OsxSetup, fakefs: true do 4 | before do 5 | FileUtils.mkdir_p(inv_conf_dir) 6 | FileUtils.mkdir_p(Invoker::Power::OsxSetup::RESOLVER_DIR) 7 | end 8 | 9 | describe "when no setup exists" do 10 | it "should create a config file with port etc" do 11 | setup = Invoker::Power::OsxSetup.new('dev') 12 | setup.expects(:install_resolver).returns(true) 13 | setup.expects(:drop_to_normal_user).returns(true) 14 | setup.expects(:install_firewall).once 15 | 16 | setup.setup_invoker 17 | 18 | config = Invoker::Power::Config.load_config 19 | expect(config.http_port).not_to be_nil 20 | expect(config.dns_port).not_to be_nil 21 | expect(config.https_port).not_to be_nil 22 | end 23 | end 24 | 25 | describe "when a setup file exists" do 26 | it "should throw error about existing file" do 27 | File.open(Invoker::Power::Config.config_file, "w") {|fl| 28 | fl.write("foo test") 29 | } 30 | Invoker::Power::Setup.any_instance.expects(:setup_invoker).never 31 | Invoker::Power::Setup.install('dev') 32 | end 33 | end 34 | 35 | describe "when pow like setup exists" do 36 | before { 37 | File.open(File.join(Invoker::Power::OsxSetup::RESOLVER_DIR, "dev"), "w") { |fl| 38 | fl.write("hello") 39 | } 40 | @setup = Invoker::Power::OsxSetup.new('dev') 41 | } 42 | 43 | describe "when user selects to overwrite it" do 44 | it "should run setup normally" do 45 | @setup.expects(:setup_resolver_file).returns(true) 46 | @setup.expects(:drop_to_normal_user).returns(true) 47 | @setup.expects(:install_resolver).returns(true) 48 | @setup.expects(:install_firewall).once() 49 | 50 | @setup.setup_invoker 51 | end 52 | end 53 | 54 | describe "when user chose not to overwrite it" do 55 | it "should abort the setup process" do 56 | @setup.expects(:setup_resolver_file).returns(false) 57 | 58 | @setup.expects(:install_resolver).never 59 | @setup.expects(:install_firewall).never 60 | 61 | @setup.setup_invoker 62 | end 63 | end 64 | end 65 | 66 | describe "uninstalling firewall rules" do 67 | it "should uninstall firewall rules and remove all files created by setup" do 68 | setup = Invoker::Power::OsxSetup.new('dev') 69 | 70 | Invoker::CLI::Question.expects(:agree).returns(true) 71 | setup.expects(:remove_resolver_file).once 72 | setup.expects(:unload_firewall_rule).with(true).once 73 | Invoker::Power::Config.expects(:delete).once 74 | 75 | setup.uninstall_invoker 76 | end 77 | end 78 | 79 | describe "setup on fresh osx install" do 80 | context "when resolver directory does not exist" do 81 | before do 82 | @setup = Invoker::Power::OsxSetup.new('dev') 83 | FileUtils.rm_rf(Invoker::Power::OsxSetup::RESOLVER_DIR) 84 | end 85 | 86 | it "should create the directory and install" do 87 | @setup.expects(:setup_resolver_file).returns(true) 88 | @setup.expects(:drop_to_normal_user).returns(true) 89 | @setup.expects(:install_firewall).once() 90 | 91 | @setup.setup_invoker 92 | expect(Dir.exist?(Invoker::Power::OsxSetup::RESOLVER_DIR)).to be_truthy 93 | end 94 | end 95 | end 96 | 97 | describe '.resolver_file' do 98 | context 'user sets up a custom top level domain' do 99 | it 'should create the correct resolver file' do 100 | setup = Invoker::Power::OsxSetup.new('local') 101 | expect(setup.resolver_file).to eq('/etc/resolver/local') 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/invoker/process_manager_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Invoker::ProcessManager do 4 | let(:process_manager) { Invoker::ProcessManager.new } 5 | 6 | describe "#start_process_by_name" do 7 | it "should find command by label and start it, if found" do 8 | @original_invoker_config = Invoker.config 9 | Invoker.config = mock 10 | 11 | Invoker.config.stubs(:processes).returns([OpenStruct.new(:label => "resque", :cmd => "foo", :dir => "bar")]) 12 | Invoker.config.expects(:process).returns(OpenStruct.new(:label => "resque", :cmd => "foo", :dir => "bar")) 13 | process_manager.expects(:start_process).returns(true) 14 | 15 | process_manager.start_process_by_name("resque") 16 | 17 | Invoker.config = @original_invoker_config 18 | end 19 | 20 | it "should not start already running process" do 21 | process_manager.workers.expects(:[]).returns(OpenStruct.new(:pid => "bogus")) 22 | expect(process_manager.start_process_by_name("resque")).to be_falsey 23 | end 24 | end 25 | 26 | describe "#stop_process" do 27 | let(:message) { MM::Remove.new(options) } 28 | describe "when a worker is found" do 29 | before do 30 | process_manager.workers.expects(:[]).returns(OpenStruct.new(:pid => "bogus")) 31 | end 32 | 33 | describe "if a signal is specified" do 34 | let(:options) { { process_name: 'bogus', signal: 'HUP' } } 35 | it "should use that signal to kill the worker" do 36 | process_manager.expects(:process_kill).with("bogus", "HUP").returns(true) 37 | expect(process_manager.stop_process(message)).to be_truthy 38 | end 39 | end 40 | 41 | describe "if no signal is specified" do 42 | let(:options) { { process_name: 'bogus' } } 43 | it "should use INT signal" do 44 | process_manager.expects(:process_kill).with("bogus", "INT").returns(true) 45 | expect(process_manager.stop_process(message)).to be_truthy 46 | end 47 | end 48 | end 49 | 50 | describe "when no worker is found" do 51 | let(:options) { { process_name: 'bogus', signal: 'HUP' } } 52 | before do 53 | process_manager.workers.expects(:[]).returns(nil) 54 | end 55 | 56 | it "should not kill anything" do 57 | process_manager.expects(:process_kill).never 58 | process_manager.stop_process(message) 59 | end 60 | end 61 | end 62 | 63 | describe "#load_env" do 64 | it "should load .env file from the specified directory" do 65 | dir = "/tmp" 66 | begin 67 | env_file = File.new("#{dir}/.env", "w") 68 | env_data =<<-EOD 69 | FOO=foo 70 | BAR=bar 71 | EOD 72 | env_file.write(env_data) 73 | env_file.close 74 | env_options = process_manager.load_env(dir) 75 | expect(env_options).to include("FOO" => "foo", "BAR" => "bar") 76 | ensure 77 | File.delete(env_file.path) 78 | end 79 | end 80 | 81 | it "should default to current directory if no directory is specified" do 82 | dir = ENV["HOME"] 83 | ENV.stubs(:[]).with("PWD").returns(dir) 84 | begin 85 | env_file = File.new("#{dir}/.env", "w") 86 | env_data =<<-EOD 87 | FOO=bar 88 | BAR=foo 89 | EOD 90 | env_file.write(env_data) 91 | env_file.close 92 | env_options = process_manager.load_env 93 | expect(env_options).to include("FOO" => "bar", "BAR" => "foo") 94 | ensure 95 | File.delete(env_file.path) 96 | end 97 | end 98 | 99 | it "should return empty hash if there is no .env file" do 100 | dir = "/tmp" 101 | expect(process_manager.load_env(dir)).to eq({}) 102 | end 103 | 104 | it "should load .local.env file if it exists" do 105 | dir = "/tmp" 106 | begin 107 | env_file = File.new("#{dir}/.env", "w") 108 | env_data =<<-EOD 109 | FOO=foo 110 | BAR=bar 111 | EOD 112 | env_file.write(env_data) 113 | env_file.close 114 | 115 | local_env_file = File.new("#{dir}/.env.local", "w") 116 | local_env_data =<<-EOD 117 | FOO=emacs 118 | EOD 119 | local_env_file.write(local_env_data) 120 | local_env_file.close 121 | 122 | env_options = process_manager.load_env(dir) 123 | expect(env_options).to include("FOO" => "emacs", "BAR" => "bar") 124 | ensure 125 | File.delete(env_file.path) 126 | File.delete(local_env_file.path) 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/invoker.rb: -------------------------------------------------------------------------------- 1 | $: << File.dirname(__FILE__) unless $:.include?(File.expand_path(File.dirname(__FILE__))) 2 | 3 | require "fileutils" 4 | require "formatador" 5 | 6 | require "ostruct" 7 | require "uuid" 8 | require "json" 9 | require "colorize" 10 | require "etc" 11 | 12 | require "invoker/version" 13 | require "invoker/logger" 14 | require "invoker/daemon" 15 | require "invoker/cli" 16 | require "invoker/dns_cache" 17 | require "invoker/ipc" 18 | require "invoker/power/config" 19 | require "invoker/power/port_finder" 20 | require "invoker/power/setup" 21 | require "invoker/power/setup/linux_setup" 22 | require "invoker/power/setup/osx_setup" 23 | require "invoker/power/powerup" 24 | require "invoker/errors" 25 | require "invoker/parsers/procfile" 26 | require "invoker/parsers/config" 27 | require "invoker/commander" 28 | require "invoker/process_manager" 29 | require "invoker/command_worker" 30 | require "invoker/reactor" 31 | require "invoker/event/manager" 32 | require "invoker/process_printer" 33 | 34 | module Invoker 35 | class << self 36 | attr_accessor :config, :tail_watchers, :commander 37 | attr_accessor :dns_cache, :daemonize, :nocolors, :certificate, :private_key 38 | 39 | alias_method :daemonize?, :daemonize 40 | alias_method :nocolors?, :nocolors 41 | 42 | def darwin? 43 | ruby_platform.downcase.include?("darwin") 44 | end 45 | 46 | def linux? 47 | ruby_platform.downcase.include?("linux") 48 | end 49 | 50 | def ruby_platform 51 | RUBY_PLATFORM 52 | end 53 | 54 | def load_invoker_config(file, port) 55 | @config = Invoker::Parsers::Config.new(file, port) 56 | @dns_cache = Invoker::DNSCache.new(@invoker_config) 57 | @tail_watchers = Invoker::CLI::TailWatcher.new 58 | @commander = Invoker::Commander.new 59 | end 60 | 61 | def close_socket(socket) 62 | socket.close 63 | rescue StandardError => error 64 | Invoker::Logger.puts "Error removing socket #{error}" 65 | end 66 | 67 | def daemon 68 | @daemon ||= Invoker::Daemon.new 69 | end 70 | 71 | def can_run_balancer?(throw_warning = true) 72 | return true if File.exist?(Invoker::Power::Config.config_file) 73 | 74 | if throw_warning 75 | Invoker::Logger.puts("Invoker has detected setup has not been run. Domain feature will not work without running setup command.".colorize(:red)) 76 | end 77 | false 78 | end 79 | 80 | def setup_config_location 81 | config_dir = Invoker::Power::Config.config_dir 82 | return config_dir if Dir.exist?(config_dir) 83 | 84 | if File.exist?(config_dir) 85 | old_config = File.read(config_dir) 86 | FileUtils.rm_f(config_dir) 87 | end 88 | 89 | FileUtils.mkdir(config_dir) 90 | 91 | migrate_old_config(old_config, config_dir) if old_config 92 | config_dir 93 | end 94 | 95 | def run_without_bundler 96 | if defined?(Bundler) 97 | Bundler.with_unbundled_env do 98 | yield 99 | end 100 | else 101 | yield 102 | end 103 | end 104 | 105 | def notify_user(message) 106 | if Invoker.darwin? 107 | run_without_bundler { check_and_notify_with_terminal_notifier(message) } 108 | elsif Invoker.linux? 109 | notify_with_libnotify(message) 110 | end 111 | end 112 | 113 | def check_and_notify_with_terminal_notifier(message) 114 | command_path = `which terminal-notifier` 115 | if command_path && !command_path.empty? 116 | system("terminal-notifier -message '#{message}' -title Invoker") 117 | end 118 | end 119 | 120 | def notify_with_libnotify(message) 121 | begin 122 | require "libnotify" 123 | Libnotify.show(body: message, summary: "Invoker", timeout: 2.5) 124 | rescue LoadError; end 125 | end 126 | 127 | def migrate_old_config(old_config, config_location) 128 | new_config = File.join(config_location, 'config') 129 | File.open(new_config, 'w') do |file| 130 | file.write(old_config) 131 | end 132 | end 133 | 134 | # On some platforms `Dir.home` or `ENV['HOME']` does not return home directory of user. 135 | # this is especially true, after effective and real user id of process 136 | # has been changed. 137 | # 138 | # @return [String] home directory of the user 139 | def home 140 | if File.writable?(Dir.home) 141 | Dir.home 142 | else 143 | Etc.getpwuid(Process.uid).dir 144 | end 145 | end 146 | 147 | def default_tld 148 | 'test' 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/invoker/power/balancer.rb: -------------------------------------------------------------------------------- 1 | require 'em-proxy' 2 | require 'http-parser' 3 | require "invoker/power/http_parser" 4 | require "invoker/power/url_rewriter" 5 | 6 | module Invoker 7 | module Power 8 | class InvokerHttpProxy < EventMachine::ProxyServer::Connection 9 | attr_accessor :host, :ip, :port 10 | def set_host(host, selected_backend) 11 | self.host = host 12 | self.ip = selected_backend[:host] 13 | self.port = selected_backend[:port] 14 | end 15 | end 16 | 17 | class InvokerHttpsProxy < InvokerHttpProxy 18 | def post_init 19 | super 20 | start_tls(private_key_file: Invoker.private_key, cert_chain_file: Invoker.certificate) 21 | end 22 | end 23 | 24 | class Balancer 25 | attr_accessor :connection, :http_parser, :session, :protocol, :upgraded_to 26 | 27 | def self.run(options = {}) 28 | start_http_proxy(InvokerHttpProxy, 'http', options) 29 | start_http_proxy(InvokerHttpsProxy, 'https', options) 30 | end 31 | 32 | def self.start_http_proxy(proxy_class, protocol, options) 33 | port = protocol == 'http' ? Invoker.config.http_port : Invoker.config.https_port 34 | EventMachine.start_server('0.0.0.0', port, 35 | proxy_class, options) do |connection| 36 | balancer = Balancer.new(connection, protocol) 37 | balancer.install_callbacks 38 | end 39 | end 40 | 41 | def initialize(connection, protocol) 42 | @connection = connection 43 | @protocol = protocol 44 | @http_parser = HttpParser.new(protocol) 45 | @session = nil 46 | @upgraded_to = nil 47 | @buffer = [] 48 | end 49 | 50 | def install_callbacks 51 | http_parser.on_headers_complete { |headers| headers_received(headers) } 52 | http_parser.on_message_complete { |full_message| complete_message_received(full_message) } 53 | connection.on_data { |data| upstream_data(data) } 54 | connection.on_response { |backend, data| backend_data(backend, data) } 55 | connection.on_finish { |backend, name| frontend_disconnect(backend, name) } 56 | end 57 | 58 | def complete_message_received(full_message) 59 | connection.relay_to_servers(full_message) 60 | http_parser.reset 61 | end 62 | 63 | def headers_received(headers) 64 | if @session 65 | return 66 | end 67 | @session = UUID.generate() 68 | headers = headers.transform_keys(&:downcase) 69 | 70 | if !headers['host'] || headers['host'].empty? 71 | return_error_page(400) 72 | return 73 | end 74 | 75 | dns_check_response = UrlRewriter.new.select_backend_config(headers['host']) 76 | if dns_check_response && dns_check_response.port 77 | connection.server(session, host: dns_check_response.ip, port: dns_check_response.port) 78 | else 79 | return_error_page(404) 80 | http_parser.reset 81 | connection.close_connection_after_writing 82 | end 83 | end 84 | 85 | def upstream_data(data) 86 | if upgraded_to == "websocket" 87 | data 88 | else 89 | append_for_http_parsing(data) 90 | nil 91 | end 92 | end 93 | 94 | def append_for_http_parsing(data) 95 | http_parser << data 96 | rescue HTTP::Parser::Error 97 | http_parser.reset 98 | connection.close_connection_after_writing 99 | end 100 | 101 | def backend_data(backend, data) 102 | @backend_data = true 103 | 104 | # check backend data for websockets connection. check for upgrade headers 105 | # - Upgrade: websocket\r\n 106 | if data =~ /Upgrade: websocket/ 107 | @upgraded_to = "websocket" 108 | end 109 | 110 | data 111 | end 112 | 113 | def frontend_disconnect(backend, name) 114 | http_parser.reset 115 | unless @backend_data 116 | return_error_page(503) 117 | end 118 | @backend_data = false 119 | connection.close_connection_after_writing if backend == session 120 | end 121 | 122 | private 123 | 124 | def return_error_page(status) 125 | http_response = Invoker::Power::HttpResponse.new() 126 | http_response.status = status 127 | http_response['Content-Type'] = "text/html; charset=utf-8" 128 | http_response.use_file_as_body(File.join(File.dirname(__FILE__), "templates/#{status}.html")) 129 | connection.send_data(http_response.http_string) 130 | end 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/invoker/power/setup/osx_setup.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module Power 3 | class OsxSetup < Setup 4 | FIREWALL_PLIST_FILE = "/Library/LaunchDaemons/com.codemancers.invoker.firewall.plist" 5 | RESOLVER_DIR = "/etc/resolver" 6 | 7 | def resolver_file 8 | File.join(RESOLVER_DIR, tld) 9 | end 10 | 11 | def setup_invoker 12 | if setup_resolver_file 13 | find_open_ports 14 | install_resolver(port_finder.dns_port) 15 | install_firewall(port_finder.http_port, port_finder.https_port) 16 | # Before writing the config file, drop down to a normal user 17 | drop_to_normal_user 18 | create_config_file 19 | else 20 | Invoker::Logger.puts("Invoker is not configured to serve from subdomains".colorize(:red)) 21 | end 22 | self 23 | end 24 | 25 | def uninstall_invoker 26 | uninstall_invoker_flag = Invoker::CLI::Question.agree("Are you sure you want to uninstall firewall rules created by setup (y/n) : ") 27 | 28 | if uninstall_invoker_flag 29 | remove_resolver_file 30 | unload_firewall_rule(true) 31 | Invoker::Power::Config.delete 32 | Invoker::Logger.puts("Firewall rules were removed") 33 | end 34 | end 35 | 36 | def build_power_config 37 | config = super 38 | config[:dns_port] = port_finder.dns_port 39 | config 40 | end 41 | 42 | def install_resolver(dns_port) 43 | open_resolver_for_write { |fl| fl.write(resolve_string(dns_port)) } 44 | rescue Errno::EACCES 45 | Invoker::Logger.puts("Running setup requires root access, please rerun it with sudo".colorize(:red)) 46 | raise 47 | end 48 | 49 | def install_firewall(http_port, https_port) 50 | File.open(FIREWALL_PLIST_FILE, "w") { |fl| 51 | fl.write(plist_string(http_port, https_port)) 52 | } 53 | unload_firewall_rule 54 | load_firewall_rule 55 | end 56 | 57 | def load_firewall_rule 58 | system("launchctl load -Fw #{FIREWALL_PLIST_FILE} 2>/dev/null") 59 | end 60 | 61 | def unload_firewall_rule(remove = false) 62 | system("pfctl -a com.apple/250.InvokerFirewall -F nat 2>/dev/null") 63 | system("launchctl unload -w #{FIREWALL_PLIST_FILE} 2>/dev/null") 64 | system("rm -rf #{FIREWALL_PLIST_FILE}") if remove 65 | end 66 | 67 | # Ripped from POW code 68 | def plist_string(http_port, https_port) 69 | plist =<<-EOD 70 | 71 | 72 | 73 | 74 | Label 75 | com.codemancers.invoker 76 | ProgramArguments 77 | 78 | sh 79 | -c 80 | #{firewall_command(http_port, https_port)} 81 | 82 | RunAtLoad 83 | 84 | UserName 85 | root 86 | 87 | 88 | EOD 89 | plist 90 | end 91 | 92 | def resolve_string(dns_port) 93 | string =<<-EOD 94 | nameserver 127.0.0.1 95 | port #{dns_port} 96 | EOD 97 | string 98 | end 99 | 100 | # Ripped from Pow code 101 | def firewall_command(http_port, https_port) 102 | rules = [ 103 | "rdr pass on lo0 inet proto tcp from any to any port 80 -> 127.0.0.1 port #{http_port}", 104 | "rdr pass on lo0 inet proto tcp from any to any port 443 -> 127.0.0.1 port #{https_port}" 105 | ].join("\n") 106 | "echo \"#{rules}\" | pfctl -a 'com.apple/250.InvokerFirewall' -f - -E" 107 | end 108 | 109 | def setup_resolver_file 110 | return true unless File.exist?(resolver_file) 111 | 112 | Invoker::Logger.puts "Invoker has detected an existing Pow installation. We recommend "\ 113 | "that you uninstall pow and rerun this setup.".colorize(:red) 114 | Invoker::Logger.puts "If you have already uninstalled Pow, proceed with installation"\ 115 | " by pressing y/n." 116 | replace_resolver_flag = Invoker::CLI::Question.agree("Replace Pow configuration (y/n) : ") 117 | 118 | if replace_resolver_flag 119 | Invoker::Logger.puts "Invoker has overwritten one or more files created by Pow. "\ 120 | "If .#{tld} domains still don't resolve locally, try turning off the wi-fi"\ 121 | " and turning it on. It'll force OS X to reload network configuration".colorize(:green) 122 | end 123 | replace_resolver_flag 124 | end 125 | 126 | private 127 | 128 | def open_resolver_for_write 129 | FileUtils.mkdir(RESOLVER_DIR) unless Dir.exist?(RESOLVER_DIR) 130 | fl = File.open(resolver_file, "w") 131 | yield fl 132 | ensure 133 | fl && fl.close 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/invoker/ipc/message.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | module IPC 3 | module Message 4 | module Serialization 5 | def self.included(base) 6 | base.extend ClassMethods 7 | end 8 | 9 | def as_json 10 | attributes.merge(type: message_type) 11 | end 12 | 13 | def to_json 14 | JSON.generate(as_json) 15 | end 16 | 17 | def message_attributes 18 | self.class.message_attributes 19 | end 20 | 21 | def encoded_message 22 | json_data = to_json 23 | json_size = json_data.length.to_s 24 | length_str = json_size.rjust(Invoker::IPC::INITIAL_PACKET_SIZE, '0') 25 | length_str + json_data 26 | end 27 | 28 | def eql?(other) 29 | other.class == self.class && 30 | compare_attributes(other) 31 | end 32 | 33 | def attributes 34 | message_attribute_keys = message_attributes || [] 35 | message_attribute_keys.reduce({}) do |mem, obj| 36 | value = send(obj) 37 | if value.is_a?(Array) 38 | mem[obj] = serialize_array(value) 39 | elsif value.is_a?(Hash) 40 | mem[obj] = serialize_hash(value) 41 | else 42 | mem[obj] = value.respond_to?(:as_json) ? value.as_json : encode_as_utf(value) 43 | end 44 | mem 45 | end 46 | end 47 | 48 | private 49 | 50 | def compare_attributes(other) 51 | message_attributes.all? do |attribute_name| 52 | send(attribute_name).eql?(other.send(attribute_name)) 53 | end 54 | end 55 | 56 | def encode_as_utf(value) 57 | return value unless value.is_a?(String) 58 | value.encode("utf-8", invalid: :replace, undef: :replace, replace: '_') 59 | end 60 | 61 | def serialize_array(attribute_array) 62 | attribute_array.map do |x| 63 | x.respond_to?(:as_json) ? x.as_json : encode_as_utf(x) 64 | end 65 | end 66 | 67 | def serialize_hash(attribute_hash) 68 | attribute_hash.inject({}) do |temp_mem, (temp_key, temp_value)| 69 | if temp_value.respond_to?(:as_json) 70 | temp_mem[temp_key] = temp_value.as_json 71 | else 72 | temp_mem[temp_key] = encode_as_utf(temp_value) 73 | end 74 | end 75 | end 76 | 77 | module ClassMethods 78 | def message_attributes(*incoming_attributes) 79 | if incoming_attributes.empty? && defined?(@message_attributes) 80 | @message_attributes 81 | else 82 | @message_attributes ||= [] 83 | new_attributes = incoming_attributes.flatten 84 | @message_attributes += new_attributes 85 | attr_accessor *new_attributes 86 | end 87 | end 88 | end 89 | end 90 | 91 | class Base 92 | def initialize(options) 93 | options.each do |key, value| 94 | if self.respond_to?("#{key}=") 95 | send("#{key}=", value) 96 | end 97 | end 98 | end 99 | 100 | def message_type 101 | Invoker::IPC.underscore(self.class.name).split("/").last 102 | end 103 | 104 | def command_handler_klass 105 | Invoker::IPC.const_get("#{IPC.camelize(message_type)}Command") 106 | end 107 | end 108 | 109 | class Add < Base 110 | include Serialization 111 | message_attributes :process_name 112 | end 113 | 114 | class Tail < Base 115 | include Serialization 116 | message_attributes :process_names 117 | end 118 | 119 | class AddHttp < Base 120 | include Serialization 121 | message_attributes :process_name, :port, :ip 122 | end 123 | 124 | class Reload < Base 125 | include Serialization 126 | message_attributes :process_name, :signal 127 | 128 | def remove_message 129 | Remove.new(process_name: process_name, signal: signal) 130 | end 131 | end 132 | 133 | class List < Base 134 | include Serialization 135 | end 136 | 137 | class Process < Base 138 | include Serialization 139 | message_attributes :process_name, :shell_command, :dir, :pid, :port 140 | end 141 | 142 | class Remove < Base 143 | include Serialization 144 | message_attributes :process_name, :signal 145 | end 146 | 147 | class DnsCheck < Base 148 | include Serialization 149 | message_attributes :process_name 150 | end 151 | 152 | class DnsCheckResponse < Base 153 | include Serialization 154 | message_attributes :process_name, :port, :ip 155 | end 156 | 157 | class Ping < Base 158 | include Serialization 159 | end 160 | 161 | class Pong < Base 162 | include Serialization 163 | message_attributes :status 164 | end 165 | end 166 | end 167 | end 168 | 169 | require "invoker/ipc/message/list_response" 170 | require "invoker/ipc/message/tail_response" 171 | -------------------------------------------------------------------------------- /lib/invoker/parsers/config.rb: -------------------------------------------------------------------------------- 1 | require 'iniparse' 2 | 3 | module Invoker 4 | module Parsers 5 | class Config 6 | PORT_REGEX = /\$PORT/ 7 | 8 | attr_accessor :processes, :power_config 9 | attr_reader :filename 10 | 11 | # initialize takes a port form cli and decrements it by 1 and sets the 12 | # instance variable @port. This port value is used as the environment 13 | # variable $PORT mentioned inside invoker.ini. When method pick_port gets 14 | # fired it increments the value of port by 1, subsequently when pick_port 15 | # again gets fired, for another command, it will again increment port 16 | # value by 1, that way generating different ports for different commands. 17 | def initialize(filename, port) 18 | @filename = filename || autodetect_config_file 19 | print_message_and_abort if invalid_config_file? 20 | 21 | @port = port - 1 22 | @processes = load_config 23 | if Invoker.can_run_balancer? 24 | @power_config = Invoker::Power::Config.load_config() 25 | end 26 | end 27 | 28 | def http_port 29 | power_config && power_config.http_port 30 | end 31 | 32 | def dns_port 33 | power_config && power_config.dns_port 34 | end 35 | 36 | def https_port 37 | power_config && power_config.https_port 38 | end 39 | 40 | def tld 41 | power_config && power_config.tld 42 | end 43 | 44 | def autorunnable_processes 45 | process_to_run = processes.reject(&:disable_autorun) 46 | process_to_run.sort_by { |process| process.index } 47 | end 48 | 49 | def process(label) 50 | processes.detect { |pconfig| pconfig.label == label } 51 | end 52 | 53 | private 54 | 55 | def autodetect_config_file 56 | Dir.glob("{invoker.ini,Procfile.dev,Procfile}").first 57 | end 58 | 59 | def invalid_config_file? 60 | @filename.nil? 61 | end 62 | 63 | def load_config 64 | @filename = to_global_file if is_global? 65 | 66 | if is_ini? 67 | process_ini 68 | elsif is_procfile? 69 | process_procfile 70 | else 71 | print_message_and_abort 72 | end 73 | end 74 | 75 | def process_ini 76 | ini_content = File.read(@filename) 77 | document = IniParse.parse(ini_content) 78 | document.map do |section| 79 | check_directory(section["directory"]) 80 | process_command_from_section(section) 81 | end 82 | end 83 | 84 | def process_procfile 85 | procfile = Invoker::Parsers::Procfile.new(@filename) 86 | procfile.entries.map do |name, command| 87 | section = { "label" => name, "command" => command } 88 | process_command_from_section(section) 89 | end 90 | end 91 | 92 | def print_message_and_abort 93 | Invoker::Logger.puts("\n Invalid config file. Invoker requires an ini or a Procfile.".colorize(:red)) 94 | abort 95 | end 96 | 97 | def process_command_from_section(section) 98 | if supports_subdomain?(section) 99 | port = pick_port(section) 100 | if port 101 | command = replace_port_in_command(section['command'], port) 102 | section['port'], section['command'] = port, command 103 | end 104 | end 105 | 106 | make_pconfig(section) 107 | end 108 | 109 | def pick_port(section) 110 | if section['command'] =~ PORT_REGEX 111 | @port += 1 112 | elsif section['port'] 113 | section['port'] 114 | else 115 | nil 116 | end 117 | end 118 | 119 | def make_pconfig(section) 120 | pconfig = { 121 | label: section["label"] || section.key, 122 | dir: expand_directory(section["directory"]), 123 | cmd: section["command"] 124 | } 125 | pconfig['port'] = section['port'] if section['port'] 126 | pconfig['disable_autorun'] = section['disable_autorun'] if section['disable_autorun'] 127 | pconfig['index'] = section['index'].to_i if section['index'] 128 | section_index = pconfig['index'].to_i 129 | if section_index 130 | pconfig['index'] = section_index 131 | else 132 | pconfig['index'] = 0 133 | end 134 | 135 | sleep_duration = section['sleep'].to_i 136 | if sleep_duration >= 0 137 | pconfig['sleep_duration'] = sleep_duration 138 | else 139 | pconfig['sleep_duration'] = 0 140 | end 141 | 142 | OpenStruct.new(pconfig) 143 | end 144 | 145 | def supports_subdomain?(section) 146 | (section['command'] =~ PORT_REGEX) || section['port'] 147 | end 148 | 149 | def check_directory(app_dir) 150 | if app_dir && !app_dir.empty? && !File.directory?(expand_directory(app_dir)) 151 | raise Invoker::Errors::InvalidConfig.new("Invalid directory #{app_dir}") 152 | end 153 | end 154 | 155 | def expand_directory(app_dir) 156 | File.expand_path(app_dir) if app_dir 157 | end 158 | 159 | def replace_port_in_command(command, port) 160 | if command =~ PORT_REGEX 161 | command.gsub(PORT_REGEX, port.to_s) 162 | else 163 | command 164 | end 165 | end 166 | 167 | def is_ini? 168 | File.extname(@filename) == '.ini' 169 | end 170 | 171 | def is_procfile? 172 | @filename =~ /Procfile/ 173 | end 174 | 175 | def to_global_file 176 | File.join(Invoker::Power::Config.config_dir, "#{@filename}.ini") 177 | end 178 | 179 | def is_global? 180 | @filename =~ /^\w+$/ && File.exist?(to_global_file) 181 | end 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /spec/invoker/commander_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Invoker::Commander" do 4 | before(:each) do 5 | @original_invoker_config = Invoker.config 6 | Invoker.config = mock 7 | end 8 | 9 | after(:each) do 10 | Invoker.config = @original_invoker_config 11 | end 12 | 13 | describe "With no processes configured" do 14 | before(:each) do 15 | @commander = Invoker::Commander.new 16 | end 17 | 18 | it "should throw error" do 19 | Invoker.config.stubs(:processes).returns([]) 20 | 21 | expect { 22 | @commander.start_manager 23 | }.to raise_error(Invoker::Errors::InvalidConfig) 24 | end 25 | end 26 | 27 | describe "#start_process" do 28 | describe "when not daemonized" do 29 | before do 30 | processes = [OpenStruct.new(:label => "foobar", :cmd => "foobar_command", :dir => ENV['HOME'], :sleep_duration => 2)] 31 | Invoker.config.stubs(:processes).returns(processes) 32 | Invoker.config.stubs(:autorunnable_processes).returns(processes) 33 | Invoker.stubs(:can_run_balancer?).returns(false) 34 | @commander = Invoker::Commander.new 35 | Invoker.commander = @commander 36 | end 37 | 38 | after do 39 | Invoker.commander = nil 40 | end 41 | 42 | it "should populate workers and open_pipes" do 43 | @commander.expects(:start_event_loop) 44 | @commander.process_manager.expects(:load_env).returns({}) 45 | @commander.process_manager.expects(:spawn).returns(100) 46 | @commander.process_manager.expects(:wait_on_pid) 47 | @commander.expects(:at_exit) 48 | @commander.start_manager 49 | expect(@commander.process_manager.open_pipes).not_to be_empty 50 | expect(@commander.process_manager.workers).not_to be_empty 51 | 52 | worker = @commander.process_manager.workers['foobar'] 53 | 54 | expect(worker).not_to be_nil 55 | expect(worker.command_label).to eq('foobar') 56 | 57 | pipe_end_worker = @commander.process_manager.open_pipes[worker.pipe_end.fileno] 58 | expect(pipe_end_worker).not_to be_nil 59 | end 60 | end 61 | 62 | describe "when daemonized" do 63 | before do 64 | processes = [OpenStruct.new(:label => "foobar", :cmd => "foobar_command", :dir => ENV['HOME'], :sleep_duration => 2)] 65 | Invoker.config.stubs(:processes).returns(processes) 66 | Invoker.config.stubs(:autorunnable_processes).returns(processes) 67 | Invoker.stubs(:can_run_balancer?).returns(false) 68 | @commander = Invoker::Commander.new 69 | Invoker.commander = @commander 70 | Invoker.daemonize = true 71 | end 72 | 73 | after do 74 | Invoker.commander = nil 75 | Invoker.daemonize = false 76 | end 77 | 78 | it "should daemonize the process and populate workers and open_pipes" do 79 | @commander.expects(:start_event_loop) 80 | @commander.process_manager.expects(:load_env).returns({}) 81 | Invoker.daemon.expects(:start).once 82 | @commander.process_manager.expects(:spawn).returns(100) 83 | @commander.process_manager.expects(:wait_on_pid) 84 | @commander.expects(:at_exit) 85 | @commander.start_manager 86 | 87 | expect(@commander.process_manager.open_pipes).not_to be_empty 88 | expect(@commander.process_manager.workers).not_to be_empty 89 | 90 | worker = @commander.process_manager.workers['foobar'] 91 | 92 | expect(worker).not_to be_nil 93 | expect(worker.command_label).to eq('foobar') 94 | 95 | pipe_end_worker = @commander.process_manager.open_pipes[worker.pipe_end.fileno] 96 | expect(pipe_end_worker).not_to be_nil 97 | end 98 | end 99 | end 100 | 101 | describe 'disable_autorun option' do 102 | context 'autorun is disabled for a process' do 103 | before do 104 | @processes = [ 105 | OpenStruct.new(:label => "foobar", :cmd => "foobar_command", :dir => ENV['HOME'], :sleep_duration => 2), 106 | OpenStruct.new(:label => "panda", :cmd => "panda_command", :dir => ENV['HOME'], :disable_autorun => true, :sleep_duration => 2) 107 | ] 108 | Invoker.config.stubs(:processes).returns(@processes) 109 | Invoker.config.stubs(:autorunnable_processes).returns([@processes.first]) 110 | 111 | @commander = Invoker::Commander.new 112 | end 113 | 114 | it "doesn't run process" do 115 | @commander.expects(:install_interrupt_handler) 116 | @commander.process_manager.expects(:run_power_server) 117 | @commander.expects(:at_exit) 118 | @commander.expects(:start_event_loop) 119 | 120 | @commander.process_manager.expects(:start_process).with(@processes[0]) 121 | @commander.process_manager.expects(:start_process).with(@processes[1]).never 122 | @commander.start_manager 123 | end 124 | end 125 | end 126 | 127 | describe "#runnables" do 128 | before do 129 | @commander = Invoker::Commander.new 130 | end 131 | 132 | it "should run runnables in reactor tick with one argument" do 133 | @commander.on_next_tick("foo") { |cmd| start_process_by_name(cmd) } 134 | @commander.expects(:start_process_by_name).returns(true) 135 | @commander.run_runnables() 136 | end 137 | 138 | it "should run runnables with multiple args" do 139 | @commander.on_next_tick("foo", "bar", "baz") { |t1,*rest| 140 | stop_process(t1, rest) 141 | } 142 | @commander.expects(:stop_process).with("foo", ["bar", "baz"]).returns(true) 143 | @commander.run_runnables() 144 | end 145 | 146 | it "should run runnable with no args" do 147 | @commander.on_next_tick() { hello() } 148 | @commander.expects(:hello).returns(true) 149 | @commander.run_runnables() 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /spec/invoker/power/setup/linux_setup_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "invoker/power/setup/distro/ubuntu" 3 | require "invoker/power/setup/distro/opensuse" 4 | 5 | def mock_socat_scripts 6 | FakeFS.deactivate! 7 | socat_content = File.read(invoker_setup.forwarder_script) 8 | socat_systemd = File.read(invoker_setup.socat_unit) 9 | FakeFS.activate! 10 | FileUtils.mkdir_p(File.dirname(invoker_setup.forwarder_script)) 11 | FileUtils.mkdir_p(File.dirname(invoker_setup.socat_unit)) 12 | File.open(invoker_setup.socat_unit, "w") do |fl| 13 | fl.write(socat_systemd) 14 | end 15 | File.open(invoker_setup.forwarder_script, "w") do |fl| 16 | fl.write(socat_content) 17 | end 18 | FileUtils.mkdir_p("/usr/bin") 19 | FileUtils.mkdir_p("/etc/systemd/system") 20 | end 21 | 22 | describe Invoker::Power::LinuxSetup, fakefs: true do 23 | before do 24 | FileUtils.mkdir_p(inv_conf_dir) 25 | FileUtils.mkdir_p(Invoker::Power::Distro::Base::RESOLVER_DIR) 26 | Invoker.config = mock 27 | end 28 | 29 | let(:invoker_setup) { Invoker::Power::LinuxSetup.new('test') } 30 | let(:distro_installer) { Invoker::Power::Distro::Ubuntu.new('test') } 31 | 32 | before do 33 | invoker_setup.distro_installer = distro_installer 34 | end 35 | 36 | it "should only proceed after user confirmation" do 37 | distro_installer.expects(:get_user_confirmation?).returns(false) 38 | 39 | invoker_setup.setup_invoker 40 | 41 | expect { Invoker::Power::Config.load_config }.to raise_error(Errno::ENOENT) 42 | end 43 | 44 | it "should create config file with http(s) ports" do 45 | invoker_setup.expects(:initialize_distro_installer).returns(true) 46 | invoker_setup.expects(:install_resolver).returns(true) 47 | invoker_setup.expects(:install_port_forwarder).returns(true) 48 | invoker_setup.expects(:drop_to_normal_user).returns(true) 49 | 50 | distro_installer.expects(:get_user_confirmation?).returns(true) 51 | distro_installer.expects(:install_required_software) 52 | distro_installer.expects(:restart_services) 53 | 54 | invoker_setup.setup_invoker 55 | 56 | config = Invoker::Power::Config.load_config 57 | expect(config.tld).to eq('test') 58 | expect(config.http_port).not_to be_nil 59 | expect(config.dns_port).to be_nil 60 | expect(config.https_port).not_to be_nil 61 | end 62 | 63 | describe "configuring services" do 64 | let(:config) { Invoker::Power::Config.load_config } 65 | 66 | before(:all) do 67 | @original_invoker_config = Invoker.config 68 | end 69 | 70 | after(:all) do 71 | Invoker.config = @original_invoker_config 72 | end 73 | 74 | before(:each) do 75 | mock_socat_scripts 76 | end 77 | 78 | def run_setup 79 | invoker_setup.expects(:initialize_distro_installer).returns(true) 80 | invoker_setup.expects(:drop_to_normal_user).returns(true) 81 | 82 | distro_installer.expects(:get_user_confirmation?).returns(true) 83 | distro_installer.expects(:install_required_software) 84 | distro_installer.expects(:restart_services) 85 | 86 | invoker_setup.setup_invoker 87 | end 88 | 89 | def test_socat_config 90 | socat_content = File.read(Invoker::Power::Distro::Base::SOCAT_SHELLSCRIPT) 91 | expect(socat_content.strip).to_not be_empty 92 | expect(socat_content.strip).to match(/#{config.https_port}/) 93 | expect(socat_content.strip).to match(/#{config.http_port}/) 94 | 95 | service_file = File.read(Invoker::Power::Distro::Base::SOCAT_SYSTEMD) 96 | expect(service_file.strip).to_not be_empty 97 | end 98 | 99 | context 'on ubuntu with systemd-resolved' do 100 | it "should create socat config & set tld to localhost" do 101 | distro_installer.expects(:using_systemd_resolved?).at_least_once.returns(true) 102 | run_setup 103 | expect(distro_installer.resolver_file).to be_nil 104 | test_socat_config 105 | expect(config.tld).to eq('localhost') 106 | end 107 | end 108 | 109 | context 'on non-systemd-resolved distro' do 110 | it "should create dnsmasq & socat configs" do 111 | run_setup 112 | dnsmasq_content = File.read(distro_installer.resolver_file) 113 | expect(dnsmasq_content.strip).to_not be_empty 114 | expect(dnsmasq_content).to match(/test/) 115 | 116 | test_socat_config 117 | end 118 | end 119 | end 120 | 121 | describe 'resolver file' do 122 | context 'user sets up a custom top level domain' do 123 | let(:tld) { 'local' } 124 | let(:linux_setup) { Invoker::Power::LinuxSetup.new(tld) } 125 | 126 | context 'on ubuntu with systemd-resolved' do 127 | it 'should not create a resolver file' do 128 | ubuntu_installer = Invoker::Power::Distro::Ubuntu.new(tld) 129 | linux_setup.distro_installer = ubuntu_installer 130 | ubuntu_installer.expects(:using_systemd_resolved?).at_least_once.returns(true) 131 | expect(linux_setup.resolver_file).to eq(nil) 132 | end 133 | end 134 | 135 | context 'on non-systemd-resolved distro' do 136 | it 'should create the correct resolver file' do 137 | suse_installer = Invoker::Power::Distro::Opensuse.new(tld) 138 | linux_setup.distro_installer = suse_installer 139 | expect(linux_setup.resolver_file).to eq("/etc/dnsmasq.d/#{tld}-tld") 140 | end 141 | end 142 | end 143 | end 144 | end 145 | 146 | describe Invoker::Power::Distro::Base, docker: true do 147 | describe '.distro_installer' do 148 | it 'correctly recognizes the current distro' do 149 | case ENV['DISTRO'] 150 | when 'archlinux', 'manjarolinux/base' 151 | expect(described_class.distro_installer('')).to be_a Invoker::Power::Distro::Arch 152 | when 'debian' 153 | expect(described_class.distro_installer('')).to be_a Invoker::Power::Distro::Debian 154 | when 'fedora' 155 | expect(described_class.distro_installer('')).to be_a Invoker::Power::Distro::Redhat 156 | when 'linuxmintd/mint20-amd64', 'ubuntu' 157 | expect(described_class.distro_installer('')).to be_a Invoker::Power::Distro::Ubuntu 158 | when 'opensuse/leap', 'opensuse/tumbleweed' 159 | expect(described_class.distro_installer('')).to be_a Invoker::Power::Distro::Opensuse 160 | when nil 161 | else 162 | raise 'Unrecognized Linux distro. Please add the appropriate docker image to the travis build matrix, update the described method, and add a case here.' 163 | end 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/invoker/cli.rb: -------------------------------------------------------------------------------- 1 | require "socket" 2 | require "thor" 3 | 4 | module Invoker 5 | class CLI < Thor 6 | def self.start(*args) 7 | cli_args = args.flatten 8 | # If it is not a valid task, it is probably file argument 9 | if default_start_command?(cli_args) 10 | args = [cli_args.unshift("start")] 11 | end 12 | super(*args) 13 | end 14 | 15 | desc "setup", "Run Invoker setup" 16 | option :tld, 17 | type: :string, 18 | banner: 'Configure invoker to use a different top level domain' 19 | def setup 20 | Invoker::Power::Setup.install(get_tld(options)) 21 | end 22 | map install: :setup 23 | 24 | desc "version", "Print Invoker version" 25 | def version 26 | Invoker::Logger.puts Invoker::VERSION 27 | end 28 | map %w(-v --version) => :version 29 | 30 | desc "uninstall", "Uninstall Invoker and all installed files" 31 | def uninstall 32 | Invoker::Power::Setup.uninstall 33 | end 34 | 35 | desc "start [CONFIG_FILE]", "Start Invoker Server" 36 | option :port, type: :numeric, banner: "Port series to be used for starting rack servers" 37 | option :daemon, 38 | type: :boolean, 39 | banner: "Daemonize the server into the background", 40 | aliases: [:d] 41 | option :nocolors, 42 | type: :boolean, 43 | banner: "Disable color in output", 44 | aliases: [:nc] 45 | option :certificate, 46 | type: :string, 47 | banner: "Path to certificate" 48 | option :private_key, 49 | type: :string, 50 | banner: "Path to private key" 51 | def start(file = nil) 52 | Invoker.setup_config_location 53 | port = options[:port] || 9000 54 | Invoker.daemonize = options[:daemon] 55 | Invoker.nocolors = options[:nocolors] 56 | Invoker.certificate = options[:certificate] 57 | Invoker.private_key = options[:private_key] 58 | Invoker.load_invoker_config(file, port) 59 | warn_about_notification 60 | pinger = Invoker::CLI::Pinger.new(unix_socket) 61 | abort("Invoker is already running".colorize(:red)) if pinger.invoker_running? 62 | Invoker.commander.start_manager 63 | end 64 | 65 | desc "add process", "Add a program to Invoker server" 66 | def add(name) 67 | unix_socket.send_command('add', process_name: name) 68 | end 69 | 70 | desc "add_http process_name port [IP]", "Add an external http process to Invoker DNS server" 71 | def add_http(name, port, ip = nil) 72 | unix_socket.send_command('add_http', process_name: name, port: port, ip: ip) 73 | end 74 | 75 | desc "tail process1 process2", "Tail a particular process" 76 | def tail(*names) 77 | tailer = Invoker::CLI::Tail.new(names) 78 | tailer.run 79 | end 80 | 81 | desc "log process1", "Get log of particular process" 82 | def log(process_name) 83 | system("egrep -a '^#{process_name}' #{Invoker.daemon.log_file}") 84 | end 85 | 86 | desc "reload process", "Reload a process managed by Invoker" 87 | option :signal, 88 | banner: "Signal to send for killing the process, default is SIGINT", 89 | aliases: [:s] 90 | def reload(name) 91 | signal = options[:signal] || 'INT' 92 | unix_socket.send_command('reload', process_name: name, signal: signal) 93 | end 94 | map restart: :reload 95 | 96 | desc "list", "List all running processes" 97 | option :raw, 98 | type: :boolean, 99 | banner: "Print process list in raw text format", 100 | aliases: [:r] 101 | option :wait, 102 | type: :boolean, 103 | banner: "wait for update", 104 | aliases: [:w] 105 | def list 106 | if options[:wait] 107 | Signal.trap("INT") { exit(0) } 108 | loop do 109 | puts "\e[H\e[2J" 110 | unix_socket.send_command('list') do |response_object| 111 | Invoker::ProcessPrinter.new(response_object).tap { |printer| printer.print_table } 112 | end 113 | sleep(5) 114 | end 115 | else 116 | unix_socket.send_command('list') do |response_object| 117 | if options[:raw] 118 | Invoker::ProcessPrinter.new(response_object).tap { |printer| printer.print_raw_text } 119 | else 120 | Invoker::ProcessPrinter.new(response_object).tap { |printer| printer.print_table } 121 | end 122 | end 123 | end 124 | end 125 | 126 | desc "remove process", "Stop a process managed by Invoker" 127 | option :signal, 128 | banner: "Signal to send for killing the process, default is SIGINT", 129 | aliases: [:s] 130 | def remove(name) 131 | signal = options[:signal] || 'INT' 132 | unix_socket.send_command('remove', process_name: name, signal: signal) 133 | end 134 | 135 | desc "stop", "Stop Invoker daemon" 136 | def stop 137 | Invoker.daemon.stop 138 | end 139 | 140 | private 141 | 142 | def self.default_start_command?(args) 143 | command_name = args.first 144 | command_name && 145 | !command_name.match(/^-/) && 146 | !valid_tasks.include?(command_name) 147 | end 148 | 149 | def self.valid_tasks 150 | tasks.keys + %w(help install restart) 151 | end 152 | 153 | # TODO(kgrz): the default TLD option is duplicated in both this file and 154 | # lib/invoker.rb May be assign this to a constant? 155 | def get_tld(options) 156 | if options[:tld] && !options[:tld].empty? 157 | options[:tld] 158 | else 159 | 'test' 160 | end 161 | end 162 | 163 | def unix_socket 164 | Invoker::IPC::UnixClient.new 165 | end 166 | 167 | def warn_about_notification 168 | if Invoker.darwin? 169 | warn_about_terminal_notifier 170 | else 171 | warn_about_libnotify 172 | end 173 | end 174 | 175 | def warn_about_libnotify 176 | require "libnotify" 177 | rescue LoadError 178 | Invoker::Logger.puts "You can install libnotify gem for Invoker notifications "\ 179 | "via system tray".colorize(:red) 180 | end 181 | 182 | def warn_about_terminal_notifier 183 | if Invoker.darwin? 184 | command_path = `which terminal-notifier` 185 | if !command_path || command_path.empty? 186 | Invoker::Logger.puts "You can enable OSX notification for processes "\ 187 | "by installing terminal-notifier gem".colorize(:red) 188 | end 189 | end 190 | end 191 | end 192 | end 193 | 194 | require "invoker/cli/question" 195 | require "invoker/cli/tail_watcher" 196 | require "invoker/cli/tail" 197 | require "invoker/cli/pinger" 198 | -------------------------------------------------------------------------------- /lib/invoker/process_manager.rb: -------------------------------------------------------------------------------- 1 | module Invoker 2 | # Class is responsible for managing all the processes Invoker is supposed 3 | # to manage. Takes care of starting, stopping and restarting processes. 4 | class ProcessManager 5 | LABEL_COLORS = [:green, :yellow, :blue, :magenta, :cyan] 6 | attr_accessor :open_pipes, :workers 7 | 8 | def initialize 9 | @open_pipes = {} 10 | @workers = {} 11 | @worker_mutex = Mutex.new 12 | @thread_group = ThreadGroup.new 13 | end 14 | 15 | def start_process(process_info) 16 | m, s = PTY.open 17 | s.raw! # disable newline conversion. 18 | 19 | pid = run_command(process_info, s) 20 | 21 | s.close 22 | 23 | worker = CommandWorker.new(process_info.label, m, pid, select_color) 24 | 25 | add_worker(worker) 26 | wait_on_pid(process_info.label, pid) 27 | end 28 | 29 | # Start a process given their name 30 | # @param process_name [String] Command label of process specified in config file. 31 | def start_process_by_name(process_name) 32 | if process_running?(process_name) 33 | Invoker::Logger.puts "\nProcess '#{process_name}' is already running".colorize(:red) 34 | return false 35 | end 36 | 37 | process_info = Invoker.config.process(process_name) 38 | start_process(process_info) if process_info 39 | end 40 | 41 | # Remove a process from list of processes managed by invoker supervisor.It also 42 | # kills the process before removing it from the list. 43 | # 44 | # @param remove_message [Invoker::IPC::Message::Remove] 45 | # @return [Boolean] if process existed and was removed else false 46 | def stop_process(remove_message) 47 | worker = workers[remove_message.process_name] 48 | command_label = remove_message.process_name 49 | return false unless worker 50 | signal_to_use = remove_message.signal || 'INT' 51 | 52 | Invoker::Logger.puts("Removing #{command_label} with signal #{signal_to_use}".colorize(:red)) 53 | kill_or_remove_process(worker.pid, signal_to_use, command_label) 54 | end 55 | 56 | # Receive a message from user to restart a Process 57 | # @param [Invoker::IPC::Message::Reload] 58 | def restart_process(reload_message) 59 | command_label = reload_message.process_name 60 | if stop_process(reload_message.remove_message) 61 | Invoker.commander.schedule_event(command_label, :worker_removed) do 62 | start_process_by_name(command_label) 63 | end 64 | else 65 | start_process_by_name(command_label) 66 | end 67 | end 68 | 69 | def run_power_server 70 | return unless Invoker.can_run_balancer?(false) 71 | 72 | powerup_id = Invoker::Power::Powerup.fork_and_start 73 | wait_on_pid("powerup_manager", powerup_id) 74 | at_exit do 75 | begin 76 | Process.kill("INT", powerup_id) 77 | rescue Errno::ESRCH; end 78 | end 79 | end 80 | 81 | # Given a file descriptor returns the worker object 82 | # 83 | # @param fd [IO] an IO object with valid file descriptor 84 | # @return [Invoker::CommandWorker] The worker object which is associated with this fd 85 | def get_worker_from_fd(fd) 86 | open_pipes[fd.fileno] 87 | end 88 | 89 | def load_env(directory = nil) 90 | directory ||= ENV['PWD'] 91 | 92 | if !directory || directory.empty? || !Dir.exist?(directory) 93 | return {} 94 | end 95 | 96 | default_env = File.join(directory, '.env') 97 | local_env = File.join(directory, '.env.local') 98 | env = {} 99 | 100 | if File.exist?(default_env) 101 | env.merge!(Dotenv::Environment.new(default_env)) 102 | end 103 | 104 | if File.exist?(local_env) 105 | env.merge!(Dotenv::Environment.new(local_env)) 106 | end 107 | 108 | env 109 | end 110 | 111 | def kill_workers 112 | @workers.each do |key, worker| 113 | kill_or_remove_process(worker.pid, "INT", worker.command_label) 114 | end 115 | @workers = {} 116 | end 117 | 118 | # List currently running commands 119 | def process_list 120 | Invoker::IPC::Message::ListResponse.from_workers(workers) 121 | end 122 | 123 | private 124 | 125 | def wait_on_pid(command_label, pid) 126 | raise Invoker::Errors::ToomanyOpenConnections if @thread_group.enclosed? 127 | 128 | thread = Thread.new do 129 | Process.wait(pid) 130 | message = "Process with command #{command_label} exited with status #{$?.exitstatus}" 131 | Invoker::Logger.puts("\n#{message}".colorize(:red)) 132 | Invoker.notify_user(message) 133 | Invoker.commander.trigger(command_label, :exit) 134 | end 135 | @thread_group.add(thread) 136 | end 137 | 138 | def select_color 139 | selected_color = LABEL_COLORS.shift 140 | LABEL_COLORS.push(selected_color) 141 | selected_color 142 | end 143 | 144 | def process_running?(command_label) 145 | !!workers[command_label] 146 | end 147 | 148 | def kill_or_remove_process(pid, signal_to_use, command_label) 149 | process_kill(pid, signal_to_use) 150 | true 151 | rescue Errno::ESRCH 152 | Invoker::Logger.puts("Killing process with #{pid} and name #{command_label} failed".colorize(:red)) 153 | remove_worker(command_label, false) 154 | false 155 | end 156 | 157 | def process_kill(pid, signal_to_use) 158 | if signal_to_use.to_i == 0 159 | Process.kill(signal_to_use, -Process.getpgid(pid)) 160 | else 161 | Process.kill(signal_to_use.to_i, -Process.getpgid(pid)) 162 | end 163 | end 164 | 165 | # Remove worker from all collections 166 | def remove_worker(command_label, trigger_event = true) 167 | worker = @workers[command_label] 168 | if worker 169 | @open_pipes.delete(worker.pipe_end.fileno) 170 | @workers.delete(command_label) 171 | # Move label color to front of array so it's reused first 172 | LABEL_COLORS.delete(worker.color) 173 | LABEL_COLORS.unshift(worker.color) 174 | end 175 | if trigger_event 176 | Invoker.commander.trigger(command_label, :worker_removed) 177 | end 178 | end 179 | 180 | # add worker to global collections 181 | def add_worker(worker) 182 | @open_pipes[worker.pipe_end.fileno] = worker 183 | @workers[worker.command_label] = worker 184 | Invoker.commander.watch_for_read(worker.pipe_end) 185 | end 186 | 187 | def run_command(process_info, write_pipe) 188 | command_label = process_info.label 189 | 190 | Invoker.commander.schedule_event(command_label, :exit) { remove_worker(command_label) } 191 | 192 | env_options = load_env(process_info.dir) 193 | 194 | spawn_options = { 195 | :chdir => process_info.dir || ENV['PWD'], :out => write_pipe, :err => write_pipe, 196 | :pgroup => true, :close_others => true, :in => :close 197 | } 198 | Invoker.run_without_bundler { spawn(env_options, process_info.cmd, spawn_options) } 199 | end 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /spec/invoker/config_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | require "tempfile" 4 | 5 | describe "Invoker::Config" do 6 | describe "with invalid directory" do 7 | it "should raise error during startup" do 8 | begin 9 | file = Tempfile.new(["invalid_config", ".ini"]) 10 | 11 | config_data =<<-EOD 12 | [try_sleep] 13 | directory = /Users/gnufied/foo 14 | command = ruby try_sleep.rb 15 | EOD 16 | file.write(config_data) 17 | file.close 18 | expect { 19 | Invoker::Parsers::Config.new(file.path, 9000) 20 | }.to raise_error(Invoker::Errors::InvalidConfig) 21 | ensure 22 | file.unlink() 23 | end 24 | end 25 | end 26 | 27 | describe "with relative directory path" do 28 | it "should expand path in commands" do 29 | begin 30 | file = Tempfile.new(["config", ".ini"]) 31 | 32 | config_data =<<-EOD 33 | [pwd_home] 34 | directory = ~ 35 | command = pwd 36 | 37 | [pwd_parent] 38 | directory = ../ 39 | command = pwd 40 | EOD 41 | file.write(config_data) 42 | file.close 43 | 44 | config = Invoker::Parsers::Config.new(file.path, 9000) 45 | command1 = config.processes.first 46 | 47 | expect(command1.dir).to match(File.expand_path('~')) 48 | 49 | command2 = config.processes[1] 50 | 51 | expect(command2.dir).to match(File.expand_path('..')) 52 | ensure 53 | file.unlink() 54 | end 55 | end 56 | end 57 | 58 | describe "for ports" do 59 | it "should replace port in commands" do 60 | begin 61 | file = Tempfile.new(["invalid_config", ".ini"]) 62 | 63 | config_data =<<-EOD 64 | [try_sleep] 65 | directory = /tmp 66 | command = ruby try_sleep.rb -p $PORT 67 | 68 | [ls] 69 | directory = /tmp 70 | command = ls -p $PORT 71 | 72 | [noport] 73 | directory = /tmp 74 | command = ls 75 | EOD 76 | file.write(config_data) 77 | file.close 78 | 79 | config = Invoker::Parsers::Config.new(file.path, 9000) 80 | command1 = config.processes.first 81 | 82 | expect(command1.port).to eq(9000) 83 | expect(command1.cmd).to match(/9000/) 84 | 85 | command2 = config.processes[1] 86 | 87 | expect(command2.port).to eq(9001) 88 | expect(command2.cmd).to match(/9001/) 89 | 90 | command2 = config.processes[2] 91 | 92 | expect(command2.port).to be_nil 93 | ensure 94 | file.unlink() 95 | end 96 | end 97 | 98 | it "should use port from separate option" do 99 | begin 100 | file = Tempfile.new(["invalid_config", ".ini"]) 101 | config_data =<<-EOD 102 | [try_sleep] 103 | directory = /tmp 104 | command = ruby try_sleep.rb -p $PORT 105 | 106 | [ls] 107 | directory = /tmp 108 | port = 3000 109 | command = pwd 110 | 111 | [noport] 112 | directory = /tmp 113 | command = ls 114 | EOD 115 | file.write(config_data) 116 | file.close 117 | 118 | config = Invoker::Parsers::Config.new(file.path, 9000) 119 | command1 = config.processes.first 120 | 121 | expect(command1.port).to eq(9000) 122 | expect(command1.cmd).to match(/9000/) 123 | 124 | command2 = config.processes[1] 125 | 126 | expect(command2.port).to eq(3000) 127 | 128 | command2 = config.processes[2] 129 | 130 | expect(command2.port).to be_nil 131 | ensure 132 | file.unlink() 133 | end 134 | end 135 | end 136 | 137 | describe "loading power config", fakefs: true do 138 | before do 139 | FileUtils.mkdir_p('/tmp') 140 | FileUtils.mkdir_p(inv_conf_dir) 141 | File.open("/tmp/foo.ini", "w") { |fl| fl.write("") } 142 | end 143 | 144 | it "does not load config if platform is darwin but there is no power config file" do 145 | Invoker::Power::Config.expects(:load_config).never 146 | Invoker::Parsers::Config.new("/tmp/foo.ini", 9000) 147 | end 148 | 149 | it "loads config if platform is darwin and power config file exists" do 150 | File.open(Invoker::Power::Config.config_file, "w") { |fl| fl.puts "sample" } 151 | Invoker::Power::Config.expects(:load_config).once 152 | Invoker::Parsers::Config.new("/tmp/foo.ini", 9000) 153 | end 154 | end 155 | 156 | describe "Procfile" do 157 | it "should load Procfiles and create config object" do 158 | File.open("/tmp/Procfile", "w") {|fl| 159 | fl.write <<-EOD 160 | web: bundle exec rails s -p $PORT 161 | EOD 162 | } 163 | config = Invoker::Parsers::Config.new("/tmp/Procfile", 9000) 164 | command1 = config.processes.first 165 | 166 | expect(command1.port).to eq(9000) 167 | expect(command1.cmd).to match(/bundle exec rails/) 168 | end 169 | end 170 | 171 | describe "Copy of DNS information" do 172 | it "should allow copy of DNS information" do 173 | File.open("/tmp/Procfile", "w") {|fl| 174 | fl.write <<-EOD 175 | web: bundle exec rails s -p $PORT 176 | EOD 177 | } 178 | Invoker.load_invoker_config("/tmp/Procfile", 9000) 179 | dns_cache = Invoker::DNSCache.new(Invoker.config) 180 | 181 | expect(dns_cache.dns_data).to_not be_empty 182 | expect(dns_cache.dns_data['web']).to_not be_empty 183 | expect(dns_cache.dns_data['web']['port']).to eql 9000 184 | end 185 | end 186 | 187 | describe "#autorunnable_processes" do 188 | it "returns a list of processes that can be autorun" do 189 | begin 190 | file = Tempfile.new(["config", ".ini"]) 191 | config_data =<<-EOD 192 | [postgres] 193 | command = postgres -D /usr/local/var/postgres 194 | 195 | [redis] 196 | command = redis-server /usr/local/etc/redis.conf 197 | disable_autorun = true 198 | 199 | [memcached] 200 | command = /usr/local/opt/memcached/bin/memcached 201 | disable_autorun = false 202 | 203 | [panda-api] 204 | command = bundle exec rails s 205 | disable_autorun = true 206 | 207 | [panda-auth] 208 | command = bundle exec rails s -p $PORT 209 | EOD 210 | file.write(config_data) 211 | file.close 212 | 213 | config = Invoker::Parsers::Config.new(file.path, 9000) 214 | expect(config.autorunnable_processes.map(&:label)).to eq(['postgres', 'memcached', 'panda-auth']) 215 | ensure 216 | file.unlink() 217 | end 218 | end 219 | 220 | it "returns a list of processes that can by index" do 221 | begin 222 | file = Tempfile.new(["config", ".ini"]) 223 | config_data =<<-EOD 224 | [postgres] 225 | command = postgres -D /usr/local/var/postgres 226 | index = 2 227 | sleep = 5 228 | 229 | [redis] 230 | command = redis-server /usr/local/etc/redis.conf 231 | disable_autorun = true 232 | index = 3 233 | 234 | [memcached] 235 | command = /usr/local/opt/memcached/bin/memcached 236 | disable_autorun = false 237 | index = 5 238 | 239 | [panda-api] 240 | command = bundle exec rails s 241 | disable_autorun = true 242 | index = 4 243 | 244 | [panda-auth] 245 | command = bundle exec rails s -p $PORT 246 | index = 1 247 | EOD 248 | file.write(config_data) 249 | file.close 250 | 251 | config = Invoker::Parsers::Config.new(file.path, 9000) 252 | processes = config.autorunnable_processes 253 | expect(processes.map(&:label)).to eq(['panda-auth', 'postgres', 'memcached']) 254 | expect(processes[0].sleep_duration).to eq(0) 255 | expect(processes[1].sleep_duration).to eq(5) 256 | ensure 257 | file.unlink() 258 | end 259 | end 260 | end 261 | 262 | describe "global config file" do 263 | it "should use global config file if available" do 264 | begin 265 | FileUtils.mkdir_p(Invoker::Power::Config.config_dir) 266 | filename = "#{Invoker::Power::Config.config_dir}/foo.ini" 267 | file = File.open(filename, "w") 268 | config_data =<<-EOD 269 | [try_sleep] 270 | directory = /tmp 271 | command = ruby try_sleep.rb 272 | EOD 273 | file.write(config_data) 274 | file.close 275 | config = Invoker::Parsers::Config.new("foo", 9000) 276 | expect(config.filename).to eql(filename) 277 | ensure 278 | File.unlink(filename) 279 | end 280 | end 281 | end 282 | 283 | describe "config file autodetection" do 284 | context "no config file given" do 285 | 286 | def create_invoker_ini 287 | file = File.open("invoker.ini", "w") 288 | config_data =<<-EOD 289 | [some_process] 290 | command = some_command 291 | EOD 292 | file.write(config_data) 293 | file.close 294 | 295 | file 296 | end 297 | 298 | def create_procfile 299 | file = File.open("Procfile", "w") 300 | config_data =<<-EOD 301 | some_other_process: some_other_command 302 | EOD 303 | file.write(config_data) 304 | file.close 305 | 306 | file 307 | end 308 | 309 | context "directory has invoker.ini" do 310 | it "autodetects invoker.ini" do 311 | begin 312 | file = create_invoker_ini 313 | 314 | config = Invoker::Parsers::Config.new(nil, 9000) 315 | expect(config.process("some_process").cmd).to eq("some_command") 316 | ensure 317 | File.delete(file) 318 | end 319 | end 320 | end 321 | 322 | context "directory has Procfile" do 323 | it "autodetects Procfile" do 324 | begin 325 | file = create_procfile 326 | 327 | config = Invoker::Parsers::Config.new(nil, 9000) 328 | expect(config.process("some_other_process").cmd).to eq("some_other_command") 329 | ensure 330 | File.delete(file) 331 | end 332 | end 333 | end 334 | 335 | context "directory has both invoker.ini and Procfile" do 336 | it "prioritizes invoker.ini" do 337 | begin 338 | invoker_ini = create_invoker_ini 339 | procfile = create_procfile 340 | 341 | config = Invoker::Parsers::Config.new(nil, 9000) 342 | expect(config.process("some_process").cmd).to eq("some_command") 343 | processes = config.autorunnable_processes 344 | process_1 = processes[0] 345 | expect(process_1.sleep_duration).to eq(0) 346 | expect(process_1.index).to eq(0) 347 | ensure 348 | File.delete(invoker_ini) 349 | File.delete(procfile) 350 | end 351 | end 352 | end 353 | 354 | context "directory doesn't have invoker.ini or Procfile" do 355 | it "aborts" do 356 | expect { Invoker::Parsers::Config.new(nil, 9000) }.to raise_error(SystemExit) 357 | end 358 | end 359 | end 360 | end 361 | end 362 | --------------------------------------------------------------------------------