c72b71d78a
build / build (push) Failing after 17s
为什么:父审指出预备份引用持久化失败仍继续写目标、接管依赖启动扫描且漏掉租约未过期、 恢复失败被标成"无需恢复"、版本登记与作业状态分叉。 - BackupPrepared 返回 error,持久化失败即零写入;预备份同时登记 production_prebackup 版本。 - 接管改为按队列租约判定 + 运行期 30s 扫描 + 原子抢占 + 固定幂等键 + attempt 归零。 - 接管场景保留预备份、不再宣称"未写无需恢复"。 - 恢复/重放同一快照重新激活 production_effective(清 superseded_at)。 - finalize 续作只做校验+登记(finalize_only),不重写生产。 - 索引映射基线缺失时采用首次观察值并登记;索引校验加孤儿文档检查(按 knowledge_id)。 真实验收(候选 API :18089 + 真实 PG/ES/MinIO): A0 首次发布、A1 撤销、B 按版本恢复、C 写阶段 SIGKILL 接管、 D 跨 kind 互斥与多 worker 单次执行、E1 登记失败、E2 finalize 续作、E3 预备份失败零写入。
152 lines
5.8 KiB
Go
152 lines
5.8 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
)
|
|
|
|
// 生产发布授权:管理员把"目标 + 快照 + 参数摘要 + 有效期"钉死。
|
|
// 换快照、换目标或改参数都会得到不同摘要,旧授权自然失效;到期或被撤销也不能用。
|
|
type libraryAuthorizationRequest struct {
|
|
Scope string `json:"scope"`
|
|
SnapshotID string `json:"snapshot_id"`
|
|
// Action 决定授权的动作:sync(默认)或 restore。两者摘要不同,
|
|
// 同步授权不能当恢复授权用,反之亦然。
|
|
Action string `json:"action"`
|
|
ExpiresInMinutes int `json:"expires_in_minutes"`
|
|
}
|
|
|
|
type libraryAuthorizationStore interface {
|
|
CreateLibraryAuthorization(ctx context.Context, item governance.LibraryReleaseAuthorization) error
|
|
LibraryAuthorizations(ctx context.Context, scope string, limit int) ([]governance.LibraryReleaseAuthorization, error)
|
|
RevokeLibraryAuthorization(ctx context.Context, id string, now time.Time) error
|
|
}
|
|
|
|
func libraryAuthorizationStoreOf(config Config) (libraryAuthorizationStore, bool) {
|
|
store, ok := config.Store.(libraryAuthorizationStore)
|
|
return store, ok
|
|
}
|
|
|
|
// librarySyncParamsDigest 与 POST /library/syncs 使用的摘要必须完全一致,
|
|
// 否则授权永远匹配不上,或者更糟——能匹配上却对应别的参数。
|
|
func librarySyncParamsDigest(scope, snapshotID string) (string, error) {
|
|
return governance.LibraryRequestDigest(scope, governance.LibraryJobSync,
|
|
map[string]any{"target_snapshot_id": snapshotID})
|
|
}
|
|
|
|
func libraryAuthorizationCreate(w http.ResponseWriter, r *http.Request, config Config) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
|
return
|
|
}
|
|
identity, ok := sessionFrom(r.Context())
|
|
if !ok || roleRank(identity.Role) < roleRank(roleAdmin) {
|
|
writeJSON(w, http.StatusForbidden, map[string]any{"error": "生产发布授权仅管理员可签发"})
|
|
return
|
|
}
|
|
store, ok := libraryAuthorizationStoreOf(config)
|
|
if !ok {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "授权存储不可用"})
|
|
return
|
|
}
|
|
var request libraryAuthorizationRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&request); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
if request.Scope != "internal" && request.Scope != "external" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "scope must be internal or external"})
|
|
return
|
|
}
|
|
if strings.TrimSpace(request.SnapshotID) == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "snapshot_id required"})
|
|
return
|
|
}
|
|
// 有效期必须显式给出且很短:不提供"永久授权"这种危险默认值。
|
|
if request.ExpiresInMinutes < 5 || request.ExpiresInMinutes > 24*60 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expires_in_minutes must be between 5 and 1440"})
|
|
return
|
|
}
|
|
action := strings.TrimSpace(request.Action)
|
|
if action == "" {
|
|
action = "sync"
|
|
}
|
|
var digest string
|
|
var err error
|
|
switch action {
|
|
case "sync":
|
|
digest, err = librarySyncParamsDigest(request.Scope, request.SnapshotID)
|
|
case "restore":
|
|
digest, err = libraryRestoreParamsDigest(request.Scope, request.SnapshotID)
|
|
default:
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action must be sync or restore"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot digest params"})
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
item := governance.LibraryReleaseAuthorization{
|
|
ID: "libauth-" + action + "-" + request.Scope + "-" + now.Format("20060102T150405.000000000"),
|
|
Scope: request.Scope,
|
|
SnapshotID: request.SnapshotID,
|
|
ParamsDigest: digest,
|
|
AuthorizedBy: identity.Username,
|
|
ExpiresAt: now.Add(time.Duration(request.ExpiresInMinutes) * time.Minute),
|
|
CreatedAt: now,
|
|
}
|
|
if err := store.CreateLibraryAuthorization(r.Context(), item); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot record authorization"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"authorization": item})
|
|
}
|
|
|
|
func libraryAuthorizationList(w http.ResponseWriter, r *http.Request, config Config) {
|
|
scope, err := scopeFromQuery(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
store, ok := libraryAuthorizationStoreOf(config)
|
|
if !ok {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"available": false, "reason": "授权存储不可用", "authorizations": []any{}})
|
|
return
|
|
}
|
|
items, err := store.LibraryAuthorizations(r.Context(), scope, 50)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot read authorizations"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"available": true, "authorizations": items})
|
|
}
|
|
|
|
func libraryAuthorizationRevoke(w http.ResponseWriter, r *http.Request, config Config) {
|
|
identity, ok := sessionFrom(r.Context())
|
|
if !ok || roleRank(identity.Role) < roleRank(roleAdmin) {
|
|
writeJSON(w, http.StatusForbidden, map[string]any{"error": "撤销生产发布授权仅管理员可执行"})
|
|
return
|
|
}
|
|
store, ok := libraryAuthorizationStoreOf(config)
|
|
if !ok {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "授权存储不可用"})
|
|
return
|
|
}
|
|
id := strings.TrimSpace(r.PathValue("id"))
|
|
if id == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "authorization id required"})
|
|
return
|
|
}
|
|
if err := store.RevokeLibraryAuthorization(r.Context(), id, time.Now().UTC()); err != nil {
|
|
writeJSON(w, http.StatusConflict, map[string]any{"error": "授权不存在或已撤销"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"revoked": id})
|
|
}
|