Skip to content

Commit e1a4f49

Browse files
AlexgodorojaAlex Godoroja
andauthored
feat(motd): message-of-the-day banner on every pilotctl command (#253)
* feat(motd): message-of-the-day banner on every pilotctl command Add an always-on "message of the day" notice that the pilot team can publish for a single UTC day at a time. It appears ahead of every pilotctl command's output, and disappears on its own when cleared. Design keeps both the CLI and the daemon off the hot path: - The daemon is the only component that touches the network. A new background loop (motdPollLoop) fetches the feed on --motd-interval (default 15m), selects the entry dated for the current UTC day, holds it in d.motd, and mirrors it to ~/.pilot/motd.json. Modeled on the existing skill-reconciler loop — no new binary ships. - pilotctl reads only that local mirror (one file read, no network, no IPC) and re-validates the UTC day on read, so a stale mirror never shows yesterday's message. Output: - text mode prepends "Message of the day: <text>" - --json mode carries it as a top-level important_update envelope field (text would break parsing); also surfaced as motd in info. Clearing is first-class: an empty feed, a removed entry, or a blank text all resolve to a cleared mirror within one poll interval. New internal/motd package (pure-stdlib leaf): Fetch, Parse, SelectForToday, WriteMirror, ReadActiveMirror, with unit tests for UTC-day selection, stale-mirror rejection, and clearing. Daemon flags --motd-feed-url / --motd-interval and PILOT_MOTD_URL env override. Feed source: https://github.com/pilot-protocol/pilot-motd (motd.json). * docs(motd): drop publishing how-to from design doc Publishing is gated to maintainers with push access to the feed. The doc now describes only the feature, design, output, configuration, and semantics — not how to post or clear a message. --------- Co-authored-by: Alex Godoroja <alex@vulturelabs.io>
1 parent bfe577a commit e1a4f49

10 files changed

Lines changed: 797 additions & 4 deletions

File tree

cmd/daemon/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"syscall"
1717
"time"
1818

19+
"github.com/TeoSlayer/pilotprotocol/internal/motd"
1920
"github.com/TeoSlayer/pilotprotocol/pkg/daemon"
2021
"github.com/pilot-protocol/common/config"
2122
"github.com/pilot-protocol/common/driver"
@@ -95,12 +96,17 @@ func main() {
9596
showVersion := flag.Bool("version", false, "print version and exit")
9697
logLevel := flag.String("log-level", "info", "log level (debug, info, warn, error)")
9798
logFormat := flag.String("log-format", "text", "log format (text, json)")
99+
motdFeedURL := flag.String("motd-feed-url", motd.DefaultFeedURL, "message-of-the-day feed URL (empty to disable); overridden by $PILOT_MOTD_URL")
100+
motdInterval := flag.Duration("motd-interval", 0, "message-of-the-day poll interval (default 15m)")
98101
flag.Parse()
99102
if *adminToken == "" {
100103
if v := os.Getenv("PILOT_ADMIN_TOKEN"); v != "" {
101104
*adminToken = v
102105
}
103106
}
107+
if v := os.Getenv("PILOT_MOTD_URL"); v != "" {
108+
*motdFeedURL = v
109+
}
104110

105111
if *showVersion {
106112
fmt.Println(version)
@@ -199,6 +205,8 @@ func main() {
199205
TransportMode: *transportMode,
200206
CompatBeaconURL: *compatBeacon,
201207
CompatTLSTrust: *tlsTrust,
208+
MOTDFeedURL: *motdFeedURL,
209+
MOTDInterval: *motdInterval,
202210
})
203211

204212
// L11 plugin lifecycle (T7.1): composition root owns the

cmd/pilotctl/main.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ func featureEnabled(name string) bool {
105105
func output(data interface{}) {
106106
if jsonOutput {
107107
envelope := map[string]interface{}{"status": "ok", "data": data}
108+
if importantUpdate != "" {
109+
envelope["important_update"] = importantUpdate
110+
}
108111
b, _ := json.Marshal(envelope)
109112
fmt.Println(string(b))
110113
} else {
@@ -128,12 +131,16 @@ func outputOK(fields map[string]interface{}) {
128131
func fatalCode(code string, format string, args ...interface{}) {
129132
msg := fmt.Sprintf(format, args...)
130133
if jsonOutput {
131-
b, _ := json.Marshal(map[string]string{
134+
env := map[string]string{
132135
"status": "error",
133136
"code": code,
134137
"message": msg,
135138
"error": msg,
136-
})
139+
}
140+
if importantUpdate != "" {
141+
env["important_update"] = importantUpdate
142+
}
143+
b, _ := json.Marshal(env)
137144
fmt.Fprintln(os.Stderr, string(b))
138145
} else {
139146
fmt.Fprintf(os.Stderr, "error: %s\n", msg)
@@ -171,13 +178,17 @@ func classifyDaemonError(err error) string {
171178
func fatalHint(code, hint, format string, args ...interface{}) {
172179
msg := fmt.Sprintf(format, args...)
173180
if jsonOutput {
174-
b, _ := json.Marshal(map[string]string{
181+
env := map[string]string{
175182
"status": "error",
176183
"code": code,
177184
"message": msg,
178185
"error": msg,
179186
"hint": hint,
180-
})
187+
}
188+
if importantUpdate != "" {
189+
env["important_update"] = importantUpdate
190+
}
191+
b, _ := json.Marshal(env)
181192
fmt.Fprintln(os.Stderr, string(b))
182193
} else {
183194
fmt.Fprintf(os.Stderr, "error: %s\nhint: %s\n", msg, hint)
@@ -868,6 +879,8 @@ Flags:
868879
--no-encrypt disable tunnel encryption
869880
--foreground run in foreground (no fork; for systemd / shell wrappers)
870881
--wait <duration> how long to wait for daemon to become ready (default: 15s)
882+
--motd-feed-url <url> message-of-the-day feed (empty to disable; env PILOT_MOTD_URL)
883+
--motd-interval <duration> message-of-the-day poll interval (default: 15m)
871884
`,
872885
"daemon stop": `Usage: pilotctl daemon stop
873886
@@ -1268,6 +1281,7 @@ Companion binaries:
12681281

12691282
func main() {
12701283
loadFeatureFlags()
1284+
loadMOTD()
12711285

12721286
// Extract global flags before subcommand
12731287
var args []string
@@ -1282,6 +1296,12 @@ func main() {
12821296
}
12831297
}
12841298

1299+
// Prepend the message-of-the-day banner (if any) ahead of every
1300+
// command's output. No-op in --json mode, where the message instead
1301+
// rides in each envelope's important_update field. Pure local read of
1302+
// ~/.pilot/motd.json — no network, no daemon call.
1303+
printMOTDBanner()
1304+
12851305
if len(args) < 1 {
12861306
usage()
12871307
}

cmd/pilotctl/motd.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package main
4+
5+
import (
6+
"fmt"
7+
"path/filepath"
8+
"time"
9+
10+
"github.com/TeoSlayer/pilotprotocol/internal/motd"
11+
)
12+
13+
// importantUpdate is the message-of-the-day text active for the current UTC
14+
// day, or "" if none. Loaded once at process start by loadMOTD() from the
15+
// local mirror the daemon maintains — pilotctl never touches the network or
16+
// the daemon for this, so every command stays fast.
17+
var importantUpdate string
18+
19+
// motdMirrorPath is the local file the daemon writes and pilotctl reads. It
20+
// lives in the same ~/.pilot directory pilotctl already uses for config.
21+
func motdMirrorPath() string {
22+
return filepath.Join(configDir(), "motd.json")
23+
}
24+
25+
// loadMOTD reads the active message-of-the-day from the local mirror. Called
26+
// once near the top of main(). Absent/empty/stale mirrors leave
27+
// importantUpdate empty (no banner).
28+
func loadMOTD() {
29+
if text, ok := motd.ReadActiveMirror(motdMirrorPath(), time.Now()); ok {
30+
importantUpdate = text
31+
}
32+
}
33+
34+
// printMOTDBanner writes the human-readable banner to stdout ahead of a
35+
// command's normal output. It is a no-op in JSON mode (there the message
36+
// rides in the envelope's important_update field instead) and when there is
37+
// no active message.
38+
func printMOTDBanner() {
39+
if jsonOutput || importantUpdate == "" {
40+
return
41+
}
42+
fmt.Printf("Message of the day: %s\n\n", importantUpdate)
43+
}

cmd/pilotctl/zz_motd_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package main
4+
5+
import (
6+
"encoding/json"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/TeoSlayer/pilotprotocol/internal/motd"
12+
)
13+
14+
// withMOTD saves and restores the package-global banner/json state so these
15+
// tests don't leak into the rest of the (non-parallel) package suite.
16+
func withMOTD(t *testing.T, msg string, asJSON bool, fn func()) {
17+
t.Helper()
18+
origMsg, origJSON := importantUpdate, jsonOutput
19+
importantUpdate, jsonOutput = msg, asJSON
20+
defer func() { importantUpdate, jsonOutput = origMsg, origJSON }()
21+
fn()
22+
}
23+
24+
func TestLoadMOTDFromMirror(t *testing.T) {
25+
home := t.TempDir()
26+
t.Setenv("HOME", home)
27+
28+
// configDir() resolves to $HOME/.pilot — write the mirror the daemon
29+
// would have produced for today, then confirm loadMOTD picks it up.
30+
now := time.Now()
31+
if err := motd.WriteMirror(motdMirrorPath(), motd.Message{Date: motd.DayKey(now), Text: "overlay maintenance 22:00 UTC"}, now); err != nil {
32+
t.Fatalf("seed mirror: %v", err)
33+
}
34+
35+
importantUpdate = ""
36+
loadMOTD()
37+
if importantUpdate != "overlay maintenance 22:00 UTC" {
38+
t.Fatalf("importantUpdate = %q, want the seeded message", importantUpdate)
39+
}
40+
41+
// Clear it (empty motd) — loadMOTD must yield no banner.
42+
if err := motd.WriteMirror(motdMirrorPath(), motd.Message{}, now); err != nil {
43+
t.Fatalf("clear mirror: %v", err)
44+
}
45+
importantUpdate = ""
46+
loadMOTD()
47+
if importantUpdate != "" {
48+
t.Fatalf("importantUpdate = %q, want empty after clear", importantUpdate)
49+
}
50+
}
51+
52+
func TestPrintMOTDBannerTextMode(t *testing.T) {
53+
withMOTD(t, "scheduled maintenance", false, func() {
54+
out := captureStdout(t, printMOTDBanner)
55+
if !strings.Contains(out, "Message of the day: scheduled maintenance") {
56+
t.Fatalf("banner missing from output: %q", out)
57+
}
58+
})
59+
}
60+
61+
func TestPrintMOTDBannerSuppressedInJSON(t *testing.T) {
62+
withMOTD(t, "scheduled maintenance", true, func() {
63+
out := captureStdout(t, printMOTDBanner)
64+
if out != "" {
65+
t.Fatalf("expected no text banner in JSON mode, got %q", out)
66+
}
67+
})
68+
}
69+
70+
func TestPrintMOTDBannerEmpty(t *testing.T) {
71+
withMOTD(t, "", false, func() {
72+
out := captureStdout(t, printMOTDBanner)
73+
if out != "" {
74+
t.Fatalf("expected no banner with no message, got %q", out)
75+
}
76+
})
77+
}
78+
79+
func TestOutputJSONCarriesImportantUpdate(t *testing.T) {
80+
withMOTD(t, "read this first", true, func() {
81+
out := captureStdout(t, func() { output(map[string]interface{}{"ok": true}) })
82+
var env map[string]interface{}
83+
if err := json.Unmarshal([]byte(out), &env); err != nil {
84+
t.Fatalf("unmarshal %q: %v", out, err)
85+
}
86+
if env["important_update"] != "read this first" {
87+
t.Fatalf("important_update = %v, want the message", env["important_update"])
88+
}
89+
})
90+
}
91+
92+
func TestOutputJSONOmitsImportantUpdateWhenEmpty(t *testing.T) {
93+
withMOTD(t, "", true, func() {
94+
out := captureStdout(t, func() { output(map[string]interface{}{"ok": true}) })
95+
var env map[string]interface{}
96+
if err := json.Unmarshal([]byte(out), &env); err != nil {
97+
t.Fatalf("unmarshal %q: %v", out, err)
98+
}
99+
if _, present := env["important_update"]; present {
100+
t.Fatalf("important_update should be absent when there is no message: %q", out)
101+
}
102+
})
103+
}

docs/motd.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Message of the day (MOTD)
2+
3+
The message-of-the-day mechanism shows a short, centrally-managed banner
4+
ahead of **every** `pilotctl` command, for one UTC calendar day at a time.
5+
It is used for network-wide notices: maintenance windows, incident updates,
6+
breaking-change heads-ups.
7+
8+
```
9+
$ pilotctl info
10+
Message of the day: overlay maintenance 22:00 UTC — expect ~5min blips
11+
12+
<normal pilotctl info output>
13+
```
14+
15+
When no message is active for the current UTC day, output is unchanged.
16+
Messages are managed centrally by the Pilot Protocol team; there is nothing
17+
to configure on a client to receive them.
18+
19+
## Design
20+
21+
Two rules drive the design:
22+
23+
1. **`pilotctl` must stay fast and never call the network or the daemon just
24+
to render the banner.**
25+
2. **The daemon must not make an on-demand call when a command runs.**
26+
27+
So the work is split:
28+
29+
- The **daemon** is the only component that touches the network. A background
30+
loop (`motdPollLoop`) fetches the feed every `--motd-interval` (default
31+
15m), selects the entry dated for the current UTC day, holds it in memory,
32+
and **mirrors** it to `~/.pilot/motd.json`.
33+
- **`pilotctl`** reads only that local mirror — one file read — and
34+
re-validates the UTC day on read, so a stale mirror (e.g. the daemon was
35+
offline across midnight) never shows yesterday's message.
36+
37+
```
38+
central feed ──poll──► pilot-daemon ──mirror──► ~/.pilot/motd.json
39+
(managed by the (only net I/O) (the local variable)
40+
Pilot team) │ read (no net, no IPC)
41+
42+
pilotctl <any command> ──► prepends banner
43+
```
44+
45+
No new binary ships: the poll is a goroutine inside `pilot-daemon`, modelled
46+
on the existing skill-reconciler loop.
47+
48+
## Output behaviour
49+
50+
- **Text mode:** the banner is prepended to stdout as
51+
`Message of the day: <text>` followed by a blank line, then the normal
52+
command output.
53+
- **`--json` mode:** no text is prepended (it would break parsing). Instead
54+
the standard envelope carries a top-level `important_update` field:
55+
56+
```json
57+
{ "status": "ok", "data": { ... }, "important_update": "overlay maintenance 22:00 UTC" }
58+
```
59+
60+
The same field is added to error envelopes. The daemon also surfaces the
61+
current value as `motd` in `pilotctl info`.
62+
63+
## Configuration
64+
65+
`pilot-daemon` flags (also settable via `pilotctl daemon start`):
66+
67+
| Flag | Default | Meaning |
68+
|------|---------|---------|
69+
| `--motd-feed-url <url>` | the central feed URL | feed location; **empty disables** polling entirely |
70+
| `--motd-interval <dur>` | `15m` | how often to re-fetch |
71+
| `$PILOT_MOTD_URL` || env override for the feed URL |
72+
73+
The mirror lives next to the daemon identity (normally `~/.pilot/motd.json`),
74+
which is where `pilotctl` looks.
75+
76+
## Semantics
77+
78+
- **UTC days.** A message is active only on its UTC calendar day. `pilotctl`
79+
re-checks the day on read, so a message never lingers past its UTC day.
80+
- **Self-clearing.** When the active message is withdrawn, the mirror is
81+
cleared within one poll interval and the banner disappears on its own.
82+
- **Fail-safe.** Non-2xx responses and parse errors are non-fatal: the daemon
83+
keeps its last good mirror and logs at debug level. An unknown
84+
`schema_version` is rejected rather than mis-parsed.

0 commit comments

Comments
 (0)