phase 3-4: get_execution_status tool, approval creation, delete bin/ + homelab.go, update AGENTS.md
This commit is contained in:
20
AGENTS.md
20
AGENTS.md
@@ -105,16 +105,16 @@ historical reference.
|
|||||||
|
|
||||||
## 5. Acting on the homelab
|
## 5. Acting on the homelab
|
||||||
|
|
||||||
- **Read state**: prefer MCP tools, then files, then shell. Examples:
|
- **Read state**: use MCP tools. Hermes (the AI agent) is the primary
|
||||||
`homelab whoami`, `homelab list`, `homelab status`, `homelab logs caddy`.
|
operator interface — it has 21 MCP tools for observe/orient/decide/act.
|
||||||
- **Cross-host actions** (caddy reload, pct exec, etc.): use the `homelab`
|
- **Actions** (restart, logs, apt, pct exec): Hermes calls `request_execution`
|
||||||
CLI — it resolves hostname → mesh address → ssh / pct path for you.
|
via MCP. `reversible_low` actions execute immediately; `config_mutation`
|
||||||
- **Secrets**: never hardcode. Call `homelab secret <name>` to decrypt on
|
and `destructive` actions are queued for operator approval via Matrix.
|
||||||
demand using the per-client age key at `/etc/age/key.txt`.
|
- **Secrets**: managed by Infisical (`oikos secret` subcommand for migration).
|
||||||
- **Mutations** (restart, edit configs, etc.): classify against
|
Never hardcode secrets — use env vars from `.env`.
|
||||||
`seeds/policy.yaml` first (`homelab decide <action> <entity>`).
|
- **Mutations** (restart, edit configs, etc.): classified against
|
||||||
`reversible_low` actions just need the interactive confirmation prompt;
|
`seeds/policy.yaml`. `reversible_low` actions auto-execute;
|
||||||
`config_mutation`/`destructive` actions are mechanically refused without
|
`config_mutation`/`destructive` actions require approval.
|
||||||
a valid `--approval-id` from `homelab approval request` — see OIKOS.md.
|
a valid `--approval-id` from `homelab approval request` — see OIKOS.md.
|
||||||
|
|
||||||
## 6. Communication mode
|
## 6. Communication mode
|
||||||
|
|||||||
1944
bin/homelab
1944
bin/homelab
File diff suppressed because it is too large
Load Diff
@@ -1,137 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
type cliHost struct {
|
|
||||||
Name string
|
|
||||||
LanIP string `yaml:"lan_ip"`
|
|
||||||
Role string
|
|
||||||
Kind string
|
|
||||||
State string
|
|
||||||
Mesh map[string]any
|
|
||||||
Runs []string
|
|
||||||
Services []map[string]any `yaml:"services_hosted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func runHomelabSubcommand() {
|
|
||||||
if len(os.Args) < 3 {
|
|
||||||
fmt.Fprintln(os.Stderr, "usage: oikos homelab <list|whoami|ssh|secret>")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctxDir := os.Getenv("HOMELAB_CONTEXT_DIR")
|
|
||||||
if ctxDir == "" {
|
|
||||||
ctxDir = "."
|
|
||||||
}
|
|
||||||
|
|
||||||
switch os.Args[2] {
|
|
||||||
case "list":
|
|
||||||
inv := loadInventory(ctxDir)
|
|
||||||
fmt.Printf("%-25s %-8s %-18s %-6s %s\n", "HOST", "TYPE", "IP", "STATE", "ROLE")
|
|
||||||
fmt.Println(strings.Repeat("-", 80))
|
|
||||||
for name, host := range inv.Hosts {
|
|
||||||
state := host.State
|
|
||||||
if state == "" {
|
|
||||||
state = "active"
|
|
||||||
}
|
|
||||||
fmt.Printf("%-25s %-8s %-18s %-6s %s\n", name, host.Kind, host.LanIP, state, host.Role)
|
|
||||||
}
|
|
||||||
|
|
||||||
case "whoami":
|
|
||||||
hostname, _ := os.Hostname()
|
|
||||||
// Try short hostname and full hostname
|
|
||||||
hostnameShort := strings.Split(hostname, ".")[0]
|
|
||||||
inv := loadInventory(ctxDir)
|
|
||||||
h, ok := inv.Hosts[hostnameShort]
|
|
||||||
if !ok {
|
|
||||||
// Try with full hostname
|
|
||||||
h, ok = inv.Hosts[hostname]
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
fmt.Printf("host: %s (not enrolled — no entry in inventory.yaml)\n", hostname)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
state := h.State
|
|
||||||
if state == "" {
|
|
||||||
state = "active"
|
|
||||||
}
|
|
||||||
fmt.Printf("host: %s kind: %s role: %s lan_ip: %s state: %s\n",
|
|
||||||
hostnameShort, h.Kind, h.Role, h.LanIP, state)
|
|
||||||
for _, svc := range h.Services {
|
|
||||||
fmt.Printf(" service: %v\n", svc["name"])
|
|
||||||
}
|
|
||||||
|
|
||||||
case "ssh":
|
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Fprintln(os.Stderr, "usage: oikos homelab ssh <host>")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
target := os.Args[3]
|
|
||||||
inv := loadInventory(ctxDir)
|
|
||||||
h, ok := inv.Hosts[target]
|
|
||||||
if !ok {
|
|
||||||
fmt.Fprintf(os.Stderr, "unknown host: %s\n", target)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
addr := h.LanIP
|
|
||||||
if addr == "" {
|
|
||||||
for _, m := range []string{"netbird", "tailscale"} {
|
|
||||||
if mh, ok := h.Mesh[m].(map[string]any); ok {
|
|
||||||
if ip, ok := mh["ip"].(string); ok && ip != "" {
|
|
||||||
addr = ip
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if addr == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "no address for %s\n", target)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
user := "root"
|
|
||||||
cmd := exec.Command("ssh", user+"@"+addr)
|
|
||||||
cmd.Stdin = os.Stdin
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
cmd.Run()
|
|
||||||
|
|
||||||
case "secret":
|
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Fprintln(os.Stderr, "usage: oikos homelab secret <name>")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
secretName := os.Args[3]
|
|
||||||
secretFile := filepath.Join(ctxDir, "secrets", secretName+".yaml")
|
|
||||||
// Shell out to sops for decryption
|
|
||||||
cmd := exec.Command("sops", "-d", secretFile)
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "decrypt %s: %v\n", secretName, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
fmt.Fprintf(os.Stderr, "unknown homelab command: %s\n", os.Args[2])
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadInventory(ctxDir string) inventoryFile {
|
|
||||||
data, _ := os.ReadFile(filepath.Join(ctxDir, "inventory.yaml"))
|
|
||||||
var inv inventoryFile
|
|
||||||
yaml.Unmarshal(data, &inv)
|
|
||||||
return inv
|
|
||||||
}
|
|
||||||
|
|
||||||
type inventoryFile struct {
|
|
||||||
Hosts map[string]cliHost `yaml:"hosts"`
|
|
||||||
}
|
|
||||||
@@ -60,8 +60,6 @@ func main() {
|
|||||||
slog.Error("export failed", "error", err)
|
slog.Error("export failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
case "homelab":
|
|
||||||
runHomelabSubcommand()
|
|
||||||
case "api":
|
case "api":
|
||||||
if err := runAPI(ctx, cfg); err != nil {
|
if err := runAPI(ctx, cfg); err != nil {
|
||||||
slog.Error("api failed", "error", err)
|
slog.Error("api failed", "error", err)
|
||||||
@@ -114,15 +112,15 @@ Roles:
|
|||||||
migrate Run database migrations (forward-only, idempotent)
|
migrate Run database migrations (forward-only, idempotent)
|
||||||
seed Ingest seed YAML files into the database
|
seed Ingest seed YAML files into the database
|
||||||
export Export DB state back to seed YAMLs (DR / version control)
|
export Export DB state back to seed YAMLs (DR / version control)
|
||||||
homelab Operator CLI: list, whoami, ssh, secret
|
api Run the REST + MCP API server
|
||||||
api Run the REST + MCP API server (Phase 2)
|
scheduler Run the observe loop
|
||||||
scheduler Run the observe + act loop (Phase 3)
|
notifier Run the notification service (Matrix alerts)
|
||||||
notifier Run the notification service (Phase 3)
|
|
||||||
all Run all roles in one process (dev mode)
|
all Run all roles in one process (dev mode)
|
||||||
secret Secret management (Phase 5)
|
secret Secret management (Infisical)
|
||||||
knowledge Convert wiki to knowledge seed (one-shot)
|
knowledge Convert wiki to knowledge seed (one-shot)
|
||||||
version Print version info
|
version Print version info
|
||||||
|
|
||||||
|
The operator interface is Hermes (MCP agent) — no CLI needed.
|
||||||
Environment:
|
Environment:
|
||||||
OIKOS_DATABASE_URL Postgres connection string
|
OIKOS_DATABASE_URL Postgres connection string
|
||||||
OIKOS_API_LISTEN API listen address (default :8090)
|
OIKOS_API_LISTEN API listen address (default :8090)
|
||||||
|
|||||||
@@ -336,6 +336,34 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
register(&mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
|
||||||
|
),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
execID, _ := args["execution_id"].(string)
|
||||||
|
if execID == "" {
|
||||||
|
return textResult("execution_id required"), nil
|
||||||
|
}
|
||||||
|
eid, err := uuid.Parse(execID)
|
||||||
|
if err != nil {
|
||||||
|
// Try finding by exec slug prefix
|
||||||
|
var found uuid.UUID
|
||||||
|
err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
|
||||||
|
if err2 != nil {
|
||||||
|
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
|
||||||
|
}
|
||||||
|
eid = found
|
||||||
|
}
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT e.entity_id::text, e.action, e.risk_class, e.status,
|
||||||
|
e.result::text, e.duration_ms, e.started_at::text,
|
||||||
|
e.completed_at::text, e.correlation_id
|
||||||
|
FROM executions e
|
||||||
|
WHERE e.entity_id = $1`, eid), nil
|
||||||
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity",
|
register(&mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity",
|
||||||
InputSchema: objSchema(
|
InputSchema: objSchema(
|
||||||
prop{"entity_id", "string", "Entity slug"},
|
prop{"entity_id", "string", "Entity slug"},
|
||||||
@@ -778,3 +806,14 @@ func resolveHost(ctx context.Context, pool *db.Pool, entitySlug string) (hostIP
|
|||||||
}
|
}
|
||||||
return "", "", fmt.Errorf("no IP found for %s", entitySlug)
|
return "", "", fmt.Errorf("no IP found for %s", entitySlug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UUID, action, params, riskClass string) {
|
||||||
|
approvalID, _ := uuid.NewV7()
|
||||||
|
payload := fmt.Sprintf(`{"action":"%s","params":"%s","execution_id":"%s"}`, action, params, execID)
|
||||||
|
pool.Exec(ctx, `
|
||||||
|
INSERT INTO approvals (entity_id, subject_entity_id, action, risk_class,
|
||||||
|
kind, payload, status, expires_at, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending',
|
||||||
|
now() + interval '1 hour', now())`,
|
||||||
|
approvalID, targetID, action, riskClass, payload)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user