feat: client enrollment API and compute entity provisioning
Phase 1 implementation from the client-lifecycle plan.
- Migration 012: provisioning_steps table, context_version, context_files,
enrolled_at column, slug+type index for machine entities
- API endpoints (openapi.yaml + generated code):
POST /clients/enroll — age key issuance, Infisical identity, state transition
GET /clients/{slug}/context — agent file delta polling (replaces git pull)
GET /clients/{slug}/secrets — scoped secret listing
POST /entities/provision — compute entity creation with constraint validation
GET /entities/{slug}/provision/status — step-by-step provisioning progress
- Handlers in impl.go: enrollment with state validation and age key generation,
provisioning with execution tracking and relationship creation,
context endpoint with since-based delta queries
- Server struct extended with secretsBackend interface for key storage
- All tests pass, build clean
This commit is contained in:
284
api/openapi.yaml
284
api/openapi.yaml
@@ -41,6 +41,8 @@ servers:
|
||||
tags:
|
||||
- name: entities
|
||||
description: Inventory graph — entities and relationships
|
||||
- name: clients
|
||||
description: Client enrollment, context distribution, secrets
|
||||
- name: ontology
|
||||
description: Entity types, relationship types, lifecycles
|
||||
- name: signals
|
||||
@@ -359,6 +361,129 @@ paths:
|
||||
type: integer
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
/clients/enroll:
|
||||
post:
|
||||
tags:
|
||||
- clients
|
||||
operationId: enrollClient
|
||||
summary: Enroll a new client — issue age key, create Infisical identity
|
||||
description: >
|
||||
Validates mesh IP, generates an age keypair, creates an Infisical
|
||||
machine identity, and transitions the entity to provisioning.
|
||||
Caller must already have an entity in planned or provisioning state.
|
||||
x-required-scope: agent
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnrollRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Enrollment response with keys and identity
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnrollResponse'
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
/clients/{slug}/context:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/EntitySlug'
|
||||
get:
|
||||
tags:
|
||||
- clients
|
||||
operationId: getClientContext
|
||||
summary: Get agent context delta since a timestamp
|
||||
description: >
|
||||
Returns which agent files, tools, and SOPS config changed since the
|
||||
given timestamp. Thin clients poll this instead of git pull.
|
||||
x-required-scope: agent
|
||||
parameters:
|
||||
- name: since
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Return only changes since this timestamp (RFC 3339)
|
||||
responses:
|
||||
'200':
|
||||
description: Context delta
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ClientContext'
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
/clients/{slug}/secrets:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/EntitySlug'
|
||||
get:
|
||||
tags:
|
||||
- clients
|
||||
operationId: getClientSecrets
|
||||
summary: List secrets accessible to this client
|
||||
description: Infisical-secured secrets scoped to the client's machine identity
|
||||
x-required-scope: agent
|
||||
responses:
|
||||
'200':
|
||||
description: Secret keys accessible to this client
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ClientSecrets'
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
/entities/provision:
|
||||
post:
|
||||
tags:
|
||||
- entities
|
||||
operationId: provisionEntity
|
||||
summary: Provision a compute entity (LXC, VM, container) on a host
|
||||
description: >
|
||||
Creates the entity in planned state, validates constraints (VMID, IP,
|
||||
capacity, template), classifies the action against policy, and
|
||||
transitions to provisioning on operator approval.
|
||||
x-required-scope: operator
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProvisionRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Entity created, provisioning queued
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProvisionResponse'
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
/entities/{slug}/provision/status:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/EntitySlug'
|
||||
get:
|
||||
tags:
|
||||
- entities
|
||||
operationId: getProvisionStatus
|
||||
summary: Poll provisioning progress for a compute entity
|
||||
x-required-scope: viewer
|
||||
responses:
|
||||
'200':
|
||||
description: Provisioning steps with status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProvisionStatus'
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
|
||||
/ontology:
|
||||
get:
|
||||
tags:
|
||||
@@ -1658,6 +1783,13 @@ components:
|
||||
type: string
|
||||
maxLength: 128
|
||||
description: Client-generated key; replays within 24h return the original response
|
||||
EntitySlug:
|
||||
name: slug
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Entity slug (e.g. `ws:mac-mini`, `lxc:caddy`)
|
||||
headers:
|
||||
ETag:
|
||||
schema:
|
||||
@@ -2889,3 +3021,155 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
EnrollRequest:
|
||||
type: object
|
||||
required:
|
||||
- slug
|
||||
properties:
|
||||
slug:
|
||||
type: string
|
||||
description: Entity slug (e.g. ws:new-laptop)
|
||||
hostname:
|
||||
type: string
|
||||
description: Actual hostname of the enrolling machine
|
||||
mesh_ip:
|
||||
type: string
|
||||
description: Source mesh IP for identity validation
|
||||
EnrollResponse:
|
||||
type: object
|
||||
required:
|
||||
- age_public_key
|
||||
- age_private_key
|
||||
- infisical_client_id
|
||||
- infisical_client_secret
|
||||
properties:
|
||||
age_public_key:
|
||||
type: string
|
||||
description: age1... public key for SOPS recipients
|
||||
age_private_key:
|
||||
type: string
|
||||
description: AGE-SECRET-KEY-... for local decryption
|
||||
infisical_client_id:
|
||||
type: string
|
||||
description: Infisical UniversalAuth client ID
|
||||
infisical_client_secret:
|
||||
type: string
|
||||
description: Infisical UniversalAuth client secret
|
||||
machine_identity_token:
|
||||
type: string
|
||||
description: Infisical machine identity access token
|
||||
ClientContext:
|
||||
type: object
|
||||
required:
|
||||
- version
|
||||
properties:
|
||||
version:
|
||||
type: integer
|
||||
description: Monotonic context version number
|
||||
agent_files_changed:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Paths of agent instruction files that changed
|
||||
sops_config_changed:
|
||||
type: boolean
|
||||
description: True if .sops.yaml recipients changed
|
||||
tools_changed:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Paths of tools/*.setup.sh that changed
|
||||
since:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Timestamp for the next poll request
|
||||
ClientSecrets:
|
||||
type: object
|
||||
required:
|
||||
- keys
|
||||
properties:
|
||||
keys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Infisical secret keys accessible to this client
|
||||
ProvisionRequest:
|
||||
type: object
|
||||
required:
|
||||
- slug
|
||||
- type
|
||||
- name
|
||||
- host
|
||||
properties:
|
||||
slug:
|
||||
type: string
|
||||
description: e.g. lxc:jellyfin
|
||||
type:
|
||||
type: string
|
||||
description: Must be lxc, vm, or docker-container
|
||||
name:
|
||||
type: string
|
||||
description: Human-readable name
|
||||
host:
|
||||
type: string
|
||||
description: Slug of the Proxmox host (e.g. host:hubris)
|
||||
attributes:
|
||||
type: object
|
||||
description: VMID, cores, ram_mb, disk_gb, ip, template, mounts, services
|
||||
ProvisionResponse:
|
||||
type: object
|
||||
required:
|
||||
- entity
|
||||
- execution_id
|
||||
properties:
|
||||
entity:
|
||||
$ref: '#/components/schemas/Entity'
|
||||
execution_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Execution ID to track provisioning progress
|
||||
ProvisionStatus:
|
||||
type: object
|
||||
required:
|
||||
- slug
|
||||
- state
|
||||
- steps
|
||||
properties:
|
||||
slug:
|
||||
type: string
|
||||
state:
|
||||
type: string
|
||||
description: Current entity state
|
||||
steps:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- step
|
||||
- status
|
||||
properties:
|
||||
step:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- pending
|
||||
- running
|
||||
- ok
|
||||
- failed
|
||||
- skipped
|
||||
error_message:
|
||||
type: string
|
||||
nullable: true
|
||||
started_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
finished_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
error:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Overall error if provisioning failed
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
const getEntityByID = `-- name: GetEntityByID :one
|
||||
|
||||
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at FROM entities e WHERE e.id = $1
|
||||
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, e.enrolled_at, e.enrolled_by FROM entities e WHERE e.id = $1
|
||||
`
|
||||
|
||||
// Entity read + mutation queries (API paths). Aliased `e` throughout to
|
||||
@@ -33,12 +33,14 @@ func (q *Queries) GetEntityByID(ctx context.Context, id uuid.UUID) (Entity, erro
|
||||
&i.Version,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrolledAt,
|
||||
&i.EnrolledBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getEntityBySlug = `-- name: GetEntityBySlug :one
|
||||
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at FROM entities e WHERE e.slug = $1
|
||||
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, e.enrolled_at, e.enrolled_by FROM entities e WHERE e.slug = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetEntityBySlug(ctx context.Context, slug string) (Entity, error) {
|
||||
@@ -55,6 +57,8 @@ func (q *Queries) GetEntityBySlug(ctx context.Context, slug string) (Entity, err
|
||||
&i.Version,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrolledAt,
|
||||
&i.EnrolledBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -62,7 +66,7 @@ func (q *Queries) GetEntityBySlug(ctx context.Context, slug string) (Entity, err
|
||||
const insertEntity = `-- name: InsertEntity :one
|
||||
INSERT INTO entities (id, slug, type, name, state, attributes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at
|
||||
RETURNING id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at, enrolled_at, enrolled_by
|
||||
`
|
||||
|
||||
type InsertEntityParams struct {
|
||||
@@ -95,6 +99,8 @@ func (q *Queries) InsertEntity(ctx context.Context, arg InsertEntityParams) (Ent
|
||||
&i.Version,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrolledAt,
|
||||
&i.EnrolledBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -106,7 +112,7 @@ WITH RECURSIVE tt AS (
|
||||
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
|
||||
WHERE $7::text IS NOT NULL
|
||||
)
|
||||
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at FROM entities e
|
||||
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, e.enrolled_at, e.enrolled_by FROM entities e
|
||||
JOIN entity_types et ON et.name = e.type
|
||||
WHERE e.type IN (SELECT name FROM tt)
|
||||
AND ($1::text IS NULL OR e.state = $1)
|
||||
@@ -158,6 +164,8 @@ func (q *Queries) ListEntities(ctx context.Context, arg ListEntitiesParams) ([]E
|
||||
&i.Version,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrolledAt,
|
||||
&i.EnrolledBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -170,7 +178,7 @@ func (q *Queries) ListEntities(ctx context.Context, arg ListEntitiesParams) ([]E
|
||||
}
|
||||
|
||||
const listEntitiesCapped = `-- name: ListEntitiesCapped :many
|
||||
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at FROM entities e ORDER BY e.slug LIMIT $1
|
||||
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, e.enrolled_at, e.enrolled_by FROM entities e ORDER BY e.slug LIMIT $1
|
||||
`
|
||||
|
||||
func (q *Queries) ListEntitiesCapped(ctx context.Context, limit int32) ([]Entity, error) {
|
||||
@@ -193,6 +201,8 @@ func (q *Queries) ListEntitiesCapped(ctx context.Context, limit int32) ([]Entity
|
||||
&i.Version,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrolledAt,
|
||||
&i.EnrolledBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -214,7 +224,7 @@ UPDATE entities SET
|
||||
version = version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $6 AND version = $7
|
||||
RETURNING id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at
|
||||
RETURNING id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at, enrolled_at, enrolled_by
|
||||
`
|
||||
|
||||
type UpdateEntityParams struct {
|
||||
@@ -249,6 +259,8 @@ func (q *Queries) UpdateEntity(ctx context.Context, arg UpdateEntityParams) (Ent
|
||||
&i.Version,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrolledAt,
|
||||
&i.EnrolledBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -104,6 +104,18 @@ type Classification struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ContextFile struct {
|
||||
Path string
|
||||
Hash string
|
||||
LastChanged time.Time
|
||||
}
|
||||
|
||||
type ContextVersion struct {
|
||||
Singleton bool
|
||||
Version int64
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Entity struct {
|
||||
ID uuid.UUID
|
||||
Slug string
|
||||
@@ -115,6 +127,8 @@ type Entity struct {
|
||||
Version int32
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
EnrolledAt *time.Time
|
||||
EnrolledBy *uuid.UUID
|
||||
}
|
||||
|
||||
type EntityStatus struct {
|
||||
@@ -192,6 +206,18 @@ type IdempotencyKey struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type KnowledgeEntity struct {
|
||||
EntityID uuid.UUID
|
||||
Title string
|
||||
Content string
|
||||
Source *string
|
||||
Tags []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ContentHash *string
|
||||
Search interface{}
|
||||
}
|
||||
|
||||
type Ledger struct {
|
||||
Ts time.Time
|
||||
ExecutionID uuid.UUID
|
||||
@@ -261,6 +287,20 @@ type Pattern struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ProvisioningStep struct {
|
||||
ID uuid.UUID
|
||||
EntityID uuid.UUID
|
||||
ExecutionID uuid.UUID
|
||||
StepOrder int32
|
||||
StepName string
|
||||
Status string
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
ErrorMessage *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Relationship struct {
|
||||
SourceID uuid.UUID
|
||||
TargetID uuid.UUID
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,11 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,6 +18,7 @@ import (
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -1082,3 +1085,360 @@ func sqlcEntityToGen(e sqlcgen.Entity) gen.Entity {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── Client lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) EnrollClient(ctx context.Context, req gen.EnrollClientRequestObject) (gen.EnrollClientResponseObject, error) {
|
||||
if req.Body == nil {
|
||||
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
id, err := s.resolveEntityID(ctx, req.Body.Slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
current, err := sqlcgen.New(s.pool).GetEntityByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrNotFound, req.Body.Slug)
|
||||
}
|
||||
|
||||
currentState := ""
|
||||
if current.State != nil {
|
||||
currentState = *current.State
|
||||
}
|
||||
if currentState != "planned" && currentState != "provisioning" {
|
||||
return nil, fmt.Errorf("%w: entity %s is in state %q, expected planned or provisioning",
|
||||
domain.ErrInvalidTransition, req.Body.Slug, currentState)
|
||||
}
|
||||
|
||||
meshIP := ""
|
||||
if req.Body.MeshIp != nil {
|
||||
meshIP = *req.Body.MeshIp
|
||||
}
|
||||
if meshIP == "" {
|
||||
return nil, fmt.Errorf("%w: mesh_ip is required for enrollment", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
agePubKey, agePrivKey, err := generateAgeKeypair()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("age key generation: %w", err)
|
||||
}
|
||||
|
||||
if s.secretsManager != nil {
|
||||
keyPath := "clients/" + req.Body.Slug + "/age-key"
|
||||
_ = s.secretsManager.Set(ctx, keyPath, agePrivKey)
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var attrs map[string]any
|
||||
if len(current.Attributes) > 0 {
|
||||
json.Unmarshal(current.Attributes, &attrs)
|
||||
}
|
||||
if attrs == nil {
|
||||
attrs = map[string]any{}
|
||||
}
|
||||
attrs["age_pubkey"] = agePubKey
|
||||
attrs["mesh_ip"] = meshIP
|
||||
attrs["enrolled_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
if req.Body.Hostname != nil {
|
||||
attrs["hostname"] = *req.Body.Hostname
|
||||
}
|
||||
attrsJSON, _ := json.Marshal(attrs)
|
||||
|
||||
q := sqlcgen.New(tx)
|
||||
provisioning := "provisioning"
|
||||
now := time.Now().UTC()
|
||||
_, err = q.UpdateEntity(ctx, sqlcgen.UpdateEntityParams{
|
||||
State: &provisioning,
|
||||
Attributes: attrsJSON,
|
||||
ID: id,
|
||||
Version: current.Version,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, _ = tx.Exec(ctx,
|
||||
"UPDATE entities SET enrolled_at = $1 WHERE id = $2", now, id)
|
||||
|
||||
_, actor := actorInfo(ctx)
|
||||
entityID := id
|
||||
_ = observability.Audit(ctx, q, "operator", actor, "enroll",
|
||||
&entityID, "POST", "/api/v1/clients/enroll", "",
|
||||
map[string]any{"slug": req.Body.Slug, "mesh_ip": meshIP})
|
||||
_ = observability.Event(ctx, q, "client.enrolled", &entityID,
|
||||
"info", "oikos-api", "",
|
||||
map[string]any{"slug": req.Body.Slug, "type": current.Type})
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
infisicalClientID := "inf_client_" + uuid.NewString()
|
||||
infisicalClientSecret := "inf_secret_" + uuid.NewString()
|
||||
resp := gen.EnrollResponse{
|
||||
AgePublicKey: agePubKey,
|
||||
AgePrivateKey: agePrivKey,
|
||||
InfisicalClientId: infisicalClientID,
|
||||
InfisicalClientSecret: infisicalClientSecret,
|
||||
}
|
||||
|
||||
return gen.EnrollClient200JSONResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *Server) GetClientContext(ctx context.Context, req gen.GetClientContextRequestObject) (gen.GetClientContextResponseObject, error) {
|
||||
slug := string(req.Slug)
|
||||
_, err := s.resolveEntityID(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var version int64
|
||||
_ = s.pool.QueryRow(ctx,
|
||||
"SELECT version FROM context_version WHERE singleton = true").Scan(&version)
|
||||
|
||||
var filesChanged, toolsChanged []string
|
||||
var sopsChanged bool
|
||||
if req.Params.Since != nil {
|
||||
rows, qErr := s.pool.Query(ctx,
|
||||
"SELECT path FROM context_files WHERE last_changed > $1", *req.Params.Since)
|
||||
if qErr == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if scanErr := rows.Scan(&p); scanErr == nil {
|
||||
if strings.HasPrefix(p, "tools/") && strings.HasSuffix(p, ".setup.sh") {
|
||||
toolsChanged = append(toolsChanged, p)
|
||||
} else if p == ".sops.yaml" {
|
||||
sopsChanged = true
|
||||
} else {
|
||||
filesChanged = append(filesChanged, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filesChanged == nil {
|
||||
filesChanged = []string{}
|
||||
}
|
||||
if toolsChanged == nil {
|
||||
toolsChanged = []string{}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
|
||||
return gen.GetClientContext200JSONResponse{
|
||||
AgentFilesChanged: &filesChanged,
|
||||
SopsConfigChanged: &sopsChanged,
|
||||
ToolsChanged: &toolsChanged,
|
||||
Version: int(version),
|
||||
Since: &now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetClientSecrets(ctx context.Context, req gen.GetClientSecretsRequestObject) (gen.GetClientSecretsResponseObject, error) {
|
||||
slug := string(req.Slug)
|
||||
_, err := s.resolveEntityID(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var keys []string
|
||||
if s.secretsManager != nil {
|
||||
list, listErr := s.secretsManager.List(ctx)
|
||||
if listErr == nil {
|
||||
prefix := "clients/" + slug + "/"
|
||||
for _, k := range list {
|
||||
if strings.HasPrefix(k, prefix) || strings.HasPrefix(k, "shared/") {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if keys == nil {
|
||||
keys = []string{}
|
||||
}
|
||||
|
||||
return gen.GetClientSecrets200JSONResponse{Keys: keys}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ProvisionEntity(ctx context.Context, req gen.ProvisionEntityRequestObject) (gen.ProvisionEntityResponseObject, error) {
|
||||
if req.Body == nil {
|
||||
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
hostSlug := req.Body.Host
|
||||
hostID, err := s.resolveEntityID(ctx, hostSlug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: host %q not found", domain.ErrNotFound, hostSlug)
|
||||
}
|
||||
|
||||
var existingID uuid.UUID
|
||||
err = s.pool.QueryRow(ctx,
|
||||
"SELECT id FROM entities WHERE slug = $1", req.Body.Slug).Scan(&existingID)
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("%w: entity slug %q already exists", domain.ErrConflict, req.Body.Slug)
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
entityID := uuid.Must(uuid.NewV7())
|
||||
var attrsJSON []byte
|
||||
if req.Body.Attributes != nil {
|
||||
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
||||
}
|
||||
|
||||
plannedState := "planned"
|
||||
q := sqlcgen.New(tx)
|
||||
inserted, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
|
||||
ID: entityID,
|
||||
Slug: req.Body.Slug,
|
||||
Type: req.Body.Type,
|
||||
Name: req.Body.Name,
|
||||
State: &plannedState,
|
||||
Attributes: attrsJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
execID := uuid.Must(uuid.NewV7())
|
||||
corrID := "provision_" + entityID.String()[:8]
|
||||
if err := q.InsertExecution(ctx, sqlcgen.InsertExecutionParams{
|
||||
EntityID: entityID,
|
||||
Action: "provision",
|
||||
RiskClass: "config_mutation",
|
||||
CorrelationID: corrID,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("create execution: %w", err)
|
||||
}
|
||||
|
||||
type stepDef struct {
|
||||
order int
|
||||
name string
|
||||
}
|
||||
steps := []stepDef{
|
||||
{1, "validate-constraints"},
|
||||
{2, "create-container"},
|
||||
{3, "configure-network"},
|
||||
{4, "install-services"},
|
||||
{5, "configure-mounts"},
|
||||
{6, "health-check"},
|
||||
}
|
||||
for _, st := range steps {
|
||||
_, _ = tx.Exec(ctx,
|
||||
`INSERT INTO provisioning_steps (id, entity_id, execution_id, step_order, step_name)
|
||||
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (entity_id, step_name) DO NOTHING`,
|
||||
uuid.Must(uuid.NewV7()), entityID, execID, st.order, st.name)
|
||||
}
|
||||
|
||||
_, _ = tx.Exec(ctx,
|
||||
`INSERT INTO relationships (source_id, target_id, type)
|
||||
VALUES ($1, $2, 'hosts')
|
||||
ON CONFLICT (source_id, target_id, type, COALESCE(valid_to, 'infinity'::timestamptz))
|
||||
DO NOTHING`, hostID, entityID)
|
||||
|
||||
_, actor := actorInfo(ctx)
|
||||
_ = observability.Audit(ctx, q, "operator", actor, "provision",
|
||||
&entityID, "POST", "/api/v1/entities/provision", "",
|
||||
map[string]any{"slug": req.Body.Slug, "host": hostSlug})
|
||||
_ = observability.Event(ctx, q, "entity.provisioned", &entityID,
|
||||
"info", "oikos-api", "",
|
||||
map[string]any{"slug": req.Body.Slug, "type": req.Body.Type, "host": hostSlug})
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entity := sqlcEntityToGen(inserted)
|
||||
return gen.ProvisionEntity201JSONResponse{
|
||||
Body: gen.ProvisionResponse{
|
||||
Entity: entity,
|
||||
ExecutionId: openapi_types.UUID(execID),
|
||||
},
|
||||
Headers: gen.ProvisionEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(int(inserted.Version)) + `"`},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetProvisionStatus(ctx context.Context, req gen.GetProvisionStatusRequestObject) (gen.GetProvisionStatusResponseObject, error) {
|
||||
slug := string(req.Slug)
|
||||
id, err := s.resolveEntityID(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var state string
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
"SELECT state FROM entities WHERE id = $1", id).Scan(&state); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrNotFound, slug)
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT step_name, status, error_message, started_at, finished_at
|
||||
FROM provisioning_steps WHERE entity_id = $1 ORDER BY step_order`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var provSteps []struct {
|
||||
ErrorMessage *string `json:"error_message"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
Status gen.ProvisionStatusStepsStatus `json:"status"`
|
||||
Step string `json:"step"`
|
||||
}
|
||||
for rows.Next() {
|
||||
var stepName, status string
|
||||
var errMsg *string
|
||||
var started, finished *time.Time
|
||||
if scanErr := rows.Scan(&stepName, &status, &errMsg, &started, &finished); scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
provSteps = append(provSteps, struct {
|
||||
ErrorMessage *string `json:"error_message"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
Status gen.ProvisionStatusStepsStatus `json:"status"`
|
||||
Step string `json:"step"`
|
||||
}{
|
||||
Step: stepName,
|
||||
Status: gen.ProvisionStatusStepsStatus(status),
|
||||
ErrorMessage: errMsg,
|
||||
StartedAt: started,
|
||||
FinishedAt: finished,
|
||||
})
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
return gen.GetProvisionStatus200JSONResponse{
|
||||
Slug: slug,
|
||||
State: state,
|
||||
Steps: provSteps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateAgeKeypair() (pubKey, privKey string, err error) {
|
||||
seed := make([]byte, 32)
|
||||
if _, err := rand.Read(seed); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
n := new(big.Int).SetBytes(seed)
|
||||
pub := fmt.Sprintf("age1%064x", n)
|
||||
priv := fmt.Sprintf("AGE-SECRET-KEY-1%064x", n)
|
||||
return pub, priv, nil
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,11 +44,19 @@ type actor struct {
|
||||
|
||||
// Server implements gen.StrictServerInterface over the DB layer.
|
||||
type Server struct {
|
||||
pool *db.Pool
|
||||
cfg config.Config
|
||||
sseBroker *sseBroker
|
||||
sseSubs map[*sseSubscriber]struct{}
|
||||
sseMu sync.Mutex
|
||||
pool *db.Pool
|
||||
cfg config.Config
|
||||
secretsManager secretsBackend
|
||||
sseBroker *sseBroker
|
||||
sseSubs map[*sseSubscriber]struct{}
|
||||
sseMu sync.Mutex
|
||||
}
|
||||
|
||||
// secretsBackend is a minimal interface for secrets operations used by the
|
||||
// HTTP API (enrollment key storage, listing). Compatible with internal/secrets.
|
||||
type secretsBackend interface {
|
||||
Set(ctx context.Context, key string, value string) error
|
||||
List(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
|
||||
|
||||
57
migrations/012_client_enrollment.up.sql
Normal file
57
migrations/012_client_enrollment.up.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- Migration 012: Client enrollment and compute entity provisioning
|
||||
-- Adds provisioning tracking and client-specific lookup indexes.
|
||||
|
||||
-- Provisioning step tracker for LXC/VM/container creation.
|
||||
-- Tracks individual steps within a provisioning execution so the client
|
||||
-- can poll GET /provision/status for progress.
|
||||
CREATE TABLE IF NOT EXISTS provisioning_steps (
|
||||
id UUID PRIMARY KEY,
|
||||
entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
execution_id UUID NOT NULL REFERENCES executions(id) ON DELETE CASCADE,
|
||||
step_order INTEGER NOT NULL,
|
||||
step_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'running', 'ok', 'failed', 'skipped')),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(entity_id, step_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provisioning_steps_entity
|
||||
ON provisioning_steps (entity_id, step_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_provisioning_steps_execution
|
||||
ON provisioning_steps (execution_id);
|
||||
|
||||
-- Fast lookup for client entities by slug prefix + type.
|
||||
-- Supports whoami(hostname) and GET /clients/{slug} lookups.
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_slug_type_machine
|
||||
ON entities (slug, type)
|
||||
WHERE type IN ('workstation', 'standalone-server', 'proxmox-host');
|
||||
|
||||
-- Track client enrollment state separately from entity state.
|
||||
-- An entity may be in provisioning for infrastructure reasons while
|
||||
-- enrollment (age key, Infisical identity) is complete.
|
||||
ALTER TABLE entities ADD COLUMN IF NOT EXISTS enrolled_at TIMESTAMPTZ;
|
||||
ALTER TABLE entities ADD COLUMN IF NOT EXISTS enrolled_by UUID;
|
||||
|
||||
-- Context version tracking — incremented when agent files change,
|
||||
-- so clients can poll GET /context?since= efficiently.
|
||||
CREATE TABLE IF NOT EXISTS context_version (
|
||||
singleton BOOLEAN PRIMARY KEY DEFAULT true
|
||||
CHECK (singleton = true),
|
||||
version BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO context_version (version) VALUES (0)
|
||||
ON CONFLICT (singleton) DO NOTHING;
|
||||
|
||||
-- List of files included in the agent context bundle.
|
||||
CREATE TABLE IF NOT EXISTS context_files (
|
||||
path TEXT PRIMARY KEY,
|
||||
hash TEXT NOT NULL,
|
||||
last_changed TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
Reference in New Issue
Block a user