Hub runtime: allocate port 8788, add Go skeleton and Docker deployment
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.git
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -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"]
|
||||||
@@ -78,6 +78,7 @@ flowchart LR
|
|||||||
- **Paperclip** is at `http://192.168.20.13:3100`. An API key is required.
|
- **Paperclip** is at `http://192.168.20.13:3100`. An API key is required.
|
||||||
- **Home Assistant** is at `http://192.168.20.30:8123`.
|
- **Home Assistant** is at `http://192.168.20.30:8123`.
|
||||||
- **Jervis** (voice) is at `http://192.168.20.13:8501`.
|
- **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`.
|
The design brief is `hub_brief.md`. Project instructions are `AGENTS.md`.
|
||||||
|
|
||||||
|
|||||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@@ -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
|
||||||
@@ -46,8 +46,7 @@ endpoint with the shape in `docs/ask-contract.md`.
|
|||||||
|
|
||||||
### Base
|
### Base
|
||||||
|
|
||||||
- Base URL: `http://192.168.20.13:8787` (port 8787 is the proposed default,
|
- Base URL: `http://192.168.20.13:8788` (the allocated Hub port, `HUB_PORT`; recorded in `docs/outline/02-config-and-env.md`).
|
||||||
`HUB_PORT`). Confirm the port against the house port list before deploy.
|
|
||||||
- Plain HTTP on the private LAN for v1. Put a TLS reverse proxy in front later.
|
- 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`.
|
- Request and response body: JSON, `application/json; charset=utf-8`.
|
||||||
- Maximum request body: 64 KiB. Larger bodies get `413`.
|
- Maximum request body: 64 KiB. Larger bodies get `413`.
|
||||||
|
|||||||
@@ -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`
|
- **Env File**: `~/.config/environment.d/10-secrets.conf`
|
||||||
- **Config Directory**: `docs/`
|
- **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
|
## Agent Restart Steps
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ Relevant keys:
|
|||||||
| Home Assistant | 192.168.20.30 | 8123 | Devices and timers. |
|
| Home Assistant | 192.168.20.30 | 8123 | Devices and timers. |
|
||||||
| MQTT | 192.168.20.13 | 1883 | Voice topics. |
|
| MQTT | 192.168.20.13 | 1883 | Voice topics. |
|
||||||
| Apprise | 192.168.20.35 | 8210 | Notifications. |
|
| 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
|
## Config Paths
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Central AI Hub — Code Structure Map
|
# Central AI Hub — Code Structure Map
|
||||||
|
|
||||||
> **Writing Style**: ASD-STE100 Simplified Technical English (`ste-writing`).
|
> **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
|
## Planned Folder Layout
|
||||||
|
|
||||||
@@ -10,6 +10,9 @@ central_ai_llm_master/
|
|||||||
AGENTS.md project rules for agents
|
AGENTS.md project rules for agents
|
||||||
hub_brief.md agreed design (source of truth)
|
hub_brief.md agreed design (source of truth)
|
||||||
README.md technical readme
|
README.md technical readme
|
||||||
|
Dockerfile multi-stage build (static binary, distroless)
|
||||||
|
docker-compose.yml one container, port 8788
|
||||||
|
.dockerignore build context filter
|
||||||
docs/
|
docs/
|
||||||
hub-architecture.mmd Mermaid source
|
hub-architecture.mmd Mermaid source
|
||||||
hub-architecture-map.html Archify map
|
hub-architecture-map.html Archify map
|
||||||
@@ -19,8 +22,9 @@ central_ai_llm_master/
|
|||||||
ask-contract.md Hub to project interface
|
ask-contract.md Hub to project interface
|
||||||
hub-api.md frozen inbound API (v1)
|
hub-api.md frozen inbound API (v1)
|
||||||
vikunja-tasks.json human task list
|
vikunja-tasks.json human task list
|
||||||
src/ Hub service code (to build)
|
src/ Hub service code
|
||||||
main.go HTTP entry point
|
go.mod module hub
|
||||||
|
main.go HTTP entry point (GET /health)
|
||||||
hear/ takes voice or text
|
hear/ takes voice or text
|
||||||
understand/ LLM intent routing
|
understand/ LLM intent routing
|
||||||
check/ authorization and approval
|
check/ authorization and approval
|
||||||
|
|||||||
3
src/go.mod
Normal file
3
src/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module hub
|
||||||
|
|
||||||
|
go 1.25
|
||||||
132
src/main.go
Normal file
132
src/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user