Project organisation for WIP separate binaries.

- The idea would be to allow running specific commands from SUID
    binaries when unpriviledged users should be able to run them. Such
    commands include getting services status, which need root
    priviledges to be implemented but shouldn’t be required by users.
  - Because the repository now builds several binaries, src/main.cr is
    now src/service.cr and a WIP src/status.cr has been added.
  - The `status` binary will likely be installed in libexec in the
    future, with the SUID bit set.
This commit is contained in:
Luka Vandervelden 2019-06-09 18:08:59 +02:00
parent 8a6829d803
commit 5eb0849abd
9 changed files with 487 additions and 466 deletions

View File

@ -9,6 +9,8 @@ description: |
targets:
service:
main: src/main.cr
main: src/service.cr
status:
main: src/status.cr
license: MIT

8
src/config.cr Normal file
View File

@ -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"

View File

@ -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 <command> [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

View File

@ -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 <command> [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 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
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?
# - 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
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.
# Youll 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

349
src/service/service.cr Normal file
View File

@ -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 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
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?
# - 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
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.
# Youll 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

16
src/status.cr Normal file
View File

@ -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