diff --git a/cmd/libraryctl/main.go b/cmd/libraryctl/main.go index b411579..f21a60e 100644 --- a/cmd/libraryctl/main.go +++ b/cmd/libraryctl/main.go @@ -732,7 +732,11 @@ func runCapture(ctx context.Context, opts options) error { if err != nil { return err } - return checkNoUndeclaredWriter(ctx, images, declared) + app, err := containerEnv(ctx, opts.sourceApp) + if err != nil { + return err + } + return checkNoUndeclaredWriter(ctx, images, declared, opts.project, sourceTargetOf(app)) } result, err := v071.CaptureLibrary(ctx, coordinator, archive, pool, search, objects, sourceReady, v071.LibraryCaptureRequest{JobID: job, Scope: opts.scope, @@ -809,21 +813,66 @@ func writerImageIDs(ctx context.Context, items []libraryfreeze.Service) ([]strin // checkNoUndeclaredWriter refuses to run when a running container uses a declared // writer image but is not part of the declared group: such a container could keep // writing during the maintenance window. -func checkNoUndeclaredWriter(ctx context.Context, images []string, declared map[string]bool) error { +func checkNoUndeclaredWriter(ctx context.Context, images []string, declared map[string]bool, + sourceProject string, source map[string]string) error { for _, image := range images { out, err := dockerOutput(ctx, "ps", "-q", "--filter", "ancestor="+image) if err != nil { return err } for _, id := range strings.Fields(out) { - if !declared[id] { - return fmt.Errorf("container %s uses a declared writer image but is not in the declared writer group", id[:12]) + if declared[id] { + continue + } + project, err := dockerOutput(ctx, "inspect", "--format", `{{index .Config.Labels "com.docker.compose.project"}}`, id) + if err != nil { + return err + } + env, err := containerEnv(ctx, id) + if err != nil { + return err + } + if conflict, reason := undeclaredWriterConflict(false, project, sourceProject, env, source); conflict { + return fmt.Errorf("container %s uses a declared writer image but is not in the declared writer group: %s", id[:12], reason) } } } return nil } +// sourceTargetOf 只取"写到哪儿"的身份:同一个数据库或同一个队列才是真冲突。 +func sourceTargetOf(env map[string]string) map[string]string { + return map[string]string{ + "DB_HOST": env["DB_HOST"], "DB_NAME": env["DB_NAME"], "DB_PORT": env["DB_PORT"], + "REDIS_ADDR": env["REDIS_ADDR"], "REDIS_NAMESPACE": env["REDIS_NAMESPACE"], + } +} + +// undeclaredWriterConflict 判断一个同镜像容器是否构成"未声明的并发写入者"。 +// +// 同一 compose 项目或没有项目标签 ⇒ 无法证明隔离,直接冲突。 +// 另一个项目 ⇒ 只有数据目标(数据库/队列)与源相同才算冲突: +// 镜像相同不等于写同一个库,本机共享业务栈与专用验收实例就是这种关系。 +func undeclaredWriterConflict(declared bool, project, sourceProject string, env, source map[string]string) (bool, string) { + if declared { + return false, "" + } + if project == "" { + return true, "没有 compose 项目标签,无法证明它不写同一个目标" + } + if project == sourceProject { + return true, "属于同一 compose 项目,却不是声明的写入服务" + } + if source["DB_HOST"] != "" && env["DB_HOST"] == source["DB_HOST"] && env["DB_NAME"] == source["DB_NAME"] { + return true, "另一个项目的同镜像容器指向同一个源数据库" + } + if source["REDIS_ADDR"] != "" && env["REDIS_ADDR"] == source["REDIS_ADDR"] && + env["REDIS_NAMESPACE"] == source["REDIS_NAMESPACE"] { + return true, "另一个项目的同镜像容器共用同一个源队列" + } + return false, "另一套隔离栈(数据目标不同)" +} + func containerImageOf(ctx context.Context, id string) (string, error) { image, err := dockerOutput(ctx, "inspect", "--format", "{{.Image}}", id) if err != nil { diff --git a/cmd/libraryctl/writer_check_test.go b/cmd/libraryctl/writer_check_test.go new file mode 100644 index 0000000..276e811 --- /dev/null +++ b/cmd/libraryctl/writer_check_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "strings" + "testing" +) + +// 同一台机器上可以有两套完全隔离的 WeKnora 栈共用同一个镜像(本机就是这样: +// 共享业务栈 + 专用验收实例)。只按镜像判断"未声明的写入者",会把另一套栈 +// 当成并发写入者,验收链永远开不了窗;但只看项目标签又会放过多起一个 +// 指向同一个库/队列的副本。判据要同时看归属与真实数据目标。 +func TestUndeclaredWriterConflict(t *testing.T) { + source := map[string]string{ + "DB_HOST": "kgmaint-postgres", "DB_NAME": "kgmaint_target", "DB_PORT": "5432", + "REDIS_ADDR": "kgmaint-redis:6379", "REDIS_NAMESPACE": "kgmaint", + } + cases := []struct { + name string + declared bool + project string + env map[string]string + want bool + reasonHint string + }{ + {"已声明容器本身", true, "kgmaint-weknora", source, false, ""}, + {"同项目里另起一份(不同名字)", false, "kgmaint-weknora", source, true, "同一 compose 项目"}, + {"没有 compose 项目标签(docker run 的副本)", false, "", source, true, "没有 compose 项目标签"}, + {"另一套隔离栈,数据目标不同", false, "weknora071full", map[string]string{ + "DB_HOST": "WKNFull-postgres", "DB_NAME": "weknora_full", "REDIS_ADDR": "WKNFull-redis:6379", "REDIS_NAMESPACE": "weknora"}, + false, ""}, + {"另一套栈但指向同一个库", false, "other-project", map[string]string{ + "DB_HOST": "kgmaint-postgres", "DB_NAME": "kgmaint_target", "REDIS_ADDR": "WKNFull-redis:6379"}, + true, "同一个源数据库"}, + {"另一套栈但共用同一队列", false, "other-project", map[string]string{ + "DB_HOST": "other-pg", "DB_NAME": "other", "REDIS_ADDR": "kgmaint-redis:6379", "REDIS_NAMESPACE": "kgmaint"}, + true, "同一个源队列"}, + } + for _, tc := range cases { + conflict, reason := undeclaredWriterConflict(tc.declared, tc.project, "kgmaint-weknora", tc.env, source) + if conflict != tc.want { + t.Fatalf("%s: conflict=%v want %v (%s)", tc.name, conflict, tc.want, reason) + } + if tc.want && tc.reasonHint != "" && !strings.Contains(reason, tc.reasonHint) { + t.Fatalf("%s: reason %q should mention %q", tc.name, reason, tc.reasonHint) + } + } +}