├── .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 | [](https://travis-ci.org/code-mancers/invoker)
4 | [](https://codeclimate.com/github/code-mancers/invoker)
5 | [](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 |
--------------------------------------------------------------------------------