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 干净
300 lines
9.7 KiB
Go
300 lines
9.7 KiB
Go
package release
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
"knowledge-governance/internal/jobs"
|
|
)
|
|
|
|
// debugReleaseItemErrors enables fail-closed-safe diagnostics: only the
|
|
// generic step-level cause text is logged, never payload or credentials.
|
|
var debugReleaseItemErrors = os.Getenv("RELEASE_DEBUG_ITEM_ERRORS") == "true"
|
|
|
|
const (
|
|
ErrorCodeContractMismatch = "release_contract_mismatch"
|
|
ErrorCodeBundleInvalid = "release_bundle_invalid"
|
|
ErrorCodeTargetUnavailable = "release_target_unavailable"
|
|
ErrorCodeTargetWriteFailed = "release_target_write_failed"
|
|
ErrorCodeVerificationFailed = "release_verification_failed"
|
|
ErrorCodeRollbackArtifactMissing = "release_rollback_artifact_missing"
|
|
ErrorCodeRollbackFailed = "release_rollback_failed"
|
|
ErrorCodeStoppedAfterFailure = "release_stopped_after_failure"
|
|
ErrorCodeInternal = "release_internal_error"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidReleaseJob = errors.New("invalid_release_job")
|
|
ErrReleaseRetryable = errors.New("release_retryable")
|
|
ErrReleaseState = errors.New("release_state_failed")
|
|
)
|
|
|
|
type PlanItem struct {
|
|
ReleaseExecutionID string `json:"release_execution_id"`
|
|
PublicationID string `json:"publication_id"`
|
|
Action string `json:"action"`
|
|
SourceItemVersionID string `json:"source_item_version_id"`
|
|
SourceRevision string `json:"source_revision"`
|
|
ParseRevision string `json:"parse_revision"`
|
|
ReleaseRevision string `json:"release_revision"`
|
|
TestKnowledgeBaseID string `json:"test_knowledge_base_id"`
|
|
KnowledgeID string `json:"knowledge_id"`
|
|
DecidedVisibility governance.Visibility `json:"decided_visibility"`
|
|
PreviousProductionRevision string `json:"previous_production_revision"`
|
|
}
|
|
|
|
type ItemResult struct {
|
|
BundleSHA256 string
|
|
PreviousProductionRevision string
|
|
ProductionRevision string
|
|
}
|
|
|
|
type StartExecutionRequest struct {
|
|
ExecutionID string
|
|
JobID string
|
|
Operation governance.ReleaseOperation
|
|
Now time.Time
|
|
}
|
|
|
|
type CompleteItemRequest struct {
|
|
ExecutionID string
|
|
PublicationID string
|
|
BundleSHA256 string
|
|
PreviousProductionRevision string
|
|
ProductionRevision string
|
|
Now time.Time
|
|
}
|
|
|
|
type FailItemRequest struct {
|
|
ExecutionID string
|
|
PublicationID string
|
|
ErrorCode string
|
|
Now time.Time
|
|
}
|
|
|
|
type Store interface {
|
|
StartExecution(context.Context, StartExecutionRequest) ([]PlanItem, error)
|
|
BeginBatch(context.Context, string, int, int, time.Time) error
|
|
CompleteItem(context.Context, CompleteItemRequest) error
|
|
FailItem(context.Context, FailItemRequest) error
|
|
FinishExecution(context.Context, string, time.Time) error
|
|
ScheduleAutomaticRollback(context.Context, string, time.Time) error
|
|
}
|
|
|
|
type Publisher interface {
|
|
Publish(context.Context, PlanItem) (ItemResult, error)
|
|
Delete(context.Context, PlanItem) (ItemResult, error)
|
|
Rollback(context.Context, PlanItem) (ItemResult, error)
|
|
}
|
|
|
|
type ItemError struct {
|
|
Code string
|
|
Retryable bool
|
|
Cause error
|
|
}
|
|
|
|
func (err *ItemError) Error() string {
|
|
if err == nil || !ValidErrorCode(err.Code) {
|
|
return ErrorCodeInternal
|
|
}
|
|
return err.Code
|
|
}
|
|
|
|
func (err *ItemError) Unwrap() error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return err.Cause
|
|
}
|
|
|
|
type jobPayload struct {
|
|
ExecutionID string `json:"execution_id"`
|
|
ReleaseBatchID string `json:"release_batch_id"`
|
|
Operation string `json:"operation"`
|
|
PriorExecutionID string `json:"prior_execution_id"`
|
|
}
|
|
|
|
func NewHandler(store Store, publisher Publisher, now func() time.Time) (jobs.Handler, error) {
|
|
if store == nil || publisher == nil || now == nil {
|
|
return nil, errors.New("release handler dependencies are required")
|
|
}
|
|
return func(ctx context.Context, job jobs.Job) error {
|
|
payload, operation, err := parseJob(job)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items, err := store.StartExecution(ctx, StartExecutionRequest{
|
|
ExecutionID: payload.ExecutionID, JobID: job.ID, Operation: operation, Now: now().UTC(),
|
|
})
|
|
if err != nil {
|
|
return ErrReleaseState
|
|
}
|
|
retryableFailure := false
|
|
permanentFailure := false
|
|
failureIndex := -1
|
|
const batchSize = 50
|
|
totalBatches := (len(items) + batchSize - 1) / batchSize
|
|
for start := 0; start < len(items); start += batchSize {
|
|
batchNumber := start/batchSize + 1
|
|
if err := store.BeginBatch(ctx, payload.ExecutionID, batchNumber, totalBatches, now().UTC()); err != nil {
|
|
return ErrReleaseState
|
|
}
|
|
end := start + batchSize
|
|
if end > len(items) {
|
|
end = len(items)
|
|
}
|
|
for index := start; index < end; index++ {
|
|
item := items[index]
|
|
result, itemErr := publishItem(ctx, publisher, operation, item)
|
|
if itemErr != nil {
|
|
code, retryable := classifyItemError(itemErr)
|
|
if debugReleaseItemErrors {
|
|
var itemErrDetail *ItemError
|
|
causeText := ""
|
|
if errors.As(itemErr, &itemErrDetail) && itemErrDetail != nil && itemErrDetail.Cause != nil {
|
|
causeText = itemErrDetail.Cause.Error()
|
|
}
|
|
log.Printf("release item %s (%s/%s) failed code=%s cause=%s", item.PublicationID, item.Action, item.KnowledgeID, code, causeText)
|
|
}
|
|
if retryable && job.Attempt < job.MaxAttempts {
|
|
retryableFailure = true
|
|
continue
|
|
}
|
|
if err := store.FailItem(ctx, FailItemRequest{ExecutionID: payload.ExecutionID, PublicationID: item.PublicationID, ErrorCode: code, Now: now().UTC()}); err != nil {
|
|
return ErrReleaseState
|
|
}
|
|
permanentFailure = true
|
|
failureIndex = index
|
|
break
|
|
}
|
|
if !validResult(operation, result) {
|
|
if err := store.FailItem(ctx, FailItemRequest{ExecutionID: payload.ExecutionID, PublicationID: item.PublicationID, ErrorCode: ErrorCodeBundleInvalid, Now: now().UTC()}); err != nil {
|
|
return ErrReleaseState
|
|
}
|
|
permanentFailure = true
|
|
failureIndex = index
|
|
break
|
|
}
|
|
if err := store.CompleteItem(ctx, CompleteItemRequest{
|
|
ExecutionID: payload.ExecutionID, PublicationID: item.PublicationID,
|
|
BundleSHA256: result.BundleSHA256, PreviousProductionRevision: result.PreviousProductionRevision,
|
|
ProductionRevision: result.ProductionRevision, Now: now().UTC(),
|
|
}); err != nil {
|
|
return ErrReleaseState
|
|
}
|
|
}
|
|
if permanentFailure {
|
|
break
|
|
}
|
|
}
|
|
if permanentFailure {
|
|
for _, item := range items[failureIndex+1:] {
|
|
if err := store.FailItem(ctx, FailItemRequest{ExecutionID: payload.ExecutionID, PublicationID: item.PublicationID, ErrorCode: ErrorCodeStoppedAfterFailure, Now: now().UTC()}); err != nil {
|
|
return ErrReleaseState
|
|
}
|
|
}
|
|
}
|
|
if retryableFailure {
|
|
return ErrReleaseRetryable
|
|
}
|
|
if err := store.FinishExecution(ctx, payload.ExecutionID, now().UTC()); err != nil {
|
|
return ErrReleaseState
|
|
}
|
|
if permanentFailure && operation == governance.ReleaseOperationPublish {
|
|
if err := store.ScheduleAutomaticRollback(ctx, payload.ExecutionID, now().UTC()); err != nil {
|
|
return ErrReleaseState
|
|
}
|
|
}
|
|
return nil
|
|
}, nil
|
|
}
|
|
|
|
func parseJob(job jobs.Job) (jobPayload, governance.ReleaseOperation, error) {
|
|
var payload jobPayload
|
|
decoder := json.NewDecoder(strings.NewReader(string(job.Payload)))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&payload); err != nil {
|
|
return jobPayload{}, "", ErrInvalidReleaseJob
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
return jobPayload{}, "", ErrInvalidReleaseJob
|
|
}
|
|
operation := governance.ReleaseOperation(payload.Operation)
|
|
expectedType := "release." + payload.Operation
|
|
if !validID(payload.ExecutionID, "rx-") || !validID(payload.ReleaseBatchID, "rb-") || !operation.Valid() || job.Type != expectedType || job.Attempt < 1 || job.MaxAttempts < job.Attempt {
|
|
return jobPayload{}, "", ErrInvalidReleaseJob
|
|
}
|
|
return payload, operation, nil
|
|
}
|
|
|
|
func publishItem(ctx context.Context, publisher Publisher, operation governance.ReleaseOperation, item PlanItem) (ItemResult, error) {
|
|
if operation == governance.ReleaseOperationRollback {
|
|
return publisher.Rollback(ctx, item)
|
|
}
|
|
if item.Action == "delete" {
|
|
return publisher.Delete(ctx, item)
|
|
}
|
|
return publisher.Publish(ctx, item)
|
|
}
|
|
|
|
func classifyItemError(err error) (string, bool) {
|
|
var itemErr *ItemError
|
|
if errors.As(err, &itemErr) && ValidErrorCode(itemErr.Code) {
|
|
return itemErr.Code, itemErr.Retryable
|
|
}
|
|
return ErrorCodeInternal, true
|
|
}
|
|
|
|
func validResult(operation governance.ReleaseOperation, result ItemResult) bool {
|
|
if strings.TrimSpace(result.ProductionRevision) == "" || len(result.ProductionRevision) > 128 {
|
|
return false
|
|
}
|
|
if operation == governance.ReleaseOperationPublish {
|
|
return validSHA256(result.BundleSHA256)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validSHA256(value string) bool {
|
|
if len(value) != 64 {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validID(value, prefix string) bool {
|
|
if !strings.HasPrefix(value, prefix) || len(value) > 36 || len(value) <= len(prefix) {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func ValidErrorCode(code string) bool {
|
|
switch code {
|
|
case ErrorCodeContractMismatch, ErrorCodeBundleInvalid, ErrorCodeTargetUnavailable,
|
|
ErrorCodeTargetWriteFailed, ErrorCodeVerificationFailed, ErrorCodeRollbackArtifactMissing,
|
|
ErrorCodeRollbackFailed, ErrorCodeStoppedAfterFailure, ErrorCodeInternal:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|