mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 22:17:42 +02:00
feat: let an installation choose its update source, and adopt a plain directory
config.yaml gains an optional update section (remote / remote_url / branch). The resolver keeps its default - whatever remote this tree already tracks - and the explicit choice wins when set, which is also what lets a tarball installation that never saw git get on the update rails at all. - RemoteURL is fetched straight into a ref namespace of its own, so a URL source cannot be shadowed by a same-named remote's stale refs. ValidRemoteURL allowlists https/http/ssh/git/file and local absolute paths, and rejects git's ext:: transport (which executes commands) before anything reaches argv. - Adopt connects a plain directory to the source. Preview is side-effect free (the fetch happens in a throwaway bare repository) and lists every local file whose content the target would replace, plus the operator content that is put aside. The confirmed run inits, adds origin, stashes operator content, keeps every replaced file under .update-backup/<ts>/overwritten/ so "the update ate my edit" has an address, lands the target content, sets upstream, rebuilds the binary. A failure removes only the .git it created itself; once the content has landed the tree stays adopted even if the build fails. - Console/API: the status envelope carries the configured source; POST /system/update/source writes it through the same config path every other setting uses (validated, audited); POST /system/update/adopt is preview-then-job, with the job's kind recorded. OpenAPI updated; RBAC rides the existing update:apply prefix and the route inventory stays green. Tests: URL-mode check/apply against real repositories (including the address beating a remote name), URL validation table, and the full adoption story - preview touches nothing, operator content survives, the replaced edit is in the backup, and the adopted tree updates and rolls back like any other installation.
This commit is contained in:
1 parent
dc5c4d6a75
commit
acd84cd525
12 files changed
+1125
-50
No files matched your search
@@ -589,3 +589,15 @@ project:
|
||||
fact_index_path_max_runes: 10000
|
||||
fact_summary_max_runes: 24000
|
||||
default_inject_deprecated: false
|
||||
|
||||
# ============================================
|
||||
# 一键更新(可选)
|
||||
# ============================================
|
||||
# 更新源:不配置时,更新跟随本目录自己跟踪的 git 远端;配置后按这里指定的来源更新。
|
||||
# remote 与 remote_url 二选一(页面保存时两者互斥;都手写时以 remote_url 为准)。
|
||||
# 非 git 目录(解压/打包安装)配置地址后,可在控制台「一键更新」页执行「接入更新源」,
|
||||
# 把目录变成跟踪该仓库的 git 工作树,此后按正常安装一键更新。
|
||||
# update:
|
||||
# remote: origin # 用已有远端名指定(mine/origin/upstream/任意名)
|
||||
# remote_url: https://github.com/AIPentest/CyberStrikeAI.git # 或直接给仓库地址
|
||||
# branch: main # 要跟踪的分支;留空按 upstream/当前分支推断
|
||||
+4
-2
@@ -617,7 +617,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
updateHandler := handler.NewUpdateHandler(configDir, log.Logger, auditSvc, func() {
|
||||
app.Shutdown()
|
||||
os.Exit(0)
|
||||
})
|
||||
}, configHandler.UpdateSource, configHandler.SetUpdateSource)
|
||||
|
||||
// 设置路由(使用 App 实例以便动态获取 handler)
|
||||
setupRoutes(
|
||||
@@ -1134,12 +1134,14 @@ func setupRoutes(
|
||||
protected.POST("/config/test-vision", configHandler.TestVision)
|
||||
protected.POST("/config/list-models", configHandler.ListModels)
|
||||
|
||||
// 系统更新(保持本安装自身源码最新:查看、检查、应用、进度、回滚)
|
||||
// 系统更新(保持本安装自身源码最新:查看、检查、应用、进度、回滚、接入、更新源)
|
||||
protected.GET("/system/update", updateHandler.GetStatus)
|
||||
protected.GET("/system/update/job", updateHandler.Job)
|
||||
protected.POST("/system/update/check", updateHandler.Check)
|
||||
protected.POST("/system/update/apply", updateHandler.Apply)
|
||||
protected.POST("/system/update/rollback", updateHandler.Rollback)
|
||||
protected.POST("/system/update/adopt", updateHandler.Adopt)
|
||||
protected.POST("/system/update/source", updateHandler.SaveSource)
|
||||
|
||||
// 系统设置 - 终端(执行命令,提高运维效率)
|
||||
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
||||
|
||||
@@ -49,6 +49,20 @@ type Config struct {
|
||||
MultiAgent MultiAgentConfig `yaml:"multi_agent,omitempty" json:"multi_agent,omitempty"`
|
||||
Project ProjectConfig `yaml:"project,omitempty" json:"project,omitempty"`
|
||||
Vision VisionConfig `yaml:"vision,omitempty" json:"vision,omitempty"`
|
||||
Update UpdateConfig `yaml:"update,omitempty" json:"update,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateConfig 一键更新的更新源(可选):显式指定从哪个仓库/分支更新本安装。
|
||||
// 不配置时更新跟随安装目录自己跟踪的远端;两种来源都在本地解析,没有写死的仓库地址。
|
||||
type UpdateConfig struct {
|
||||
// Remote 用安装树里已有的远端名指定更新源(mine/origin/upstream 或任意自定义名)。
|
||||
Remote string `yaml:"remote,omitempty" json:"remote,omitempty"`
|
||||
// RemoteURL 直接给出仓库地址(https/http/ssh/git/file:// 或本机绝对路径),不必先 git remote add;
|
||||
// 与 Remote 同时配置时以地址为准(页面保存时两者互斥)。
|
||||
RemoteURL string `yaml:"remote_url,omitempty" json:"remote_url,omitempty"`
|
||||
// Branch 要跟踪的分支;留空时取当前分支的 upstream(没有 upstream 时取当前分支名;
|
||||
// 非 git 目录接入时取仓库的默认分支)。
|
||||
Branch string `yaml:"branch,omitempty" json:"branch,omitempty"`
|
||||
}
|
||||
|
||||
type EnsureLocalConfigResult struct {
|
||||
|
||||
@@ -1912,6 +1912,26 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// SetUpdateSource 记录一键更新的更新源(remote 与 remote_url 二选一,另有可选 branch)。
|
||||
// 走与其他设置相同的写入路径:备份旧文件、按 YAML 文档只改 update 段、原子落盘。
|
||||
func (h *ConfigHandler) SetUpdateSource(remote, remoteURL, branch string) error {
|
||||
h.mu.Lock()
|
||||
h.config.Update = config.UpdateConfig{
|
||||
Remote: strings.TrimSpace(remote),
|
||||
RemoteURL: strings.TrimSpace(remoteURL),
|
||||
Branch: strings.TrimSpace(branch),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return h.saveConfig()
|
||||
}
|
||||
|
||||
// UpdateSource 读当前生效的更新源(加读锁;更新处理器每次请求现读,保存后立即生效)。
|
||||
func (h *ConfigHandler) UpdateSource() (string, string, string) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.config.Update.Remote, h.config.Update.RemoteURL, h.config.Update.Branch
|
||||
}
|
||||
|
||||
// saveConfig 保存配置到文件
|
||||
func (h *ConfigHandler) saveConfig() error {
|
||||
configFileMu.Lock()
|
||||
@@ -1938,6 +1958,7 @@ func (h *ConfigHandler) saveConfig() error {
|
||||
updateAIConfig(root, h.config.AI)
|
||||
removeKeyFromMap(root.Content[0], "openai")
|
||||
updateVisionConfig(root, h.config.Vision)
|
||||
updateUpdateSourceConfig(root, h.config.Update)
|
||||
updateFOFAConfig(root, h.config.FOFA)
|
||||
updateSpaceSearchConfig(root, "zoomeye", h.config.ZoomEye)
|
||||
updateSpaceSearchConfig(root, "quake", h.config.Quake)
|
||||
@@ -2065,6 +2086,35 @@ func updateMCPConfig(doc *yaml.Node, cfg config.MCPConfig) {
|
||||
setIntInMap(mcpNode, "port", cfg.Port)
|
||||
}
|
||||
|
||||
// updateUpdateSourceConfig 写入一键更新的更新源(update 段)。三项全空时把整段删掉,
|
||||
// 而不是留下一个空的 update: 块——"没有配置"应当就是文件里没有这一段。
|
||||
func updateUpdateSourceConfig(doc *yaml.Node, cfg config.UpdateConfig) {
|
||||
root := doc.Content[0]
|
||||
remote := strings.TrimSpace(cfg.Remote)
|
||||
remoteURL := strings.TrimSpace(cfg.RemoteURL)
|
||||
branch := strings.TrimSpace(cfg.Branch)
|
||||
if remote == "" && remoteURL == "" && branch == "" {
|
||||
removeKeyFromMap(root, "update")
|
||||
return
|
||||
}
|
||||
node := ensureMap(root, "update")
|
||||
if remote != "" {
|
||||
setStringInMap(node, "remote", remote)
|
||||
} else {
|
||||
removeKeyFromMap(node, "remote")
|
||||
}
|
||||
if remoteURL != "" {
|
||||
setStringInMap(node, "remote_url", remoteURL)
|
||||
} else {
|
||||
removeKeyFromMap(node, "remote_url")
|
||||
}
|
||||
if branch != "" {
|
||||
setStringInMap(node, "branch", branch)
|
||||
} else {
|
||||
removeKeyFromMap(node, "branch")
|
||||
}
|
||||
}
|
||||
|
||||
func updateVisionConfig(doc *yaml.Node, cfg config.VisionConfig) {
|
||||
root := doc.Content[0]
|
||||
visionNode := ensureMap(root, "vision")
|
||||
|
||||
@@ -3418,6 +3418,61 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/system/update/source": map[string]interface{}{
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"系统更新"},
|
||||
"summary": "配置更新源(本安装从哪个仓库更新)",
|
||||
"description": "把更新源写进 config.yaml 的 update 段:remote(已有远端名)与 remote_url(仓库地址)二选一,branch 可选。不配置时更新跟随本目录自己跟踪的远端;地址白名单为 https/http/ssh/git/file:// 与本机绝对路径(git 的 ext:: 传输会执行命令,一律拒绝)。",
|
||||
"operationId": "saveUpdateSource",
|
||||
"requestBody": map[string]interface{}{
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"remote": map[string]interface{}{"type": "string", "description": "安装树里已有的远端名(mine/origin/upstream/任意名)"},
|
||||
"remoteUrl": map[string]interface{}{"type": "string", "description": "仓库地址(与 remote 二选一)"},
|
||||
"branch": map[string]interface{}{"type": "string", "description": "要跟踪的分支;留空按 upstream/当前分支推断"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{"description": "已保存(返回生效的更新源)"},
|
||||
"400": map[string]interface{}{"description": "远端名/地址/分支不合法,或两者同时给出"},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/system/update/adopt": map[string]interface{}{
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"系统更新"},
|
||||
"summary": "把非 git 目录接入更新源",
|
||||
"description": "解压/打包安装所在的目录接入配置好的更新源:不带 confirm 做无副作用预览(在临时仓库里 fetch,列出会被目标版本替换的文件与会保留的运维者内容);confirm=true 时异步执行(202 + job_id,用 /api/system/update/job 轮询):git init、添加 origin、落地目标分支内容,运维者内容先暂存再放回,被替换的文件全部留底到 .update-backup/<时间戳>/overwritten/。已是 git 工作树时返回 409(用一键更新而不是接入)。",
|
||||
"operationId": "adoptUpdateSource",
|
||||
"requestBody": map[string]interface{}{
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"confirm": map[string]interface{}{"type": "boolean", "description": "false/缺省=只预览;true=确认执行"},
|
||||
"restart": map[string]interface{}{"type": "boolean", "description": "接入成功后退出进程交给外部守护拉起,默认 false"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{"description": "预览结果(plan:将被替换/保留的文件清单与总数)"},
|
||||
"202": map[string]interface{}{"description": "接入任务已受理,返回 job_id"},
|
||||
"400": map[string]interface{}{"description": "没有配置更新源、地址不合法、分支不合法,或要求重启但没有重启钩子"},
|
||||
"409": map[string]interface{}{"description": "目录已是 git 工作树;或有任务在进行中"},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/config": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"配置管理"},
|
||||
|
||||
+133
-11
@@ -6,6 +6,7 @@ import (
|
||||
"cyberstrike-ai/internal/update"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -29,6 +30,13 @@ type UpdateHandler struct {
|
||||
// request asked for a restart, because an unsupervised process that exits stays
|
||||
// exited - "I restarted you" would be a lie on most deployments.
|
||||
restart func()
|
||||
// source reports the configured update source (config.yaml update section) as the
|
||||
// operator last saved it; empty strings mean "not configured". A function rather
|
||||
// than a value because the config can change while the process runs.
|
||||
source func() (remote, remoteURL, branch string)
|
||||
// saveSource persists a new source through the config layer; nil means this start
|
||||
// has no config to write to.
|
||||
saveSource func(remote, remoteURL, branch string) error
|
||||
|
||||
mu sync.Mutex
|
||||
jobs map[string]*updateJob
|
||||
@@ -41,6 +49,7 @@ type UpdateHandler struct {
|
||||
// compiler finishes.
|
||||
type updateJob struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // apply | adopt
|
||||
State string `json:"state"` // running | succeeded | failed
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished,omitempty"`
|
||||
@@ -61,17 +70,38 @@ func (j *updateJob) view() *updateJob {
|
||||
// NewUpdateHandler takes the audit service as a constructor argument rather than through
|
||||
// a SetAudit the assembly has to remember: forgetting here is a compile error instead of
|
||||
// a privileged endpoint that writes no audit records at all.
|
||||
func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, restart func()) *UpdateHandler {
|
||||
func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, restart func(),
|
||||
source func() (remote, remoteURL, branch string), saveSource func(remote, remoteURL, branch string) error) *UpdateHandler {
|
||||
return &UpdateHandler{
|
||||
root: root,
|
||||
logger: logger,
|
||||
audit: auditSvc,
|
||||
restart: restart,
|
||||
jobs: map[string]*updateJob{},
|
||||
root: root,
|
||||
logger: logger,
|
||||
audit: auditSvc,
|
||||
restart: restart,
|
||||
source: source,
|
||||
saveSource: saveSource,
|
||||
jobs: map[string]*updateJob{},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *UpdateHandler) options() update.Options { return update.Options{Root: h.root} }
|
||||
func (h *UpdateHandler) options() update.Options {
|
||||
opts := update.Options{Root: h.root}
|
||||
if h.source != nil {
|
||||
opts.Remote, opts.RemoteURL, opts.Branch = h.source()
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// sourceView is the configured source as the page edits it (empty strings = unset).
|
||||
func (h *UpdateHandler) sourceView() gin.H {
|
||||
var remote, remoteURL, branch string
|
||||
if h.source != nil {
|
||||
remote, remoteURL, branch = h.source()
|
||||
}
|
||||
return gin.H{
|
||||
"remote": remote, "remoteUrl": remoteURL, "branch": branch,
|
||||
"configured": remote != "" || remoteURL != "" || branch != "",
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus answers GET /api/system/update: the install tree as it is on disk, without a
|
||||
// network round trip, so opening the page is never gated on GitHub being reachable.
|
||||
@@ -85,6 +115,7 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) {
|
||||
"status": snap,
|
||||
"job": h.latest(),
|
||||
"canRestart": h.restart != nil,
|
||||
"source": h.sourceView(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,6 +134,92 @@ func (h *UpdateHandler) Check(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": snap})
|
||||
}
|
||||
|
||||
// SaveSource answers POST /api/system/update/source: the operator picks what this
|
||||
// installation updates from - the official repository, their own fork, or a second
|
||||
// development line - and the choice lands in the update section of config.yaml. A remote
|
||||
// name and an address are two ways to say the same thing and are refused together rather
|
||||
// than silently ordered.
|
||||
func (h *UpdateHandler) SaveSource(c *gin.Context) {
|
||||
var body struct {
|
||||
Remote string `json:"remote"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体不是合法 JSON"})
|
||||
return
|
||||
}
|
||||
body.Remote = strings.TrimSpace(body.Remote)
|
||||
body.RemoteURL = strings.TrimSpace(body.RemoteURL)
|
||||
body.Branch = strings.TrimSpace(body.Branch)
|
||||
if body.Remote != "" && body.RemoteURL != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "远端名与远端地址二选一:要么指名已有远端,要么直接给地址"})
|
||||
return
|
||||
}
|
||||
if body.Remote != "" && !update.ValidName(body.Remote) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "远端名不合法:" + body.Remote})
|
||||
return
|
||||
}
|
||||
if body.RemoteURL != "" && !update.ValidRemoteURL(body.RemoteURL) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"})
|
||||
return
|
||||
}
|
||||
if body.Branch != "" && !update.ValidName(body.Branch) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "分支名不合法:" + body.Branch})
|
||||
return
|
||||
}
|
||||
if h.saveSource == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "本次启动没有接入配置保存,更新源改不了"})
|
||||
return
|
||||
}
|
||||
if err := h.saveSource(body.Remote, body.RemoteURL, body.Branch); err != nil {
|
||||
h.record(c, "update.source", "failure", "保存更新源失败: "+err.Error(), map[string]interface{}{"error": err.Error()})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
h.record(c, "update.source", "success", "更新源已保存", map[string]interface{}{
|
||||
"remote": body.Remote, "remote_url": body.RemoteURL, "branch": body.Branch,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"source": h.sourceView()})
|
||||
}
|
||||
|
||||
// Adopt answers POST /api/system/update/adopt. Without confirm it is a side-effect-free
|
||||
// preview (the fetch happens in a throwaway repository outside the install tree); with
|
||||
// confirm it starts the same kind of job an update does.
|
||||
func (h *UpdateHandler) Adopt(c *gin.Context) {
|
||||
var body struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
Restart bool `json:"restart"`
|
||||
}
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体不是合法 JSON"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.Confirm && body.Restart && h.restart == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,接入完成后请由外部进程管理器重启服务"})
|
||||
return
|
||||
}
|
||||
if !body.Confirm {
|
||||
plan, err := update.Preview(c.Request.Context(), h.options())
|
||||
if err != nil {
|
||||
h.record(c, "update.adopt", "failure", "接入预览失败: "+err.Error(), map[string]interface{}{"error": errMessage(err)})
|
||||
c.JSON(statusForError(err), errorBody(err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"plan": plan})
|
||||
return
|
||||
}
|
||||
job, started := h.startJob("adopt", body.Restart)
|
||||
if !started {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job})
|
||||
return
|
||||
}
|
||||
h.record(c, "update.adopt", "started", "开始把目录接入更新源", map[string]interface{}{"job": job.ID, "restart": body.Restart})
|
||||
c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State})
|
||||
}
|
||||
|
||||
// Apply answers POST /api/system/update/apply. It starts one job and returns immediately;
|
||||
// a second concurrent apply is refused, because two processes moving the same working
|
||||
// tree and swapping the same binary is how an installation becomes unrecoverable.
|
||||
@@ -121,7 +238,7 @@ func (h *UpdateHandler) Apply(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
job, started := h.startJob(body.Restart)
|
||||
job, started := h.startJob("apply", body.Restart)
|
||||
if !started {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job})
|
||||
return
|
||||
@@ -157,13 +274,14 @@ func (h *UpdateHandler) Rollback(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"result": res})
|
||||
}
|
||||
|
||||
func (h *UpdateHandler) startJob(restart bool) (*updateJob, bool) {
|
||||
func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) {
|
||||
h.mu.Lock()
|
||||
if h.active != "" {
|
||||
return h.jobs[h.active].view(), false
|
||||
}
|
||||
job := &updateJob{
|
||||
ID: fmt.Sprintf("upd-%d", time.Now().UnixNano()),
|
||||
Kind: kind,
|
||||
State: "running",
|
||||
Started: time.Now().Format(time.RFC3339),
|
||||
Restart: restart,
|
||||
@@ -197,7 +315,11 @@ func (h *UpdateHandler) startJob(restart bool) (*updateJob, bool) {
|
||||
h.logger.Info("更新进度", zap.String("phase", s.Phase), zap.String("message", s.Message))
|
||||
}
|
||||
}
|
||||
res, err := update.Apply(ctx, h.options(), onStep)
|
||||
var run func(context.Context, update.Options, func(update.Step)) (*update.Result, error) = update.Apply
|
||||
if kind == "adopt" {
|
||||
run = update.Adopt
|
||||
}
|
||||
res, err := run(ctx, h.options(), onStep)
|
||||
finish := "succeeded"
|
||||
if err != nil {
|
||||
finish = "failed"
|
||||
@@ -308,7 +430,7 @@ func statusForError(err error) int {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
switch ue.Reason {
|
||||
case "local_source_edits", "diverged", "no_state", "moved_since_update", "no_binary":
|
||||
case "local_source_edits", "diverged", "no_state", "moved_since_update", "no_binary", "already_a_repo":
|
||||
return http.StatusConflict
|
||||
default:
|
||||
return http.StatusBadRequest
|
||||
|
||||
@@ -16,14 +16,21 @@ import (
|
||||
// the length of a compile, and refusals that stay distinguishable from accidents.
|
||||
|
||||
func newUpdateRouter(root string, restart func()) (*gin.Engine, *UpdateHandler) {
|
||||
return newUpdateRouterWithSource(root, restart, nil, nil)
|
||||
}
|
||||
|
||||
func newUpdateRouterWithSource(root string, restart func(),
|
||||
source func() (string, string, string), save func(string, string, string) error) (*gin.Engine, *UpdateHandler) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewUpdateHandler(root, nil, nil, restart)
|
||||
h := NewUpdateHandler(root, nil, nil, restart, source, save)
|
||||
router := gin.New()
|
||||
router.GET("/api/system/update", h.GetStatus)
|
||||
router.GET("/api/system/update/job", h.Job)
|
||||
router.POST("/api/system/update/check", h.Check)
|
||||
router.POST("/api/system/update/apply", h.Apply)
|
||||
router.POST("/api/system/update/rollback", h.Rollback)
|
||||
router.POST("/api/system/update/adopt", h.Adopt)
|
||||
router.POST("/api/system/update/source", h.SaveSource)
|
||||
return router, h
|
||||
}
|
||||
|
||||
@@ -166,3 +173,74 @@ func TestUpdateRollbackWithoutARecordedUpdateIsAConflict(t *testing.T) {
|
||||
t.Errorf("reason = %v, want no_state so the page can say there is nothing to undo", body["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSourceSaveValidatesBeforeWriting(t *testing.T) {
|
||||
var saved []string
|
||||
router, _ := newUpdateRouterWithSource(t.TempDir(), nil,
|
||||
func() (string, string, string) { return "", "", "" },
|
||||
func(r, u, b string) error { saved = append(saved, r+"|"+u+"|"+b); return nil })
|
||||
|
||||
// A name and an address are two ways to say the same thing; guessing which one the
|
||||
// operator meant is worse than asking again.
|
||||
w := doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remote":"origin","remoteUrl":"https://github.com/x/y.git"}`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body = %s, want 400 for remote+remoteUrl together", w.Code, w.Body)
|
||||
}
|
||||
// git's ext:: transport runs commands; it must never reach the config.
|
||||
w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remoteUrl":"ext::sh -c true"}`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body = %s, want 400 for an ext:: address", w.Code, w.Body)
|
||||
}
|
||||
if len(saved) != 0 {
|
||||
t.Fatalf("a refused save must not reach the config layer: %v", saved)
|
||||
}
|
||||
|
||||
// A valid save is trimmed and lands in the config layer as three values.
|
||||
w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remoteUrl":" https://github.com/Sycun/CyberStrikeAI.git ","branch":"main"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s, want 200", w.Code, w.Body)
|
||||
}
|
||||
if len(saved) != 1 || saved[0] != "|https://github.com/Sycun/CyberStrikeAI.git|main" {
|
||||
t.Fatalf("saved = %v, want the trimmed address and branch", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStatusCarriesTheConfiguredSource(t *testing.T) {
|
||||
router, _ := newUpdateRouterWithSource(t.TempDir(), nil,
|
||||
func() (string, string, string) { return "", "https://github.com/Sycun/CyberStrikeAI.git", "main" }, nil)
|
||||
|
||||
w := doUpdate(router, http.MethodGet, "/api/system/update", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
var body struct {
|
||||
Source struct {
|
||||
Remote string `json:"remote"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
Branch string `json:"branch"`
|
||||
Configured bool `json:"configured"`
|
||||
} `json:"source"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !body.Source.Configured || body.Source.RemoteURL != "https://github.com/Sycun/CyberStrikeAI.git" || body.Source.Branch != "main" {
|
||||
t.Fatalf("source = %+v, want the configured address and branch", body.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAdoptPreviewRefusesWithoutASource(t *testing.T) {
|
||||
router, _ := newUpdateRouter(t.TempDir(), nil)
|
||||
|
||||
w := doUpdate(router, http.MethodPost, "/api/system/update/adopt", `{}`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body = %s, want 400", w.Code, w.Body)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["reason"] != "no_source" {
|
||||
t.Fatalf("reason = %v, want no_source so the page can say what is missing", body["reason"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Adopt connects a plain directory - a tarball or zip installation, or a copy carried in by
|
||||
// hand - to the configured update source, so it becomes a git work tree the one-click
|
||||
// update applies to from then on.
|
||||
//
|
||||
// The rules are the ones an update already keeps: operator content (roles/skills/tools/
|
||||
// agents/knowledge_base/data/config.yaml) is put aside and put back, and nothing else is
|
||||
// replaced without having been named first - the preview lists every local file whose
|
||||
// content differs from the target, and each replaced file is kept under
|
||||
// <backup>/overwritten/ so "the update ate my edit" always has an address.
|
||||
|
||||
// maxAdoptList caps the file lists a plan carries over the wire; totals are separate so a
|
||||
// truncated list can never be read as the whole truth.
|
||||
const maxAdoptList = 50
|
||||
|
||||
// AdoptPlan is the preview of connecting a directory to a source. Producing it must not
|
||||
// change the directory.
|
||||
type AdoptPlan struct {
|
||||
Root string `json:"root"`
|
||||
Source string `json:"source"`
|
||||
Branch string `json:"branch"`
|
||||
Commit string `json:"commit"`
|
||||
Subject string `json:"subject"`
|
||||
|
||||
Incoming int `json:"incoming"` // files the target branch tracks
|
||||
|
||||
OverwrittenTotal int `json:"overwrittenTotal"`
|
||||
Overwritten []string `json:"overwritten"` // differing local files the target's version will replace (capped)
|
||||
|
||||
ProtectedTotal int `json:"protectedTotal"`
|
||||
Protected []string `json:"protected"` // operator content put aside and put back (capped)
|
||||
}
|
||||
|
||||
// adoptionScan is the full (uncapped) comparison an adoption is based on.
|
||||
type adoptionScan struct {
|
||||
target map[string]string // tracked path -> blob sha
|
||||
paths []string // every tracked path, sorted
|
||||
changed []string // local file differs from the target and is not protected
|
||||
kept []string // local file differs from the target and is protected
|
||||
}
|
||||
|
||||
// Preview builds an adoption plan without touching the directory: the fetch happens in a
|
||||
// throwaway bare repository outside it, so a failure cannot leave a half-adopted tree.
|
||||
func Preview(ctx context.Context, opts Options) (*AdoptPlan, error) {
|
||||
root, source, branch, err := adoptPreflight(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmp, err := os.MkdirTemp("", "csai-adopt-preview-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
if _, err := gitCmd(ctx, tmp, "init", "--quiet", "--bare"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
branch, commit, subject, err := fetchSource(ctx, tmp, source, branch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scan, err := scanAdoption(ctx, root, tmp, "origin/"+branch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scan.plan(root, source, branch, commit, subject), nil
|
||||
}
|
||||
|
||||
// Adopt carries the directory onto the source. It runs its own fetch inside the directory
|
||||
// (the preview's throwaway fetch cannot be reused), and on any failure removes only the
|
||||
// .git it created itself, so a retry starts from the same pristine state.
|
||||
func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error) {
|
||||
if onStep == nil {
|
||||
onStep = func(Step) {}
|
||||
}
|
||||
step := func(phase, msg string) { onStep(Step{Phase: phase, Message: msg, At: time.Now().Format(time.RFC3339)}) }
|
||||
start := time.Now()
|
||||
|
||||
root, source, branch, err := adoptPreflight(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
step("preflight", fmt.Sprintf("把目录 %s 接入更新源 %s(分支 %s)", root, source, branch))
|
||||
|
||||
if _, err := gitCmd(ctx, root, "init", "--quiet"); err != nil {
|
||||
return nil, &Error{Reason: "init_failed", Message: "git init 失败:" + err.Error()}
|
||||
}
|
||||
adopted := false
|
||||
defer func() {
|
||||
if !adopted {
|
||||
// Only the repository git just created; the directory's own files are untouched.
|
||||
_ = os.RemoveAll(filepath.Join(root, ".git"))
|
||||
}
|
||||
}()
|
||||
if _, err := gitCmd(ctx, root, "remote", "add", "origin", source); err != nil {
|
||||
return nil, &Error{Reason: "init_failed", Message: "添加远端失败:" + err.Error()}
|
||||
}
|
||||
|
||||
branch, commit, subject, err := fetchSource(ctx, root, source, branch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref := "origin/" + branch
|
||||
scan, err := scanAdoption(ctx, root, root, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
step("fetch", fmt.Sprintf("%s/%s(%s %s)共 %d 个文件:%d 个本机版本会被目标版本替换,%d 个运维者文件先暂存再放回",
|
||||
source, branch, commit, subject, len(scan.paths), len(scan.changed), len(scan.kept)))
|
||||
|
||||
backupDir := filepath.Join(root, ".update-backup", time.Now().Format("20060102_150405"))
|
||||
backupAll := append(append([]string{}, scan.changed...), scan.kept...)
|
||||
for _, p := range backupAll {
|
||||
abs := filepath.Join(root, filepath.FromSlash(p))
|
||||
info, statErr := os.Stat(abs)
|
||||
if statErr != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(backupDir, "overwritten", filepath.FromSlash(p))
|
||||
if Protected(p) {
|
||||
dst = filepath.Join(backupDir, filepath.FromSlash(p))
|
||||
}
|
||||
if err := copyFile(abs, dst, info.Mode()); err != nil {
|
||||
return nil, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", p, err)}
|
||||
}
|
||||
}
|
||||
if len(backupAll) > 0 {
|
||||
step("protect", fmt.Sprintf("%d 个文件已留底(被替换的在 %s/overwritten/,运维者的会原样放回)", len(backupAll), backupDir))
|
||||
}
|
||||
|
||||
if _, err := gitCmd(ctx, root, "checkout", "--force", "-B", branch, ref); err != nil {
|
||||
return nil, &Error{Reason: "checkout_failed", Message: "落地目标内容失败:" + err.Error()}
|
||||
}
|
||||
if err := restoreProtected(backupDir, scan.kept); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := gitCmd(ctx, root, "branch", "--set-upstream-to="+ref); err != nil {
|
||||
return nil, &Error{Reason: "upstream_failed", Message: "设置分支跟踪失败:" + err.Error()}
|
||||
}
|
||||
// From here the directory IS a work tree tracking the source: a later build failure
|
||||
// must leave it that way (the source has landed; only the binary has not), so the
|
||||
// cleanup above must not remove the repository any more.
|
||||
adopted = true
|
||||
|
||||
res := &Result{
|
||||
ToCommit: commit,
|
||||
FilesTouched: len(scan.paths),
|
||||
KeptContent: scan.kept,
|
||||
Overwritten: scan.changed,
|
||||
BackupDir: backupDir,
|
||||
Adopted: true,
|
||||
NeedsRestart: true,
|
||||
}
|
||||
|
||||
if opts.binaryName() == "none" {
|
||||
step("done", fmt.Sprintf("目录已接入 %s/%s(%s);按请求跳过重新编译", source, branch, commit))
|
||||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
bin := filepath.Join(root, opts.binaryName())
|
||||
res.BinaryPath = bin
|
||||
if _, canBuild := toolchain(ctx, root); !canBuild {
|
||||
step("done", fmt.Sprintf("目录已接入 %s/%s(%s);本机没有 Go 工具链,二进制未重编", source, branch, commit))
|
||||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return res, &Error{
|
||||
Reason: "no_toolchain",
|
||||
Message: "目录已接入更新源,但本机没有 Go 工具链,无法重新编译。装好 go 后再点一次更新即可补上二进制。",
|
||||
}
|
||||
}
|
||||
|
||||
step("build", "开始编译二进制(首次会下载依赖,可能需要几分钟)")
|
||||
staging := filepath.Join(root, ".update-staging")
|
||||
if err := os.MkdirAll(staging, 0o755); err != nil {
|
||||
return res, &Error{Reason: "staging_failed", Message: err.Error()}
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
newBin := filepath.Join(staging, opts.binaryName())
|
||||
if err := build(ctx, root, newBin); err != nil {
|
||||
return res, &Error{Reason: "build_failed", Message: "编译失败,目录已接入但二进制保持原版本:\n" + err.Error()}
|
||||
}
|
||||
prev, err := installBinary(newBin, bin)
|
||||
if err != nil {
|
||||
return res, &Error{Reason: "swap_failed", Message: err.Error()}
|
||||
}
|
||||
res.BinaryBuilt = true
|
||||
res.PrevBinary = prev
|
||||
step("done", fmt.Sprintf("目录已接入 %s/%s(%s)并换好二进制", source, branch, commit))
|
||||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// adoptPreflight validates the operation's preconditions: a plain directory, a usable
|
||||
// source address, and (when given) a sane branch name. It does not touch the directory.
|
||||
func adoptPreflight(ctx context.Context, opts Options) (root, source, branch string, err error) {
|
||||
root, err = opts.installRoot()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return "", "", "", &Error{Reason: "no_git", Message: "本机没有 git,无法把目录接入更新源"}
|
||||
}
|
||||
if _, err := gitCmd(ctx, root, "rev-parse", "--is-inside-work-tree"); err == nil {
|
||||
return "", "", "", &Error{Reason: "already_a_repo", Message: "这个目录已经是 git 工作树:用「一键更新」,不需要接入"}
|
||||
}
|
||||
if fi, statErr := os.Stat(root); statErr != nil || !fi.IsDir() {
|
||||
return "", "", "", &Error{Reason: "bad_root", Message: "安装目录不存在:" + root}
|
||||
}
|
||||
source = strings.TrimSpace(opts.RemoteURL)
|
||||
if source == "" {
|
||||
return "", "", "", &Error{Reason: "no_source", Message: "还没有配置更新源地址:先填入仓库地址(配置文件的 update.remote_url)再接入"}
|
||||
}
|
||||
if !ValidRemoteURL(source) {
|
||||
return "", "", "", &Error{Reason: "bad_source", Message: "更新源地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"}
|
||||
}
|
||||
branch = strings.TrimSpace(opts.Branch)
|
||||
if branch != "" && !ValidName(branch) {
|
||||
return "", "", "", &Error{Reason: "bad_branch", Message: fmt.Sprintf("分支名不合法:%q", branch)}
|
||||
}
|
||||
return root, source, branch, nil
|
||||
}
|
||||
|
||||
// fetchSource fetches one branch from source into repo and reports which branch that
|
||||
// ended up being (an empty request resolves the repository's default branch), its short
|
||||
// commit and its subject. Both callers fetch into a repository of their own.
|
||||
func fetchSource(ctx context.Context, repo, source, branch string) (string, string, string, error) {
|
||||
if branch == "" {
|
||||
out, err := gitCmd(ctx, repo, "ls-remote", "--symref", source, "HEAD")
|
||||
if err != nil {
|
||||
return "", "", "", &Error{Reason: "fetch_failed", Message: "读取远端默认分支失败:" + err.Error()}
|
||||
}
|
||||
branch = parseDefaultBranch(out)
|
||||
if branch == "" {
|
||||
return "", "", "", &Error{Reason: "no_branch", Message: "无法确定远端默认分支,请在配置里写明分支(update.branch)"}
|
||||
}
|
||||
}
|
||||
if _, err := gitCmd(ctx, repo, "fetch", "--quiet", "--no-tags", "--recurse-submodules=no",
|
||||
source, "+refs/heads/"+branch+":refs/remotes/origin/"+branch); err != nil {
|
||||
return "", "", "", &Error{Reason: "fetch_failed", Message: "拉取失败:" + err.Error()}
|
||||
}
|
||||
ref := "origin/" + branch
|
||||
commit, err := gitCmd(ctx, repo, "rev-parse", "--short", ref)
|
||||
if err != nil {
|
||||
return "", "", "", &Error{Reason: "no_branch", Message: fmt.Sprintf("远端没有分支 %s", branch)}
|
||||
}
|
||||
subject, _ := gitCmd(ctx, repo, "log", "-1", "--format=%s", ref)
|
||||
return branch, commit, subject, nil
|
||||
}
|
||||
|
||||
// parseDefaultBranch reads "ref: refs/heads/<name>\tHEAD" out of ls-remote --symref output.
|
||||
func parseDefaultBranch(out string) string {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "ref: refs/heads/") {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimPrefix(line, "ref: refs/heads/")
|
||||
if i := strings.IndexAny(rest, "\t "); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
if ValidName(rest) {
|
||||
return rest
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// scanAdoption compares the directory against the tracked files of ref in repo.
|
||||
func scanAdoption(ctx context.Context, root, repo, ref string) (*adoptionScan, error) {
|
||||
out, err := gitRaw(ctx, repo, "ls-tree", "-r", "-z", "--full-tree", ref)
|
||||
if err != nil {
|
||||
return nil, &Error{Reason: "diff_failed", Message: err.Error()}
|
||||
}
|
||||
scan := &adoptionScan{target: map[string]string{}}
|
||||
for _, rec := range nulList(out) {
|
||||
tab := strings.IndexByte(rec, '\t')
|
||||
if tab < 0 {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(rec[:tab])
|
||||
if len(fields) < 3 || fields[1] != "blob" {
|
||||
continue // submodules and trees are not files this comparison replaces
|
||||
}
|
||||
p := rec[tab+1:]
|
||||
scan.target[p] = fields[2]
|
||||
scan.paths = append(scan.paths, p)
|
||||
}
|
||||
sort.Strings(scan.paths)
|
||||
for _, p := range scan.paths {
|
||||
abs := filepath.Join(root, filepath.FromSlash(p))
|
||||
info, err := os.Lstat(abs)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
hash, err := gitBlobHash(abs)
|
||||
if err != nil || hash == scan.target[p] {
|
||||
continue
|
||||
}
|
||||
if Protected(p) {
|
||||
scan.kept = append(scan.kept, p)
|
||||
} else {
|
||||
scan.changed = append(scan.changed, p)
|
||||
}
|
||||
}
|
||||
return scan, nil
|
||||
}
|
||||
|
||||
// plan renders the capped, wire-facing view of a scan.
|
||||
func (s *adoptionScan) plan(root, source, branch, commit, subject string) *AdoptPlan {
|
||||
p := &AdoptPlan{
|
||||
Root: root, Source: source, Branch: branch, Commit: commit, Subject: subject,
|
||||
Incoming: len(s.paths),
|
||||
OverwrittenTotal: len(s.changed),
|
||||
ProtectedTotal: len(s.kept),
|
||||
Overwritten: append([]string{}, s.changed...),
|
||||
Protected: append([]string{}, s.kept...),
|
||||
}
|
||||
if len(p.Overwritten) > maxAdoptList {
|
||||
p.Overwritten = p.Overwritten[:maxAdoptList]
|
||||
}
|
||||
if len(p.Protected) > maxAdoptList {
|
||||
p.Protected = p.Protected[:maxAdoptList]
|
||||
}
|
||||
if p.Overwritten == nil {
|
||||
p.Overwritten = []string{}
|
||||
}
|
||||
if p.Protected == nil {
|
||||
p.Protected = []string{}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// gitBlobHash computes the object id git would give a file's content. It hashes the bytes
|
||||
// as they are (no filters), because the comparison is against raw blobs in the tree. sha1 is
|
||||
// not a security choice here - it is the blob-id algorithm of the repository format.
|
||||
func gitBlobHash(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := sha1.New()
|
||||
fmt.Fprintf(h, "blob %d\x00", len(data))
|
||||
h.Write(data)
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newAdoptFixture builds the shape a tarball installation has: the upstream project's files
|
||||
// on disk with the repository removed, plus operator content and one locally edited
|
||||
// product file that the adoption is expected to name (and keep a copy of).
|
||||
func newAdoptFixture(t *testing.T) (*tree, string) {
|
||||
t.Helper()
|
||||
tr := newTree(t)
|
||||
dir := filepath.Join(filepath.Dir(tr.upstream), "release")
|
||||
mustGit(t, filepath.Dir(tr.upstream), "clone", "-q", tr.upstream, dir)
|
||||
if err := os.RemoveAll(filepath.Join(dir, ".git")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Operator content: the live config (never in the repository), their own role, and an
|
||||
// edit to a shipped role.
|
||||
writeFile(t, filepath.Join(dir, "config.yaml"), "server:\n port: 8088\n")
|
||||
writeFile(t, filepath.Join(dir, "roles", "mine.yaml"), "name: mine\n")
|
||||
writeFile(t, filepath.Join(dir, "roles", "shipped.yaml"), "name: shipped\ndescription: mine, do not touch\n")
|
||||
// A locally edited product file.
|
||||
writeFile(t, filepath.Join(dir, "run.sh"), "#!/bin/sh\necho hacked\n")
|
||||
return tr, dir
|
||||
}
|
||||
|
||||
func TestPreviewNamesWhatAdoptionWouldReplaceWithoutTouchingTheDirectory(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr, dir := newAdoptFixture(t)
|
||||
|
||||
plan, err := Preview(context.Background(), Options{Root: dir, RemoteURL: tr.upstream, Branch: "main"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".git")); !os.IsNotExist(err) {
|
||||
t.Fatal("a preview must not create a repository")
|
||||
}
|
||||
if plan.Incoming < 3 {
|
||||
t.Fatalf("incoming = %d, want at least the fixture's files", plan.Incoming)
|
||||
}
|
||||
if !contains(plan.Overwritten, "run.sh") {
|
||||
t.Errorf("the edited product file must be named: %v", plan.Overwritten)
|
||||
}
|
||||
if contains(plan.Overwritten, "roles/shipped.yaml") {
|
||||
t.Errorf("operator content must not be listed as replaced: %v", plan.Overwritten)
|
||||
}
|
||||
if !contains(plan.Protected, "roles/shipped.yaml") {
|
||||
t.Errorf("the edited shipped role must be listed as protected: %v", plan.Protected)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(dir, "run.sh")); !strings.Contains(got, "hacked") {
|
||||
t.Errorf("preview must not change file contents: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdoptConnectsTheDirectoryAndKeepsOperatorContent(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr, dir := newAdoptFixture(t)
|
||||
|
||||
res, err := Adopt(context.Background(), Options{Root: dir, RemoteURL: tr.upstream, Branch: "main", BinaryName: "none"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("adopt failed: %v\nresult: %+v", err, res)
|
||||
}
|
||||
if !res.Adopted || res.ToCommit == "" {
|
||||
t.Fatalf("res = %+v, want an adopted result", res)
|
||||
}
|
||||
// The directory is now a work tree tracking the chosen source.
|
||||
if got := mustGit(t, dir, "rev-parse", "--abbrev-ref", "HEAD"); got != "main" {
|
||||
t.Errorf("HEAD branch = %q, want main", got)
|
||||
}
|
||||
if got := mustGit(t, dir, "rev-parse", "--abbrev-ref", "@{upstream}"); got != "origin/main" {
|
||||
t.Errorf("upstream = %q, want origin/main - from here the tree tracks its source by itself", got)
|
||||
}
|
||||
// The target's content replaced the local edit; the local edit survives in the backup.
|
||||
if got := readFile(t, filepath.Join(dir, "run.sh")); !strings.Contains(got, "echo old") {
|
||||
t.Errorf("run.sh = %q, want the target's version", got)
|
||||
}
|
||||
if !contains(res.Overwritten, "run.sh") {
|
||||
t.Errorf("result must name the replaced file: %v", res.Overwritten)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(res.BackupDir, "overwritten", "run.sh")); !strings.Contains(got, "hacked") {
|
||||
t.Errorf("the replaced file must be kept for recovery, got %q", got)
|
||||
}
|
||||
// Operator content survived: the live config untouched, their own role untouched, their
|
||||
// edit to a shipped role restored over the target's version.
|
||||
if got := readFile(t, filepath.Join(dir, "config.yaml")); !strings.Contains(got, "8088") {
|
||||
t.Errorf("config.yaml was disturbed: %q", got)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(dir, "roles", "mine.yaml")); !strings.Contains(got, "name: mine") {
|
||||
t.Errorf("the operator's own role must survive: %q", got)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(dir, "roles", "shipped.yaml")); !strings.Contains(got, "do not touch") {
|
||||
t.Errorf("the operator's edit to a shipped role must win: %q", got)
|
||||
}
|
||||
if !contains(res.KeptContent, "roles/shipped.yaml") {
|
||||
t.Errorf("keptContent = %v, want roles/shipped.yaml", res.KeptContent)
|
||||
}
|
||||
|
||||
// From now on it is a normal installation: the next upstream commit updates it, and the
|
||||
// first real update writes the first rollback point.
|
||||
tr.upstreamCommit(t, "second: bump service", map[string]string{
|
||||
"internal_service.go": "package service\n\nconst Version = \"2\"\n",
|
||||
})
|
||||
snap, err := Check(context.Background(), Options{Root: dir, BinaryName: "none"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap.CheckError != "" || snap.Behind != 1 {
|
||||
t.Fatalf("the adopted tree must update like any other: checkError=%q behind=%d", snap.CheckError, snap.Behind)
|
||||
}
|
||||
res2, err := Apply(context.Background(), Options{Root: dir, BinaryName: "none"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply after adopt failed: %v", err)
|
||||
}
|
||||
if res2.ToCommit == "" || res2.Adopted {
|
||||
t.Fatalf("res2 = %+v", res2)
|
||||
}
|
||||
if _, ok := readState(dir); !ok {
|
||||
t.Error("the first real update must write a rollback point")
|
||||
}
|
||||
if got := readFile(t, filepath.Join(dir, "internal_service.go")); !strings.Contains(got, "Version = \"2\"") {
|
||||
t.Errorf("code after the first update = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdoptRefusesWhatItCannotHonestlyDo(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr := newTree(t)
|
||||
// Already a work tree: that is an update, not an adoption.
|
||||
if _, err := Adopt(context.Background(), Options{Root: tr.install, RemoteURL: tr.upstream, BinaryName: "none"}, nil); asReason(err) != "already_a_repo" {
|
||||
t.Errorf("err = %v, want already_a_repo", err)
|
||||
}
|
||||
// No source configured.
|
||||
plain := t.TempDir()
|
||||
if _, err := Adopt(context.Background(), Options{Root: plain, BinaryName: "none"}, nil); asReason(err) != "no_source" {
|
||||
t.Errorf("err = %v, want no_source", err)
|
||||
}
|
||||
// An address git must never be handed.
|
||||
if _, err := Adopt(context.Background(), Options{Root: plain, RemoteURL: "ext::sh -c true", BinaryName: "none"}, nil); asReason(err) != "bad_source" {
|
||||
t.Errorf("err = %v, want bad_source", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func asReason(err error) string {
|
||||
if ue, ok := err.(*Error); ok {
|
||||
return ue.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+31
-23
@@ -70,13 +70,20 @@ type Result struct {
|
||||
// Upstream may well have changed them; saying which ones is the difference between
|
||||
// "my roles disappeared" and "my roles are still mine, and these upstream files did
|
||||
// not land".
|
||||
KeptContent []string `json:"keptContent"`
|
||||
BinaryPath string `json:"binaryPath"`
|
||||
BinaryBuilt bool `json:"binaryBuilt"`
|
||||
PrevBinary string `json:"prevBinary"`
|
||||
BackupDir string `json:"backupDir"`
|
||||
NeedsRestart bool `json:"needsRestart"`
|
||||
Duration string `json:"duration"`
|
||||
KeptContent []string `json:"keptContent"`
|
||||
// Overwritten names local files whose content the target repository's version replaced
|
||||
// during an adoption; copies are kept under <BackupDir>/overwritten/ so nothing is lost
|
||||
// without a way back.
|
||||
Overwritten []string `json:"overwritten,omitempty"`
|
||||
// Adopted marks a result that comes from connecting a plain directory to a source
|
||||
// rather than from moving an existing work tree.
|
||||
Adopted bool `json:"adopted,omitempty"`
|
||||
BinaryPath string `json:"binaryPath"`
|
||||
BinaryBuilt bool `json:"binaryBuilt"`
|
||||
PrevBinary string `json:"prevBinary"`
|
||||
BackupDir string `json:"backupDir"`
|
||||
NeedsRestart bool `json:"needsRestart"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
// Error is a refusal with the information needed to act on it. Callers render the
|
||||
@@ -107,7 +114,7 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error
|
||||
|
||||
// A refusal is not the end of the timeline: the page renders these lines, so the first
|
||||
// one says what was looked at even when the answer is "nothing to do".
|
||||
step("preflight", fmt.Sprintf("检查安装目录 %s(分支 %s / 远端 %s / 提交 %s)", snap.Root, snap.Branch, snap.Remote, snap.Commit))
|
||||
step("preflight", fmt.Sprintf("检查安装目录 %s(分支 %s / 远端 %s / 提交 %s)", snap.Root, snap.Branch, snap.sourceLabel(), snap.Commit))
|
||||
|
||||
if !snap.Installed {
|
||||
return nil, &Error{Reason: "not_a_repo", Message: "这个目录不是 git 工作树,无法自动更新"}
|
||||
@@ -140,14 +147,14 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error
|
||||
}, nil
|
||||
}
|
||||
|
||||
step("fetch", fmt.Sprintf("%s/%s 有 %d 个新提交:%s → %s", snap.Remote, snap.Branch, snap.Behind, snap.Commit, snap.RemoteCommit))
|
||||
step("fetch", fmt.Sprintf("%s/%s 有 %d 个新提交:%s → %s", snap.sourceLabel(), snap.Branch, snap.Behind, snap.Commit, snap.RemoteCommit))
|
||||
|
||||
// Put operator-owned content aside, and clear the paths the incoming change would
|
||||
// write to, so the fast-forward cannot be refused by "your local changes would be
|
||||
// overwritten". Nothing here deletes a file the operator made: every path is copied
|
||||
// out first and copied back after.
|
||||
root := snap.Root
|
||||
ref := snap.Remote + "/" + snap.Branch
|
||||
ref := snap.sourceRef()
|
||||
backupDir, kept, err := stashProtected(ctx, root, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -155,12 +162,12 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error
|
||||
step("protect", fmt.Sprintf("已暂存 %d 个本地内容文件(roles/skills/tools/数据/配置),更新完成后放回", len(kept)))
|
||||
|
||||
if out, err := gitCmd(ctx, root, "merge", "--ff-only", "--no-verify", ref); err != nil {
|
||||
restoreProtected(backupDir)
|
||||
restoreProtected(backupDir, kept)
|
||||
return nil, &Error{Reason: "merge_failed", Message: fmt.Sprintf("快进合并失败:%v\n%s", err, strings.TrimSpace(out))}
|
||||
}
|
||||
// The merge wrote upstream's version of the content paths; the operator's copies win
|
||||
// them back, which is what "update the code, keep my work" means.
|
||||
if err := restoreProtected(backupDir); err != nil {
|
||||
if err := restoreProtected(backupDir, kept); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -271,25 +278,26 @@ func stashProtected(ctx context.Context, root, ref string) (backupDir string, ke
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
// restoreProtected puts the operator's copies back over whatever the merge wrote.
|
||||
func restoreProtected(backupDir string) error {
|
||||
// restoreProtected puts the named operator-owned copies back over whatever the merge
|
||||
// wrote. The list is the one stashProtected returned: an adoption parks replaced
|
||||
// non-protected files in the same backup directory (under overwritten/), and those must
|
||||
// NOT be restored over the target repository's version.
|
||||
func restoreProtected(backupDir string, kept []string) error {
|
||||
if backupDir == "" {
|
||||
return nil
|
||||
}
|
||||
return filepath.WalkDir(backupDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(backupDir, path)
|
||||
root := filepath.Join(filepath.Dir(backupDir), "..")
|
||||
for _, rel := range kept {
|
||||
abs := filepath.Join(backupDir, filepath.FromSlash(rel))
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if err := copyFile(abs, filepath.Join(root, filepath.FromSlash(rel)), info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
return copyFile(path, filepath.Join(filepath.Dir(backupDir), "..", rel), info.Mode())
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// untrackedUnder lists untracked files under the given repository paths - the operator's
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The explicit update source (config.yaml update.remote_url) is what lets an installation
|
||||
// follow a repository that is not one of its own remotes - the official one, a fork, or a
|
||||
// second-development repository somebody was told about. These tests keep that path honest:
|
||||
// the address must win over remote names, must not invent a remote, and must not let git's
|
||||
// ext:: transport (which executes commands) through.
|
||||
|
||||
func TestValidRemoteURL(t *testing.T) {
|
||||
yes := []string{
|
||||
"https://github.com/Sycun/CyberStrikeAI.git",
|
||||
"http://example.com/x.git",
|
||||
"ssh://git@example.com/x.git",
|
||||
"git://example.com/x.git",
|
||||
"file:///srv/repos/x.git",
|
||||
"/Volumes/code/CyberStrikeAI/开发版-CyberStrikeAI",
|
||||
}
|
||||
no := []string{
|
||||
"",
|
||||
"-u./evil",
|
||||
"ext::sh -c whoami",
|
||||
"ext::touch /tmp/x",
|
||||
"a\nb",
|
||||
"javascript:alert(1)",
|
||||
"ftp://example.com/x",
|
||||
" https://github.com/x/y.git",
|
||||
strings.Repeat("a", 600),
|
||||
}
|
||||
for _, s := range yes {
|
||||
if !ValidRemoteURL(s) {
|
||||
t.Errorf("%q must be accepted as a fetch source", s)
|
||||
}
|
||||
}
|
||||
for _, s := range no {
|
||||
if ValidRemoteURL(s) {
|
||||
t.Errorf("%q must be rejected before it reaches git", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAndApplyFromAnExplicitURL(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr := newTree(t)
|
||||
// No remote at all: the source is purely the configured address.
|
||||
mustGit(t, tr.install, "remote", "remove", "origin")
|
||||
|
||||
urlOpts := Options{Root: tr.install, RemoteURL: tr.upstream, BinaryName: "none"}
|
||||
|
||||
snap, err := Status(context.Background(), urlOpts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !snap.Installed || snap.Remote != "" || snap.RemoteURL != tr.upstream {
|
||||
t.Fatalf("source must resolve to the configured address: installed=%v remote=%q url=%q",
|
||||
snap.Installed, snap.Remote, snap.RemoteURL)
|
||||
}
|
||||
// With no upstream to name a branch, the current branch is the default.
|
||||
if snap.Branch != "main" {
|
||||
t.Fatalf("branch = %q, want main", snap.Branch)
|
||||
}
|
||||
|
||||
tr.upstreamCommit(t, "second: bump service", map[string]string{
|
||||
"internal_service.go": "package service\n\nconst Version = \"2\"\n",
|
||||
})
|
||||
snap, err = Check(context.Background(), urlOpts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap.CheckError != "" {
|
||||
t.Fatalf("check failed: %s", snap.CheckError)
|
||||
}
|
||||
if !snap.UpdateAvailable || snap.Behind != 1 {
|
||||
t.Fatalf("behind=%d available=%v, want 1/true", snap.Behind, snap.UpdateAvailable)
|
||||
}
|
||||
|
||||
res, err := Apply(context.Background(), urlOpts, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v\nresult: %+v", err, res)
|
||||
}
|
||||
if res.Commits != 1 {
|
||||
t.Fatalf("res = %+v, want one commit moved", res)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(tr.install, "internal_service.go")); !strings.Contains(got, "Version = \"2\"") {
|
||||
t.Fatalf("code was not updated: %q", got)
|
||||
}
|
||||
// A URL source parks its ref in a namespace of its own; it must not fabricate a remote
|
||||
// the operator never configured.
|
||||
if remotes := strings.TrimSpace(mustGit(t, tr.install, "remote")); remotes != "" {
|
||||
t.Fatalf("a URL source must not create a remote, got %q", remotes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteURLWinsOverARemoteName(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr := newTree(t)
|
||||
// A second repository with its own newer commit: the address must beat the origin remote.
|
||||
other := filepath.Join(filepath.Dir(tr.upstream), "other")
|
||||
mustGit(t, filepath.Dir(tr.upstream), "clone", "-q", tr.upstream, other)
|
||||
writeFile(t, filepath.Join(other, "internal_service.go"), "package service\n\nconst Version = \"9\"\n")
|
||||
mustGit(t, other, "add", ".")
|
||||
mustGit(t, other, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "other moves")
|
||||
|
||||
snap, err := Check(context.Background(), Options{
|
||||
Root: tr.install, Remote: "origin", RemoteURL: other, BinaryName: "none",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap.CheckError != "" {
|
||||
t.Fatalf("check failed: %s", snap.CheckError)
|
||||
}
|
||||
if snap.RemoteURL != other || snap.Behind != 1 || len(snap.Incoming) != 1 || snap.Incoming[0].Subject != "other moves" {
|
||||
t.Fatalf("the address must win over the remote name: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidRemoteURLIsAReadableStateNotAnError(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr := newTree(t)
|
||||
snap, err := Status(context.Background(), Options{Root: tr.install, RemoteURL: "ext::sh -c true"})
|
||||
if err != nil {
|
||||
t.Fatalf("a bad configured source is a state the page can show, not a fault: %v", err)
|
||||
}
|
||||
if !strings.Contains(snap.CheckError, "不合法") {
|
||||
t.Fatalf("checkError = %q, want the illegality named", snap.CheckError)
|
||||
}
|
||||
}
|
||||
+92
-13
@@ -34,6 +34,11 @@ import (
|
||||
// Binary is the default executable name an update replaces.
|
||||
const Binary = "cyberstrike-ai"
|
||||
|
||||
// urlRefNamespace is where a fetch from an explicit address (config.yaml update.remote_url)
|
||||
// parks its remote-tracking ref. A fixed name no remote can shadow keeps the update reading
|
||||
// the same shape whether the source is a remote name or a URL.
|
||||
const urlRefNamespace = "update-source"
|
||||
|
||||
// maxIncoming caps how many commits a status page lists; the count is reported
|
||||
// separately so "37 new commits, showing 50" is impossible.
|
||||
const maxIncoming = 50
|
||||
@@ -44,6 +49,38 @@ const maxIncoming = 50
|
||||
// rule instead of one that is stricter in the case nobody attacks.
|
||||
var namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/@-]*$`)
|
||||
|
||||
// urlSchemePattern is the allowlist of fetch schemes. git also accepts "ext::" URLs, which
|
||||
// run the command written after the colons, so this is an allowlist rather than "whatever
|
||||
// git accepts"; every other "::" form is rejected outright.
|
||||
var urlSchemePattern = regexp.MustCompile(`^(https?|ssh|git|file)://`)
|
||||
|
||||
// maxRemoteURLLength caps what a config file can put on a git command line.
|
||||
const maxRemoteURLLength = 512
|
||||
|
||||
// ValidRemoteURL reports whether s may be used as a fetch source. Local absolute paths are
|
||||
// allowed on purpose: they are how this project's own trees point at each other in tests
|
||||
// and on one machine.
|
||||
func ValidRemoteURL(s string) bool {
|
||||
if s == "" || len(s) > maxRemoteURLLength {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(s, "-") {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if strings.Contains(s, "::") {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(s, "/") {
|
||||
return true
|
||||
}
|
||||
return urlSchemePattern.MatchString(s)
|
||||
}
|
||||
|
||||
// protectedDirs are install-tree directories whose contents the operator may have
|
||||
// changed through the console or by hand. An update never overwrites them.
|
||||
var protectedDirs = []string{
|
||||
@@ -73,11 +110,15 @@ func Protected(rel string) bool {
|
||||
}
|
||||
|
||||
// Options describes which installation to act on. Root is the only required field;
|
||||
// Remote and Branch exist for a tree that tracks something other than its own upstream.
|
||||
// Remote, RemoteURL and Branch carry an explicit update source (config.yaml update
|
||||
// section) for a tree that tracks something other than its own upstream - either a remote
|
||||
// it already has, by name, or an address to fetch directly. When both are set, the
|
||||
// address wins.
|
||||
type Options struct {
|
||||
Root string
|
||||
Remote string
|
||||
Branch string
|
||||
Root string
|
||||
Remote string
|
||||
RemoteURL string
|
||||
Branch string
|
||||
// BinaryName overrides the executable swapped after a successful update. Empty
|
||||
// means "cyberstrike-ai" in Root; "none" means leave the binary alone.
|
||||
BinaryName string
|
||||
@@ -120,10 +161,13 @@ type Commit struct {
|
||||
|
||||
// Snapshot is everything a person needs to decide whether to press the button.
|
||||
type Snapshot struct {
|
||||
Root string `json:"root"`
|
||||
Installed bool `json:"installed"`
|
||||
Branch string `json:"branch"`
|
||||
Remote string `json:"remote"`
|
||||
Root string `json:"root"`
|
||||
Installed bool `json:"installed"`
|
||||
Branch string `json:"branch"`
|
||||
Remote string `json:"remote"`
|
||||
// RemoteURL is set when the update source is an explicit address (config.yaml
|
||||
// update.remote_url) rather than a remote this tree already has.
|
||||
RemoteURL string `json:"remoteUrl,omitempty"`
|
||||
Remotes []string
|
||||
Commit string `json:"commit"`
|
||||
Subject string `json:"subject"`
|
||||
@@ -211,11 +255,19 @@ func Status(ctx context.Context, opts Options) (*Snapshot, error) {
|
||||
BlockingChanges: []Change{},
|
||||
}
|
||||
|
||||
if url := strings.TrimSpace(opts.RemoteURL); url != "" {
|
||||
if !ValidRemoteURL(url) {
|
||||
snap.CheckError = "配置的更新源地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"
|
||||
return snap, nil
|
||||
}
|
||||
snap.RemoteURL = url
|
||||
}
|
||||
|
||||
if _, err := gitCmd(ctx, root, "rev-parse", "--is-inside-work-tree"); err != nil {
|
||||
// Not a git tree: the page still has to say something useful, so this is a
|
||||
// state rather than an error.
|
||||
snap.Installed = false
|
||||
snap.CheckError = "这个目录不是 git 工作树,无法自动更新;请用源码包方式更新"
|
||||
snap.CheckError = "这个目录不是 git 工作树:可以先配置更新源再接入(配置文件 update 段),或按发布包方式更新"
|
||||
snap.GoToolchain, snap.CanBuild = toolchain(ctx, root)
|
||||
snap.HasBinary = fileExists(filepath.Join(root, opts.binaryName()))
|
||||
return snap, nil
|
||||
@@ -295,6 +347,33 @@ func pickRemote(remotes []string, branch, optsRemote string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// fetchTarget is what git is pointed at: the configured address, or the chosen remote.
|
||||
func (s *Snapshot) fetchTarget() string {
|
||||
if s.RemoteURL != "" {
|
||||
return s.RemoteURL
|
||||
}
|
||||
return s.Remote
|
||||
}
|
||||
|
||||
// fetchNamespace is where the fetched branch is parked under refs/remotes/.
|
||||
func (s *Snapshot) fetchNamespace() string {
|
||||
if s.RemoteURL != "" {
|
||||
return urlRefNamespace
|
||||
}
|
||||
return s.Remote
|
||||
}
|
||||
|
||||
// sourceRef is the remote-tracking ref a status or an update compares against.
|
||||
func (s *Snapshot) sourceRef() string { return s.fetchNamespace() + "/" + s.Branch }
|
||||
|
||||
// sourceLabel is the human-facing name of where an update would come from.
|
||||
func (s *Snapshot) sourceLabel() string {
|
||||
if s.RemoteURL != "" {
|
||||
return s.RemoteURL
|
||||
}
|
||||
return s.Remote
|
||||
}
|
||||
|
||||
// localChanges lists modified and deleted tracked files, and splits them by who owns
|
||||
// the path.
|
||||
func localChanges(ctx context.Context, root string) (all, blocking []Change) {
|
||||
@@ -344,8 +423,8 @@ func Check(ctx context.Context, opts Options) (*Snapshot, error) {
|
||||
return snap, nil
|
||||
}
|
||||
root := snap.Root
|
||||
if snap.Remote == "" {
|
||||
snap.CheckError = "该安装没有配置任何 git remote,无法检查更新"
|
||||
if snap.Remote == "" && snap.RemoteURL == "" {
|
||||
snap.CheckError = "该安装没有配置任何 git remote,也没有配置更新源地址,无法检查更新"
|
||||
return snap, nil
|
||||
}
|
||||
if snap.Branch == "" || snap.Branch == "HEAD" || !ValidName(snap.Branch) {
|
||||
@@ -356,12 +435,12 @@ func Check(ctx context.Context, opts Options) (*Snapshot, error) {
|
||||
// history. A detached fetch of one branch means a remote with many refs cannot
|
||||
// turn the update check into a full clone.
|
||||
if _, err := gitCmd(ctx, root, "fetch", "--quiet", "--no-tags", "--prune", "--recurse-submodules=no",
|
||||
snap.Remote, "+refs/heads/"+snap.Branch+":refs/remotes/"+snap.Remote+"/"+snap.Branch); err != nil {
|
||||
snap.fetchTarget(), "+refs/heads/"+snap.Branch+":refs/remotes/"+snap.fetchNamespace()+"/"+snap.Branch); err != nil {
|
||||
snap.CheckError = err.Error()
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
ref := snap.Remote + "/" + snap.Branch
|
||||
ref := snap.sourceRef()
|
||||
if v, err := gitCmd(ctx, root, "rev-parse", "--short", ref); err == nil {
|
||||
snap.RemoteCommit = v
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user