dcdc8d1ddd
全栈知识治理平台首次入库(P1-P10 + 发布链路模拟验证闭环): - 治理核心:源目录/变更集/人工审核/发布批次/发布执行(fail-closed 闸口) - 测试通道:WeKnora 测试 KB 上传/轮询/验收;poll 完成时写入 parser_generation/index_generation,修复 manual/seed 路径发布闸口永久拦截 - 发布适配器 v071(本地 source=测试角色 / 云端 target=生产角色 模拟验证通过): * ES 导出/导入 mapping 自适应(.keyword 子字段、可选 tenant_id) * OSS locator 支持嵌套 scheme;chunks.seq_id 由目标序列分配 * bundle 归档同身份重试原子替换;verifier 按 bundle chunk_type 计数 * 回滚 job payload 支持 prior_execution_id(strict 解码) - 三通道发布+回滚演练实测:publish 4/4 verified,检索命中, rollback 4/4 rolled_back,目标端 PG is_enabled=false、ES is_enabled=false - 前端 A·Data-console 风格全站统一,120 用例通过 - 门禁:go vet / go test 全包通过,gofmt 干净
1966 lines
85 KiB
Go
1966 lines
85 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
"knowledge-governance/internal/jobs"
|
|
"knowledge-governance/internal/monitoring"
|
|
postgresstore "knowledge-governance/internal/platform/postgres"
|
|
)
|
|
|
|
type JobStore interface {
|
|
Enqueue(ctx context.Context, request jobs.EnqueueRequest, now time.Time) (*jobs.Job, error)
|
|
List(ctx context.Context, limit int) ([]jobs.Job, error)
|
|
}
|
|
|
|
type MonitorProvider interface {
|
|
Overview(ctx context.Context) (governance.MonitorOverview, error)
|
|
ListFiles(ctx context.Context, knowledgeBaseID, status string, limit, offset int) (governance.KnowledgeMonitorFilePage, error)
|
|
Metrics(ctx context.Context) (governance.RunMetrics, error)
|
|
Services(ctx context.Context) []governance.ServiceHealth
|
|
}
|
|
|
|
type Store interface {
|
|
JobStore
|
|
ListSourceConnections(ctx context.Context) (governance.SourceConnectionPage, error)
|
|
CreateSourceSettingsRevisionWithOperation(ctx context.Context, connectionID string, draft postgresstore.SourceSettingsDraft, operationID string, now time.Time) (string, bool, error)
|
|
ActivateSourceSettingsRevisionWithOperation(ctx context.Context, connectionID, revisionID, operationID string, now time.Time) (bool, error)
|
|
ListSourceConnectionRevisions(ctx context.Context, connectionID string) ([]governance.SourceConnectionRevision, error)
|
|
TriggerPPSourceScan(ctx context.Context, connectionID string, now time.Time) (governance.SourceScan, error)
|
|
TriggerPPSourceScanWithOperation(ctx context.Context, connectionID string, now time.Time, operationID string) (governance.SourceScan, bool, error)
|
|
ListSourceScans(ctx context.Context, connectionID string, limit, offset int) (governance.SourceScanPage, error)
|
|
GetSourceScanDetail(ctx context.Context, connectionID, scanID string) (governance.SourceScanDetailView, error)
|
|
ListReviewCandidates(ctx context.Context, filter governance.ReviewCandidateFilter) (governance.ReviewCandidatePage, error)
|
|
ReviewPublication(ctx context.Context, request governance.ReviewPublicationRequest) (governance.PublicationReview, error)
|
|
ReviewPublicationWithOperation(ctx context.Context, request governance.ReviewPublicationRequest, operationID string) (governance.PublicationReview, bool, error)
|
|
CreateReleaseBatch(ctx context.Context, request governance.CreateReleaseBatchRequest) (governance.ReleaseBatch, error)
|
|
CreateReleaseBatchWithOperation(ctx context.Context, request governance.CreateReleaseBatchRequest, operationID string) (governance.ReleaseBatch, bool, error)
|
|
GetReleaseAuditChain(ctx context.Context, executionID string) (governance.ReleaseAuditChain, error)
|
|
GenerateReleaseChangeSetWithOperation(ctx context.Context, request governance.GenerateReleaseChangeSetRequest, operationID string) (governance.ReleaseChangeSet, bool, error)
|
|
ConfirmReleaseChangeSetWithOperation(ctx context.Context, request governance.ConfirmReleaseChangeSetRequest, operationID string) (governance.ReleaseChangeSet, bool, error)
|
|
ListReleaseChangeSets(ctx context.Context, filter governance.ReleaseBatchFilter) (governance.ReleaseChangeSetPage, error)
|
|
GetReleaseChangeSet(ctx context.Context, id string) (governance.ReleaseChangeSetDetail, error)
|
|
LatestReleaseChangeSet(ctx context.Context) (governance.ReleaseChangeSetDetail, error)
|
|
SealReleaseBatch(ctx context.Context, request governance.SealReleaseBatchRequest) (governance.ReleaseBatch, error)
|
|
SealReleaseBatchWithOperation(ctx context.Context, request governance.SealReleaseBatchRequest, operationID string) (governance.ReleaseBatch, bool, error)
|
|
ListReleaseBatches(ctx context.Context, filter governance.ReleaseBatchFilter) (governance.ReleaseBatchPage, error)
|
|
GetReleaseBatch(ctx context.Context, batchID string) (governance.ReleaseBatchDetail, error)
|
|
AuthorizeReleaseBatch(ctx context.Context, request governance.AuthorizeReleaseBatchRequest) (governance.ReleaseExecution, error)
|
|
AuthorizeReleaseBatchWithOperation(ctx context.Context, request governance.AuthorizeReleaseBatchRequest, operationID string) (governance.ReleaseExecution, bool, error)
|
|
AuthorizeReleaseRollback(ctx context.Context, request governance.AuthorizeReleaseRollbackRequest) (governance.ReleaseExecution, error)
|
|
AuthorizeReleaseRollbackWithOperation(ctx context.Context, request governance.AuthorizeReleaseRollbackRequest, operationID string) (governance.ReleaseExecution, bool, error)
|
|
RetryReleaseExecution(ctx context.Context, request governance.RetryReleaseExecutionRequest) (governance.ReleaseExecution, error)
|
|
RetryReleaseExecutionWithOperation(ctx context.Context, request governance.RetryReleaseExecutionRequest, operationID string) (governance.ReleaseExecution, bool, error)
|
|
ListReleaseExecutions(ctx context.Context, filter governance.ReleaseExecutionFilter) (governance.ReleaseExecutionPage, error)
|
|
GetReleaseExecution(ctx context.Context, executionID string) (governance.ReleaseExecutionDetail, error)
|
|
ListReleasePlan(ctx context.Context) (governance.ReleasePlan, error)
|
|
GetActiveUISettings(ctx context.Context) (governance.UISettings, error)
|
|
CreateUISettingsCandidate(ctx context.Context, request governance.CreateUISettingsCandidateRequest) (governance.UISettings, error)
|
|
CreateUISettingsCandidateWithOperation(ctx context.Context, request governance.CreateUISettingsCandidateRequest, operationID string) (governance.UISettings, bool, error)
|
|
ActivateUISettings(ctx context.Context, revisionID string, now time.Time) (governance.UISettings, error)
|
|
ActivateUISettingsWithOperation(ctx context.Context, revisionID string, now time.Time, operationID string) (governance.UISettings, bool, error)
|
|
ListSourceAssets(ctx context.Context, filter governance.SourceAssetFilter) (governance.SourceAssetPage, error)
|
|
ListCatalogSources(ctx context.Context) ([]governance.CatalogSource, error)
|
|
ListCatalogNodes(ctx context.Context, filter governance.CatalogNodeFilter) (governance.CatalogNodePage, error)
|
|
ListCatalogSnapshots(ctx context.Context, connectionID string, limit, offset int) (governance.CatalogSnapshotPage, error)
|
|
ListCatalogNodesInSnapshot(ctx context.Context, filter governance.CatalogSnapshotNodeFilter) (governance.CatalogNodePage, error)
|
|
GetLatestCatalogChangeSet(ctx context.Context, filter governance.CatalogChangeSetFilter) (governance.CatalogChangeSetDetail, error)
|
|
RouteCatalogFiles(ctx context.Context, request governance.CatalogRouteRequest) (governance.CatalogRouteResult, error)
|
|
RouteCatalogFilesWithOperation(ctx context.Context, request governance.CatalogRouteRequest, operationID string) (governance.CatalogRouteResult, bool, error)
|
|
EnqueueCatalogRefreshWithOperation(ctx context.Context, source governance.CatalogSource, operationID string, now time.Time) (jobs.Job, bool, error)
|
|
ListScopeRules(ctx context.Context, connectionID string) ([]governance.ScopeRule, error)
|
|
MutateScopeRule(ctx context.Context, request governance.ScopeRuleMutation) (governance.ScopeRule, error)
|
|
MutateScopeRuleWithOperation(ctx context.Context, request governance.ScopeRuleMutation, operationID string) (governance.ScopeRule, bool, error)
|
|
ListTestPublications(ctx context.Context, filter governance.TestPublicationFilter) (governance.TestPublicationPage, error)
|
|
StartTestPublications(ctx context.Context, request governance.StartTestPublicationsRequest) (governance.StartTestPublicationsResult, error)
|
|
StartTestPublicationsWithOperation(ctx context.Context, request governance.StartTestPublicationsRequest, operationID string) (governance.StartTestPublicationsResult, bool, error)
|
|
ConfirmCatalogChangeSetWithOperation(ctx context.Context, request governance.ConfirmCatalogChangeSetRequest, operationID string) (governance.ConfirmCatalogChangeSetResult, bool, error)
|
|
ListAcceptanceResults(ctx context.Context, publicationID string) ([]governance.AcceptanceRecord, error)
|
|
RetryPublication(ctx context.Context, publicationID string, now time.Time) error
|
|
RetryPublicationWithOperation(ctx context.Context, publicationID string, now time.Time, operationID string) (bool, error)
|
|
ListJobFailures(ctx context.Context, filter governance.JobFailureFilter) (governance.JobFailurePage, error)
|
|
RetryPublicationsBatchWithOperation(ctx context.Context, request governance.RetryPublicationsBatchRequest, operationID string) (governance.RetryPublicationsBatchResult, bool, error)
|
|
}
|
|
|
|
type Config struct {
|
|
Store Store
|
|
Monitor MonitorProvider
|
|
InternalRoutingKnowledgeBaseID string
|
|
ExternalRoutingKnowledgeBaseID string
|
|
Now func() time.Time
|
|
RequireWeKnoraReady bool
|
|
ProductionReleaseRequestsEnabled bool
|
|
ProductionBackupRestoreVerified bool
|
|
ProductionReleaseAuthorizerRef string
|
|
ProductionReleaseAuthorizationToken string
|
|
NewID func() (string, error)
|
|
}
|
|
|
|
func NewHandler(config Config) (http.Handler, error) {
|
|
if config.Store == nil {
|
|
return nil, errors.New("job store is required")
|
|
}
|
|
if config.Now == nil {
|
|
config.Now = time.Now
|
|
}
|
|
if config.Monitor == nil {
|
|
config.Monitor = monitoring.NewSimulationProvider(config.Now)
|
|
}
|
|
for _, knowledgeBaseID := range []string{config.InternalRoutingKnowledgeBaseID, config.ExternalRoutingKnowledgeBaseID} {
|
|
if knowledgeBaseID != "" && !validCatalogID(knowledgeBaseID) {
|
|
return nil, errors.New("routing knowledge base ID is invalid")
|
|
}
|
|
}
|
|
if config.ProductionReleaseRequestsEnabled &&
|
|
(strings.TrimSpace(config.ProductionReleaseAuthorizerRef) == "" || strings.TrimSpace(config.ProductionReleaseAuthorizationToken) == "") {
|
|
return nil, errors.New("production release authorization identity and token are required")
|
|
}
|
|
if config.NewID == nil {
|
|
config.NewID = newID
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
// P10: liveness answers "is this process serving HTTP" and NEVER depends on
|
|
// third parties; readiness answers "can this instance accept dependent work".
|
|
mux.HandleFunc("GET /livez", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"status": "live", "observed_at": config.Now().UTC()})
|
|
})
|
|
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
|
|
health := config.Monitor.Services(r.Context())
|
|
// The API's own hard dependencies gate readiness. Worker and MinerU
|
|
// outages degrade the matrix (visible in Services) but must not take
|
|
// the admin UI out of rotation — the page is how operators fix them.
|
|
critical := map[string]bool{"postgres": true, "queue": true}
|
|
blocked := make([]string, 0)
|
|
degraded := make([]string, 0)
|
|
warnings := make([]string, 0)
|
|
for _, item := range health {
|
|
if !item.Applicable {
|
|
continue
|
|
}
|
|
if !item.Configured {
|
|
warnings = append(warnings, item.Key)
|
|
continue
|
|
}
|
|
key := strings.SplitN(item.Key, ":", 2)[0]
|
|
if item.Readiness == "ready" {
|
|
continue
|
|
}
|
|
if critical[key] || key == "weknora" && config.RequireWeKnoraReady {
|
|
blocked = append(blocked, item.Key)
|
|
} else {
|
|
degraded = append(degraded, item.Key)
|
|
}
|
|
}
|
|
status := map[string]any{"status": "ready", "blocked": blocked, "degraded": degraded, "not_configured": warnings, "observed_at": config.Now().UTC()}
|
|
if len(blocked) > 0 {
|
|
status["status"] = "blocked"
|
|
writeJSON(w, http.StatusServiceUnavailable, status)
|
|
return
|
|
}
|
|
if len(degraded) > 0 {
|
|
status["status"] = "degraded"
|
|
}
|
|
writeJSON(w, http.StatusOK, status)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
|
createJob(w, r, config)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
|
listJobs(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/source-items", func(w http.ResponseWriter, r *http.Request) {
|
|
listSourceAssets(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/source-catalog/sources", func(w http.ResponseWriter, r *http.Request) {
|
|
listCatalogSources(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/source-catalog/nodes", func(w http.ResponseWriter, r *http.Request) {
|
|
listCatalogNodes(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/source-catalog/snapshots", func(w http.ResponseWriter, r *http.Request) {
|
|
listCatalogSnapshots(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/source-catalog/snapshots/{id}/nodes", func(w http.ResponseWriter, r *http.Request) {
|
|
listCatalogSnapshotNodes(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/source-catalog/change-sets/latest", func(w http.ResponseWriter, r *http.Request) {
|
|
getLatestCatalogChangeSet(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/source-catalog/routing-capabilities", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]bool{"internal_enabled": config.InternalRoutingKnowledgeBaseID != "", "external_enabled": config.ExternalRoutingKnowledgeBaseID != ""}})
|
|
})
|
|
mux.HandleFunc("POST /api/v1/source-catalog/route", func(w http.ResponseWriter, r *http.Request) {
|
|
routeCatalogFiles(w, r, config)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/source-catalog/sources/{id}/refresh", func(w http.ResponseWriter, r *http.Request) {
|
|
triggerCatalogRefresh(w, r, config)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/source-catalog/change-sets/{id}/confirm", func(w http.ResponseWriter, r *http.Request) {
|
|
confirmCatalogChangeSet(w, r, config)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/monitor/overview", func(w http.ResponseWriter, r *http.Request) {
|
|
monitorOverview(w, r, config.Monitor)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/monitor/knowledge-bases/{id}/files", func(w http.ResponseWriter, r *http.Request) {
|
|
monitorFiles(w, r, config.Monitor)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/monitor/metrics", func(w http.ResponseWriter, r *http.Request) {
|
|
monitorMetrics(w, r, config.Monitor)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/scope-rules", func(w http.ResponseWriter, r *http.Request) {
|
|
listScopeRules(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("PUT /api/v1/scope-rules", func(w http.ResponseWriter, r *http.Request) {
|
|
mutateScopeRule(w, r, config, false)
|
|
})
|
|
mux.HandleFunc("DELETE /api/v1/scope-rules", func(w http.ResponseWriter, r *http.Request) {
|
|
mutateScopeRule(w, r, config, true)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/source-connections", func(w http.ResponseWriter, r *http.Request) {
|
|
listSourceConnections(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/source-connections/{id}/scan", func(w http.ResponseWriter, r *http.Request) {
|
|
triggerPPSourceScan(w, r, config)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/source-connections/{id}/scans", func(w http.ResponseWriter, r *http.Request) {
|
|
listSourceScans(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/source-connections/{id}/scans/{scan_id}", func(w http.ResponseWriter, r *http.Request) {
|
|
getSourceScanDetail(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/source-connections/{id}/settings/revisions", func(w http.ResponseWriter, r *http.Request) {
|
|
listSourceSettingsRevisions(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/source-connections/{id}/settings/revisions", func(w http.ResponseWriter, r *http.Request) {
|
|
createSourceSettingsRevision(w, r, config)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/source-connections/{id}/settings/revisions/{revision_id}/activate", func(w http.ResponseWriter, r *http.Request) {
|
|
activateSourceSettingsRevision(w, r, config)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/review-candidates", func(w http.ResponseWriter, r *http.Request) {
|
|
listReviewCandidates(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/review-candidates/{id}/decision", func(w http.ResponseWriter, r *http.Request) {
|
|
reviewPublication(w, r, config)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/release-batches", func(w http.ResponseWriter, r *http.Request) {
|
|
listReleaseBatches(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/release-executions/{id}/audit-chain", func(w http.ResponseWriter, r *http.Request) {
|
|
getReleaseAuditChain(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/release-change-sets", func(w http.ResponseWriter, r *http.Request) {
|
|
generateReleaseChangeSet(w, r, config)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/release-change-sets", func(w http.ResponseWriter, r *http.Request) {
|
|
listReleaseChangeSets(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/release-change-sets/latest", func(w http.ResponseWriter, r *http.Request) {
|
|
getReleaseChangeSet(w, r, config.Store, "latest")
|
|
})
|
|
mux.HandleFunc("GET /api/v1/release-change-sets/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
getReleaseChangeSet(w, r, config.Store, r.PathValue("id"))
|
|
})
|
|
mux.HandleFunc("POST /api/v1/release-change-sets/{id}/confirm", func(w http.ResponseWriter, r *http.Request) {
|
|
confirmReleaseChangeSet(w, r, config)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/release-plan", func(w http.ResponseWriter, r *http.Request) {
|
|
plan, err := config.Store.ListReleasePlan(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not build release plan"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": plan})
|
|
})
|
|
mux.HandleFunc("GET /api/v1/ui-settings", func(w http.ResponseWriter, r *http.Request) {
|
|
getActiveUISettings(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/ui-settings/candidates", func(w http.ResponseWriter, r *http.Request) {
|
|
createUISettingsCandidate(w, r, config)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/ui-settings/{id}/activate", func(w http.ResponseWriter, r *http.Request) {
|
|
activateUISettings(w, r, config)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/release-batches", func(w http.ResponseWriter, r *http.Request) {
|
|
createReleaseBatch(w, r, config)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/release-batches/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
getReleaseBatch(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/release-batches/{id}/seal", func(w http.ResponseWriter, r *http.Request) {
|
|
sealReleaseBatch(w, r, config)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/release-batches/{id}/authorize-production", func(w http.ResponseWriter, r *http.Request) {
|
|
authorizeProductionRelease(w, r, config)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/release-executions/{id}/authorize-rollback", func(w http.ResponseWriter, r *http.Request) {
|
|
authorizeProductionRollback(w, r, config)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/release-executions/{id}/retry", func(w http.ResponseWriter, r *http.Request) {
|
|
retryProductionRelease(w, r, config)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/release-executions", func(w http.ResponseWriter, r *http.Request) {
|
|
listReleaseExecutions(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/release-executions/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
getReleaseExecution(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/release-capabilities", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]bool{
|
|
"production_release_requests_enabled": config.ProductionReleaseRequestsEnabled,
|
|
"production_backup_restore_verified": config.ProductionBackupRestoreVerified,
|
|
}})
|
|
})
|
|
mux.HandleFunc("GET /api/v1/test-publications", func(w http.ResponseWriter, r *http.Request) {
|
|
listTestPublications(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/test-publications/start", func(w http.ResponseWriter, r *http.Request) {
|
|
startTestPublications(w, r, config)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/test-publications/{id}/acceptance", func(w http.ResponseWriter, r *http.Request) {
|
|
listAcceptance(w, r, config.Store)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/test-publications/{id}/retry", func(w http.ResponseWriter, r *http.Request) {
|
|
retryPublication(w, r, config)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/test-publications/retry-batch", func(w http.ResponseWriter, r *http.Request) {
|
|
retryPublicationsBatch(w, r, config)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/job-failures", func(w http.ResponseWriter, r *http.Request) {
|
|
listJobFailures(w, r, config.Store)
|
|
})
|
|
return mux, nil
|
|
}
|
|
|
|
type createJobRequest struct {
|
|
Type string `json:"type"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
Priority int `json:"priority"`
|
|
MaxAttempts int `json:"max_attempts"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
}
|
|
|
|
func createJob(w http.ResponseWriter, r *http.Request, config Config) {
|
|
var input createJobRequest
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON request"})
|
|
return
|
|
}
|
|
if strings.TrimSpace(input.Type) == "" || len(input.Type) > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "type is required and must not exceed 100 characters"})
|
|
return
|
|
}
|
|
if strings.TrimSpace(input.IdempotencyKey) == "" || len(input.IdempotencyKey) > 255 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "idempotency_key is required and must not exceed 255 characters"})
|
|
return
|
|
}
|
|
if input.MaxAttempts == 0 {
|
|
input.MaxAttempts = 5
|
|
}
|
|
if input.MaxAttempts < 1 || input.MaxAttempts > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "max_attempts must be between 1 and 100"})
|
|
return
|
|
}
|
|
if len(input.Payload) == 0 {
|
|
input.Payload = json.RawMessage(`{}`)
|
|
}
|
|
if !json.Valid(input.Payload) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "payload must be valid JSON"})
|
|
return
|
|
}
|
|
id, err := config.NewID()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create job ID"})
|
|
return
|
|
}
|
|
now := config.Now().UTC()
|
|
job, err := config.Store.Enqueue(r.Context(), jobs.EnqueueRequest{
|
|
ID: id,
|
|
Type: input.Type,
|
|
Payload: input.Payload,
|
|
Priority: input.Priority,
|
|
AvailableAt: now,
|
|
MaxAttempts: input.MaxAttempts,
|
|
IdempotencyKey: input.IdempotencyKey,
|
|
}, now)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not enqueue job"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"data": job})
|
|
}
|
|
|
|
func listJobs(w http.ResponseWriter, r *http.Request, store JobStore) {
|
|
limit := 50
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 1 || value > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
|
|
return
|
|
}
|
|
limit = value
|
|
}
|
|
result, err := store.List(r.Context(), limit)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list jobs"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": result})
|
|
}
|
|
|
|
func listSourceAssets(w http.ResponseWriter, r *http.Request, store Store) {
|
|
filter := governance.SourceAssetFilter{State: r.URL.Query().Get("state"), Limit: 50}
|
|
if filter.State != "" && filter.State != "completed" && filter.State != "content_alias" && filter.State != "over_limit" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "state must be completed, content_alias, or over_limit"})
|
|
return
|
|
}
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 1 || value > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
|
|
return
|
|
}
|
|
filter.Limit = value
|
|
}
|
|
if raw := r.URL.Query().Get("offset"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 0 || value > 1000000 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
|
|
return
|
|
}
|
|
filter.Offset = value
|
|
}
|
|
page, err := store.ListSourceAssets(r.Context(), filter)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list source items"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"data": page.Items, "summary": page.Summary, "limit": filter.Limit, "offset": filter.Offset,
|
|
})
|
|
}
|
|
|
|
func listCatalogSources(w http.ResponseWriter, r *http.Request, store Store) {
|
|
sources, err := store.ListCatalogSources(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list source catalogs"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": sources, "summary": map[string]int{"total": len(sources)}})
|
|
}
|
|
|
|
func listCatalogNodes(w http.ResponseWriter, r *http.Request, store Store) {
|
|
filter := governance.CatalogNodeFilter{ConnectionID: r.URL.Query().Get("connection_id"), ParentNodeID: r.URL.Query().Get("parent_id"), Limit: 50}
|
|
if !validCatalogID(filter.ConnectionID) || !validCatalogID(filter.ParentNodeID) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source catalog location"})
|
|
return
|
|
}
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 1 || value > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
|
|
return
|
|
}
|
|
filter.Limit = value
|
|
}
|
|
if raw := r.URL.Query().Get("offset"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 0 || value > 1000000 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
|
|
return
|
|
}
|
|
filter.Offset = value
|
|
}
|
|
page, err := store.ListCatalogNodes(r.Context(), filter)
|
|
if errors.Is(err, postgresstore.ErrCatalogNodeNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source catalog location not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list source catalog"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, page)
|
|
}
|
|
|
|
func listCatalogSnapshots(w http.ResponseWriter, r *http.Request, store Store) {
|
|
connectionID := r.URL.Query().Get("connection_id")
|
|
if !validCatalogID(connectionID) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source catalog"})
|
|
return
|
|
}
|
|
limit, offset, ok := parseLimitOffset(w, r, 20)
|
|
if !ok {
|
|
return
|
|
}
|
|
page, err := store.ListCatalogSnapshots(r.Context(), connectionID, limit, offset)
|
|
if errors.Is(err, postgresstore.ErrCatalogSnapshotNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source catalog not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list snapshot history"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, page)
|
|
}
|
|
|
|
func listCatalogSnapshotNodes(w http.ResponseWriter, r *http.Request, store Store) {
|
|
filter := governance.CatalogSnapshotNodeFilter{ConnectionID: r.URL.Query().Get("connection_id"), SnapshotID: r.PathValue("id"), ParentNodeID: r.URL.Query().Get("parent_id"), Limit: 50}
|
|
if !validCatalogID(filter.ConnectionID) || !validCatalogID(filter.SnapshotID) || !validCatalogID(filter.ParentNodeID) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid snapshot location"})
|
|
return
|
|
}
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 1 || value > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
|
|
return
|
|
}
|
|
filter.Limit = value
|
|
}
|
|
if raw := r.URL.Query().Get("offset"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 0 || value > 1000000 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
|
|
return
|
|
}
|
|
filter.Offset = value
|
|
}
|
|
page, err := store.ListCatalogNodesInSnapshot(r.Context(), filter)
|
|
if errors.Is(err, postgresstore.ErrCatalogSnapshotNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "snapshot not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list snapshot nodes"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, page)
|
|
}
|
|
|
|
func getLatestCatalogChangeSet(w http.ResponseWriter, r *http.Request, store Store) {
|
|
filter := governance.CatalogChangeSetFilter{ConnectionID: strings.TrimSpace(r.URL.Query().Get("connection_id")), Action: strings.TrimSpace(r.URL.Query().Get("action")), Limit: 20}
|
|
if !validCatalogID(filter.ConnectionID) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source catalog"})
|
|
return
|
|
}
|
|
allowedActions := map[string]bool{"": true, "add": true, "update": true, "move": true, "move_scope": true, "delete": true, "no_change": true}
|
|
if !allowedActions[filter.Action] {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid change action"})
|
|
return
|
|
}
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 1 || value > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
|
|
return
|
|
}
|
|
filter.Limit = value
|
|
}
|
|
if raw := r.URL.Query().Get("offset"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 0 || value > 1000000 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
|
|
return
|
|
}
|
|
filter.Offset = value
|
|
}
|
|
detail, err := store.GetLatestCatalogChangeSet(r.Context(), filter)
|
|
if errors.Is(err, postgresstore.ErrCatalogChangeSetNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source catalog change set not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load source catalog change set"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": detail})
|
|
}
|
|
|
|
func validCatalogID(value string) bool {
|
|
if value == "" || len(value) > 36 {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || character == '-' || character == '_' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func requireOperationID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
operationID := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
|
if operationID == "" || len(operationID) > 128 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Idempotency-Key is required and must not exceed 128 characters"})
|
|
return "", false
|
|
}
|
|
return operationID, true
|
|
}
|
|
|
|
type catalogRouteInput struct {
|
|
ConnectionID string `json:"connection_id"`
|
|
NodeIDs []string `json:"node_ids"`
|
|
Policy string `json:"policy"`
|
|
Visibility string `json:"visibility"`
|
|
OperatorRef string `json:"operator_ref"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
func routeCatalogFiles(w http.ResponseWriter, r *http.Request, config Config) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input catalogRouteInput
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<16))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil || len(input.NodeIDs) == 0 || len(input.NodeIDs) > 100 || !validCatalogID(input.ConnectionID) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid catalog route request"})
|
|
return
|
|
}
|
|
policy := strings.TrimSpace(input.Policy)
|
|
if policy == "" {
|
|
policy = strings.TrimSpace(input.Visibility)
|
|
}
|
|
visibility := governance.Visibility(policy)
|
|
targetKnowledgeBaseIDs := make([]string, 0, 2)
|
|
switch visibility {
|
|
case governance.VisibilityInternal:
|
|
if config.InternalRoutingKnowledgeBaseID != "" {
|
|
targetKnowledgeBaseIDs = append(targetKnowledgeBaseIDs, config.InternalRoutingKnowledgeBaseID)
|
|
}
|
|
case governance.VisibilityExternal:
|
|
if config.ExternalRoutingKnowledgeBaseID != "" {
|
|
targetKnowledgeBaseIDs = append(targetKnowledgeBaseIDs, config.ExternalRoutingKnowledgeBaseID)
|
|
}
|
|
case governance.VisibilityBoth:
|
|
if config.InternalRoutingKnowledgeBaseID != "" {
|
|
targetKnowledgeBaseIDs = append(targetKnowledgeBaseIDs, config.InternalRoutingKnowledgeBaseID)
|
|
}
|
|
if config.ExternalRoutingKnowledgeBaseID != "" {
|
|
targetKnowledgeBaseIDs = append(targetKnowledgeBaseIDs, config.ExternalRoutingKnowledgeBaseID)
|
|
}
|
|
case governance.VisibilityBlocked:
|
|
default:
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "policy must be internal, external, both, or blocked"})
|
|
return
|
|
}
|
|
if visibility != governance.VisibilityBlocked &&
|
|
((visibility == governance.VisibilityBoth && len(targetKnowledgeBaseIDs) != 2) ||
|
|
(visibility != governance.VisibilityBoth && len(targetKnowledgeBaseIDs) != 1)) {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "routing target is not configured"})
|
|
return
|
|
}
|
|
targetKnowledgeBaseID := ""
|
|
if len(targetKnowledgeBaseIDs) > 0 {
|
|
targetKnowledgeBaseID = targetKnowledgeBaseIDs[0]
|
|
}
|
|
result, replayed, err := config.Store.RouteCatalogFilesWithOperation(r.Context(), governance.CatalogRouteRequest{
|
|
ConnectionID: input.ConnectionID, NodeIDs: input.NodeIDs, Visibility: visibility,
|
|
TargetKnowledgeBaseID: targetKnowledgeBaseID, TargetKnowledgeBaseIDs: targetKnowledgeBaseIDs,
|
|
OperatorRef: strings.TrimSpace(input.OperatorRef),
|
|
Reason: strings.TrimSpace(input.Reason), Now: config.Now().UTC(),
|
|
}, operationID)
|
|
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not route selected files"})
|
|
return
|
|
}
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"data": result, "operation": operation})
|
|
}
|
|
|
|
func triggerCatalogRefresh(w http.ResponseWriter, r *http.Request, config Config) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
connectionID := r.PathValue("id")
|
|
if !validCatalogID(connectionID) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source catalog"})
|
|
return
|
|
}
|
|
sources, err := config.Store.ListCatalogSources(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not inspect source catalog"})
|
|
return
|
|
}
|
|
var source *governance.CatalogSource
|
|
for index := range sources {
|
|
if sources[index].ConnectionID == connectionID {
|
|
source = &sources[index]
|
|
break
|
|
}
|
|
}
|
|
if source == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source catalog not found"})
|
|
return
|
|
}
|
|
job, replayed, err := config.Store.EnqueueCatalogRefreshWithOperation(r.Context(), *source, operationID, config.Now().UTC())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not enqueue catalog refresh"})
|
|
return
|
|
}
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"data": job, "operation": operation})
|
|
}
|
|
|
|
func monitorOverview(w http.ResponseWriter, r *http.Request, provider MonitorProvider) {
|
|
overview, err := provider.Overview(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load monitor overview"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, overview)
|
|
}
|
|
|
|
func monitorMetrics(w http.ResponseWriter, r *http.Request, provider MonitorProvider) {
|
|
metrics, err := provider.Metrics(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "run metrics unavailable"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": metrics})
|
|
}
|
|
|
|
func monitorFiles(w http.ResponseWriter, r *http.Request, provider MonitorProvider) {
|
|
knowledgeBaseID := r.PathValue("id")
|
|
status := r.URL.Query().Get("status")
|
|
allowedStatus := map[string]bool{"": true, "completed": true, "pending": true, "processing": true, "finalizing": true, "failed": true, "cancelled": true}
|
|
if !validCatalogID(knowledgeBaseID) || !allowedStatus[status] {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid monitor file query"})
|
|
return
|
|
}
|
|
limit, offset := 20, 0
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 1 || value > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
|
|
return
|
|
}
|
|
limit = value
|
|
}
|
|
if raw := r.URL.Query().Get("offset"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 0 || value > 1000000 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
|
|
return
|
|
}
|
|
offset = value
|
|
}
|
|
page, err := provider.ListFiles(r.Context(), knowledgeBaseID, status, limit, offset)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load monitored files"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, page)
|
|
}
|
|
|
|
type scopeRuleInput struct {
|
|
TargetType governance.ScopeTargetType `json:"target_type"`
|
|
ConnectionID string `json:"connection_id"`
|
|
DirectoryPath string `json:"directory_path"`
|
|
SourceItemID string `json:"source_item_id"`
|
|
Visibility governance.Visibility `json:"visibility"`
|
|
OperatorRef string `json:"operator_ref"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
func listScopeRules(w http.ResponseWriter, r *http.Request, store Store) {
|
|
connectionID := strings.TrimSpace(r.URL.Query().Get("connection_id"))
|
|
if connectionID == "" || len(connectionID) > 36 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "connection_id is required"})
|
|
return
|
|
}
|
|
rules, err := store.ListScopeRules(r.Context(), connectionID)
|
|
if errors.Is(err, postgresstore.ErrScopeTargetNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "scope target not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list scope rules"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": rules, "summary": map[string]int{"total": len(rules)}})
|
|
}
|
|
|
|
func mutateScopeRule(w http.ResponseWriter, r *http.Request, config Config, clear bool) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input scopeRuleInput
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<16))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid scope rule request"})
|
|
return
|
|
}
|
|
request := governance.ScopeRuleMutation{
|
|
TargetType: input.TargetType, ConnectionID: strings.TrimSpace(input.ConnectionID),
|
|
DirectoryPath: input.DirectoryPath, SourceItemID: strings.TrimSpace(input.SourceItemID),
|
|
Visibility: input.Visibility, OperatorRef: strings.TrimSpace(input.OperatorRef), Reason: strings.TrimSpace(input.Reason),
|
|
Clear: clear, Now: config.Now().UTC(),
|
|
}
|
|
if clear {
|
|
request.Visibility = ""
|
|
}
|
|
if err := request.Validate(); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid scope rule request"})
|
|
return
|
|
}
|
|
rule, replayed, err := config.Store.MutateScopeRuleWithOperation(r.Context(), request, operationID)
|
|
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
return
|
|
}
|
|
if errors.Is(err, postgresstore.ErrScopeTargetNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "scope target not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not update scope rule"})
|
|
return
|
|
}
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": rule, "operation": operation})
|
|
}
|
|
|
|
type sourceSettingsRevisionRequest struct {
|
|
SourceType string `json:"source_type"`
|
|
Name string `json:"name"`
|
|
BaseURL string `json:"endpoint"`
|
|
SecretRef string `json:"credential_ref"`
|
|
Enabled bool `json:"enabled"`
|
|
Schedule struct {
|
|
RefreshIntervalSeconds *int `json:"refresh_interval_seconds"`
|
|
} `json:"schedule"`
|
|
Operator string `json:"operator"`
|
|
}
|
|
|
|
func (req sourceSettingsRevisionRequest) toDraft() postgresstore.SourceSettingsDraft {
|
|
schedule := "{}"
|
|
if req.Schedule.RefreshIntervalSeconds != nil {
|
|
schedule = fmt.Sprintf(`{"refresh_interval_seconds":%d}`, *req.Schedule.RefreshIntervalSeconds)
|
|
}
|
|
return postgresstore.SourceSettingsDraft{
|
|
SourceType: req.SourceType, Name: req.Name, BaseURL: req.BaseURL,
|
|
SecretRef: req.SecretRef, Enabled: req.Enabled,
|
|
Schedule: json.RawMessage(schedule), Operator: req.Operator,
|
|
}
|
|
}
|
|
|
|
func listSourceSettingsRevisions(w http.ResponseWriter, r *http.Request, store Store) {
|
|
connectionID := r.PathValue("id")
|
|
if !validCatalogID(connectionID) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source connection"})
|
|
return
|
|
}
|
|
revisions, err := store.ListSourceConnectionRevisions(r.Context(), connectionID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list source settings revisions"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": revisions})
|
|
}
|
|
|
|
func createSourceSettingsRevision(w http.ResponseWriter, r *http.Request, config Config) {
|
|
if r.Header.Get("X-Governance-Action") != "source-settings" {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "source settings write intent is required"})
|
|
return
|
|
}
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
connectionID := r.PathValue("id")
|
|
if !validCatalogID(connectionID) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source connection"})
|
|
return
|
|
}
|
|
var request sourceSettingsRevisionRequest
|
|
if err := decodeJSONStrict(r, &request); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source settings revision"})
|
|
return
|
|
}
|
|
revisionID, replayed, err := config.Store.CreateSourceSettingsRevisionWithOperation(r.Context(), connectionID, request.toDraft(), operationID, config.Now().UTC())
|
|
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
return
|
|
}
|
|
if errors.Is(err, postgresstore.ErrInvalidSourceSettings) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source settings revision"})
|
|
return
|
|
}
|
|
if errors.Is(err, postgresstore.ErrSourceConnectionNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source connection not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create source settings revision"})
|
|
return
|
|
}
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]string{"id": revisionID}, "operation": operation})
|
|
}
|
|
|
|
func activateSourceSettingsRevision(w http.ResponseWriter, r *http.Request, config Config) {
|
|
if r.Header.Get("X-Governance-Action") != "source-settings" {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "source settings write intent is required"})
|
|
return
|
|
}
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
connectionID, revisionID := r.PathValue("id"), r.PathValue("revision_id")
|
|
if !validCatalogID(connectionID) || !validCatalogID(revisionID) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source settings revision"})
|
|
return
|
|
}
|
|
var request struct{}
|
|
if err := decodeJSONStrict(r, &request); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source settings activation"})
|
|
return
|
|
}
|
|
replayed, err := config.Store.ActivateSourceSettingsRevisionWithOperation(r.Context(), connectionID, revisionID, operationID, config.Now().UTC())
|
|
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
return
|
|
}
|
|
if errors.Is(err, postgresstore.ErrInvalidSourceSettings) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "only validated revisions can be activated"})
|
|
return
|
|
}
|
|
if errors.Is(err, postgresstore.ErrSourceConnectionNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source settings revision not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not activate source settings revision"})
|
|
return
|
|
}
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]string{"connection_id": connectionID, "revision_id": revisionID}, "operation": operation})
|
|
}
|
|
|
|
func listSourceConnections(w http.ResponseWriter, r *http.Request, store Store) {
|
|
page, err := store.ListSourceConnections(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list source connections"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": page.Items, "summary": page.Summary})
|
|
}
|
|
|
|
func triggerPPSourceScan(w http.ResponseWriter, r *http.Request, config Config) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
scan, replayed, err := config.Store.TriggerPPSourceScanWithOperation(r.Context(), r.PathValue("id"), config.Now().UTC(), operationID)
|
|
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
return
|
|
}
|
|
if errors.Is(err, postgresstore.ErrSourceConnectionNotScannable) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source connection is not scannable"})
|
|
return
|
|
}
|
|
if errors.Is(err, postgresstore.ErrSourceScanAlreadyActive) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "source scan already active"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not trigger source scan"})
|
|
return
|
|
}
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"data": scan, "operation": operation})
|
|
}
|
|
|
|
func listSourceScans(w http.ResponseWriter, r *http.Request, store Store) {
|
|
connectionID := r.PathValue("id")
|
|
if connectionID == "" || len(connectionID) > 64 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source connection ID"})
|
|
return
|
|
}
|
|
limit, offset := 20, 0
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 1 || value > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
|
|
return
|
|
}
|
|
limit = value
|
|
}
|
|
if raw := r.URL.Query().Get("offset"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 0 || value > 1000000 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
|
|
return
|
|
}
|
|
offset = value
|
|
}
|
|
page, err := store.ListSourceScans(r.Context(), connectionID, limit, offset)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list source scans"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": page.Items, "total": page.Total, "limit": limit, "offset": offset})
|
|
}
|
|
|
|
func getSourceScanDetail(w http.ResponseWriter, r *http.Request, store Store) {
|
|
connectionID, scanID := r.PathValue("id"), r.PathValue("scan_id")
|
|
if connectionID == "" || len(connectionID) > 64 || scanID == "" || len(scanID) > 64 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source scan ID"})
|
|
return
|
|
}
|
|
detail, err := store.GetSourceScanDetail(r.Context(), connectionID, scanID)
|
|
if errors.Is(err, postgresstore.ErrSourceScanNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source scan not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load source scan"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": detail})
|
|
}
|
|
|
|
func listReviewCandidates(w http.ResponseWriter, r *http.Request, store Store) {
|
|
filter := governance.ReviewCandidateFilter{ReviewState: r.URL.Query().Get("review_state"), Limit: 20}
|
|
if filter.ReviewState != "" && filter.ReviewState != "pending" && filter.ReviewState != "approved" && filter.ReviewState != "rejected" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "review_state must be pending, approved, or rejected"})
|
|
return
|
|
}
|
|
limit, offset, ok := parseLimitOffset(w, r, 20)
|
|
if !ok {
|
|
return
|
|
}
|
|
filter.Limit, filter.Offset = limit, offset
|
|
page, err := store.ListReviewCandidates(r.Context(), filter)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list review candidates"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": page.Items, "total": page.Total, "limit": limit, "offset": offset})
|
|
}
|
|
|
|
type reviewPublicationRequest struct {
|
|
Decision string `json:"decision"`
|
|
DecidedVisibility string `json:"decided_visibility"`
|
|
ReviewerRef string `json:"reviewer_ref"`
|
|
DecisionCode string `json:"decision_code"`
|
|
}
|
|
|
|
func reviewPublication(w http.ResponseWriter, r *http.Request, config Config) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input reviewPublicationRequest
|
|
if err := decodeStrictJSON(r, &input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid review request"})
|
|
return
|
|
}
|
|
review, replayed, err := config.Store.ReviewPublicationWithOperation(r.Context(), governance.ReviewPublicationRequest{
|
|
PublicationID: r.PathValue("id"), Decision: governance.ReviewState(input.Decision),
|
|
DecidedVisibility: governance.Visibility(input.DecidedVisibility), ReviewerRef: input.ReviewerRef,
|
|
DecisionCode: input.DecisionCode, Now: config.Now().UTC(),
|
|
}, operationID)
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
case errors.Is(err, postgresstore.ErrInvalidPublicationReview):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid review request"})
|
|
case errors.Is(err, postgresstore.ErrPublicationReviewNotEligible):
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "publication is not eligible for this review decision"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not record review decision"})
|
|
default:
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"data": review, "operation": operation})
|
|
}
|
|
}
|
|
|
|
type createUISettingsCandidateRequest struct {
|
|
DocumentRefreshSeconds int `json:"document_refresh_seconds"`
|
|
ResourceRefreshSeconds int `json:"resource_refresh_seconds"`
|
|
StalledAfterSeconds int `json:"stalled_after_seconds"`
|
|
FinalizingAfterSeconds int `json:"finalizing_after_seconds"`
|
|
UpdatedBy string `json:"updated_by"`
|
|
}
|
|
|
|
func getActiveUISettings(w http.ResponseWriter, r *http.Request, store Store) {
|
|
settings, err := store.GetActiveUISettings(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load active UI settings"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": settings})
|
|
}
|
|
|
|
func createUISettingsCandidate(w http.ResponseWriter, r *http.Request, config Config) {
|
|
if r.Header.Get("X-Governance-Action") != "ui-settings" {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "UI settings write intent is required"})
|
|
return
|
|
}
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var request createUISettingsCandidateRequest
|
|
if err := decodeJSONStrict(r, &request); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid UI settings candidate"})
|
|
return
|
|
}
|
|
settings, replayed, err := config.Store.CreateUISettingsCandidateWithOperation(r.Context(), governance.CreateUISettingsCandidateRequest{
|
|
DocumentRefreshSeconds: request.DocumentRefreshSeconds,
|
|
ResourceRefreshSeconds: request.ResourceRefreshSeconds,
|
|
StalledAfterSeconds: request.StalledAfterSeconds,
|
|
FinalizingAfterSeconds: request.FinalizingAfterSeconds,
|
|
UpdatedBy: request.UpdatedBy,
|
|
Now: config.Now(),
|
|
}, operationID)
|
|
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
return
|
|
}
|
|
if errors.Is(err, postgresstore.ErrInvalidUISettings) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid UI settings candidate"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create UI settings candidate"})
|
|
return
|
|
}
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"data": settings, "operation": operation})
|
|
}
|
|
|
|
func activateUISettings(w http.ResponseWriter, r *http.Request, config Config) {
|
|
if r.Header.Get("X-Governance-Action") != "ui-settings" {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "UI settings write intent is required"})
|
|
return
|
|
}
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var request struct{}
|
|
if err := decodeJSONStrict(r, &request); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid UI settings activation"})
|
|
return
|
|
}
|
|
settings, replayed, err := config.Store.ActivateUISettingsWithOperation(r.Context(), r.PathValue("id"), config.Now(), operationID)
|
|
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
return
|
|
}
|
|
if errors.Is(err, postgresstore.ErrInvalidUISettings) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid UI settings activation"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not activate UI settings"})
|
|
return
|
|
}
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": settings, "operation": operation})
|
|
}
|
|
|
|
type createReleaseBatchRequest struct {
|
|
Name string `json:"name"`
|
|
CreatedBy string `json:"created_by"`
|
|
PublicationIDs []string `json:"publication_ids"`
|
|
DeletePublicationIDs []string `json:"delete_publication_ids"`
|
|
ReleaseChangeSetID string `json:"release_change_set_id"`
|
|
}
|
|
|
|
func createReleaseBatch(w http.ResponseWriter, r *http.Request, config Config) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input createReleaseBatchRequest
|
|
if err := decodeStrictJSON(r, &input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release batch request"})
|
|
return
|
|
}
|
|
batch, replayed, err := config.Store.CreateReleaseBatchWithOperation(r.Context(), governance.CreateReleaseBatchRequest{
|
|
Name: input.Name, CreatedBy: input.CreatedBy, PublicationIDs: input.PublicationIDs, DeletePublicationIDs: input.DeletePublicationIDs, ReleaseChangeSetID: input.ReleaseChangeSetID, Now: config.Now().UTC(),
|
|
}, operationID)
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
case errors.Is(err, postgresstore.ErrInvalidReleaseBatch):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release batch request"})
|
|
case errors.Is(err, postgresstore.ErrReleaseBatchNotEligible):
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "release batch contains ineligible publications"})
|
|
case errors.Is(err, postgresstore.ErrReleaseChangeSetUnconfirmed):
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "release change set must be confirmed and cover every requested publication"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create release batch"})
|
|
default:
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"data": batch, "operation": operation})
|
|
}
|
|
}
|
|
|
|
type generateReleaseChangeSetRequest struct {
|
|
Name string `json:"name"`
|
|
CreatedBy string `json:"created_by"`
|
|
}
|
|
|
|
func getReleaseAuditChain(w http.ResponseWriter, r *http.Request, store Store) {
|
|
id := r.PathValue("id")
|
|
if !validCatalogID(id) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release execution id"})
|
|
return
|
|
}
|
|
chain, err := store.GetReleaseAuditChain(r.Context(), id)
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrReleaseAuditChainNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "release audit chain not found"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load release audit chain"})
|
|
default:
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": chain})
|
|
}
|
|
}
|
|
|
|
func generateReleaseChangeSet(w http.ResponseWriter, r *http.Request, config Config) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input generateReleaseChangeSetRequest
|
|
if err := decodeStrictJSON(r, &input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release change set request"})
|
|
return
|
|
}
|
|
set, replayed, err := config.Store.GenerateReleaseChangeSetWithOperation(r.Context(), governance.GenerateReleaseChangeSetRequest{
|
|
Name: input.Name, CreatedBy: input.CreatedBy, Now: config.Now().UTC(),
|
|
}, operationID)
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
case errors.Is(err, postgresstore.ErrInvalidReleaseChangeSet):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release change set request"})
|
|
case errors.Is(err, postgresstore.ErrReleaseChangeSetEmpty):
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "当前发布计划没有可发布项"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not generate release change set"})
|
|
default:
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"data": set, "operation": operation})
|
|
}
|
|
}
|
|
|
|
func listReleaseChangeSets(w http.ResponseWriter, r *http.Request, store Store) {
|
|
limit, offset, ok := parseLimitOffset(w, r, 20)
|
|
if !ok {
|
|
return
|
|
}
|
|
page, err := store.ListReleaseChangeSets(r.Context(), governance.ReleaseBatchFilter{Limit: limit, Offset: offset})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list release change sets"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": page})
|
|
}
|
|
|
|
func getReleaseChangeSet(w http.ResponseWriter, r *http.Request, store Store, id string) {
|
|
if id == "" {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "release change set not found"})
|
|
return
|
|
}
|
|
var detail governance.ReleaseChangeSetDetail
|
|
var err error
|
|
if id == "latest" {
|
|
detail, err = store.LatestReleaseChangeSet(r.Context())
|
|
} else {
|
|
if !validCatalogID(id) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release change set id"})
|
|
return
|
|
}
|
|
detail, err = store.GetReleaseChangeSet(r.Context(), id)
|
|
}
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrReleaseChangeSetNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "release change set not found"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load release change set"})
|
|
default:
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": detail})
|
|
}
|
|
}
|
|
|
|
func confirmReleaseChangeSet(w http.ResponseWriter, r *http.Request, config Config) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input struct {
|
|
ConfirmedBy string `json:"confirmed_by"`
|
|
}
|
|
if err := decodeStrictJSON(r, &input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid confirmation request"})
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
set, replayed, err := config.Store.ConfirmReleaseChangeSetWithOperation(r.Context(), governance.ConfirmReleaseChangeSetRequest{
|
|
ChangeSetID: id, ConfirmedBy: input.ConfirmedBy, Now: config.Now().UTC(),
|
|
}, operationID)
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
case errors.Is(err, postgresstore.ErrInvalidReleaseChangeSet):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid confirmation request"})
|
|
case errors.Is(err, postgresstore.ErrReleaseChangeSetNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "release change set not found"})
|
|
case errors.Is(err, postgresstore.ErrReleaseChangeSetStateConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "只有 planned 状态的发布计划可以确认"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not confirm release change set"})
|
|
default:
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": set, "operation": operation})
|
|
}
|
|
}
|
|
|
|
type sealReleaseBatchRequest struct {
|
|
SealedBy string `json:"sealed_by"`
|
|
}
|
|
|
|
func sealReleaseBatch(w http.ResponseWriter, r *http.Request, config Config) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input sealReleaseBatchRequest
|
|
if err := decodeStrictJSON(r, &input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid seal request"})
|
|
return
|
|
}
|
|
batch, replayed, err := config.Store.SealReleaseBatchWithOperation(r.Context(), governance.SealReleaseBatchRequest{
|
|
BatchID: r.PathValue("id"), SealedBy: input.SealedBy, Now: config.Now().UTC(),
|
|
}, operationID)
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
case errors.Is(err, postgresstore.ErrInvalidReleaseBatch):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid seal request"})
|
|
case errors.Is(err, postgresstore.ErrReleaseBatchGateConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "release batch no longer satisfies the seal gate"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not seal release batch"})
|
|
default:
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": batch, "operation": operation})
|
|
}
|
|
}
|
|
|
|
type authorizeProductionReleaseRequest struct {
|
|
Confirmation string `json:"confirmation"`
|
|
}
|
|
|
|
func authorizeProductionRelease(w http.ResponseWriter, r *http.Request, config Config) {
|
|
if !config.ProductionReleaseRequestsEnabled {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "production release requests are disabled"})
|
|
return
|
|
}
|
|
if !config.ProductionBackupRestoreVerified {
|
|
writeJSON(w, http.StatusPreconditionFailed, map[string]string{"error": "production backup restore verification is required"})
|
|
return
|
|
}
|
|
if !productionReleaseAuthorized(r, config.ProductionReleaseAuthorizationToken) {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "production release authorization required"})
|
|
return
|
|
}
|
|
var input authorizeProductionReleaseRequest
|
|
if err := decodeStrictJSON(r, &input); err != nil || input.Confirmation != "publish-to-production" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid production release authorization"})
|
|
return
|
|
}
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
execution, replayed, err := config.Store.AuthorizeReleaseBatchWithOperation(r.Context(), governance.AuthorizeReleaseBatchRequest{
|
|
BatchID: r.PathValue("id"), AuthorizedBy: config.ProductionReleaseAuthorizerRef,
|
|
AuthorizationCode: "production_release_authorized", Now: config.Now().UTC(),
|
|
}, operationID)
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
case errors.Is(err, postgresstore.ErrInvalidReleaseAuthorization):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid production release authorization"})
|
|
case errors.Is(err, postgresstore.ErrReleaseAuthorizationGateConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "sealed batch no longer satisfies the production release gate"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not authorize production release"})
|
|
default:
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{
|
|
"id": execution.ID, "release_batch_id": execution.ReleaseBatchID,
|
|
"operation": execution.Operation, "state": execution.State,
|
|
"target_environment": execution.TargetEnvironment, "job_id": execution.JobID,
|
|
"item_count": execution.ItemCount, "created_at": execution.CreatedAt,
|
|
}, "operation": map[bool]string{true: "replayed", false: "created"}[replayed]})
|
|
}
|
|
}
|
|
|
|
type authorizeProductionRollbackRequest struct {
|
|
Confirmation string `json:"confirmation"`
|
|
}
|
|
|
|
func authorizeProductionRollback(w http.ResponseWriter, r *http.Request, config Config) {
|
|
if !config.ProductionReleaseRequestsEnabled {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "production release requests are disabled"})
|
|
return
|
|
}
|
|
if !productionReleaseAuthorized(r, config.ProductionReleaseAuthorizationToken) {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "production release authorization required"})
|
|
return
|
|
}
|
|
var input authorizeProductionRollbackRequest
|
|
if err := decodeStrictJSON(r, &input); err != nil || input.Confirmation != "rollback-production-release" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid production rollback authorization"})
|
|
return
|
|
}
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
execution, replayed, err := config.Store.AuthorizeReleaseRollbackWithOperation(r.Context(), governance.AuthorizeReleaseRollbackRequest{
|
|
PriorExecutionID: r.PathValue("id"), AuthorizedBy: config.ProductionReleaseAuthorizerRef,
|
|
AuthorizationCode: "production_rollback_authorized", Now: config.Now().UTC(),
|
|
}, operationID)
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
case errors.Is(err, postgresstore.ErrInvalidReleaseAuthorization):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid production rollback authorization"})
|
|
case errors.Is(err, postgresstore.ErrReleaseAuthorizationGateConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "release execution is not eligible for rollback"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not authorize production rollback"})
|
|
default:
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{
|
|
"id": execution.ID, "release_batch_id": execution.ReleaseBatchID,
|
|
"operation": execution.Operation, "state": execution.State,
|
|
"target_environment": execution.TargetEnvironment, "job_id": execution.JobID,
|
|
"item_count": execution.ItemCount, "created_at": execution.CreatedAt,
|
|
}, "operation": map[bool]string{true: "replayed", false: "created"}[replayed]})
|
|
}
|
|
}
|
|
|
|
type retryProductionReleaseRequest struct {
|
|
Confirmation string `json:"confirmation"`
|
|
}
|
|
|
|
func retryProductionRelease(w http.ResponseWriter, r *http.Request, config Config) {
|
|
if !config.ProductionReleaseRequestsEnabled {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "production release requests are disabled"})
|
|
return
|
|
}
|
|
if !productionReleaseAuthorized(r, config.ProductionReleaseAuthorizationToken) {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "production release authorization required"})
|
|
return
|
|
}
|
|
var input retryProductionReleaseRequest
|
|
if err := decodeStrictJSON(r, &input); err != nil || input.Confirmation != "retry-production-release" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid production release retry confirmation"})
|
|
return
|
|
}
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
execution, replayed, err := config.Store.RetryReleaseExecutionWithOperation(r.Context(), governance.RetryReleaseExecutionRequest{ExecutionID: r.PathValue("id"), Now: config.Now().UTC()}, operationID)
|
|
if err != nil {
|
|
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
return
|
|
}
|
|
if errors.Is(err, postgresstore.ErrReleaseExecutionRetryConflict) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "release execution cannot be retried"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "retry release execution"})
|
|
return
|
|
}
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"data": safeReleaseExecution(execution), "operation": operation})
|
|
}
|
|
|
|
func productionReleaseAuthorized(r *http.Request, token string) bool {
|
|
provided := r.Header.Get("Authorization")
|
|
expected := "Bearer " + token
|
|
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
|
|
}
|
|
|
|
func listReleaseExecutions(w http.ResponseWriter, r *http.Request, store Store) {
|
|
filter := governance.ReleaseExecutionFilter{Operation: r.URL.Query().Get("operation"), State: r.URL.Query().Get("state"), Limit: 20}
|
|
if filter.Operation != "" && !governance.ReleaseOperation(filter.Operation).Valid() {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "operation must be publish or rollback"})
|
|
return
|
|
}
|
|
if filter.State != "" && !governance.ReleaseExecutionState(filter.State).Valid() {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release execution state"})
|
|
return
|
|
}
|
|
limit, offset, ok := parseLimitOffset(w, r, 20)
|
|
if !ok {
|
|
return
|
|
}
|
|
filter.Limit, filter.Offset = limit, offset
|
|
page, err := store.ListReleaseExecutions(r.Context(), filter)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list release executions"})
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(page.Items))
|
|
for _, execution := range page.Items {
|
|
items = append(items, safeReleaseExecution(execution))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": items, "total": page.Total, "limit": limit, "offset": offset})
|
|
}
|
|
|
|
func getReleaseExecution(w http.ResponseWriter, r *http.Request, store Store) {
|
|
detail, err := store.GetReleaseExecution(r.Context(), r.PathValue("id"))
|
|
if errors.Is(err, postgresstore.ErrReleaseExecutionNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "release execution not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load release execution"})
|
|
return
|
|
}
|
|
result := safeReleaseExecution(detail.ReleaseExecution)
|
|
items := make([]map[string]any, 0, len(detail.Items))
|
|
for _, item := range detail.Items {
|
|
items = append(items, map[string]any{
|
|
"publication_id": item.PublicationID, "title": item.Title, "state": item.State,
|
|
"previous_production_revision": item.PreviousProductionRevision,
|
|
"production_revision": item.ProductionRevision, "error_code": item.ErrorCode,
|
|
"created_at": item.CreatedAt, "completed_at": item.CompletedAt,
|
|
})
|
|
}
|
|
result["items"] = items
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": result})
|
|
}
|
|
|
|
func safeReleaseExecution(execution governance.ReleaseExecution) map[string]any {
|
|
return map[string]any{
|
|
"id": execution.ID, "release_batch_id": execution.ReleaseBatchID,
|
|
"operation": execution.Operation, "prior_execution_id": execution.PriorExecutionID,
|
|
"state": execution.State, "target_environment": execution.TargetEnvironment,
|
|
"authorized_by": execution.AuthorizedBy, "job_id": execution.JobID,
|
|
"item_count": execution.ItemCount, "completed_item_count": execution.CompletedItemCount,
|
|
"failed_item_count": execution.FailedItemCount, "error_code": execution.ErrorCode,
|
|
"failure_level": execution.FailureLevel, "restore_trigger": execution.RestoreTrigger,
|
|
"release_change_set_id": execution.ReleaseChangeSetID,
|
|
"created_at": execution.CreatedAt, "started_at": execution.StartedAt, "completed_at": execution.CompletedAt,
|
|
}
|
|
}
|
|
|
|
func listReleaseBatches(w http.ResponseWriter, r *http.Request, store Store) {
|
|
filter := governance.ReleaseBatchFilter{State: r.URL.Query().Get("state"), Limit: 20}
|
|
if filter.State != "" && filter.State != "draft" && filter.State != "sealed" && filter.State != "cancelled" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "state must be draft, sealed, or cancelled"})
|
|
return
|
|
}
|
|
limit, offset, ok := parseLimitOffset(w, r, 20)
|
|
if !ok {
|
|
return
|
|
}
|
|
filter.Limit, filter.Offset = limit, offset
|
|
page, err := store.ListReleaseBatches(r.Context(), filter)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list release batches"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": page.Items, "total": page.Total, "limit": limit, "offset": offset})
|
|
}
|
|
|
|
func getReleaseBatch(w http.ResponseWriter, r *http.Request, store Store) {
|
|
detail, err := store.GetReleaseBatch(r.Context(), r.PathValue("id"))
|
|
if errors.Is(err, postgresstore.ErrReleaseBatchNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "release batch not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load release batch"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": detail})
|
|
}
|
|
|
|
func listTestPublications(w http.ResponseWriter, r *http.Request, store Store) {
|
|
filter := governance.TestPublicationFilter{ProcessingState: r.URL.Query().Get("processing_state"), TargetScope: r.URL.Query().Get("target_scope"), Limit: 50}
|
|
if filter.ProcessingState != "" && !validProcessingState(filter.ProcessingState) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "processing_state must be pending, processing, finalizing, completed, failed, or stalled"})
|
|
return
|
|
}
|
|
if filter.TargetScope != "" && filter.TargetScope != "internal" && filter.TargetScope != "external" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "target_scope must be internal or external"})
|
|
return
|
|
}
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 1 || value > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
|
|
return
|
|
}
|
|
filter.Limit = value
|
|
}
|
|
if raw := r.URL.Query().Get("offset"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 0 || value > 1000000 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
|
|
return
|
|
}
|
|
filter.Offset = value
|
|
}
|
|
page, err := store.ListTestPublications(r.Context(), filter)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list test publications"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"data": page.Items, "total": page.Total, "limit": filter.Limit, "offset": filter.Offset,
|
|
})
|
|
}
|
|
|
|
type confirmChangeSetInput struct {
|
|
ItemSeqs []int64 `json:"item_seqs"`
|
|
OperatorRef string `json:"operator_ref"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
func confirmCatalogChangeSet(w http.ResponseWriter, r *http.Request, config Config) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input confirmChangeSetInput
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<16))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil || len(input.ItemSeqs) == 0 || len(input.ItemSeqs) > 200 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid change set confirmation"})
|
|
return
|
|
}
|
|
result, replayed, err := config.Store.ConfirmCatalogChangeSetWithOperation(r.Context(), governance.ConfirmCatalogChangeSetRequest{
|
|
ChangeSetID: r.PathValue("id"), ItemSeqs: input.ItemSeqs,
|
|
OperatorRef: input.OperatorRef, Reason: input.Reason,
|
|
InternalTargetKBID: config.InternalRoutingKnowledgeBaseID, ExternalTargetKBID: config.ExternalRoutingKnowledgeBaseID,
|
|
Now: config.Now().UTC(),
|
|
}, operationID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
case errors.Is(err, postgresstore.ErrCatalogChangeSetNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "change set not found"})
|
|
default:
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not confirm change set"})
|
|
}
|
|
return
|
|
}
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": result, "operation": operation})
|
|
}
|
|
|
|
type startTestPublicationsInput struct {
|
|
PublicationIDs []string `json:"publication_ids"`
|
|
OperatorRef string `json:"operator_ref"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
func startTestPublications(w http.ResponseWriter, r *http.Request, config Config) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input startTestPublicationsInput
|
|
if err := decodeStrictJSON(r, &input); err != nil || len(input.PublicationIDs) == 0 || len(input.PublicationIDs) > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid processing confirmation"})
|
|
return
|
|
}
|
|
operator := strings.TrimSpace(input.OperatorRef)
|
|
reason := strings.TrimSpace(input.Reason)
|
|
if operator == "" || len(operator) > 128 || reason == "" || len(reason) > 500 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "operator_ref and reason are required"})
|
|
return
|
|
}
|
|
seen := make(map[string]bool, len(input.PublicationIDs))
|
|
for _, publicationID := range input.PublicationIDs {
|
|
if !validCatalogID(publicationID) || seen[publicationID] {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid publication_ids"})
|
|
return
|
|
}
|
|
seen[publicationID] = true
|
|
}
|
|
result, replayed, err := config.Store.StartTestPublicationsWithOperation(r.Context(), governance.StartTestPublicationsRequest{
|
|
PublicationIDs: input.PublicationIDs,
|
|
OperatorRef: operator,
|
|
Reason: reason,
|
|
Now: config.Now().UTC(),
|
|
}, operationID)
|
|
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not start test processing"})
|
|
return
|
|
}
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"data": result, "operation": operation})
|
|
}
|
|
|
|
func listAcceptance(w http.ResponseWriter, r *http.Request, store Store) {
|
|
records, err := store.ListAcceptanceResults(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list acceptance results"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": records})
|
|
}
|
|
|
|
func listJobFailures(w http.ResponseWriter, r *http.Request, store Store) {
|
|
limit, offset, ok := parseLimitOffset(w, r, 50)
|
|
if !ok {
|
|
return
|
|
}
|
|
page, err := store.ListJobFailures(r.Context(), governance.JobFailureFilter{
|
|
State: r.URL.Query().Get("state"), Limit: limit, Offset: offset,
|
|
})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid failure filter"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": page})
|
|
}
|
|
|
|
func retryPublicationsBatch(w http.ResponseWriter, r *http.Request, config Config) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input startTestPublicationsInput
|
|
if err := decodeStrictJSON(r, &input); err != nil || len(input.PublicationIDs) == 0 || len(input.PublicationIDs) > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid retry batch"})
|
|
return
|
|
}
|
|
operator := strings.TrimSpace(input.OperatorRef)
|
|
reason := strings.TrimSpace(input.Reason)
|
|
if operator == "" || len(operator) > 128 || reason == "" || len(reason) > 500 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "operator_ref and reason are required"})
|
|
return
|
|
}
|
|
seen := make(map[string]bool, len(input.PublicationIDs))
|
|
for _, publicationID := range input.PublicationIDs {
|
|
if !validCatalogID(publicationID) || seen[publicationID] {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid publication_ids"})
|
|
return
|
|
}
|
|
seen[publicationID] = true
|
|
}
|
|
result, replayed, err := config.Store.RetryPublicationsBatchWithOperation(r.Context(), governance.RetryPublicationsBatchRequest{
|
|
PublicationIDs: input.PublicationIDs,
|
|
OperatorRef: operator,
|
|
Reason: reason,
|
|
Now: config.Now().UTC(),
|
|
}, operationID)
|
|
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not retry publications"})
|
|
return
|
|
}
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": result, "operation": operation})
|
|
}
|
|
|
|
func retryPublication(w http.ResponseWriter, r *http.Request, config Config) {
|
|
operationID, ok := requireOperationID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
publicationID := r.PathValue("id")
|
|
replayed, err := config.Store.RetryPublicationWithOperation(r.Context(), publicationID, config.Now().UTC(), operationID)
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
|
|
case errors.Is(err, postgresstore.ErrPublicationNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "publication not found"})
|
|
case errors.Is(err, postgresstore.ErrPublicationStateConflict):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "publication is not in a retryable state"})
|
|
case errors.Is(err, postgresstore.ErrPublicationNotRetryable):
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "publication has no verified spool artifact"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not retry publication"})
|
|
default:
|
|
operation := "created"
|
|
if replayed {
|
|
operation = "replayed"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]string{"id": publicationID, "status": "retry_queued"}, "operation": operation})
|
|
}
|
|
}
|
|
|
|
func parseLimitOffset(w http.ResponseWriter, r *http.Request, defaultLimit int) (int, int, bool) {
|
|
limit, offset := defaultLimit, 0
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 1 || value > 100 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
|
|
return 0, 0, false
|
|
}
|
|
limit = value
|
|
}
|
|
if raw := r.URL.Query().Get("offset"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 0 || value > 1000000 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
|
|
return 0, 0, false
|
|
}
|
|
offset = value
|
|
}
|
|
return limit, offset, true
|
|
}
|
|
|
|
func decodeStrictJSON(r *http.Request, destination any) error {
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(destination); err != nil {
|
|
return err
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
return errors.New("trailing JSON value")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validProcessingState(state string) bool {
|
|
switch state {
|
|
case "pending", "processing", "finalizing", "completed", "failed", "stalled":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func newID() (string, error) {
|
|
var raw [16]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return "", fmt.Errorf("read random ID: %w", err)
|
|
}
|
|
raw[6] = (raw[6] & 0x0f) | 0x40
|
|
raw[8] = (raw[8] & 0x3f) | 0x80
|
|
encoded := hex.EncodeToString(raw[:])
|
|
return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil
|
|
}
|
|
|
|
func decodeJSONStrict(r *http.Request, target any) error {
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 16*1024))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
if err == nil {
|
|
return errors.New("multiple JSON values are not allowed")
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(value)
|
|
}
|