commit a836916098bd72f0b6f2ee298c8a72d0fe0c013d Author: ge Date: Sun Oct 20 07:07:47 2024 +0300 init 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: [] +}