Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"syscall"
"time"

"github.com/TeoSlayer/pilotprotocol/internal/motd"
"github.com/TeoSlayer/pilotprotocol/pkg/daemon"
"github.com/pilot-protocol/common/config"
"github.com/pilot-protocol/common/driver"
Expand Down Expand Up @@ -95,12 +96,17 @@ func main() {
showVersion := flag.Bool("version", false, "print version and exit")
logLevel := flag.String("log-level", "info", "log level (debug, info, warn, error)")
logFormat := flag.String("log-format", "text", "log format (text, json)")
motdFeedURL := flag.String("motd-feed-url", motd.DefaultFeedURL, "message-of-the-day feed URL (empty to disable); overridden by $PILOT_MOTD_URL")
motdInterval := flag.Duration("motd-interval", 0, "message-of-the-day poll interval (default 15m)")
flag.Parse()
if *adminToken == "" {
if v := os.Getenv("PILOT_ADMIN_TOKEN"); v != "" {
*adminToken = v
}
}
if v := os.Getenv("PILOT_MOTD_URL"); v != "" {
*motdFeedURL = v
}

if *showVersion {
fmt.Println(version)
Expand Down Expand Up @@ -199,6 +205,8 @@ func main() {
TransportMode: *transportMode,
CompatBeaconURL: *compatBeacon,
CompatTLSTrust: *tlsTrust,
MOTDFeedURL: *motdFeedURL,
MOTDInterval: *motdInterval,
})

// L11 plugin lifecycle (T7.1): composition root owns the
Expand Down
28 changes: 24 additions & 4 deletions cmd/pilotctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ func featureEnabled(name string) bool {
func output(data interface{}) {
if jsonOutput {
envelope := map[string]interface{}{"status": "ok", "data": data}
if importantUpdate != "" {
envelope["important_update"] = importantUpdate
}
b, _ := json.Marshal(envelope)
fmt.Println(string(b))
} else {
Expand All @@ -128,12 +131,16 @@ func outputOK(fields map[string]interface{}) {
func fatalCode(code string, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
if jsonOutput {
b, _ := json.Marshal(map[string]string{
env := map[string]string{
"status": "error",
"code": code,
"message": msg,
"error": msg,
})
}
if importantUpdate != "" {
env["important_update"] = importantUpdate
}
b, _ := json.Marshal(env)
fmt.Fprintln(os.Stderr, string(b))
} else {
fmt.Fprintf(os.Stderr, "error: %s\n", msg)
Expand Down Expand Up @@ -171,13 +178,17 @@ func classifyDaemonError(err error) string {
func fatalHint(code, hint, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
if jsonOutput {
b, _ := json.Marshal(map[string]string{
env := map[string]string{
"status": "error",
"code": code,
"message": msg,
"error": msg,
"hint": hint,
})
}
if importantUpdate != "" {
env["important_update"] = importantUpdate
}
b, _ := json.Marshal(env)
fmt.Fprintln(os.Stderr, string(b))
} else {
fmt.Fprintf(os.Stderr, "error: %s\nhint: %s\n", msg, hint)
Expand Down Expand Up @@ -868,6 +879,8 @@ Flags:
--no-encrypt disable tunnel encryption
--foreground run in foreground (no fork; for systemd / shell wrappers)
--wait <duration> how long to wait for daemon to become ready (default: 15s)
--motd-feed-url <url> message-of-the-day feed (empty to disable; env PILOT_MOTD_URL)
--motd-interval <duration> message-of-the-day poll interval (default: 15m)
`,
"daemon stop": `Usage: pilotctl daemon stop

Expand Down Expand Up @@ -1268,6 +1281,7 @@ Companion binaries:

func main() {
loadFeatureFlags()
loadMOTD()

// Extract global flags before subcommand
var args []string
Expand All @@ -1282,6 +1296,12 @@ func main() {
}
}

// Prepend the message-of-the-day banner (if any) ahead of every
// command's output. No-op in --json mode, where the message instead
// rides in each envelope's important_update field. Pure local read of
// ~/.pilot/motd.json — no network, no daemon call.
printMOTDBanner()

if len(args) < 1 {
usage()
}
Expand Down
43 changes: 43 additions & 0 deletions cmd/pilotctl/motd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package main

import (
"fmt"
"path/filepath"
"time"

"github.com/TeoSlayer/pilotprotocol/internal/motd"
)

// importantUpdate is the message-of-the-day text active for the current UTC
// day, or "" if none. Loaded once at process start by loadMOTD() from the
// local mirror the daemon maintains — pilotctl never touches the network or
// the daemon for this, so every command stays fast.
var importantUpdate string

// motdMirrorPath is the local file the daemon writes and pilotctl reads. It
// lives in the same ~/.pilot directory pilotctl already uses for config.
func motdMirrorPath() string {
return filepath.Join(configDir(), "motd.json")
}

// loadMOTD reads the active message-of-the-day from the local mirror. Called
// once near the top of main(). Absent/empty/stale mirrors leave
// importantUpdate empty (no banner).
func loadMOTD() {
if text, ok := motd.ReadActiveMirror(motdMirrorPath(), time.Now()); ok {
importantUpdate = text
}
}

// printMOTDBanner writes the human-readable banner to stdout ahead of a
// command's normal output. It is a no-op in JSON mode (there the message
// rides in the envelope's important_update field instead) and when there is
// no active message.
func printMOTDBanner() {
if jsonOutput || importantUpdate == "" {
return
}
fmt.Printf("Message of the day: %s\n\n", importantUpdate)
}
103 changes: 103 additions & 0 deletions cmd/pilotctl/zz_motd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package main

import (
"encoding/json"
"strings"
"testing"
"time"

"github.com/TeoSlayer/pilotprotocol/internal/motd"
)

// withMOTD saves and restores the package-global banner/json state so these
// tests don't leak into the rest of the (non-parallel) package suite.
func withMOTD(t *testing.T, msg string, asJSON bool, fn func()) {
t.Helper()
origMsg, origJSON := importantUpdate, jsonOutput
importantUpdate, jsonOutput = msg, asJSON
defer func() { importantUpdate, jsonOutput = origMsg, origJSON }()
fn()
}

func TestLoadMOTDFromMirror(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

// configDir() resolves to $HOME/.pilot — write the mirror the daemon
// would have produced for today, then confirm loadMOTD picks it up.
now := time.Now()
if err := motd.WriteMirror(motdMirrorPath(), motd.Message{Date: motd.DayKey(now), Text: "overlay maintenance 22:00 UTC"}, now); err != nil {
t.Fatalf("seed mirror: %v", err)
}

importantUpdate = ""
loadMOTD()
if importantUpdate != "overlay maintenance 22:00 UTC" {
t.Fatalf("importantUpdate = %q, want the seeded message", importantUpdate)
}

// Clear it (empty motd) — loadMOTD must yield no banner.
if err := motd.WriteMirror(motdMirrorPath(), motd.Message{}, now); err != nil {
t.Fatalf("clear mirror: %v", err)
}
importantUpdate = ""
loadMOTD()
if importantUpdate != "" {
t.Fatalf("importantUpdate = %q, want empty after clear", importantUpdate)
}
}

func TestPrintMOTDBannerTextMode(t *testing.T) {
withMOTD(t, "scheduled maintenance", false, func() {
out := captureStdout(t, printMOTDBanner)
if !strings.Contains(out, "Message of the day: scheduled maintenance") {
t.Fatalf("banner missing from output: %q", out)
}
})
}

func TestPrintMOTDBannerSuppressedInJSON(t *testing.T) {
withMOTD(t, "scheduled maintenance", true, func() {
out := captureStdout(t, printMOTDBanner)
if out != "" {
t.Fatalf("expected no text banner in JSON mode, got %q", out)
}
})
}

func TestPrintMOTDBannerEmpty(t *testing.T) {
withMOTD(t, "", false, func() {
out := captureStdout(t, printMOTDBanner)
if out != "" {
t.Fatalf("expected no banner with no message, got %q", out)
}
})
}

func TestOutputJSONCarriesImportantUpdate(t *testing.T) {
withMOTD(t, "read this first", true, func() {
out := captureStdout(t, func() { output(map[string]interface{}{"ok": true}) })
var env map[string]interface{}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("unmarshal %q: %v", out, err)
}
if env["important_update"] != "read this first" {
t.Fatalf("important_update = %v, want the message", env["important_update"])
}
})
}

func TestOutputJSONOmitsImportantUpdateWhenEmpty(t *testing.T) {
withMOTD(t, "", true, func() {
out := captureStdout(t, func() { output(map[string]interface{}{"ok": true}) })
var env map[string]interface{}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("unmarshal %q: %v", out, err)
}
if _, present := env["important_update"]; present {
t.Fatalf("important_update should be absent when there is no message: %q", out)
}
})
}
84 changes: 84 additions & 0 deletions docs/motd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Message of the day (MOTD)

The message-of-the-day mechanism shows a short, centrally-managed banner
ahead of **every** `pilotctl` command, for one UTC calendar day at a time.
It is used for network-wide notices: maintenance windows, incident updates,
breaking-change heads-ups.

```
$ pilotctl info
Message of the day: overlay maintenance 22:00 UTC — expect ~5min blips

<normal pilotctl info output>
```

When no message is active for the current UTC day, output is unchanged.
Messages are managed centrally by the Pilot Protocol team; there is nothing
to configure on a client to receive them.

## Design

Two rules drive the design:

1. **`pilotctl` must stay fast and never call the network or the daemon just
to render the banner.**
2. **The daemon must not make an on-demand call when a command runs.**

So the work is split:

- The **daemon** is the only component that touches the network. A background
loop (`motdPollLoop`) fetches the feed every `--motd-interval` (default
15m), selects the entry dated for the current UTC day, holds it in memory,
and **mirrors** it to `~/.pilot/motd.json`.
- **`pilotctl`** reads only that local mirror — one file read — and
re-validates the UTC day on read, so a stale mirror (e.g. the daemon was
offline across midnight) never shows yesterday's message.

```
central feed ──poll──► pilot-daemon ──mirror──► ~/.pilot/motd.json
(managed by the (only net I/O) (the local variable)
Pilot team) │ read (no net, no IPC)
pilotctl <any command> ──► prepends banner
```

No new binary ships: the poll is a goroutine inside `pilot-daemon`, modelled
on the existing skill-reconciler loop.

## Output behaviour

- **Text mode:** the banner is prepended to stdout as
`Message of the day: <text>` followed by a blank line, then the normal
command output.
- **`--json` mode:** no text is prepended (it would break parsing). Instead
the standard envelope carries a top-level `important_update` field:

```json
{ "status": "ok", "data": { ... }, "important_update": "overlay maintenance 22:00 UTC" }
```

The same field is added to error envelopes. The daemon also surfaces the
current value as `motd` in `pilotctl info`.

## Configuration

`pilot-daemon` flags (also settable via `pilotctl daemon start`):

| Flag | Default | Meaning |
|------|---------|---------|
| `--motd-feed-url <url>` | the central feed URL | feed location; **empty disables** polling entirely |
| `--motd-interval <dur>` | `15m` | how often to re-fetch |
| `$PILOT_MOTD_URL` | — | env override for the feed URL |

The mirror lives next to the daemon identity (normally `~/.pilot/motd.json`),
which is where `pilotctl` looks.

## Semantics

- **UTC days.** A message is active only on its UTC calendar day. `pilotctl`
re-checks the day on read, so a message never lingers past its UTC day.
- **Self-clearing.** When the active message is withdrawn, the mirror is
cleared within one poll interval and the banner disappears on its own.
- **Fail-safe.** Non-2xx responses and parse errors are non-fatal: the daemon
keeps its last good mirror and logs at debug level. An unknown
`schema_version` is rejected rather than mis-parsed.
Loading
Loading