require "yaml" require "colorize" require "file_utils" require "passwd" require "./context.cr" 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 lib C fun waitpid(pid : Int32, status_ptr : Int32*, options : Int32) : Int32 end class Process def self.waitpid(pid) options = 0 status_ptr = uninitialized Int32 C.waitpid(pid, pointerof(status_ptr), options) end end class Service getter environment : Environment getter providers : ProvidersList setter name : String? property domain : String? getter ports = Hash(String, Int32).new @reference : ServiceDefinition # The place we’ll store configuration and data. @root : String? @context : Context class Exception < ::Exception end class ProvidersList < Hash(String, String) @context : Service::Context def initialize(@context) initialize end def []?(name) super(name).try { |x| @context.get_service_by_id x} end end struct Consumer getter token : String getter from : String def initialize(@token, @from) end end def initialize(@context : Context, name, environment_name : String?) @providers = ProvidersList.new @context @reference = context.get_service_definition_by_name name @environment = if environment_name.nil? || environment_name == "" @context.root_environment else @context.get_environment_by_name environment_name end end def initialize(@context : Context, specs : SpecParser) @providers = ProvidersList.new @context assignments = specs.assignments # Complicated because of compat with when services had no dedicated # name and their types were their name. type = assignments["type"]?.try &.as_s @name = (assignments["name"]?.try(&.as_s) || type).not_nil! @reference = context.get_service_definition_by_name (type || @name).not_nil! @domain = assignments["domain"]?.try &.as_s env = assignments["environment"]?.try &.as_s @environment = if env.nil? || env == "" @context.root_environment else @context.get_environment_by_name env end @root = assignments["root"]?.try &.as_s assignments["ports"]?.try &.as_a_or_s.each do |port_string| match = port_string.match /(.*)=(.*)/ if match.nil? STDERR.puts "warning: '#{id}' has invalid port strings" next end _, name, number = match number = number.to_i @ports[name] = number end specs.sections.select(&.name.==("consumes")).each do |section| env, provider = Service.parse_id section.content["from"].as_s @providers[section.options[0]] = "#{env}/#{provider}" end end def to_spec file = [ "type: #{@reference.name}", "environment: #{@environment.name}" ] unless @name.nil? file << "name: #{@name}" end if @ports.size > 0 ports_list = @ports.map do |name, number| "#{name}=#{number}" end.join ", " file << "ports: #{ports_list}" end if @root file << "root: #{@root}" end if @domain file << "domain: #{@domain}" end @providers.each do |token, provider| file << "%consumes #{token}" file << " from: #{provider}" end file.join("\n") + "\n" end def full_id "#{@environment.name}/#{name}" end def id if @environment.name == "root" name else full_id end end # FIXME: At this point, macros would be both more readable and shorter. def name @name || @reference.name end def type @reference.name end def command @reference.command end def stop_command @reference.stop_command end def reload_command @reference.reload_command end def readiness_check_command @reference.readiness_check_command end def provides @reference.provides end def consumes @reference.consumes end def requires_domain @reference.requires_domain end def port_definitions @reference.port_definitions end def non_runnable @reference.non_runnable end def root @root || "#{@environment.root}/#{name}" end def provides?(token) provides.any? do |provider| provider.token == token end end @commands_environment : Hash(String, String)? private def build_commands_environment! env = {} of String => String env["SERVICE_NAME"] = name env["SERVICE_ROOT"] = root env["SERVICE_ID"] = full_id env["SERVICE_USER"] = user_name if _pid = pid @context.pid_directory env["SERVICE_PID"] = _pid.to_s end env["ENVIRONMENT"] = @environment.name env["ENVIRONMENT_TYPE"] = @environment.type.to_s @providers.each do |token, provider| service_provider = @context.get_service_by_id provider # FIXME: Warning if mandatory? next if service_provider.nil? env["#{token.upcase}_PROVIDER"] = provider env["#{token.upcase}_ENVIRONMENT"] = service_provider.environment.name env["#{token.upcase}_ROOT"] = service_provider.root end env["SERVICE_TOKENS"] = @providers.to_a.map(&.[0]).join ':' @ports.each do |name, number| name = name.upcase env["PORT_#{name}"] = number.to_s end # 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 @commands_environment = env end def commands_environment : Hash(String, String) @commands_environment || build_commands_environment! end # FIXME: Is working on ${} really a good idea? # FIXME: There should be a unified way of obtaining a service’s # environment, and we should take our variables from that # instead of hardcoding everything. private def evaluate(string) env = commands_environment string.gsub /\${[a-zA-Z_]+}/ do |match| match = match[2..match.size-2] env[match.upcase]? || "" end end def files @environment.files + @reference.files end def build_files!(context : Context) files.each do |file| run_hook = false next unless creation_command = file.creation_command path = evaluate file.file_path if path[0] != '/' path = "#{root}/#{path}" end run_hook = (!File.exists? path) || file.is_configuration? next unless run_hook context.info "Creating #{file.name}" uid, gid = get_uid_gid child = Process.fork do Dir.cd root System.become_user user_name Process.exec "sh", ["-c", creation_command], output: Process::Redirect::Inherit, error: Process::Redirect::Inherit, env: commands_environment end.wait unless child.success? raise Service::Exception.new "Child process exited with status “#{(child.exit_status/256).to_i}”." break end end end def start(context : Context) return if running? context.pid_directory if non_runnable context.title "Setting up #{to_s}" else context.title "Starting #{to_s}" end create_user_and_group! uid, gid = get_uid_gid FileUtils.mkdir_p root File.chown root, uid, gid build_files! context return if non_runnable # 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 = "#{context.log_directory}/#{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 unless @reference.start_as_root System.become_user user_name end Process.exec command, args, chdir: (@reference.directory.try { |x| evaluate x } || root), env: commands_environment end self.save_pid context.pid_directory, process.pid # FIXME: At this point, let’s wait a few seconds for the service to be ready. if command = readiness_check_command context.info "Waiting for service to become ready…" start_time = Time.local now = Time.local while (now - start_time).seconds < 5 r = Process.run "sh", ["-c", command], output: Process::Redirect::Inherit, error: Process::Redirect::Inherit, env: commands_environment if r.success? break end sleep 0.05 now = Time.local end end provides.each do |token_definition| reverse_dependencies = get_consumers(token_definition.token) .map(&.id) .compact_map do |id| context.get_service_by_id id end reverse_dependencies.each do |service| next unless service.non_runnable should_start = service .dependency_tree.flatten .select(&.!=(self)) .map(&.status(context)) .map do |status| status.running? || status.non_runnable? end .reduce do |a, b| a && b end if should_start service.start context end end end end # TODO: # - Custom shutdown commands. # - Should we wait for the process to die? # - Shouldn’t we remove the pid file? def stop(context : Context) return if non_runnable context.title "Stopping #{to_s}" _pid = pid context.pid_directory if _pid command = stop_command if command command, args = split_command command Process.run(command, args) else Process.kill Signal::TERM, _pid end Process.waitpid _pid File.delete(get_pid_file context.pid_directory) else # Already stopped or dead, nothing to be done here. end end def reloadable? !@reload_command.nil? end def reload(context : Context) build_files! context command = reload_command unless command raise Exception.new "This service cannot be reloaded!" end Process.fork do Dir.cd root Process.exec "sh", ["-c", command], output: Process::Redirect::Inherit, error: Process::Redirect::Inherit, env: commands_environment end.wait end def get_pid_file(pid_dir) "#{pid_dir}/#{name}.#{environment.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) FileUtils.mkdir_p pid_dir File.write get_pid_file(pid_dir), new_pid end enum Status Running Dead Stopped NonRunnable end def status(pid_dir) return Status::NonRunnable if non_runnable _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 "%-16s #{name.colorize(:white).to_s}\n" % "Name:" + "%-16s #{type.colorize(:white).to_s}\n" % "Type:" + "%-16s #{environment.name.colorize(:white).to_s} (#{environment.type.to_s.downcase})\n" % "Environment:" + ( if provides.size > 0 "Provides:\n" + (provides.map { |x| consumers = get_consumers(x.token) consumers_string = if consumers.size > 0 consumers.map(&.full_id.colorize(:green).to_s).join ", " else "n/a".colorize(:yellow).to_s end " - %-12s #{consumers_string}\n" % "#{x.token}:" }).join else "" end ) + ( if @reference.consumes.size > 0 "Consumes:\n" + (@reference.consumes.map { |x| provider = @providers[x.token]? provider_string = if provider provider.full_id.colorize(:green).to_s elsif x.optional "n/a".colorize(:yellow).to_s else "MISSING".colorize(:red).to_s end " - %-12s #{provider_string}\n" % x.token }).join else "" end ) + ( if @ports.size > 0 "Ports:\n" + (@ports.map { |name, number| " - %-12s #{number.colorize(:magenta).to_s}\n" % "#{name}:" }).join else "" end ) end def write(path) FileUtils.mkdir_p "#{path}" File.write "#{path}/#{name}.#{@environment.name}.spec", to_spec end def remove(context) context.title "Removing #{to_s}" files.reverse.each do |file| file_path = evaluate file.file_path context.info "Removing #{file.name}" command = file.deletion_command child = Process.fork do Dir.cd root exit 0 unless File.exists? file_path if command Process.exec "sh", ["-c", command], output: Process::Redirect::Inherit, error: Process::Redirect::Inherit, env: commands_environment else FileUtils.rm_rf file_path end end.wait unless child.success? raise Service::Exception.new "Child process exited with status “#{(child.exit_status/256).to_i}”." break end end remove_user_and_group! File.delete "#{context.services_directory}/#{name}.#{@environment.name}.spec" FileUtils.rm_rf root end def is_id?(id) id == self.id || (@environment.name == "root" && id == "root/#{name}") end def self.parse_id(id) : Tuple(String, String) s = id.split '/' environment = s[0] service = s[1]? if service.nil? service = environment environment = "root" end {environment, service} 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 @providers.each do |token, provider_id| service = @context.get_service_by_id provider_id 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_dependencies rdeps = [self] i = 0 while i < rdeps.size item = rdeps[i] @context.services.each do |service| service.providers.any? do |token, id| if item.is_id?(id) && ! rdeps.any? service rdeps << service end end end i += 1 end rdeps end def get_default_provider(token) : String? @environment.get_provider(token) || @context.root_environment.get_provider(token) end def consumes?(token, origin) providers.select do |_token, provider| token == _token && origin.is_id?(provider) end.size > 0 end def get_consumers(token) @context.services.select(&.consumes?(token, self)) end def default_user_name full_id.sub('/', '.') end def user_name @reference.user || default_user_name end def group_name @reference.group || default_user_name end def get_uid_gid passwd = Passwd.new("/etc/passwd", "/etc/group") user = passwd.get_user(user_name).not_nil! {user.uid, user.gid} end def create_user_and_group! Passwd.new("/etc/passwd", "/etc/group").tap do |passwd| if user = passwd.get_user user_name group = passwd.get_group user_name return if group.nil? group.users = reverse_dependencies.map &.user_name passwd.mod_group group else passwd.add_user user_name, full_name: "Service[#{id}]" end end end def remove_user_and_group! Passwd.new("/etc/passwd", "/etc/group").tap do |passwd| passwd.remove_user user_name passwd.remove_group user_name end end end