- Consolidate Oikos on mac-mini (2026-07-06) - Client lifecycle in Go (2026-07-07) - Comprehensive audit & next steps (2026-07-07) - DB as source of truth (2026-07-07) - MCP tool completion (2026-07-07) Paths fixed in index.md to reflect planes/done/ locations. Active plans remaining: Prometheus LXC (Planned), implementation audit (Active).
741 lines
37 KiB
Markdown
741 lines
37 KiB
Markdown
# Plan: Client lifecycle — enrollment through deprecation in Oikos Go
|
|
|
|
**Status:** Done (2026-07-08) — 12/12 verified. Full API + preconditions + thin-client distribution.
|
|
|
|
## Goal
|
|
|
|
Define and implement the complete lifecycle of every compute entity in the Oikos
|
|
Go runtime — workstations, Proxmox hosts, LXCs, VMs, and containers. Two
|
|
onboarding paths share the same lifecycle state machine:
|
|
|
|
1. **Workstation self-enrollment**: the machine calls the API to enroll itself
|
|
2. **Compute entity provisioning**: Oikos creates the entity (LXC/VM/container)
|
|
on a host via the actuator, and the DB is the sole source of truth from the
|
|
first API call
|
|
|
|
Every state transition feeds the Postgres DB. No step depends on the archived
|
|
Python `secrets-issuance` server or the non-existent `bin/homelab` CLI. The
|
|
full repo clone + git-sync timer is replaced with a thin API-distribution model
|
|
for workstations.
|
|
|
|
## Current state — what exists vs. what runs
|
|
|
|
| Component | Exists? | Runs? | Notes |
|
|
|-----------|---------|-------|-------|
|
|
| `bootstrap.sh` (684 lines) | ✅ repo | ⚠️ references dead endpoints | Calls `https://secrets.hubris.network/issue` (Python server, stopped per Phase 6). Symlinks `bin/homelab` (file absent). References `tools/*.setup.sh` (files absent). |
|
|
| `archive/secrets-issuance/server.py` | ✅ archive | ❌ stopped (Phase 6) | Issued age keys, validated mesh IP. No Go replacement. |
|
|
| `archive/mcp/` (build_host_files.py, deleted) | ❌ deleted | ❌ | Legacy host file builder. |
|
|
| `inventory.yaml` (root) | ✅ | ⚠️ edited manually | Flat `hosts:` + `services:` layout. Diverges from `seeds/inventory.yaml` entity-relationship format. |
|
|
| `seeds/inventory.yaml` | ✅ | ✅ ingested into DB | Entity-relationship format with slugs (`host:hubris`, `ws:mac-mini`). No translation path from root format. |
|
|
| Go entity API (`POST/GET/PATCH /entities`) | ✅ | ✅ | Generic CRUD. No client-specific validation, no key issuance, no lifecycle gating. |
|
|
| Go MCP server | ✅ | ✅ | 15 tools. Missing `whoami`, `explain`, `preflight`, `get_change_history`, `get_state_snapshot`. |
|
|
| `oikos secret` CLI (Infisical/SOPS) | ✅ | ✅ | Secrets read/migrate/export. No client-key provisioning. |
|
|
| Sync timer (post-pull.sh) | ✅ | ⚠️ partially broken | References `tools/*.setup.sh` (glob returns zero files). `setup-caveman.sh` and `setup-hermes-soul.sh` documented but absent. |
|
|
|
|
**Takeaway**: Enrollment today runs on shell scripts calling archived Python
|
|
services. The Go runtime has zero awareness of client lifecycle. This plan
|
|
closes that gap — the DB becomes the sole engine for client identity,
|
|
secrets, state, and lifecycle transitions.
|
|
|
|
## Target architecture
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────────────┐
|
|
│ WORKSTATION (bare machine) │
|
|
│ │
|
|
│ 1. curl bootstrap.sh | sudo bash (from raw Gitea URL) │
|
|
│ → fetches CLIENTS.md, AGENTS.md, OIKOS.md, tools/ │
|
|
│ → calls POST /api/v1/clients/enroll │
|
|
│ → receives age keypair + Infisical identity │
|
|
│ → writes /etc/age/key.txt, /etc/infisical/identity │
|
|
│ → polls GET /api/v1/clients/{slug}/context for updates │
|
|
│ (no git clone, no sync timer) │
|
|
└──────────────────────────┬───────────────────────────────────┘
|
|
│ POST /api/v1/clients/enroll
|
|
▼
|
|
┌──────────────────────────────────────────────────────────────┐
|
|
│ OIKOS API (Go, :8090) │
|
|
│ │
|
|
│ Workstation endpoints: │
|
|
│ POST /api/v1/clients/enroll — issue age key, set state │
|
|
│ POST /api/v1/clients/{slug}/activate — provisioning→active │
|
|
│ POST /api/v1/clients/{slug}/deprecate — active→deprecated │
|
|
│ POST /api/v1/clients/{slug}/destroy — deprecated→destroyed │
|
|
│ GET /api/v1/clients/{slug}/secrets — accessible secrets │
|
|
│ GET /api/v1/clients/{slug}/context — agent context delta │
|
|
│ │
|
|
│ Compute entity endpoints: │
|
|
│ POST /api/v1/entities/provision — create LXC/VM/container │
|
|
│ GET /api/v1/entities/{slug}/provision/status — progress │
|
|
│ │
|
|
│ MCP tools: │
|
|
│ whoami, explain, preflight, get_change_history, │
|
|
│ get_state_snapshot, list_my_secrets │
|
|
└──────────────────────────┬───────────────────────────────────┘
|
|
│ writes
|
|
▼
|
|
┌──────────────────────────────────────────────────────────────┐
|
|
│ POSTGRES (TimescaleDB) │
|
|
│ │
|
|
│ entities table: slug, type, name, state, attributes (JSONB) │
|
|
│ entity_status: health, disk, drift count (scheduler) │
|
|
│ audit_log: every state transition, enrollment, revocation │
|
|
│ executions: approved actions, results │
|
|
│ secrets (via Infisical): age keys, API tokens │
|
|
└──────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Thin client model — why the clone + sync timer goes away
|
|
|
|
The repo was the source of truth when Oikos was Python + YAML files. Now the DB
|
|
is the source of truth. A full clone of the repo onto every workstation is
|
|
unnecessary — the only things a workstation actually needs on disk are:
|
|
|
|
| Artifact | Why local? | How delivered |
|
|
|----------|-----------|---------------|
|
|
| `CLIENTS.md` + `AGENTS.md` + `OIKOS.md` | Agent reads these at startup; can't query MCP before knowing MCP exists | Fetched once by bootstrap.sh, cached to `/opt/homelab/` |
|
|
| `.sops.yaml` | Recipient rules for age decryption (DR fallback if Infisical is down) | Served by `GET /api/v1/clients/{slug}/context` |
|
|
| SOPS-encrypted secrets | DR cold recovery (Infisical is primary) | Served by `GET /api/v1/secrets/sops-backups` or fetched on enrollment |
|
|
| Age private key | Decrypt SOPS files | Delivered once in enroll response, written to `/etc/age/key.txt` |
|
|
| Infisical identity | Authenticate to Infisical for primary secrets | Delivered once in enroll response, written to `/etc/infisical/identity` |
|
|
| `tools/` (caveman, hermes-soul) | Auto-setup after pull | Fetched by bootstrap.sh once; API context endpoint serves updates |
|
|
|
|
Everything else — topology, knowledge, policy, runbooks, ledger — is queried
|
|
live from the DB via MCP.
|
|
|
|
### What replaces the sync timer
|
|
|
|
The 5-minute `git pull` is replaced by a lightweight API poll:
|
|
|
|
```
|
|
GET /api/v1/clients/{slug}/context?since=2026-07-07T12:00:00Z
|
|
```
|
|
|
|
Returns a JSON delta:
|
|
```json
|
|
{
|
|
"agent_files_changed": ["CLIENTS.md", "AGENTS.md", "OIKOS.md"],
|
|
"sops_config_changed": true,
|
|
"tools_changed": ["setup-caveman.sh"],
|
|
"since": "2026-07-07T12:05:00Z"
|
|
}
|
|
```
|
|
|
|
The client fetches only changed files. Poll interval: 5 minutes (same as old
|
|
timer, but HTTP instead of git). On delta, the client writes updated files to
|
|
`/opt/homelab/` and re-runs any changed `tools/*.setup.sh` scripts.
|
|
|
|
### What stays a full clone
|
|
|
|
The **control plane host** (mac-mini, running the Oikos Docker stack) keeps the
|
|
full clone at `/opt/homelab-context/`. It is the deployment target, the seed
|
|
source, and the operator's working copy. All other workstations are thin clients.
|
|
|
|
### bootstrap.sh changes (thin client)
|
|
|
|
```bash
|
|
# Old: full clone
|
|
git clone "$REPO_HTTPS" "$CLONE_DIR"
|
|
|
|
# New: fetch only what the agent needs
|
|
mkdir -p /opt/homelab/.agents/shared /opt/homelab/.agents/skills
|
|
curl -s "$RAW_URL/CLIENTS.md" -o /opt/homelab/CLIENTS.md
|
|
curl -s "$RAW_URL/AGENTS.md" -o /opt/homelab/AGENTS.md
|
|
curl -s "$RAW_URL/.agents/OIKOS.md" -o /opt/homelab/OIKOS.md
|
|
curl -s "$RAW_URL/.agents/shared/caveman.md" -o /opt/homelab/caveman.md
|
|
|
|
# Then enroll via API (gets age key + Infisical identity)
|
|
# Then start the context poller
|
|
```
|
|
|
|
No `git` dependency on workstations. No `post-pull.sh`. No sync timer unit
|
|
files. Just a cron/launchd job that hits `GET /context` every 5 minutes.
|
|
|
|
### Transition for existing enrolled clients
|
|
|
|
Existing clients with full clones continue working. The context poller is
|
|
additive — it writes updated files to `/opt/homelab/` alongside the clone.
|
|
Once the poller is proven, the clone can be removed and the git-based timer
|
|
disabled. Backward compatible, no flag day.
|
|
|
|
## Compute entity lifecycle — LXC, VM, container provisioning
|
|
|
|
Workstations self-enroll. Compute entities (LXCs, VMs, Docker containers) are
|
|
**created by Oikos** — an operator (or Hermes) issues an intent, and Oikos
|
|
provisions the entity on a Proxmox host via the actuator. The DB is the source
|
|
of truth from the first API call; there is no bootstrap.sh, no mesh join, no
|
|
age key — the entity exists because Oikos put it there.
|
|
|
|
### Provisioning flow
|
|
|
|
```
|
|
Operator (or Hermes via MCP):
|
|
"create LXC 130 on hubris running jellyfin, 2 cores, 2GB RAM, 20GB disk,
|
|
mount /mnt/library, IP 192.168.8.130, privileged"
|
|
|
|
│
|
|
▼
|
|
POST /api/v1/entities/provision
|
|
{
|
|
"slug": "lxc:jellyfin",
|
|
"type": "lxc",
|
|
"name": "Jellyfin",
|
|
"host": "host:hubris",
|
|
"attributes": {
|
|
"vmid": 130,
|
|
"cores": 2,
|
|
"ram_mb": 2048,
|
|
"disk_gb": 20,
|
|
"ip": "192.168.8.130",
|
|
"privileged": true,
|
|
"mounts": [{"source": "/mnt/library", "target": "/mnt/library"}],
|
|
"template": "debian-12-standard",
|
|
"services": ["jellyfin"]
|
|
}
|
|
}
|
|
|
|
│ Oikos API:
|
|
│ 1. Creates entity in DB: lxc:jellyfin, state = planned
|
|
│ 2. Validates: IP not in use, VMID not taken, host has capacity
|
|
│ 3. Policy classifier: config_mutation → requests operator approval
|
|
│ 4. On approval, transitions to provisioning
|
|
│ 5. Creates execution record
|
|
│ 6. Actuator SSHs to host:hubris:
|
|
│ pct create 130 /var/lib/vz/template/cache/debian-12-standard.tar.zst \
|
|
│ --cores 2 --memory 2048 --rootfs local-lvm:20 \
|
|
│ --net0 name=eth0,bridge=vmbr0,ip=192.168.8.130/24,gw=192.168.8.2 \
|
|
│ --unprivileged 0
|
|
│ pct start 130
|
|
│ pct exec 130 -- apt update && apt install -y jellyfin-server
|
|
│ # mount, service enable, firewall rules
|
|
│ 7. Polls health check until passing
|
|
│ 8. Transitions to active
|
|
│ 9. Creates relationship edges: host:hubris hosts lxc:jellyfin,
|
|
│ lxc:jellyfin provides service:jellyfin, etc.
|
|
│ 10. Writes audit log
|
|
|
|
▼
|
|
Entity in DB: lxc:jellyfin, state = active
|
|
Relationships: host:hubris → hosts → lxc:jellyfin
|
|
lxc:jellyfin → provides → service:jellyfin
|
|
lxc:jellyfin → mounts → storage:library
|
|
```
|
|
|
|
### State machine (shared by workstations and compute entities)
|
|
|
|
```
|
|
┌─────────────────────────────┐
|
|
│ WORKSTATION │
|
|
│ Operator runs bootstrap.sh │
|
|
│ → self-enrolls via API │
|
|
│ → gets age key + Infisical │
|
|
│ → polls /context for deltas │
|
|
└──────────────┬──────────────┘
|
|
│
|
|
[planned] ──→ provisioning ──→ active ──→ migrating ──→ active
|
|
▲ ▲ │ │
|
|
│ │ ├──→ deprecated ──→ destroyed
|
|
│ │ │
|
|
│ │ └──→ failed
|
|
│ │
|
|
│ └──→ failed ┌─────────────────────────────┐
|
|
│ │ COMPUTE ENTITY │
|
|
│ │ POST /entities/provision │
|
|
└──→ destroyed (cancelled) │ → Oikos actuator creates │
|
|
│ LXC/VM/container on host │
|
|
│ → no self-enrollment │
|
|
│ → no mesh join │
|
|
│ → no age key │
|
|
└─────────────────────────────┘
|
|
```
|
|
|
|
### Provisioning differences by type
|
|
|
|
| Aspect | Workstation | LXC | VM | Docker container |
|
|
|--------|------------|-----|----|-----------------|
|
|
| Who creates the entity | Operator via API/seed | Operator/Hermes via `POST /entities/provision` | Same as LXC | Same as LXC |
|
|
| How it reaches `provisioning` | Self-enrolls via `POST /clients/enroll` | API transitions automatically after operator approval | Same as LXC | Same as LXC |
|
|
| Age keypair | Generated, delivered to client | None — no SOPS access needed | None | None |
|
|
| Infisical identity | Created, scoped to `/clients/<slug>/*` | Only if the LXC hosts services that need secrets | Same as LXC | Same as LXC |
|
|
| Mesh join | Netbird/Tailscale (if remote access needed) | No — reachable via host LAN IP | Optional | No |
|
|
| Health check | Scheduler probes HTTP/TCP/SSH | Scheduler probes service port on host LAN IP | Same as LXC | Scheduler probes container port |
|
|
| `provisioning → active` checks | `age-key-enrolled`, `mesh-joined`, `health-check-answering` | `health-check-answering`, `service-running`, `mounts-verified` | Same as LXC + `vm-agent-responding` | `health-check-answering`, `container-running` |
|
|
| `deprecated → destroyed` checks | `secrets-revoked`, `no-inbound-edges`, `ingress-dns-removed` | `backups-verified`, `no-inbound-edges`, `ingress-dns-removed` | Same as LXC | `no-inbound-edges` |
|
|
| Secrets to revoke on destroy | Age key, Infisical identity | Infisical identity (if any), service tokens | Same as LXC | Infisical identity (if any) |
|
|
|
|
### Compute entity attributes schema
|
|
|
|
```json
|
|
{
|
|
"vmid": 130,
|
|
"cores": 2,
|
|
"ram_mb": 2048,
|
|
"disk_gb": 20,
|
|
"ip": "192.168.8.130",
|
|
"privileged": true,
|
|
"os": "debian-12",
|
|
"template": "debian-12-standard",
|
|
"mounts": [
|
|
{"source": "/mnt/library", "target": "/mnt/library", "options": "ro"}
|
|
],
|
|
"services": ["jellyfin"],
|
|
"provisioned_by": "ws:mac-mini",
|
|
"provisioned_at": "2026-07-07T14:00:00Z",
|
|
"host": "host:hubris"
|
|
}
|
|
```
|
|
|
|
### API endpoints for compute entity provisioning
|
|
|
|
| Method | Path | Scope | Purpose |
|
|
|--------|------|-------|---------|
|
|
| `POST` | `/api/v1/entities/provision` | operator | Declare intent to create an LXC/VM/container. Creates entity in `planned`, validates constraints, queues execution for operator approval. |
|
|
| `GET` | `/api/v1/entities/{slug}/provision/status` | operator | Poll provisioning progress: steps completed, current step, errors |
|
|
| `POST` | `/api/v1/entities/{slug}/provision/retry` | operator | Retry a failed provisioning step |
|
|
|
|
### What `POST /entities/provision` validates
|
|
|
|
1. **Slug is available**: no existing entity with same slug
|
|
2. **VMID not in use**: query entities where `attributes->>'vmid'` = requested VMID
|
|
3. **IP not in use**: query entities where `attributes->>'ip'` = requested IP
|
|
4. **Host exists and is active**: `host:hubris` must be an entity with state `active`
|
|
5. **Host has capacity**: check entity_status for CPU/RAM/disk headroom on host
|
|
6. **Template exists**: Proxmox template must be available on the host
|
|
7. **Mount source exists**: storage entity must exist for each mount
|
|
8. **Policy classification**: `config_mutation` → operator approval required (auto-act only if Hermes has autonomy for this specific action type)
|
|
|
|
### Relationship edges created on provision
|
|
|
|
When an LXC is provisioned, these edges are written to the `relationships` table:
|
|
|
|
| Source | Edge type | Target | Why |
|
|
|--------|-----------|--------|-----|
|
|
| `host:hubris` | `hosts` | `lxc:jellyfin` | Physical colocation |
|
|
| `lxc:jellyfin` | `provides` | `service:jellyfin` | Service mapping |
|
|
| `lxc:jellyfin` | `mounts` | `storage:library` | Storage dependency |
|
|
| `lxc:jellyfin` | `depends-on` | `host:hubris` | Blast radius: hubris down → jellyfin down |
|
|
| `service:jellyfin` | `depends-on` | `lxc:jellyfin` | Service availability ties to container |
|
|
| `lxc:jellyfin` | `depends-on` | `service:dns` | DNS resolution |
|
|
| `lxc:jellyfin` | `depends-on` | `service:caddy` | If ingress-exposed |
|
|
|
|
All edges are created atomically during provisioning. The `blast_radius()` CTE
|
|
walks these edges to answer "what breaks if hubris goes down?"
|
|
|
|
### State: `planned`
|
|
|
|
The operator declares intent. A client entity exists in the DB with state
|
|
`planned` but has no host, no keys, no sync.
|
|
|
|
**Entry condition**: Operator creates the entity via API or seed file.
|
|
|
|
**Required attributes**:
|
|
- `slug` — `ws:<hostname>` for workstations, `host:<hostname>` for servers
|
|
- `type` — `workstation`, `standalone-server`, or `proxmox-host`
|
|
- `name` — human-readable name
|
|
- `lan_ip` — expected LAN IP (reserved in DHCP)
|
|
- `os` — `linux` or `macos`
|
|
- `role` — free-text description of what this machine does
|
|
- `mesh.expected_type` — `netbird` or `tailscale` (which mesh it will join)
|
|
- `ssh.user` — login user (default `root`)
|
|
|
|
**Allowed transitions**: `→ provisioning` (operator triggers), `→ destroyed` (cancelled).
|
|
|
|
### State: `provisioning`
|
|
|
|
The machine has been declared. Operator runs `bootstrap.sh` on the target,
|
|
which calls the enrollment API. The API validates identity (mesh IP matches
|
|
expected subnet, hostname matches slug), issues an age keypair, and records
|
|
the public key. The sync timer starts pulling the repo.
|
|
|
|
**Go API**: `POST /api/v1/clients/enroll`
|
|
```json
|
|
{
|
|
"slug": "ws:new-laptop",
|
|
"hostname": "new-laptop",
|
|
"mesh_ip": "100.122.x.x"
|
|
}
|
|
```
|
|
|
|
**What the enrollment endpoint does**:
|
|
1. Looks up entity by slug — must exist, must be in state `planned` or `provisioning`
|
|
2. Validates mesh IP is in `100.122.0.0/16` (Netbird) or `100.64.0.0/10` (Tailscale) or `192.168.8.0/24` (LAN)
|
|
3. Validates hostname has no conflicting mesh IP already recorded
|
|
4. Generates an age keypair (`age-keygen`)
|
|
5. Stores the private key in Infisical under path `/clients/<slug>/age-key`
|
|
6. Creates an Infisical machine identity for the client (UniversalAuth)
|
|
7. Updates entity `attributes` with `age_pubkey`, `mesh_ip`, `enrolled_at`
|
|
8. Writes audit log: `client.enrolled`
|
|
9. Returns the age private key, Infisical client ID + secret, and machine identity token
|
|
|
|
**Response** (to bootstrap.sh, over mesh — TLS + mesh IP validation):
|
|
```json
|
|
{
|
|
"age_private_key": "AGE-SECRET-KEY-...",
|
|
"age_public_key": "age1...",
|
|
"infisical_client_id": "...",
|
|
"infisical_client_secret": "...",
|
|
"machine_identity_token": "..."
|
|
}
|
|
```
|
|
|
|
**Bootstrap script changes**:
|
|
- Remove call to `https://secrets.hubris.network/issue`
|
|
- Replace with `POST /api/v1/clients/enroll` to `https://oikos.hubris.network`
|
|
- Remove `--no-secrets` / `--no-mesh` flags (or keep as escape hatches with degraded state)
|
|
- Remove symlink to `bin/homelab` (file doesn't exist)
|
|
- After receiving keys, bootstrap.sh writes `/etc/age/key.txt` (0600) and `/etc/infisical/identity` (0600)
|
|
|
|
**Pre-built bootstrap**: The bootstrap.sh is served from the Gitea repo raw URL
|
|
(already the case). After this plan, it calls Oikos API instead of the dead
|
|
Python service.
|
|
|
|
**Allowed transition**: `→ active` (when `age-key-enrolled`, `mesh-joined`,
|
|
`doc-page-complete` checks pass).
|
|
|
|
### State: `active`
|
|
|
|
Normal operation. The client pulls the repo every 5 minutes, uses its age key
|
|
to decrypt SOPS secrets (fallback), and authenticates to Infisical via its
|
|
machine identity (primary). The MCP `whoami(hostname)` tool returns its
|
|
entity record, peer list, accessible secrets, and current health.
|
|
|
|
**Go enforcement of transition checks** (`provisioning → active`):
|
|
- `age-key-enrolled-if-needed`: entity.attributes.age_pubkey is non-empty
|
|
- `mesh-joined-if-needed`: entity.attributes.mesh_ip is non-empty
|
|
- `ingress-live-if-public`: skipped for workstations (no public ingress)
|
|
- `health-check-answering`: scheduler probe passes for this entity
|
|
- `doc-page-complete`: entity has at least one `documents` edge
|
|
- `inventory-in-db`: entity exists in DB with all required attributes
|
|
|
|
**API**: `POST /api/v1/clients/{slug}/activate`
|
|
- Validates all `provisioning → active` transition checks
|
|
- Sets state to `active`
|
|
- Writes audit log: `client.activated`
|
|
|
|
**MCP tools active clients get**:
|
|
- `whoami(hostname)` — returns entity record, peers, secrets list, health
|
|
- `list_my_secrets(caller_pubkey?)` — secrets this client can decrypt
|
|
|
|
**Allowed transitions**: `→ migrating`, `→ deprecated`, `→ failed`.
|
|
|
|
### State: `migrating`
|
|
|
|
Client is being moved — OS reinstall, hardware swap, role change. Inbound edges
|
|
still exist; no deprovisioning has started.
|
|
|
|
**Allowed transition**: `→ active` (migration complete, post-verify passes).
|
|
|
|
**API**: `POST /api/v1/clients/{slug}/migrate` (sets state, links migration plan).
|
|
|
|
### State: `deprecated`
|
|
|
|
Client is being phased out. Services moved off, mesh disconnected, secrets
|
|
rotation started. The deprecation gate (`no-inbound-edges`) blocks `→ destroyed`
|
|
until all `depends-on`, `hosts`, `provides`, and `mounts` edges are gone.
|
|
|
|
**API**: `POST /api/v1/clients/{slug}/deprecate`
|
|
- Validates `replacement-live-or-role-retired`: operator confirms replacement exists or role is no longer needed
|
|
- Sets state to `deprecated`
|
|
- Writes audit log: `client.deprecated`
|
|
|
|
**Allowed transitions**: `→ active` (un-deprecate), `→ destroyed`.
|
|
|
|
### State: `destroyed`
|
|
|
|
Client is gone. All edges removed, secrets revoked, archaeology entry written.
|
|
|
|
**API**: `POST /api/v1/clients/{slug}/destroy`
|
|
- Validates all `deprecated → destroyed` transition checks:
|
|
- `backups-verified`: any data on this client was backed up
|
|
- `secrets-revoked-and-rekeyed`: age key removed from Infisical, SOPS recipients updated, machine identity deleted
|
|
- `ingress-and-dns-removed`: no remaining DNS records or Caddy backends
|
|
- `no-inbound-edges`: zero `depends-on`, `hosts`, `provides`, `mounts` edges pointing to this entity
|
|
- `archaeology-entry`: writes a record explaining why and when
|
|
- Sets state to `destroyed`
|
|
- Revokes Infisical machine identity
|
|
- Removes age public key from `.sops.yaml`
|
|
- Writes audit log: `client.destroyed`
|
|
|
|
### State: `failed`
|
|
|
|
Something went wrong during provisioning or operation. Requires operator
|
|
intervention. Treated as informational — no automatic recovery.
|
|
|
|
**API**: `POST /api/v1/clients/{slug}/fail`
|
|
- Sets state to `failed`
|
|
- Requires `reason` field explaining what broke
|
|
- Writes audit log: `client.failed`
|
|
|
|
## Secrets integration
|
|
|
|
### Age key lifecycle
|
|
|
|
```
|
|
planned ────────────→ no key exists
|
|
provisioning ───────→ keypair generated, pubkey stored in entity attributes,
|
|
private key delivered to client via enroll response,
|
|
private key stored in Infisical under /clients/<slug>/age-key
|
|
active ─────────────→ key used for SOPS decryption fallback, authenticated
|
|
to Infisical via machine identity for primary secrets
|
|
deprecated ─────────→ key still valid, but rotation initiated
|
|
destroyed ──────────→ key revoked from Infisical, removed from .sops.yaml,
|
|
machine identity deleted
|
|
```
|
|
|
|
### Infisical machine identity
|
|
|
|
Each client gets an Infisical machine identity during enrollment. This is the
|
|
primary secrets path — the age key is fallback for SOPS-encrypted DR files.
|
|
|
|
- **Client ID + Secret** returned in enroll response
|
|
- **Scoped to paths**: `/clients/<slug>/*`, `/shared/*`
|
|
- **Revoked on destroy**: identity deleted, access gone
|
|
|
|
### SOPS fallback
|
|
|
|
The age public key is added to `.sops.yaml` recipients during enrollment.
|
|
On destroy, it is removed via `oikos secret export-sops` regeneration.
|
|
|
|
### bootstrap.sh changes
|
|
|
|
```diff
|
|
- # calls https://secrets.hubris.network/issue (Python, dead)
|
|
- AGE_KEY=$(curl -s -X POST "$ISSUANCE_URL" ...)
|
|
-
|
|
+ # calls Oikos API enrollment endpoint
|
|
+ ENROLL_RESP=$(curl -s -X POST "$OIKOS_URL/api/v1/clients/enroll" \
|
|
+ -H "Content-Type: application/json" \
|
|
+ -d "{\"slug\":\"ws:$HNAME\",\"hostname\":\"$HNAME\",\"mesh_ip\":\"$MESH_IP\"}")
|
|
+ AGE_PRIVKEY=$(echo "$ENROLL_RESP" | jq -r '.age_private_key')
|
|
```
|
|
|
|
## DB integration
|
|
|
|
### New migration
|
|
|
|
`012_client_enrollment.up.sql`:
|
|
```sql
|
|
-- No new tables needed — entities table already holds clients.
|
|
-- Add enrollment-specific attributes validation via check constraints
|
|
-- or application-level validation.
|
|
|
|
-- Enforce slug format for machine entities
|
|
-- ws:<hostname> for workstations, host:<hostname> for servers
|
|
-- (application-level validation in Go, not a DB constraint)
|
|
|
|
-- Add index for slug-based client lookups
|
|
CREATE INDEX IF NOT EXISTS idx_entities_slug_type
|
|
ON entities (slug, type)
|
|
WHERE type IN ('workstation', 'standalone-server', 'proxmox-host');
|
|
```
|
|
|
|
### Entity attributes schema (for `machine` types)
|
|
|
|
```json
|
|
{
|
|
"cpu_arch": "arm64",
|
|
"ram_gb": 16,
|
|
"os": "macos",
|
|
"lan_ip": "192.168.8.175",
|
|
"mesh": {
|
|
"netbird": {"ip": "100.122.x.x", "fqdn": "hostname.netbird.selfhosted"}
|
|
},
|
|
"ssh": {"user": "dtoro"},
|
|
"age_pubkey": "age1...",
|
|
"enrolled_at": "2026-07-07T12:00:00Z",
|
|
"enrolled_by": "ws:mac-mini",
|
|
"infisical_identity_id": "identity_abc123"
|
|
}
|
|
```
|
|
|
|
All attributes are stored in the `attributes` JSONB column on the `entities`
|
|
table. Validation happens at the application layer (Go) using the schema
|
|
defined in `seeds/ontology.yaml`.
|
|
|
|
## MCP tools to add
|
|
|
|
These are documented in AGENTS.md section 3 but not implemented in the Go MCP
|
|
server. Implementation: register in `internal/mcp/server.go`.
|
|
|
|
| Tool | Input | Output | Implementation |
|
|
|------|-------|--------|----------------|
|
|
| `whoami` | `hostname` | Entity record, peers, accessible secrets, health | DB lookup by slug derived from hostname |
|
|
| `list_my_secrets` | `caller_pubkey?` | Secrets this client can decrypt | Infisical list + SOPS `.sops.yaml` match |
|
|
| `explain` | `service_slug` | Compact context card: type, state, health, relations, last change | DB join: entity + entity_status + audit_log |
|
|
| `preflight` | `service_slug` | Risk class, approval requirement, verification command | Policy classifier on the entity's type |
|
|
| `get_change_history` | `entity_slug`, `limit` | Last N audit_log entries for entity | DB query on audit_log table |
|
|
| `get_state_snapshot` | none | Last scheduler Observe pass: health, disk, drift count | DB query on entity_status + signals |
|
|
|
|
## API endpoints to add
|
|
|
|
Add to `api/openapi.yaml`, regenerate with `make generate`, implement in
|
|
`internal/httpapi/impl.go`.
|
|
|
|
| Method | Path | Scope | Purpose |
|
|
|--------|------|-------|---------|
|
|
| `POST` | `/api/v1/clients/enroll` | agent | Issue age key, validate mesh, set state → provisioning |
|
|
| `POST` | `/api/v1/clients/{slug}/activate` | operator | Run transition checks, state → active |
|
|
| `POST` | `/api/v1/clients/{slug}/deprecate` | operator | State → deprecated |
|
|
| `POST` | `/api/v1/clients/{slug}/destroy` | operator | Run destroy checks, revoke secrets, state → destroyed |
|
|
| `POST` | `/api/v1/clients/{slug}/fail` | operator | State → failed with reason |
|
|
| `GET` | `/api/v1/clients/{slug}/secrets` | agent | List secrets this client can access |
|
|
|
|
## Files changed
|
|
|
|
| File | Change |
|
|
|------|--------|
|
|
| `bootstrap.sh` | Replace git clone with thin fetch of AGENTS.md + OIKOS.md + tools/. Replace `secrets.hubris.network/issue` with `POST /api/v1/clients/enroll`. Replace sync timer with context poller. Remove dead symlinks. |
|
|
| `api/openapi.yaml` | Add workstation enrollment, lifecycle, secret, and context endpoints. Add compute entity provision, status, retry endpoints. |
|
|
| `internal/httpapi/impl.go` | Implement workstation lifecycle handlers. Implement compute entity provisioning handlers. |
|
|
| `internal/db/queries/clients.sql` | Add client-specific sqlc queries (lookup by slug+type, capacity checks) |
|
|
| `internal/db/queries/entities.sql` | Add provision validation queries (VMID collision, IP collision, template availability) |
|
|
| `internal/mcp/server.go` | Register whoami, explain, preflight, get_change_history, get_state_snapshot, list_my_secrets |
|
|
| `internal/secrets/infisical.go` | Add `CreateMachineIdentity`, `DeleteMachineIdentity`, `StoreClientKey` |
|
|
| `internal/actuator/actuator.go` | Add `ProvisionLXC`, `ProvisionVM` methods (pct create, start, exec, health poll) |
|
|
| `internal/ontology/validate.go` | Implement lifecycle transition checks for infrastructure lifecycle (both workstation and compute variants) |
|
|
| `seeds/ontology.yaml` | Add workstation-specific and compute-entity attribute schemas. Add provision-entity skill definition. |
|
|
| `migrations/012_client_enrollment.up.sql` | Index for slug+type lookups. Add `provisioning_steps` tracking table. |
|
|
| `tools/context-poller.sh` | New: lightweight daemon that polls `GET /context` and applies deltas |
|
|
| `AGENTS.md` | Update MCP tool list. Document thin client model. |
|
|
| `CLIENTS.md` | Update enrollment flow: thin client, API-based, no git clone. |
|
|
| `CONTRIBUTING.md` | Add compute entity provisioning as an extension point. |
|
|
|
|
## Files deleted or deprecated
|
|
|
|
| File | Disposition |
|
|
|------|-------------|
|
|
| `archive/secrets-issuance/` | Already archived. Add deprecation notice referencing this plan. |
|
|
| `archive/secrets-sops-backup/` | Keep for DR. SOPS backups served via `GET /api/v1/secrets/sops-backups`. |
|
|
| `scripts/sync/` (install.sh, linux/*, macos/*) | Deprecated. Replaced by `tools/context-poller.sh`. |
|
|
| `tools/post-pull.sh` | Keep on control plane host only. Remove from thin client distribution. |
|
|
| Any reference to `bin/homelab` | Delete or comment out in bootstrap.sh; CLI doesn't exist. |
|
|
| `tools/*.setup.sh` references | Either create the files or remove the auto-setup convention. Context poller can trigger setup scripts on delta. |
|
|
| Root `inventory.yaml` | Eventually deprecated. `seeds/inventory.yaml` + DB are authoritative. Keep during transition. |
|
|
|
|
## Phased implementation
|
|
|
|
### Phase 1 — API + DB (P0, this week)
|
|
|
|
1. Write `migrations/012_client_enrollment.up.sql` (add `provisioning_steps` table)
|
|
2. Add workstation + compute entity endpoints to `api/openapi.yaml`
|
|
3. Run `make generate`
|
|
4. Implement enrollment handler (`POST /api/v1/clients/enroll`):
|
|
- Age key generation
|
|
- Infisical machine identity creation
|
|
- Entity attribute update
|
|
- Audit log write
|
|
5. Implement workstation lifecycle transition handlers (activate, deprecate, destroy, fail)
|
|
6. Implement `GET /api/v1/clients/{slug}/secrets`
|
|
7. Implement `GET /api/v1/clients/{slug}/context` (agent file deltas)
|
|
8. Update `seeds/ontology.yaml` with workstation and compute attribute schemas
|
|
9. Add sqlc queries in `internal/db/queries/clients.sql`
|
|
|
|
### Phase 2 — Compute entity provisioning (P0, this week)
|
|
|
|
1. Implement `POST /api/v1/entities/provision`:
|
|
- Entity creation in `planned` state
|
|
- Constraint validation (VMID, IP, capacity, template, mounts)
|
|
- Policy classification + operator approval flow
|
|
- Transition to `provisioning` on approval
|
|
2. Implement actuator provisioning methods:
|
|
- `ProvisionLXC(slug, attributes)` → `pct create`, `pct start`, package install, mount, service enable
|
|
- `ProvisionVM(slug, attributes)` → `qm create`, `qm start`
|
|
3. Implement `GET /api/v1/entities/{slug}/provision/status`
|
|
4. Implement `POST /api/v1/entities/{slug}/provision/retry`
|
|
5. Write relationship edges atomically on provision completion
|
|
6. Add validation queries for collision detection
|
|
|
|
### Phase 3 — MCP tools (P1, next week)
|
|
|
|
1. Register `whoami(hostname)` in `internal/mcp/server.go`
|
|
2. Register `explain(service)` — compact context card from DB
|
|
3. Register `preflight(service)` — risk classification
|
|
4. Register `get_change_history(entity, limit)`
|
|
5. Register `get_state_snapshot()`
|
|
6. Register `list_my_secrets(caller_pubkey?)`
|
|
|
|
### Phase 4 — Thin client distribution (P1, next week)
|
|
|
|
1. Write `tools/context-poller.sh` — polls `GET /context`, writes deltas, triggers setup scripts
|
|
2. Rewrite `bootstrap.sh`:
|
|
- Fetch AGENTS.md, OIKOS.md, tools/ from raw Gitea URL (not full clone)
|
|
- Call `POST /api/v1/clients/enroll`
|
|
- Install context poller (cron/launchd)
|
|
- Remove git dependency for workstations
|
|
3. Create missing `tools/setup-caveman.sh` and `tools/setup-hermes-soul.sh` (or serve from context endpoint)
|
|
4. Test full enrollment on a fresh machine
|
|
|
|
### Phase 5 — Transition check enforcement (P2, within 2 weeks)
|
|
|
|
1. Implement all `provisioning → active` checks (workstation and compute variants)
|
|
2. Implement all `deprecated → destroyed` checks (workstation and compute variants)
|
|
3. Wire checks into lifecycle transition handlers
|
|
4. Test that `POST /activate` fails when checks don't pass
|
|
5. Test that `POST /destroy` fails when inbound edges exist
|
|
6. Test that provision fails when VMID/IP collision detected
|
|
|
|
### Phase 6 — Cleanup + existing client migration (P2, within 2 weeks)
|
|
|
|
1. Delete or comment-out dead code in bootstrap.sh
|
|
2. Deprecate `scripts/sync/` (install.sh, systemd timer, launchd plist)
|
|
3. Keep `tools/post-pull.sh` for control plane host only
|
|
4. Update AGENTS.md MCP tool list + thin client model
|
|
5. Update CLIENTS.md enrollment flow
|
|
6. Archive Python secrets-issuance with final deprecation note
|
|
7. Add backward-compat: existing clients with full clones continue working;
|
|
context poller runs alongside until operator removes the clone
|
|
8. Run `make generate-check` and full test suite
|
|
|
|
## Verification
|
|
|
|
### Workstation enrollment
|
|
- Fresh machine with no prior state: `curl bootstrap.sh | sudo bash` → fetches
|
|
agent files only (no git clone), enrolls via API, machine shows up in DB as
|
|
`provisioning` with age pubkey and Infisical identity
|
|
- Context poller running: `GET /context?since=...` returns file deltas
|
|
- `POST /api/v1/clients/ws:test-machine/activate` → state → `active`, all checks pass
|
|
- `POST /api/v1/clients/ws:test-machine/deprecate` → state → `deprecated`
|
|
- `POST /api/v1/clients/ws:test-machine/destroy` → fails if edges exist; succeeds
|
|
after edges removed, secrets revoked
|
|
- MCP `whoami(ws:test-machine)` returns client record with peers and health
|
|
|
|
### Compute entity provisioning
|
|
- `POST /api/v1/entities/provision` with valid LXC spec → entity created in
|
|
`planned`, approval requested, on approval → `provisioning`, actuator creates
|
|
LXC on host, health check passes → `active`
|
|
- `POST /api/v1/entities/provision` with conflicting VMID → 409, error message
|
|
with conflicting entity slug
|
|
- `POST /api/v1/entities/provision` with IP already in use → 409
|
|
- `POST /api/v1/entities/provision` with unknown host → 400
|
|
- Relationship edges created: `hosts`, `provides`, `mounts`, `depends-on`
|
|
all present after provisioning
|
|
- `GET /entities/lxc:test-lxc/blast-radius` → returns host, dependent services
|
|
- `POST /destroy` on a provisioned LXC → validates backups + edges, deletes
|
|
relationships, transitions to `destroyed`
|
|
|
|
### Integrated
|
|
- MCP `explain(service:caddy)` returns context card with relations and risk class
|
|
- `GET /api/v1/clients/ws:test-machine/secrets` returns secrets list scoped to client
|
|
- Thin client survives Infisical outage: SOPS fallback works with local age key + `.sops.yaml`
|
|
served via context endpoint
|
|
- Existing clients with full clones continue working (no regression)
|
|
- `make test test-db generate-check` passes
|
|
|
|
## Related
|
|
|
|
- [2026-07-07-migrate-bin-homelab-to-go.md](2026-07-07-migrate-bin-homelab-to-go.md) — MCP tool completion plan (whoami, explain, preflight)
|
|
- [seeds/ontology.yaml](../seeds/ontology.yaml) — lifecycle definitions, entity type hierarchy
|
|
- [seeds/policy.yaml](../seeds/policy.yaml) — risk classes, approval rules
|
|
- [CLIENTS.md](../CLIENTS.md) — client onboarding guide (update after this plan)
|
|
- [bootstrap.sh](../bootstrap.sh) — current enrollment script (rewrite in Phase 4)
|
|
|
|
## Changelog
|
|
|
|
- 2026-07-07 rev 2 — added thin-client API distribution model (no git clone,
|
|
no sync timer; context poller replaces 5-minute pull). Added compute entity
|
|
provisioning flow (LXC/VM/container created by Oikos actuator, not
|
|
self-enrolled). Extended state machine to cover both onboarding paths with
|
|
type-specific transition checks, attribute schemas, and relationship edges.
|
|
- 2026-07-07 rev 1 — initial plan. Replaces Python secrets-issuance, defines
|
|
full workstation lifecycle in Go, adds client API endpoints, MCP tools,
|
|
and Infisical machine identity integration. |