From a836916098bd72f0b6f2ee298c8a72d0fe0c013d Mon Sep 17 00:00:00 2001 From: ge Date: Sun, 20 Oct 2024 07:07:47 +0300 Subject: [PATCH] init --- .editorconfig | 8 ++ .gitattributes | 7 ++ .gitignore | 23 ++++++ Makefile | 13 +++ README.md | 37 +++++++++ completion.bash | 37 +++++++++ ptrc.toml | 39 +++++++++ src/em.v | 109 +++++++++++++++++++++++++ src/loadconf.v | 110 ++++++++++++++++++++++++++ src/main.v | 205 ++++++++++++++++++++++++++++++++++++++++++++++++ src/pidfile.v | 46 +++++++++++ src/signal.c.v | 20 +++++ v.mod | 7 ++ 13 files changed, 661 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 completion.bash create mode 100644 ptrc.toml create mode 100644 src/em.v create mode 100644 src/loadconf.v create mode 100644 src/main.v create mode 100644 src/pidfile.v create mode 100644 src/signal.c.v create mode 100644 v.mod diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..01072ca --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.v] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f4011a7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto eol=lf +*.bat eol=crlf + +**/*.v linguist-language=V +**/*.vv linguist-language=V +**/*.vsh linguist-language=V +**/v.mod linguist-language=V diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fd0c63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Binaries for programs and plugins +pt +*.exe +*.exe~ +*.so +*.dylib +*.dll + +# Ignore binary output folders +bin/ + +# Ignore common editor/system specific metadata +.DS_Store +.idea/ +.vscode/ +*.iml + +# ENV +.env + +# vweb and database +*.db +*.js diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..14e8d07 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +PT_VERSION ?= 0.0.1 + +all: prod + +dev: + v -o pt src/ + +prod: + v -prod -cc gcc -cflags -static -o pt src/ \ + -d pt_version=$(PT_VERSION) \ + -d pt_piddir=pt \ + -d pt_max_recursion_depth=10 \ + -d pt_default_config_file=~/.ptrc diff --git a/README.md b/README.md new file mode 100644 index 0000000..03ef97a --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# pt — daemonless background processes for Linux (WIP) + +Run and manage background processes without daemon or root privileges. `pt` is a small process manager with limited capabilities + +`pt` stands for process tool. + +## Features + +- Run arbitrary command in background. The process will be adopted by /sbin/init. +- No daemon needed. `pt` just stores pidfile in runtime directory and checks procfs on invokation. +- Run commands defined in the configuration file. +- Set environment and working directory for process. +- Run commands selected by labels. +- Print defined commands and currently running commands. +- [not implemented] Run commands without writing configuration file. +- [not implemented] TUI. + +## Install + +First install [V compiler](https://github.com/vlang/v). + +Clone this repo and do: + +``` +cd pt +make +install -Dm0755 pt $HOME/.local/bin/pt +install -Dm0644 completion.bash ${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions/pt +``` + +Next step is configuration. + +## Configuration + +Default configuration file is `~/.ptrc`. This is [TOML](https://toml.io) format file. + +See full configuration example with comments in [ptrc.toml](ptrc.toml). diff --git a/completion.bash b/completion.bash new file mode 100644 index 0000000..c536cc5 --- /dev/null +++ b/completion.bash @@ -0,0 +1,37 @@ +# ex: filetype=sh +# shellcheck disable=SC2207 +_pt_completions() +{ + local cur="${COMP_WORDS[COMP_CWORD]}" + local prev="${COMP_WORDS[COMP_CWORD-1]}" + COMPREPLY=($(compgen -W '-debug -c -config -help' -- "$cur")) + case "$prev" in + -c|-config) + COMPREPLY+=($(compgen -f -- "$cur")) + ;; + -l|-label) + if [[ ${COMP_WORDS[*]} =~ start ]]; then + COMPREPLY+=($(compgen -W "$(./pt labels)" -- "$cur")) + fi + ;; + *) + local words=($(./pt ls -o brief) start stop signal) + if [[ ${words[*]} =~ $prev ]]; then + COMPREPLY+=($(compgen -W "$(./pt ls -o brief)" -- "$cur")) + fi + local commands='start stop ls ps help version signal labels' + local invoked=() + for comm in $commands; do + if [[ ${COMP_WORDS[*]} =~ $comm ]]; then + invoked+=("$comm") + fi + done + # Do not complete commands if any command is already invoked + if [[ "${#invoked[@]}" == 0 ]]; then + COMPREPLY+=($(compgen -W "$commands" -- "$cur")) + fi + ;; + esac +} + +complete -o filenames -F _pt_completions pt diff --git a/ptrc.toml b/ptrc.toml new file mode 100644 index 0000000..ee396c1 --- /dev/null +++ b/ptrc.toml @@ -0,0 +1,39 @@ +# Runtime directory. You shouldn't set rundir without a good reason. Omit this +# parameter if everything works fine automatically. +# rundir = '/run/user/1000' + +# You can include another config files using path glob. This is suitable for +# splitting configuration into separate files. All configs will recursively +# loaded. Max recursion depth is 10. pt will warn if some files aren't loaded. +# You need to recompile pt to change max_recursion_depth. +include = '~/.config/pt.d/*.toml' + +# The command entry defenition. There 'sleep' is an entry name. TOML syntax +# allows you to use quoting for non-letter names e.g. [entry."hello@world"] +# All entry parameters described below. +[entry.sleep] +# PID file. You should not to set pidfile is most cases. Depends on rundir. +# Filename pattern is `{rundir}/{piddir}/{entry.name}.pid`. pidddir is always +# 'pt'. +# pidfile = '/run/user/1000/pt/sleep.pid' + +# Working directory. If the process should be executed in a specific directory +# you can specify it here. If not specified, the current working directory is +# used. +workdir = '.' + +# Labels do not affect the operation of the process in any way, but can be used +# to group commands and run them with `pt start -l label1 -l label2`. +labels = ['example'] + +# exec is array of strings with executable and arguments. Describing a command +# using this syntax may seem awkward, but it allows you to clearly and +# unambiguously define arguments that may include spaces and other special +# characters. +exec = ['/usr/bin/sleep', '100'] + +# You can pass environment variables to a process by declaring them as map. +env = { PATH = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin' } + +# Description. Leave a note explaining what this command is for. +description = 'Just sleep' diff --git a/src/em.v b/src/em.v new file mode 100644 index 0000000..7175d3a --- /dev/null +++ b/src/em.v @@ -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 + } +} diff --git a/src/loadconf.v b/src/loadconf.v new file mode 100644 index 0000000..0ebfae0 --- /dev/null +++ b/src/loadconf.v @@ -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 +} diff --git a/src/main.v b/src/main.v new file mode 100644 index 0000000..42207aa --- /dev/null +++ b/src/main.v @@ -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: '[]...' + 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: ' []...' + 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: '[]...' + execute: stop_command + description: 'Stop entries.' + }, + ] + } + app.setup() + app.parse(os.args) +} diff --git a/src/pidfile.v b/src/pidfile.v new file mode 100644 index 0000000..17de800 --- /dev/null +++ b/src/pidfile.v @@ -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{} } +} diff --git a/src/signal.c.v b/src/signal.c.v new file mode 100644 index 0000000..2429b21 --- /dev/null +++ b/src/signal.c.v @@ -0,0 +1,20 @@ +module main + +import os +import log + +#include + +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}') } + } +} diff --git a/v.mod b/v.mod new file mode 100644 index 0000000..35cf5cc --- /dev/null +++ b/v.mod @@ -0,0 +1,7 @@ +Module{ + name: 'pt' + description: 'Daemonless background processes for Linux' + version: '0.0.1' + license: 'MIT' + dependencies: [] +}