Add persisted message dump command

This commit is contained in:
m1ngsama 2026-05-27 09:37:51 +08:00
parent 7b5a683557
commit 8b55a3d9ab
19 changed files with 395 additions and 5 deletions

View file

@ -197,6 +197,7 @@ ssh -p 2222 chat.example.com health
ssh -p 2222 chat.example.com stats --json ssh -p 2222 chat.example.com stats --json
ssh -p 2222 chat.example.com users ssh -p 2222 chat.example.com users
ssh -p 2222 chat.example.com "tail -n 20" ssh -p 2222 chat.example.com "tail -n 20"
ssh -p 2222 chat.example.com "dump -n 100"
ssh -p 2222 operator@chat.example.com post "service notice" ssh -p 2222 operator@chat.example.com post "service notice"
ssh -p 2222 chat.example.com post "/me deploys v2.0" ssh -p 2222 chat.example.com post "/me deploys v2.0"
``` ```
@ -212,6 +213,7 @@ around the same SSH exec interface:
```sh ```sh
tntctl chat.example.com health tntctl chat.example.com health
tntctl -p 2222 chat.example.com stats --json tntctl -p 2222 chat.example.com stats --json
tntctl -p 2222 chat.example.com dump -n 100
tntctl -l operator chat.example.com post "service notice" tntctl -l operator chat.example.com post "service notice"
``` ```

View file

@ -7,6 +7,8 @@
and JSON field shapes for package tests, scripts, and future `tntctl` work. and JSON field shapes for package tests, scripts, and future `tntctl` work.
- Documented `messages.log` v1 as the stable TNT 1.x persisted history format, - Documented `messages.log` v1 as the stable TNT 1.x persisted history format,
including parser, sanitization, and partial-record recovery rules. including parser, sanitization, and partial-record recovery rules.
- Added `dump [N]` / `dump -n N` to the SSH exec interface and `tntctl` for
exporting valid persisted `messages.log` v1 records.
- Added a public security policy, supported-version guidance, and GitHub issue - Added a public security policy, supported-version guidance, and GitHub issue
templates for bug reports and feature requests. templates for bug reports and feature requests.
- Added `tntctl`, a thin local wrapper around the documented SSH exec - Added `tntctl`, a thin local wrapper around the documented SSH exec

View file

@ -56,6 +56,7 @@ ssh -p 2222 chat.example.com health
ssh -p 2222 chat.example.com stats --json ssh -p 2222 chat.example.com stats --json
ssh -p 2222 chat.example.com users --json ssh -p 2222 chat.example.com users --json
ssh -p 2222 chat.example.com "tail -n 20" ssh -p 2222 chat.example.com "tail -n 20"
ssh -p 2222 chat.example.com "dump -n 100"
ssh -p 2222 operator@chat.example.com post "service notice" ssh -p 2222 operator@chat.example.com post "service notice"
``` ```
@ -64,6 +65,7 @@ The same commands can be run through `tntctl`:
```sh ```sh
tntctl chat.example.com health tntctl chat.example.com health
tntctl -p 2222 chat.example.com stats --json tntctl -p 2222 chat.example.com stats --json
tntctl -p 2222 chat.example.com dump -n 100
tntctl -l operator chat.example.com post "service notice" tntctl -l operator chat.example.com post "service notice"
tntctl --host-key-checking accept-new chat.example.com users tntctl --host-key-checking accept-new chat.example.com users
``` ```
@ -128,6 +130,22 @@ Prints recent in-memory messages as tab-separated lines:
The current upper bound is `MAX_MESSAGES`. This command reads the live The current upper bound is `MAX_MESSAGES`. This command reads the live
in-memory room buffer, not the full persisted log. in-memory room buffer, not the full persisted log.
### `dump [N]` / `dump -n N`
Exports valid persisted `messages.log` v1 records in chronological order:
```text
2026-05-25T12:00:00Z|alice|hello
```
Without `N`, `dump` exports all valid persisted records. With `N`, it exports
the last `N` valid persisted records. Malformed, invalid UTF-8, oversized, or
truncated records are skipped by the same strict parser used for replay and
search.
This command reads the on-disk log, not the live in-memory room buffer. A
missing log produces empty output and exit status `0`.
### `post MESSAGE` ### `post MESSAGE`
Posts a message as the SSH login name and prints: Posts a message as the SSH login name and prints:

View file

@ -53,6 +53,13 @@ Replay and search use the same strict parser. TNT skips records that are:
Skipping a bad record is intentional recovery behavior. A truncated final Skipping a bad record is intentional recovery behavior. A truncated final
line is treated as a partial append and ignored rather than replayed. line is treated as a partial append and ignored rather than replayed.
## Export
`dump [N]` and `dump -n N` export valid persisted records through the SSH exec
interface and `tntctl`. The output format is exactly the v1 record format
above. Without `N`, `dump` exports all valid records; with `N`, it exports the
last `N` valid records.
## Compatibility ## Compatibility
The v1 record format is stable for TNT 1.x. Future incompatible storage The v1 record format is stable for TNT 1.x. Future incompatible storage

View file

@ -46,6 +46,14 @@ INSERT MODE
limit 1023 bytes/message; over-limit input rings bell limit 1023 bytes/message; over-limit input rings bell
normal opens/follows latest; k/PgUp older, j/PgDn newer normal opens/follows latest; k/PgUp older, j/PgDn newer
EXEC COMMANDS
health print service health
stats [--json] print room statistics
users [--json] list online users
tail [N] / tail -n N recent in-memory room messages
dump [N] / dump -n N persisted messages.log v1 records
post <message> post as the SSH login name
STRUCTURE STRUCTURE
src/main.c entry, signals src/main.c entry, signals
src/cli_text.c startup CLI text src/cli_text.c startup CLI text

View file

@ -57,8 +57,8 @@ Goal: make stored history durable, inspectable, and recoverable.
- ✅ formalize the message log v1 format - ✅ formalize the message log v1 format
- ✅ keep persisted timestamps in UTC throughout write and replay - ✅ keep persisted timestamps in UTC throughout write and replay
- ✅ validate persisted UTF-8 and record structure before replay/search - ✅ validate persisted UTF-8 and record structure before replay/search
- ✅ provide an inspection/export command for persisted records
- add log rotation and compaction tooling - add log rotation and compaction tooling
- provide an offline inspection/export command
- define broader recovery tooling for truncated or partially corrupted logs - define broader recovery tooling for truncated or partially corrupted logs
## Stage 4: Interactive UX ## Stage 4: Interactive UX

View file

@ -14,7 +14,7 @@ The product path should stay short:
7. User uses commands only when needed: `:users`, `:msg`, `:inbox`, `:last`, 7. User uses commands only when needed: `:users`, `:msg`, `:inbox`, `:last`,
`:search`, `:nick`, `:mute-joins`, and `:q`. `:search`, `:nick`, `:mute-joins`, and `:q`.
8. Scripts and operators use `tntctl` or SSH exec commands for `health`, 8. Scripts and operators use `tntctl` or SSH exec commands for `health`,
`stats`, `users`, `tail`, and `post`. `stats`, `users`, `tail`, `dump`, and `post`.
## TUI Experience Notes ## TUI Experience Notes

View file

@ -9,6 +9,7 @@ typedef enum {
TNT_EXEC_COMMAND_USERS, TNT_EXEC_COMMAND_USERS,
TNT_EXEC_COMMAND_STATS, TNT_EXEC_COMMAND_STATS,
TNT_EXEC_COMMAND_TAIL, TNT_EXEC_COMMAND_TAIL,
TNT_EXEC_COMMAND_DUMP,
TNT_EXEC_COMMAND_POST, TNT_EXEC_COMMAND_POST,
TNT_EXEC_COMMAND_EXIT TNT_EXEC_COMMAND_EXIT
} tnt_exec_command_id_t; } tnt_exec_command_id_t;

View file

@ -26,4 +26,9 @@ void message_format(const message_t *msg, char *buffer, size_t buf_size, int wid
* Returns the last max_results matches in chronological order; caller must free *results. */ * Returns the last max_results matches in chronological order; caller must free *results. */
int message_search(const char *query, message_t **results, int max_results); int message_search(const char *query, message_t **results, int max_results);
/* Export valid persisted log records in messages.log v1 format. max_records
* 0 exports all valid records; positive values export the last max_records
* valid records. Caller must free *output. */
int message_dump_text(char **output, size_t *output_len, int max_records);
#endif /* MESSAGE_H */ #endif /* MESSAGE_H */

View file

@ -291,6 +291,45 @@ static int parse_tail_count(const char *args, int *count) {
return 0; return 0;
} }
static int parse_dump_count(const char *args, int *count) {
char *end = NULL;
long value;
if (!count) {
return -1;
}
*count = 0;
if (!args || args[0] == '\0') {
return 0;
}
if (strncmp(args, "-n", 2) == 0) {
args += 2;
while (*args && isspace((unsigned char)*args)) {
args++;
}
}
value = strtol(args, &end, 10);
if (end == args) {
return -1;
}
while (*end) {
if (!isspace((unsigned char)*end)) {
return -1;
}
end++;
}
if (value < 1 || value > 10000) {
return -1;
}
*count = (int)value;
return 0;
}
static int exec_command_tail(client_t *client, const char *args) { static int exec_command_tail(client_t *client, const char *args) {
int requested = 20; int requested = 20;
int total_messages; int total_messages;
@ -347,6 +386,27 @@ static int exec_command_tail(client_t *client, const char *args) {
return rc; return rc;
} }
static int exec_command_dump(client_t *client, const char *args) {
int requested = 0;
char *output = NULL;
size_t output_len = 0;
int rc;
if (parse_dump_count(args, &requested) < 0) {
return exec_command_usage(client, TNT_EXEC_COMMAND_DUMP);
}
if (message_dump_text(&output, &output_len, requested) < 0) {
client_printf(client, "dump: failed to read message log\n");
return TNT_EXIT_ERROR;
}
rc = client_send(client, output, output_len) == 0 ? TNT_EXIT_OK
: TNT_EXIT_ERROR;
free(output);
return rc;
}
static int exec_command_post(client_t *client, const char *args) { static int exec_command_post(client_t *client, const char *args) {
char content[MAX_MESSAGE_LEN]; char content[MAX_MESSAGE_LEN];
char username[MAX_USERNAME_LEN]; char username[MAX_USERNAME_LEN];
@ -451,6 +511,8 @@ int exec_dispatch(client_t *client) {
return exec_command_stats(client, args != NULL); return exec_command_stats(client, args != NULL);
case TNT_EXEC_COMMAND_TAIL: case TNT_EXEC_COMMAND_TAIL:
return exec_command_tail(client, args); return exec_command_tail(client, args);
case TNT_EXEC_COMMAND_DUMP:
return exec_command_dump(client, args);
case TNT_EXEC_COMMAND_POST: case TNT_EXEC_COMMAND_POST:
return exec_command_post(client, args); return exec_command_post(client, args);
case TNT_EXEC_COMMAND_EXIT: case TNT_EXEC_COMMAND_EXIT:

View file

@ -38,6 +38,14 @@ static const exec_catalog_entry_t entries[] = {
"tail -n N", "tail [N] | tail -n N", "tail -n N", "tail [N] | tail -n N",
I18N_STRING("Print recent messages", "输出最近消息"), I18N_STRING("Print recent messages", "输出最近消息"),
false, false, false}, false, false, false},
{TNT_EXEC_COMMAND_DUMP, "dump", NULL,
"dump [N]", "dump [N] | dump -n N",
I18N_STRING("Export persisted messages", "导出持久化消息"),
false, false, false},
{TNT_EXEC_COMMAND_DUMP, "dump", NULL,
"dump -n N", "dump [N] | dump -n N",
I18N_STRING("Export persisted messages", "导出持久化消息"),
false, false, false},
{TNT_EXEC_COMMAND_POST, "post", NULL, {TNT_EXEC_COMMAND_POST, "post", NULL,
"post MESSAGE", "post MESSAGE", "post MESSAGE", "post MESSAGE",
I18N_STRING("Post a message non-interactively", "非交互发送消息"), I18N_STRING("Post a message non-interactively", "非交互发送消息"),

View file

@ -6,6 +6,7 @@
#endif #endif
#include "message.h" #include "message.h"
#include "utf8.h" #include "utf8.h"
#include <errno.h>
#include <unistd.h> #include <unistd.h>
#include <fcntl.h> #include <fcntl.h>
@ -26,6 +27,17 @@ static time_t parse_rfc3339_utc(const char *timestamp_str) {
return timegm(&tm); return timegm(&tm);
} }
static void format_rfc3339_utc(time_t ts, char *buffer, size_t buf_size) {
struct tm tm_info;
if (!buffer || buf_size == 0) {
return;
}
gmtime_r(&ts, &tm_info);
strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info);
}
static void discard_line_remainder(FILE *fp) { static void discard_line_remainder(FILE *fp) {
int c; int c;
@ -102,6 +114,47 @@ static bool parse_log_record(const char *line, message_t *out,
return true; return true;
} }
static int append_dump_record(char **output, size_t *capacity,
size_t *len, const message_t *msg) {
char timestamp[64];
int needed;
size_t available;
if (!output || !capacity || !len || !msg) {
return -1;
}
format_rfc3339_utc(msg->timestamp, timestamp, sizeof(timestamp));
needed = snprintf(NULL, 0, "%s|%s|%s\n", timestamp, msg->username,
msg->content);
if (needed < 0) {
return -1;
}
available = *capacity > *len ? *capacity - *len : 0;
if ((size_t)needed + 1 > available) {
size_t new_capacity = *capacity ? *capacity : 1024;
while ((size_t)needed + 1 > new_capacity - *len) {
if (new_capacity > SIZE_MAX / 2) {
return -1;
}
new_capacity *= 2;
}
char *grown = realloc(*output, new_capacity);
if (!grown) {
return -1;
}
*output = grown;
*capacity = new_capacity;
}
snprintf(*output + *len, *capacity - *len, "%s|%s|%s\n", timestamp,
msg->username, msg->content);
*len += (size_t)needed;
return 0;
}
/* Initialize message subsystem */ /* Initialize message subsystem */
void message_init(void) { void message_init(void) {
/* Nothing to initialize for now */ /* Nothing to initialize for now */
@ -339,6 +392,118 @@ int message_search(const char *query, message_t **results, int max_results) {
return (count < max_results) ? count : max_results; return (count < max_results) ? count : max_results;
} }
int message_dump_text(char **output, size_t *output_len, int max_records) {
char log_path[PATH_MAX];
char *buf = NULL;
size_t capacity = 0;
size_t len = 0;
message_t *ring = NULL;
int seen = 0;
int rc = 0;
if (!output || !output_len || max_records < 0) {
return -1;
}
*output = calloc(1, 1);
if (!*output) {
return -1;
}
*output_len = 0;
if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) {
free(*output);
*output = NULL;
return -1;
}
if (max_records > 0) {
ring = calloc((size_t)max_records, sizeof(*ring));
if (!ring) {
free(*output);
*output = NULL;
return -1;
}
}
pthread_mutex_lock(&g_message_file_lock);
FILE *fp = fopen(log_path, "r");
if (!fp) {
int saved_errno = errno;
pthread_mutex_unlock(&g_message_file_lock);
free(ring);
if (saved_errno != ENOENT) {
free(*output);
*output = NULL;
return -1;
}
return 0;
}
char line[2048];
time_t now = time(NULL);
while (fgets(line, sizeof(line), fp)) {
size_t line_len = strlen(line);
if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
discard_line_remainder(fp);
continue;
}
message_t parsed;
if (!parse_log_record(line, &parsed, now)) {
continue;
}
if (max_records > 0) {
ring[seen % max_records] = parsed;
seen++;
} else if (append_dump_record(output, &capacity, output_len,
&parsed) < 0) {
rc = -1;
break;
}
}
fclose(fp);
pthread_mutex_unlock(&g_message_file_lock);
if (rc == 0 && max_records > 0 && seen > 0) {
int count = seen < max_records ? seen : max_records;
int start = seen < max_records ? 0 : seen % max_records;
free(*output);
*output = NULL;
*output_len = 0;
for (int i = 0; i < count; i++) {
message_t *msg = &ring[(start + i) % max_records];
if (append_dump_record(&buf, &capacity, &len, msg) < 0) {
rc = -1;
break;
}
}
if (rc == 0) {
*output = buf ? buf : calloc(1, 1);
*output_len = len;
if (!*output) {
rc = -1;
}
} else {
free(buf);
}
}
free(ring);
if (rc < 0) {
free(*output);
*output = NULL;
*output_len = 0;
return -1;
}
return 0;
}
/* Format a message for display */ /* Format a message for display */
void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) { void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) {
struct tm tm_info; struct tm tm_info;

View file

@ -20,7 +20,7 @@ static void print_usage(FILE *stream) {
" -h, --help Print this help and exit\n" " -h, --help Print this help and exit\n"
"\n" "\n"
"Commands mirror the TNT SSH exec interface: health, stats, users,\n" "Commands mirror the TNT SSH exec interface: health, stats, users,\n"
"tail, post, help, and exit.\n"); "tail, dump, post, help, and exit.\n");
} }
static bool is_valid_port(const char *value) { static bool is_valid_port(const char *value) {
@ -78,6 +78,7 @@ static bool is_known_exec_command(const char *command) {
strcmp(command, "stats") == 0 || strcmp(command, "stats") == 0 ||
strcmp(command, "users") == 0 || strcmp(command, "users") == 0 ||
strcmp(command, "tail") == 0 || strcmp(command, "tail") == 0 ||
strcmp(command, "dump") == 0 ||
strcmp(command, "post") == 0 || strcmp(command, "post") == 0 ||
strcmp(command, "help") == 0 || strcmp(command, "help") == 0 ||
strcmp(command, "exit") == 0); strcmp(command, "exit") == 0);

View file

@ -140,6 +140,19 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
DUMP_USAGE=$(ssh $SSH_OPTS localhost "dump -n nope" 2>/dev/null)
DUMP_USAGE_STATUS=$?
printf '%s\n' "$DUMP_USAGE" | grep -q '^dump: 用法: dump \[N\] | dump -n N$'
if [ $? -eq 0 ] && [ "$DUMP_USAGE_STATUS" -eq 64 ]; then
echo "✓ dump usage follows TNT_LANG and exits 64"
PASS=$((PASS + 1))
else
echo "✗ dump usage output unexpected"
printf '%s\n' "$DUMP_USAGE"
echo "exit status: $DUMP_USAGE_STATUS"
FAIL=$((FAIL + 1))
fi
POST_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "hello from exec" 2>/dev/null || true) POST_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "hello from exec" 2>/dev/null || true)
if [ "$POST_OUTPUT" = "posted" ]; then if [ "$POST_OUTPUT" = "posted" ]; then
echo "✓ post publishes a message" echo "✓ post publishes a message"
@ -161,6 +174,17 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
DUMP_OUTPUT=$(ssh $SSH_OPTS localhost "dump -n 1" 2>/dev/null || true)
printf '%s\n' "$DUMP_OUTPUT" | grep -q '|execposter|hello from exec$'
if [ $? -eq 0 ]; then
echo "✓ dump returns persisted message log records"
PASS=$((PASS + 1))
else
echo "✗ dump output unexpected"
printf '%s\n' "$DUMP_OUTPUT"
FAIL=$((FAIL + 1))
fi
PERSIST_FAIL_MARKER="persist-fail-marker" PERSIST_FAIL_MARKER="persist-fail-marker"
rm -f "$STATE_DIR/messages.log" rm -f "$STATE_DIR/messages.log"
mkdir "$STATE_DIR/messages.log" mkdir "$STATE_DIR/messages.log"
@ -261,6 +285,17 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
TNTCTL_DUMP=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost "dump" "-n" "1" 2>/dev/null || true)
printf '%s\n' "$TNTCTL_DUMP" | grep -q '|ctlposter|hello from tntctl$'
if [ $? -eq 0 ]; then
echo "✓ tntctl dump returns persisted message log records"
PASS=$((PASS + 1))
else
echo "✗ tntctl dump output unexpected"
printf '%s\n' "$TNTCTL_DUMP"
FAIL=$((FAIL + 1))
fi
EXPECT_SCRIPT="${STATE_DIR}/watcher.expect" EXPECT_SCRIPT="${STATE_DIR}/watcher.expect"
WATCHER_READY="${STATE_DIR}/watcher.ready" WATCHER_READY="${STATE_DIR}/watcher.ready"
cat >"$EXPECT_SCRIPT" <<EOF cat >"$EXPECT_SCRIPT" <<EOF

View file

@ -108,6 +108,17 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi fi
run_ok "dump command is accepted" "$BIN" example.com dump -n 1
grep -q '^dump -n 1$' "$SSH_LOG"
if [ $? -eq 0 ]; then
echo "✓ dump argv is forwarded as one remote command"
PASS=$((PASS + 1))
else
echo "✗ dump argv unexpected"
cat "$SSH_LOG"
FAIL=$((FAIL + 1))
fi
PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" "$BIN" example.com users --xml >/dev/null 2>&1 PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" "$BIN" example.com users --xml >/dev/null 2>&1
REMOTE_STATUS=$? REMOTE_STATUS=$?
if [ "$REMOTE_STATUS" -eq 64 ]; then if [ "$REMOTE_STATUS" -eq 64 ]; then

View file

@ -28,12 +28,14 @@ TEST(generates_localized_exec_help) {
assert(strstr(en, "TNT exec interface") != NULL); assert(strstr(en, "TNT exec interface") != NULL);
assert(strstr(en, "Commands:") != NULL); assert(strstr(en, "Commands:") != NULL);
assert(strstr(en, "users [--json]") != NULL); assert(strstr(en, "users [--json]") != NULL);
assert(strstr(en, "dump [N]") != NULL);
assert(strstr(en, "post MESSAGE") != NULL); assert(strstr(en, "post MESSAGE") != NULL);
assert(strstr(en, "support") == NULL); assert(strstr(en, "support") == NULL);
assert(strstr(zh, "TNT exec 接口") != NULL); assert(strstr(zh, "TNT exec 接口") != NULL);
assert(strstr(zh, "命令:") != NULL); assert(strstr(zh, "命令:") != NULL);
assert(strstr(zh, "users [--json]") != NULL); assert(strstr(zh, "users [--json]") != NULL);
assert(strstr(zh, "dump [N]") != NULL);
assert(strstr(zh, "post MESSAGE") != NULL); assert(strstr(zh, "post MESSAGE") != NULL);
assert(strstr(zh, "support") == NULL); assert(strstr(zh, "support") == NULL);
assert_ascii_angle_placeholders(zh); assert_ascii_angle_placeholders(zh);
@ -65,6 +67,10 @@ TEST(matches_exec_commands_and_args) {
assert(id == TNT_EXEC_COMMAND_TAIL); assert(id == TNT_EXEC_COMMAND_TAIL);
assert(strcmp(args, "-n 20") == 0); assert(strcmp(args, "-n 20") == 0);
assert(exec_catalog_match("dump -n 20", &id, &args));
assert(id == TNT_EXEC_COMMAND_DUMP);
assert(strcmp(args, "-n 20") == 0);
assert(exec_catalog_match("post hello world", &id, &args)); assert(exec_catalog_match("post hello world", &id, &args));
assert(id == TNT_EXEC_COMMAND_POST); assert(id == TNT_EXEC_COMMAND_POST);
assert(strcmp(args, "hello world") == 0); assert(strcmp(args, "hello world") == 0);
@ -90,6 +96,9 @@ TEST(validates_argument_shapes) {
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, NULL)); assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, "-n 20")); assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, "-n 20"));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_DUMP, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_DUMP, "-n 20"));
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, NULL)); assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, "hello")); assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, "hello"));
} }
@ -111,8 +120,8 @@ TEST(generates_localized_usage) {
memset(en, 0, sizeof(en)); memset(en, 0, sizeof(en));
en_pos = 0; en_pos = 0;
exec_catalog_append_usage(en, sizeof(en), &en_pos, exec_catalog_append_usage(en, sizeof(en), &en_pos,
TNT_EXEC_COMMAND_TAIL, (ui_lang_t)99); TNT_EXEC_COMMAND_DUMP, (ui_lang_t)99);
assert(strcmp(en, "tail: usage: tail [N] | tail -n N\n") == 0); assert(strcmp(en, "dump: usage: dump [N] | dump -n N\n") == 0);
} }
int main(void) { int main(void) {

View file

@ -208,6 +208,53 @@ TEST(message_search_skips_malformed_records) {
cleanup_state_dir(); cleanup_state_dir();
} }
TEST(message_dump_exports_valid_records) {
char ts[64];
char log_path[PATH_MAX];
char expected_all[512];
char expected_last_two[512];
char *dump = NULL;
size_t dump_len = 0;
setup_state_dir();
format_rfc3339_now(ts, sizeof(ts));
snprintf(log_path, sizeof(log_path), "%s/messages.log", test_state_dir);
FILE *fp = fopen(log_path, "wb");
assert(fp != NULL);
fprintf(fp, "%s|alice|first valid\n", ts);
fprintf(fp, "%s|mallory|extra|pipe\n", ts);
fprintf(fp, "%s|bob|second valid\n", ts);
fprintf(fp, "%s|carol|third valid\n", ts);
fprintf(fp, "%s|partial|truncated record", ts);
fclose(fp);
snprintf(expected_all, sizeof(expected_all),
"%s|alice|first valid\n"
"%s|bob|second valid\n"
"%s|carol|third valid\n",
ts, ts, ts);
assert(message_dump_text(&dump, &dump_len, 0) == 0);
assert(dump != NULL);
assert(dump_len == strlen(expected_all));
assert(strcmp(dump, expected_all) == 0);
free(dump);
dump = NULL;
dump_len = 0;
snprintf(expected_last_two, sizeof(expected_last_two),
"%s|bob|second valid\n"
"%s|carol|third valid\n",
ts, ts);
assert(message_dump_text(&dump, &dump_len, 2) == 0);
assert(dump != NULL);
assert(dump_len == strlen(expected_last_two));
assert(strcmp(dump, expected_last_two) == 0);
free(dump);
cleanup_state_dir();
}
/* Test edge cases */ /* Test edge cases */
TEST(message_edge_cases) { TEST(message_edge_cases) {
message_t msg; message_t msg;
@ -303,6 +350,7 @@ int main(void) {
RUN_TEST(message_save_basic); RUN_TEST(message_save_basic);
RUN_TEST(message_load_skips_malformed_records); RUN_TEST(message_load_skips_malformed_records);
RUN_TEST(message_search_skips_malformed_records); RUN_TEST(message_search_skips_malformed_records);
RUN_TEST(message_dump_exports_valid_records);
RUN_TEST(message_edge_cases); RUN_TEST(message_edge_cases);
RUN_TEST(message_special_characters); RUN_TEST(message_special_characters);
RUN_TEST(message_buffer_safety); RUN_TEST(message_buffer_safety);

1
tnt.1
View file

@ -222,6 +222,7 @@ ssh host \-p 2222 help
ssh host \-p 2222 users \-\-json ssh host \-p 2222 users \-\-json
ssh host \-p 2222 stats \-\-json ssh host \-p 2222 stats \-\-json
ssh host \-p 2222 tail 20 ssh host \-p 2222 tail 20
ssh host \-p 2222 dump \-n 100
ssh host \-p 2222 post "Hello from a script" ssh host \-p 2222 post "Hello from a script"
ssh host \-p 2222 post "/me deploys v2.0" ssh host \-p 2222 post "/me deploys v2.0"
ssh host \-p 2222 health ssh host \-p 2222 health

View file

@ -73,6 +73,12 @@ Print recent messages.
.B tail -n N .B tail -n N
Print recent messages. Print recent messages.
.TP .TP
.B dump [N]
Export persisted messages.
.TP
.B dump -n N
Export persisted messages.
.TP
.B post MESSAGE .B post MESSAGE
Post a message non-interactively. Post a message non-interactively.
.TP .TP
@ -82,6 +88,7 @@ Print the server exec help.
.nf .nf
tntctl chat.example.com health tntctl chat.example.com health
tntctl -p 2222 chat.example.com stats --json tntctl -p 2222 chat.example.com stats --json
tntctl -p 2222 chat.example.com dump -n 100
tntctl -l operator chat.example.com post "service notice" tntctl -l operator chat.example.com post "service notice"
tntctl --host-key-checking accept-new chat.example.com users tntctl --host-key-checking accept-new chat.example.com users
.fi .fi