From 7a1b0ec63b9bf8bda4fd26cc65bc6157ddc08f6d Mon Sep 17 00:00:00 2001 From: Giuseppe Scrivano Date: Fri, 12 Sep 2025 17:02:18 +0200 Subject: [PATCH] conmon: add --timer-command to run command every N seconds Signed-off-by: Giuseppe Scrivano --- docs/conmon.8.md | 9 ++ src/cli.c | 119 ++++++++++++++++++++++ src/cli.h | 11 ++ src/conmon.c | 20 ++++ src/ctr_exit.c | 109 ++++++++++++++------ src/ctr_exit.h | 2 + test/07-timer-command.bats | 199 +++++++++++++++++++++++++++++++++++++ 7 files changed, 437 insertions(+), 32 deletions(-) create mode 100644 test/07-timer-command.bats diff --git a/docs/conmon.8.md b/docs/conmon.8.md index f6d0e7ac..64f0af3a 100644 --- a/docs/conmon.8.md +++ b/docs/conmon.8.md @@ -142,6 +142,15 @@ Specify the Container UUID to use. **--version** Print the version and exit. +**--timer-command** +Execute COMMAND every SECONDS. Format: ID:SECONDS:COMMAND. Can be specified multiple times. + +**--timer-command-argument** +Add an argument to timer-command with ID. Format: ID:ARGUMENT. Can be specified multiple times. + +This option allows adding command-line arguments to a specific timer-command by ID. +Arguments are added in the order they are specified. + ## SEE ALSO podman(1), buildah(1), cri-o(1), crun(8), runc(8) diff --git a/src/cli.c b/src/cli.c index 0ad100ab..ff106234 100644 --- a/src/cli.c +++ b/src/cli.c @@ -8,6 +8,9 @@ #include #include #include +#include +#include +#include #ifdef __linux__ #include #endif @@ -57,6 +60,9 @@ char *opt_sdnotify_socket = NULL; gboolean opt_full_attach_path = FALSE; char *opt_seccomp_notify_socket = NULL; char *opt_seccomp_notify_plugins = NULL; +gchar **opt_timer_command = NULL; +gchar **opt_timer_command_argument = NULL; +GPtrArray *timer_command_entries = NULL; GOptionEntry opt_entries[] = { {"api-version", 0, 0, G_OPTION_ARG_NONE, &opt_api_version, "Conmon API version to use", NULL}, {"bundle", 'b', 0, G_OPTION_ARG_STRING, &opt_bundle_path, "Location of the OCI Bundle path", NULL}, @@ -117,6 +123,10 @@ GOptionEntry opt_entries[] = { "Path to the socket where the seccomp notification fd is received", NULL}, {"seccomp-notify-plugins", 0, 0, G_OPTION_ARG_STRING, &opt_seccomp_notify_plugins, "Plugins to use for managing the seccomp notifications", NULL}, + {"timer-command", 0, 0, G_OPTION_ARG_STRING_ARRAY, &opt_timer_command, + "Execute COMMAND every SECONDS. Format: ID:SECONDS:COMMAND. Can be specified multiple times", NULL}, + {"timer-command-argument", 0, 0, G_OPTION_ARG_STRING_ARRAY, &opt_timer_command_argument, + "Add an argument to timer-command with ID. Format: ID:ARGUMENT. Can be specified multiple times", NULL}, {NULL, 0, 0, 0, NULL, NULL, NULL}}; @@ -205,4 +215,113 @@ void process_cli() if (opt_no_container_partial_message && !logging_is_journald_enabled()) { nwarnf("--no-container-partial-message has no effect without journald log driver"); } + + /* Parse timer-command entries with ID-based arguments */ + if (opt_timer_command || opt_timer_command_argument) { + timer_command_entries = g_ptr_array_new_with_free_func(g_free); + + /* Parse timer-command commands first */ + if (opt_timer_command) { + for (int i = 0; opt_timer_command[i] != NULL; i++) { + char *entry = g_strdup(opt_timer_command[i]); + char *first_colon = strchr(entry, ':'); + if (!first_colon) { + nexitf("Invalid timer-command format '%s'. Expected ID:SECONDS:COMMAND", opt_timer_command[i]); + } + + *first_colon = '\0'; + char *second_colon = strchr(first_colon + 1, ':'); + if (!second_colon) { + nexitf("Invalid timer-command format '%s'. Expected ID:SECONDS:COMMAND", opt_timer_command[i]); + } + + *second_colon = '\0'; + + char *id_str = entry; + char *interval_str = first_colon + 1; + char *command = second_colon + 1; + + /* Parse ID */ + char *endptr; + errno = 0; + long id_long = strtol(id_str, &endptr, 10); + if (errno != 0 || endptr == id_str || *endptr != '\0' || id_long < 0 || id_long > INT_MAX) { + nexitf("Invalid timer-command ID '%s' in '%s'", id_str, opt_timer_command[i]); + } + int id = (int)id_long; + + /* Require incremental IDs starting from 0 */ + int expected_id = i; + if (id != expected_id) { + nexitf("Timer-command IDs must be incremental starting from 0. Expected %d, got %d", expected_id, + id); + } + + /* Parse interval */ + errno = 0; + long interval_long = strtol(interval_str, &endptr, 10); + if (errno != 0 || endptr == interval_str || *endptr != '\0' || interval_long <= 0 + || interval_long > INT_MAX) { + nexitf("Invalid timer-command interval '%s' in '%s'", interval_str, opt_timer_command[i]); + } + int interval = (int)interval_long; + + if (*command == '\0') { + nexitf("Empty timer-command command in '%s'", opt_timer_command[i]); + } + + timer_command_entry_t *cc_entry = g_malloc(sizeof(timer_command_entry_t)); + cc_entry->id = id; + cc_entry->interval = interval; + cc_entry->args = g_malloc(sizeof(gchar *) * 2); + cc_entry->args[0] = g_strdup(command); + cc_entry->args[1] = NULL; + + g_ptr_array_add(timer_command_entries, cc_entry); + + ndebugf("Added timer-command: ID=%d, %d seconds, command: %s", id, interval, command); + g_free(entry); + } + } + + if (opt_timer_command_argument) { + for (int i = 0; opt_timer_command_argument[i] != NULL; i++) { + char *entry = g_strdup(opt_timer_command_argument[i]); + char *colon = strchr(entry, ':'); + if (!colon) { + nexitf("Invalid timer-command-argument format '%s'. Expected ID:ARGUMENT", + opt_timer_command_argument[i]); + } + + *colon = '\0'; + char *id_str = entry; + char *argument = colon + 1; + + /* Parse ID */ + char *endptr; + errno = 0; + long id_long = strtol(id_str, &endptr, 10); + if (errno != 0 || endptr == id_str || *endptr != '\0' || id_long < 0 || id_long > INT_MAX) { + nexitf("Invalid timer-command ID '%s' in argument '%s'", id_str, opt_timer_command_argument[i]); + } + int id = (int)id_long; + + if (id < 0 || id >= (int)timer_command_entries->len) { + nexitf("Timer-command ID %d not found for argument '%s'", id, argument); + } + timer_command_entry_t *target_entry = g_ptr_array_index(timer_command_entries, id); + + int arg_count = 0; + while (target_entry->args[arg_count]) + arg_count++; + + target_entry->args = g_realloc(target_entry->args, sizeof(gchar *) * (arg_count + 2)); + target_entry->args[arg_count] = g_strdup(argument); + target_entry->args[arg_count + 1] = NULL; + + ndebugf("Added argument '%s' to timer-command ID %d", argument, id); + g_free(entry); + } + } + } } diff --git a/src/cli.h b/src/cli.h index ca20cfe9..bd8c6cf8 100644 --- a/src/cli.h +++ b/src/cli.h @@ -3,6 +3,7 @@ #include /* gboolean and GOptionEntry */ #include /* int64_t */ +#include /* time_t */ extern gboolean opt_version; extern gboolean opt_terminal; @@ -49,6 +50,16 @@ extern char *opt_seccomp_notify_socket; extern char *opt_seccomp_notify_plugins; extern GOptionEntry opt_entries[]; extern gboolean opt_full_attach_path; +extern gchar **opt_timer_command; +extern gchar **opt_timer_command_argument; + +typedef struct { + int id; + int interval; + gchar **args; +} timer_command_entry_t; + +extern GPtrArray *timer_command_entries; int initialize_cli(int argc, char *argv[]); void process_cli(); diff --git a/src/conmon.c b/src/conmon.c index 81f6b181..1c40dd31 100644 --- a/src/conmon.c +++ b/src/conmon.c @@ -34,6 +34,17 @@ static void disconnect_std_streams(int dev_null_r, int dev_null_w) pexit("Failed to dup over stderr"); } + +static gboolean timer_command_timer_cb(gpointer user_data) +{ + timer_command_entry_t *entry = (timer_command_entry_t *)user_data; + + ndebugf("Executing timer-command: %s", entry->args[0]); + execute_command_detached(entry->args, 0); + + return G_SOURCE_CONTINUE; // Keep repeating +} + #define DEFAULT_UMASK 0022 int main(int argc, char *argv[]) @@ -423,6 +434,15 @@ int main(int argc, char *argv[]) g_timeout_add_seconds(opt_timeout, timeout_cb, NULL); } + /* Add individual timers for each timer-command entry */ + if (timer_command_entries && timer_command_entries->len > 0) { + for (guint i = 0; i < timer_command_entries->len; i++) { + timer_command_entry_t *entry = g_ptr_array_index(timer_command_entries, i); + g_timeout_add_seconds(entry->interval, timer_command_timer_cb, entry); + ndebugf("Timer-command timer set up: %d seconds, command: %s", entry->interval, entry->args[0]); + } + } + if (data.exit_status_cache) { GHashTableIter iter; gpointer key, value; diff --git a/src/ctr_exit.c b/src/ctr_exit.c index eaad82ee..f9791762 100644 --- a/src/ctr_exit.c +++ b/src/ctr_exit.c @@ -10,10 +10,12 @@ #include "oom.h" #include +#include #include #include #include #include +#include #include volatile sig_atomic_t container_pid = -1; @@ -152,53 +154,105 @@ void container_exit_cb(G_GNUC_UNUSED GPid pid, int status, G_GNUC_UNUSED gpointe g_main_loop_quit(main_loop); } -void do_exit_command() +/* + * Execute a command with double fork to completely detach the process. + * Used for timer-command commands that should run independently of conmon. + * args[0] must be the command path and args must be NULL-terminated. + */ +void execute_command_detached(gchar **args, int delay) { - if (signal(SIGCHLD, SIG_DFL) == SIG_ERR) { - _pexit("Failed to reset signal for SIGCHLD"); + pid_t first_fork = fork(); + if (first_fork < 0) { + nwarnf("Failed to fork for command: %s", args[0]); + return; + } + + if (first_fork) { + int status; + waitpid(first_fork, &status, 0); + return; + } + + pid_t second_fork = fork(); + if (second_fork < 0) { + _exit(1); + } else if (second_fork == 0) { + close_all_fds_ge_than(3); + + int null_fd = open("/dev/null", O_RDWR); + if (null_fd >= 0) { + dup2(null_fd, STDIN_FILENO); + dup2(null_fd, STDOUT_FILENO); + dup2(null_fd, STDERR_FILENO); + if (null_fd > 2) { + close(null_fd); + } + } + + setsid(); + + execute_command(args, delay, false); + _exit(EXIT_FAILURE); + } else { + _exit(0); } +} - /* - * Close everything except stdin, stdout and stderr. - */ +/* + * Execute a command with single fork. + * If do_wait is true, wait for completion (used by exit commands). + * If do_wait is false, don't wait (used by detached commands after double fork). + * args[0] must be the command path and args must be NULL-terminated. + */ +void execute_command(gchar **args, int delay, gboolean do_wait) +{ close_all_fds_ge_than(3); - /* - * We don't want the exit command to be reaped by the parent conmon - * as that would prevent double-fork from doing its job. - * Unfortunately, that also means that any new subchildren from - * still running processes could also get lost - */ if (set_subreaper(false) != 0) { nwarn("Failed to disable self subreaper attribute - might wait for indirect children a long time"); } - pid_t exit_pid = fork(); - if (exit_pid < 0) { + pid_t child_pid = fork(); + if (child_pid < 0) { _pexit("Failed to fork"); } - if (exit_pid) { + if (child_pid) { + if (!do_wait) { + return; + } int ret, exit_status = 0; - /* - * Make sure to cleanup any zombie process that the container runtime - * could have left around. - */ do { int tmp; exit_status = 0; ret = waitpid(-1, &tmp, 0); - if (ret == exit_pid) + if (ret == child_pid) exit_status = get_exit_status(tmp); } while ((ret < 0 && errno == EINTR) || ret > 0); if (exit_status) _exit(exit_status); - return; } + if (delay > 0) { + ndebugf("Sleeping for %d seconds before executing command", delay); + sleep(delay); + } + + reset_oom_adjust(); + + execv(args[0], args); + + _exit(EXIT_FAILURE); +} + +void do_exit_command() +{ + if (signal(SIGCHLD, SIG_DFL) == SIG_ERR) { + _pexit("Failed to reset signal for SIGCHLD"); + } /* Count the additional args, if any. */ size_t n_args = 0; @@ -214,19 +268,10 @@ void do_exit_command() args[n_args + 1] = opt_exit_args[n_args]; args[n_args + 1] = NULL; - if (opt_exit_delay) { - ndebugf("Sleeping for %d seconds before executing exit command", opt_exit_delay); - sleep(opt_exit_delay); - } - - reset_oom_adjust(); - - execv(opt_exit_command, args); - - /* Should not happen, but better be safe. */ - _exit(EXIT_FAILURE); + execute_command(args, opt_exit_delay, true); } + void reap_children() { /* We need to reap any zombies (from an OCI runtime that errored) before diff --git a/src/ctr_exit.h b/src/ctr_exit.h index 7f256856..c5a442cc 100644 --- a/src/ctr_exit.h +++ b/src/ctr_exit.h @@ -22,6 +22,8 @@ gboolean timeout_cb(G_GNUC_UNUSED gpointer user_data); int get_exit_status(int status); void runtime_exit_cb(G_GNUC_UNUSED GPid pid, int status, G_GNUC_UNUSED gpointer user_data); void container_exit_cb(G_GNUC_UNUSED GPid pid, int status, G_GNUC_UNUSED gpointer user_data); +void execute_command_detached(gchar **args, int delay); +void execute_command(gchar **args, int delay, gboolean do_wait); void do_exit_command(); void reap_children(); void cleanup_socket_dir_symlink(); diff --git a/test/07-timer-command.bats b/test/07-timer-command.bats new file mode 100644 index 00000000..b0c7ae01 --- /dev/null +++ b/test/07-timer-command.bats @@ -0,0 +1,199 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + check_conmon_binary + check_runtime_binary + setup_container_env +} + +teardown() { + cleanup_test_env +} + +@test "timer-command flag in help" { + run_conmon --help + assert_success + assert_output_contains "--timer-command" + assert_output_contains "Execute COMMAND every SECONDS" +} + +@test "invalid timer-command format should fail" { + run_conmon --cid "$CTR_ID" --cuuid "$CTR_ID" --runtime "/bin/true" \ + --log-path "$LOG_PATH" --timer-command "invalid" + assert_failure + assert_output_contains "Invalid timer-command format 'invalid'. Expected ID:SECONDS:COMMAND" +} + +@test "incomplete timer-command format should fail" { + run_conmon --cid "$CTR_ID" --cuuid "$CTR_ID" --runtime "/bin/true" \ + --log-path "$LOG_PATH" --timer-command "0:5" + assert_failure + assert_output_contains "Invalid timer-command format '0:5'. Expected ID:SECONDS:COMMAND" +} + +@test "invalid timer-command ID should fail" { + run_conmon --cid "$CTR_ID" --cuuid "$CTR_ID" --runtime "/bin/true" \ + --log-path "$LOG_PATH" --timer-command "abc:5:echo test" + assert_failure + assert_output_contains "Invalid timer-command ID 'abc'" +} + +@test "invalid timer-command interval should fail" { + run_conmon --cid "$CTR_ID" --cuuid "$CTR_ID" --runtime "/bin/true" \ + --log-path "$LOG_PATH" --timer-command "0:abc:echo test" + assert_failure + assert_output_contains "Invalid timer-command interval 'abc'" +} + +@test "zero timer-command interval should fail" { + run_conmon --cid "$CTR_ID" --cuuid "$CTR_ID" --runtime "/bin/true" \ + --log-path "$LOG_PATH" --timer-command "0:0:echo test" + assert_failure + assert_output_contains "Invalid timer-command interval '0'" +} + +@test "empty timer-command command should fail" { + run_conmon --cid "$CTR_ID" --cuuid "$CTR_ID" --runtime "/bin/true" \ + --log-path "$LOG_PATH" --timer-command "0:5:" + assert_failure + assert_output_contains "Empty timer-command command" +} + + +@test "timer-command argument without timer-command should fail" { + run_conmon --cid "$CTR_ID" --cuuid "$CTR_ID" --runtime "/bin/true" \ + --log-path "$LOG_PATH" --timer-command-argument "0:arg1" + assert_failure + assert_output_contains "Timer-command ID 0 not found for argument 'arg1'" +} + +@test "invalid timer-command argument format should fail" { + run_conmon --cid "$CTR_ID" --cuuid "$CTR_ID" --runtime "/bin/true" \ + --log-path "$LOG_PATH" --timer-command "0:5:echo" --timer-command-argument "invalid" + assert_failure + assert_output_contains "Invalid timer-command-argument format 'invalid'. Expected ID:ARGUMENT" +} + +@test "valid timer-command format should be accepted" { + run_conmon --cid "$CTR_ID" --cuuid "$CTR_ID" --runtime "/bin/true" \ + --log-path "$LOG_PATH" --timer-command "0:5:echo test" + if [[ "$output" == *"Invalid timer-command"* ]]; then + echo "Timer-command parsing failed: $output" + return 1 + fi +} + +@test "multiple timer-command entries should be accepted" { + run_conmon --cid "$CTR_ID" --cuuid "$CTR_ID" --runtime "/bin/true" \ + --log-path "$LOG_PATH" \ + --timer-command "0:5:echo test1" \ + --timer-command "1:10:echo test2" + if [[ "$output" == *"Invalid timer-command"* ]]; then + echo "Multiple timer-command parsing failed: $output" + return 1 + fi +} + +@test "timer-command with arguments should be accepted" { + run_conmon --cid "$CTR_ID" --cuuid "$CTR_ID" --runtime "/bin/true" \ + --log-path "$LOG_PATH" \ + --timer-command "0:5:echo" \ + --timer-command-argument "0:arg1" \ + --timer-command-argument "0:arg2" + if [[ "$output" == *"Invalid timer-command"* ]]; then + echo "Timer-command with arguments parsing failed: $output" + return 1 + fi +} + +@test "timer-commands execute and create files" { + TEST_FILE1="$TEST_TMPDIR/timer_test_1_$$" + TEST_FILE2="$TEST_TMPDIR/timer_test_2_$$" + + jq '.process.args = ["/busybox", "sleep", "15"]' "$BUNDLE_PATH/config.json" > "$BUNDLE_PATH/config.json.tmp" + mv "$BUNDLE_PATH/config.json.tmp" "$BUNDLE_PATH/config.json" + + timeout 15s "$CONMON_BINARY" \ + --cid "$CTR_ID" \ + --cuuid "$CTR_ID" \ + --runtime "$RUNTIME_BINARY" \ + --log-path "k8s-file:$LOG_PATH" \ + --bundle "$BUNDLE_PATH" \ + --socket-dir-path "$SOCKET_PATH" \ + --container-pidfile "$PID_FILE" \ + --conmon-pidfile "$CONMON_PID_FILE" \ + --timer-command "0:1:/bin/sh" \ + --timer-command-argument "0:-c" \ + --timer-command-argument "0:echo 'timer0_executed' >> $TEST_FILE1" \ + --timer-command "1:2:/bin/sh" \ + --timer-command-argument "1:-c" \ + --timer-command-argument "1:echo 'timer1_executed' >> $TEST_FILE2" & + + local conmon_pid=$! + + local start_time=$(date +%s) + local file1_lines=0 + local file2_lines=0 + local runtime_available=false + + while [ $(($(date +%s) - start_time)) -lt 15 ]; do + if [ -f "$TEST_FILE1" ]; then + file1_lines=$(wc -l < "$TEST_FILE1" 2>/dev/null || echo 0) + runtime_available=true + fi + if [ -f "$TEST_FILE2" ]; then + file2_lines=$(wc -l < "$TEST_FILE2" 2>/dev/null || echo 0) + runtime_available=true + fi + + if [ $file1_lines -ge 2 ] && [ $file2_lines -ge 2 ]; then + break + fi + + sleep 0.125 + done + + kill $conmon_pid 2>/dev/null || true + wait $conmon_pid 2>/dev/null || true + + # Verify results for functional test + [ -f "$TEST_FILE1" ] || { + echo "Timer-command 0 file was not created" + rm -f "$TEST_FILE1" "$TEST_FILE2" + return 1 + } + + [ -f "$TEST_FILE2" ] || { + echo "Timer-command 1 file was not created" + rm -f "$TEST_FILE1" "$TEST_FILE2" + return 1 + } + + [ $file1_lines -ge 2 ] || { + echo "Timer-command 0 executed only $file1_lines times (expected at least 2)" + rm -f "$TEST_FILE1" "$TEST_FILE2" + return 1 + } + + [ $file2_lines -ge 2 ] || { + echo "Timer-command 1 executed only $file2_lines times (expected at least 2)" + rm -f "$TEST_FILE1" "$TEST_FILE2" + return 1 + } + + grep -q "timer0_executed" "$TEST_FILE1" || { + echo "Timer-command 0 file has incorrect content" + rm -f "$TEST_FILE1" "$TEST_FILE2" + return 1 + } + + grep -q "timer1_executed" "$TEST_FILE2" || { + echo "Timer-command 1 file has incorrect content" + rm -f "$TEST_FILE1" "$TEST_FILE2" + return 1 + } + + rm -f "$TEST_FILE1" "$TEST_FILE2" +}