service/src/service/service.cr

565 lines
12 KiB
Crystal
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

require "yaml"
require "colorize"
require "file_utils"
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.new
setter name : String?
property domain : String?
getter ports = Hash(String, Int32).new
# The place well store configuration and data.
@root : String?
class Exception < ::Exception
end
class ProvidersList < Hash(String, String)
def []?(name)
super(name).try { |x| Service.get_by_id x}
end
end
struct Consumer
getter token : String
getter from : String
def initialize(@token, @from)
end
end
def initialize(name, environment_name : String?)
@reference = ServiceDefinition.get name
@environment = if environment_name.nil? || environment_name == ""
Environment.root
else
Environment.get environment_name
end
end
def initialize(specs : SpecParser)
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 = ServiceDefinition.get type || @name
@domain = assignments["domain"]?.try &.as_s
env = assignments["environment"]?.try &.as_s
@environment = if env.nil? || env == ""
Environment.root
else
Environment.get 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 provides
@reference.provides
end
def consumes
@reference.consumes
end
def requires_domain
@reference.requires_domain
end
def port_definitions
@reference.port_definitions
end
def root
@root || "#{@environment.root}/#{name}"
end
def provides?(token)
provides.any? do |provider|
provider.token == token
end
end
private def build_environment
env = {} of String => String
env["SERVICE_ROOT"] = root
env["SERVICE_ID"] = full_id
env["ENVIRONMENT"] = @environment.name
env["ENVIRONMENT_TYPE"] = @environment.type.to_s
@providers.each do |token, provider|
service_provider = Service.get_by_id provider
# FIXME: Warning?
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 ':'
# FIXME: Parsing should probably be done… when parsing the file.
# FIXME: Parsing is probably a bit primitive. Maybe this isnt 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
env
end
# FIXME: Is working on ${} really a good idea?
private def evaluate(string)
string.gsub /\${[a-zA-Z_]+}/ do |match|
match = match[2..match.size-2]
if match.downcase == "environment"
@environment.name
elsif match.downcase == "service_root"
root
else
""
end
end
end
def pre_start_hooks
@environment.pre_start_hooks + @reference.pre_start_hooks
end
def start(pid_dir : String, log_dir : String)
pre_start_hooks.each do |hook|
run_hook = false
hook.unless_file.try do |file|
file = evaluate file
run_hook = true if ! File.exists? file
end
hook.unless_directory.try do |directory|
directory = evaluate directory
run_hook = true if ! Dir.exists? directory
end
unless run_hook
next
end
puts " - #{hook.name}"
child = Process.fork do
Process.exec "sh", ["-c", hook.command],
output: Process::Redirect::Inherit,
error: Process::Redirect::Inherit,
env: build_environment
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
@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 command, args,
chdir: (@reference.directory.try { |x| evaluate x } || root),
env: build_environment
end
self.save_pid pid_dir, process.pid
end
# TODO:
# - Custom shutdown commands.
# - Should we wait for the process to die?
# - Shouldnt 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
Process.waitpid _pid
File.delete(get_pid_file pid_dir)
else
# Already stopped or dead, nothing to be done here.
end
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
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
"%-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
class_getter all = [] of Service
def self.load(path)
return unless Dir.exists? path
Dir.each_child path do |child|
unless child.match /\.spec$/
next
end
begin
specs = SpecParser.parse(File.read "#{path}/#{child}").not_nil!
rescue
next
end
@@all << Service.new specs
end
end
def write(path)
FileUtils.mkdir_p "#{path}"
File.write "#{path}/#{name}.#{@environment.name}.spec", to_spec
end
def remove(path)
File.delete "#{path}/#{name}.#{@environment.name}.spec"
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, service_name = Service.parse_id id
@@all.find do |service|
service.name == service_name && service.environment.name == environment_name
end
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
def self.get_used_ports(other_reservations = Array(Int32).new)
Service.all.map(&.ports.to_a)
.flatten
.map { |k, v| v }
.+(other_reservations)
end
def self.get_free_port(other_reservations = Array(Int32).new)
port = 49152
used_ports = Service.get_used_ports other_reservations
while used_ports.any? &.==(port)
port = port + 1
end
port
end
def self.is_port_used(port, other_reservations = Array(Int32).new)
used_ports = Service.get_used_ports other_reservations
used_ports.any? &.==(port)
end
alias ServiceTree = Array(ServiceTree) | Service
# Returns a dependency tree.
# Youll probably want to flatten and reverse it afterwards.
def dependency_tree
tree = [self] of ServiceTree
@providers.each do |token, provider_id|
service = Service.get_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]
@@all.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) || Environment.root.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)
Service.all.select(&.consumes?(token, self))
end
end