This commit is contained in:
ge
2024-10-20 07:07:47 +03:00
commit a836916098
13 changed files with 661 additions and 0 deletions

109
src/em.v Normal file
View File

@ -0,0 +1,109 @@
module main
import arrays
import os
import log
struct EntryManager {
config Config
}
fn EntryManager.new(config Config) EntryManager {
return EntryManager{
config: config
}
}
fn (em EntryManager) labels() []string {
mut labels := []string{}
for _, val in em.config.entries {
labels << val.labels
}
return arrays.uniq(labels)
}
fn (em EntryManager) by_labels(labels []string) []Entry {
log.debug('Lookup entries by labels: ${labels}')
mut entries := []Entry{}
for _, val in em.config.entries {
mut found := true
for label in labels {
if label !in val.labels {
found = false
}
}
if found == true {
entries << val
}
}
return entries
}
fn (em EntryManager) by_name(name string) !Entry {
log.debug('Lookup entry: ${name}')
return em.config.entries[name] or { error('No such entry named ${name}') }
}
fn (em EntryManager) run(entry Entry) ! {
is_running := em.is_running(entry.name) or { false }
if is_running {
log.warn("Entry '${entry.name}' is already running")
return
}
log.debug('Starting up entry: ${entry.name}')
log.debug('${entry}')
mut process := os.new_process(entry.exec[0])
process.set_args(entry.exec[1..])
process.set_environment(entry.env)
process.set_work_folder(if entry.workdir == '' { os.getwd() } else { os.abs_path(entry.workdir) })
process.run()
pidfile := get_pidfile_path(rundir: em.config.rundir, entry: entry.name, path: entry.pidfile)
write_pidfile(pidfile, process.pid)
}
fn (em EntryManager) signal(name string, signal os.Signal) ! {
entry := em.by_name(name) or { return err }
pidfile := get_pidfile_path(rundir: em.config.rundir, entry: entry.name, path: entry.pidfile)
pid := read_pidfile(pidfile)
send_signal(pid, signal)
}
fn (em EntryManager) processes() []Entry {
mut pidfiles := get_pidfiles(em.config.rundir)
for _, entry in em.config.entries {
if entry.pidfile != '' && entry.pidfile !in pidfiles {
pidfiles << entry.pidfile
}
}
mut running_entries := []Entry{}
for pidfile in pidfiles {
pid := read_pidfile(pidfile)
if os.exists(os.join_path_single('/proc', pid.str())) {
entry_name := os.file_name(pidfile).split('.')[0]
mut entry := em.by_name(entry_name) or { Entry{} }
if entry.name != '' {
entry.pid = pid
running_entries << entry
}
} else {
os.rm(pidfile) or {} // FIXME
}
}
return running_entries
}
fn (em EntryManager) is_running(name string) !bool {
entry := em.by_name(name) or { return err }
pidfile := get_pidfile_path(rundir: em.config.rundir, entry: entry.name, path: entry.pidfile)
if os.exists(pidfile) {
pid := read_pidfile(pidfile)
if os.exists(os.join_path_single('/proc', pid.str())) {
return true
} else {
os.rm(pidfile) or { return err }
return false
}
} else {
return false
}
}

110
src/loadconf.v Normal file
View File

@ -0,0 +1,110 @@
module main
import os
import log
import maps
import toml { Any }
pub struct Entry {
mut:
name string
pid int @[json: '-']
pub mut:
exec []string
env map[string]string
pidfile string
workdir string
labels []string
description string
}
pub struct Config {
pub mut:
rundir string
include []string
entries map[string]Entry
}
pub fn (mut c Config) from_toml(any Any) {
all := any.as_map()
c.rundir = all.value('rundir').default_to(runtime_dir).string()
c.include = all.value('include').default_to([]Any{}).array().as_strings()
entries := all['entry'] or { Any(map[string]Any{}) }.as_map()
for k, v in entries {
entry := {
k: Entry{
name: k
exec: v.as_map().value('exec').default_to([]Any{}).array().as_strings()
env: v.as_map().value('env').default_to(map[string]Any{}).as_map().as_strings()
pidfile: v.as_map().value('pidfile').default_to('').string()
workdir: v.as_map().value('workdir').default_to('').string()
labels: v.as_map().value('labels').default_to([]Any{}).array().as_strings()
description: v.as_map().value('description').default_to('').string()
}
}
maps.merge_in_place(mut c.entries, entry)
}
}
const runtime_dir = get_runtime_dir()
fn get_runtime_dir() string {
return os.getenv_opt('XDG_RUNTIME_DIR') or {
if os.geteuid() == 0 {
if os.exists('/run') {
return '/run'
} else {
return '/var/run'
}
}
dir := os.temp_dir()
log.warn('XDG_RUNTIME_DIR is unset, fallback to ${dir} for PID-files')
return dir
}
}
fn load_config(config_path string) Config {
filepath := os.abs_path(os.expand_tilde_to_home(os.norm_path(config_path)))
mut conf := Config{}
return load_config_recursively(mut conf, 0, filepath)
}
const max_recursion_depth = $d('pt_max_recursion_depth', 10)
fn load_config_recursively(mut conf Config, recursion_depth int, file string) Config {
mut recursion := recursion_depth + 1
if recursion > max_recursion_depth {
log.warn('Max recursion depth reached, ${file} is not loaded')
return conf
}
log.debug('Loading config file ${file}')
text := os.read_file(file) or {
log.error('Unable to read file ${file}: ${err}')
exit(1)
}
loaded := toml.decode[Config](text) or {
log.error('Unable to parse config file ${file}: ${err}')
exit(1)
}
if recursion == 1 {
conf.rundir = loaded.rundir // disallow rundir overriding
}
conf.include = loaded.include
maps.merge_in_place(mut conf.entries, loaded.entries)
if conf.include.len != 0 {
mut matched_files := []string{}
old_cwd := os.getwd()
os.chdir(os.dir(file)) or {}
for glob in conf.include {
matched_files << os.glob(os.expand_tilde_to_home(glob)) or { [] }
}
for filepath in matched_files {
if os.is_dir(filepath) {
continue
}
conf = load_config_recursively(mut conf, recursion, os.abs_path(filepath))
}
os.chdir(old_cwd) or {}
}
return conf
}

205
src/main.v Normal file
View File

@ -0,0 +1,205 @@
module main
import os
import log
import cli { Command, Flag }
import x.json2 as json
const default_config_file = $d('pt_default_config_file', '~/.ptrc')
fn init(cmd Command) Config {
debug := cmd.flags.get_bool('debug') or { false }
config := cmd.flags.get_string('config') or { default_config_file }
if debug {
log.set_level(.debug)
}
return load_config(config)
}
fn root_command(cmd Command) ! {
println(cmd.help_message())
}
fn ls_command(cmd Command) ! {
conf := init(cmd)
output := cmd.flags.get_string('output')!
match output {
'json' {
mut entries := []Entry{}
for _, val in conf.entries {
entries << val
}
println(json.encode[[]Entry](entries))
}
'brief' {
for key, _ in conf.entries {
println(key)
}
}
else {
for key, val in conf.entries {
println('${key:-24}${val.description:01}')
}
}
}
}
fn start_command(cmd Command) ! {
conf := init(cmd)
em := EntryManager.new(conf)
labels := cmd.flags.get_strings('label') or { []string{} }
mut code := 0
if labels.len > 0 {
if cmd.args.len > 0 {
log.warn('Positional arguments are ignored: ${cmd.args}')
}
for entry in em.by_labels(labels) {
em.run(entry) or {
log.error(err.str())
code = 1
}
}
} else if cmd.args.len > 0 {
for arg in cmd.args {
entry := em.by_name(arg) or {
log.error(err.str())
code = 1
continue
}
em.run(entry) or {
log.error(err.str())
code = 1
continue
}
}
}
exit(code)
}
fn ps_command(cmd Command) ! {
conf := init(cmd)
em := EntryManager.new(conf)
entries := em.processes()
println('PID NAME')
for entry in entries {
println('${entry.pid:-12}${entry.name}')
}
}
fn signal_command(cmd Command) ! {
conf := init(cmd)
em := EntryManager.new(conf)
if cmd.args.len < 2 {
println(cmd.help_message())
}
sig := signal_from_string(cmd.args[0]) or {
log.error(err.str())
exit(2)
}
for entry in cmd.args[1..] {
em.signal(entry, sig) or {
log.error(err.str())
exit(1)
}
}
}
fn labels_command(cmd Command) ! {
conf := init(cmd)
em := EntryManager.new(conf)
for label in em.labels().sorted() {
println(label)
}
}
fn stop_command(cmd Command) ! {
conf := init(cmd)
em := EntryManager.new(conf)
for entry in cmd.args {
em.signal(entry, .term) or {
log.error(err.str())
exit(1)
}
}
}
fn main() {
mut app := Command{
name: 'pt'
execute: root_command
version: $d('pt_version', '0.0.0')
sort_commands: true
defaults: struct {
man: false
}
flags: [
Flag{
flag: .string
name: 'config'
abbrev: 'c'
description: 'Config file path.'
global: true
default_value: [default_config_file]
},
Flag{
flag: .bool
name: 'debug'
description: 'Enable debug logs.'
global: true
},
]
commands: [
Command{
name: 'ls'
execute: ls_command
description: 'List defined command entries.'
flags: [
Flag{
flag: .string
name: 'output'
abbrev: 'o'
description: 'Set output format [text, json, brief].'
},
]
},
Command{
name: 'start'
usage: '[<entry>]...'
execute: start_command
description: 'Start entries by name or label.'
flags: [
Flag{
flag: .string_array
name: 'label'
abbrev: 'l'
description: 'Select entries by label. Can be multiple.'
},
]
},
Command{
name: 'ps'
execute: ps_command
description: 'Print running entries list.'
},
Command{
name: 'signal'
usage: '<SIGNAL> <entry> [<entry>]...'
execute: signal_command
description: 'Send OS signal to running entry.'
},
Command{
name: 'labels'
execute: labels_command
description: 'List all entry labels.'
},
Command{
name: 'stop'
usage: '[<entry>]...'
execute: stop_command
description: 'Stop entries.'
},
]
}
app.setup()
app.parse(os.args)
}

46
src/pidfile.v Normal file
View File

@ -0,0 +1,46 @@
module main
import os
import log
fn read_pidfile(path string) int {
log.debug('Read PID file: ${path}')
pid := os.read_file(os.norm_path(path)) or {
log.error('Unable to read PID file: ${err}')
exit(1)
}
return pid.i32()
}
fn write_pidfile(path string, pid int) {
log.debug("Write PID file '${path}' for process ${pid}")
filepath := os.norm_path(path)
os.mkdir_all(os.dir(filepath)) or {
log.error('Cannot create dirs ${filepath}')
exit(1)
}
os.write_file(filepath, pid.str()) or {
log.error('Cannot write PID file: ${err}')
exit(1)
}
}
const piddir = $d('pt_piddir', 'pt')
@[params]
struct PidfileParams {
rundir string
entry string // entry.name
path string // entry.pidfile
}
fn get_pidfile_path(params PidfileParams) string {
if params.path != '' {
return params.path
}
return os.join_path(params.rundir, piddir, params.entry + '.pid')
}
fn get_pidfiles(rundir string) []string {
return os.glob(os.join_path(rundir, piddir, '*.pid')) or { []string{} }
}

20
src/signal.c.v Normal file
View File

@ -0,0 +1,20 @@
module main
import os
import log
#include <signal.h>
fn send_signal(pid int, signal os.Signal) {
log.debug('Send ${signal} to process ${pid}')
C.kill(pid, int(signal))
}
fn signal_from_string(signal string) !os.Signal {
sig := signal.to_lower().trim_string_left('sig')
if sig.is_int() {
return os.Signal.from(sig.int()) or { return error('Invalid signal ${sig}') }
} else {
return os.Signal.from_string(sig) or { return error('Invalid signal ${sig}') }
}
}