diff --git a/shard.yml b/shard.yml index e5b413a..1746b59 100644 --- a/shard.yml +++ b/shard.yml @@ -9,6 +9,8 @@ description: | targets: service: - main: src/main.cr + main: src/service.cr + status: + main: src/status.cr license: MIT diff --git a/src/config.cr b/src/config.cr new file mode 100644 index 0000000..ddd014a --- /dev/null +++ b/src/config.cr @@ -0,0 +1,8 @@ + +# Those are set to local to make testing easier. Will change at some point. +PID_DIRECTORY = "./pid" +RC_DIRECTORY = "./rc" +LOG_DIRECTORY = "./log" +SERVICES_DIRECTORY = "./services" +ENVIRONMENTS_DIRECTORY = "./environments" + diff --git a/src/main.cr b/src/main.cr deleted file mode 100644 index 7828d28..0000000 --- a/src/main.cr +++ /dev/null @@ -1,141 +0,0 @@ -require "option_parser" -require "yaml" -require "colorize" - -# Those are set to local to make testing easier. -PID_DIRECTORY = "./pid" -RC_DIRECTORY = "./rc" -LOG_DIRECTORY = "./log" -SERVICES_DIRECTORY = "./services" -ENVIRONMENTS_DIRECTORY = "./environments" - -# TODO: -# - Be more declarative about the definition of commands. - -require "./service_definition.cr" -require "./environment.cr" -require "./service.cr" - -args = [] of String - -parser = OptionParser.parse! do |parser| - parser.banner = "usage: service [options]\n" + - "\n" + - "commands:\n" + - " start Starts a stopped or dead service.\n" + - " stop Stops a running service.\n" + - " status Shows the current state of a service.\n" + - " show Describe a service in detail.\n" + - " add Add a service to an environment.\n" + - " del Remove a service from an environment.\n" + - " list Lists registered services.\n" + - " list-environments Lists registered environments.\n" + - "\n" + - "options:\n" - - parser.on "-h", "--help", "Prints this help message." do - puts parser - exit 0 - end - parser.unknown_args do |x| - args = x - end -end - -command = args[0]? -if command.nil? - STDERR << parser << "\n" - exit 1 -end - -ServiceDefinition.load SERVICES_DIRECTORY -Environment.load ENVIRONMENTS_DIRECTORY -Service.load RC_DIRECTORY - -begin - if args[0] == "help" - puts parser - elsif args[0] == "add" - Service.new(args[1], args[2]?).write RC_DIRECTORY - elsif args[0] == "del" - Service.new(args[1], args[2]?).remove RC_DIRECTORY - elsif args[0] == "start" - services = args[1..args.size].map do |arg| - service = Service.get_by_id(arg) - - unless service - raise Service::Exception.new "Service '#{arg}' does not exist." - end - - service - end - - services.each do |service| - service.dependency_tree.flatten.reverse.each do |service| - next if service.running? PID_DIRECTORY - - puts "starting #{service.to_s}" - service.start PID_DIRECTORY, LOG_DIRECTORY - end - end - elsif args[0] == "stop" - services = args[1..args.size].map do |arg| - service = Service.get_by_id(arg) - - unless service - raise Service::Exception.new "Service '#{arg}' does not exist." - end - - service - end - - services.each do |service| - # FIXME: Build revdep tree and stop services started as dependencies? - next if ! service.running? PID_DIRECTORY - # FIXME: Should we remove duplicate services from the - # tree once flattened? - service.reverse_dependency_tree.flatten.reverse.each do |service| - next if ! service.running? PID_DIRECTORY - - puts "stopping #{service.to_s}" - service.stop PID_DIRECTORY - end - end - elsif args[0] == "status" - puts Service.new(args[1], args[2]?).status PID_DIRECTORY - elsif args[0] == "show" - service = Service.all.find do |service| - unless service.name == args[1] - next false - end - - env = args[2]? || "root" - if service.environment.name != env - next false - end - - true - end - if service - puts service.summary - else - STDERR << "No such service is registered.\n" - exit 2 - end - elsif args[0] == "list" - Service.all.map do |service| - puts service.to_s - end - elsif args[0] == "list-environments" - Environment.all.map do |env| - puts env.to_s - end - else - STDERR << parser << "\n" - exit 1 - end -rescue e : Service::Exception - STDERR << e.message << "\n" - exit 2 -end - diff --git a/src/service.cr b/src/service.cr index f77516e..5b71e52 100644 --- a/src/service.cr +++ b/src/service.cr @@ -1,347 +1,134 @@ +require "option_parser" require "yaml" require "colorize" -require "./libc.cr" +require "./config.cr" -def split_command(string) - args = string.split /\ (?=([^\"]*\"[^\"]*\")*[^\"]*$)/ +# TODO: +# - Be more declarative about the definition of commands. - command = args[0] - args.delete_at 0 +require "./service/*" - return command, args +args = [] of String + +parser = OptionParser.parse! do |parser| + parser.banner = "usage: service [options]\n" + + "\n" + + "commands:\n" + + " start Starts a stopped or dead service.\n" + + " stop Stops a running service.\n" + + " status Shows the current state of a service.\n" + + " show Describe a service in detail.\n" + + " add Add a service to an environment.\n" + + " del Remove a service from an environment.\n" + + " list Lists registered services.\n" + + " list-environments Lists registered environments.\n" + + "\n" + + "options:\n" + + parser.on "-h", "--help", "Prints this help message." do + puts parser + exit 0 + end + parser.unknown_args do |x| + args = x + end end -class Service - getter environment : Environment - getter providers = ProvidersList.new +command = args[0]? +if command.nil? + STDERR << parser << "\n" + exit 1 +end - class Exception < ::Exception - end +ServiceDefinition.load SERVICES_DIRECTORY +Environment.load ENVIRONMENTS_DIRECTORY +Service.load RC_DIRECTORY - class ProvidersList < Hash(String, String) - def []?(name) - super(name).try { |x| Service.get_by_id x} - end - end - - struct AsYAML - YAML.mapping({ - name: String, - environment: String, - consumes: { - type: Array(YAMLConsumes), - default: [] of YAMLConsumes - } - }) - end - struct YAMLConsumes - YAML.mapping({ - token: String, - from: String - }) - end - - def initialize(name, environment_name : String?, @consumes = [] of YAMLConsumes) - @reference = ServiceDefinition.get name - @environment = if environment_name.nil? || environment_name == "" - Environment.root - else - Environment.get environment_name - end - - @consumes.each do |consume| - @providers[consume.token] = consume.from - end - end - - def self.from_yaml(yaml) - yaml = AsYAML.from_yaml yaml - - - self.new yaml.name, yaml.environment, yaml.consumes - end - def to_yaml - { - name: name, - environment: @environment.name - }.to_yaml - end - - def id - if @environment.name == "root" - name - else - "#{@environment.name}/#{name}" - end - end - - # FIXME: At this point, macros would be both more readable and shorter. - def name - @reference.name - end - def type - @reference.type - end - def command - @reference.command - end - def stop_command - @reference.stop_command - end - def provides - @reference.provides - end - - def export_environment_variables - ENV["SERVICE_ENVIRONMENT"] = @environment.name - ENV["SERVICE_ENVIRONMENT_TYPE"] = @environment.type.to_s - - # FIXME: Parsing should probably be done… when parsing the file. - # FIXME: Parsing is probably a bit primitive. Maybe this isn’t the right way of defining this. - @reference.environment_variables.each do |string| - # FIXME: Should probably deserve a warning. - variable = string.match(/^[^=]*=/).not_nil![0] - value = string[variable.size..string.size] - variable = variable[0..variable.size-2] - - ENV[variable] = value - end - end - private def evaluate(string) - string.gsub /%{[a-zA-Z]+}/ do |match| - match = match[2..match.size-2] - - if match.downcase == "environment" - @environment.name - else - "" - end - end - end - - def start(pid_dir : String, log_dir : String) - export_environment_variables - - (@environment.checks + @reference.checks).each do |check| - run_check = false - - check.file.try do |file| - run_check = true if ! File.exists? evaluate file - end - - check.directory.try do |directory| - run_check = true if ! Dir.exists? evaluate directory - end - - unless run_check - next - end - - puts " - #{check.name}" - - child = Process.fork do - @reference.user.try do |user| - unless System.become_user user - STDERR << "service: child could not setuid() to user '#{user}'.\n" - exit 1 - end - end - - Process.exec "sh", ["-c", evaluate check.command], output: Process::Redirect::Inherit, error: Process::Redirect::Inherit - end.wait - - if child.exit_status != 0 - raise Service::Exception.new "Child process exited with status “#{child.exit_status}”." - break - end - end - - # FIXME: Should evaluate be used in split_command? What namespace should split_command use? - command, args = split_command command - args.map! do |arg| evaluate arg end - - process = Process.fork do - base_log_name = "#{log_dir}/#{name}.#{@environment.name}" - stdout_file = File.open "#{base_log_name}.out", "w" - stderr_file = File.open "#{base_log_name}.err", "w" - - LibC.dup2 stdout_file.fd, 1 - LibC.dup2 stderr_file.fd, 2 - - Process.exec command, args, chdir: @reference.directory - end - - self.save_pid pid_dir, process.pid - end - - # TODO: - # - Custom shutdown commands. - # - Should we wait for the process to die? - # - Shouldn’t we remove the pid file? - def stop(pid_dir : String) - _pid = pid pid_dir - - if _pid - command = stop_command - if command - command, args = split_command command - - Process.run(command, args) - else - Process.kill Signal::TERM, _pid - end - else - # Already stopped or dead, nothing to be done here. - end - end - - def get_pid_file(pid_dir) - "#{pid_dir}/#{name}.pid" - end - - def pid(pid_dir) - File.read(get_pid_file pid_dir).to_i - rescue e # pid file missing, corrupted or otherwise not readable - nil - end - - def save_pid(pid_dir, new_pid) - File.write get_pid_file(pid_dir), new_pid - end - - enum Status - Running - Dead - Stopped - end - - def status(pid_dir) - _pid = pid pid_dir - - if _pid - if Process.exists? _pid - Status::Running - else - Status::Dead - end - else - Status::Stopped - end - end - def running?(pid_dir) - status(pid_dir) == Service::Status::Running - end - - def to_s - "#{name} (in #{@environment.name})" - end - - def summary - "Name: #{name}\n" + - "Environment: #{environment.name} (#{environment.type.to_s.downcase})\n" + - ( - if provides.size > 0 - "Provides:\n" + - (provides.map { |x| " - " + x.token + "\n" }).join - else - "" - end - ) + - ( - if @reference.consumes.size > 0 - "Consumes:\n" + - (@reference.consumes.map { |x| - provider = @providers[x.token]? - if provider - " - #{x.token} (from #{provider.id})\n" - else - " - #{x.token} " + - "(NOT CURRENTLY PROVIDED)".colorize(:red).to_s + - "\n" - end - }).join - else - "" - end - ) - end - - class_getter all = [] of Service - def self.load(path) - Dir.each_child path do |child| - unless child.match /\.yaml$/ - next - end - - @@all << Service.from_yaml File.read "#{path}/#{child}" - end - end - - def write(path) - File.write "#{path}/#{name}.#{@environment.name}.yaml", to_yaml - end - - def remove(path) - File.delete "#{path}/#{name}.#{@environment.name}.yaml" - end - - def self.get_by_id(id) - matches = id.match /[^\/]*/ - unless matches # Should not happen, above regex would always match. - raise Exception.new "FIXME" - end - environment_name = matches[0] - service_name = if environment_name == id - environment_name = "root" - id - else - id.sub 0..matches[0].size, "" - end - - @@all.find do |service| - service.name == service_name - end - end - def is_id?(id) - id == self.id || (@environment.name == "root" && id == "root/#{name}") - end - - alias ServiceTree = Array(ServiceTree) | Service - - # Returns a dependency tree. - # You’ll probably want to flatten and reverse it afterwards. - def dependency_tree - tree = [self] of ServiceTree - - @consumes.each do |token| - service = Service.get_by_id token.from +begin + if args[0] == "help" + puts parser + elsif args[0] == "add" + Service.new(args[1], args[2]?).write RC_DIRECTORY + elsif args[0] == "del" + Service.new(args[1], args[2]?).remove RC_DIRECTORY + elsif args[0] == "start" + services = args[1..args.size].map do |arg| + service = Service.get_by_id(arg) unless service - # FIXME: Does it make the dep tree invalid? - # FIXME: Only optional deps should be fine. - next + raise Service::Exception.new "Service '#{arg}' does not exist." end - tree << service.dependency_tree + service end - tree - end + services.each do |service| + service.dependency_tree.flatten.reverse.each do |service| + next if service.running? PID_DIRECTORY - def reverse_dependency_tree - tree = [self] of ServiceTree - - @@all.each do |service| - service.providers.any? do |token, id| - if self.is_id?(id) && ! tree.any? service - tree << service - end + puts "starting #{service.to_s}" + service.start PID_DIRECTORY, LOG_DIRECTORY end end + elsif args[0] == "stop" + services = args[1..args.size].map do |arg| + service = Service.get_by_id(arg) - tree + unless service + raise Service::Exception.new "Service '#{arg}' does not exist." + end + + service + end + + services.each do |service| + # FIXME: Build revdep tree and stop services started as dependencies? + next if ! service.running? PID_DIRECTORY + # FIXME: Should we remove duplicate services from the + # tree once flattened? + service.reverse_dependency_tree.flatten.reverse.each do |service| + next if ! service.running? PID_DIRECTORY + + puts "stopping #{service.to_s}" + service.stop PID_DIRECTORY + end + end + elsif args[0] == "status" + puts Service.new(args[1], args[2]?).status PID_DIRECTORY + elsif args[0] == "show" + service = Service.all.find do |service| + unless service.name == args[1] + next false + end + + env = args[2]? || "root" + if service.environment.name != env + next false + end + + true + end + if service + puts service.summary + else + STDERR << "No such service is registered.\n" + exit 2 + end + elsif args[0] == "list" + Service.all.map do |service| + puts service.to_s + end + elsif args[0] == "list-environments" + Environment.all.map do |env| + puts env.to_s + end + else + STDERR << parser << "\n" + exit 1 end +rescue e : Service::Exception + STDERR << e.message << "\n" + exit 2 end - diff --git a/src/environment.cr b/src/service/environment.cr similarity index 100% rename from src/environment.cr rename to src/service/environment.cr diff --git a/src/libc.cr b/src/service/libc.cr similarity index 100% rename from src/libc.cr rename to src/service/libc.cr diff --git a/src/service/service.cr b/src/service/service.cr new file mode 100644 index 0000000..a83ed07 --- /dev/null +++ b/src/service/service.cr @@ -0,0 +1,349 @@ +require "yaml" +require "colorize" + +require "./service_definition.cr" +require "./environment.cr" +require "./libc.cr" + +def split_command(string) + args = string.split /\ (?=([^\"]*\"[^\"]*\")*[^\"]*$)/ + + command = args[0] + args.delete_at 0 + + return command, args +end + +class Service + getter environment : Environment + getter providers = ProvidersList.new + + class Exception < ::Exception + end + + class ProvidersList < Hash(String, String) + def []?(name) + super(name).try { |x| Service.get_by_id x} + end + end + + struct AsYAML + YAML.mapping({ + name: String, + environment: String, + consumes: { + type: Array(YAMLConsumes), + default: [] of YAMLConsumes + } + }) + end + struct YAMLConsumes + YAML.mapping({ + token: String, + from: String + }) + end + + def initialize(name, environment_name : String?, @consumes = [] of YAMLConsumes) + @reference = ServiceDefinition.get name + @environment = if environment_name.nil? || environment_name == "" + Environment.root + else + Environment.get environment_name + end + + @consumes.each do |consume| + @providers[consume.token] = consume.from + end + end + + def self.from_yaml(yaml) + yaml = AsYAML.from_yaml yaml + + + self.new yaml.name, yaml.environment, yaml.consumes + end + def to_yaml + { + name: name, + environment: @environment.name + }.to_yaml + end + + def id + if @environment.name == "root" + name + else + "#{@environment.name}/#{name}" + end + end + + # FIXME: At this point, macros would be both more readable and shorter. + def name + @reference.name + end + def type + @reference.type + end + def command + @reference.command + end + def stop_command + @reference.stop_command + end + def provides + @reference.provides + end + + def export_environment_variables + ENV["SERVICE_ENVIRONMENT"] = @environment.name + ENV["SERVICE_ENVIRONMENT_TYPE"] = @environment.type.to_s + + # FIXME: Parsing should probably be done… when parsing the file. + # FIXME: Parsing is probably a bit primitive. Maybe this isn’t the right way of defining this. + @reference.environment_variables.each do |string| + # FIXME: Should probably deserve a warning. + variable = string.match(/^[^=]*=/).not_nil![0] + value = string[variable.size..string.size] + variable = variable[0..variable.size-2] + + ENV[variable] = value + end + end + private def evaluate(string) + string.gsub /%{[a-zA-Z]+}/ do |match| + match = match[2..match.size-2] + + if match.downcase == "environment" + @environment.name + else + "" + end + end + end + + def start(pid_dir : String, log_dir : String) + export_environment_variables + + (@environment.checks + @reference.checks).each do |check| + run_check = false + + check.file.try do |file| + run_check = true if ! File.exists? evaluate file + end + + check.directory.try do |directory| + run_check = true if ! Dir.exists? evaluate directory + end + + unless run_check + next + end + + puts " - #{check.name}" + + child = Process.fork do + @reference.user.try do |user| + unless System.become_user user + STDERR << "service: child could not setuid() to user '#{user}'.\n" + exit 1 + end + end + + Process.exec "sh", ["-c", evaluate check.command], output: Process::Redirect::Inherit, error: Process::Redirect::Inherit + end.wait + + if child.exit_status != 0 + raise Service::Exception.new "Child process exited with status “#{child.exit_status}”." + break + end + end + + # FIXME: Should evaluate be used in split_command? What namespace should split_command use? + command, args = split_command command + args.map! do |arg| evaluate arg end + + process = Process.fork do + base_log_name = "#{log_dir}/#{name}.#{@environment.name}" + stdout_file = File.open "#{base_log_name}.out", "w" + stderr_file = File.open "#{base_log_name}.err", "w" + + LibC.dup2 stdout_file.fd, 1 + LibC.dup2 stderr_file.fd, 2 + + Process.exec command, args, chdir: @reference.directory + end + + self.save_pid pid_dir, process.pid + end + + # TODO: + # - Custom shutdown commands. + # - Should we wait for the process to die? + # - Shouldn’t we remove the pid file? + def stop(pid_dir : String) + _pid = pid pid_dir + + if _pid + command = stop_command + if command + command, args = split_command command + + Process.run(command, args) + else + Process.kill Signal::TERM, _pid + end + else + # Already stopped or dead, nothing to be done here. + end + end + + def get_pid_file(pid_dir) + "#{pid_dir}/#{name}.pid" + end + + def pid(pid_dir) + File.read(get_pid_file pid_dir).to_i + rescue e # pid file missing, corrupted or otherwise not readable + nil + end + + def save_pid(pid_dir, new_pid) + File.write get_pid_file(pid_dir), new_pid + end + + enum Status + Running + Dead + Stopped + end + + def status(pid_dir) + _pid = pid pid_dir + + if _pid + if Process.exists? _pid + Status::Running + else + Status::Dead + end + else + Status::Stopped + end + end + def running?(pid_dir) + status(pid_dir) == Service::Status::Running + end + + def to_s + "#{name} (in #{@environment.name})" + end + + def summary + "Name: #{name}\n" + + "Environment: #{environment.name} (#{environment.type.to_s.downcase})\n" + + ( + if provides.size > 0 + "Provides:\n" + + (provides.map { |x| " - " + x.token + "\n" }).join + else + "" + end + ) + + ( + if @reference.consumes.size > 0 + "Consumes:\n" + + (@reference.consumes.map { |x| + provider = @providers[x.token]? + if provider + " - #{x.token} (from #{provider.id})\n" + else + " - #{x.token} " + + "(NOT CURRENTLY PROVIDED)".colorize(:red).to_s + + "\n" + end + }).join + else + "" + end + ) + end + + class_getter all = [] of Service + def self.load(path) + Dir.each_child path do |child| + unless child.match /\.yaml$/ + next + end + + @@all << Service.from_yaml File.read "#{path}/#{child}" + end + end + + def write(path) + File.write "#{path}/#{name}.#{@environment.name}.yaml", to_yaml + end + + def remove(path) + File.delete "#{path}/#{name}.#{@environment.name}.yaml" + end + + def self.get_by_id(id) + matches = id.match /[^\/]*/ + unless matches # Should not happen, above regex would always match. + raise Exception.new "FIXME" + end + environment_name = matches[0] + service_name = if environment_name == id + environment_name = "root" + id + else + id.sub 0..matches[0].size, "" + end + + @@all.find do |service| + service.name == service_name + end + end + def is_id?(id) + id == self.id || (@environment.name == "root" && id == "root/#{name}") + end + + alias ServiceTree = Array(ServiceTree) | Service + + # Returns a dependency tree. + # You’ll probably want to flatten and reverse it afterwards. + def dependency_tree + tree = [self] of ServiceTree + + @consumes.each do |token| + service = Service.get_by_id token.from + + unless service + # FIXME: Does it make the dep tree invalid? + # FIXME: Only optional deps should be fine. + next + end + + tree << service.dependency_tree + end + + tree + end + + def reverse_dependency_tree + tree = [self] of ServiceTree + + @@all.each do |service| + service.providers.any? do |token, id| + if self.is_id?(id) && ! tree.any? service + tree << service + end + end + end + + tree + end +end + + diff --git a/src/service_definition.cr b/src/service/service_definition.cr similarity index 100% rename from src/service_definition.cr rename to src/service/service_definition.cr diff --git a/src/status.cr b/src/status.cr new file mode 100644 index 0000000..5416ac7 --- /dev/null +++ b/src/status.cr @@ -0,0 +1,16 @@ + +require "./service/service.cr" + +require "./config.cr" + +ServiceDefinition.load SERVICES_DIRECTORY +Environment.load ENVIRONMENTS_DIRECTORY +Service.load RC_DIRECTORY + +Service.get_by_id(ARGV[0]).try do |service| + puts service.status PID_DIRECTORY + exit 0 +end + +exit 1 +