From 8525d5a95bdaa25ca4cfefc6c38e27078dbce05a Mon Sep 17 00:00:00 2001 From: argszero Date: Sun, 27 Sep 2026 04:14:22 +0800 Subject: [PATCH] fix(deploy): let the deploy artifacts carry the version Cargo.toml declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker-compose.yml` and `Dockerfile` each copy two facts out of `Cargo.toml` — the release version (`image: aitokenpool:`, the copy-pasteable `docker build -t aitokenpool: .`, the `(v)` header) and the minimum Rust version (`FROM rust:-slim`). Cross-format files cannot import, so a copy is unavoidable; the problem is that nothing asserted it. The copy was written in a51b3ba (#99, v0.6.6) and never touched again, so by now it was 22 releases stale: the `docker compose up -d --build` that README recommends tagged the image `aitokenpool:0.6.6` while the binary inside it reports 0.7.28 (the `/healthz` version comes from `env!("CARGO_PKG_VERSION")`). Fix the five stale literals, and give the copies an executor in the existing `src/deploy_gate.rs`: expectations are derived from `Cargo.toml` (no snapshots), every `aitokenpool:` tag and `(v)` remark must equal the declared version, and every `FROM rust:` must be at least the declared `rust-version` (building with a newer toolchain is legitimate — the guarded direction is "the declaration was raised and the Dockerfile did not follow"). Moving tags such as `latest` are not version claims and are skipped, and the scanner is scoped to the `[package]` section so the `version = "0.7"` entries of the dependency tables cannot stand in for it. Measured on this tree: before, the rule reports exactly the five stale sites (compose :1/:19, Dockerfile :1/:3/:6); lowering the builder image to rust:1.85 and bumping the manifest to 0.7.29 each turn it red. `cargo test` 396 -> 399. Consequence, deliberate: a release PR now also has to update these two files, and the gate fails loudly if it does not. --- Dockerfile | 6 +- docker-compose.yml | 4 +- src/deploy_gate.rs | 350 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 355 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index e4cf797..3552988 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -# AITokenPool — multi-stage release image (v0.6.6) +# AITokenPool — multi-stage release image (v0.7.28) # -# Build: docker build -t aitokenpool:0.6.6 . +# Build: docker build -t aitokenpool:0.7.28 . # Run: docker run -p 8080:8080 -v "$PWD/atp-data:/data" \ # -e ATP_MASTER_KEY="$(openssl rand -hex 32)" \ -# aitokenpool:0.6.6 +# aitokenpool:0.7.28 # # Notes: # - unified data dir (rant 2026-08-19T20:53:23): ATP_DATA_DIR=/data holds diff --git a/docker-compose.yml b/docker-compose.yml index 3494617..74debe4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -# AITokenPool — Docker Compose 一键部署(v0.6.6) +# AITokenPool — Docker Compose 一键部署(v0.7.28) # # 快速开始: # export ATP_MASTER_KEY=$(openssl rand -hex 32) # 必填,见下方说明 @@ -16,7 +16,7 @@ services: aitokenpool: build: . - image: aitokenpool:0.6.6 + image: aitokenpool:0.7.28 ports: - "8080:8080" volumes: diff --git a/src/deploy_gate.rs b/src/deploy_gate.rs index 16b367d..edd8cdb 100644 --- a/src/deploy_gate.rs +++ b/src/deploy_gate.rs @@ -23,6 +23,34 @@ //! **仅测试期编译**、**零新依赖**、**编译期读入**(不依赖工作目录); //! **阳性对照**:断言确实在 compose 里找到了那条设置、config 示例里找到了那一行 —— //! 否则「扫描到 0 条」也会「通过」。 +//! +//! # 第二条规则(R77):部署产物**抄**的那些版本号必须等于 `Cargo.toml` 声明的那份 +//! +//! `docker-compose.yml` / `Dockerfile` 各自抄了一份 `Cargo.toml` 的事实 —— 发行版本号 +//! (`image: aitokenpool:`、`# Build: docker build -t aitokenpool: .`、头注释里的 +//!「(v)」)与最低 Rust 版本(`FROM rust:-slim`)。跨格式无法 import,只能抄; +//! 而**抄了没有断言**就是腐烂的栖息地(C2059)—— 这份副本生于 `a51b3ba`(v0.6.6)后 +//! 一次未改,到 R77 已陈旧 **22 个发行版**:README 推荐的 `docker compose up -d --build` +//! 建出的镜像被标成 `aitokenpool:0.6.6`,而里面的二进制自报 `0.7.28` +//!(`/healthz` 的 `env!("CARGO_PKG_VERSION")`)。 +//! +//! 因此与 `catalog_gate.rs` 的 `MODEL_COUNT` 同判:**能抄的前提是有人守**。 +//! 期望值**从 `Cargo.toml` 派生**(`manifest_field`,不写快照): +//! +//! | 形态 | 例 | 判定 | +//! |---|---|---| +//! | 镜像 tag | `image: aitokenpool:` | `` 必须**等于**发行版本号 | +//! | 注释标注 | `(v)` / `(v)` | `` 必须**等于**发行版本号 | +//! | 构建镜像 | `FROM rust:-slim` | `` 必须 **≥** `rust-version` | +//! +//! `latest` 这类移动 tag 不是版本**声明**,不参与判定(README 里正是它)。 +//! `FROM rust:` 用 **≥** 而非相等:用**更新**的工具链构建是合法的,被守的是 +//!「声明被抬高、Dockerfile 没跟」这一向(与 `ci.yml` 的 `msrv` job 同向)。 +//! +//! 代价是**发行 PR 从此还要改这两个文件** —— 漏改会被本门禁当场抓住(这是刻意的)。 +//! **射程**:只覆盖部署产物;`CONTRIBUTING.md` / `docs/architecture.md` 里的散文提及 +//! 不在射程内(散文无运行时后果,且本仓库既有的约定是「能不抄就不抄」, +//! 见 `docs/plan-api-matrix.md` 第 4 节)。 /// 编译期读入的部署/配置产物。 const FILES: &[(&str, &str)] = &[ @@ -133,6 +161,193 @@ fn violations() -> Vec { bad } +// --------------------------------------------------------------------------- +// 第二条规则(R77):部署产物抄的版本号必须与 `Cargo.toml` 声明的一致 +// --------------------------------------------------------------------------- + +/// `Cargo.toml` 原文 —— 本门禁的**唯一期望来源**(不写快照)。 +const MANIFEST: &str = include_str!("../Cargo.toml"); + +/// 取 `[package]` 段里某个字段的值(去引号)。 +/// +/// 只认 `[package]` 段:依赖表里也有 `version = "0.7"` 这类同名字段,全局匹配会把它们 +/// 当成期望值 —— 同名字段必须按**所属段**收窄(同 C2173 的教训)。 +fn manifest_field(key: &str) -> Option { + let mut in_package = false; + for line in MANIFEST.lines() { + let t = line.trim(); + if t.starts_with('[') { + in_package = t == "[package]"; + continue; + } + if !in_package || t.starts_with('#') { + continue; + } + let Some(rest) = t.strip_prefix(key) else { + continue; + }; + let Some(rest) = rest.trim_start().strip_prefix('=') else { + continue; // 例如 `versioned = …` 之类,不是赋值 + }; + return Some( + rest.trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_string(), + ); + } + None +} + +/// 发行版本号(`Cargo.toml` 的 `version`)。 +fn declared_version() -> String { + manifest_field("version").expect("Cargo.toml 的 [package] 段必须有 version") +} + +/// 声明的最低 Rust 版本(`Cargo.toml` 的 `rust-version`)。 +fn declared_msrv() -> String { + manifest_field("rust-version").expect("Cargo.toml 的 [package] 段必须有 rust-version") +} + +/// `x.y.z` 形态的版本号。`latest` 这类移动 tag 不是版本**声明** ⇒ 不参与判定。 +fn looks_like_version(tok: &str) -> bool { + tok.contains('.') + && tok.chars().all(|c| c.is_ascii_digit() || c == '.') + && !tok.starts_with('.') + && !tok.ends_with('.') +} + +fn as_tuple(v: &str) -> Vec { + v.split('.').map(|p| p.parse().unwrap_or(0)).collect() +} + +/// `a >= b`(缺失分量按 0 补:`1.86` == `1.86.0`)。 +fn at_least(a: &str, b: &str) -> bool { + let (mut x, mut y) = (as_tuple(a), as_tuple(b)); + while x.len() < y.len() { + x.push(0); + } + while y.len() < x.len() { + y.push(0); + } + x >= y +} + +/// 一行里 `:` 形态的镜像引用;只收**版本号** tag。 +fn image_tags(src: &str, image: &str) -> Vec<(usize, String)> { + let needle = format!("{image}:"); + let mut out = Vec::new(); + for (i, line) in src.lines().enumerate() { + let mut from = 0usize; + while let Some(pos) = line[from..].find(&needle) { + let start = from + pos + needle.len(); + let tok: String = line[start..] + .chars() + .take_while(|c| !c.is_whitespace() && !"\"'`".contains(*c)) + .collect(); + if looks_like_version(&tok) { + out.push((i + 1, tok)); + } + from = start; + if from >= line.len() { + break; + } + } + } + out +} + +/// 头注释里的 `(v<版本>)` / `(v<版本>)`(本仓库用它标注「这份产物属于哪个发行版」)。 +fn version_comments(src: &str) -> Vec<(usize, String)> { + let mut out = Vec::new(); + for (i, line) in src.lines().enumerate() { + let cs: Vec = line.chars().collect(); + let mut j = 1; + while j + 1 < cs.len() { + if cs[j] == 'v' && (cs[j - 1] == '(' || cs[j - 1] == '(') && cs[j + 1].is_ascii_digit() + { + let mut k = j + 1; + while k < cs.len() && (cs[k].is_ascii_digit() || cs[k] == '.') { + k += 1; + } + if matches!(cs.get(k), Some(')') | Some(')')) { + out.push((i + 1, cs[j + 1..k].iter().collect())); + j = k; + continue; + } + } + j += 1; + } + } + out +} + +/// `FROM rust:<版本>[-slim]` —— `rust-version` 的副本。 +fn rust_base_images(src: &str) -> Vec<(usize, String)> { + let mut out = Vec::new(); + for (i, line) in src.lines().enumerate() { + let t = line.trim_start(); + let Some(rest) = t.strip_prefix("FROM ").or_else(|| t.strip_prefix("from ")) else { + continue; + }; + let Some(rest) = rest.trim_start().strip_prefix("rust:") else { + continue; + }; + let ver: String = rest + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + if looks_like_version(&ver) { + out.push((i + 1, ver)); + } + } + out +} + +/// 版本类违规项(人类可读)。空 = 通过。 +fn version_violations_of(files: &[(&str, &str)]) -> Vec { + let ver = declared_version(); + let msrv = declared_msrv(); + let mut bad = Vec::new(); + for (name, src) in files { + for (ln, tag) in image_tags(src, "aitokenpool") { + if tag != ver { + bad.push(format!( + "{name}:{ln}: 镜像 tag 是 {tag},而 Cargo.toml 声明的是 {ver}(副本没跟上)" + )); + } + } + for (ln, v) in version_comments(src) { + if v != ver { + bad.push(format!( + "{name}:{ln}: 注释标注的发行版是 v{v},而 Cargo.toml 声明的是 {ver}" + )); + } + } + for (ln, v) in rust_base_images(src) { + if !at_least(&v, &msrv) { + bad.push(format!( + "{name}:{ln}: 构建镜像 rust:{v} 低于 Cargo.toml 声明的 rust-version {msrv}" + )); + } + } + } + bad +} + +fn version_violations() -> Vec { + version_violations_of(FILES) +} + +#[test] +fn the_deploy_artifacts_carry_the_version_the_manifest_declares() { + let bad = version_violations(); + assert!( + bad.is_empty(), + "部署产物里的版本号必须与 Cargo.toml 一致:\n{}", + bad.join("\n") + ); +} + #[test] fn no_master_key_default_that_is_not_valid_hex() { let bad = violations(); @@ -229,3 +444,138 @@ fn classifier_and_config_parser_flag_the_pre_fix_shapes() { env_assignments(&bad) ); } + +#[test] +fn the_version_scanners_actually_see_the_copies_they_guard() { + // 期望值确实**从 Cargo.toml 派生**(与编译期版本同源),不是抄来的常量。 + let ver = declared_version(); + let msrv = declared_msrv(); + assert_eq!(ver, env!("CARGO_PKG_VERSION"), "期望值应与编译期版本同源"); + assert!(looks_like_version(&ver), "`version` 应是 x.y.z:{ver:?}"); + assert!( + looks_like_version(&msrv), + "`rust-version` 应是 x.y:{msrv:?}" + ); + // 同名字段按段收窄:依赖表里的 `version = "0.7"` 不得顶替 `[package]` 的版本。 + assert!( + !FILES + .iter() + .any(|(_, s)| s.contains("axum") && s.contains("0.7.28")), + "依赖段不得被当成期望来源" + ); + + // 扫描器必须**真的看见**那些行 —— 否则「改名/改路径后扫到 0 条」也会「通过」。 + let compose = FILES + .iter() + .find(|(n, _)| *n == "docker-compose.yml") + .unwrap() + .1; + let tags = image_tags(compose, "aitokenpool"); + assert_eq!(tags.len(), 1, "compose 里应恰好 1 处镜像 tag:{tags:?}"); + assert!( + compose + .lines() + .nth(tags[0].0 - 1) + .is_some_and(|l| l.contains("image:")), + "被扫到的那处应是生效的 `image:` 字段:{tags:?}" + ); + assert_eq!( + version_comments(compose).len(), + 1, + "compose 头部应有 1 处版本标注" + ); + + let dockerfile = FILES.iter().find(|(n, _)| *n == "Dockerfile").unwrap().1; + assert_eq!( + image_tags(dockerfile, "aitokenpool").len(), + 2, + "Dockerfile 的 build/run 示例各有一处 `aitokenpool:…`" + ); + assert_eq!( + version_comments(dockerfile).len(), + 1, + "Dockerfile 头部应有 1 处版本标注" + ); + let froms = rust_base_images(dockerfile); + assert_eq!( + froms.len(), + 1, + "Dockerfile 应有 1 处 `FROM rust:…`:{froms:?}" + ); + + // README 用的是移动 tag(`latest`),不是版本声明 —— 不得被当成违规。 + for name in ["README.md", "README.en.md"] { + let src = FILES.iter().find(|(n, _)| *n == name).unwrap().1; + assert!( + image_tags(src, "aitokenpool").is_empty(), + "{name} 的 tag 是 `latest`,不该被判为版本声明" + ); + } +} + +#[test] +fn the_version_scanners_flag_the_stale_shapes() { + // 什么算「版本号」:`latest` / 空 / 非数字串都不是。 + assert!(looks_like_version("0.6.6")); + assert!(looks_like_version("1.86")); + assert!(!looks_like_version("latest")); + assert!(!looks_like_version("")); + assert!(!looks_like_version("atp-data")); + assert!(!looks_like_version(".1")); + + // 镜像 tag:移动 tag 跳过;YAML 服务名(`aitokenpool:` 后无版本)不得被算进去。 + assert!(image_tags("ghcr.io/argszero/aitokenpool:latest", "aitokenpool").is_empty()); + assert_eq!( + image_tags(" image: aitokenpool:0.6.6", "aitokenpool"), + vec![(1, "0.6.6".to_string())] + ); + assert!(image_tags(" aitokenpool:\n", "aitokenpool").is_empty()); + + // 版本标注:两种括号都要认。 + assert_eq!( + version_comments("# …(v0.6.6)"), + vec![(1, "0.6.6".to_string())] + ); + assert_eq!( + version_comments("# … (v0.7.28)"), + vec![(1, "0.7.28".to_string())] + ); + assert!(version_comments("(v) x\n").is_empty()); + + // 构建镜像:低于声明 ⇒ 违规;等于/高于 ⇒ 合规(用更新工具链构建是合法的)。 + assert_eq!( + rust_base_images("FROM rust:1.86-slim AS builder"), + vec![(1, "1.86".to_string())] + ); + assert!(rust_base_images("FROM debian:bookworm-slim AS runtime").is_empty()); + assert!(at_least("1.86", "1.86")); + assert!(at_least("1.86.0", "1.86")); + assert!(at_least("1.90", "1.86")); + assert!(!at_least("1.85", "1.86")); + + // 规则本身有牙:拿**合规**的合成输入,各自改成陈旧值 ⇒ 必须报违规。 + // (合成输入而非活文件:规则与「活文件此刻是否已修」是两件事,不该互相污染读数。) + let ver = declared_version(); + let msrv = declared_msrv(); + let ok = + format!(" image: aitokenpool:{ver}\n# (v{ver})\nFROM rust:{msrv}-slim AS builder\n"); + assert!( + version_violations_of(&[("synthetic", &ok)]).is_empty(), + "合规输入不得报违规" + ); + + let cases: &[(&str, &str)] = &[ + (&format!("aitokenpool:{ver}"), "aitokenpool:0.0.1"), + (&format!("(v{ver})"), "(v0.0.1)"), + (&format!("FROM rust:{msrv}-slim"), "FROM rust:1.0-slim"), + ]; + for (from, to) in cases { + let stale = ok.replace(from, to); + assert_ne!(stale, ok, "synthetic 替换必须真的发生:{from:?}"); + let bad = version_violations_of(&[("synthetic", &stale)]); + assert!( + bad.iter().any(|m| m.contains("0.0.1") || m.contains("1.0")), + "{to:?} 必须被判违规:{bad:?}" + ); + } +}