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:
|
tags:
|
||||||
- name: entities
|
- name: entities
|
||||||
description: Inventory graph — entities and relationships
|
description: Inventory graph — entities and relationships
|
||||||
|
- name: clients
|
||||||
|
description: Client enrollment, context distribution, secrets
|
||||||
- name: ontology
|
- name: ontology
|
||||||
description: Entity types, relationship types, lifecycles
|
description: Entity types, relationship types, lifecycles
|
||||||
- name: signals
|
- name: signals
|
||||||
@@ -359,6 +361,129 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
default:
|
default:
|
||||||
$ref: '#/components/responses/Problem'
|
$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:
|
/ontology:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -1658,6 +1783,13 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
maxLength: 128
|
maxLength: 128
|
||||||
description: Client-generated key; replays within 24h return the original response
|
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:
|
headers:
|
||||||
ETag:
|
ETag:
|
||||||
schema:
|
schema:
|
||||||
@@ -2889,3 +3021,155 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
nullable: true
|
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
|
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
|
// 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.Version,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.EnrolledAt,
|
||||||
|
&i.EnrolledBy,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEntityBySlug = `-- name: GetEntityBySlug :one
|
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) {
|
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.Version,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.EnrolledAt,
|
||||||
|
&i.EnrolledBy,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -62,7 +66,7 @@ func (q *Queries) GetEntityBySlug(ctx context.Context, slug string) (Entity, err
|
|||||||
const insertEntity = `-- name: InsertEntity :one
|
const insertEntity = `-- name: InsertEntity :one
|
||||||
INSERT INTO entities (id, slug, type, name, state, attributes)
|
INSERT INTO entities (id, slug, type, name, state, attributes)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
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 {
|
type InsertEntityParams struct {
|
||||||
@@ -95,6 +99,8 @@ func (q *Queries) InsertEntity(ctx context.Context, arg InsertEntityParams) (Ent
|
|||||||
&i.Version,
|
&i.Version,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.EnrolledAt,
|
||||||
|
&i.EnrolledBy,
|
||||||
)
|
)
|
||||||
return i, err
|
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
|
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
|
||||||
WHERE $7::text IS NOT NULL
|
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
|
JOIN entity_types et ON et.name = e.type
|
||||||
WHERE e.type IN (SELECT name FROM tt)
|
WHERE e.type IN (SELECT name FROM tt)
|
||||||
AND ($1::text IS NULL OR e.state = $1)
|
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.Version,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.EnrolledAt,
|
||||||
|
&i.EnrolledBy,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -170,7 +178,7 @@ func (q *Queries) ListEntities(ctx context.Context, arg ListEntitiesParams) ([]E
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listEntitiesCapped = `-- name: ListEntitiesCapped :many
|
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) {
|
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.Version,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.EnrolledAt,
|
||||||
|
&i.EnrolledBy,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -214,7 +224,7 @@ UPDATE entities SET
|
|||||||
version = version + 1,
|
version = version + 1,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $6 AND version = $7
|
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 {
|
type UpdateEntityParams struct {
|
||||||
@@ -249,6 +259,8 @@ func (q *Queries) UpdateEntity(ctx context.Context, arg UpdateEntityParams) (Ent
|
|||||||
&i.Version,
|
&i.Version,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.EnrolledAt,
|
||||||
|
&i.EnrolledBy,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,18 @@ type Classification struct {
|
|||||||
CreatedAt time.Time
|
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 {
|
type Entity struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Slug string
|
Slug string
|
||||||
@@ -115,6 +127,8 @@ type Entity struct {
|
|||||||
Version int32
|
Version int32
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
EnrolledAt *time.Time
|
||||||
|
EnrolledBy *uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
type EntityStatus struct {
|
type EntityStatus struct {
|
||||||
@@ -192,6 +206,18 @@ type IdempotencyKey struct {
|
|||||||
CreatedAt time.Time
|
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 {
|
type Ledger struct {
|
||||||
Ts time.Time
|
Ts time.Time
|
||||||
ExecutionID uuid.UUID
|
ExecutionID uuid.UUID
|
||||||
@@ -261,6 +287,20 @@ type Pattern struct {
|
|||||||
CreatedAt time.Time
|
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 {
|
type Relationship struct {
|
||||||
SourceID uuid.UUID
|
SourceID uuid.UUID
|
||||||
TargetID uuid.UUID
|
TargetID uuid.UUID
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,11 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,6 +18,7 @@ import (
|
|||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -1082,3 +1085,360 @@ func sqlcEntityToGen(e sqlcgen.Entity) gen.Entity {
|
|||||||
}
|
}
|
||||||
return out
|
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.
|
// Server implements gen.StrictServerInterface over the DB layer.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
pool *db.Pool
|
pool *db.Pool
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
sseBroker *sseBroker
|
secretsManager secretsBackend
|
||||||
sseSubs map[*sseSubscriber]struct{}
|
sseBroker *sseBroker
|
||||||
sseMu sync.Mutex
|
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,
|
// 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