- 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).
37 KiB
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:
- Workstation self-enrollment: the machine calls the API to enroll itself
- 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:
{
"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)
# 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
{
"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
- Slug is available: no existing entity with same slug
- VMID not in use: query entities where
attributes->>'vmid'= requested VMID - IP not in use: query entities where
attributes->>'ip'= requested IP - Host exists and is active:
host:hubrismust be an entity with stateactive - Host has capacity: check entity_status for CPU/RAM/disk headroom on host
- Template exists: Proxmox template must be available on the host
- Mount source exists: storage entity must exist for each mount
- 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 serverstype—workstation,standalone-server, orproxmox-hostname— human-readable namelan_ip— expected LAN IP (reserved in DHCP)os—linuxormacosrole— free-text description of what this machine doesmesh.expected_type—netbirdortailscale(which mesh it will join)ssh.user— login user (defaultroot)
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
{
"slug": "ws:new-laptop",
"hostname": "new-laptop",
"mesh_ip": "100.122.x.x"
}
What the enrollment endpoint does:
- Looks up entity by slug — must exist, must be in state
plannedorprovisioning - Validates mesh IP is in
100.122.0.0/16(Netbird) or100.64.0.0/10(Tailscale) or192.168.8.0/24(LAN) - Validates hostname has no conflicting mesh IP already recorded
- Generates an age keypair (
age-keygen) - Stores the private key in Infisical under path
/clients/<slug>/age-key - Creates an Infisical machine identity for the client (UniversalAuth)
- Updates entity
attributeswithage_pubkey,mesh_ip,enrolled_at - Writes audit log:
client.enrolled - Returns the age private key, Infisical client ID + secret, and machine identity token
Response (to bootstrap.sh, over mesh — TLS + mesh IP validation):
{
"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/enrolltohttps://oikos.hubris.network - Remove
--no-secrets/--no-meshflags (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-emptymesh-joined-if-needed: entity.attributes.mesh_ip is non-emptyingress-live-if-public: skipped for workstations (no public ingress)health-check-answering: scheduler probe passes for this entitydoc-page-complete: entity has at least onedocumentsedgeinventory-in-db: entity exists in DB with all required attributes
API: POST /api/v1/clients/{slug}/activate
- Validates all
provisioning → activetransition checks - Sets state to
active - Writes audit log:
client.activated
MCP tools active clients get:
whoami(hostname)— returns entity record, peers, secrets list, healthlist_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 → destroyedtransition checks:backups-verified: any data on this client was backed upsecrets-revoked-and-rekeyed: age key removed from Infisical, SOPS recipients updated, machine identity deletedingress-and-dns-removed: no remaining DNS records or Caddy backendsno-inbound-edges: zerodepends-on,hosts,provides,mountsedges pointing to this entityarchaeology-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
reasonfield 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
- # 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:
-- 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)
{
"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)
- Write
migrations/012_client_enrollment.up.sql(addprovisioning_stepstable) - Add workstation + compute entity endpoints to
api/openapi.yaml - Run
make generate - Implement enrollment handler (
POST /api/v1/clients/enroll):- Age key generation
- Infisical machine identity creation
- Entity attribute update
- Audit log write
- Implement workstation lifecycle transition handlers (activate, deprecate, destroy, fail)
- Implement
GET /api/v1/clients/{slug}/secrets - Implement
GET /api/v1/clients/{slug}/context(agent file deltas) - Update
seeds/ontology.yamlwith workstation and compute attribute schemas - Add sqlc queries in
internal/db/queries/clients.sql
Phase 2 — Compute entity provisioning (P0, this week)
- Implement
POST /api/v1/entities/provision:- Entity creation in
plannedstate - Constraint validation (VMID, IP, capacity, template, mounts)
- Policy classification + operator approval flow
- Transition to
provisioningon approval
- Entity creation in
- Implement actuator provisioning methods:
ProvisionLXC(slug, attributes)→pct create,pct start, package install, mount, service enableProvisionVM(slug, attributes)→qm create,qm start
- Implement
GET /api/v1/entities/{slug}/provision/status - Implement
POST /api/v1/entities/{slug}/provision/retry - Write relationship edges atomically on provision completion
- Add validation queries for collision detection
Phase 3 — MCP tools (P1, next week)
- Register
whoami(hostname)ininternal/mcp/server.go - Register
explain(service)— compact context card from DB - Register
preflight(service)— risk classification - Register
get_change_history(entity, limit) - Register
get_state_snapshot() - Register
list_my_secrets(caller_pubkey?)
Phase 4 — Thin client distribution (P1, next week)
- Write
tools/context-poller.sh— pollsGET /context, writes deltas, triggers setup scripts - 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
- Create missing
tools/setup-caveman.shandtools/setup-hermes-soul.sh(or serve from context endpoint) - Test full enrollment on a fresh machine
Phase 5 — Transition check enforcement (P2, within 2 weeks)
- Implement all
provisioning → activechecks (workstation and compute variants) - Implement all
deprecated → destroyedchecks (workstation and compute variants) - Wire checks into lifecycle transition handlers
- Test that
POST /activatefails when checks don't pass - Test that
POST /destroyfails when inbound edges exist - Test that provision fails when VMID/IP collision detected
Phase 6 — Cleanup + existing client migration (P2, within 2 weeks)
- Delete or comment-out dead code in bootstrap.sh
- Deprecate
scripts/sync/(install.sh, systemd timer, launchd plist) - Keep
tools/post-pull.shfor control plane host only - Update AGENTS.md MCP tool list + thin client model
- Update CLIENTS.md enrollment flow
- Archive Python secrets-issuance with final deprecation note
- Add backward-compat: existing clients with full clones continue working; context poller runs alongside until operator removes the clone
- Run
make generate-checkand 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 asprovisioningwith 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 passPOST /api/v1/clients/ws:test-machine/deprecate→ state →deprecatedPOST /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/provisionwith valid LXC spec → entity created inplanned, approval requested, on approval →provisioning, actuator creates LXC on host, health check passes →activePOST /api/v1/entities/provisionwith conflicting VMID → 409, error message with conflicting entity slugPOST /api/v1/entities/provisionwith IP already in use → 409POST /api/v1/entities/provisionwith unknown host → 400- Relationship edges created:
hosts,provides,mounts,depends-onall present after provisioning GET /entities/lxc:test-lxc/blast-radius→ returns host, dependent servicesPOST /destroyon a provisioned LXC → validates backups + edges, deletes relationships, transitions todestroyed
Integrated
- MCP
explain(service:caddy)returns context card with relations and risk class GET /api/v1/clients/ws:test-machine/secretsreturns secrets list scoped to client- Thin client survives Infisical outage: SOPS fallback works with local age key +
.sops.yamlserved via context endpoint - Existing clients with full clones continue working (no regression)
make test test-db generate-checkpasses
Related
- 2026-07-07-migrate-bin-homelab-to-go.md — MCP tool completion plan (whoami, explain, preflight)
- seeds/ontology.yaml — lifecycle definitions, entity type hierarchy
- seeds/policy.yaml — risk classes, approval rules
- CLIENTS.md — client onboarding guide (update after this plan)
- 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.