port build_host_files.py to Go, simplify Hermes MCP, cutover final
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

- cmd/hermes/main.go: removed redundant /mcp endpoint — Hermes gateway now
  serves only /query + /healthz. MCP goes direct to API (:8090/mcp).
- cmd/oikos/build_hosts.go: Go port of mcp/build_host_files.py as
  'oikos build-hosts'. Reads inventory.yaml, writes hosts/*.yaml.
- cmd/oikos/main.go: added build-hosts role.
- docker-compose.yml: hermes service simplified.
- apps/105: all 6 Oikos services stopped + disabled (confirmed inactive).
- Watchdog cron installed, API stop/restart verified.
- Infisical bootstrap pending (image pull timeout — retry separately).

Remaining:
- Port bin/homelab CLI to Go (separate plan — large surface)
- Caddy DNS push (needs dtoro/caddy-conf repo access)
- Rollback drill
This commit is contained in:
2026-07-07 18:59:54 +02:00
parent a0f059d19f
commit 128e11b823
29 changed files with 801 additions and 638 deletions

187
cmd/oikos/build_hosts.go Normal file
View File

@@ -0,0 +1,187 @@
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
func runBuildHosts() error {
repoDir := "."
if d := os.Getenv("HOMELAB_CONTEXT_DIR"); d != "" {
repoDir = d
}
data, err := os.ReadFile(filepath.Join(repoDir, "inventory.yaml"))
if err != nil {
return fmt.Errorf("read inventory: %w", err)
}
var inventory map[string]any
if err := yaml.Unmarshal(data, &inventory); err != nil {
return fmt.Errorf("parse inventory: %w", err)
}
hosts, _ := inventory["hosts"].(map[string]any)
services, _ := inventory["services"].(map[string]any)
mesh, _ := inventory["mesh"].(map[string]any)
hostsDir := filepath.Join(repoDir, "hosts")
os.MkdirAll(hostsDir, 0755)
desired := make(map[string]string)
for name, entry := range hosts {
entryMap, _ := entry.(map[string]any)
record := buildHostRecord(name, entryMap, services, mesh)
content := fmt.Sprintf("# Generated by oikos build-hosts from inventory.yaml.\n# Do NOT edit by hand.\n\n%s", asYAML(record))
desired[name+".yaml"] = content
}
for name, content := range desired {
path := filepath.Join(hostsDir, name)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
fmt.Printf("wrote %s\n", name)
}
// Clean orphans
entries, _ := os.ReadDir(hostsDir)
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".yaml") && desired[e.Name()] == "" {
os.Remove(filepath.Join(hostsDir, e.Name()))
fmt.Printf("deleted orphan %s\n", e.Name())
}
}
return nil
}
func buildHostRecord(name string, entry map[string]any, services map[string]any, mesh map[string]any) map[string]any {
kind, _ := entry["kind"].(string)
var runsServices []string
for svc, v := range services {
if svcMap, ok := v.(map[string]any); ok {
if backend, ok := svcMap["backend"].(string); ok && backend == name {
runsServices = append(runsServices, svc)
}
}
}
sort.Strings(runsServices)
var hosted []map[string]any
for _, svc := range runsServices {
if svcMap, ok := services[svc].(map[string]any); ok {
entry := make(map[string]any)
for k, v := range svcMap {
entry[k] = v
}
entry["name"] = svc
hosted = append(hosted, entry)
}
}
record := map[string]any{
"name": name,
"kind": kind,
"os": entry["os"],
"role": entry["role"],
"state": stringOr(entry["state"], "active"),
"host": entry["host"],
"pve_id": entry["pve_id"],
"storage": entry["storage"],
"depends_on": entry["depends_on"],
"lan_ip": entry["lan_ip"],
"mesh": entry["mesh"],
"peers": entry["peers"],
"mounts": entry["mounts"],
"public_host": entry["public_host"],
"ssh": entry["ssh"],
"runs": append(stringSlice(entry["runs"]), runsServices...),
"services_hosted": hosted,
"notes": entry["notes"],
"age_pubkey": entry["age_pubkey"],
}
if mesh != nil {
record["mesh_globals"] = map[string]any{
"primary": mesh["primary"],
"accepted": mesh["accepted"],
}
}
if mcp, ok := services["homelab_mcp"].(map[string]any); ok {
record["mcp_endpoint"] = mcp["endpoint"]
}
if si, ok := services["secrets_issuance"].(map[string]any); ok {
record["secrets_issuance_endpoint"] = si["endpoint"]
}
cleaned := make(map[string]any)
for k, v := range record {
switch val := v.(type) {
case nil:
continue
case string:
if val == "" {
continue
}
case []string:
if len(val) == 0 {
continue
}
case []map[string]any:
if len(val) == 0 {
continue
}
case map[string]any:
if len(val) == 0 {
continue
}
case int:
if val == 0 {
continue
}
}
cleaned[k] = v
}
return cleaned
}
func stringOr(v any, def string) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return def
}
func stringSlice(v any) []string {
switch val := v.(type) {
case []any:
var out []string
for _, item := range val {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
case []string:
return val
}
return nil
}
func asYAML(v any) string {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
enc.Encode(v)
return buf.String()
}

View File

@@ -59,6 +59,11 @@ func main() {
slog.Error("export failed", "error", err)
os.Exit(1)
}
case "build-hosts":
if err := runBuildHosts(); err != nil {
slog.Error("build-hosts failed", "error", err)
os.Exit(1)
}
case "api":
if err := runAPI(ctx, cfg); err != nil {
slog.Error("api failed", "error", err)
@@ -111,6 +116,7 @@ Roles:
migrate Run database migrations (forward-only, idempotent)
seed Ingest seed YAML files into the database
export Export DB state back to seed YAMLs (DR / version control)
build-hosts Generate hosts/*.yaml from seeds/inventory.yaml
api Run the REST + MCP API server (Phase 2)
scheduler Run the observe + act loop (Phase 3)
notifier Run the notification service (Phase 3)