459 lines
16 KiB
Go
459 lines
16 KiB
Go
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
|
|
}
|