910c0fcd76
build / build (push) Failing after 16s
- 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 服务并读回权限
107 lines
3.8 KiB
Go
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})
|
|
}
|