6440e3f679
- 迁移 000044:production_kb_bindings + release_item_executions.target_knowledge_base_id - 系统设置新增「生产知识库绑定(对内/对外)」:从生产 WeKnora 实际列表选库, 保存时真校验(不存在拒收;生产端不可达只提示并如实回报),仅管理员可改 - 执行计划按绑定解析目标库并冻结进 release_item_executions;空目标直接拒收; 已发布过的条目保留原目标(重试/回滚回到同一处) - DocumentBundle.RemapKnowledgeBase:knowledges/chunks/knowledge_tags/wiki_pages/ wiki_folders 的 knowledge_base_id 与 ES 文档字段改写为目标库,重算表/文档摘要并重新封装 - 契约比对改为「声明的映射对」(KnowledgeBaseMapping),其余字段继续 fail-closed - 契约检查器 / 目标身份 / 验证器全部走映射后的目标库;对象通道无需改写 - Compose 拆分:数据面接线 compose.release-sim.yaml + 两道门禁 compose.release-gates.yaml - 测试:RemapKnowledgeBase 全通道/无操作/fail-closed、映射契约、绑定接口、RBAC、真实 PG 集成
314 lines
10 KiB
Go
314 lines
10 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"`
|
|
// TargetKnowledgeBaseID is the 源库 → 目标库 mapping resolved when the
|
|
// execution started (see production_kb_bindings). It equals the tested base
|
|
// when the scope is unbound, which is the historical same-id semantics.
|
|
TargetKnowledgeBaseID string `json:"target_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
|
|
}
|
|
|
|
// TargetBase is the knowledge base this item is written to: the resolved
|
|
// 源库 → 目标库 mapping when the release carries one, otherwise the tested base
|
|
// itself (the historical same-id semantics).
|
|
func (item PlanItem) TargetBase() string {
|
|
if trimmed := strings.TrimSpace(item.TargetKnowledgeBaseID); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
return item.TestKnowledgeBaseID
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|