Files
knowledge-governance/internal/httpapi/library_capture.go
T
姚章浩 910c0fcd76
build / build (push) Failing after 16s
feat(library): 页面发起封存的最小权限主机侧执行边界
- libraryagent:0700 目录内 0600 unix socket + SO_PEERCRED 同 uid,
  固定 binary/归档/日志/项目/写入者声明,请求只能选范围与作业号
- API:POST/GET /api/v1/library/captures(入队 library.capture + 读队列状态)
- worker:library.capture handler 走 socket;启动先 recover;不挂 Docker socket、
  不持有来源凭据
- web:整库版本页显示真实作业状态并可发起封存(未接线/无权限给出原因)
- scripts/library-agent-install.sh:构建、安装 systemd user 服务并读回权限
2026-10-28 12:11:08 +08:00

107 lines
3.8 KiB
Go

package httpapi
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"knowledge-governance/internal/jobs"
)
// 页面发起封存:只投递一个作业,真正的停机/导出/恢复发生在主机侧 agent。
// 这里没有任何 Docker 访问,也没有来源凭据 —— 调用方只能选择范围与作业号。
const libraryCaptureJobType = "library.capture"
var libraryCaptureJobPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
type libraryCaptureRequest struct {
Scope string `json:"scope"`
Job string `json:"job"`
}
func libraryCaptureEnqueue(w http.ResponseWriter, r *http.Request, config Config) {
if config.LibraryAgentSocket == "" {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"code": "library_capture_unwired",
"reason": "未接线:主机侧封存 agent 没有挂载,页面无法发起封存",
})
return
}
var request libraryCaptureRequest
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
}
// The job id is the idempotency key handed to the agent: repeating the same
// scope+job returns the already sealed version instead of sealing twice.
captureJob := strings.TrimSpace(request.Job)
if captureJob == "" {
captureJob = "lib-" + request.Scope + "-" + time.Now().UTC().Format("20060102T150405")
}
if !libraryCaptureJobPattern.MatchString(captureJob) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid job id"})
return
}
requestedBy := ""
if identity, ok := sessionFrom(r.Context()); ok {
requestedBy = identity.Username
}
payload, err := json.Marshal(map[string]string{
"scope": request.Scope, "capture_job": captureJob, "requested_by": requestedBy,
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot build capture job"})
return
}
now := time.Now().UTC()
job, err := config.Store.Enqueue(r.Context(), jobs.EnqueueRequest{
ID: fmt.Sprintf("library-capture-%s-%d", request.Scope, now.UnixNano()),
Type: libraryCaptureJobType,
Payload: payload,
AvailableAt: now,
MaxAttempts: 2,
IdempotencyKey: "library.capture:" + captureJob,
}, now)
if err != nil || job == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot enqueue capture job"})
return
}
writeJSON(w, http.StatusAccepted, map[string]any{
"job_id": job.ID, "capture_job": captureJob, "scope": request.Scope, "state": job.State,
})
}
// libraryCaptureJobs lists recent capture jobs so the page can show real state
// instead of an optimistic spinner. It reads the queue, not the archive.
func libraryCaptureJobs(w http.ResponseWriter, r *http.Request, config Config) {
queue, err := config.Store.List(r.Context(), 200)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot read capture jobs"})
return
}
views := make([]map[string]any, 0, 20)
for _, job := range queue {
if job.Type != libraryCaptureJobType || len(views) >= 20 {
continue
}
var payload struct {
Scope string `json:"scope"`
CaptureJob string `json:"capture_job"`
RequestedBy string `json:"requested_by"`
}
_ = json.Unmarshal(job.Payload, &payload)
views = append(views, map[string]any{"job_id": job.ID, "capture_job": payload.CaptureJob,
"scope": payload.Scope, "state": job.State, "attempt": job.Attempt,
"last_error": job.LastError, "requested_by": payload.RequestedBy,
"created_at": job.CreatedAt, "updated_at": job.UpdatedAt})
}
writeJSON(w, http.StatusOK, map[string]any{"available": true, "jobs": views})
}