move 5 completed plans to plans/done/
- 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).
This commit is contained in:
741
plans/done/2026-07-07-client-lifecycle-in-go.md
Normal file
741
plans/done/2026-07-07-client-lifecycle-in-go.md
Normal file
@@ -0,0 +1,741 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user