package node import ( "context" "encoding/json" "errors" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/example/remote-access-platform/backend/internal/platform/httpx" "github.com/example/remote-access-platform/backend/internal/platform/module" ) type Module struct { db *pgxpool.Pool } type Node struct { ID string `json:"id"` OwnerOrganizationID *string `json:"owner_organization_id,omitempty"` NodeKey string `json:"node_key"` Name string `json:"name"` OwnershipType string `json:"ownership_type"` RegistrationStatus string `json:"registration_status"` HealthStatus string `json:"health_status"` VersionState string `json:"version_state"` PartitionState string `json:"partition_state"` DesiredVersion *string `json:"desired_version,omitempty"` ReportedVersion *string `json:"reported_version,omitempty"` LastSeenAt *time.Time `json:"last_seen_at,omitempty"` Metadata json.RawMessage `json:"metadata"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type NodeCapability struct { NodeID string `json:"node_id"` Capability string `json:"capability"` Value json.RawMessage `json:"value"` UpdatedAt time.Time `json:"updated_at"` } type NodeService struct { NodeID string `json:"node_id"` ServiceType string `json:"service_type"` Enabled bool `json:"enabled"` DesiredState string `json:"desired_state"` ReportedState string `json:"reported_state"` LastReportedAt *time.Time `json:"last_reported_at,omitempty"` Metadata json.RawMessage `json:"metadata"` UpdatedAt time.Time `json:"updated_at"` } type NodeUpdatePolicy struct { NodeID string `json:"node_id"` Mode string `json:"mode"` Channel string `json:"channel"` MaintenanceWindow json.RawMessage `json:"maintenance_window"` Canary bool `json:"canary"` AutomaticRollout bool `json:"automatic_rollout"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type NodePartitionState struct { NodeID string `json:"node_id"` ClusterState string `json:"cluster_state"` RecoveryMode string `json:"recovery_mode"` Notes *string `json:"notes,omitempty"` UpdatedAt time.Time `json:"updated_at"` } type upsertNodeRequest struct { ActorUserID string `json:"actor_user_id"` OwnerOrganizationID *string `json:"owner_organization_id"` NodeKey string `json:"node_key"` Name string `json:"name"` OwnershipType string `json:"ownership_type"` DesiredVersion *string `json:"desired_version"` Metadata json.RawMessage `json:"metadata"` Capabilities []struct { Capability string `json:"capability"` Value json.RawMessage `json:"value"` } `json:"capabilities"` Services []struct { ServiceType string `json:"service_type"` Enabled bool `json:"enabled"` DesiredState string `json:"desired_state"` Metadata json.RawMessage `json:"metadata"` } `json:"services"` UpdatePolicy struct { Mode string `json:"mode"` Channel string `json:"channel"` MaintenanceWindow json.RawMessage `json:"maintenance_window"` Canary bool `json:"canary"` AutomaticRollout bool `json:"automatic_rollout"` } `json:"update_policy"` PartitionState struct { ClusterState string `json:"cluster_state"` RecoveryMode string `json:"recovery_mode"` Notes *string `json:"notes"` } `json:"partition_state"` } func NewModule(deps module.Dependencies) *Module { return &Module{db: deps.Infra.DB} } func (m *Module) Name() string { return "node" } func (m *Module) RegisterRoutes(router chi.Router) { router.Route("/nodes", func(r chi.Router) { r.Get("/", m.listNodes) r.Post("/", m.createNode) r.Get("/{nodeID}", m.getNode) r.Put("/{nodeID}", m.updateNode) }) } func (m *Module) listNodes(w http.ResponseWriter, r *http.Request) { rows, err := m.db.Query(r.Context(), ` SELECT id, owner_organization_id, node_key, name, ownership_type, registration_status, health_status, version_state, partition_state, desired_version, reported_version, last_seen_at, metadata, created_at, updated_at FROM nodes ORDER BY created_at DESC `) if err != nil { httpx.WriteError(w, http.StatusInternalServerError, err.Error()) return } defer rows.Close() var items []Node for rows.Next() { item, err := scanNode(rows) if err != nil { httpx.WriteError(w, http.StatusInternalServerError, err.Error()) return } items = append(items, item) } httpx.WriteJSON(w, http.StatusOK, map[string]any{"nodes": items}) } func (m *Module) getNode(w http.ResponseWriter, r *http.Request) { nodeID := chi.URLParam(r, "nodeID") item, err := m.getNodeByID(r.Context(), nodeID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { httpx.WriteError(w, http.StatusNotFound, "node not found") return } httpx.WriteError(w, http.StatusInternalServerError, err.Error()) return } caps, _ := m.listCapabilities(r.Context(), nodeID) services, _ := m.listServices(r.Context(), nodeID) updatePolicy, _ := m.getUpdatePolicy(r.Context(), nodeID) partitionState, _ := m.getPartitionState(r.Context(), nodeID) httpx.WriteJSON(w, http.StatusOK, map[string]any{ "node": item, "capabilities": caps, "services": services, "update_policy": updatePolicy, "partition_state": partitionState, }) } func (m *Module) createNode(w http.ResponseWriter, r *http.Request) { req, err := decodeNodeRequest(r) if err != nil { httpx.WriteError(w, http.StatusBadRequest, err.Error()) return } item := Node{ ID: uuid.NewString(), OwnerOrganizationID: req.OwnerOrganizationID, NodeKey: req.NodeKey, Name: req.Name, OwnershipType: req.OwnershipType, RegistrationStatus: "pending", HealthStatus: "unknown", VersionState: "unknown", PartitionState: "healthy", DesiredVersion: req.DesiredVersion, Metadata: req.Metadata, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } if err := m.persistNode(r.Context(), item, req, true); err != nil { httpx.WriteError(w, http.StatusInternalServerError, err.Error()) return } httpx.WriteJSON(w, http.StatusCreated, map[string]any{"node": item}) } func (m *Module) updateNode(w http.ResponseWriter, r *http.Request) { req, err := decodeNodeRequest(r) if err != nil { httpx.WriteError(w, http.StatusBadRequest, err.Error()) return } nodeID := chi.URLParam(r, "nodeID") item, err := m.getNodeByID(r.Context(), nodeID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { httpx.WriteError(w, http.StatusNotFound, "node not found") return } httpx.WriteError(w, http.StatusInternalServerError, err.Error()) return } item.OwnerOrganizationID = req.OwnerOrganizationID item.NodeKey = req.NodeKey item.Name = req.Name item.OwnershipType = req.OwnershipType item.DesiredVersion = req.DesiredVersion item.Metadata = req.Metadata item.UpdatedAt = time.Now().UTC() if err := m.persistNode(r.Context(), item, req, false); err != nil { httpx.WriteError(w, http.StatusInternalServerError, err.Error()) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"node": item}) } func decodeNodeRequest(r *http.Request) (*upsertNodeRequest, error) { var req upsertNodeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { return nil, errors.New("invalid node payload") } if req.ActorUserID == "" || req.NodeKey == "" || req.Name == "" || req.OwnershipType == "" { return nil, errors.New("actor_user_id, node_key, name, and ownership_type are required") } if len(req.Metadata) == 0 { req.Metadata = json.RawMessage(`{}`) } if !json.Valid(req.Metadata) { return nil, errors.New("metadata must be valid json") } if req.UpdatePolicy.Mode == "" { req.UpdatePolicy.Mode = "manual" } if req.UpdatePolicy.Channel == "" { req.UpdatePolicy.Channel = "stable" } if len(req.UpdatePolicy.MaintenanceWindow) == 0 { req.UpdatePolicy.MaintenanceWindow = json.RawMessage(`{}`) } if req.PartitionState.ClusterState == "" { req.PartitionState.ClusterState = "healthy" } if req.PartitionState.RecoveryMode == "" { req.PartitionState.RecoveryMode = "normal" } for i := range req.Capabilities { if len(req.Capabilities[i].Value) == 0 { req.Capabilities[i].Value = json.RawMessage(`{}`) } } for i := range req.Services { if req.Services[i].DesiredState == "" { req.Services[i].DesiredState = "disabled" } if len(req.Services[i].Metadata) == 0 { req.Services[i].Metadata = json.RawMessage(`{}`) } } return &req, nil } func (m *Module) persistNode(ctx context.Context, item Node, req *upsertNodeRequest, create bool) error { tx, err := m.db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) if create { _, err = tx.Exec(ctx, ` INSERT INTO nodes ( id, owner_organization_id, node_key, name, ownership_type, registration_status, health_status, version_state, partition_state, desired_version, reported_version, last_seen_at, metadata, created_at, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::jsonb,$14,$15) `, item.ID, item.OwnerOrganizationID, item.NodeKey, item.Name, item.OwnershipType, item.RegistrationStatus, item.HealthStatus, item.VersionState, item.PartitionState, item.DesiredVersion, item.ReportedVersion, item.LastSeenAt, []byte(item.Metadata), item.CreatedAt, item.UpdatedAt) } else { _, err = tx.Exec(ctx, ` UPDATE nodes SET owner_organization_id=$2, node_key=$3, name=$4, ownership_type=$5, desired_version=$6, metadata=$7::jsonb, updated_at=$8 WHERE id=$1 `, item.ID, item.OwnerOrganizationID, item.NodeKey, item.Name, item.OwnershipType, item.DesiredVersion, []byte(item.Metadata), item.UpdatedAt) } if err != nil { return err } if _, err := tx.Exec(ctx, `DELETE FROM node_capabilities WHERE node_id = $1`, item.ID); err != nil { return err } for _, capability := range req.Capabilities { if _, err := tx.Exec(ctx, ` INSERT INTO node_capabilities (node_id, capability, value, updated_at) VALUES ($1, $2, $3::jsonb, $4) `, item.ID, capability.Capability, []byte(capability.Value), time.Now().UTC()); err != nil { return err } } if _, err := tx.Exec(ctx, `DELETE FROM node_services WHERE node_id = $1`, item.ID); err != nil { return err } for _, service := range req.Services { if _, err := tx.Exec(ctx, ` INSERT INTO node_services ( node_id, service_type, enabled, desired_state, reported_state, last_reported_at, metadata, updated_at ) VALUES ($1, $2, $3, $4, 'unknown', NULL, $5::jsonb, $6) `, item.ID, service.ServiceType, service.Enabled, service.DesiredState, []byte(service.Metadata), time.Now().UTC()); err != nil { return err } } if _, err := tx.Exec(ctx, ` INSERT INTO node_update_policies ( node_id, mode, channel, maintenance_window, canary, automatic_rollout, created_at, updated_at ) VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8) ON CONFLICT (node_id) DO UPDATE SET mode = EXCLUDED.mode, channel = EXCLUDED.channel, maintenance_window = EXCLUDED.maintenance_window, canary = EXCLUDED.canary, automatic_rollout = EXCLUDED.automatic_rollout, updated_at = EXCLUDED.updated_at `, item.ID, req.UpdatePolicy.Mode, req.UpdatePolicy.Channel, []byte(req.UpdatePolicy.MaintenanceWindow), req.UpdatePolicy.Canary, req.UpdatePolicy.AutomaticRollout, time.Now().UTC(), time.Now().UTC()); err != nil { return err } if _, err := tx.Exec(ctx, ` INSERT INTO node_partition_states (node_id, cluster_state, recovery_mode, notes, updated_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (node_id) DO UPDATE SET cluster_state = EXCLUDED.cluster_state, recovery_mode = EXCLUDED.recovery_mode, notes = EXCLUDED.notes, updated_at = EXCLUDED.updated_at `, item.ID, req.PartitionState.ClusterState, req.PartitionState.RecoveryMode, req.PartitionState.Notes, time.Now().UTC()); err != nil { return err } return tx.Commit(ctx) } func (m *Module) getNodeByID(ctx context.Context, nodeID string) (Node, error) { row := m.db.QueryRow(ctx, ` SELECT id, owner_organization_id, node_key, name, ownership_type, registration_status, health_status, version_state, partition_state, desired_version, reported_version, last_seen_at, metadata, created_at, updated_at FROM nodes WHERE id = $1 `, nodeID) return scanNode(row) } func (m *Module) listCapabilities(ctx context.Context, nodeID string) ([]NodeCapability, error) { rows, err := m.db.Query(ctx, `SELECT node_id, capability, value, updated_at FROM node_capabilities WHERE node_id = $1 ORDER BY capability`, nodeID) if err != nil { return nil, err } defer rows.Close() var out []NodeCapability for rows.Next() { var item NodeCapability if err := rows.Scan(&item.NodeID, &item.Capability, &item.Value, &item.UpdatedAt); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } func (m *Module) listServices(ctx context.Context, nodeID string) ([]NodeService, error) { rows, err := m.db.Query(ctx, ` SELECT node_id, service_type, enabled, desired_state, reported_state, last_reported_at, metadata, updated_at FROM node_services WHERE node_id = $1 ORDER BY service_type `, nodeID) if err != nil { return nil, err } defer rows.Close() var out []NodeService for rows.Next() { var item NodeService if err := rows.Scan(&item.NodeID, &item.ServiceType, &item.Enabled, &item.DesiredState, &item.ReportedState, &item.LastReportedAt, &item.Metadata, &item.UpdatedAt); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } func (m *Module) getUpdatePolicy(ctx context.Context, nodeID string) (*NodeUpdatePolicy, error) { row := m.db.QueryRow(ctx, ` SELECT node_id, mode, channel, maintenance_window, canary, automatic_rollout, created_at, updated_at FROM node_update_policies WHERE node_id = $1 `, nodeID) var item NodeUpdatePolicy if err := row.Scan(&item.NodeID, &item.Mode, &item.Channel, &item.MaintenanceWindow, &item.Canary, &item.AutomaticRollout, &item.CreatedAt, &item.UpdatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, err } return &item, nil } func (m *Module) getPartitionState(ctx context.Context, nodeID string) (*NodePartitionState, error) { row := m.db.QueryRow(ctx, ` SELECT node_id, cluster_state, recovery_mode, notes, updated_at FROM node_partition_states WHERE node_id = $1 `, nodeID) var item NodePartitionState if err := row.Scan(&item.NodeID, &item.ClusterState, &item.RecoveryMode, &item.Notes, &item.UpdatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, err } return &item, nil } type rowScanner interface { Scan(dest ...any) error } func scanNode(row rowScanner) (Node, error) { var item Node if err := row.Scan( &item.ID, &item.OwnerOrganizationID, &item.NodeKey, &item.Name, &item.OwnershipType, &item.RegistrationStatus, &item.HealthStatus, &item.VersionState, &item.PartitionState, &item.DesiredVersion, &item.ReportedVersion, &item.LastSeenAt, &item.Metadata, &item.CreatedAt, &item.UpdatedAt, ); err != nil { return Node{}, err } if len(item.Metadata) == 0 { item.Metadata = json.RawMessage(`{}`) } return item, nil }