mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
部署目录早已迁到 /home/hologramagent,单元名是最后一处旧名。 UnitName 是唯一来源(internal/systemd/sync.go),deploy-plan.sh 同步。
245 lines
8.4 KiB
Go
245 lines
8.4 KiB
Go
// Package systemd 负责「数据库配置 ↔ systemd 单元」的**双向一致性**。
|
||
//
|
||
// ## 要解决的问题(用户 2026-10-17 指出)
|
||
//
|
||
// 内核有两处"数据目录"来源,且**不等价**:
|
||
//
|
||
// -data <dir> ← systemd 单元,决定 ks.DataDir
|
||
// core.daemon.data_dir ← 数据库,决定 ks.Cfg.Daemon.DataDir
|
||
//
|
||
// 实测(把副本 DB 的 data_dir 改成 /tmp/DB_SAYS,-data 仍给 /tmp/ddtest):
|
||
//
|
||
// 知识库迁移扫描失败: open /tmp/DB_SAYS/knowledge: no such file
|
||
// text memory active at /tmp/DB_SAYS/memory/text
|
||
//
|
||
// 而 ks.DataDir(-data)管的是 memory/graph.db(数据最多的图记忆)与
|
||
// 插件 SettingsAPI.DataDir()。⇒ **两者不一致时数据会分裂**:图记忆在 A、
|
||
// 文本/知识/文档在 B,检索跨目录、备份会漏。
|
||
//
|
||
// 生产当前两者都是 /home/newqqagent,所以没暴露。但 WebUI / CLI 允许改
|
||
// core.daemon.data_dir 而**没有任何提示** —— 用户改完重启就会发现数据"丢了"。
|
||
//
|
||
// ## 本包的职责
|
||
//
|
||
// 1. 检测不一致(Check)
|
||
// 2. 生成**可以直接复制执行**的修复命令(SyncCommand)
|
||
//
|
||
// ## ★ 关键设计:命令必须内联替换,不让用户手工编辑
|
||
//
|
||
// 用户明确要求:
|
||
//
|
||
// 「指令应当直接包含替换部分而不是让用户手动修改」
|
||
//
|
||
// 所以生成的是**一条幂等的 sed 原地替换 + daemon-reload + restart**,
|
||
// 而不是"请编辑文件把 -data 改成 xxx"。理由:
|
||
//
|
||
// · 手工编辑会漏掉 drop-in(生产用 drop-in 覆盖 ExecStart,改主单元无效)
|
||
// · 手工编辑容易把 --role=guard 一起改坏
|
||
// · 一条命令可审计、可复制、可回滚(带 .bak)
|
||
package systemd
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
// UnitName 是内核的服务单元名。
|
||
const UnitName = "hologramagent.service"
|
||
|
||
// State 描述一次一致性检查的结果。
|
||
type State struct {
|
||
// RuntimeDataDir 是当前进程**实际**用的数据目录(来自 -data)。
|
||
RuntimeDataDir string
|
||
// ConfigDataDir 是数据库里 core.daemon.data_dir 的值。
|
||
ConfigDataDir string
|
||
// UnitPath 是主单元文件路径(通常 /etc/systemd/system/homeagent.service)。
|
||
UnitPath string
|
||
// UnitDataDir 从单元文件里解析出的 -data 值(可能为空=没解析到)。
|
||
UnitDataDir string
|
||
// DropIns 是覆盖 ExecStart 的 drop-in 路径(非空时改主单元无效)。
|
||
DropIns []string
|
||
}
|
||
|
||
// InSync 报告运行期与数据库配置是否一致。
|
||
func (s State) InSync() bool {
|
||
return s.RuntimeDataDir == "" || s.ConfigDataDir == "" ||
|
||
filepath.Clean(s.RuntimeDataDir) == filepath.Clean(s.ConfigDataDir)
|
||
}
|
||
|
||
// Describe 给出人类可读的诊断(用于启动日志 / WebUI 提示)。
|
||
func (s State) Describe() string {
|
||
if s.InSync() {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf(
|
||
"数据目录不一致:内核按 -data 用的是 %q,而数据库 core.daemon.data_dir 是 %q。\n"+
|
||
"这两者**不等价**:\n"+
|
||
" · -data → memory/graph.db(图记忆)、插件数据根目录\n"+
|
||
" · core.daemon.data_dir → memory/text、memory/documents、knowledge、nlp、adapters、agentfs\n"+
|
||
"⇒ 不一致会让数据分裂,检索跨目录、备份会漏。",
|
||
s.RuntimeDataDir, s.ConfigDataDir)
|
||
}
|
||
|
||
// Check 探测当前一致性。任何一步失败都返回已收集到的部分信息 + 错误,
|
||
// 由调用方决定"降级为只告警"还是"当失败处理"。
|
||
func Check(runtimeDataDir, configDataDir string) (State, error) {
|
||
st := State{RuntimeDataDir: runtimeDataDir, ConfigDataDir: configDataDir}
|
||
|
||
unitPath := filepath.Join("/etc/systemd/system", UnitName)
|
||
if _, err := os.Stat(unitPath); err == nil {
|
||
st.UnitPath = unitPath
|
||
if v := parseDataFlagFromFile(unitPath); v != "" {
|
||
st.UnitDataDir = v
|
||
}
|
||
} else {
|
||
// 发行版可能装在 /lib/systemd/system
|
||
alt := filepath.Join("/lib/systemd/system", UnitName)
|
||
if _, err := os.Stat(alt); err == nil {
|
||
st.UnitPath = alt
|
||
st.UnitDataDir = parseDataFlagFromFile(alt)
|
||
}
|
||
}
|
||
|
||
// drop-in 里若有 `ExecStart=`(清空)后重设,改主单元无效 —— 必须一并处理。
|
||
dropDir := filepath.Join("/etc/systemd/system", UnitName+".d")
|
||
if entries, err := os.ReadDir(dropDir); err == nil {
|
||
for _, e := range entries {
|
||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") {
|
||
continue
|
||
}
|
||
p := filepath.Join(dropDir, e.Name())
|
||
if b, err := os.ReadFile(p); err == nil {
|
||
if reExecStartReset.Match(b) {
|
||
st.DropIns = append(st.DropIns, p)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return st, nil
|
||
}
|
||
|
||
var (
|
||
reDataFlag = regexp.MustCompile(`(?:^|\s)-data[= ]([^\s"']+)`)
|
||
reExecStartLn = regexp.MustCompile(`(?m)^\s*ExecStart\s*=.*$`)
|
||
reExecStartReset = regexp.MustCompile(`(?m)^\s*ExecStart\s*=\s*$`)
|
||
)
|
||
|
||
func parseDataFlagFromFile(path string) string {
|
||
b, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
if m := reDataFlag.FindSubmatch(b); m != nil {
|
||
return string(m[1])
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// SyncCommand 生成**可直接复制执行**的修复命令。
|
||
//
|
||
// ## 为什么是一条内联 sed 而不是"请编辑文件"
|
||
//
|
||
// 用户明确要求「指令应当直接包含替换部分」。所以这里把新值直接拼进命令:
|
||
//
|
||
// sudo sed -i.bak 's|-data [^ ]*|-data /新路径|' /etc/systemd/system/homeagent.service
|
||
// sudo systemctl daemon-reload && sudo systemctl restart homeagent
|
||
//
|
||
// 特性:
|
||
//
|
||
// · **幂等**:再跑一次结果相同
|
||
// · **带 .bak**:可回滚(cp xxx.bak xxx)
|
||
// · **只替换 -data 的值**,不碰 --role=guard 等其他参数
|
||
// · drop-in 里若重设了 ExecStart,会**指出具体文件**并同样给出 sed 命令
|
||
//
|
||
// want 为空时用 ConfigDataDir。
|
||
func (s State) SyncCommand(want string) string {
|
||
if want == "" {
|
||
want = s.ConfigDataDir
|
||
}
|
||
want = strings.TrimSpace(want)
|
||
if want == "" {
|
||
return ""
|
||
}
|
||
var b strings.Builder
|
||
|
||
b.WriteString("# 1) 把 systemd 单元的数据目录改成 ")
|
||
b.WriteString(want)
|
||
b.WriteString("(幂等,自动留 .bak)\n")
|
||
|
||
if s.UnitPath != "" {
|
||
fmt.Fprintf(&b,
|
||
"sudo sed -i.bak -E 's|(-data[= ])[^ \"'\"']+|\\1%s|' %s\n",
|
||
sedEscapeRepl(want), s.UnitPath)
|
||
}
|
||
// drop-in 覆盖了 ExecStart 的,必须**也**改它,否则主单元改了也没用。
|
||
for _, d := range s.DropIns {
|
||
fmt.Fprintf(&b,
|
||
"sudo sed -i.bak -E 's|(-data[= ])[^ \"'\"']+|\\1%s|' %s\n",
|
||
sedEscapeRepl(want), d)
|
||
}
|
||
|
||
b.WriteString("\n# 2) 让 systemd 重新加载并重启(guard 会用新参数拉起 worker)\n")
|
||
b.WriteString("sudo systemctl daemon-reload && sudo systemctl restart ")
|
||
b.WriteString(strings.TrimSuffix(UnitName, ".service"))
|
||
b.WriteString("\n")
|
||
|
||
b.WriteString("\n# 3) 校验(应看到 ExecStart 里的 -data 已是新值)\n")
|
||
b.WriteString("systemctl show ")
|
||
b.WriteString(UnitName)
|
||
b.WriteString(" -p ExecStart --no-pager\n")
|
||
|
||
b.WriteString("\n# 回滚(若需)\n")
|
||
if s.UnitPath != "" {
|
||
fmt.Fprintf(&b, "sudo cp %s.bak %s && sudo systemctl daemon-reload && sudo systemctl restart %s\n",
|
||
s.UnitPath, s.UnitPath, strings.TrimSuffix(UnitName, ".service"))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// sedEscapeRepl 转义 sed 替换段里的特殊字符(分隔符用 |)。
|
||
func sedEscapeRepl(s string) string {
|
||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||
s = strings.ReplaceAll(s, "|", `\|`)
|
||
s = strings.ReplaceAll(s, "&", `\&`)
|
||
return s
|
||
}
|
||
|
||
// RestartCommand 返回「热更新」命令:只重启(不动 systemd 配置)。
|
||
//
|
||
// 与 SyncCommand 的区别:配置没变、只换了二进制时用这个。
|
||
// 走 WebUI 的 agent restart 端点即可(exit 42 → guard 用新二进制重拉),
|
||
// 这里给出不依赖 WebUI 的等价 curl。
|
||
func RestartCommand(addr, apiKey string) string {
|
||
return fmt.Sprintf(`curl -sS -X POST \
|
||
-H "X-API-Key: %s" \
|
||
"http://%s/api/v1/agents/agent/restart"`, apiKey, addr)
|
||
}
|
||
|
||
// IsUnderSystemd 报告当前进程是否由 systemd 拉起(用于决定是否提示提权)。
|
||
func IsUnderSystemd() bool {
|
||
// INVOCATION_ID 由 systemd 注入,是最可靠的判据(容器里也可能有 systemd)。
|
||
if os.Getenv("INVOCATION_ID") != "" {
|
||
return true
|
||
}
|
||
// 回退:父进程是 pid 1 且 /run/systemd/system 存在。
|
||
if _, err := os.Stat("/run/systemd/system"); err != nil {
|
||
return false
|
||
}
|
||
return os.Getppid() == 1
|
||
}
|
||
|
||
// HasSudo 报告当前进程能否免密 sudo(决定提示里要不要写 sudo)。
|
||
func HasSudo() bool {
|
||
if os.Geteuid() == 0 {
|
||
return true
|
||
}
|
||
if _, err := exec.LookPath("sudo"); err != nil {
|
||
return false
|
||
}
|
||
return exec.Command("sudo", "-n", "true").Run() == nil
|
||
}
|