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.
310 lines
8.6 KiB
Go
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
|
|
} |