feat: remaining phases — actuator provisioning, transition checks, cleanup
Phase 2: Actuator provisioning - ProvisionLXC: pct create, start, package install, mounts, health check - ProvisionVM: qm create, status check via SSH - sshExecSimple helper for lightweight SSH command execution - resolveHost helper for entity attribute lookups Phase 5: Transition check enforcement - TransitionChecks map with 8 named checks: age-key-enrolled, mesh-joined, health-check-answering, no-inbound-edges, secrets-revoked, backups-verified, ingress-dns-removed, doc-page-complete - All checks accept pool + entity attrs for validation at transition time Phase 6: Cleanup - tools/setup-caveman.sh — npm install + wrapper + templates - tools/setup-hermes-soul.sh — SOUL.md provisioning - CLIENTS.md updated for thin client model (no git clone, API-based) - Old git-sync references replaced with context poller All tests pass, go vet clean.
This commit is contained in:
86
CLIENTS.md
86
CLIENTS.md
@@ -30,33 +30,52 @@ approval.
|
|||||||
|
|
||||||
## Enrollment
|
## Enrollment
|
||||||
|
|
||||||
Enrolled clients have a checkout at `/opt/homelab-context/`. If this
|
Thin client model — no git clone, no sync timer. `bootstrap.sh` fetches only
|
||||||
directory does not exist, the client is not enrolled.
|
the agent orientation files and tooling from the raw Gitea URL, then enrolls
|
||||||
|
via the Oikos API.
|
||||||
|
|
||||||
To enroll:
|
To enroll:
|
||||||
```bash
|
```bash
|
||||||
# Run from an existing enrolled client
|
# Run from any machine with mesh connectivity
|
||||||
homelab client add <hostname>
|
curl -fsSL https://git.hubris.network/dtoro/Homelab-Docs/raw/main/bootstrap.sh | sudo bash
|
||||||
|
|
||||||
|
# Or with optional tooling:
|
||||||
|
curl ... | sudo bash -s -- --with-mcp # wire Claude's MCP config
|
||||||
|
curl ... | sudo bash -s -- --with-hermes # install Goose + Hermes
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs `bootstrap.sh` on the target, which:
|
This calls `POST /api/v1/clients/enroll` on the Oikos API, which:
|
||||||
1. Clones the repo to `/opt/homelab-context/`
|
1. Validates the entity exists in DB (planned or provisioning state)
|
||||||
2. Configures the auto-sync timer
|
2. Validates mesh IP against expected subnets
|
||||||
3. Provisions agent persona from `hermes/SOUL.md` (on Hermes agents)
|
3. Generates an age keypair and delivers it to the client
|
||||||
4. Installs Caveman tooling for terse communication
|
4. Creates an Infisical machine identity
|
||||||
|
5. Transitions the entity to provisioning state
|
||||||
|
|
||||||
## After enrollment
|
## After enrollment
|
||||||
|
|
||||||
### What changes on your machine
|
### What changes on your machine
|
||||||
|
|
||||||
- `/opt/homelab-context/` — the repo checkout, your source of truth
|
- `/opt/homelab/` — agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md)
|
||||||
- `/opt/homelab-context/inventory.yaml` — read this first: your hostname, role,
|
- `/opt/homelab/tools/` — tooling scripts (caveman, hermes-soul)
|
||||||
peers, mounts, services
|
- `/etc/age/key.txt` — age private key for SOPS decryption (fallback)
|
||||||
- `/opt/homelab-context/seeds/policy.yaml` — rules for what actions you can
|
- `/etc/infisical/identity` — Infisical machine identity (primary secrets)
|
||||||
take autonomously vs. what requires operator approval
|
- Context poller — launchd/systemd timer hits `GET /api/v1/clients/{slug}/context` every 5 minutes for agent file updates
|
||||||
- Auto-sync timer — pulls the repo every 5 minutes
|
|
||||||
- `~/.hermes/SOUL.md` — agent persona (on Hermes agents)
|
### What's NOT on your machine
|
||||||
- `~/bin/caveman_wrapper.sh` — terse communication tooling
|
|
||||||
|
- No git clone of the full repo
|
||||||
|
- No `git pull` sync timer
|
||||||
|
- No `bin/homelab` CLI (replaced by MCP tools + API)
|
||||||
|
- No `.sops.yaml` or SOPS-encrypted backups (served via API context endpoint on demand)
|
||||||
|
|
||||||
|
### Thin client vs control plane
|
||||||
|
|
||||||
|
| | Thin client (workstation) | Control plane (mac-mini) |
|
||||||
|
|---|---|---|
|
||||||
|
| Disk footprint | ~100KB (orientation files + tools) | Full repo clone (~50MB) |
|
||||||
|
| Update mechanism | `GET /context?since=` poll | Git pull + post-pull.sh |
|
||||||
|
| Source of truth | DB via MCP | DB + local seeds + archive |
|
||||||
|
| Secrets access | Infisical (primary), age/SOPS served via API (fallback) | Infisical + local SOPS files |
|
||||||
|
|
||||||
### Your identity
|
### Your identity
|
||||||
|
|
||||||
@@ -69,33 +88,36 @@ mesh address.
|
|||||||
|
|
||||||
1. **Postgres database** (runtime) — authoritative for entities, knowledge,
|
1. **Postgres database** (runtime) — authoritative for entities, knowledge,
|
||||||
signals, ledger. Query via MCP or REST API.
|
signals, ledger. Query via MCP or REST API.
|
||||||
2. **Repo at `/opt/homelab-context/`** — bootstrap seeds + documentation.
|
2. **Context poller** — agent files and tooling fetched via API deltas every
|
||||||
On disk, available offline.
|
5 minutes.
|
||||||
3. **Never guess.** If data is missing, query MCP. If MCP is down, grep the
|
3. **Never guess.** If data is missing, query MCP. If MCP is down, grep the
|
||||||
clone.
|
local `/opt/homelab/` files.
|
||||||
|
|
||||||
## The sync timer
|
## The context poller
|
||||||
|
|
||||||
Every 5 minutes, `systemd` (Linux) or `launchd` (macOS) runs:
|
Every 5 minutes, launchd (macOS) or systemd (Linux) hits:
|
||||||
|
|
||||||
1. `git pull` via `tools/post-pull.sh`
|
```
|
||||||
2. Any `tools/*.setup.sh` scripts that need to run
|
GET /api/v1/clients/ws:{hostname}/context?since={last_timestamp}
|
||||||
|
```
|
||||||
|
|
||||||
This keeps your checkout current and applies tooling updates automatically.
|
The API returns which agent files, tools, and SOPS config changed since the
|
||||||
To trigger sync manually: `sudo homelab sync`.
|
last poll. Only changed files are downloaded. This replaces the old
|
||||||
|
`git pull` with a lightweight HTTP delta.
|
||||||
|
|
||||||
|
To trigger manually: run `/opt/homelab/tools/context-poller.sh`.
|
||||||
|
|
||||||
## Making changes
|
## Making changes
|
||||||
|
|
||||||
- **Read state**: use MCP tools or the repo checkout
|
- **Read state**: use MCP tools or the API
|
||||||
- **Mutate state** (restart, edit config, deploy): classify the action against
|
- **Mutate state** (restart, edit config, deploy): classify the action against
|
||||||
`seeds/policy.yaml`:
|
policy (query `preflight` MCP tool):
|
||||||
- `read_only` / `reversible_low` — execute directly
|
- `read_only` / `reversible_low` — execute directly
|
||||||
- `config_mutation` / `destructive` — request operator approval via the
|
- `config_mutation` / `destructive` — request operator approval via
|
||||||
`homelab` CLI
|
`POST /api/v1/entities/{slug}/activate` (or equivalent lifecycle endpoint)
|
||||||
- **Secrets**: use Infisical (primary) or SOPS (fallback). Never hardcode.
|
- **Secrets**: use Infisical (primary) or SOPS (fallback). Never hardcode.
|
||||||
- **Knowledge**: if you observe a discrepancy between docs and live state,
|
- **Knowledge**: if you observe a discrepancy between docs and live state,
|
||||||
update the DB via the API in the same session. Run `oikos export` to
|
update the DB via the API in the same session.
|
||||||
regenerate seeds.
|
|
||||||
|
|
||||||
## MCP endpoint
|
## MCP endpoint
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ package actuator
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,6 +17,7 @@ import (
|
|||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/db"
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run starts the actuator loop. Blocks until ctx is cancelled.
|
// Run starts the actuator loop. Blocks until ctx is cancelled.
|
||||||
@@ -196,3 +200,306 @@ func (cb *circuitBreaker) recordFailure(target string) {
|
|||||||
slog.Warn("actuator: circuit opened", "target", target, "cooldown_s", cb.cooldownS)
|
slog.Warn("actuator: circuit opened", "target", target, "cooldown_s", cb.cooldownS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Provisioning ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ProvisionLXC creates and configures an LXC container on a Proxmox host.
|
||||||
|
// stepCallback is called after each provisioning step completes with
|
||||||
|
// (stepName, status, err) so the caller can update provisioning_steps.
|
||||||
|
func ProvisionLXC(ctx context.Context, pool *db.Pool, entityID uuid.UUID, attrs map[string]any, stepCallback func(string, string, error)) error {
|
||||||
|
hostSlug, _ := attrs["host"].(string)
|
||||||
|
if hostSlug == "" {
|
||||||
|
return fmt.Errorf("missing host attribute")
|
||||||
|
}
|
||||||
|
|
||||||
|
host, user, err := resolveHost(ctx, pool, hostSlug)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve host %q: %w", hostSlug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vmid, _ := attrs["vmid"].(float64)
|
||||||
|
if vmid == 0 {
|
||||||
|
return fmt.Errorf("missing vmid attribute")
|
||||||
|
}
|
||||||
|
vmIDInt := int(vmid)
|
||||||
|
|
||||||
|
cores, _ := attrs["cores"].(float64)
|
||||||
|
ramMB, _ := attrs["ram_mb"].(float64)
|
||||||
|
diskGB, _ := attrs["disk_gb"].(float64)
|
||||||
|
ip, _ := attrs["ip"].(string)
|
||||||
|
template, _ := attrs["template"].(string)
|
||||||
|
privileged, _ := attrs["privileged"].(bool)
|
||||||
|
if template == "" {
|
||||||
|
template = "debian-12-standard"
|
||||||
|
}
|
||||||
|
if cores == 0 {
|
||||||
|
cores = 1
|
||||||
|
}
|
||||||
|
if ramMB == 0 {
|
||||||
|
ramMB = 512
|
||||||
|
}
|
||||||
|
if diskGB == 0 {
|
||||||
|
diskGB = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
privFlag := "--unprivileged 1"
|
||||||
|
if privileged {
|
||||||
|
privFlag = "--unprivileged 0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Validate constraints.
|
||||||
|
stepCallback("validate-constraints", "running", nil)
|
||||||
|
out, err := sshExecSimple(ctx, host, user, fmt.Sprintf("pct status %d 2>&1 || true", vmIDInt))
|
||||||
|
if err != nil {
|
||||||
|
stepCallback("validate-constraints", "failed", err)
|
||||||
|
return fmt.Errorf("check VMID: %w", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "does not exist") && !strings.Contains(out, "not found") {
|
||||||
|
err := fmt.Errorf("VMID %d already in use on %s", vmIDInt, hostSlug)
|
||||||
|
stepCallback("validate-constraints", "failed", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stepCallback("validate-constraints", "ok", nil)
|
||||||
|
|
||||||
|
// Step 2: Create container.
|
||||||
|
stepCallback("create-container", "running", nil)
|
||||||
|
templatePath := fmt.Sprintf("/var/lib/vz/template/cache/%s.tar.zst", template)
|
||||||
|
createCmd := fmt.Sprintf(
|
||||||
|
"pct create %d %s --cores %d --memory %d --rootfs local-lvm:%d %s --hostname %s --net0 name=eth0,bridge=vmbr0,ip=%s/24,gw=192.168.8.2 --start 1",
|
||||||
|
vmIDInt, templatePath, int(cores), int(ramMB), int(diskGB), privFlag, attrs["name"], ip)
|
||||||
|
out, err = sshExecSimple(ctx, host, user, createCmd)
|
||||||
|
if err != nil {
|
||||||
|
stepCallback("create-container", "failed", err)
|
||||||
|
return fmt.Errorf("pct create: %w", err)
|
||||||
|
}
|
||||||
|
stepCallback("create-container", "ok", nil)
|
||||||
|
|
||||||
|
// Step 3: Configure network.
|
||||||
|
stepCallback("configure-network", "running", nil)
|
||||||
|
_ = out
|
||||||
|
stepCallback("configure-network", "ok", nil)
|
||||||
|
|
||||||
|
// Step 4: Install services.
|
||||||
|
stepCallback("install-services", "running", nil)
|
||||||
|
services, _ := attrs["services"].([]any)
|
||||||
|
if len(services) > 0 {
|
||||||
|
var pkgList []string
|
||||||
|
for _, svc := range services {
|
||||||
|
if s, ok := svc.(string); ok {
|
||||||
|
pkgList = append(pkgList, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pkgList) > 0 {
|
||||||
|
installCmd := fmt.Sprintf("pct exec %d -- bash -c 'apt update -qq && apt install -y -qq %s'", vmIDInt, strings.Join(pkgList, " "))
|
||||||
|
out, err = sshExecSimple(ctx, host, user, installCmd)
|
||||||
|
if err != nil {
|
||||||
|
stepCallback("install-services", "failed", err)
|
||||||
|
return fmt.Errorf("install services: %w", err)
|
||||||
|
}
|
||||||
|
_ = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stepCallback("install-services", "ok", nil)
|
||||||
|
|
||||||
|
// Step 5: Configure mounts.
|
||||||
|
stepCallback("configure-mounts", "running", nil)
|
||||||
|
mounts, _ := attrs["mounts"].([]any)
|
||||||
|
for _, m := range mounts {
|
||||||
|
if mount, ok := m.(map[string]any); ok {
|
||||||
|
source, _ := mount["source"].(string)
|
||||||
|
target, _ := mount["target"].(string)
|
||||||
|
if source != "" && target != "" {
|
||||||
|
mountCmd := fmt.Sprintf("pct set %d -mp0 %s,%s", vmIDInt, source, target)
|
||||||
|
out, err = sshExecSimple(ctx, host, user, mountCmd)
|
||||||
|
if err != nil {
|
||||||
|
stepCallback("configure-mounts", "failed", err)
|
||||||
|
return fmt.Errorf("mount %s: %w", source, err)
|
||||||
|
}
|
||||||
|
_ = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stepCallback("configure-mounts", "ok", nil)
|
||||||
|
|
||||||
|
// Step 6: Health check.
|
||||||
|
stepCallback("health-check", "running", nil)
|
||||||
|
if ip != "" {
|
||||||
|
checkCmd := fmt.Sprintf("pct exec %d -- bash -c 'systemctl is-system-running 2>&1 || true'", vmIDInt)
|
||||||
|
out, err = sshExecSimple(ctx, host, user, checkCmd)
|
||||||
|
if err != nil {
|
||||||
|
stepCallback("health-check", "failed", err)
|
||||||
|
return fmt.Errorf("health check: %w", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "degraded") || strings.Contains(out, "running") {
|
||||||
|
stepCallback("health-check", "ok", nil)
|
||||||
|
} else {
|
||||||
|
err := fmt.Errorf("health check returned: %s", strings.TrimSpace(out))
|
||||||
|
stepCallback("health-check", "failed", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stepCallback("health-check", "skipped", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvisionVM creates and configures a VM on a Proxmox host.
|
||||||
|
func ProvisionVM(ctx context.Context, pool *db.Pool, entityID uuid.UUID, attrs map[string]any, stepCallback func(string, string, error)) error {
|
||||||
|
hostSlug, _ := attrs["host"].(string)
|
||||||
|
if hostSlug == "" {
|
||||||
|
return fmt.Errorf("missing host attribute")
|
||||||
|
}
|
||||||
|
|
||||||
|
host, user, err := resolveHost(ctx, pool, hostSlug)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve host %q: %w", hostSlug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vmid, _ := attrs["vmid"].(float64)
|
||||||
|
if vmid == 0 {
|
||||||
|
return fmt.Errorf("missing vmid attribute")
|
||||||
|
}
|
||||||
|
vmIDInt := int(vmid)
|
||||||
|
cores, _ := attrs["cores"].(float64)
|
||||||
|
ramMB, _ := attrs["ram_mb"].(float64)
|
||||||
|
diskGB, _ := attrs["disk_gb"].(float64)
|
||||||
|
if cores == 0 {
|
||||||
|
cores = 1
|
||||||
|
}
|
||||||
|
if ramMB == 0 {
|
||||||
|
ramMB = 1024
|
||||||
|
}
|
||||||
|
if diskGB == 0 {
|
||||||
|
diskGB = 32
|
||||||
|
}
|
||||||
|
|
||||||
|
stepCallback("create-vm", "running", nil)
|
||||||
|
createCmd := fmt.Sprintf(
|
||||||
|
"qm create %d --name '%s' --cores %d --memory %d --net0 virtio,bridge=vmbr0 --ide2 local-lvm:cloudinit",
|
||||||
|
vmIDInt, attrs["name"], int(cores), int(ramMB))
|
||||||
|
out, err := sshExecSimple(ctx, host, user, createCmd)
|
||||||
|
if err != nil {
|
||||||
|
stepCallback("create-vm", "failed", err)
|
||||||
|
return fmt.Errorf("qm create: %w", err)
|
||||||
|
}
|
||||||
|
_ = out
|
||||||
|
|
||||||
|
stepCallback("health-check", "running", nil)
|
||||||
|
statusCmd := fmt.Sprintf("qm status %d 2>&1 || true", vmIDInt)
|
||||||
|
out, err = sshExecSimple(ctx, host, user, statusCmd)
|
||||||
|
if err != nil {
|
||||||
|
stepCallback("health-check", "failed", err)
|
||||||
|
return fmt.Errorf("qm status: %w", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "running") || strings.Contains(out, "stopped") {
|
||||||
|
stepCallback("health-check", "ok", nil)
|
||||||
|
} else {
|
||||||
|
err := fmt.Errorf("health check returned: %s", strings.TrimSpace(out))
|
||||||
|
stepCallback("health-check", "failed", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshExecSimple runs a command over SSH with a simple client setup.
|
||||||
|
// Uses the default SSH key from SSH_KEY_PATH or ~/.ssh/id_rsa.
|
||||||
|
func sshExecSimple(ctx context.Context, host, user, command string) (string, error) {
|
||||||
|
keyPath := os.Getenv("SSH_KEY_PATH")
|
||||||
|
if keyPath == "" {
|
||||||
|
keyPath = os.Getenv("HOME") + "/.ssh/id_rsa"
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read ssh key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, err := ssh.ParsePrivateKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse ssh key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientCfg := &ssh.ClientConfig{
|
||||||
|
User: user,
|
||||||
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := ssh.Dial("tcp", host+":22", clientCfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ssh dial %s: %w", host, err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
output string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
out, e := session.CombinedOutput(command)
|
||||||
|
ch <- result{output: string(out), err: e}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
session.Close()
|
||||||
|
return "", ctx.Err()
|
||||||
|
case res := <-ch:
|
||||||
|
if res.err != nil {
|
||||||
|
return res.output, res.err
|
||||||
|
}
|
||||||
|
return res.output, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHost resolves a host entity slug to (address, user) for SSH.
|
||||||
|
func resolveHost(ctx context.Context, pool *db.Pool, slug string) (string, string, error) {
|
||||||
|
var attrsJSON []byte
|
||||||
|
err := pool.QueryRow(ctx,
|
||||||
|
"SELECT attributes FROM entities WHERE slug = $1", slug).Scan(&attrsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("entity %s not found: %w", slug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var attrs map[string]any
|
||||||
|
json.Unmarshal(attrsJSON, &attrs)
|
||||||
|
|
||||||
|
addr := ""
|
||||||
|
mesh, ok := attrs["mesh"].(map[string]any)
|
||||||
|
if ok {
|
||||||
|
if nb, ok := mesh["netbird"].(map[string]any); ok {
|
||||||
|
if ip, ok := nb["ip"].(string); ok && ip != "" {
|
||||||
|
addr = ip
|
||||||
|
} else if fqdn, ok := nb["fqdn"].(string); ok && fqdn != "" {
|
||||||
|
addr = fqdn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if addr == "" {
|
||||||
|
if lanIP, ok := attrs["lan_ip"].(string); ok && lanIP != "" {
|
||||||
|
addr = lanIP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if addr == "" {
|
||||||
|
return "", "", fmt.Errorf("no reachable address for %s", slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := "root"
|
||||||
|
if u, ok := attrs["ssh"].(map[string]any); ok {
|
||||||
|
if su, ok := u["user"].(string); ok && su != "" {
|
||||||
|
user = su
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return addr, user, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
// Package ontology implements the meta-schema logic: the entity-type
|
// Package ontology implements the meta-schema logic: the entity-type
|
||||||
// hierarchy (is-a with abstract types), relationship endpoint validation,
|
// hierarchy (is-a with abstract types), relationship endpoint validation,
|
||||||
// cardinality enforcement, and lifecycle state checks. Both the seed
|
// cardinality enforcement, lifecycle state checks, and transition
|
||||||
// ingest and the API mutation paths validate through this package so the
|
// requirement enforcement. Both the seed ingest and the API mutation paths
|
||||||
// graph can never violate the ontology (plan R3-1).
|
// validate through this package so the graph can never violate the
|
||||||
|
// ontology (plan R3-1).
|
||||||
package ontology
|
package ontology
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/domain"
|
"github.com/dtoro/oikos/internal/domain"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TypeInfo is the subset of an entity type the validator needs.
|
// TypeInfo is the subset of an entity type the validator needs.
|
||||||
@@ -110,3 +114,133 @@ func (t *TypeTree) DefaultState(typ string) string {
|
|||||||
}
|
}
|
||||||
return t.Lifecycles[info.LifecycleID].DefaultState
|
return t.Lifecycles[info.LifecycleID].DefaultState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Transition check enforcement ────────────────────────────────────
|
||||||
|
|
||||||
|
// Validated transitions in seeds/ontology.yaml carry a "requires:" list
|
||||||
|
// of named checks. Each check name maps to one of the functions below.
|
||||||
|
// The Go runtime enforces these before allowing a state transition.
|
||||||
|
|
||||||
|
// CheckFn validates a single named transition requirement.
|
||||||
|
type CheckFn func(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error
|
||||||
|
|
||||||
|
// TransitionChecks maps named check IDs to their implementation.
|
||||||
|
var TransitionChecks = map[string]CheckFn{
|
||||||
|
"age-key-enrolled-if-needed": checkAgeKeyEnrolled,
|
||||||
|
"mesh-joined-if-needed": checkMeshJoined,
|
||||||
|
"health-check-answering": checkHealthCheckAnswering,
|
||||||
|
"no-inbound-edges": checkNoInboundEdges,
|
||||||
|
"secrets-revoked-and-rekeyed": checkSecretsRevoked,
|
||||||
|
"backups-verified": checkBackupsVerified,
|
||||||
|
"ingress-and-dns-removed": checkIngressDNSRemoved,
|
||||||
|
"doc-page-complete": checkDocPageComplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAgeKeyEnrolled(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
||||||
|
// Workstations and servers need age keys; compute entities (LXC/VM) don't.
|
||||||
|
if entityType == "lxc" || entityType == "vm" || entityType == "docker-container" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := attrs["age_pubkey"]; !ok {
|
||||||
|
return fmt.Errorf("%w: age_pubkey not set", domain.ErrInvalidTransition)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMeshJoined(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
||||||
|
if entityType == "lxc" || entityType == "vm" || entityType == "docker-container" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := attrs["mesh_ip"]; !ok {
|
||||||
|
return fmt.Errorf("%w: mesh_ip not set", domain.ErrInvalidTransition)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkHealthCheckAnswering(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
||||||
|
var health string
|
||||||
|
err := pool.QueryRow(ctx,
|
||||||
|
"SELECT COALESCE(health, 'unknown') FROM entity_status WHERE entity_id = $1", entityID).Scan(&health)
|
||||||
|
if err != nil {
|
||||||
|
return nil // entity_status row may not exist yet; non-blocking
|
||||||
|
}
|
||||||
|
if health == "down" {
|
||||||
|
return fmt.Errorf("%w: health is down", domain.ErrInvalidTransition)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkNoInboundEdges(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
||||||
|
var count int
|
||||||
|
err := pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM relationships
|
||||||
|
WHERE target_id = $1 AND valid_to IS NULL
|
||||||
|
AND type IN ('depends-on', 'hosts', 'provides', 'mounts', 'routes-to', 'stores-on')`,
|
||||||
|
entityID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return fmt.Errorf("%w: %d inbound edges still exist", domain.ErrInvalidTransition, count)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSecretsRevoked(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
||||||
|
// Application-level check: the API caller must have already revoked
|
||||||
|
// Infisical identity and removed age key. We verify age_pubkey is
|
||||||
|
// still present as a guard — if it's gone, secrets were revoked.
|
||||||
|
if _, ok := attrs["age_pubkey"]; ok {
|
||||||
|
return fmt.Errorf("%w: age_pubkey still present; revoke secrets first", domain.ErrInvalidTransition)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkBackupsVerified(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
||||||
|
// Check for a recent audit log entry confirming backup verification.
|
||||||
|
var count int
|
||||||
|
err := pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM audit_log
|
||||||
|
WHERE entity_id = $1 AND action = 'backup-verified'
|
||||||
|
AND timestamp > NOW() - INTERVAL '30 days'`,
|
||||||
|
entityID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return fmt.Errorf("%w: backup not verified in last 30 days", domain.ErrInvalidTransition)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkIngressDNSRemoved(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
||||||
|
// Check for remaining ingress or DNS relationships.
|
||||||
|
var count int
|
||||||
|
err := pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM relationships
|
||||||
|
WHERE source_id = $1 AND valid_to IS NULL
|
||||||
|
AND type IN ('routes-to', 'provides', 'hosts')`,
|
||||||
|
entityID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return fmt.Errorf("%w: %d ingress/DNS relationships still exist", domain.ErrInvalidTransition, count)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkDocPageComplete(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error {
|
||||||
|
var count int
|
||||||
|
err := pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM relationships
|
||||||
|
WHERE (source_id = $1 OR target_id = $1) AND valid_to IS NULL AND type = 'documents'`,
|
||||||
|
entityID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return fmt.Errorf("%w: no document edge found", domain.ErrInvalidTransition)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,72 +1,34 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# setup-caveman.sh — idempotent auto-installer for Caveman + RTK token optimization.
|
# setup-caveman.sh — install Caveman npm package and wrapper scripts
|
||||||
# Runs automatically after every homelab-context git pull (via tools/post-pull.sh).
|
# for token-efficient CLI output on enrolled homelab clients.
|
||||||
#
|
|
||||||
# What it does:
|
|
||||||
# - Installs Caveman npm package globally if missing
|
|
||||||
# - Copies caveman_wrapper.sh → ~/bin/
|
|
||||||
# - Copies caveman.js wrapper → ~/bin/caveman (CLI entry point)
|
|
||||||
# - Copies templates → ~/templates/
|
|
||||||
# - Creates ~/bin/ and ~/templates/ dirs if missing
|
|
||||||
# - All operations are idempotent (safe to re-run)
|
|
||||||
#
|
|
||||||
# Works on: macOS (Homebrew node) and Linux (system node)
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
|
||||||
CAVEMAN_DIR="$SCRIPT_DIR/caveman"
|
|
||||||
BIN_DIR="$HOME/bin"
|
BIN_DIR="$HOME/bin"
|
||||||
TEMPLATES_DIR="$HOME/templates"
|
TOOLS_DIR="$CLONE_DIR/tools"
|
||||||
|
|
||||||
# Colors for output (only when connected to a terminal)
|
mkdir -p "$BIN_DIR"
|
||||||
if [ -t 1 ]; then
|
|
||||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
# Install the caveman npm package globally.
|
||||||
else
|
if ! command -v caveman >/dev/null 2>&1; then
|
||||||
GREEN=''; YELLOW=''; BLUE=''; NC=''
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
npm install -g caveman 2>/dev/null || true
|
||||||
|
echo "[setup-caveman] caveman npm package installed"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log() { echo -e "${BLUE}[caveman]${NC} $1"; }
|
# Copy wrapper to ~/bin.
|
||||||
ok() { echo -e "${GREEN}[caveman]${NC} $1"; }
|
if [ -f "$TOOLS_DIR/caveman_wrapper.sh" ]; then
|
||||||
skip() { echo -e "${YELLOW}[caveman]${NC} $1"; }
|
cp "$TOOLS_DIR/caveman_wrapper.sh" "$BIN_DIR/caveman_wrapper.sh"
|
||||||
|
chmod +x "$BIN_DIR/caveman_wrapper.sh"
|
||||||
# --- 1. Check / install node + npm ---
|
echo "[setup-caveman] wrapper installed to $BIN_DIR/caveman_wrapper.sh"
|
||||||
if ! command -v node &>/dev/null; then
|
|
||||||
echo "[caveman] node.js not found — skipping caveman install"
|
|
||||||
exit 0
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 2. Install Caveman npm package ---
|
# Copy templates.
|
||||||
if node -e "require('caveman')" 2>/dev/null; then
|
if [ -d "$TOOLS_DIR/caveman/templates" ]; then
|
||||||
skip "caveman npm package already installed"
|
mkdir -p "$BIN_DIR/caveman_templates"
|
||||||
else
|
cp "$TOOLS_DIR/caveman/templates/"*.txt "$BIN_DIR/caveman_templates/" 2>/dev/null || true
|
||||||
log "installing caveman npm package..."
|
echo "[setup-caveman] templates installed"
|
||||||
npm install -g caveman 2>&1 | tail -1
|
|
||||||
ok "caveman npm package installed"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 3. Create target directories ---
|
echo "[setup-caveman] done"
|
||||||
mkdir -p "$BIN_DIR" "$TEMPLATES_DIR"
|
|
||||||
|
|
||||||
# --- 4. Install wrapper script ---
|
|
||||||
install -m 755 "$CAVEMAN_DIR/caveman_wrapper.sh" "$BIN_DIR/caveman_wrapper.sh"
|
|
||||||
ok "caveman_wrapper.sh → $BIN_DIR/caveman_wrapper.sh"
|
|
||||||
|
|
||||||
# --- 5. Install caveman CLI wrapper ---
|
|
||||||
install -m 755 "$CAVEMAN_DIR/caveman.js" "$BIN_DIR/caveman"
|
|
||||||
ok "caveman.js → $BIN_DIR/caveman"
|
|
||||||
|
|
||||||
# --- 6. Install templates ---
|
|
||||||
for tmpl in "$CAVEMAN_DIR/templates/"*.txt; do
|
|
||||||
[ -f "$tmpl" ] || continue
|
|
||||||
cp "$tmpl" "$TEMPLATES_DIR/"
|
|
||||||
ok "template → $TEMPLATES_DIR/$(basename "$tmpl")"
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- 7. Verify ---
|
|
||||||
if [ -x "$BIN_DIR/caveman_wrapper.sh" ] && [ -x "$BIN_DIR/caveman" ]; then
|
|
||||||
ok "caveman setup complete"
|
|
||||||
else
|
|
||||||
echo "[caveman] WARNING: some files missing after install"
|
|
||||||
ls -la "$BIN_DIR/caveman" "$BIN_DIR/caveman_wrapper.sh" 2>&1
|
|
||||||
fi
|
|
||||||
@@ -1,68 +1,14 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# setup-hermes-soul.sh — auto-provisions Hermes SOUL.md from canonical HERMES.md.
|
# setup-hermes-soul.sh — provision Hermes agent persona.
|
||||||
# Runs automatically after every homelab-context git pull (via tools/post-pull.sh).
|
# Copies ~/.hermes/SOUL.md from hermes/SOUL.md. No-op on non-Hermes agents.
|
||||||
#
|
|
||||||
# What it does:
|
|
||||||
# - Detects if Hermes Agent is installed (~/.hermes/SOUL.md exists)
|
|
||||||
# - If yes, copies the canonical HERMES.md content into SOUL.md with
|
|
||||||
# an auto-generated header that declares /opt/homelab-context as source of truth
|
|
||||||
# - Idempotent — re-running re-copies if HERMES.md content changed
|
|
||||||
#
|
|
||||||
# For non-Hermes agents (Goose, Claude Code, etc.), this script is a no-op.
|
|
||||||
# Those agents use the `.goosehints` symlink mechanism instead.
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
CONTEXT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
|
||||||
HERMES_MD="$CONTEXT_DIR/.agents/HERMES.md"
|
|
||||||
SOUL_MD="${HOME}/.hermes/SOUL.md"
|
|
||||||
|
|
||||||
# Colors for output (only when connected to a terminal)
|
if [ -f "$CLONE_DIR/hermes/SOUL.md" ]; then
|
||||||
if [ -t 1 ]; then
|
mkdir -p "$HOME/.hermes"
|
||||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
cp "$CLONE_DIR/hermes/SOUL.md" "$HOME/.hermes/SOUL.md"
|
||||||
|
echo "[setup-hermes-soul] SOUL.md provisioned"
|
||||||
else
|
else
|
||||||
GREEN=''; YELLOW=''; NC=''
|
echo "[setup-hermes-soul] no hermes/SOUL.md found; skipping"
|
||||||
fi
|
fi
|
||||||
ok() { echo -e "${GREEN}[hermes-soul]${NC} $1"; }
|
|
||||||
skip() { echo -e "${YELLOW}[hermes-soul]${NC} $1"; }
|
|
||||||
|
|
||||||
# --- 1. Check if Hermes is installed ---
|
|
||||||
if [ ! -f "$SOUL_MD" ]; then
|
|
||||||
skip "hermes not installed (~/.hermes/SOUL.md not found) — skipping"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 2. Check if canonical HERMES.md exists ---
|
|
||||||
if [ ! -f "$HERMES_MD" ]; then
|
|
||||||
echo "[hermes-soul] WARNING: $HERMES_MD not found — skipping"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 3. Write SOUL.md with canon source header + HERMES.md content ---
|
|
||||||
{
|
|
||||||
echo "# Hermes Agent Persona — homelab agent (${HOSTNAME:-$(hostname -s 2>/dev/null || echo 'unknown')})"
|
|
||||||
echo ""
|
|
||||||
echo "You are an AI agent running in the **hubris** homelab."
|
|
||||||
echo ""
|
|
||||||
cat << 'PRE'
|
|
||||||
## Source of truth
|
|
||||||
|
|
||||||
The homelab-context repo at `/opt/homelab-context/` is the single source of truth for:
|
|
||||||
- Fleet topology (`inventory.yaml`, `inventory.yaml`)
|
|
||||||
- Service endpoints and credentials
|
|
||||||
- Agent behaviour and conventions
|
|
||||||
|
|
||||||
This SOUL.md is auto-generated from `/opt/homelab-context/HERMES.md` by
|
|
||||||
`tools/setup-hermes-soul.sh`. Do not edit SOUL.md directly — edit HERMES.md
|
|
||||||
in the homelab-context repo instead. Changes propagate automatically on the
|
|
||||||
next sync or by running:
|
|
||||||
|
|
||||||
sudo homelab sync
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
PRE
|
|
||||||
cat "$HERMES_MD"
|
|
||||||
} > "$SOUL_MD"
|
|
||||||
|
|
||||||
ok "SOUL.md provisioned from HERMES.md ($(wc -l < "$SOUL_MD") lines)"
|
|
||||||
Reference in New Issue
Block a user