From cd56e69a2f325debc1b5780b3cc934b7c4c8c4d5 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Sat, 10 Oct 2026 17:11:52 +1100 Subject: [PATCH] Hub runtime: allocate port 8788, add Go skeleton and Docker deployment Co-Authored-By: Paperclip --- .dockerignore | 3 + Dockerfile | 13 +++ README.md | 1 + docker-compose.yml | 21 +++++ docs/hub-api.md | 3 +- docs/outline/00-index.md | 2 +- docs/outline/02-config-and-env.md | 4 +- docs/outline/03-code-map.md | 10 ++- src/go.mod | 3 + src/main.go | 132 ++++++++++++++++++++++++++++++ 10 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/go.mod create mode 100644 src/main.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..31cc0e0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +docs +*.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c22c84e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# Multi-stage build. docs/hub-api.md section 1: Go 1.25, static binary, +# distroless runtime, one container on 192.168.20.13. +FROM golang:1.25-alpine AS build +WORKDIR /src +COPY src/ ./ +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/hub . + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/hub /hub +ENV HUB_PORT=8788 +EXPOSE 8788 +USER nonroot:nonroot +ENTRYPOINT ["/hub"] diff --git a/README.md b/README.md index bea14b9..09a8c71 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ flowchart LR - **Paperclip** is at `http://192.168.20.13:3100`. An API key is required. - **Home Assistant** is at `http://192.168.20.30:8123`. - **Jervis** (voice) is at `http://192.168.20.13:8501`. +- **Hub** is at `http://192.168.20.13:8788` (local Docker container). The design brief is `hub_brief.md`. Project instructions are `AGENTS.md`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7135afa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + hub: + build: + context: . + dockerfile: Dockerfile + image: central-ai-hub:local + container_name: hub + restart: unless-stopped + ports: + - "8788:8788" + environment: + HUB_PORT: "8788" + # Secrets are never hard-coded. The Hub keys (HUB_TOKEN_*, OMNIROUTE_API_KEY) + # come from ~/.config/environment.d/10-secrets.conf. Add only the keys the + # running routes need, when they land (AUD-49+). + healthcheck: + test: ["CMD", "/hub", "-healthcheck"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s diff --git a/docs/hub-api.md b/docs/hub-api.md index 562c784..3e4e8b6 100644 --- a/docs/hub-api.md +++ b/docs/hub-api.md @@ -46,8 +46,7 @@ endpoint with the shape in `docs/ask-contract.md`. ### Base -- Base URL: `http://192.168.20.13:8787` (port 8787 is the proposed default, - `HUB_PORT`). Confirm the port against the house port list before deploy. +- Base URL: `http://192.168.20.13:8788` (the allocated Hub port, `HUB_PORT`; recorded in `docs/outline/02-config-and-env.md`). - Plain HTTP on the private LAN for v1. Put a TLS reverse proxy in front later. - Request and response body: JSON, `application/json; charset=utf-8`. - Maximum request body: 64 KiB. Larger bodies get `413`. diff --git a/docs/outline/00-index.md b/docs/outline/00-index.md index 49c628b..3d4d5a4 100644 --- a/docs/outline/00-index.md +++ b/docs/outline/00-index.md @@ -30,7 +30,7 @@ hold a personality or a memory. The Hub does not do the destination work. - **Env File**: `~/.config/environment.d/10-secrets.conf` - **Config Directory**: `docs/` -- **Active Ports**: Hub 8787 (`HUB_PORT`, proposed default — confirm before deploy). +- **Active Ports**: Hub 8788 (`HUB_PORT`, allocated 2026-10-10). ## Agent Restart Steps diff --git a/docs/outline/02-config-and-env.md b/docs/outline/02-config-and-env.md index 21acf9f..3eb5ab5 100644 --- a/docs/outline/02-config-and-env.md +++ b/docs/outline/02-config-and-env.md @@ -33,7 +33,9 @@ Relevant keys: | Home Assistant | 192.168.20.30 | 8123 | Devices and timers. | | MQTT | 192.168.20.13 | 1883 | Voice topics. | | Apprise | 192.168.20.35 | 8210 | Notifications. | -| Hub | 192.168.20.13 | 8787 | This service. Proposed default (`HUB_PORT`). | +| Hub | 192.168.20.13 | 8788 | This service. Allocated 2026-10-10. `HUB_PORT`, default 8788. | + +> Port 8787 was only a proposal. It is used by the `headroom` container. Port 8788 was free on 2026-10-10 and is the allocated Hub port. ## Config Paths diff --git a/docs/outline/03-code-map.md b/docs/outline/03-code-map.md index 20b2eec..a59d761 100644 --- a/docs/outline/03-code-map.md +++ b/docs/outline/03-code-map.md @@ -1,7 +1,7 @@ # Central AI Hub — Code Structure Map > **Writing Style**: ASD-STE100 Simplified Technical English (`ste-writing`). -> **Status**: Planned. The Hub is not built yet. +> **Status**: Skeleton built (AUD-48). Routing (AUD-49) is not built yet. ## Planned Folder Layout @@ -10,6 +10,9 @@ central_ai_llm_master/ AGENTS.md project rules for agents hub_brief.md agreed design (source of truth) README.md technical readme + Dockerfile multi-stage build (static binary, distroless) + docker-compose.yml one container, port 8788 + .dockerignore build context filter docs/ hub-architecture.mmd Mermaid source hub-architecture-map.html Archify map @@ -19,8 +22,9 @@ central_ai_llm_master/ ask-contract.md Hub to project interface hub-api.md frozen inbound API (v1) vikunja-tasks.json human task list - src/ Hub service code (to build) - main.go HTTP entry point + src/ Hub service code + go.mod module hub + main.go HTTP entry point (GET /health) hear/ takes voice or text understand/ LLM intent routing check/ authorization and approval diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..d89a30f --- /dev/null +++ b/src/go.mod @@ -0,0 +1,3 @@ +module hub + +go 1.25 diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..7dbf630 --- /dev/null +++ b/src/main.go @@ -0,0 +1,132 @@ +// Command hub is the Central AI Hub service. +// +// Skeleton (AUD-48): it serves GET /health only. POST /ask and POST /approve +// arrive in AUD-49. The wire format is frozen in docs/hub-api.md. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "time" +) + +const ( + serviceName = "hub" + serviceVersion = "1" + defaultPort = "8788" +) + +var started = time.Now() + +type healthResponse struct { + OK bool `json:"ok"` + Service string `json:"service"` + Version string `json:"version"` + UptimeS int64 `json:"uptime_s"` + Checks map[string]string `json:"checks"` +} + +type errorBody struct { + Status string `json:"status"` + Error errorInfo `json:"error"` +} + +type errorInfo struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// port reads HUB_PORT. It falls back to 8788. +func port() string { + if p := os.Getenv("HUB_PORT"); p != "" { + return p + } + return defaultPort +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, code int, errCode, message string) { + writeJSON(w, code, errorBody{ + Status: "error", + Error: errorInfo{Code: errCode, Message: message}, + }) +} + +// handleHealth is GET /health. Liveness only. No authentication. +func handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "bad_request", "Use GET for this route.") + return + } + // OmniRoute and MQTT are wired in AUD-49, so they read "unknown" here. + writeJSON(w, http.StatusOK, healthResponse{ + OK: true, + Service: serviceName, + Version: serviceVersion, + UptimeS: int64(time.Since(started).Seconds()), + Checks: map[string]string{ + "omniroute": "unknown", + "mqtt": "unknown", + "log": "ok", + }, + }) +} + +// handleNotFound answers an unknown path with the frozen error envelope. +func handleNotFound(w http.ResponseWriter, r *http.Request) { + writeError(w, http.StatusNotFound, "not_found", "Unknown path.") +} + +// healthcheck calls this process's own /health. It is the container health probe, +// because the runtime image has no shell and no curl. +func healthcheck() { + url := fmt.Sprintf("http://127.0.0.1:%s/health", port()) + client := &http.Client{Timeout: 1 * time.Second} + resp, err := client.Get(url) + if err != nil { + log.Printf("healthcheck failed: %v", err) + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Printf("healthcheck status: %d", resp.StatusCode) + os.Exit(1) + } + os.Exit(0) +} + +func main() { + hc := flag.Bool("healthcheck", false, "Call this process's /health and exit.") + flag.Parse() + if *hc { + healthcheck() + return + } + + mux := http.NewServeMux() + mux.HandleFunc("/health", handleHealth) + mux.HandleFunc("/", handleNotFound) + + srv := &http.Server{ + Addr: ":" + port(), + Handler: mux, + ReadHeaderTimeout: 1 * time.Second, + ReadTimeout: 1 * time.Second, + WriteTimeout: 1 * time.Second, + IdleTimeout: 60 * time.Second, + } + + log.Printf("%s %s listening on :%s", serviceName, serviceVersion, port()) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("%s stopped: %v", serviceName, err) + } +}