1fa35e9870
按生产级备份组合落地:
- internal/releasesnapshot:新增门禁包,要求 root 下存在覆盖目标库、未过期
(默认 24h)、含 wiki_pages.jsonl 与 sha256 的快照;本地文件再校验哈希。
7 条单测(过期/换库/篡改/缺页/未接线/半成品)。
- 发布器:写目标库之前(Publish/Delete/Rollback 三条路)先过门禁;映射目标
(源库→目标库,共享真实库)或 policy=always 时必须有快照;门禁未接线
同样拒绝(fail-closed)。新错误码 release_target_snapshot_missing(不可重试)
+ 页面中文提示。6 条发布器单测。
- worker 配置新增 target_snapshot{root,max_age_hours,policy},校验 policy/
max_age/绝对路径;compose.release-sim.yaml 只读挂载快照目录。
- scripts/target_kb_snapshot.sh:默认云内执行(与 DB 同 VPC,凭据取自 WeKnora
容器)+ 默认不导 chunks(44s 出 8 张表);修复 \copy 不能跨行/不能带 WITH、
ES 计数改用 knowledge_base_id.keyword。
- ES:注册 kb_backup 仓库(path.repo),首个快照 36s/4.7GB/0 分片失败,
SLM kb-hourly(3d)+kb-daily(7d) 已建并执行成功。
- OSS:目标桶开启版本控制(此前未开启)。
- 文档 P6 增补硬门禁与云内执行;真实快照目录上门禁实测:两个绑定库通过、
未覆盖库拒绝、未接线拒绝。
318 lines
10 KiB
Go
318 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"
|
|
// ErrorCodeTargetSnapshotMissing:写目标库之前必须存在覆盖该库的、未过期的
|
|
// 快照留档(scripts/target_kb_snapshot.sh 产出),否则拒绝发布。
|
|
ErrorCodeTargetSnapshotMissing = "release_target_snapshot_missing"
|
|
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, ErrorCodeTargetSnapshotMissing,
|
|
ErrorCodeInternal:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|