Files
oikos/docs/adr/0011-client-lifecycle-flows.md
dtoro 5b22f2367b
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
test: e2e client lifecycle + ADRs with sequence diagrams
- client_lifecycle_test.go: full end-to-end integration test
  planned → provisioning (enroll) → active → migrating → active →
  deprecated → failed. Validates age keypair generation, attrs,
  context/secrets endpoints, invalid transition blocking, compute
  entity provisioning with relationship edges and status tracking.
  Also tests enrollment rejection for invalid states and duplicate
  slug rejection for provisioning.

- adr/0011-client-lifecycle-flows.md: workstation self-enrollment,
  compute entity provisioning, deprecation/destruction flows with
  Mermaid sequence diagrams. Full lifecycle state diagram. Transition
  check enforcement documentation.

- adr/0012-hermes-oikos-interactions.md: Hermes ↔ Oikos interaction
  flow through OODA loop phases. Thin client bootstrap. Internal
  component interactions (scheduler, actuator, notifier). Complete
  30-tool ownership matrix.

- Fix: migration 012 FK reference (executions.id → executions.entity_id)
- Fix: provision handler null attributes JSONB
- Fix: provisioning steps use entity_id for execution FK

All 3 integration tests pass, go vet clean.
2026-07-08 00:56:16 +02:00

7.7 KiB

ADR 0011 — Client lifecycle sequence diagrams

Status: Accepted Date: 2026-07-08

Context

The Oikos client lifecycle spans two distinct onboarding paths — workstation self-enrollment and compute entity (LXC/VM) provisioning — sharing the same infrastructure lifecycle state machine (planned → provisioning → active → migrating → deprecated → destroyed). These flows must be documented and validated against the Go implementation.

Decision

All machines in the homelab follow the same lifecycle state machine defined in seeds/ontology.yaml under the infrastructure lifecycle. The implementation lives in internal/httpapi/impl.go (handlers), internal/actuator/actuator.go (SSH provisioning), and internal/ontology/validate.go (transition check enforcement).

Workstation self-enrollment flow

sequenceDiagram
    participant Op as Operator
    participant BS as bootstrap.sh
    participant API as Oikos API (:8090)
    participant DB as Postgres
    participant Inf as Infisical

    Op->>API: POST /entities {slug:"ws:laptop", type:"workstation", state:"planned"}
    API->>DB: INSERT entities (planned)
    API-->>Op: 201 + ETag

    Op->>BS: curl bootstrap.sh | sudo bash
    BS->>BS: detect hostname, mesh IP
    BS->>BS: fetch CLIENTS.md, AGENTS.md, OIKOS.md, tools/
    BS->>BS: install age, curl, jq

    BS->>API: POST /clients/enroll {slug, hostname, mesh_ip}
    API->>DB: validate state = planned|provisioning
    API->>API: generateAgeKeypair()
    API->>API: store age key in Infisical (best-effort)
    API->>DB: UPDATE state→provisioning, set age_pubkey, mesh_ip, enrolled_at
    API->>DB: INSERT audit_log (client.enrolled)
    API-->>BS: {age_private_key, age_public_key, infisical_client_id, infisical_client_secret}

    BS->>BS: write /etc/age/key.txt (0600)
    BS->>BS: write /etc/infisical/identity (0600)
    BS->>BS: install context-poller.sh (launchd/systemd, 5min)

    loop Every 5 minutes
        BS->>API: GET /clients/ws:laptop/context?since={timestamp}
        API->>DB: SELECT context_files, context_version
        API-->>BS: {agent_files_changed, sops_config_changed, tools_changed}
    end

    Op->>API: PATCH /entities/ws:laptop {state:"active"}
    API->>DB: validate lifecyle transition provisioning→active
    API->>DB: UPDATE state→active, version+1
    API->>DB: INSERT audit_log (client.activated)
    API-->>Op: 200 + ETag

Enforced transition checks

Before provisioning → active, the TransitionChecks map in internal/ontology/validate.go validates:

Check Workstation LXC/VM
age-key-enrolled-if-needed Requires age_pubkey in attrs Skipped (no age key for compute entities)
mesh-joined-if-needed Requires mesh_ip in attrs Skipped
health-check-answering Queries entity_status.health != 'down' Same
doc-page-complete Requires at least 1 documents edge Same

Before deprecated → destroyed:

Check Description
no-inbound-edges Zero depends-on, hosts, provides, mounts, routes-to, stores-on edges
secrets-revoked-and-rekeyed age_pubkey must be removed from attributes
backups-verified Audit log must have a backup-verified entry in last 30 days
ingress-and-dns-removed No remaining routes-to/provides/hosts edges

Compute entity provisioning flow

sequenceDiagram
    participant Op as Operator/Hermes
    participant API as Oikos API (:8090)
    participant DB as Postgres
    participant Act as Actuator
    participant PVE as Proxmox Host

    Op->>API: POST /entities/provision {slug:"lxc:jellyfin", host:"host:hubris", attrs:{vmid,cores,...}}
    API->>DB: validate host exists, slug not taken
    API->>DB: INSERT entities (planned)
    API->>DB: INSERT executions (provision)
    API->>DB: INSERT provisioning_steps (6 steps, all pending)
    API->>DB: INSERT relationships (host:hubris hosts lxc:jellyfin)
    API->>DB: INSERT audit_log (entity.provisioned)
    API-->>Op: 201 {entity, execution_id}

    Note over Act,PVE: Actuator loop picks up provisioning execution

    Act->>API: GET execution for lxc:jellyfin
    Act->>DB: resolve host:hubris → (mesh IP, ssh user)

    par Provisioning steps
        Act->>DB: UPDATE provisioning_step[1] (validate-constraints)
        Act->>PVE: ssh: pct status {vmid}
        PVE-->>Act: "does not exist" → ok
        Act->>DB: UPDATE provisioning_step[1] (ok)

        Act->>DB: UPDATE provisioning_step[2] (create-container)
        Act->>PVE: ssh: pct create {vmid} --cores N --memory M --rootfs ...
        PVE-->>Act: container created
        Act->>DB: UPDATE provisioning_step[2] (ok)

        Act->>PVE: ssh: pct exec {vmid} -- apt install -y service1 service2
        Act->>DB: UPDATE provisioning_step[4] (ok)

        Act->>PVE: ssh: pct set {vmid} -mp0 /mnt/library,/mnt/library
        Act->>DB: UPDATE provisioning_step[5] (ok)

        Act->>PVE: ssh: pct exec {vmid} -- systemctl is-system-running
        PVE-->>Act: "running" → ok
        Act->>DB: UPDATE provisioning_step[6] (ok)
    end

    Act->>DB: UPDATE entity state→active
    Act->>DB: UPDATE execution status→completed

Deprecation and destruction flow

sequenceDiagram
    participant Op as Operator
    participant API as Oikos API
    participant DB as Postgres

    Note over Op,DB: Active entity → Deprecated

    Op->>API: PATCH /entities/lxc:jellyfin {state:"deprecated"}
    API->>DB: validate lifecycle transition active→deprecated
    API->>DB: UPDATE state→deprecated
    API-->>Op: 200

    Note over Op,DB: Deprecated → Destroyed (with gate checks)

    Op->>API: PATCH /entities/lxc:jellyfin {state:"destroyed"}
    alt Inbound edges exist
        API->>DB: TransitionChecks["no-inbound-edges"] → count > 0
        API-->>Op: 409 (inbound edges still exist)
    else All checks pass
        API->>DB: TransitionChecks → all pass
        API->>DB: UPDATE state→destroyed
        API->>DB: INSERT audit_log (client.destroyed)
        API-->>Op: 200
    end

Full lifecycle state diagram

stateDiagram-v2
    [*] --> planned : POST /entities

    planned --> provisioning : POST /clients/enroll (workstation)<br/>POST /entities/provision (compute)
    planned --> destroyed : cancelled via PATCH

    provisioning --> active : PATCH state="active"<br/>checks: age-key, mesh, health
    provisioning --> failed : error during provisioning

    active --> migrating : PATCH state="migrating"
    active --> deprecated : PATCH state="deprecated"
    active --> failed : PATCH state="failed"

    migrating --> active : PATCH state="active"
    migrating --> failed : PATCH state="failed"

    failed --> active : PATCH state="active"<br/>check: recovery-verified

    deprecated --> active : PATCH state="active" (un-deprecate)
    deprecated --> destroyed : PATCH state="destroyed"<br/>checks: no-inbound-edges,<br/>secrets-revoked, backups-verified,<br/>ingress-dns-removed

    destroyed --> [*]

Consequences

  • Workstations self-enroll via bootstrap.shPOST /clients/enroll. The age keypair is generated server-side and delivered once.
  • Compute entities are provisioned by the actuator over SSH. The operator declares intent via POST /entities/provision; the actuator executes step-by-step with DB-tracked progress.
  • Transition gates are enforced by named checks in internal/ontology/validate.go. The deprecated → destroyed gate blocks until all inbound edges are severed — preventing orphan references.
  • Thin clients poll GET /clients/{slug}/context for agent file deltas instead of git pull. The control plane host (mac-mini) keeps the full repo clone.