mirror of
https://github.com/hanzoai/platform-services.git
synced 2026-10-30 18:23:56 +00:00
334 lines
13 KiB
Go
334 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
)
|
|
|
|
// This file holds the pure-proxy category handlers — cloudStatus, platform,
|
|
// infrastructure, explorer, search, vector, natural-language filters. Each is a
|
|
// typed front for the same HTTP API the original tRPC router called: it
|
|
// validates required upstream config at the boundary (StatusPrecondition when
|
|
// unset, mirroring the routers' PRECONDITION_FAILED), decodes its JSON Params,
|
|
// and proxies via doJSON. None stores state locally.
|
|
|
|
// --- cloudStatus (cloudStatusRouter) ---------------------------------------
|
|
//
|
|
// getStatus: incident.io summary. Self-host (no cloud region) returns null,
|
|
// exactly as the router short-circuited.
|
|
func (s *Server) handleCloudStatus(ctx context.Context, _ string, req psRequest) (uint32, []byte) {
|
|
if req.Op != OpCloudGetStatus {
|
|
return fail(StatusBadRequest, fmt.Sprintf("cloudStatus: unknown op %d", req.Op))
|
|
}
|
|
if s.up.CloudRegion == "" {
|
|
// Self-host: no cloud status feed. Router returned { status: null }.
|
|
return respond(StatusOK, map[string]any{"status": nil})
|
|
}
|
|
pr, err := doJSON(ctx, "GET", s.up.CloudStatusURL, nil, "", nil)
|
|
if err != nil {
|
|
s.logger.Debug("cloudStatus proxy", "err", err)
|
|
// Router swallowed fetch errors and returned null rather than failing.
|
|
return respond(StatusOK, map[string]any{"status": nil})
|
|
}
|
|
return proxied(pr)
|
|
}
|
|
|
|
// --- platform (platformRouter) ---------------------------------------------
|
|
//
|
|
// All four ops hit the PaaS container API. paasBase asserts the required env;
|
|
// the container/pipeline path is derived per op.
|
|
func (s *Server) handlePlatform(ctx context.Context, _ string, req psRequest) (uint32, []byte) {
|
|
base, status, msg := s.paasBase()
|
|
if status != StatusOK {
|
|
return fail(status, msg)
|
|
}
|
|
var p struct {
|
|
ProjectID string `json:"projectId"`
|
|
ContainerID string `json:"containerId"`
|
|
}
|
|
if !req.bind(&p) {
|
|
return fail(StatusBadRequest, "platform: malformed params")
|
|
}
|
|
|
|
switch req.Op {
|
|
case OpPlatformListContainers:
|
|
return proxyOr(s, ctx, "GET", base+"/container", nil)
|
|
case OpPlatformGetContainer:
|
|
if p.ContainerID == "" {
|
|
return fail(StatusBadRequest, "platform.getContainer: containerId required")
|
|
}
|
|
return proxyOr(s, ctx, "GET", base+"/container/"+url.PathEscape(p.ContainerID), nil)
|
|
case OpPlatformListPipelines:
|
|
if p.ContainerID == "" {
|
|
return fail(StatusBadRequest, "platform.listPipelines: containerId required")
|
|
}
|
|
return proxyOr(s, ctx, "GET", base+"/container/"+url.PathEscape(p.ContainerID)+"/pipelines", nil)
|
|
case OpPlatformTriggerBuild:
|
|
if p.ContainerID == "" {
|
|
return fail(StatusBadRequest, "platform.triggerBuild: containerId required")
|
|
}
|
|
return proxyOr(s, ctx, "POST", base+"/container/"+url.PathEscape(p.ContainerID)+"/redeploy", nil)
|
|
default:
|
|
return fail(StatusBadRequest, fmt.Sprintf("platform: unknown op %d", req.Op))
|
|
}
|
|
}
|
|
|
|
// --- infrastructure (infrastructureRouter) ---------------------------------
|
|
//
|
|
// getServiceHealth and getDeploymentEvents both read the PaaS container list
|
|
// (and, for events, each container's pipelines) then DERIVE their result. The
|
|
// derivation (health status, event aggregation) lived in the router; here it is
|
|
// preserved as a TODO because it requires multi-call fan-out + reshaping that
|
|
// the console client currently performs against the raw PaaS payload. We proxy
|
|
// the raw container list through so the client keeps working unchanged.
|
|
//
|
|
// TODO(infrastructure): port deriveHealthStatus + getDeploymentEvents
|
|
// aggregation (read /container, then per-container /pipelines, fold to
|
|
// ServiceHealth[] / DeploymentEvent[]). Until then the raw PaaS container list
|
|
// is returned and the console derives client-side. Driven by PAAS_* env.
|
|
func (s *Server) handleInfra(ctx context.Context, _ string, req psRequest) (uint32, []byte) {
|
|
base, status, msg := s.paasBase()
|
|
if status != StatusOK {
|
|
return fail(status, msg)
|
|
}
|
|
switch req.Op {
|
|
case OpInfraServiceHealth, OpInfraDeploymentEvents:
|
|
return proxyOr(s, ctx, "GET", base+"/container", nil)
|
|
default:
|
|
return fail(StatusBadRequest, fmt.Sprintf("infrastructure: unknown op %d", req.Op))
|
|
}
|
|
}
|
|
|
|
// --- explorer (explorerRouter) ---------------------------------------------
|
|
//
|
|
// Lux Explorer per-network stats/health. URLs are derived per network; no env
|
|
// config (the router baked them in).
|
|
func (s *Server) handleExplorer(ctx context.Context, _ string, req psRequest) (uint32, []byte) {
|
|
var p struct {
|
|
Network string `json:"network"`
|
|
}
|
|
if !req.bind(&p) {
|
|
return fail(StatusBadRequest, "explorer: malformed params")
|
|
}
|
|
|
|
switch req.Op {
|
|
case OpExplorerStats:
|
|
if !validNetwork(p.Network) {
|
|
return fail(StatusBadRequest, "explorer.getStats: network must be mainnet|testnet|devnet")
|
|
}
|
|
return proxyOr(s, ctx, "GET", explorerStatsURL(p.Network), nil)
|
|
case OpExplorerAllStats:
|
|
return s.explorerAll(ctx)
|
|
case OpExplorerHealth:
|
|
return s.explorerAll(ctx)
|
|
default:
|
|
return fail(StatusBadRequest, fmt.Sprintf("explorer: unknown op %d", req.Op))
|
|
}
|
|
}
|
|
|
|
// --- search (searchRouter) -------------------------------------------------
|
|
//
|
|
// Hanzo Search/Docs API. All ops require HANZO_SEARCH_API_KEY. Paths mirror the
|
|
// router 1:1 (/api/search-docs/*, /api/scrape-docs, /api/chat-docs).
|
|
func (s *Server) handleSearch(ctx context.Context, _ string, req psRequest) (uint32, []byte) {
|
|
if s.up.SearchAPIKey == "" {
|
|
return fail(StatusPrecondition, "search: HANZO_SEARCH_API_KEY not configured")
|
|
}
|
|
base := s.up.SearchURL
|
|
key := s.up.SearchAPIKey
|
|
var p struct {
|
|
StoreName string `json:"storeName"`
|
|
KeyType string `json:"keyType"`
|
|
}
|
|
if !req.bind(&p) {
|
|
return fail(StatusBadRequest, "search: malformed params")
|
|
}
|
|
|
|
switch req.Op {
|
|
case OpSearchStats:
|
|
return proxyKeyed(s, ctx, "GET", base+"/api/search-docs/stats", nil, key)
|
|
case OpSearchListIndexes:
|
|
return proxyKeyed(s, ctx, "GET", base+"/api/search-docs/indexes", nil, key)
|
|
case OpSearchCreateIndex:
|
|
return proxyKeyed(s, ctx, "POST", base+"/api/scrape-docs", req.Params, key)
|
|
case OpSearchDeleteIndex:
|
|
if p.StoreName == "" {
|
|
return fail(StatusBadRequest, "search.deleteIndex: storeName required")
|
|
}
|
|
return proxyKeyed(s, ctx, "DELETE", base+"/api/search-docs/indexes/"+url.PathEscape(p.StoreName), nil, key)
|
|
case OpSearchReindex:
|
|
if p.StoreName == "" {
|
|
return fail(StatusBadRequest, "search.reindex: storeName required")
|
|
}
|
|
return proxyKeyed(s, ctx, "POST", base+"/api/search-docs/indexes/"+url.PathEscape(p.StoreName)+"/reindex", nil, key)
|
|
case OpSearchQuery:
|
|
return proxyKeyed(s, ctx, "POST", base+"/api/search-docs", req.Params, key)
|
|
case OpSearchChat:
|
|
return proxyKeyed(s, ctx, "POST", base+"/api/chat-docs", req.Params, key)
|
|
case OpSearchGetKeys:
|
|
return proxyKeyed(s, ctx, "GET", base+"/api/search-docs/keys", nil, key)
|
|
case OpSearchRegenerateKey:
|
|
return proxyKeyed(s, ctx, "POST", base+"/api/search-docs/keys/regenerate", req.Params, key)
|
|
case OpSearchScrapePreview:
|
|
return proxyKeyed(s, ctx, "POST", base+"/api/scrape-docs/preview", req.Params, key)
|
|
default:
|
|
return fail(StatusBadRequest, fmt.Sprintf("search: unknown op %d", req.Op))
|
|
}
|
|
}
|
|
|
|
// --- vector (vectorRouter) -------------------------------------------------
|
|
//
|
|
// Hanzo Vector API — shares the Search base URL + API key. Paths /api/vector/*.
|
|
func (s *Server) handleVector(ctx context.Context, _ string, req psRequest) (uint32, []byte) {
|
|
if s.up.SearchAPIKey == "" {
|
|
return fail(StatusPrecondition, "vector: HANZO_SEARCH_API_KEY not configured")
|
|
}
|
|
base := s.up.SearchURL
|
|
key := s.up.SearchAPIKey
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
CollectionName string `json:"collectionName"`
|
|
}
|
|
if !req.bind(&p) {
|
|
return fail(StatusBadRequest, "vector: malformed params")
|
|
}
|
|
|
|
switch req.Op {
|
|
case OpVectorStats:
|
|
return proxyKeyed(s, ctx, "GET", base+"/api/vector/stats", nil, key)
|
|
case OpVectorListCollections:
|
|
return proxyKeyed(s, ctx, "GET", base+"/api/vector/collections", nil, key)
|
|
case OpVectorCreateCollection:
|
|
return proxyKeyed(s, ctx, "POST", base+"/api/vector/collections", req.Params, key)
|
|
case OpVectorDeleteCollection:
|
|
if p.Name == "" {
|
|
return fail(StatusBadRequest, "vector.deleteCollection: name required")
|
|
}
|
|
return proxyKeyed(s, ctx, "DELETE", base+"/api/vector/collections/"+url.PathEscape(p.Name), nil, key)
|
|
case OpVectorSearch:
|
|
if p.CollectionName == "" {
|
|
return fail(StatusBadRequest, "vector.search: collectionName required")
|
|
}
|
|
return proxyKeyed(s, ctx, "POST", base+"/api/vector/collections/"+url.PathEscape(p.CollectionName)+"/search", req.Params, key)
|
|
default:
|
|
return fail(StatusBadRequest, fmt.Sprintf("vector: unknown op %d", req.Op))
|
|
}
|
|
}
|
|
|
|
// --- naturalLanguageFilters (naturalLanguageFilterRouter) ------------------
|
|
//
|
|
// createCompletion turns a prompt into structured filter conditions via AWS
|
|
// Bedrock, using a managed prompt fetched from Hanzo AI Features. This is the
|
|
// one category whose upstream is NOT a single REST proxy — it needs the AWS
|
|
// Bedrock SigV4 invoke + the AI-Features getPrompt round trip. Wiring the AWS
|
|
// SDK + SigV4 signer into this pure-Go binary is deferred.
|
|
//
|
|
// TODO(nlFilter): wire AWS Bedrock InvokeModel (model HANZO_AWS_BEDROCK_MODEL)
|
|
// behind a SigV4 signer, fetch the "get-filter-conditions-from-query" prompt
|
|
// from Hanzo AI Features (HANZO_AI_FEATURES_HOST + PUBLIC/SECRET keys, project
|
|
// HANZO_AI_FEATURES_PROJECT_ID), run the completion, parse + validate the
|
|
// FilterCondition[]. All five env vars must be present (self-host returns the
|
|
// disabled signal, matching the router's region gate).
|
|
func (s *Server) handleNLFilter(_ context.Context, _ string, req psRequest) (uint32, []byte) {
|
|
if req.Op != OpNLFilterCreateCompletion {
|
|
return fail(StatusBadRequest, fmt.Sprintf("nlFilter: unknown op %d", req.Op))
|
|
}
|
|
if s.up.CloudRegion == "" {
|
|
return fail(StatusPrecondition, "nlFilter: disabled on self-host (NEXT_PUBLIC_HANZO_CLOUD_REGION unset)")
|
|
}
|
|
for k, v := range map[string]string{
|
|
"HANZO_AWS_BEDROCK_MODEL": s.up.BedrockModel,
|
|
"HANZO_AI_FEATURES_PUBLIC_KEY": s.up.AIFeaturesPublic,
|
|
"HANZO_AI_FEATURES_SECRET_KEY": s.up.AIFeaturesSecret,
|
|
"HANZO_AI_FEATURES_PROJECT_ID": s.up.AIFeaturesProject,
|
|
} {
|
|
if v == "" {
|
|
return fail(StatusPrecondition, "nlFilter: "+k+" not configured")
|
|
}
|
|
}
|
|
// Upstream Bedrock+SigV4 invoke not yet wired (see TODO above).
|
|
return fail(StatusInternal, "nlFilter: Bedrock completion upstream not yet wired (see TODO in handlers_proxy.go)")
|
|
}
|
|
|
|
// --- shared proxy helpers ---------------------------------------------------
|
|
|
|
// paasBase asserts the required PaaS env and returns the org/project/env-scoped
|
|
// base URL prefix (…/v1/org/{org}/project/{proj}/env/{env}).
|
|
func (s *Server) paasBase() (base string, status uint32, msg string) {
|
|
for k, v := range map[string]string{
|
|
"PAAS_ORG_ID": s.up.PaaSOrgID,
|
|
"PAAS_PROJECT_ID": s.up.PaaSProjectID,
|
|
"PAAS_ENV_ID": s.up.PaaSEnvID,
|
|
"PAAS_SERVICE_TOKEN": s.up.PaaSServiceToken,
|
|
} {
|
|
if v == "" {
|
|
return "", StatusPrecondition, "platform/infrastructure: " + k + " not configured"
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s/v1/org/%s/project/%s/env/%s",
|
|
s.up.PaaSURL, s.up.PaaSOrgID, s.up.PaaSProjectID, s.up.PaaSEnvID), StatusOK, ""
|
|
}
|
|
|
|
// proxyOr proxies a PaaS request (Bearer = PAAS_SERVICE_TOKEN) and adapts the
|
|
// result, translating a transport error into a StatusBadGateway error body.
|
|
func proxyOr(s *Server, ctx context.Context, method, u string, body []byte) (uint32, []byte) {
|
|
pr, err := doJSON(ctx, method, u, body, s.up.PaaSServiceToken, nil)
|
|
if err != nil {
|
|
s.logger.Debug("paas proxy", "method", method, "err", err)
|
|
return fail(StatusBadGateway, "platform upstream: "+err.Error())
|
|
}
|
|
return proxied(pr)
|
|
}
|
|
|
|
// proxyKeyed proxies with an explicit bearer key (search/vector) and adapts.
|
|
func proxyKeyed(s *Server, ctx context.Context, method, u string, body []byte, key string) (uint32, []byte) {
|
|
pr, err := doJSON(ctx, method, u, body, key, nil)
|
|
if err != nil {
|
|
s.logger.Debug("keyed proxy", "method", method, "err", err)
|
|
return fail(StatusBadGateway, "upstream: "+err.Error())
|
|
}
|
|
return proxied(pr)
|
|
}
|
|
|
|
// validNetwork reports whether n is one of the three Lux networks.
|
|
func validNetwork(n string) bool {
|
|
return n == "mainnet" || n == "testnet" || n == "devnet"
|
|
}
|
|
|
|
// explorerStatsURL builds the Lux Explorer v2 stats URL for a network.
|
|
func explorerStatsURL(network string) string {
|
|
return fmt.Sprintf("https://api-explore-%s.lux.network/api/v2/stats", network)
|
|
}
|
|
|
|
// explorerAll fans out to all three networks and returns an array of raw stats,
|
|
// each tagged with its network, with a zero/unhealthy fallback per network —
|
|
// matching the router's Promise.allSettled behaviour.
|
|
func (s *Server) explorerAll(ctx context.Context) (uint32, []byte) {
|
|
networks := []string{"mainnet", "testnet", "devnet"}
|
|
out := make([]json.RawMessage, 0, len(networks))
|
|
for _, n := range networks {
|
|
pr, err := doJSON(ctx, "GET", explorerStatsURL(n), nil, "", nil)
|
|
if err != nil || pr.Status != StatusOK || len(pr.Body) == 0 {
|
|
out = append(out, json.RawMessage(fmt.Sprintf(`{"network":%q,"healthy":false}`, n)))
|
|
continue
|
|
}
|
|
out = append(out, tagNetwork(n, pr.Body))
|
|
}
|
|
return respond(StatusOK, out)
|
|
}
|
|
|
|
// tagNetwork merges {"network":n} into a JSON object body. If the body is not a
|
|
// JSON object it is wrapped as {"network":n,"stats":<body>}.
|
|
func tagNetwork(n string, body []byte) json.RawMessage {
|
|
var obj map[string]json.RawMessage
|
|
if json.Unmarshal(body, &obj) == nil {
|
|
obj["network"], _ = json.Marshal(n)
|
|
if merged, err := json.Marshal(obj); err == nil {
|
|
return merged
|
|
}
|
|
}
|
|
return json.RawMessage(fmt.Sprintf(`{"network":%q,"stats":%s}`, n, body))
|
|
}
|