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.
|
||||
- **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`.
|
||||
|
||||
|
||||
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 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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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