service/src/service/service.cr

719 lines
15 KiB
Crystal
Raw Permalink 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 "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 well 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 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
@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 services
# 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, lets 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?
# - Shouldnt 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.
# Youll 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