Files
oikos/internal/httpapi/client_lifecycle.go
dtoro 64f7d54011
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 2 — ports package, secrets port move, postgres adapter move
Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md)
must give the use-cases-to-be their contract surface: driven-port
interfaces, test fakes, the secrets interface moved into core, and the
postgres package inside the adapters tree — before the first vertical
slice (Phase 3) can wire a composition root.

Change:
- internal/core/ports: full driven-port catalog per plan §3.3 —
  repositories as transaction-scoped aggregates whose inputs carry
  derived checks, audit, and events (§3.6), plus CommandExecutor,
  TargetResolver, Checker, Secrets, EventPublisher, Provisioner.
  Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry,
  ExecResult) keep signatures off infrastructure; TypeTree aliases
  internal/ontology (pure over domain) until checkdefaults is absorbed.
  ReadModels intentionally not declared yet — it materializes with the
  Phase 3 slice and grows as report handlers rewire.
- secrets.Backend is now an alias of ports.Secrets; implementations
  (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend
  subset is deleted; tool constructors take ports.Secrets.
- internal/db → internal/adapters/postgres (mechanical import rewrite;
  package identifier stays db until the Phase 3 repository split).
  sqlc.yaml, Makefile, golangci exclusions, and docs follow the move;
  make generate-check verified.
- internal/adapters/ssh: Executor implements ports.CommandExecutor over
  the actuator dial pool + RunStreaming (10-min default timeout carried
  over from the httpapi path).
- internal/adapters/remote: Resolver implements ports.TargetResolver
  delegating to internal/remote (still pool-based; drops onto
  ports.EntityRepository when repositories land in Phase 3 — documented
  transitional import).
- internal/core/ports/portstest: importable fakes — in-memory
  EntityRepo (with check-then-act SetState, side-effect recording),
  RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction
  guards; tests.

Risk: ports are declared ahead of implementations — signatures firm up
per phase as slices land (documented in the package doc); the
remote→postgres transitional import is explicit and dissolves in
Phase 3.

Verification: go vet, make test (race, 19 packages), generate-check,
golangci on core+adapters — 0 issues; full-repo baseline down
365→344.
2026-08-15 22:56:56 +02:00

310 lines
8.7 KiB
Go

package httpapi
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"strconv"
"time"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/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
}