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
50 changes: 50 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,53 @@ fmt: ## Run go fmt against code.
.PHONY: vet
vet: ## Run go vet against code.
go vet ./...

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] The addition of deploy-hub targets provides a robust and atomic deployment mechanism for ARM64 binaries to a remote hub. This is a significant improvement for the project's operational efficiency and maintainability.

# --- Hub deploy (RO rootfs: binaries live on /data) -----------------------
# Hub runs Dropbear. Older images lack /usr/libexec/sftp-server; -O uses
# legacy scp. Harmless on images that ship openssh sftp-server.
#
# make deploy-hub
# make deploy-hub HUB=192.168.0.10
#
# Installs linux/arm64 prod binaries into /data/opt/bigfred/bin (preferred
# over the image copies in /opt) and restarts microinit services.
HUB ?= 192.168.0.1
HUB_USER ?= root
HUB_SSH ?= $(HUB_USER)@$(HUB)
SCP ?= scp
SCP_OPTS ?= -O
SSH ?= ssh
HUB_BIN_DIR ?= /data/opt/bigfred/bin

.PHONY: hub-arm64 deploy-hub
hub-arm64: web-build
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags prod -ldflags="-s -w $(VERSION_LDFLAGS)" \
-o bin/loco-server-linux-arm64 ./pkgs/bigfred/server
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(VERSION_LDFLAGS)" \
-o bin/bigfred-remote-icmp-linux-arm64 ./pkgs/bigfred/remote-icmp
@ls -lh bin/loco-server-linux-arm64 bin/bigfred-remote-icmp-linux-arm64

# Upload next to the target and rename: writing in place fails with ETXTBSY
# once the hub is running the /data copy, and rename(2) swaps the inode
# atomically. dcc-bus runs `bigfred dcc-bus …` from the same binary, so those
# daemons keep the old inode until they are restarted too.
deploy-hub: hub-arm64
@test -f bin/loco-server-linux-arm64 || { echo "error: bin/loco-server-linux-arm64 missing" >&2; exit 1; }
@test -f bin/bigfred-remote-icmp-linux-arm64 || { echo "error: bin/bigfred-remote-icmp-linux-arm64 missing" >&2; exit 1; }
$(SSH) $(HUB_SSH) 'mkdir -p $(HUB_BIN_DIR)'
$(SCP) $(SCP_OPTS) bin/loco-server-linux-arm64 $(HUB_SSH):$(HUB_BIN_DIR)/.bigfred.new
$(SCP) $(SCP_OPTS) bin/bigfred-remote-icmp-linux-arm64 $(HUB_SSH):$(HUB_BIN_DIR)/.bigfred-remote-icmp.new
$(SSH) $(HUB_SSH) 'set -e; \
cd $(HUB_BIN_DIR); \
chmod 755 .bigfred.new .bigfred-remote-icmp.new; \
mv -f .bigfred.new bigfred; \
mv -f .bigfred-remote-icmp.new bigfred-remote-icmp; \
command -v setcap >/dev/null && setcap cap_net_raw+ep bigfred-remote-icmp || true; \
rc=0; \
microinit restart bigfred || rc=1; \
microinit restart remote-icmp || rc=1; \
for p in $$(microinit list 2>/dev/null | grep -o "^dcc-bus-[^ ]*" || true); do \
echo "restarting $$p"; \
microinit restart "$$p" || rc=1; \
done; \
exit $$rc'
8 changes: 6 additions & 2 deletions pkgs/bigfred/dcc-bus/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ func New(ctx context.Context, log *logrus.Logger, cfg Config) (*Daemon, error) {
log.Info("dcc-bus ws metrics enabled")
}

wsSrv := ws.NewServer(ws.ServerConfig{
wsCfg := ws.ServerConfig{
Verifier: verifier,
Hub: hub,
Router: ws.NewRouterAdapter(router),
Expand All @@ -386,7 +386,11 @@ func New(ctx context.Context, log *logrus.Logger, cfg Config) (*Daemon, error) {
AllowedOrigins: cfg.AllowedOrigins,
Verifier: verifier,
}),
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] Integrating the StationHealth interface into the ws.Server configuration is a necessary and positive change. It allows the /healthz endpoint to accurately reflect the Z21 command station's reachability, enhancing system observability.

if h, ok := st.(ws.StationHealth); ok {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] Adding the StationHealth interface to the ws.ServerConfig allows the /healthz endpoint to accurately reflect the command station's status. This is a crucial improvement for operational monitoring.

wsCfg.StationHealth = h
}
wsSrv := ws.NewServer(wsCfg)

srv := &http.Server{
Addr: net.JoinHostPort(cfg.BindAddr, strconv.Itoa(int(cfg.Port))),
Expand Down
55 changes: 39 additions & 16 deletions pkgs/bigfred/dcc-bus/ws/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ type Server struct {
// the daemon binds to loopback because the reverse proxy on
// loco-server already validates Origin).
AllowedOrigins []string

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] Defining the StationHealth interface provides a clean and extensible way for command station drivers to report their reachability status. This promotes good architectural design.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] The introduction of the StationHealth interface and its integration into the /healthz endpoint is a major improvement. This allows the health check to accurately report the command station's connectivity status, returning a 503 Service Unavailable with a clear station_unreachable code when the station is down. This significantly enhances the system's monitoring capabilities.

// stationHealth is optional; Z21 reports UDP reachability so
// /healthz can fail closed while the station is reconnecting.
stationHealth StationHealth
}

// StationHealth is implemented by command-station drivers that can
// report whether the physical bus is reachable (Z21 serial heartbeat).
type StationHealth interface {
Reachable() bool
}

// ServerConfig collects the few knobs Server takes at construction.
Expand All @@ -101,6 +111,8 @@ type ServerConfig struct {
// ProgrammingEnabled opens the loco.cvRead / cvWrite / addrGet /
// addrSet frames. Off by default.
ProgrammingEnabled bool
// StationHealth, when set, is consulted by /healthz.
StationHealth StationHealth
}

// NewServer returns a ready-to-mount Server. Heartbeat and dead-man
Expand All @@ -124,19 +136,20 @@ func NewServer(cfg ServerConfig) *Server {
log = logrus.New()
}
return &Server{
verifier: cfg.Verifier,
hub: cfg.Hub,
router: cfg.Router,
log: log,
layoutID: cfg.LayoutID,
csID: cfg.CommandStation,
speedSteps: steps,
heartbeatSecs: hb,
deadmanSecs: dms,
AllowedOrigins: cfg.AllowedOrigins,
metrics: cfg.Metrics,
slotsDiag: cfg.SlotsDiag,
verifier: cfg.Verifier,
hub: cfg.Hub,
router: cfg.Router,
log: log,
layoutID: cfg.LayoutID,
csID: cfg.CommandStation,
speedSteps: steps,
heartbeatSecs: hb,
deadmanSecs: dms,
AllowedOrigins: cfg.AllowedOrigins,
metrics: cfg.Metrics,
slotsDiag: cfg.SlotsDiag,
programmingEnabled: cfg.ProgrammingEnabled,
stationHealth: cfg.StationHealth,
}
}

Expand All @@ -160,13 +173,23 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}
case "/healthz":
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
s.handleHealthz(w)
default:
http.NotFound(w, r)
}
}

func (s *Server) handleHealthz(w http.ResponseWriter) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] The refactoring of the /healthz handler to check StationHealth is a direct and effective solution to the problem of the service reporting healthy when the station is down. The detailed JSON response for an unhealthy status is also very helpful for debugging.

w.Header().Set("Content-Type", "application/json")
if s.stationHealth != nil && !s.stationHealth.Reachable() {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"status":"unhealthy","code":"station_unreachable"}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}

// handleWS authenticates, upgrades, registers the session and runs
// the read loop until ctx ends or the client disconnects.
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -250,8 +273,8 @@ func (s *Server) readLoop(ctx context.Context, sess *Session) {
sess.Close(errors.WsCodeSessionReadLoopDone)
}
s.log.WithFields(logrus.Fields{
"sessionId": sess.ID,
"userId": sess.UserID,
"sessionId": sess.ID,
"userId": sess.UserID,
"userSessionsRemaining": len(s.hub.SessionsForUser(sess.UserID)),
}).Info("dcc-bus session closed")
// Give the browser time to reconnect before firing the dead-man's
Expand Down
49 changes: 49 additions & 0 deletions pkgs/bigfred/dcc-bus/ws/handler_healthz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package ws

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] Adding dedicated unit tests for the /healthz endpoint, covering cases with and without StationHealth and both reachable/unreachable states, ensures the new logic works as expected and prevents regressions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✨ [POSITIVE] Adding comprehensive unit tests for the /healthz endpoint, covering various StationHealth states, is excellent. This ensures the new health check logic functions correctly and prevents regressions.


import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)

type fakeStationHealth struct{ ok bool }

func (f fakeStationHealth) Reachable() bool { return f.ok }

func TestHealthzOKWhenNoStationHealth(t *testing.T) {
s := NewServer(ServerConfig{})
rec := httptest.NewRecorder()
s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}

func TestHealthzOKWhenStationReachable(t *testing.T) {
s := NewServer(ServerConfig{StationHealth: fakeStationHealth{ok: true}})
rec := httptest.NewRecorder()
s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}

func TestHealthzUnavailableWhenStationUnreachable(t *testing.T) {
s := NewServer(ServerConfig{StationHealth: fakeStationHealth{ok: false}})
rec := httptest.NewRecorder()
s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
var body struct {
Status string `json:"status"`
Code string `json:"code"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("json: %v", err)
}
if body.Status != "unhealthy" || body.Code != "station_unreachable" {
t.Fatalf("body = %+v", body)
}
}
Loading
Loading