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
|
||||
|
||||
Enrolled clients have a checkout at `/opt/homelab-context/`. If this
|
||||
directory does not exist, the client is not enrolled.
|
||||
Thin client model — no git clone, no sync timer. `bootstrap.sh` fetches only
|
||||
the agent orientation files and tooling from the raw Gitea URL, then enrolls
|
||||
via the Oikos API.
|
||||
|
||||
To enroll:
|
||||
```bash
|
||||
# Run from an existing enrolled client
|
||||
homelab client add <hostname>
|
||||
# Run from any machine with mesh connectivity
|
||||
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:
|
||||
1. Clones the repo to `/opt/homelab-context/`
|
||||
2. Configures the auto-sync timer
|
||||
3. Provisions agent persona from `hermes/SOUL.md` (on Hermes agents)
|
||||
4. Installs Caveman tooling for terse communication
|
||||
This calls `POST /api/v1/clients/enroll` on the Oikos API, which:
|
||||
1. Validates the entity exists in DB (planned or provisioning state)
|
||||
2. Validates mesh IP against expected subnets
|
||||
3. Generates an age keypair and delivers it to the client
|
||||
4. Creates an Infisical machine identity
|
||||
5. Transitions the entity to provisioning state
|
||||
|
||||
## After enrollment
|
||||
|
||||
### What changes on your machine
|
||||
|
||||
- `/opt/homelab-context/` — the repo checkout, your source of truth
|
||||
- `/opt/homelab-context/inventory.yaml` — read this first: your hostname, role,
|
||||
peers, mounts, services
|
||||
- `/opt/homelab-context/seeds/policy.yaml` — rules for what actions you can
|
||||
take autonomously vs. what requires operator approval
|
||||
- Auto-sync timer — pulls the repo every 5 minutes
|
||||
- `~/.hermes/SOUL.md` — agent persona (on Hermes agents)
|
||||
- `~/bin/caveman_wrapper.sh` — terse communication tooling
|
||||
- `/opt/homelab/` — agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md)
|
||||
- `/opt/homelab/tools/` — tooling scripts (caveman, hermes-soul)
|
||||
- `/etc/age/key.txt` — age private key for SOPS decryption (fallback)
|
||||
- `/etc/infisical/identity` — Infisical machine identity (primary secrets)
|
||||
- Context poller — launchd/systemd timer hits `GET /api/v1/clients/{slug}/context` every 5 minutes for agent file updates
|
||||
|
||||
### What's NOT on your machine
|
||||
|
||||
- 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
|
||||
|
||||
@@ -69,33 +88,36 @@ mesh address.
|
||||
|
||||
1. **Postgres database** (runtime) — authoritative for entities, knowledge,
|
||||
signals, ledger. Query via MCP or REST API.
|
||||
2. **Repo at `/opt/homelab-context/`** — bootstrap seeds + documentation.
|
||||
On disk, available offline.
|
||||
2. **Context poller** — agent files and tooling fetched via API deltas every
|
||||
5 minutes.
|
||||
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.
|
||||
To trigger sync manually: `sudo homelab sync`.
|
||||
The API returns which agent files, tools, and SOPS config changed since the
|
||||
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
|
||||
|
||||
- **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
|
||||
`seeds/policy.yaml`:
|
||||
policy (query `preflight` MCP tool):
|
||||
- `read_only` / `reversible_low` — execute directly
|
||||
- `config_mutation` / `destructive` — request operator approval via the
|
||||
`homelab` CLI
|
||||
- `config_mutation` / `destructive` — request operator approval via
|
||||
`POST /api/v1/entities/{slug}/activate` (or equivalent lifecycle endpoint)
|
||||
- **Secrets**: use Infisical (primary) or SOPS (fallback). Never hardcode.
|
||||
- **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
|
||||
regenerate seeds.
|
||||
update the DB via the API in the same session.
|
||||
|
||||
## MCP endpoint
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ package actuator
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +17,7 @@ import (
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
// hierarchy (is-a with abstract types), relationship endpoint validation,
|
||||
// cardinality enforcement, and lifecycle state checks. Both the seed
|
||||
// ingest and the API mutation paths validate through this package so the
|
||||
// graph can never violate the ontology (plan R3-1).
|
||||
// cardinality enforcement, lifecycle state checks, and transition
|
||||
// requirement enforcement. Both the seed ingest and the API mutation paths
|
||||
// validate through this package so the graph can never violate the
|
||||
// ontology (plan R3-1).
|
||||
package ontology
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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.
|
||||
@@ -110,3 +114,133 @@ func (t *TypeTree) DefaultState(typ string) string {
|
||||
}
|
||||
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
|
||||
# setup-caveman.sh — idempotent auto-installer for Caveman + RTK token optimization.
|
||||
# Runs automatically after every homelab-context git pull (via tools/post-pull.sh).
|
||||
#
|
||||
# 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)
|
||||
|
||||
# setup-caveman.sh — install Caveman npm package and wrapper scripts
|
||||
# for token-efficient CLI output on enrolled homelab clients.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CAVEMAN_DIR="$SCRIPT_DIR/caveman"
|
||||
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
|
||||
BIN_DIR="$HOME/bin"
|
||||
TEMPLATES_DIR="$HOME/templates"
|
||||
TOOLS_DIR="$CLONE_DIR/tools"
|
||||
|
||||
# Colors for output (only when connected to a terminal)
|
||||
if [ -t 1 ]; then
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
else
|
||||
GREEN=''; YELLOW=''; BLUE=''; NC=''
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
# Install the caveman npm package globally.
|
||||
if ! command -v caveman >/dev/null 2>&1; then
|
||||
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
|
||||
|
||||
log() { echo -e "${BLUE}[caveman]${NC} $1"; }
|
||||
ok() { echo -e "${GREEN}[caveman]${NC} $1"; }
|
||||
skip() { echo -e "${YELLOW}[caveman]${NC} $1"; }
|
||||
|
||||
# --- 1. Check / install node + npm ---
|
||||
if ! command -v node &>/dev/null; then
|
||||
echo "[caveman] node.js not found — skipping caveman install"
|
||||
exit 0
|
||||
# Copy wrapper to ~/bin.
|
||||
if [ -f "$TOOLS_DIR/caveman_wrapper.sh" ]; then
|
||||
cp "$TOOLS_DIR/caveman_wrapper.sh" "$BIN_DIR/caveman_wrapper.sh"
|
||||
chmod +x "$BIN_DIR/caveman_wrapper.sh"
|
||||
echo "[setup-caveman] wrapper installed to $BIN_DIR/caveman_wrapper.sh"
|
||||
fi
|
||||
|
||||
# --- 2. Install Caveman npm package ---
|
||||
if node -e "require('caveman')" 2>/dev/null; then
|
||||
skip "caveman npm package already installed"
|
||||
else
|
||||
log "installing caveman npm package..."
|
||||
npm install -g caveman 2>&1 | tail -1
|
||||
ok "caveman npm package installed"
|
||||
# Copy templates.
|
||||
if [ -d "$TOOLS_DIR/caveman/templates" ]; then
|
||||
mkdir -p "$BIN_DIR/caveman_templates"
|
||||
cp "$TOOLS_DIR/caveman/templates/"*.txt "$BIN_DIR/caveman_templates/" 2>/dev/null || true
|
||||
echo "[setup-caveman] templates installed"
|
||||
fi
|
||||
|
||||
# --- 3. Create target directories ---
|
||||
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
|
||||
echo "[setup-caveman] done"
|
||||
@@ -1,68 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-hermes-soul.sh — auto-provisions Hermes SOUL.md from canonical HERMES.md.
|
||||
# Runs automatically after every homelab-context git pull (via tools/post-pull.sh).
|
||||
#
|
||||
# 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.
|
||||
|
||||
# setup-hermes-soul.sh — provision Hermes agent persona.
|
||||
# Copies ~/.hermes/SOUL.md from hermes/SOUL.md. No-op on non-Hermes agents.
|
||||
set -euo pipefail
|
||||
|
||||
CONTEXT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
HERMES_MD="$CONTEXT_DIR/.agents/HERMES.md"
|
||||
SOUL_MD="${HOME}/.hermes/SOUL.md"
|
||||
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
|
||||
|
||||
# Colors for output (only when connected to a terminal)
|
||||
if [ -t 1 ]; then
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
if [ -f "$CLONE_DIR/hermes/SOUL.md" ]; then
|
||||
mkdir -p "$HOME/.hermes"
|
||||
cp "$CLONE_DIR/hermes/SOUL.md" "$HOME/.hermes/SOUL.md"
|
||||
echo "[setup-hermes-soul] SOUL.md provisioned"
|
||||
else
|
||||
GREEN=''; YELLOW=''; NC=''
|
||||
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)"
|
||||
echo "[setup-hermes-soul] no hermes/SOUL.md found; skipping"
|
||||
fi
|
||||
Reference in New Issue
Block a user