Initial project snapshot
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
package nodeagent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
clustermodule "github.com/example/remote-access-platform/backend/internal/modules/cluster"
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/httpx"
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/module"
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/secrets"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
db *pgxpool.Pool
|
||||
cluster *clustermodule.Service
|
||||
}
|
||||
|
||||
func NewModule(deps module.Dependencies) *Module {
|
||||
clusterStore := clustermodule.NewPostgresStore(deps.Infra.DB)
|
||||
if deps.Config.Secret.EncryptionKeyBase64 != "" {
|
||||
if encryptor, err := secrets.NewEncryptor(deps.Config.Secret.EncryptionKeyBase64, deps.Config.Secret.EncryptionKeyID); err == nil {
|
||||
clusterStore.WithClusterKeyEncryptor(encryptor)
|
||||
}
|
||||
}
|
||||
return &Module{
|
||||
db: deps.Infra.DB,
|
||||
cluster: clustermodule.NewService(clusterStore),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) Name() string {
|
||||
return "nodeagent"
|
||||
}
|
||||
|
||||
func (m *Module) RegisterRoutes(router chi.Router) {
|
||||
router.Route("/node-agents", func(r chi.Router) {
|
||||
r.Post("/enroll", m.enrollAgent)
|
||||
r.Post("/enrollments/{requestID}/bootstrap", m.bootstrapEnrollment)
|
||||
r.Post("/register", m.registerAgent)
|
||||
r.Post("/{nodeID}/health", m.reportHealth)
|
||||
r.Post("/{nodeID}/services/status", m.reportServiceStatus)
|
||||
r.Post("/{nodeID}/update-manifest/request", m.requestUpdateManifest)
|
||||
r.Post("/{nodeID}/update-result", m.acknowledgeUpdateResult)
|
||||
r.Post("/{nodeID}/rollback-result", m.reportRollbackResult)
|
||||
r.Get("/{nodeID}/clusters/{clusterID}/vpn-assignments/desired", m.listVPNAssignments)
|
||||
r.Post("/{nodeID}/clusters/{clusterID}/vpn-assignments/{vpnConnectionID}/status", m.reportVPNAssignmentStatus)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) enrollAgent(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
ClusterID string `json:"cluster_id"`
|
||||
JoinToken string `json:"join_token"`
|
||||
NodeName string `json:"node_name"`
|
||||
NodeFingerprint string `json:"node_fingerprint"`
|
||||
PublicKey string `json:"public_key"`
|
||||
ReportedCapabilities json.RawMessage `json:"reported_capabilities"`
|
||||
ReportedFacts json.RawMessage `json:"reported_facts"`
|
||||
RequestedRoles json.RawMessage `json:"requested_roles"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid agent enrollment payload")
|
||||
return
|
||||
}
|
||||
joinRequest, err := m.cluster.CreateJoinRequest(r.Context(), clustermodule.CreateJoinRequestInput{
|
||||
ClusterID: payload.ClusterID,
|
||||
JoinToken: payload.JoinToken,
|
||||
NodeName: payload.NodeName,
|
||||
NodeFingerprint: payload.NodeFingerprint,
|
||||
PublicKey: payload.PublicKey,
|
||||
ReportedCapabilities: payload.ReportedCapabilities,
|
||||
ReportedFacts: payload.ReportedFacts,
|
||||
RequestedRoles: payload.RequestedRoles,
|
||||
})
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{
|
||||
"status": "pending_approval",
|
||||
"join_request": joinRequest,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) bootstrapEnrollment(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
ClusterID string `json:"cluster_id"`
|
||||
NodeFingerprint string `json:"node_fingerprint"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid enrollment bootstrap payload")
|
||||
return
|
||||
}
|
||||
result, err := m.cluster.GetJoinRequestBootstrap(r.Context(), clustermodule.GetJoinRequestBootstrapInput{
|
||||
ClusterID: payload.ClusterID,
|
||||
JoinRequestID: chi.URLParam(r, "requestID"),
|
||||
NodeFingerprint: payload.NodeFingerprint,
|
||||
PublicKey: payload.PublicKey,
|
||||
})
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (m *Module) registerAgent(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
NodeKey string `json:"node_key"`
|
||||
Name string `json:"name"`
|
||||
OwnershipType string `json:"ownership_type"`
|
||||
OwnerOrganizationID *string `json:"owner_organization_id"`
|
||||
ReportedVersion *string `json:"reported_version"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid agent registration payload")
|
||||
return
|
||||
}
|
||||
if payload.NodeKey == "" || payload.Name == "" || payload.OwnershipType == "" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "node_key, name, and ownership_type are required")
|
||||
return
|
||||
}
|
||||
if len(payload.Metadata) == 0 {
|
||||
payload.Metadata = json.RawMessage(`{}`)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
nodeID := uuid.NewString()
|
||||
if err := m.db.QueryRow(r.Context(), `
|
||||
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, 'active', 'unknown', 'unknown', 'healthy', NULL, $6, $7, $8::jsonb, $9, $10)
|
||||
ON CONFLICT (node_key) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
ownership_type = EXCLUDED.ownership_type,
|
||||
owner_organization_id = EXCLUDED.owner_organization_id,
|
||||
registration_status = 'active',
|
||||
reported_version = EXCLUDED.reported_version,
|
||||
last_seen_at = EXCLUDED.last_seen_at,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id
|
||||
`, nodeID, payload.OwnerOrganizationID, payload.NodeKey, payload.Name, payload.OwnershipType, payload.ReportedVersion, now, []byte(payload.Metadata), now, now).Scan(&nodeID); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"node_id": nodeID,
|
||||
"status": "registered",
|
||||
"legacy": true,
|
||||
"warning": "direct node-agent registration is retained for compatibility; production enrollment must use /node-agents/enroll",
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) reportHealth(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
HealthStatus string `json:"health_status"`
|
||||
ReportedVersion *string `json:"reported_version"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid node health payload")
|
||||
return
|
||||
}
|
||||
if payload.HealthStatus == "" {
|
||||
payload.HealthStatus = "unknown"
|
||||
}
|
||||
if len(payload.Metadata) == 0 {
|
||||
payload.Metadata = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := m.db.Exec(r.Context(), `
|
||||
UPDATE nodes
|
||||
SET health_status = $2, reported_version = COALESCE($3, reported_version), last_seen_at = $4, metadata = $5::jsonb, updated_at = $4
|
||||
WHERE id = $1
|
||||
`, chi.URLParam(r, "nodeID"), payload.HealthStatus, payload.ReportedVersion, time.Now().UTC(), []byte(payload.Metadata)); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"status": "accepted"})
|
||||
}
|
||||
|
||||
func (m *Module) reportServiceStatus(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
Services []struct {
|
||||
ServiceType string `json:"service_type"`
|
||||
ReportedState string `json:"reported_state"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
} `json:"services"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid node service status payload")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, service := range payload.Services {
|
||||
if len(service.Metadata) == 0 {
|
||||
service.Metadata = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := m.db.Exec(r.Context(), `
|
||||
INSERT INTO node_services (
|
||||
node_id, service_type, enabled, desired_state, reported_state, last_reported_at, metadata, updated_at
|
||||
) VALUES ($1, $2, FALSE, 'disabled', $3, $4, $5::jsonb, $4)
|
||||
ON CONFLICT (node_id, service_type) DO UPDATE SET
|
||||
reported_state = EXCLUDED.reported_state,
|
||||
last_reported_at = EXCLUDED.last_reported_at,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`, chi.URLParam(r, "nodeID"), service.ServiceType, service.ReportedState, now, []byte(service.Metadata)); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"status": "accepted"})
|
||||
}
|
||||
|
||||
func (m *Module) listVPNAssignments(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.cluster.ListNodeVPNAssignments(r.Context(), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID"))
|
||||
if writeClusterServiceError(w, err) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"vpn_assignments": items,
|
||||
"runtime_execution_enabled": false,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) reportVPNAssignmentStatus(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
ObservedStatus string `json:"observed_status"`
|
||||
StatusPayload json.RawMessage `json:"status_payload"`
|
||||
ObservedAt *time.Time `json:"observed_at"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn assignment status payload")
|
||||
return
|
||||
}
|
||||
observedAt := time.Time{}
|
||||
if payload.ObservedAt != nil {
|
||||
observedAt = *payload.ObservedAt
|
||||
}
|
||||
item, err := m.cluster.ReportNodeVPNAssignmentStatus(r.Context(), clustermodule.ReportNodeVPNAssignmentStatusInput{
|
||||
ClusterID: chi.URLParam(r, "clusterID"),
|
||||
NodeID: chi.URLParam(r, "nodeID"),
|
||||
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
||||
ObservedStatus: payload.ObservedStatus,
|
||||
StatusPayload: payload.StatusPayload,
|
||||
ObservedAt: observedAt,
|
||||
})
|
||||
if writeClusterServiceError(w, err) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{
|
||||
"vpn_assignment_status": item,
|
||||
"runtime_execution_enabled": false,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) requestUpdateManifest(w http.ResponseWriter, r *http.Request) {
|
||||
nodeID := chi.URLParam(r, "nodeID")
|
||||
var mode, channel string
|
||||
var canary, automatic bool
|
||||
var desiredVersion *string
|
||||
if err := m.db.QueryRow(r.Context(), `
|
||||
SELECT n.desired_version, p.mode, p.channel, p.canary, p.automatic_rollout
|
||||
FROM nodes n
|
||||
LEFT JOIN node_update_policies p ON p.node_id = n.id
|
||||
WHERE n.id = $1
|
||||
`, nodeID).Scan(&desiredVersion, &mode, &channel, &canary, &automatic); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"manifest": map[string]any{
|
||||
"node_id": nodeID,
|
||||
"desired_version": desiredVersion,
|
||||
"mode": mode,
|
||||
"channel": channel,
|
||||
"canary": canary,
|
||||
"automatic_rollout": automatic,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) acknowledgeUpdateResult(w http.ResponseWriter, r *http.Request) {
|
||||
m.recordUpdateRun(w, r, "update")
|
||||
}
|
||||
|
||||
func (m *Module) reportRollbackResult(w http.ResponseWriter, r *http.Request) {
|
||||
m.recordUpdateRun(w, r, "rollback")
|
||||
}
|
||||
|
||||
func (m *Module) recordUpdateRun(w http.ResponseWriter, r *http.Request, action string) {
|
||||
var payload struct {
|
||||
TargetVersion string `json:"target_version"`
|
||||
Status string `json:"status"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid update result payload")
|
||||
return
|
||||
}
|
||||
if payload.Status == "" {
|
||||
payload.Status = "acknowledged"
|
||||
}
|
||||
if len(payload.Payload) == 0 {
|
||||
payload.Payload = json.RawMessage(`{}`)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
runID := uuid.NewString()
|
||||
if _, err := m.db.Exec(r.Context(), `
|
||||
INSERT INTO node_agent_update_runs (
|
||||
id, node_id, action, target_version, status, requested_at, acknowledged_at, completed_at, payload
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $6, CASE WHEN $5 IN ('succeeded', 'failed') THEN $6 ELSE NULL END, $7::jsonb)
|
||||
`, runID, chi.URLParam(r, "nodeID"), action, payload.TargetVersion, payload.Status, now, []byte(payload.Payload)); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if action == "update" && payload.Status == "succeeded" {
|
||||
_, _ = m.db.Exec(r.Context(), `
|
||||
UPDATE nodes
|
||||
SET reported_version = $2, version_state = 'current', updated_at = $3
|
||||
WHERE id = $1
|
||||
`, chi.URLParam(r, "nodeID"), payload.TargetVersion, now)
|
||||
}
|
||||
if action == "rollback" && payload.Status == "succeeded" {
|
||||
_, _ = m.db.Exec(r.Context(), `
|
||||
UPDATE nodes
|
||||
SET reported_version = $2, version_state = 'rollback', updated_at = $3
|
||||
WHERE id = $1
|
||||
`, chi.URLParam(r, "nodeID"), payload.TargetVersion, now)
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"status": "accepted", "run_id": runID})
|
||||
}
|
||||
|
||||
func writeClusterServiceError(w http.ResponseWriter, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, clustermodule.ErrVPNLeaseOwnerNotAllowed), errors.Is(err, clustermodule.ErrVPNLeaseOwnerRoleRequired):
|
||||
httpx.WriteError(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, clustermodule.ErrInvalidPayload):
|
||||
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user