port build_host_files.py to Go, simplify Hermes MCP, cutover final
- 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:
187
cmd/oikos/build_hosts.go
Normal file
187
cmd/oikos/build_hosts.go
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user