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 } }