Skip to content

Commit fc9f9ca

Browse files
committed
Harden Telegram command reliability and CI quality gates
1 parent fe22c5f commit fc9f9ca

7 files changed

Lines changed: 352 additions & 157 deletions

File tree

.github/workflows/release.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,25 @@ jobs:
4242
echo "tag=${TAG}" >> "${GITHUB_OUTPUT}"
4343
echo "prerelease=${PRERELEASE}" >> "${GITHUB_OUTPUT}"
4444
45-
build:
45+
test:
4646
needs: prepare
4747
runs-on: ubuntu-latest
48+
steps:
49+
- uses: actions/checkout@v4
50+
51+
- uses: actions/setup-go@v5
52+
with:
53+
go-version-file: go.mod
54+
cache: false
55+
56+
- name: Run go test
57+
run: |
58+
set -euo pipefail
59+
go test ./...
60+
61+
build:
62+
needs: [prepare, test]
63+
runs-on: ubuntu-latest
4864
strategy:
4965
fail-fast: false
5066
matrix:

.github/workflows/test.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ permissions:
1212
jobs:
1313
go-test:
1414
runs-on: ubuntu-latest
15+
env:
16+
COVERAGE_MIN: "25.0"
1517
steps:
1618
- uses: actions/checkout@v4
1719

@@ -41,3 +43,14 @@ jobs:
4143
- name: Publish test summary
4244
run: |
4345
echo "Total Go coverage: ${{ steps.coverage.outputs.total }}" >> "${GITHUB_STEP_SUMMARY}"
46+
47+
- name: Enforce minimum coverage
48+
run: |
49+
set -euo pipefail
50+
total="${{ steps.coverage.outputs.total }}"
51+
pct="${total%\%}"
52+
min="${COVERAGE_MIN}"
53+
awk -v pct="${pct}" -v min="${min}" 'BEGIN { exit !(pct+0 >= min+0) }' || {
54+
echo "coverage gate failed: total=${total}, required>=${min}%"
55+
exit 1
56+
}

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,9 @@ ralphctl --project-dir "$PWD" telegram setup --non-interactive \
186186
--user-ids "<user-id>" \
187187
--allow-control=false \
188188
--notify=true \
189-
--notify-scope auto
189+
--notify-scope auto \
190+
--command-timeout-sec 300 \
191+
--command-concurrency 4
190192
```
191193

192194
권장:
@@ -212,7 +214,9 @@ ralphctl --project-dir "$PWD" telegram setup --non-interactive \
212214
--user-ids "<allowed-user-id-1>,<allowed-user-id-2>" \
213215
--allow-control=false \
214216
--notify=true \
215-
--notify-scope auto
217+
--notify-scope auto \
218+
--command-timeout-sec 300 \
219+
--command-concurrency 4
216220
```
217221

218222
보안 규칙:

cmd/ralphctl/telegram_command.go

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
func runTelegramCommand(controlDir string, paths ralph.Paths, args []string) error {
2626
usage := func() {
2727
fmt.Fprintln(os.Stderr, "Usage: ralphctl --control-dir DIR --project-dir DIR telegram <run|setup|stop|status|tail> [flags]")
28-
fmt.Fprintln(os.Stderr, "Env: RALPH_TELEGRAM_BOT_TOKEN, RALPH_TELEGRAM_CHAT_IDS, RALPH_TELEGRAM_USER_IDS, RALPH_TELEGRAM_ALLOW_CONTROL, RALPH_TELEGRAM_NOTIFY, RALPH_TELEGRAM_NOTIFY_SCOPE")
28+
fmt.Fprintln(os.Stderr, "Env: RALPH_TELEGRAM_BOT_TOKEN, RALPH_TELEGRAM_CHAT_IDS, RALPH_TELEGRAM_USER_IDS, RALPH_TELEGRAM_ALLOW_CONTROL, RALPH_TELEGRAM_NOTIFY, RALPH_TELEGRAM_NOTIFY_SCOPE, RALPH_TELEGRAM_COMMAND_TIMEOUT_SEC, RALPH_TELEGRAM_COMMAND_CONCURRENCY")
2929
}
3030
if len(args) == 0 {
3131
usage()
@@ -68,6 +68,8 @@ func runTelegramRunCommand(controlDir string, paths ralph.Paths, args []string)
6868
notifyIntervalSec := fs.Int("notify-interval-sec", envIntDefault("RALPH_TELEGRAM_NOTIFY_INTERVAL_SEC", cfg.NotifyIntervalSec), "status poll interval for notify alerts")
6969
notifyRetryThreshold := fs.Int("notify-retry-threshold", envIntDefault("RALPH_TELEGRAM_NOTIFY_RETRY_THRESHOLD", cfg.NotifyRetryThreshold), "codex retry alert threshold")
7070
notifyPermStreakThreshold := fs.Int("notify-perm-streak-threshold", envIntDefault("RALPH_TELEGRAM_NOTIFY_PERM_STREAK_THRESHOLD", cfg.NotifyPermStreakThreshold), "permission streak alert threshold")
71+
commandTimeoutSec := fs.Int("command-timeout-sec", envIntDefault("RALPH_TELEGRAM_COMMAND_TIMEOUT_SEC", cfg.CommandTimeoutSec), "timeout seconds per telegram command")
72+
commandConcurrency := fs.Int("command-concurrency", envIntDefault("RALPH_TELEGRAM_COMMAND_CONCURRENCY", cfg.CommandConcurrency), "max concurrent command workers across chats")
7173
pollTimeoutSec := fs.Int("poll-timeout-sec", 30, "telegram getUpdates timeout (seconds)")
7274
offsetFile := fs.String("offset-file", filepath.Join(controlDir, "telegram.offset"), "telegram update offset file")
7375
if err := fs.Parse(args); err != nil {
@@ -101,6 +103,12 @@ func runTelegramRunCommand(controlDir string, paths ralph.Paths, args []string)
101103
if *notifyIntervalSec <= 0 {
102104
return fmt.Errorf("--notify-interval-sec must be > 0")
103105
}
106+
if *commandTimeoutSec <= 0 {
107+
return fmt.Errorf("--command-timeout-sec must be > 0")
108+
}
109+
if *commandConcurrency <= 0 {
110+
return fmt.Errorf("--command-concurrency must be > 0")
111+
}
104112
resolvedNotifyScope, err := normalizeNotifyScope(*notifyScope)
105113
if err != nil {
106114
return fmt.Errorf("invalid --notify-scope: %w", err)
@@ -141,6 +149,8 @@ func runTelegramRunCommand(controlDir string, paths ralph.Paths, args []string)
141149
fmt.Printf("Notify Every: %ds\n", *notifyIntervalSec)
142150
fmt.Printf("Retry Alert: %d\n", *notifyRetryThreshold)
143151
fmt.Printf("Perm Alert: %d\n", *notifyPermStreakThreshold)
152+
fmt.Printf("Cmd Timeout: %ds\n", *commandTimeoutSec)
153+
fmt.Printf("Cmd Workers: %d\n", *commandConcurrency)
144154
fmt.Printf("Allowed Chats: %d\n", len(allowedChatIDs))
145155
if len(allowedUserIDs) > 0 {
146156
fmt.Printf("Allowed Users: %d\n", len(allowedUserIDs))
@@ -157,15 +167,17 @@ func runTelegramRunCommand(controlDir string, paths ralph.Paths, args []string)
157167
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
158168
defer stop()
159169
return ralph.RunTelegramBot(ctx, ralph.TelegramBotOptions{
160-
Token: *token,
161-
AllowedChatIDs: allowedChatIDs,
162-
AllowedUserIDs: allowedUserIDs,
163-
PollTimeoutSec: *pollTimeoutSec,
164-
NotifyIntervalSec: *notifyIntervalSec,
165-
OffsetFile: *offsetFile,
166-
Out: os.Stdout,
167-
OnCommand: telegramCommandHandler(controlDir, paths, *allowControl),
168-
OnNotifyTick: notifyHandler,
170+
Token: *token,
171+
AllowedChatIDs: allowedChatIDs,
172+
AllowedUserIDs: allowedUserIDs,
173+
PollTimeoutSec: *pollTimeoutSec,
174+
NotifyIntervalSec: *notifyIntervalSec,
175+
CommandTimeoutSec: *commandTimeoutSec,
176+
CommandConcurrency: *commandConcurrency,
177+
OffsetFile: *offsetFile,
178+
Out: os.Stdout,
179+
OnCommand: telegramCommandHandler(controlDir, paths, *allowControl),
180+
OnNotifyTick: notifyHandler,
169181
})
170182
}
171183

@@ -243,6 +255,8 @@ func runTelegramSetupCommand(controlDir string, args []string) error {
243255
defaultNotifyInterval := envIntDefault("RALPH_TELEGRAM_NOTIFY_INTERVAL_SEC", cfg.NotifyIntervalSec)
244256
defaultNotifyRetry := envIntDefault("RALPH_TELEGRAM_NOTIFY_RETRY_THRESHOLD", cfg.NotifyRetryThreshold)
245257
defaultNotifyPerm := envIntDefault("RALPH_TELEGRAM_NOTIFY_PERM_STREAK_THRESHOLD", cfg.NotifyPermStreakThreshold)
258+
defaultCommandTimeout := envIntDefault("RALPH_TELEGRAM_COMMAND_TIMEOUT_SEC", cfg.CommandTimeoutSec)
259+
defaultCommandConcurrency := envIntDefault("RALPH_TELEGRAM_COMMAND_CONCURRENCY", cfg.CommandConcurrency)
246260

247261
fs := flag.NewFlagSet("telegram setup", flag.ContinueOnError)
248262
configFileFlag := fs.String("config-file", configFile, "telegram config file path")
@@ -256,6 +270,8 @@ func runTelegramSetupCommand(controlDir string, args []string) error {
256270
notifyIntervalFlag := fs.Int("notify-interval-sec", defaultNotifyInterval, "notify interval seconds")
257271
notifyRetryFlag := fs.Int("notify-retry-threshold", defaultNotifyRetry, "notify retry threshold")
258272
notifyPermFlag := fs.Int("notify-perm-streak-threshold", defaultNotifyPerm, "notify permission streak threshold")
273+
commandTimeoutFlag := fs.Int("command-timeout-sec", defaultCommandTimeout, "timeout seconds per telegram command")
274+
commandConcurrencyFlag := fs.Int("command-concurrency", defaultCommandConcurrency, "max concurrent command workers across chats")
259275
if err := fs.Parse(args); err != nil {
260276
return err
261277
}
@@ -270,6 +286,8 @@ func runTelegramSetupCommand(controlDir string, args []string) error {
270286
NotifyIntervalSec: *notifyIntervalFlag,
271287
NotifyRetryThreshold: *notifyRetryFlag,
272288
NotifyPermStreakThreshold: *notifyPermFlag,
289+
CommandTimeoutSec: *commandTimeoutFlag,
290+
CommandConcurrency: *commandConcurrencyFlag,
273291
}
274292
configFile = strings.TrimSpace(*configFileFlag)
275293

@@ -338,6 +356,22 @@ func runTelegramSetupCommand(controlDir string, args []string) error {
338356
if v, convErr := strconv.Atoi(strings.TrimSpace(permInput)); convErr == nil {
339357
final.NotifyPermStreakThreshold = v
340358
}
359+
360+
timeoutInput, err := promptFleetInput(reader, "Command timeout sec", strconv.Itoa(final.CommandTimeoutSec))
361+
if err != nil {
362+
return err
363+
}
364+
if v, convErr := strconv.Atoi(strings.TrimSpace(timeoutInput)); convErr == nil {
365+
final.CommandTimeoutSec = v
366+
}
367+
368+
workersInput, err := promptFleetInput(reader, "Command concurrency", strconv.Itoa(final.CommandConcurrency))
369+
if err != nil {
370+
return err
371+
}
372+
if v, convErr := strconv.Atoi(strings.TrimSpace(workersInput)); convErr == nil {
373+
final.CommandConcurrency = v
374+
}
341375
}
342376

343377
if strings.TrimSpace(final.Token) == "" {
@@ -363,6 +397,12 @@ func runTelegramSetupCommand(controlDir string, args []string) error {
363397
if final.NotifyIntervalSec <= 0 {
364398
return fmt.Errorf("notify-interval-sec must be > 0")
365399
}
400+
if final.CommandTimeoutSec <= 0 {
401+
return fmt.Errorf("command-timeout-sec must be > 0")
402+
}
403+
if final.CommandConcurrency <= 0 {
404+
return fmt.Errorf("command-concurrency must be > 0")
405+
}
366406
scope, err := normalizeNotifyScope(final.NotifyScope)
367407
if err != nil {
368408
return fmt.Errorf("notify-scope: %w", err)
@@ -378,6 +418,8 @@ func runTelegramSetupCommand(controlDir string, args []string) error {
378418
fmt.Printf("Allow Control: %t\n", final.AllowControl)
379419
fmt.Printf("Notify: %t\n", final.Notify)
380420
fmt.Printf("Notify Scope: %s\n", final.NotifyScope)
421+
fmt.Printf("Cmd Timeout: %ds\n", final.CommandTimeoutSec)
422+
fmt.Printf("Cmd Workers: %d\n", final.CommandConcurrency)
381423
fmt.Println()
382424
fmt.Println("Next Commands")
383425
fmt.Printf("- run: ralphctl --project-dir \"$PWD\" telegram run --config-file %s\n", configFile)
@@ -396,6 +438,8 @@ type telegramCLIConfig struct {
396438
NotifyIntervalSec int
397439
NotifyRetryThreshold int
398440
NotifyPermStreakThreshold int
441+
CommandTimeoutSec int
442+
CommandConcurrency int
399443
}
400444

401445
func defaultTelegramCLIConfig() telegramCLIConfig {
@@ -406,6 +450,8 @@ func defaultTelegramCLIConfig() telegramCLIConfig {
406450
NotifyIntervalSec: 30,
407451
NotifyRetryThreshold: 2,
408452
NotifyPermStreakThreshold: 3,
453+
CommandTimeoutSec: 300,
454+
CommandConcurrency: 4,
409455
}
410456
}
411457

@@ -470,6 +516,12 @@ func loadTelegramCLIConfig(path string) (telegramCLIConfig, error) {
470516
if v, ok := parseIntRaw(values["RALPH_TELEGRAM_NOTIFY_PERM_STREAK_THRESHOLD"]); ok {
471517
cfg.NotifyPermStreakThreshold = v
472518
}
519+
if v, ok := parseIntRaw(values["RALPH_TELEGRAM_COMMAND_TIMEOUT_SEC"]); ok {
520+
cfg.CommandTimeoutSec = v
521+
}
522+
if v, ok := parseIntRaw(values["RALPH_TELEGRAM_COMMAND_CONCURRENCY"]); ok {
523+
cfg.CommandConcurrency = v
524+
}
473525
return cfg, nil
474526
}
475527

@@ -492,6 +544,8 @@ func saveTelegramCLIConfig(path string, cfg telegramCLIConfig) error {
492544
b.WriteString("RALPH_TELEGRAM_NOTIFY_INTERVAL_SEC=" + strconv.Itoa(cfg.NotifyIntervalSec) + "\n")
493545
b.WriteString("RALPH_TELEGRAM_NOTIFY_RETRY_THRESHOLD=" + strconv.Itoa(cfg.NotifyRetryThreshold) + "\n")
494546
b.WriteString("RALPH_TELEGRAM_NOTIFY_PERM_STREAK_THRESHOLD=" + strconv.Itoa(cfg.NotifyPermStreakThreshold) + "\n")
547+
b.WriteString("RALPH_TELEGRAM_COMMAND_TIMEOUT_SEC=" + strconv.Itoa(cfg.CommandTimeoutSec) + "\n")
548+
b.WriteString("RALPH_TELEGRAM_COMMAND_CONCURRENCY=" + strconv.Itoa(cfg.CommandConcurrency) + "\n")
495549
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
496550
return err
497551
}

cmd/ralphctl/telegram_command_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ func TestSaveLoadTelegramCLIConfig(t *testing.T) {
174174
NotifyIntervalSec: 45,
175175
NotifyRetryThreshold: 3,
176176
NotifyPermStreakThreshold: 5,
177+
CommandTimeoutSec: 180,
178+
CommandConcurrency: 6,
177179
}
178180
if err := saveTelegramCLIConfig(path, want); err != nil {
179181
t.Fatalf("saveTelegramCLIConfig failed: %v", err)
@@ -210,6 +212,12 @@ func TestSaveLoadTelegramCLIConfig(t *testing.T) {
210212
if got.NotifyPermStreakThreshold != want.NotifyPermStreakThreshold {
211213
t.Fatalf("notify perm mismatch: got=%d want=%d", got.NotifyPermStreakThreshold, want.NotifyPermStreakThreshold)
212214
}
215+
if got.CommandTimeoutSec != want.CommandTimeoutSec {
216+
t.Fatalf("command timeout mismatch: got=%d want=%d", got.CommandTimeoutSec, want.CommandTimeoutSec)
217+
}
218+
if got.CommandConcurrency != want.CommandConcurrency {
219+
t.Fatalf("command concurrency mismatch: got=%d want=%d", got.CommandConcurrency, want.CommandConcurrency)
220+
}
213221

214222
info, err := os.Stat(path)
215223
if err != nil {

0 commit comments

Comments
 (0)