Files
oikos/internal/httpapi/client_lifecycle.go
dtoro 75c0848a6f
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
0.29.0 — code-quality refactor (plan E1–E5): file splits, sqlc migration, SSH unification, test coverage
E1: split monolithic files — cmd/nomos (main.go → server.go + mcp.go + workers.go),
    internal/mcp/tools.go → entity_tools/ops_tools/knowledge_tools/analysis_tools,
    internal/httpapi/impl.go → domain files (entities, events, signals, ontology,
    fleet_health, client_context, client_lifecycle, entity_mutations, query_audit).
E2: migrate raw pool.Exec queries to sqlc (entities/relationships queries + generated).
E3: unify SSH — consolidate crypto/ssh dial into actuator/client.go (+client_test).
E4/E5: add tests — db/lifecycle, checkdefaults/build, ontology/preconditions, policy/risk.
2026-08-08 22:47:06 +02:00

310 lines
8.6 KiB
Go

package httpapi
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"strconv"
"time"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
openapi_types "github.com/oapi-codegen/runtime/types"
)
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", "",
nil,
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 := ensureDefaultChecks(ctx, tx, id, req.Body.Slug, current.Type, current.Name, attrsJSON); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
// Store age key in Infisical when backend is available.
if s.secretsManager != nil {
keyPath := "clients/" + req.Body.Slug + "/age-key"
_ = s.secretsManager.Set(ctx, keyPath, agePrivKey)
}
resp := gen.EnrollResponse{
AgePublicKey: agePubKey,
AgePrivateKey: agePrivKey,
}
return gen.EnrollClient200JSONResponse(resp), 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)
}
if len(attrsJSON) == 0 {
attrsJSON = []byte("{}")
}
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 {
_, err = tx.Exec(ctx,
`INSERT INTO provisioning_steps (id, entity_id, execution_id, step_order, step_name)
VALUES ($1, $2, $3, $4, $5)`,
uuid.Must(uuid.NewV7()), entityID, entityID /* executions PK is entity_id */, st.order, st.name)
if err != nil {
return nil, fmt.Errorf("insert provisioning step: %w", err)
}
}
_, err = tx.Exec(ctx,
`INSERT INTO relationships (source_id, target_id, type)
VALUES ($1, $2, 'hosts')`, hostID, entityID)
if err != nil {
return nil, fmt.Errorf("insert relationship: %w", err)
}
_, actor := actorInfo(ctx)
_ = observability.Audit(ctx, q, "operator", actor, "provision",
&entityID, "POST", "/api/v1/entities/provision", "",
nil,
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
}