рабочий вариант, но скороть 10 МБит
build / backend (push) Has been cancelled
build / node-agent (push) Has been cancelled
build / worker (push) Has been cancelled

This commit is contained in:
2026-05-22 21:46:49 +03:00
parent 469fa0e860
commit 20d361a886
280 changed files with 954890 additions and 18524 deletions
+155 -61
View File
@@ -60,13 +60,30 @@ func (m *Module) Name() string {
}
func (m *Module) RegisterRoutes(router chi.Router) {
router.Get("/ui/admin", m.renderAdminHTML)
router.Get("/ui/htmx-lite.js", m.renderHTMXLiteJS)
router.Get("/downloads/{fileName}", m.downloadReleaseFile)
router.Get("/downloads/releases/{version}/{fileName}", m.downloadVersionedReleaseFile)
router.Route("/clusters", func(r chi.Router) {
r.Get("/", m.listClusters)
r.Post("/", m.createCluster)
r.Get("/{clusterID}", m.getCluster)
r.Put("/{clusterID}", m.updateCluster)
r.Get("/{clusterID}/nodes", m.listClusterNodes)
r.Get("/{clusterID}/ui/overview", m.renderFarmOverviewHTML)
r.Get("/{clusterID}/ui/nodes", m.renderNodesHTML)
r.Get("/{clusterID}/ui/nodes/fragment", m.renderNodesHTMLFragment)
r.Get("/{clusterID}/ui/nodes/{nodeID}/details", m.renderNodeDetailsHTML)
r.Get("/{clusterID}/ui/updates", m.renderUpdatesHTML)
r.Get("/{clusterID}/ui/updates/fragment", m.renderUpdatesHTMLFragment)
r.Post("/{clusterID}/ui/updates/check-now", m.renderUpdateCheckNowAllHTML)
r.Post("/{clusterID}/ui/updates/{nodeID}/check-now", m.renderUpdateCheckNowHTML)
r.Get("/{clusterID}/ui/topology", m.renderTopologyHTML)
r.Get("/{clusterID}/ui/fabric", m.renderFabricConsoleHTML)
r.Post("/{clusterID}/ui/fabric/policy", m.renderFabricPolicyHTML)
r.Get("/{clusterID}/ui/web-control", m.renderWebControlHTML)
r.Post("/{clusterID}/ui/web-control/desired", m.renderWebControlDesiredHTML)
r.Get("/{clusterID}/ui/audit", m.renderAuditConsoleHTML)
r.Get("/{clusterID}/node-groups", m.listNodeGroups)
r.Post("/{clusterID}/node-groups", m.createNodeGroup)
r.Get("/{clusterID}/join-requests", m.listJoinRequests)
@@ -84,6 +101,7 @@ func (m *Module) RegisterRoutes(router chi.Router) {
r.Post("/{clusterID}/updates/releases", m.createReleaseVersion)
r.Put("/{clusterID}/nodes/{nodeID}/updates/policy", m.upsertNodeUpdatePolicy)
r.Get("/{clusterID}/nodes/{nodeID}/updates/plan", m.getNodeUpdatePlan)
r.Get("/{clusterID}/nodes/{nodeID}/updates/artifacts/{artifactID}/content", m.getNodeUpdateArtifactContent)
r.Get("/{clusterID}/nodes/{nodeID}/updates/bridge-replay-plan", m.getNodeBridgeReplayPlan)
r.Post("/{clusterID}/nodes/{nodeID}/updates/status", m.reportNodeUpdateStatus)
r.Get("/{clusterID}/nodes/{nodeID}/updates/statuses", m.listNodeUpdateStatuses)
@@ -191,9 +209,45 @@ func (m *Module) downloadReleaseFile(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
setReleaseDownloadHeaders(w, fileName)
http.ServeFile(w, r, path)
}
func (m *Module) downloadVersionedReleaseFile(w http.ResponseWriter, r *http.Request) {
version := filepath.Base(strings.TrimSpace(chi.URLParam(r, "version")))
fileName := filepath.Base(strings.TrimSpace(chi.URLParam(r, "fileName")))
if version == "" || version == "." || version != strings.TrimSpace(chi.URLParam(r, "version")) {
http.NotFound(w, r)
return
}
if fileName == "" || fileName == "." || fileName != strings.TrimSpace(chi.URLParam(r, "fileName")) {
http.NotFound(w, r)
return
}
releaseDir := strings.TrimSpace(os.Getenv("RAP_RELEASE_DIR"))
if releaseDir == "" {
releaseDir = "/tmp/rap-release"
}
path := filepath.Join(releaseDir, "releases", version, fileName)
if _, err := os.Stat(path); err != nil {
http.NotFound(w, r)
return
}
setReleaseDownloadHeaders(w, fileName)
http.ServeFile(w, r, path)
}
func setReleaseDownloadHeaders(w http.ResponseWriter, fileName string) {
switch strings.ToLower(filepath.Ext(fileName)) {
case ".apk":
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
w.Header().Set("Content-Disposition", `attachment; filename="`+fileName+`"`)
w.Header().Set("X-Content-Type-Options", "nosniff")
case ".json":
w.Header().Set("Content-Type", "application/json; charset=utf-8")
}
}
func (m *Module) listClusters(w http.ResponseWriter, r *http.Request) {
items, err := m.service.ListClusters(r.Context(), r.URL.Query().Get("actor_user_id"))
if writeServiceError(w, err) {
@@ -340,14 +394,10 @@ func adminRuntimeProjectionResponse(statusCode int, status string, reason string
func isAllowedAdminRuntimeProjectionScope(scope string, serviceClass string) bool {
switch serviceClass {
case FabricServiceClassPlatformAdmin:
return scope == "platform"
case FabricServiceClassClusterAdmin:
return scope == "cluster"
case FabricServiceClassOrganization:
return scope == "organization"
case FabricServiceClassUserPortal:
return scope == "user" || scope == "organization"
case FabricServiceClassAdminIngress:
return scope == "platform" || scope == "cluster"
case FabricServiceClassPublicIngress:
return scope == "organization" || scope == "user"
default:
return false
}
@@ -357,18 +407,22 @@ func adminRuntimeManifest(scope string, serviceClass string) map[string]any {
sections := []string{"status"}
actions := []string{"read_status"}
switch strings.TrimSpace(serviceClass) {
case FabricServiceClassPlatformAdmin:
case FabricServiceClassAdminIngress:
sections = []string{"clusters", "nodes", "roles", "fabric", "workloads", "audit"}
actions = []string{"read_platform_summary", "read_cluster_summaries", "read_node_status"}
case FabricServiceClassClusterAdmin:
sections = []string{"cluster", "nodes", "fabric", "workloads", "audit"}
actions = []string{"read_cluster_summary", "read_node_status"}
case FabricServiceClassOrganization:
if scope == "cluster" {
sections = []string{"cluster", "nodes", "fabric", "workloads", "audit"}
actions = []string{"read_cluster_summary", "read_node_status"}
} else {
actions = []string{"read_platform_summary", "read_cluster_summaries", "read_node_status"}
}
case FabricServiceClassPublicIngress:
sections = []string{"organization", "sessions", "resources", "audit"}
actions = []string{"read_organization_summary", "read_sessions"}
case FabricServiceClassUserPortal:
sections = []string{"profile", "sessions", "resources"}
actions = []string{"read_profile", "read_sessions"}
if scope == "user" {
sections = []string{"profile", "sessions", "resources"}
actions = []string{"read_profile", "read_sessions"}
} else {
actions = []string{"read_organization_summary", "read_sessions"}
}
}
return map[string]any{
"schema_version": adminRuntimeManifestSchema,
@@ -729,6 +783,10 @@ func (m *Module) getNodeUpdatePlan(w http.ResponseWriter, r *http.Request) {
InstallType: r.URL.Query().Get("install_type"),
Channel: r.URL.Query().Get("channel"),
ArtifactOrigin: requestOrigin(r),
ExecutorCapabilities: append(
r.URL.Query()["executor_capability"],
r.URL.Query()["executor_capabilities"]...,
),
})
if writeServiceError(w, err) {
return
@@ -736,6 +794,35 @@ func (m *Module) getNodeUpdatePlan(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"node_update_plan": item})
}
func (m *Module) getNodeUpdateArtifactContent(w http.ResponseWriter, r *http.Request) {
item, err := m.service.GetNodeUpdateArtifactContent(r.Context(), GetNodeUpdateArtifactContentInput{
ClusterID: chi.URLParam(r, "clusterID"),
NodeID: chi.URLParam(r, "nodeID"),
ArtifactID: chi.URLParam(r, "artifactID"),
Offset: parseInt64Query(r, "offset"),
Length: parseInt64Query(r, "length"),
})
if writeServiceError(w, err) {
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func parseInt64Query(r *http.Request, key string) int64 {
if r == nil {
return 0
}
value := strings.TrimSpace(r.URL.Query().Get(key))
if value == "" {
return 0
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0
}
return parsed
}
func requestOrigin(r *http.Request) string {
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
if proto == "" {
@@ -1732,35 +1819,35 @@ func (m *Module) getFabricServiceChannelPoolPolicy(w http.ResponseWriter, r *htt
func (m *Module) updateFabricServiceChannelPoolPolicy(w http.ResponseWriter, r *http.Request) {
var payload struct {
ActorUserID string `json:"actor_user_id"`
EntryPoolNodeIDs []string `json:"entry_pool_node_ids"`
ExitPoolNodeIDs []string `json:"exit_pool_node_ids"`
PreferredEntryNodeID string `json:"preferred_entry_node_id"`
PreferredExitNodeID string `json:"preferred_exit_node_id"`
SelectionStrategy string `json:"selection_strategy"`
RouteRebuild string `json:"route_rebuild"`
EntryFailover string `json:"entry_failover"`
ExitFailover string `json:"exit_failover"`
BackendFallbackAllowed *bool `json:"backend_fallback_allowed"`
StickySession *bool `json:"sticky_session"`
ActorUserID string `json:"actor_user_id"`
EntryPoolNodeIDs []string `json:"entry_pool_node_ids"`
ExitPoolNodeIDs []string `json:"exit_pool_node_ids"`
PreferredEntryNodeID string `json:"preferred_entry_node_id"`
PreferredExitNodeID string `json:"preferred_exit_node_id"`
SelectionStrategy string `json:"selection_strategy"`
RouteRebuild string `json:"route_rebuild"`
EntryFailover string `json:"entry_failover"`
ExitFailover string `json:"exit_failover"`
CompatFallbackAllowed *bool `json:"degraded_route_allowed"`
StickySession *bool `json:"sticky_session"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid pool policy payload")
return
}
item, err := m.service.UpdateFabricServiceChannelPoolPolicy(r.Context(), UpdateFabricServiceChannelPoolPolicyInput{
ActorUserID: payload.ActorUserID,
ClusterID: chi.URLParam(r, "clusterID"),
EntryPoolNodeIDs: payload.EntryPoolNodeIDs,
ExitPoolNodeIDs: payload.ExitPoolNodeIDs,
PreferredEntryNodeID: payload.PreferredEntryNodeID,
PreferredExitNodeID: payload.PreferredExitNodeID,
SelectionStrategy: payload.SelectionStrategy,
RouteRebuild: payload.RouteRebuild,
EntryFailover: payload.EntryFailover,
ExitFailover: payload.ExitFailover,
BackendFallbackAllowed: payload.BackendFallbackAllowed,
StickySession: payload.StickySession,
ActorUserID: payload.ActorUserID,
ClusterID: chi.URLParam(r, "clusterID"),
EntryPoolNodeIDs: payload.EntryPoolNodeIDs,
ExitPoolNodeIDs: payload.ExitPoolNodeIDs,
PreferredEntryNodeID: payload.PreferredEntryNodeID,
PreferredExitNodeID: payload.PreferredExitNodeID,
SelectionStrategy: payload.SelectionStrategy,
RouteRebuild: payload.RouteRebuild,
EntryFailover: payload.EntryFailover,
ExitFailover: payload.ExitFailover,
CompatFallbackAllowed: payload.CompatFallbackAllowed,
StickySession: payload.StickySession,
})
if writeServiceError(w, err) {
return
@@ -3411,7 +3498,7 @@ func writeServiceError(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
var legacyRemovalBlocked *LegacyRemovalBlockedError
var standardCleanupBlocked *FabricStandardCleanupBlockedError
switch {
case errors.Is(err, ErrAccessDenied):
httpx.WriteError(w, http.StatusForbidden, err.Error())
@@ -3419,11 +3506,11 @@ func writeServiceError(w http.ResponseWriter, err error) bool {
httpx.WriteError(w, http.StatusForbidden, err.Error())
case errors.Is(err, ErrClusterReadOnly):
httpx.WriteError(w, http.StatusConflict, err.Error())
case errors.As(err, &legacyRemovalBlocked):
case errors.As(err, &standardCleanupBlocked):
httpx.WriteErrorMessage(w, http.StatusConflict, httpx.ErrorResponse{
Error: httpx.NewErrorMessage(http.StatusConflict, err.Error(), legacyRemovalBlockedErrorDetails(*legacyRemovalBlocked), ""),
Error: httpx.NewErrorMessage(http.StatusConflict, err.Error(), FabricStandardCleanupBlockedErrorDetails(*standardCleanupBlocked), ""),
})
case errors.Is(err, ErrLegacyRemovalBlocked):
case errors.Is(err, ErrFabricStandardCleanupBlocked):
httpx.WriteError(w, http.StatusConflict, err.Error())
case errors.Is(err, ErrVPNLeaseAlreadyActive):
httpx.WriteError(w, http.StatusConflict, err.Error())
@@ -3437,24 +3524,31 @@ func writeServiceError(w http.ResponseWriter, err error) bool {
return true
}
func legacyRemovalBlockedErrorDetails(err LegacyRemovalBlockedError) map[string]any {
func FabricStandardCleanupBlockedErrorDetails(err FabricStandardCleanupBlockedError) map[string]any {
details := map[string]any{
"blocked_operation": err.BlockedOperation,
"legacy_removal_allowed": err.Report.LegacyRemovalAllowed,
"bridge_hold_required": err.Report.BridgeHoldRequired,
"bridge_hold_reasons": err.Report.BridgeHoldReasons,
"blocked_operations": err.Report.BlockedOperations,
"heartbeat_stale_after_seconds": err.Report.HeartbeatStaleAfterSeconds,
"stale_nodes": err.Report.Summary.StaleNodes,
"blocked_nodes": err.Report.Summary.BlockedNodes,
"artifact_gap_nodes": err.Report.Summary.ArtifactGapNodes,
"unknown_profile_nodes": err.Report.Summary.UnknownProfileNodes,
"waiting_update_status_nodes": err.Report.Summary.WaitingUpdateStatusNodes,
"unknown_version_nodes": err.Report.Summary.UnknownVersionNodes,
"legacy_recovery_contract_nodes": err.Report.Summary.LegacyRecoveryContractNodes,
"recovery_bridge_required_nodes": err.Report.Summary.RecoveryBridgeRequiredNodes,
"blocked_operation": err.BlockedOperation,
"fabric_standard_cleanup_allowed": err.Report.FabricStandardCleanupAllowed,
"bridge_hold_required": err.Report.BridgeHoldRequired,
"bridge_hold_reasons": err.Report.BridgeHoldReasons,
"blocked_operations": err.Report.BlockedOperations,
"heartbeat_stale_after_seconds": err.Report.HeartbeatStaleAfterSeconds,
"stale_nodes": err.Report.Summary.StaleNodes,
"blocked_nodes": err.Report.Summary.BlockedNodes,
"artifact_gap_nodes": err.Report.Summary.ArtifactGapNodes,
"area_diversity_alert_nodes": err.Report.Summary.AreaDiversityAlertNodes,
"independent_ingress_alert_nodes": err.Report.Summary.IndependentIngressAlertNodes,
"updater_wake_unsupported_nodes": err.Report.Summary.UpdaterWakeUnsupportedNodes,
"updater_runtime_missing_nodes": err.Report.Summary.UpdaterRuntimeMissingNodes,
"staged_self_update_pending_nodes": err.Report.Summary.StagedSelfUpdatePendingNodes,
"standard_control_dependency_nodes": err.Report.Summary.StandardControlDependencyNodes,
"registry_candidate_only_nodes": err.Report.Summary.RegistryCandidateOnlyNodes,
"unknown_profile_nodes": err.Report.Summary.UnknownProfileNodes,
"waiting_update_status_nodes": err.Report.Summary.WaitingUpdateStatusNodes,
"unknown_version_nodes": err.Report.Summary.UnknownVersionNodes,
"standard_recovery_contract_nodes": err.Report.Summary.StandardRecoveryContractNodes,
"recovery_bridge_required_nodes": err.Report.Summary.RecoveryBridgeRequiredNodes,
"recovery_bridge_replay_ready_nodes": err.Report.Summary.RecoveryBridgeReplayReadyNodes,
"waiting_recovery_heartbeat_nodes": err.Report.Summary.WaitingRecoveryHeartbeatNodes,
"waiting_recovery_heartbeat_nodes": err.Report.Summary.WaitingRecoveryHeartbeatNodes,
}
blockedNodeIDs := make([]string, 0, len(err.Report.Nodes))
for _, node := range err.Report.Nodes {