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 }