mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-06-26 05:44:38 +08:00
Add persisted message dump command
This commit is contained in:
parent
7b5a683557
commit
8b55a3d9ab
19 changed files with 395 additions and 5 deletions
|
|
@ -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"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
62
src/exec.c
62
src/exec.c
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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", "非交互发送消息"),
|
||||||
|
|
|
||||||
165
src/message.c
165
src/message.c
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
1
tnt.1
|
|
@ -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
|
||||||
|
|
|
||||||
7
tntctl.1
7
tntctl.1
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue