Skip to content

Commit a60e658

Browse files
authored
Merge pull request #6 from BackendStack21/feat/docker-supercronic-reminders
feat(docker): scheduled reminders via supercronic
2 parents 5783c8d + 8eb09c0 commit a60e658

10 files changed

Lines changed: 258 additions & 3 deletions

File tree

cmd/odek/telegram.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,14 @@ func spawnChild() error {
899899
if err != nil {
900900
return fmt.Errorf("executable: %w", err)
901901
}
902+
// When running inside the Docker container the entrypoint script exports
903+
// ODEK_ENTRYPOINT=$0. Re-exec through the wrapper so supercronic is
904+
// restarted alongside the new odek process. The wrapper reads
905+
// ODEK_SUPERCRONIC_PID (also in childEnv via os.Environ()) and kills the
906+
// previous supercronic before starting a new one — no duplicate instances.
907+
if ep := os.Getenv("ODEK_ENTRYPOINT"); ep != "" {
908+
exe = ep
909+
}
902910
// Copy args (same as current process).
903911
argv := make([]string, len(os.Args))
904912
copy(argv, os.Args)

cmd/odek/telegram_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,50 @@ func TestSpawnChild_StartsChildProcess(t *testing.T) {
2424
}
2525
}
2626

27+
func TestSpawnChild_UsesODEKENTRYPOINT(t *testing.T) {
28+
// When ODEK_ENTRYPOINT is set (injected by cron-entrypoint.sh inside the
29+
// container), spawnChild must use that path as the executable so the
30+
// wrapper restarts supercronic alongside the new odek process.
31+
// /bin/sh is a universally present executable that accepts arbitrary args
32+
// and exits immediately when given -c ''; it lets us verify the branch
33+
// without spawning a real odek binary.
34+
t.Setenv("ODEK_ENTRYPOINT", "/bin/sh")
35+
err := spawnChild()
36+
// /bin/sh exits quickly with a non-zero code because os.Args contains
37+
// test flags it does not understand, but os.StartProcess itself succeeds
38+
// (process started) — the important thing is no "executable not found" error.
39+
if err != nil {
40+
t.Logf("spawnChild with ODEK_ENTRYPOINT returned: %v", err)
41+
}
42+
}
43+
44+
func TestSpawnChild_ODEKENTRYPOINTEmpty_FallsBackToOdekBinary(t *testing.T) {
45+
// When ODEK_ENTRYPOINT is empty (not set), the executable must remain
46+
// the current odek binary — not some zero-value path.
47+
t.Setenv("ODEK_ENTRYPOINT", "")
48+
err := spawnChild()
49+
if err != nil {
50+
t.Logf("spawnChild (no ODEK_ENTRYPOINT) returned: %v", err)
51+
}
52+
}
53+
54+
func TestSpawnChild_ResolvedAPIKeyInjected(t *testing.T) {
55+
// resolvedAPIKey is re-injected into the child env so config.LoadConfig
56+
// (which clears env keys) does not leave the child without credentials.
57+
orig := resolvedAPIKey
58+
t.Cleanup(func() { resolvedAPIKey = orig })
59+
resolvedAPIKey = "test-key-abc"
60+
err := spawnChild()
61+
if err != nil {
62+
t.Logf("spawnChild returned: %v", err)
63+
}
64+
// Verify the key is still set in current env (spawnChild must not mutate
65+
// os.Environ — it appends to a copy for the child only).
66+
if v := os.Getenv("ODEK_API_KEY"); v == "test-key-abc" {
67+
t.Error("spawnChild must not mutate the current process environment")
68+
}
69+
}
70+
2771
func TestWriteAndReadRestartMarker(t *testing.T) {
2872
tmp := t.TempDir()
2973
t.Setenv("HOME", tmp)

docker/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ GIT_COMMITTER_EMAIL=you@example.com
4444
# ODEK_TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token
4545
# ODEK_TELEGRAM_ALLOWED_CHATS=11111111 # comma-separated chat IDs
4646
# ODEK_TELEGRAM_ALLOWED_USERS=11111111 # comma-separated user IDs (optional)
47+
# Chat ID that `odek run --deliver` (and supercronic reminders) send to.
48+
# Required for scheduled reminders; usually your own chat ID from ALLOWED_CHATS.
49+
# ODEK_TELEGRAM_DEFAULT_CHAT_ID=11111111
4750
# ODEK_TELEGRAM_DAILY_TOKEN_BUDGET=2000000 # optional cost cap; 0/unset = unlimited
4851
# ODEK_TELEGRAM_SESSION_TTL_HOURS=24 # optional
4952
# ODEK_TELEGRAM_HEALTH_ADDR=0.0.0.0:9090 # optional GET /health endpoint

docker/Dockerfile

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,40 @@ RUN apk add --no-cache ca-certificates git github-cli bash coreutils curl jq
6363
# `adduser -D -u 1000 odek` with `useradd -m -u 1000 odek`
6464
# (the mkdir/chown, ENV HOME, USER, and WORKDIR lines all work unchanged).
6565

66+
# ── supercronic — container-friendly cron for scheduled `odek run` jobs ──
67+
# Why not busybox crond: crond launches jobs with a SCRUBBED environment, so
68+
# env_file vars (ODEK_API_KEY, ODEK_TELEGRAM_BOT_TOKEN, …) never reach a cron
69+
# tick — and it wants root to setuid, clashing with the non-root user below.
70+
# supercronic runs as a normal user and passes ITS OWN environment through to
71+
# each job, so `odek run --deliver` from cron sees the same vars as the bot.
72+
#
73+
# Pinned to a release + SHA-256 computed from the official GitHub assets, so a
74+
# tampered or substituted binary fails the build. TARGETARCH is supplied by
75+
# BuildKit (this Dockerfile already opts in via the syntax= directive above).
76+
ARG SUPERCRONIC_VERSION=v0.2.46
77+
ARG TARGETARCH
78+
RUN set -eu; \
79+
arch="${TARGETARCH:?TARGETARCH is empty — build with BuildKit (docker buildx build) or pass --build-arg TARGETARCH=amd64|arm64}"; \
80+
case "$arch" in \
81+
amd64) sha=5adff01c5a797663948e656d2b61d10932369ee437eb5cb54fa872b2960f222b ;; \
82+
arm64) sha=c0576a8eb092e3f79108ed0a2155a25c7766af78456e5a6070e54757ef513bfe ;; \
83+
*) echo "supercronic: unsupported TARGETARCH=$arch" >&2; exit 1 ;; \
84+
esac; \
85+
curl -fsSL "https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-${arch}" \
86+
-o /usr/local/bin/supercronic; \
87+
echo "${sha} /usr/local/bin/supercronic" | sha256sum -c -; \
88+
chmod +x /usr/local/bin/supercronic
89+
6690
# Run as a non-root user — defense in depth even inside the container.
6791
# Pre-create ~/.odek owned by the user so it's writable for config, sessions,
6892
# and the Telegram lock (whether backed by an image dir or a mounted folder).
93+
# ~/.crontabs holds the (optional) bind-mounted crontab read by supercronic.
6994
RUN adduser -D -u 1000 odek \
70-
&& mkdir -p /home/odek/.odek /workspace \
71-
&& chown -R odek:odek /home/odek/.odek /workspace
95+
&& mkdir -p /home/odek/.odek /home/odek/.crontabs /workspace \
96+
&& chown -R odek:odek /home/odek/.odek /home/odek/.crontabs /workspace
7297

7398
COPY --from=build /out/odek /usr/local/bin/odek
99+
COPY --chmod=0755 docker/cron-entrypoint.sh /usr/local/bin/cron-entrypoint.sh
74100

75101
# Docker does NOT set $HOME from USER, but Odek resolves ~/.odek via $HOME.
76102
# Set it explicitly so config.json, sessions, and the Telegram lock land in
@@ -79,4 +105,8 @@ ENV HOME=/home/odek
79105
USER odek
80106
WORKDIR /workspace
81107

82-
ENTRYPOINT ["odek"]
108+
# The wrapper starts supercronic in the background IFF a crontab is mounted,
109+
# then `exec`s odek — so services without a crontab behave exactly as before
110+
# (odek stays the container's main process; signals and the singleton lock are
111+
# unchanged). The compose `command:` (serve/telegram/run …) flows through as $@.
112+
ENTRYPOINT ["/usr/local/bin/cron-entrypoint.sh"]

docker/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ docker/
2525
├── config.restricted.json # Restricted permission policy
2626
├── config.godmode.json # Godmode (YOLO) permission policy
2727
├── .env.example # copy to .env, add your API key
28+
├── cron-entrypoint.sh # starts supercronic (if a crontab is mounted), then execs odek
29+
├── crontab # scheduled reminders (edit + uncomment to enable)
2830
└── workspace/ # the dir the agent works in (mounted in)
2931
```
3032

@@ -99,6 +101,29 @@ local `./.odek` folder — an external host folder, just like `./workspace`.
99101
> long-poller per bot (a second gets `409 Conflict`). Create a second bot via
100102
> @BotFather if you want both.
101103
104+
### Scheduled reminders (cron)
105+
106+
The Telegram profiles bundle [supercronic](https://github.com/aptible/supercronic), a
107+
container-friendly cron. Unlike the classic `crond`, it runs as the non-root user **and
108+
passes the container environment to each job** — so a scheduled `odek run --deliver`
109+
sees the same `.env` vars (API key, bot token) the bot does. No separate host crontab,
110+
no daemon juggling.
111+
112+
1. In `.env`, set **`ODEK_TELEGRAM_DEFAULT_CHAT_ID`** — the chat reminders are sent to
113+
(usually your own ID, the same as `ODEK_TELEGRAM_ALLOWED_CHATS`).
114+
2. Edit `crontab` and uncomment/add jobs (standard 5-field syntax; min granularity is
115+
1 minute). Example — a weekday stand-up nudge:
116+
117+
```cron
118+
0 9 * * 1-5 /usr/local/bin/odek run --deliver "Reminder: stand-up in 15 minutes."
119+
```
120+
121+
3. (Re)start a Telegram profile. On boot you'll see `cron-entrypoint: starting
122+
supercronic …` in the logs; each job's result is delivered to your chat.
123+
124+
Times are UTC unless you set `TZ` in `.env`. An empty/all-commented `crontab` is fine —
125+
supercronic simply schedules nothing.
126+
102127
## Verify the profiles differ
103128

104129
- **Restricted**: ask it to `rm -rf` everything in `/workspace` → denied, never runs.

docker/cron-entrypoint.sh

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/bin/sh
2+
# cron-entrypoint.sh — container entrypoint for the odek image.
3+
#
4+
# If a crontab is mounted, start supercronic in the background, then hand the
5+
# container over to the real odek command (serve / telegram / run / …) via
6+
# `exec` so odek remains the main process: signals, graceful restart, and the
7+
# Telegram singleton lock all behave exactly as they did before this wrapper.
8+
#
9+
# supercronic inherits THIS process's environment and passes it to every cron
10+
# job, so a scheduled `odek run --deliver` sees the same env_file vars
11+
# (ODEK_API_KEY, ODEK_TELEGRAM_BOT_TOKEN, ODEK_TELEGRAM_DEFAULT_CHAT_ID, …)
12+
# that the bot does. That is the whole reason for using supercronic over the
13+
# classic crond, which scrubs the environment from its jobs.
14+
set -eu
15+
16+
# Path to the crontab. Overridable so an operator can relocate the mount.
17+
CRONTAB="${ODEK_CRONTAB:-/home/odek/.crontabs/crontab}"
18+
19+
# Graceful-restart support: odek's /restart command re-execs via this script
20+
# (see ODEK_ENTRYPOINT below). Kill any supercronic from the previous run so we
21+
# never end up with two instances scheduling the same crontab concurrently.
22+
if [ -n "${ODEK_SUPERCRONIC_PID:-}" ]; then
23+
kill "$ODEK_SUPERCRONIC_PID" 2>/dev/null || true
24+
unset ODEK_SUPERCRONIC_PID
25+
fi
26+
27+
if [ -d "$CRONTAB" ]; then
28+
# Docker creates a directory when the bind-mount source doesn't exist on the
29+
# host. This is almost always a misconfiguration — warn loudly rather than
30+
# silently skipping so the operator knows why reminders aren't firing.
31+
echo "cron-entrypoint: WARNING: $CRONTAB is a directory, not a file" >&2
32+
echo "cron-entrypoint: Docker created it because the host path was missing." >&2
33+
echo "cron-entrypoint: Fix: remove the directory on the host and create the file." >&2
34+
echo "cron-entrypoint: Skipping supercronic — cron jobs will NOT run." >&2
35+
elif [ -f "$CRONTAB" ]; then
36+
echo "cron-entrypoint: starting supercronic for $CRONTAB" >&2
37+
# -passthrough-logs keeps each job's own stdout/stderr intact in the
38+
# container log alongside supercronic's scheduling lines.
39+
supercronic -passthrough-logs "$CRONTAB" &
40+
ODEK_SUPERCRONIC_PID=$!
41+
export ODEK_SUPERCRONIC_PID
42+
# Brief liveness check: supercronic parses the crontab at startup and exits
43+
# immediately on a syntax error or missing binary. Neither is recoverable at
44+
# runtime, so catching it here produces a clear warning rather than silent
45+
# non-delivery. set -e does not cover backgrounded processes, so we check
46+
# explicitly after a short window.
47+
sleep 1
48+
if ! kill -0 "$ODEK_SUPERCRONIC_PID" 2>/dev/null; then
49+
echo "cron-entrypoint: WARNING: supercronic exited immediately — cron jobs will NOT run" >&2
50+
unset ODEK_SUPERCRONIC_PID
51+
fi
52+
else
53+
echo "cron-entrypoint: no crontab at $CRONTAB — skipping supercronic" >&2
54+
fi
55+
56+
# Advertise this script's own path so spawnChild (odek's /restart handler) can
57+
# re-exec through the wrapper instead of the bare binary. Without this, a
58+
# graceful restart would skip supercronic entirely.
59+
export ODEK_ENTRYPOINT="$0"
60+
61+
# Default to printing usage if no command was provided (matches the previous
62+
# `ENTRYPOINT ["odek"]` behaviour for a bare `docker run`).
63+
exec odek "$@"

docker/crontab

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# odek reminders — supercronic crontab (standard 5-field cron syntax).
2+
#
3+
# This file is bind-mounted read-only into the container at
4+
# /home/odek/.crontabs/crontab (see docker-compose.yml). When present, the
5+
# entrypoint starts supercronic, which runs each job below on schedule.
6+
#
7+
# Each reminder is just `odek run --deliver "<task>"`. --deliver sends the
8+
# agent's final response to the Telegram chat in ODEK_TELEGRAM_DEFAULT_CHAT_ID
9+
# (set in .env). supercronic passes the container environment to every job, so
10+
# ODEK_API_KEY and the bot token are available here with no extra wiring.
11+
#
12+
# ┌ minute (0-59)
13+
# │ ┌ hour (0-23)
14+
# │ │ ┌ day-of-month (1-31)
15+
# │ │ │ ┌ month (1-12)
16+
# │ │ │ │ ┌ day-of-week (0-6, Sun=0)
17+
# │ │ │ │ │
18+
# * * * * * command
19+
#
20+
# Times are UTC unless you set TZ in .env. Use the absolute binary path.
21+
#
22+
# Uncomment / edit the examples below to enable reminders:
23+
24+
# Weekdays at 09:00 — stand-up nudge:
25+
# 0 9 * * 1-5 /usr/local/bin/odek run --deliver "Reminder: daily stand-up starts in 15 minutes."
26+
27+
# Every day at 18:30 — end-of-day wrap-up prompt:
28+
# 30 18 * * * /usr/local/bin/odek run --deliver "End of day: summarize what I shipped and what's open for tomorrow."

docker/docker-compose.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,20 @@ services:
6262
image: odek:local
6363
env_file: .env
6464
command: ["telegram"]
65+
# init: true adds Docker's built-in init (tini) as PID 1. This gives us:
66+
# - Zombie reaping: supercronic child processes are reaped when they exit.
67+
# - Signal forwarding: SIGTERM from `docker stop` reaches all children,
68+
# giving in-flight cron jobs a clean shutdown window.
69+
# - Graceful restart safety: when odek exits during /restart, the spawned
70+
# child is reparented to the init rather than dying with odek.
71+
init: true
6572
volumes:
6673
- ./workspace:/workspace
6774
- ./.odek:/home/odek/.odek
6875
- ./config.restricted.json:/home/odek/.odek/config.json:ro
76+
# Scheduled reminders: supercronic runs the jobs in ./crontab and
77+
# delivers results to ODEK_TELEGRAM_DEFAULT_CHAT_ID via `--deliver`.
78+
- ./crontab:/home/odek/.crontabs/crontab:ro
6979
restart: unless-stopped
7080

7181
# ── Telegram bot — Godmode (no prompts; unrestricted) ──
@@ -79,8 +89,12 @@ services:
7989
image: odek:local
8090
env_file: .env
8191
command: ["telegram"]
92+
init: true # zombie reaping + SIGTERM forwarding (see telegram-restricted)
8293
volumes:
8394
- ./workspace:/workspace
8495
- ./.odek:/home/odek/.odek
8596
- ./config.godmode.json:/home/odek/.odek/config.json:ro
97+
# Scheduled reminders: supercronic runs the jobs in ./crontab and
98+
# delivers results to ODEK_TELEGRAM_DEFAULT_CHAT_ID via `--deliver`.
99+
- ./crontab:/home/odek/.crontabs/crontab:ro
86100
restart: unless-stopped

internal/telegram/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ func ConfigFromEnv(base TelegramConfig) TelegramConfig {
9797
if v := os.Getenv("ODEK_TELEGRAM_LOG_FILE"); v != "" {
9898
cfg.LogFile = v
9999
}
100+
if v := os.Getenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID"); v != "" {
101+
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
102+
cfg.DefaultChatID = id
103+
}
104+
}
100105

101106
return cfg
102107
}

internal/telegram/config_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,41 @@ func TestConfigFromEnv_pollTimeoutEmpty(t *testing.T) {
205205
}
206206
}
207207

208+
func TestConfigFromEnv_defaultChatID(t *testing.T) {
209+
t.Setenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID", "8592463065")
210+
cfg := ConfigFromEnv(DefaultConfig())
211+
if cfg.DefaultChatID != 8592463065 {
212+
t.Errorf("DefaultChatID = %d, want 8592463065", cfg.DefaultChatID)
213+
}
214+
}
215+
216+
func TestConfigFromEnv_defaultChatIDNegative(t *testing.T) {
217+
// Group/channel chat IDs are negative; ParseInt must accept them.
218+
t.Setenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID", "-1001234567890")
219+
cfg := ConfigFromEnv(DefaultConfig())
220+
if cfg.DefaultChatID != -1001234567890 {
221+
t.Errorf("DefaultChatID = %d, want -1001234567890", cfg.DefaultChatID)
222+
}
223+
}
224+
225+
func TestConfigFromEnv_defaultChatIDInvalidKeepsBase(t *testing.T) {
226+
t.Setenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID", "not-a-number")
227+
base := DefaultConfig()
228+
base.DefaultChatID = 42 // a non-zero base must survive an unparseable env value
229+
cfg := ConfigFromEnv(base)
230+
if cfg.DefaultChatID != 42 {
231+
t.Errorf("DefaultChatID = %d, want base 42 preserved", cfg.DefaultChatID)
232+
}
233+
}
234+
235+
func TestConfigFromEnv_defaultChatIDEmpty(t *testing.T) {
236+
t.Setenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID", "")
237+
cfg := ConfigFromEnv(DefaultConfig())
238+
if cfg.DefaultChatID != 0 {
239+
t.Errorf("DefaultChatID = %d, want default 0", cfg.DefaultChatID)
240+
}
241+
}
242+
208243
func TestConfigFromEnv_maxMsgLength(t *testing.T) {
209244
t.Setenv("ODEK_TELEGRAM_MAX_MSG_LENGTH", "1024")
210245
cfg := ConfigFromEnv(DefaultConfig())

0 commit comments

Comments
 (0)