2bec257289
iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。 - Go module:iotlcm-worker-svr → git.5iot.com/iotlcm-public/iotlcm-worker-svr(214 文件) - 凭据与内部标识脱敏:MySQL/Redis 凭据、私网与生产 IP、内网域名、 库名 xiujie_admin → iotlcm、哨兵名占位 - 159 处真实手机号 → 合法 11 位编造号(保留号段与后 4 位,维持互异, 不破坏「校验 MSISDN 格式」等测试断言) - 移除内部资产:CI 触发器、Jenkins 触发测试文档、预演门禁与镜像 digest 记录 - Dockerfile 改用公共基础镜像(golang:1.25-alpine → alpine:3.23), 移除写死的 git 凭据 - README 重写(架构/全量同步模型/配置/已知失败),补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes 已知:3 个测试包在源仓同一提交上同样失败(client/upstream、 service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
322 lines
11 KiB
Go
322 lines
11 KiB
Go
// Package httpapi exposes the internal full-sync control API through contract
|
|
// interfaces. Persistence and replay execution belong to the injected services.
|
|
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
|
|
fullsyncruntime "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/runtime"
|
|
|
|
"github.com/gogf/gf/v2/errors/gerror"
|
|
)
|
|
|
|
const maxReplayBodyBytes = 64 << 10
|
|
|
|
// Dependencies are the only application services used by the HTTP handlers.
|
|
type Dependencies struct {
|
|
Runs contract.RunReader
|
|
List contract.RunLister
|
|
Failures contract.FailurePager
|
|
Replays contract.ReplayRequester
|
|
Actions contract.ActionReader
|
|
Simple *fullsyncruntime.SimpleReplay
|
|
}
|
|
|
|
// ErrorResponse is shared by authentication, routing, validation and service
|
|
// failures. Messages deliberately exclude underlying storage error details.
|
|
type ErrorResponse struct {
|
|
Error APIError `json:"error"`
|
|
}
|
|
|
|
type APIError struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// NewHandler exposes the control routes on the private Docker network. Admin
|
|
// performs the user JWT and permission checks before calling this listener.
|
|
func NewHandler(bearerToken string, deps Dependencies) (http.Handler, error) {
|
|
if strings.ContainsFunc(bearerToken, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) {
|
|
return nil, gerror.New("invalid full-sync HTTP token")
|
|
}
|
|
if deps.Runs == nil || deps.List == nil || deps.Failures == nil || deps.Replays == nil || deps.Actions == nil {
|
|
return nil, gerror.New("full-sync HTTP requires run list, run, failure, replay and action services")
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(contract.ListRunsRoute, method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) {
|
|
query, err := url.ParseQuery(r.URL.RawQuery)
|
|
if err != nil || len(query["limit"]) > 1 || len(query["syncKind"]) > 1 {
|
|
writeError(w, contract.ErrInvalidArgument)
|
|
return
|
|
}
|
|
filter := contract.RunListFilter{SyncKind: contract.SyncKind(query.Get("syncKind"))}
|
|
if filter.SyncKind != "" && !filter.SyncKind.Valid() {
|
|
writeError(w, contract.ErrInvalidArgument)
|
|
return
|
|
}
|
|
if query.Get("limit") != "" {
|
|
filter.Limit, err = strconv.Atoi(query.Get("limit"))
|
|
if err != nil || filter.Limit < 1 || filter.Limit > 100 {
|
|
writeError(w, contract.ErrInvalidArgument)
|
|
return
|
|
}
|
|
}
|
|
result, err := deps.List.ListRuns(r.Context(), filter)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}))
|
|
mux.HandleFunc(contract.GetRunRoute, method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("runId")
|
|
if err := contract.ValidateID(id); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
view, err := deps.Runs.GetRun(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, view)
|
|
}))
|
|
mux.HandleFunc(contract.GetFailuresRoute, method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("runId")
|
|
if err := contract.ValidateID(id); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
page, err := parsePage(r.URL.RawQuery)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
result, err := deps.Failures.ListFailures(r.Context(), id, page)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
if result.Items == nil {
|
|
result.Items = []contract.Failure{}
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}))
|
|
mux.HandleFunc(contract.PostReplayRoute, method(http.MethodPost, func(w http.ResponseWriter, r *http.Request) {
|
|
if deps.Simple != nil {
|
|
id := r.PathValue("runId")
|
|
if err := contract.ValidateID(id); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
var request struct {
|
|
RequestedBy string `json:"requestedBy"`
|
|
MaxRounds int `json:"maxRounds"`
|
|
}
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxReplayBodyBytes))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&request); err != nil {
|
|
writeError(w, contract.ErrInvalidArgument)
|
|
return
|
|
}
|
|
if err := decoder.Decode(new(any)); err != io.EOF {
|
|
writeError(w, contract.ErrInvalidArgument)
|
|
return
|
|
}
|
|
accepted, err := deps.Simple.Request(r.Context(), id, request.RequestedBy, request.MaxRounds)
|
|
if errors.Is(err, fullsyncruntime.ErrNoFailures) {
|
|
writeAPIError(w, http.StatusUnprocessableEntity, "no_failures", "Run has no failures to replay")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, accepted)
|
|
return
|
|
}
|
|
request, err := parseReplay(w, r)
|
|
if err != nil {
|
|
var tooLarge *http.MaxBytesError
|
|
if errors.As(err, &tooLarge) {
|
|
writeAPIError(w, http.StatusRequestEntityTooLarge, "body_too_large", "Replay request body is too large")
|
|
} else {
|
|
writeError(w, err)
|
|
}
|
|
return
|
|
}
|
|
accepted, err := deps.Replays.RequestReplay(r.Context(), request)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
// Construct a local status URL from the returned identity, never an
|
|
// externally supplied redirect. Invalid service output is a server error.
|
|
accepted.StatusURL, err = contract.ActionStatusURL(accepted.ActionID)
|
|
if err != nil || contract.ValidateID(accepted.TargetRunID) != nil || !accepted.Status.Valid() {
|
|
writeAPIError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
|
|
return
|
|
}
|
|
w.Header().Set("Location", accepted.StatusURL)
|
|
writeJSON(w, http.StatusAccepted, accepted)
|
|
}))
|
|
mux.HandleFunc("/internal/v1/full-sync-replays/{replayRunId}", method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) {
|
|
if deps.Simple == nil {
|
|
writeError(w, contract.ErrNotFound)
|
|
return
|
|
}
|
|
status, err := deps.Simple.Status(r.Context(), r.PathValue("replayRunId"))
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, status)
|
|
}))
|
|
mux.HandleFunc(contract.GetActionRoute, method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) {
|
|
if deps.Simple != nil {
|
|
writeError(w, contract.ErrNotFound)
|
|
return
|
|
}
|
|
id := r.PathValue("actionId")
|
|
if err := contract.ValidateID(id); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
action, err := deps.Actions.GetReplayAction(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, action)
|
|
}))
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeError(w, contract.ErrNotFound)
|
|
})
|
|
if bearerToken == "" {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
mux.ServeHTTP(w, r)
|
|
}), nil
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
values := r.Header.Values("Authorization")
|
|
if len(values) == 1 {
|
|
parts := strings.Fields(values[0])
|
|
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") && subtle.ConstantTimeCompare([]byte(parts[1]), []byte(bearerToken)) == 1 {
|
|
mux.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="full-sync"`)
|
|
writeAPIError(w, http.StatusUnauthorized, "unauthorized", "Valid bearer authentication is required")
|
|
}), nil
|
|
}
|
|
|
|
func method(expected string, next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != expected {
|
|
w.Header().Set("Allow", expected)
|
|
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func parsePage(rawQuery string) (contract.PageRequest, error) {
|
|
query, err := url.ParseQuery(rawQuery)
|
|
if err != nil || len(query["cursor"]) > 1 || len(query["limit"]) > 1 {
|
|
return contract.PageRequest{}, gerror.Wrap(contract.ErrInvalidArgument, "invalid pagination query")
|
|
}
|
|
page := contract.PageRequest{Cursor: contract.Cursor(query.Get("cursor"))}
|
|
if value := query.Get("limit"); value != "" {
|
|
page.Limit, err = strconv.Atoi(value)
|
|
if err != nil {
|
|
return contract.PageRequest{}, gerror.Wrap(contract.ErrInvalidArgument, "limit must be an integer")
|
|
}
|
|
}
|
|
return page.Normalized()
|
|
}
|
|
|
|
func parseReplay(w http.ResponseWriter, r *http.Request) (contract.ReplayRequest, error) {
|
|
var request contract.ReplayRequest
|
|
if value := r.Header.Get("Content-Type"); value != "" {
|
|
mediaType, _, err := mime.ParseMediaType(value)
|
|
if err != nil || mediaType != "application/json" {
|
|
return request, gerror.Wrap(contract.ErrInvalidArgument, "replay body must be JSON")
|
|
}
|
|
}
|
|
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxReplayBodyBytes))
|
|
if err != nil {
|
|
var tooLarge *http.MaxBytesError
|
|
if errors.As(err, &tooLarge) {
|
|
return request, err
|
|
}
|
|
return request, gerror.Wrap(contract.ErrInvalidArgument, "cannot read replay body")
|
|
}
|
|
if !utf8.Valid(data) {
|
|
return request, gerror.Wrap(contract.ErrInvalidArgument, "replay body must be UTF-8")
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&request); err != nil {
|
|
return request, gerror.Wrap(contract.ErrInvalidArgument, "invalid replay JSON")
|
|
}
|
|
if err := decoder.Decode(new(any)); err != io.EOF {
|
|
return request, gerror.Wrap(contract.ErrInvalidArgument, "replay body must contain exactly one JSON object")
|
|
}
|
|
if len(r.Header.Values(contract.IdempotencyHeader)) != 1 {
|
|
return request, gerror.Wrap(contract.ErrInvalidArgument, "exactly one Idempotency-Key is required")
|
|
}
|
|
request.SourceRunID = r.PathValue("runId")
|
|
request.IdempotencyKey = r.Header.Get(contract.IdempotencyHeader)
|
|
return request, request.Validate()
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, contract.ErrInvalidArgument):
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_argument", "Invalid request")
|
|
case errors.Is(err, contract.ErrNotFound):
|
|
writeAPIError(w, http.StatusNotFound, "not_found", "Resource not found")
|
|
case errors.Is(err, contract.ErrFailureDetailsUnavailable):
|
|
writeAPIError(w, http.StatusGone, "failure_details_unavailable", "Live failure details are no longer available")
|
|
case errors.Is(err, contract.ErrConflict), errors.Is(err, contract.ErrLeaseLost):
|
|
writeAPIError(w, http.StatusConflict, "conflict", "Request conflicts with the current resource")
|
|
case errors.Is(err, contract.ErrBusy):
|
|
writeAPIError(w, http.StatusServiceUnavailable, "busy", "Resource is busy; retry later")
|
|
default:
|
|
writeAPIError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
|
|
}
|
|
}
|
|
|
|
func writeAPIError(w http.ResponseWriter, status int, code, message string) {
|
|
writeJSON(w, status, ErrorResponse{Error: APIError{Code: code, Message: message}})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
|
// Marshal before writing headers so an invalid service payload cannot
|
|
// produce a successful status with truncated JSON.
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
status = http.StatusInternalServerError
|
|
data = []byte(`{"error":{"code":"internal_error","message":"Internal server error"}}`)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(append(data, '\n'))
|
|
}
|