diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 2240af2..89514d9 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test'; -// The tests' own ports, apart from mise run up's (8080 to 8085), so both can run at once. +// The tests' own ports, apart from mise run up's (8080 to 8088), so both can run at once. const gateway = '127.0.0.1:8180'; const web = '127.0.0.1:8182'; // Nothing listens here: the tests answer the app's API calls in the browser, so none should reach the services. @@ -37,7 +37,7 @@ export default defineConfig({ env: { BAKHYARD_LISTEN: `tcp://${gateway}`, BAKHYARD_PEERS: [ - ...['home', 'notes', 'notify', 'nudge', 'pulls', 'todos'].map((name) => `${name}=${nowhere}`), + ...['home', 'notes', 'notify', 'nudge', 'pulls', 'time', 'todos'].map((name) => `${name}=${nowhere}`), `web=tcp://${web}`, ].join(','), }, diff --git a/docs/plan.md b/docs/plan.md index d8cc7ba..898f73b 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -164,6 +164,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `jira-worklogs` | The shared Jira client reads my worklogs in a span of time: my account from `/myself`, the tickets I logged work on from a `worklogAuthor` search, then each ticket's worklogs, keeping mine that start in the span, the earliest first. The second of `time-tracking`'s eleven steps (decision 180) | Done (#148) | | `time-report` | A package of the new `time` service, `report`, works out the time report from my worklogs: the hours I logged on each of the last 7 days, the weekdays before today with nothing logged marked as missing, and this week's hours against its 40-hour target. The third of `time-tracking`'s twelve steps (decision 181) | Done (#149) | | `time-service` | A new `time` service polls Jira for my worklogs of the last 7 days as it starts and every 5 minutes after, and answers the time report at `GET /time/report`, with when the latest poll to succeed began and why the latest poll failed, if it did. It reads todos' Jira settings and token: without them it answers 404, and with only some it doesn't start. `mise run up` runs it on port 8088 and the Gitea workflow builds its image; the gateway reaches it with `time-gateway`. The fourth of `time-tracking`'s twelve steps (decision 182) | Done (#150) | +| `time-gateway` | The gateway forwards `/api/time` to the `time` service, behind both of its locks, so the app reaches my time report at `GET /api/time/report`. `time` is one of the gateway's peers, each of which `BAKHYARD_PEERS` must name, and `mise run up` starts the gateway once time is ready. The sixth of `time-tracking`'s twelve steps (decision 181) | Done (#151) | | `nudge-merge` | Nudge, the user's phone-questions tool for agents (`codedogapp/nudge`), moves into Bakhyard: one app, not two (decision 157). The nudge session does the work, one small PR per step, from its plan, [`bakhyard-merge.md`](https://github.com/codedogapp/nudge/blob/main/docs/bakhyard-merge.md); this session doesn't stop its own steps for it | In review | After these: the product phases (§8.2), each split into steps. @@ -373,7 +374,7 @@ flowchart LR | Service | Role | Listens on | Peers | Grows into (§8.2) | | --- | --- | --- | --- | --- | | nats | Event bus; JetStream on for later durable flows | `tcp://127.0.0.1:4222`, monitoring `:8222` | | reminders → notify | -| gateway | The only way in: `/api` to the services, everything else to web | `tcp://127.0.0.1:8080`; ops on an admin unix socket | web, home, notes, pulls, todos | auth-signin | +| gateway | The only way in: `/api` to the services, everything else to web | `tcp://127.0.0.1:8080`; ops on an admin unix socket | web, home, notes, notify, nudge, pulls, time, todos | auth-signin | | web | Serves the embedded Svelte build | unix socket | | frontend-shell (PWA parts) | | home | Builds the Home screen from every app | unix socket | nats, time, notes, todos, pulls, github, games, planner | home-feed | | notes | Obsidian vault | unix socket | nats | vault-index, vault-render, vault-edit-sync | @@ -549,7 +550,7 @@ Streaming handlers watch an app-lifetime context, so open streams end promptly. - Each service has a `description`, a `command` (`bin/`, run with `bash -c` from the repo root) and an `environment` with its `BAKHYARD_LISTEN` and, for the gateway, `BAKHYARD_PEERS` (§4.7). - A readiness probe checks each service's `GET /healthz` (§4.7), first 1 s after the start, then every 2 s. A check gets 1 s to answer, and 3 failures in a row fail the probe. -- The gateway `depends_on` home, notes, pulls, todos and web with `condition: process_healthy`, so it starts once their probes pass. +- The gateway `depends_on` home, notes, notify, nudge, pulls, time, todos and web with `condition: process_healthy`, so it starts once their probes pass. - What every service shares sits in an `x-service` YAML anchor, merged into each with `<<: *service`: - `availability.restart: always`: a service that exits, or fails its probe, restarts after 1 s, however often it needs to. A service stopped by hand stays stopped. - `shutdown.timeout_seconds: 10`: a stop sends SIGTERM to the service's process group, then SIGKILL if it's still running 10 s later. Without the timeout, process-compose never sends SIGKILL, and a hung service never stops. @@ -1199,8 +1200,8 @@ Every item is pending unless it says done. The service an item lands in is in br - `jira-worklogs`: the client reads my worklogs in a span of days: my account from `/myself`, the tickets I logged on from a `worklogAuthor` search, and each ticket's worklogs. Done (#148). - `time-report`: a package of the `time` service, `report`, works out the time report from my worklogs: the hours I logged on each of the last 7 days, with every weekday before today that has none marked as missing, plus this week's total against the target. Done (#149). - `time-service`: a new `time` service polls the last 7 days' worklogs every 5 minutes and answers the time report at `GET /time/report` (decision 182). Done (#150). - - `homelab-time`: in the homelab repo, `time` runs on the laptop with todos' Jira settings and token, and the gateway's and home's peers name it. - - `time-gateway`: the gateway forwards `/api/time` to `time`. + - `homelab-time`: done (codedogapp/homelab#42). In the homelab repo, `time` runs on the laptop with todos' Jira settings and token, and the gateway's and home's peers name it. + - `time-gateway`: the gateway forwards `/api/time` to `time`. Done (#151). - `time-page`: the Time page shows the time report. - `time-week`: `time` answers `TimeService/GetWeek` (§4.10). - `home-week`: home asks `time` for the week, and the Home's "This week" shows it, or "Hours unavailable" when `time` doesn't answer (§7). diff --git a/docs/setup.md b/docs/setup.md index 40888b2..768decb 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -26,7 +26,7 @@ cp mise.local.example.toml mise.local.toml | `BAKHYARD_BITBUCKET_WORKSPACES` | pulls | The slugs of the workspaces to list your open pull requests in, separated by commas | | `BAKHYARD_TEST_DATABASE` | Go tests | The test database's URL, password included ([below](#the-test-database)) | -The gateway doesn't start without its four settings, nor notes without its vault: each exits 1 and says what's missing. The gateway also waits for notes, notify, nudge, pulls and todos to start. Todos and time run without Jira when none of the three Jira settings is set, and pulls without Bitbucket when none of the three Bitbucket settings is set. +The gateway doesn't start without its four settings, nor notes without its vault: each exits 1 and says what's missing. The gateway also waits for notes, notify, nudge, pulls, time and todos to start. Todos and time run without Jira when none of the three Jira settings is set, and pulls without Bitbucket when none of the three Bitbucket settings is set. On the Mac, the gateway, todos, pulls and time read their secrets from the login Keychain, as the sections below show. On the laptop, which has no Keychain, `BAKHYARD_SECRETS` names a folder of files instead, each named as its secret's Keychain account (`google-client-secret`, `postgres-gateway`, `postgres-todos`, `postgres-notify`, `postgres-nudge`, `vapid-key`, `jira-token`, `bitbucket-token`) and holding the secret exactly, with no newline at the end: the files of the Kubernetes Secrets mounted there (decision 154). Leave it unset on the Mac. diff --git a/process-compose.yaml b/process-compose.yaml index 4364779..bb7e3d9 100644 --- a/process-compose.yaml +++ b/process-compose.yaml @@ -134,7 +134,7 @@ processes: command: bin/gateway environment: - BAKHYARD_LISTEN=tcp://127.0.0.1:8080 - - BAKHYARD_PEERS=home=tcp://127.0.0.1:8081,notes=tcp://127.0.0.1:8083,notify=tcp://127.0.0.1:8086,nudge=tcp://127.0.0.1:8087,pulls=tcp://127.0.0.1:8085,todos=tcp://127.0.0.1:8084,web=tcp://127.0.0.1:8082 + - BAKHYARD_PEERS=home=tcp://127.0.0.1:8081,notes=tcp://127.0.0.1:8083,notify=tcp://127.0.0.1:8086,nudge=tcp://127.0.0.1:8087,pulls=tcp://127.0.0.1:8085,time=tcp://127.0.0.1:8088,todos=tcp://127.0.0.1:8084,web=tcp://127.0.0.1:8082 - BAKHYARD_DATABASE=postgres://bakhyard_gateway@127.0.0.1:5433/bakhyard_gateway depends_on: home: @@ -147,6 +147,8 @@ processes: condition: process_healthy pulls: condition: process_healthy + time: + condition: process_healthy todos: condition: process_healthy web: diff --git a/services/gateway/headers_test.go b/services/gateway/headers_test.go index 0de20b8..aa5f618 100644 --- a/services/gateway/headers_test.go +++ b/services/gateway/headers_test.go @@ -33,7 +33,7 @@ func TestEveryResponseCarriesTheSecurityHeaders(t *testing.T) { vars["BAKHYARD_PEERS"] = "home=tcp://127.0.0.1:1" + ",notes=tcp://" + strings.TrimPrefix(notes.URL, "http://") + ",notify=tcp://127.0.0.1:1,nudge=tcp://127.0.0.1:1" + - ",pulls=tcp://127.0.0.1:1,todos=tcp://127.0.0.1:1" + + ",pulls=tcp://127.0.0.1:1,time=tcp://127.0.0.1:1,todos=tcp://127.0.0.1:1" + ",web=tcp://" + strings.TrimPrefix(web.URL, "http://") router, token := gatewayFrom(t, vars, items) diff --git a/services/gateway/lock_test.go b/services/gateway/lock_test.go index 783611a..af821cb 100644 --- a/services/gateway/lock_test.go +++ b/services/gateway/lock_test.go @@ -31,6 +31,7 @@ func TestTheGatewayNeedsASessionForTheAPIOnly(t *testing.T) { {http.MethodGet, "/api/notes", http.StatusUnauthorized}, {http.MethodGet, "/api/notes/files/Inbox.md", http.StatusUnauthorized}, {http.MethodGet, "/api/pulls/mine", http.StatusUnauthorized}, + {http.MethodGet, "/api/time/report", http.StatusUnauthorized}, {http.MethodGet, "/api/todos", http.StatusUnauthorized}, {http.MethodGet, "/api/nudge/state", http.StatusUnauthorized}, {http.MethodPost, "/api/notify/subscriptions", http.StatusUnauthorized}, @@ -69,11 +70,13 @@ func TestTheGatewayKeepsTheSessionFromTheServices(t *testing.T) { return srv } home, pulls, todos, web := service("home"), service("pulls"), service("todos"), service("web") + timeService := service("time") vars, items := gatewayEnv(t, web.URL) vars["BAKHYARD_PEERS"] = "home=tcp://" + strings.TrimPrefix(home.URL, "http://") + ",notes=tcp://127.0.0.1:1,notify=tcp://127.0.0.1:1,nudge=tcp://127.0.0.1:1" + ",pulls=tcp://" + strings.TrimPrefix(pulls.URL, "http://") + + ",time=tcp://" + strings.TrimPrefix(timeService.URL, "http://") + ",todos=tcp://" + strings.TrimPrefix(todos.URL, "http://") + ",web=tcp://" + strings.TrimPrefix(web.URL, "http://") router, token := gatewayFrom(t, vars, items) @@ -86,6 +89,7 @@ func TestTheGatewayKeepsTheSessionFromTheServices(t *testing.T) { }{ {"/api/home", "home", true}, {"/api/pulls/mine", "pulls", true}, + {"/api/time/report", "time", true}, {"/api/todos", "todos", true}, {"/notes", "web", false}, } { diff --git a/services/gateway/main.go b/services/gateway/main.go index 7f7c07f..a48efb1 100644 --- a/services/gateway/main.go +++ b/services/gateway/main.go @@ -111,21 +111,24 @@ func routes(getenv func(string) string, readSecret secrets.Reader) (http.Handler // upstreams are the URLs of the services that the gateway forwards to. type upstreams struct { - home, notes, notify, nudge, pulls, todos, web *url.URL + home, notes, notify, nudge, pulls, time, todos, web *url.URL } -// peers returns the URLs of home, notes, notify, nudge, pulls, todos and web from BAKHYARD_PEERS, or an error -// that names every one of them it lacks. +// peers returns the URLs of home, notes, notify, nudge, pulls, time, todos and web from +// BAKHYARD_PEERS, or an error that names every one of them it lacks. func peers(getenv func(string) string) (upstreams, error) { home, homeErr := peer(getenv, "home") notes, notesErr := peer(getenv, "notes") notify, notifyErr := peer(getenv, "notify") nudge, nudgeErr := peer(getenv, "nudge") pulls, pullsErr := peer(getenv, "pulls") + timeURL, timeErr := peer(getenv, "time") todos, todosErr := peer(getenv, "todos") web, webErr := peer(getenv, "web") - err := errors.Join(homeErr, notesErr, notifyErr, nudgeErr, pullsErr, todosErr, webErr) + err := errors.Join( + homeErr, notesErr, notifyErr, nudgeErr, pullsErr, timeErr, todosErr, webErr, + ) if err != nil { return upstreams{}, err } @@ -136,6 +139,7 @@ func peers(getenv func(string) string) (upstreams, error) { notify: notify, nudge: nudge, pulls: pulls, + time: timeURL, todos: todos, web: web, }, nil diff --git a/services/gateway/main_test.go b/services/gateway/main_test.go index f15e34d..8bb8dcf 100644 --- a/services/gateway/main_test.go +++ b/services/gateway/main_test.go @@ -217,7 +217,7 @@ func gatewayEnv(t *testing.T, webURL string) (vars, items map[string]string) { vars = map[string]string{ "BAKHYARD_PEERS": "home=tcp://127.0.0.1:1,notes=tcp://127.0.0.1:1,notify=tcp://127.0.0.1:1" + ",nudge=tcp://127.0.0.1:1,pulls=tcp://127.0.0.1:1" + - ",todos=tcp://127.0.0.1:1,web=tcp://" + strings.TrimPrefix( + ",time=tcp://127.0.0.1:1,todos=tcp://127.0.0.1:1,web=tcp://" + strings.TrimPrefix( webURL, "http://", ), diff --git a/services/gateway/open_test.go b/services/gateway/open_test.go index 3100175..4d35fb9 100644 --- a/services/gateway/open_test.go +++ b/services/gateway/open_test.go @@ -14,6 +14,7 @@ func TestTheOpenGatewayLetsEveryRequestThrough(t *testing.T) { {http.MethodGet, "/api/home", "home got GET /home"}, {http.MethodGet, "/api/notes/files/Inbox.md", "notes got GET /notes/files/Inbox.md"}, {http.MethodGet, "/api/pulls/mine", "pulls got GET /pulls/mine"}, + {http.MethodGet, "/api/time/report", "time got GET /time/report"}, {http.MethodPost, "/api/todos", "todos got POST /todos"}, {http.MethodGet, "/", "web got GET /"}, {http.MethodHead, "/todos", "web got HEAD /todos"}, @@ -89,7 +90,7 @@ func TestTheOpenGatewayKeepsEverythingButTheLocks(t *testing.T) { func TestTheOpenGatewayNeedsItsPeers(t *testing.T) { _, err := openRoutes(func(string) string { return "" }) - for _, name := range []string{"home", "notes", "notify", "nudge", "pulls", "todos", "web"} { + for _, name := range peerNames() { if err == nil || !strings.Contains(err.Error(), name) { t.Errorf("without BAKHYARD_PEERS: got %v, want an error that names %s", err, name) } @@ -102,7 +103,7 @@ func TestOnlyTheEndToEndBuildIsOpen(t *testing.T) { if key == "BAKHYARD_PEERS" { return "home=tcp://127.0.0.1:1,notes=tcp://127.0.0.1:1,notify=tcp://127.0.0.1:1" + ",nudge=tcp://127.0.0.1:1,pulls=tcp://127.0.0.1:1" + - ",todos=tcp://127.0.0.1:1,web=tcp://127.0.0.1:1" + ",time=tcp://127.0.0.1:1,todos=tcp://127.0.0.1:1,web=tcp://127.0.0.1:1" } return "" @@ -120,7 +121,7 @@ func openGateway(t *testing.T) http.Handler { var peers []string - for _, name := range []string{"home", "notes", "notify", "nudge", "pulls", "todos", "web"} { + for _, name := range peerNames() { peer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Got", name+" got "+r.Method+" "+r.URL.Path) w.Header().Set("Got-Cookies", r.Header.Get("Cookie")) diff --git a/services/gateway/peers_test.go b/services/gateway/peers_test.go index b41bcec..7277174 100644 --- a/services/gateway/peers_test.go +++ b/services/gateway/peers_test.go @@ -23,6 +23,7 @@ func TestTheGatewayForwardsEachAPIToItsService(t *testing.T) { home, notes := service("home"), service("notes") pulls, todos := service("pulls"), service("todos") notify, nudge := service("notify"), service("nudge") + timeService := service("time") web := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { t.Errorf("web got %s %s, want nothing", r.Method, r.URL.Path) @@ -35,6 +36,7 @@ func TestTheGatewayForwardsEachAPIToItsService(t *testing.T) { ",notify=tcp://" + strings.TrimPrefix(notify.URL, "http://") + ",nudge=tcp://" + strings.TrimPrefix(nudge.URL, "http://") + ",pulls=tcp://" + strings.TrimPrefix(pulls.URL, "http://") + + ",time=tcp://" + strings.TrimPrefix(timeService.URL, "http://") + ",todos=tcp://" + strings.TrimPrefix(todos.URL, "http://") + ",web=tcp://" + strings.TrimPrefix(web.URL, "http://") router, token := gatewayFrom(t, vars, items) @@ -51,6 +53,7 @@ func TestTheGatewayForwardsEachAPIToItsService(t *testing.T) { }, {http.MethodGet, "/api/pulls/mine", "pulls got GET /pulls/mine"}, {http.MethodPost, "/api/pulls", "pulls got POST /pulls"}, + {http.MethodGet, "/api/time/report", "time got GET /time/report"}, {http.MethodGet, "/api/todos", "todos got GET /todos"}, {http.MethodPost, "/api/todos", "todos got POST /todos"}, {http.MethodGet, "/api/notify/key", "notify got GET /notify/key"}, @@ -74,11 +77,16 @@ func TestTheGatewayForwardsEachAPIToItsService(t *testing.T) { } } +// peerNames returns the names of the gateway's peers, each of which BAKHYARD_PEERS must name. +func peerNames() []string { + return []string{"home", "notes", "notify", "nudge", "pulls", "time", "todos", "web"} +} + // withPeers returns BAKHYARD_PEERS with every peer but those in lacking. func withPeers(lacking ...string) string { var peers []string - for _, name := range []string{"home", "notes", "notify", "nudge", "pulls", "todos", "web"} { + for _, name := range peerNames() { if !slices.Contains(lacking, name) { peers = append(peers, name+"=tcp://127.0.0.1:1") } @@ -94,7 +102,8 @@ func TestTheGatewayNeedsItsPeers(t *testing.T) { withPeers("pulls"): {"pulls"}, withPeers("notify", "nudge"): {"notify", "nudge"}, withPeers("pulls", "todos"): {"pulls", "todos"}, - "": {"home", "notes", "notify", "nudge", "pulls", "todos", "web"}, + withPeers("time"): {"time"}, + "": peerNames(), } { vars, items := gatewayEnv(t, "http://127.0.0.1:2") vars["BAKHYARD_PEERS"] = peers diff --git a/services/gateway/router.go b/services/gateway/router.go index bde52f1..1bc516e 100644 --- a/services/gateway/router.go +++ b/services/gateway/router.go @@ -44,6 +44,7 @@ func wire(upstream upstreams, guard locks, logger *slog.Logger) http.Handler { proxy.Register(api, "notify", upstream.notify, logger) proxy.Register(api, "nudge", upstream.nudge, logger) proxy.Register(api, "pulls", upstream.pulls, logger) + proxy.Register(api, "time", upstream.time, logger) proxy.Register(api, "todos", upstream.todos, logger) proxy.RegisterUnclaimed(api) })