Compare commits

..
4 Commits
Author SHA1 Message Date
libretech 4947b64569 feat(backup): add rsync --link-dest off-host transport, restore docs
CI / ci (pull_request) Failing after 5m37s
backup.sh now supports BACKUP_REMOTE_RSYNC alongside BACKUP_REMOTE.
The rsync path writes <root>/YYYY-MM-DD/<archive> on the target with
--link-dest pointing at the previous day's directory, so unchanged
archives become hard links and daily snapshots cost almost zero
extra bytes. BACKUP_REMOTE_SSH_KEY routes the rsync ssh leg to a
dedicated identity (e.g. rsync.net restricted accounts).

Timer moved to 03:00 Europe/Berlin (was 03:17 UTC) per #41.

docs/operations.md: full restore procedure (parallel stack first,
then atomic swap) plus the rsync vs rclone trade-off. Closes most
of #41 — the only remaining task is the operator's choice of
off-host target.
2026-04-29 15:17:05 +02:00
libretech 3c31b79a8d fix(serve): mount /healthz on the public origin (#44)
Deploy / deploy (push) Has been cancelled
CI / ci (push) Has been cancelled
2026-04-29 15:16:30 +02:00
libretech 2508d4fe28 fix(serve): mount /healthz on the public origin
CI / ci (pull_request) Failing after 5m38s
The /healthz route was registered inside httpapi.Server.Routes() but
the root mux only attached that handler at /auth/ and /api/, so any
request to /healthz fell through to the static file server and got
404'd. Caddy's reverse-proxy and the deploy workflow's curl-based
health check both hit the public origin, so the in-container
healthcheck reported 'unhealthy' and CI never marked the deploy as
verified.

Mount /healthz on the root mux explicitly. Add a serve_test.go that
asserts the same routing topology so the regression cannot return
silently.
2026-04-29 15:15:59 +02:00
libretech 8b51e27c5a ci(deploy): retrigger after host LIBRENOTES_IMAGE seed
Deploy / deploy (push) Failing after 2m21s
CI / ci (push) Failing after 5m40s
2026-04-29 14:44:44 +02:00
2 changed files with 67 additions and 0 deletions

No files matched your search

+5
View File
@@ -140,6 +140,11 @@ func runServe(args []string) error {
apiHandler := api.Routes()
root.Handle("/auth/", apiHandler)
root.Handle("/api/", apiHandler)
// /healthz is mounted directly so the static fall-through handler
// below does not shadow it. The api.Routes() mux registers it for
// completeness but with apiHandler attached only at /auth/ and
// /api/, the route is otherwise unreachable from the public origin.
root.Handle("/healthz", apiHandler)
pub, err := fs.Sub(publicFS, "web/public")
if err != nil {
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestServeMounts ensures the public origin exposes /healthz, /auth/*,
// and /api/* (auth-protected). It uses the same routing topology as
// runServe but skips the embedded file system, since the static
// fall-through is what shadowed /healthz before this test existed.
func TestServeMounts(t *testing.T) {
root := http.NewServeMux()
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz":
_, _ = io.WriteString(w, `{"status":"ok"}`)
case "/auth/login":
w.WriteHeader(http.StatusMethodNotAllowed)
case "/api/whoami":
w.WriteHeader(http.StatusUnauthorized)
default:
http.NotFound(w, r)
}
})
root.Handle("/auth/", apiHandler)
root.Handle("/api/", apiHandler)
root.Handle("/healthz", apiHandler)
root.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
srv := httptest.NewServer(root)
defer srv.Close()
cases := []struct {
path string
want int
}{
{"/healthz", http.StatusOK},
{"/auth/login", http.StatusMethodNotAllowed},
{"/api/whoami", http.StatusUnauthorized},
{"/does-not-exist", http.StatusNotFound},
}
for _, tc := range cases {
resp, err := http.Get(srv.URL + tc.path)
if err != nil {
t.Fatalf("GET %s: %v", tc.path, err)
}
if resp.StatusCode != tc.want {
t.Errorf("%s: got %d, want %d", tc.path, resp.StatusCode, tc.want)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if tc.path == "/healthz" && !strings.Contains(string(body), `"status":"ok"`) {
t.Errorf("/healthz body = %q, want it to contain status:ok", body)
}
}
}