From e52d2e1e4b06542b5a763c916826eb380276c11e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 19 Oct 2026 02:07:36 +0800 Subject: [PATCH] Separate database connections from file-backed host information and document handoff --- .env.example | 12 +- .gitea/workflows/README.md | 2 +- .gitea/workflows/ci-deploy.yml | 2 +- Cargo.lock | 14 ++- Cargo.toml | 3 +- Dockerfile | 4 +- README.md | 15 +-- crates/README.md | 15 +-- crates/cli/Cargo.toml | 3 +- crates/cli/README.md | 8 +- crates/cli/src/README.md | 4 +- crates/cli/src/main.rs | 30 ++--- crates/host/Cargo.toml | 16 +++ crates/host/README.md | 11 ++ crates/host/src/README.md | 8 ++ crates/host/src/lib.rs | 4 + crates/host/src/metadata.rs | 172 ++++++++++++++++++++++++++++ crates/host/tests/README.md | 5 + crates/host/tests/metadata.rs | 131 +++++++++++++++++++++ crates/server/Cargo.toml | 2 +- crates/server/README.md | 8 +- crates/server/src/README.md | 11 +- crates/server/src/main.rs | 59 ++-------- crates/storage/README.md | 10 +- crates/storage/src/README.md | 11 +- crates/storage/src/lib.rs | 150 +++++++----------------- crates/storage/src/schema.sql | 4 - crates/storage/tests/README.md | 8 +- crates/storage/tests/postgres.rs | 78 ++++++++----- crates/storage/tests/storage.rs | 141 ++++++++++++++--------- docker-compose.ces-control.yml | 7 +- docker-compose.postgres.yml | 8 +- docker-compose.yml | 2 +- docs/README.md | 11 +- docs/architecture/README.md | 39 +++---- docs/contributing/README.md | 7 +- docs/contributing/handoff.md | 36 ++++++ docs/operations/README.md | 52 ++++----- docs/operations/configuration.md | 22 ++-- docs/operations/deployment.md | 23 ++-- docs/operations/host-information.md | 47 ++++++++ docs/operations/validation.md | 8 +- docs/runtime/README.md | 4 +- docs/runtime/database-objects.md | 36 ++++++ docs/runtime/lifecycle.md | 2 +- docs/runtime/objects.md | 2 +- docs/runtime/text-encoding.md | 29 +++++ scripts/README.md | 4 +- scripts/deploy-ces-control.sh | 67 +++++++++-- scripts/smoke.py | 30 +++-- 50 files changed, 935 insertions(+), 442 deletions(-) create mode 100644 crates/host/Cargo.toml create mode 100644 crates/host/README.md create mode 100644 crates/host/src/README.md create mode 100644 crates/host/src/lib.rs create mode 100644 crates/host/src/metadata.rs create mode 100644 crates/host/tests/README.md create mode 100644 crates/host/tests/metadata.rs delete mode 100644 crates/storage/src/schema.sql create mode 100644 docs/contributing/handoff.md create mode 100644 docs/operations/host-information.md create mode 100644 docs/runtime/database-objects.md create mode 100644 docs/runtime/text-encoding.md diff --git a/.env.example b/.env.example index 451be57..3527928 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,8 @@ -# Docker Compose reads .env automatically; cargo programs read exported env only. +# Docker Compose reads .env; programs read exported host settings. ASTER_PORT=8080 -# Local host settings (export explicitly when running cargo): ASTER_LISTEN=127.0.0.1:8080 -SQLITE_PATH=data/aster.db -# Unset DATABASE_URL to use SQLite. Do not set it to an empty string. -# DATABASE_URL=postgres://aster:password@127.0.0.1:5432/aster -# PostgreSQL Compose only: replace with a random hex string, e.g. openssl rand -hex 24. -# Hex avoids URL-encoding ambiguity when used in the Compose connection URL. +# JSON host information, separate from databases: +ASTER_STATE_DIR=data/host +# PostgreSQL provisioning only: credentials are not consumed by the VM host. +# Database-backed objects will receive an explicit DatabaseConfig. POSTGRES_PASSWORD=replace-with-a-random-hex-password diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md index 44254c6..05a4fb8 100644 --- a/.gitea/workflows/README.md +++ b/.gitea/workflows/README.md @@ -2,7 +2,7 @@ [ci-deploy.yml](ci-deploy.yml) 是当前唯一工作流:main push 或 workflow_dispatch 触发,运行于 ces-control,生产并发组串行且不取消正在运行的发布,任务超时 60 分钟。 -顺序为检出 → ci-ces-control.sh 验证 → 构建 SHA 生产镜像 → main 分支执行 deploy-ces-control.sh。设置 DOCKER_BUILDKIT=0 使用当前 builder 路径,检出不保留认证信息;工作流不更改共享 Runner。 +顺序为检出 → ci-ces-control.sh 验证 → 构建 SHA 生产镜像 → main 分支执行 deploy-ces-control.sh。设置 DOCKER_BUILDKIT=0 使用当前 builder 路径,检出不保留认证信息;工作流不更改共享 Runner。PostgreSQL 保留为预置资源,基础信息由文件保存;发布只为旧基础信息做一次只读导入,不再写入数据库。 脚本职责见 [scripts](../../scripts/README.md),完整发布、保留数据和回退规则见 [部署](../../docs/operations/deployment.md)。main 推送会进入现有发布链,不能把推送成功写成 CI 或部署成功;按用户约定不主动跟踪状态。 diff --git a/.gitea/workflows/ci-deploy.yml b/.gitea/workflows/ci-deploy.yml index c92ac88..5a17e13 100644 --- a/.gitea/workflows/ci-deploy.yml +++ b/.gitea/workflows/ci-deploy.yml @@ -28,6 +28,6 @@ jobs: - name: Build production image run: docker build --target runtime -t "endorphin-aster-vm:$ASTER_RELEASE_SHA" . - - name: Deploy persistent PostgreSQL host + - name: Deploy VM host and provision PostgreSQL if: gitea.ref == 'refs/heads/main' run: sh scripts/deploy-ces-control.sh diff --git a/Cargo.lock b/Cargo.lock index e775c9e..9cc1fbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,8 +131,7 @@ name = "cli" version = "0.1.0" dependencies = [ "core", - "storage", - "tokio", + "host", ] [[package]] @@ -468,6 +467,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "host" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "http" version = "1.5.0" @@ -1065,8 +1073,8 @@ version = "0.1.0" dependencies = [ "axum", "core", + "host", "serde", - "storage", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index c115405..72e4ed7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/core", "crates/storage", "crates/cli", "crates/server"] +members = ["crates/core", "crates/storage", "crates/cli", "crates/server", "crates/host"] resolver = "2" [workspace.package] @@ -11,6 +11,7 @@ license = "MIT" [workspace.dependencies] core = { path = "crates/core" } storage = { path = "crates/storage" } +host = { path = "crates/host" } axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/Dockerfile b/Dockerfile index c6665ed..dfb99f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,12 +19,12 @@ FROM debian:bookworm-slim AS runtime RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \ && rm -rf /var/lib/apt/lists/* \ && useradd --system --uid 10001 --create-home aster \ - && mkdir -p /data && chown aster:aster /data + && mkdir -p /data/host && chown -R aster:aster /data COPY --from=build /build/target/release/server /usr/local/bin/server COPY --from=build /build/target/release/cli /usr/local/bin/cli USER 10001 WORKDIR /data -ENV ASTER_LISTEN=0.0.0.0:8080 SQLITE_PATH=/data/aster.db +ENV ASTER_LISTEN=0.0.0.0:8080 ASTER_STATE_DIR=/data/host EXPOSE 8080 HEALTHCHECK --interval=10s --timeout=6s --start-period=10s --retries=3 \ CMD curl --fail --silent http://127.0.0.1:8080/health || exit 1 diff --git a/README.md b/README.md index 8b2d63a..02c2086 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Rust 编写的 Endor 虚拟机实现。当前具备 EndorObject、稳定对象分配、代际句柄、引用计数、显式循环回收,以及可执行语言函数的小型 wordcode VM。数字统一为无损表示优先的 Number。 -VM 从空堆启动;尚无源码 lexer/parser、Python type/元类体系、数值算术或完整异常传播。宿主数据库保存元数据,不保存 VM 堆。当前边界见 [系统架构](docs/architecture/README.md)。 +VM 从空堆启动;尚无源码 lexer/parser、Python type/元类体系、数值算术或完整异常传播。数据库连接仅为需要数据库的特殊类保留;普通宿主不连接数据库;基础信息改用 host 包的 UTF-8 JSON 文件,不保存 VM 堆。当前边界见 [系统架构](docs/architecture/README.md)。 ## 从哪里读 @@ -12,6 +12,7 @@ VM 从空堆启动;尚无源码 lexer/parser、Python type/元类体系、 | 看源码目录与 crate 边界 | [crates](crates/README.md) | | 看指令、代码对象、执行帧和执行循环 | [字节码 VM](docs/runtime/bytecode.md) | | 看 VM/帧/对象如何存活和销毁 | [生命周期](docs/runtime/lifecycle.md) | +| 看基础信息文件和旧库迁移 | [宿主信息](docs/operations/host-information.md) | | 看配置、UTF-8 与 locale 边界 | [配置](docs/operations/configuration.md) | | 运行 CLI/HTTP 宿主 | [运行指南](docs/operations/README.md) | | 看 ces-control CI 与 PostgreSQL 部署 | [部署](docs/operations/deployment.md) | @@ -21,18 +22,18 @@ VM 从空堆启动;尚无源码 lexer/parser、Python type/元类体系、 | 文件或目录 | 职责 | | --- | --- | -| Cargo.toml / Cargo.lock | 工作区、包依赖、Rust 版本要求及依赖锁定;包名与目录一致:core、storage、cli、server | -| crates/ | core、storage、cli、server 四个包,每个目录的 README 解释本目录 | +| Cargo.toml / Cargo.lock | 工作区、包依赖、Rust 版本要求及依赖锁定;包名与目录一致:core、storage、host、cli、server | +| crates/ | core、storage、host、cli、server 五个包,每个目录的 README 解释本目录 | | docs/ | 跨目录的架构、运行时契约、配置、运行、部署与维护说明 | | scripts/ | 指定远端环境中的检查、冒烟、CI 与部署脚本 | | .gitea/ | Gitea Actions 工作流 | | Dockerfile | source、ci、build、runtime 多阶段镜像 | -| docker-compose.yml | 独立 SQLite 运行方案 | -| docker-compose.postgres.yml | 独立 PostgreSQL 运行方案 | -| docker-compose.ces-control.yml | ces-control 正式 PostgreSQL 部署 | +| docker-compose.yml | VM 宿主运行方案,保留原有数据卷 | +| docker-compose.postgres.yml | VM 宿主与预置 PostgreSQL | +| docker-compose.ces-control.yml | ces-control 宿主与 PostgreSQL 资源部署 | | .env.example / .gitignore / .dockerignore | 配置样例、Git 与 Docker 上下文排除规则 | | LICENSE | MIT 许可 | 本地不编译、不测试、不下载依赖;可做文档和静态文件检查。运行验证交给 ces-control;按用户约定不主动跟踪 CI/部署结果。命令和使用条件见 [验证](docs/operations/validation.md)。 -文档随代码版本更新:就地解释放各目录 README,跨目录契约放 docs,对外阅读副本以仓库文档为准。忽略的 work、outputs、target、data 和 Git 内部目录不属于需要维护 README 的源码目录。 +文档随代码版本更新:就地解释放各目录 README,跨目录契约放 docs,对外阅读副本以仓库文档为准。忽略的 work、target、data 和 Git 内部目录不属于需要维护 README 的源码目录。 diff --git a/crates/README.md b/crates/README.md index 75ef136..25cb299 100644 --- a/crates/README.md +++ b/crates/README.md @@ -1,14 +1,15 @@ # Cargo 工作区 -本目录包含四个 crate。Cargo 包名与目录一致,均不带项目前缀;路径依赖定义在根 Cargo.toml。 +包名与目录一致,路径依赖定义在根 Cargo.toml。 | 目录 | 包名/产物 | 职责 | | --- | --- | --- | -| [core](core/README.md) | core | 零外部依赖的对象运行时、堆与字节码 VM | -| [storage](storage/README.md) | storage | 宿主元数据与 SQLite/PostgreSQL 适配,不依赖 core | -| [cli](cli/README.md) | cli,二进制 cli | 引导演示和 metadata 命令 | -| [server](server/README.md) | server | 最小常驻 HTTP 宿主 | +| [core](core/README.md) | core,库 endor_core | 零外部依赖的对象运行时、堆与字节码 VM | +| [host](host/README.md) | host | UTF-8 JSON 文件基础信息,与数据库分离 | +| [storage](storage/README.md) | storage | 为数据库特殊类保留的显式连接适配,不提供基础信息存储 | +| [cli](cli/README.md) | cli,二进制 cli | 引导演示与文件信息命令,不使用数据库 | +| [server](server/README.md) | server,二进制 server | HTTP 宿主与文件版本信息,不使用数据库 | -core 的 Rust 库目标明确命名为 endor_core,避免与 Rust 标准库 core 重名;storage 的引用名为 storage,两个二进制分别为 cli、server。Cargo 通过 [lib] 自定义库名,详见 [Cargo 目标说明](https://doc.rust-lang.org/cargo/reference/cargo-targets.html#the-name-field)。 +CLI 和 Server 依赖 Core 与 Host,不依赖 Storage;Core 与 Storage 也互不依赖。未来特殊类的宿主实现按需依赖 Storage,见 [数据库对象](../docs/runtime/database-objects.md)。 -CLI/Server 依赖 Core 和 Storage,Core 与 Storage 互不依赖。网络、数据库、浏览器、进程配置和信号留在宿主。全局说明见 [架构](../docs/architecture/README.md),查功能位置从 [文档索引](../docs/README.md) 进入。返回 [项目入口](../README.md)。 +core 的 Rust 库名为 endor_core,避免与标准库 core 重名,见 [Cargo 目标说明](https://doc.rust-lang.org/cargo/reference/cargo-targets.html#the-name-field)。网络、数据库、进程配置和信号不进入 Core。全局说明见 [架构](../docs/architecture/README.md),返回 [项目入口](../README.md)。 diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 184715d..050171f 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -11,8 +11,7 @@ path = "src/main.rs" [dependencies] core.workspace = true -storage.workspace = true -tokio.workspace = true +host.workspace = true [lints] workspace = true diff --git a/crates/cli/README.md b/crates/cli/README.md index 1bbc98c..8ff74be 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -1,10 +1,10 @@ # cli -命令行宿主。bootstrap 演示两个运行时对象并释放;metadata put/get 使用独立 Storage,不让数据库配置进入 Core。 +同步命令行宿主,提供 bootstrap、help 和文件 metadata 命令。bootstrap 演示两个运行时对象并释放,不读取数据库配置、不连接或写入数据库。 | 内容 | 职责 | | --- | --- | -| Cargo.toml | CLI 包、cli 二进制名称、core/storage/Tokio 依赖 | -| [src](src/README.md) | 参数路由、命令执行和退出码 | +| Cargo.toml | cli 二进制与 core/host 依赖;无 storage 或 Tokio 依赖 | +| [src](src/README.md) | 参数路由、引导演示和退出码 | -用法见 [运行指南](../../docs/operations/README.md),文本参数与环境边界见 [配置](../../docs/operations/configuration.md)。此 CLI 当前不是 Endor 源码解释命令。返回 [工作区](../README.md)。 +运行方式见 [运行指南](../../docs/operations/README.md),输入边界见 [配置](../../docs/operations/configuration.md)。当前不是 Endor 源码解释命令,metadata put/get 和只导入缺失项的 import 均使用文件,见 [宿主信息](../../docs/operations/host-information.md)。返回 [工作区](../README.md)。 diff --git a/crates/cli/src/README.md b/crates/cli/src/README.md index 695219a..0640f8f 100644 --- a/crates/cli/src/README.md +++ b/crates/cli/src/README.md @@ -1,7 +1,7 @@ # CLI 入口 -[main.rs](main.rs) 是唯一入口:main 将 run 的结果转成进程退出码;run 解析 bootstrap、metadata put/get 和 help,错误打印到 stderr 并返回失败。 +[main.rs](main.rs) 是唯一入口:同步 main 将 run 的结果转成进程退出码;run 处理 bootstrap、help、文件 metadata put/get/import,错误打印到 stderr 并返回失败。 -bootstrap 创建并显式释放两个对象,不打开数据库。metadata 每次读取 StorageConfig、连接、操作并关闭池。参数目前由 std::env::args 读取,非 Unicode 参数可能 panic,现状及后续边界见 [配置](../../../docs/operations/configuration.md)。 +bootstrap 创建并显式释放两个对象,不读取数据库环境、不打开数据库。未知命令报错;metadata 通过 host::MetadataStore 读写文件,import 从标准输入接收旧记录且不覆盖已有文件。参数目前由 std::env::args 读取,非 Unicode 参数可能 panic,边界见 [配置](../../../docs/operations/configuration.md)。 新增命令时更新用法、[运行指南](../../../docs/operations/README.md) 和 [smoke.py 的导航](../../../scripts/README.md)。返回 [cli](../README.md)。 diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 32dfdeb..e586040 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -2,13 +2,12 @@ use std::error::Error; use std::process::ExitCode; use endor_core::{Value, Vm}; -use storage::{Storage, StorageConfig}; +use host::MetadataStore; -const USAGE: &str = "Usage: cli [bootstrap | metadata put | metadata get ]"; +const USAGE: &str = "Usage: cli [bootstrap | metadata put | metadata get | metadata import | help]"; -#[tokio::main] -async fn main() -> ExitCode { - match run().await { +fn main() -> ExitCode { + match run() { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("cli: {error}"); @@ -17,7 +16,7 @@ async fn main() -> ExitCode { } } -async fn run() -> Result<(), Box> { +fn run() -> Result<(), Box> { let args: Vec = std::env::args().skip(1).collect(); match args .iter() @@ -43,22 +42,25 @@ async fn run() -> Result<(), Box> { vm.release(first)?; vm.release(second)?; println!("released heap_objects={}", vm.object_count()); - println!("bootstrap is independent of host storage"); + println!("bootstrap uses no database"); } ["help"] | ["--help"] | ["-h"] => println!("{USAGE}"), ["metadata", "put", key, value] => { - let store = Storage::connect(&StorageConfig::from_env()?).await?; - store.put(key, value).await?; - println!("stored ({})", store.backend_name()); - store.close().await; + MetadataStore::from_env()?.put(key, value)?; + println!("stored (file)"); } ["metadata", "get", key] => { - let store = Storage::connect(&StorageConfig::from_env()?).await?; println!( "{}", - store.get(key).await?.as_deref().unwrap_or("(missing)") + MetadataStore::from_env()? + .get(key)? + .as_deref() + .unwrap_or("(missing)") ); - store.close().await; + } + ["metadata", "import"] => { + let count = MetadataStore::from_env()?.import_missing(std::io::stdin().lock())?; + println!("imported {count} missing entries (file)"); } _ => return Err(USAGE.into()), } diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml new file mode 100644 index 0000000..db6bc5a --- /dev/null +++ b/crates/host/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "host" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/host/README.md b/crates/host/README.md new file mode 100644 index 0000000..69e8a24 --- /dev/null +++ b/crates/host/README.md @@ -0,0 +1,11 @@ +# host + +宿主基础信息的文件层,与 Core 和数据库 Storage 分开。CLI/Server 使用它保存 host.version、deployment.release 和其他命名信息;不打开数据库,不保存 VM 堆。 + +| 内容 | 职责 | +| --- | --- | +| Cargo.toml | host 包、Serde/JSON 依赖及远端检查依赖 | +| [src](src/README.md) | MetadataStore、UTF-8 JSON 文件与迁移导入 | +| [tests](tests/README.md) | 持久读取、替换、并发、坏记录、路径边界和幂等导入 | + +ASTER_STATE_DIR 指定信息目录;默认 data/host,容器使用 /data/host。协议、生命周期及旧数据库一次性迁移见 [宿主信息](../../docs/operations/host-information.md)。返回 [工作区](../README.md)。 diff --git a/crates/host/src/README.md b/crates/host/src/README.md new file mode 100644 index 0000000..4fb6fd8 --- /dev/null +++ b/crates/host/src/README.md @@ -0,0 +1,8 @@ +# Host 源码导航 + +| 文件 | 职责 | +| --- | --- | +| [lib.rs](lib.rs) | 导出 MetadataStore,明确文件层不依赖 VM 或 SQL | +| [metadata.rs](metadata.rs) | 目录配置、键校验、逐键 JSON 读取/同步写入、原子发布、仅导入缺失项 | + +文件数据以 UTF-8 JSON 保存,操作系统路径用 PathBuf;坏记录不自动覆盖。每个键独立发布,不提供多键事务。旧库导入不覆盖新文件,规则见 [宿主信息](../../../docs/operations/host-information.md)。返回 [host](../README.md)。 diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs new file mode 100644 index 0000000..2758209 --- /dev/null +++ b/crates/host/src/lib.rs @@ -0,0 +1,4 @@ +//! Host information stored in UTF-8 JSON files, independently of the VM and SQL. +mod metadata; + +pub use metadata::MetadataStore; diff --git a/crates/host/src/metadata.rs b/crates/host/src/metadata.rs new file mode 100644 index 0000000..e5c6ca5 --- /dev/null +++ b/crates/host/src/metadata.rs @@ -0,0 +1,172 @@ +use std::fs::{self, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); +const FORMAT_VERSION: u32 = 1; + +#[derive(Deserialize)] +struct LegacyRecord { + key: String, + value: String, +} + +#[derive(Serialize, Deserialize)] +struct Record { + format_version: u32, + key: String, + value: String, +} + +/// One independently replaceable JSON file per ASCII metadata key. Clones +/// share a directory, not a cached map; reads always observe the file system. +#[derive(Debug, Clone)] +pub struct MetadataStore { + directory: PathBuf, +} + +impl MetadataStore { + /// Does not create the directory until the first write. + pub fn new(directory: impl Into) -> io::Result { + let directory = directory.into(); + if directory.as_os_str().is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "metadata directory is empty", + )); + } + Ok(Self { directory }) + } + + /// Preserve OS paths without requiring the environment value to be UTF-8. + pub fn from_env() -> io::Result { + Self::new( + std::env::var_os("ASTER_STATE_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("data/host")), + ) + } + + pub fn directory(&self) -> &Path { + &self.directory + } + + pub fn get(&self, key: &str) -> io::Result> { + let path = self.path(key)?; + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + let record: Record = serde_json::from_slice(&bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if record.format_version != FORMAT_VERSION || record.key != key { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "metadata key or format version mismatch", + )); + } + Ok(Some(record.value)) + } + + /// Writes a new sibling file, syncs it, then publishes it with rename. + /// Different keys do not share a read/modify/write map. For the same key, + /// the last successful rename wins. This is not a multi-key transaction. + pub fn put(&self, key: &str, value: &str) -> io::Result<()> { + self.store(key, value, true).map(|_| ()) + } + + /// One-time migration input: a JSON array of key/value string records. + /// Validate all keys first, and never overwrite an existing file record. + pub fn import_missing(&self, reader: impl Read) -> io::Result { + let records: Vec = serde_json::from_reader(reader) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + for record in &records { + self.path(&record.key)?; + } + let mut imported = 0; + for record in records { + if self.store(&record.key, &record.value, false)? { + imported += 1; + } else if self.get(&record.key)?.is_none() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "existing metadata disappeared during import", + )); + } + } + Ok(imported) + } + + fn store(&self, key: &str, value: &str, replace: bool) -> io::Result { + let destination = self.path(key)?; + let mut contents = serde_json::to_vec_pretty(&Record { + format_version: FORMAT_VERSION, + key: key.into(), + value: value.into(), + }) + .map_err(io::Error::other)?; + contents.push(b'\n'); + fs::create_dir_all(&self.directory)?; + for _ in 0..32 { + let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let temporary = self + .directory + .join(format!(".{key}.{}.{sequence}.tmp", std::process::id())); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = match options.open(&temporary) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + }; + let result = (|| { + file.write_all(&contents)?; + file.sync_all()?; + drop(file); + if replace { + fs::rename(&temporary, &destination)?; + Ok(true) + } else { + // Atomic create-if-absent: a concurrent writer's record + // cannot be replaced by the legacy import. + match fs::hard_link(&temporary, &destination) { + Ok(()) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(false), + Err(error) => Err(error), + } + } + })(); + let _ = fs::remove_file(&temporary); + return result; + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "cannot reserve a metadata temporary file", + )) + } + + fn path(&self, key: &str) -> io::Result { + if key.is_empty() + || key.len() > 128 + || !key.as_bytes()[0].is_ascii_alphanumeric() + || !key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "metadata keys must start with an ASCII letter/digit and contain at most 128 ASCII letters, digits, dots, underscores or hyphens", + )); + } + Ok(self.directory.join(format!("{key}.json"))) + } +} diff --git a/crates/host/tests/README.md b/crates/host/tests/README.md new file mode 100644 index 0000000..bbb8a68 --- /dev/null +++ b/crates/host/tests/README.md @@ -0,0 +1,5 @@ +# Host 文件信息检查 + +[metadata.rs](metadata.rs) 检查缺失读取不建目录、日文/中文/emoji 值和日文路径、重开与同键替换、非法键不越界、坏 JSON/版本/键不匹配不改原文件、并发替换可见完整记录,以及旧记录幂等导入和保留更新值。 + +检查使用临时目录,仅在指定远端环境运行。本地不执行文件行为测试;当前是否通过应以对应提交的 CI 为准。命令见 [验证](../../../docs/operations/validation.md),返回 [host](../README.md)。 diff --git a/crates/host/tests/metadata.rs b/crates/host/tests/metadata.rs new file mode 100644 index 0000000..30a3be8 --- /dev/null +++ b/crates/host/tests/metadata.rs @@ -0,0 +1,131 @@ +use host::MetadataStore; + +#[test] +fn missing_reads_do_not_create_state() { + let temporary = tempfile::tempdir().unwrap(); + let directory = temporary.path().join("state"); + let store = MetadataStore::new(&directory).unwrap(); + assert_eq!(store.get("missing").unwrap(), None); + assert!(!directory.exists()); +} + +#[test] +fn utf8_values_survive_reopening_and_same_key_replacement() { + let temporary = tempfile::tempdir().unwrap(); + let directory = temporary.path().join("日本語"); + let store = MetadataStore::new(&directory).unwrap(); + store.put("host.version", "0.1.0").unwrap(); + store.put("project.note", "日本語・中文・😀").unwrap(); + let reopened = MetadataStore::new(&directory).unwrap(); + assert_eq!( + reopened.get("project.note").unwrap().as_deref(), + Some("日本語・中文・😀") + ); + reopened.put("host.version", "0.2.0").unwrap(); + assert_eq!(store.get("host.version").unwrap().as_deref(), Some("0.2.0")); + assert_eq!( + store.get("project.note").unwrap().as_deref(), + Some("日本語・中文・😀") + ); + let record: serde_json::Value = + serde_json::from_slice(&std::fs::read(directory.join("project.note.json")).unwrap()) + .unwrap(); + assert_eq!(record["format_version"], 1); + assert_eq!(record["key"], "project.note"); +} + +#[test] +fn keys_cannot_escape_the_state_directory() { + let temporary = tempfile::tempdir().unwrap(); + let directory = temporary.path().join("state"); + let store = MetadataStore::new(&directory).unwrap(); + for key in [ + "", + "../outside", + "/outside", + "nested/key", + "nested\\key", + ".hidden", + "日本語", + ] { + assert!(store.put(key, "invalid").is_err(), "{key}"); + assert!(store.get(key).is_err(), "{key}"); + } + assert!(store.put(&"x".repeat(129), "invalid").is_err()); + assert!(!directory.exists()); +} + +#[test] +fn corrupt_or_mismatched_records_fail_without_being_rewritten() { + let temporary = tempfile::tempdir().unwrap(); + let store = MetadataStore::new(temporary.path()).unwrap(); + let path = temporary.path().join("host.version.json"); + for bytes in [ + b"not-json".as_slice(), + b"\xff".as_slice(), + br#"{"format_version":2,"key":"host.version","value":"old"}"#.as_slice(), + br#"{"format_version":1,"key":"wrong","value":"old"}"#.as_slice(), + ] { + std::fs::write(&path, bytes).unwrap(); + assert!(store.get("host.version").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + } +} + +#[test] +fn concurrent_replacements_expose_complete_records_and_preserve_other_keys() { + let temporary = tempfile::tempdir().unwrap(); + let store = MetadataStore::new(temporary.path()).unwrap(); + store.put("independent", "keep").unwrap(); + let a = "a".repeat(8192); + let b = "b".repeat(8192); + store.put("shared", &a).unwrap(); + std::thread::scope(|scope| { + for value in [&a, &b] { + let store = &store; + scope.spawn(move || { + for _ in 0..8 { + store.put("shared", value).unwrap(); + } + }); + } + for _ in 0..64 { + let value = store.get("shared").unwrap().unwrap(); + assert!(value == a || value == b); + } + }); + assert_eq!(store.get("independent").unwrap().as_deref(), Some("keep")); + assert!(std::fs::read_dir(temporary.path()).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .ends_with(".tmp") + })); +} + +#[test] +fn legacy_import_is_repeatable_and_preserves_newer_file_values() { + let temporary = tempfile::tempdir().unwrap(); + let store = MetadataStore::new(temporary.path()).unwrap(); + store.put("host.version", "new").unwrap(); + let input = + br#"[{"key":"host.version","value":"old"},{"key":"project.note","value":"legacy"}]"#; + assert_eq!(store.import_missing(input.as_slice()).unwrap(), 1); + assert_eq!(store.import_missing(input.as_slice()).unwrap(), 0); + assert_eq!(store.get("host.version").unwrap().as_deref(), Some("new")); + assert_eq!( + store.get("project.note").unwrap().as_deref(), + Some("legacy") + ); +} + +#[test] +fn invalid_import_keys_are_rejected_before_any_record_is_written() { + let temporary = tempfile::tempdir().unwrap(); + let directory = temporary.path().join("state"); + let store = MetadataStore::new(&directory).unwrap(); + let input = br#"[{"key":"valid","value":"keep"},{"key":"../outside","value":"invalid"}]"#; + assert!(store.import_missing(input.as_slice()).is_err()); + assert!(!directory.exists()); +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 0e0cf69..749cb40 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true [dependencies] core.workspace = true -storage.workspace = true +host.workspace = true axum.workspace = true serde.workspace = true tokio.workspace = true diff --git a/crates/server/README.md b/crates/server/README.md index b881ef4..4cab163 100644 --- a/crates/server/README.md +++ b/crates/server/README.md @@ -1,10 +1,10 @@ # server -最小常驻 HTTP 宿主,提供数据库健康检查和只读 VM 引导信息。它通过 Arc 共享 Vm,没有语言执行接口或可变 VM 请求上下文。 +最小常驻 HTTP 宿主,提供存活检查和只读 VM 信息。通过 Arc 共享 Vm,启动不读取数据库配置、不连接或写入数据库;通过 host 包把 host.version 写入文件。特殊类的数据库能力尚未接入 HTTP 宿主。 | 内容 | 职责 | | --- | --- | -| Cargo.toml | core/storage、Axum、Tokio、Serde 依赖 | -| [src](src/README.md) | 启动顺序、AppState、路由、信号与退出 | +| Cargo.toml | core/host、Axum、Tokio、Serde 依赖;无 storage 依赖 | +| [src](src/README.md) | AppState、启动、路由、信号与退出 | -HTTP 返回内容和运行方式见 [运行指南](../../docs/operations/README.md),变量见 [配置](../../docs/operations/configuration.md),生产端口/数据库见 [部署](../../docs/operations/deployment.md)。返回 [工作区](../README.md)。 +HTTP 返回内容见 [运行指南](../../docs/operations/README.md),监听变量见 [配置](../../docs/operations/configuration.md),预置 PostgreSQL 资源见 [部署](../../docs/operations/deployment.md)。没有语言执行接口或可变 VM 请求上下文。返回 [工作区](../README.md)。 diff --git a/crates/server/src/README.md b/crates/server/src/README.md index 7d74642..0e1fe20 100644 --- a/crates/server/src/README.md +++ b/crates/server/src/README.md @@ -1,14 +1,13 @@ # Server 入口 -[main.rs](main.rs) 包含当前所有宿主启动代码: +[main.rs](main.rs) 包含所有宿主启动代码: | 部分 | 职责 | | --- | --- | -| AppState | 共享只读 Arc 与可克隆的 Storage 连接池 | -| main/run | 创建 VM、连接数据库、写 host.version、绑定地址、组装路由、服务结束后关闭存储 | -| health | ping 数据库,返回 200/503 及后端标签 | +| AppState | 共享只读 Arc | +| main/run | 创建空 VM、绑定地址、设置退出信号、写文件 host.version、组装路由;不创建数据库连接 | +| health | 返回 200 和 status=ok,仅检查 HTTP 宿主可响应 | | bootstrap | 返回对象模型、type_system=false 和堆对象数量,不分配对象 | | shutdown_signal | Ctrl-C;Unix 上同时接受 SIGTERM,触发 Axum 优雅退出 | -| tests | 关闭数据库池后健康状态变为 unavailable | -改变端点时同步 [运行指南](../../../docs/operations/README.md) 与 [smoke.py](../../../scripts/README.md);改变启动变量时同步 [配置](../../../docs/operations/configuration.md)。网络、信号和数据库逻辑不进入 Core。返回 [server](../README.md)。 +/health 不表示 PostgreSQL、特殊类查询或执行脚本正常;数据库检查应属于实际消费连接的对象。宿主独立于数据库的行为由远端 smoke.py 验证,见 [验证](../../../docs/operations/validation.md)。改变端点同步 [运行指南](../../../docs/operations/README.md) 和 [scripts](../../../scripts/README.md)。返回 [server](../README.md)。 diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 63db08d..76d0612 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -3,21 +3,19 @@ use std::future::Future; use std::process::ExitCode; use std::sync::Arc; -use axum::{Json, Router, extract::State, http::StatusCode, routing::get}; +use axum::{Json, Router, extract::State, routing::get}; use endor_core::Vm; +use host::MetadataStore; use serde::Serialize; -use storage::{Storage, StorageConfig}; #[derive(Clone)] struct AppState { vm: Arc, - storage: Storage, } #[derive(Serialize)] struct Health { status: &'static str, - storage: &'static str, } #[derive(Serialize)] @@ -40,44 +38,23 @@ async fn main() -> ExitCode { async fn run() -> Result<(), Box> { let vm = Arc::new(Vm::new()?); - let storage = Storage::connect(&StorageConfig::from_env()?).await?; - storage - .put("host.version", env!("CARGO_PKG_VERSION")) - .await?; let address = std::env::var("ASTER_LISTEN").unwrap_or_else(|_| "127.0.0.1:8080".into()); let listener = tokio::net::TcpListener::bind(&address).await?; let shutdown = shutdown_signal()?; + MetadataStore::from_env()?.put("host.version", env!("CARGO_PKG_VERSION"))?; let app = Router::new() .route("/health", get(health)) .route("/bootstrap", get(bootstrap)) - .with_state(AppState { - vm, - storage: storage.clone(), - }); - println!( - "Endor host listening on {} (storage={})", - listener.local_addr()?, - storage.backend_name() - ); + .with_state(AppState { vm }); + println!("Endor host listening on {}", listener.local_addr()?); axum::serve(listener, app) .with_graceful_shutdown(shutdown) .await?; - storage.close().await; Ok(()) } -async fn health(State(state): State) -> (StatusCode, Json) { - let (code, status) = match state.storage.ping().await { - Ok(()) => (StatusCode::OK, "ok"), - Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "storage_unavailable"), - }; - ( - code, - Json(Health { - status, - storage: state.storage.backend_name(), - }), - ) +async fn health() -> Json { + Json(Health { status: "ok" }) } async fn bootstrap(State(state): State) -> Json { @@ -98,25 +75,3 @@ fn shutdown_signal() -> std::io::Result> { let _ = tokio::signal::ctrl_c().await; }) } - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn health_reports_unavailable_after_storage_closes() { - let storage = Storage::connect(&StorageConfig::SqliteMemory) - .await - .unwrap(); - let state = AppState { - vm: Arc::new(Vm::new().unwrap()), - storage, - }; - let (status, _) = health(State(state.clone())).await; - assert_eq!(status, StatusCode::OK); - state.storage.close().await; - let (status, Json(body)) = health(State(state)).await; - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(body.status, "storage_unavailable"); - } -} diff --git a/crates/storage/README.md b/crates/storage/README.md index 07adb6a..951b2e5 100644 --- a/crates/storage/README.md +++ b/crates/storage/README.md @@ -1,11 +1,11 @@ # storage -宿主元数据存储,依赖 SQLx/Tokio,不依赖 VM 内核。一个 Storage 选择 SQLite 或 PostgreSQL;明确配置 PostgreSQL 后失败不回退。 +按需数据库连接适配层,供将来需要数据库的特殊类或宿主适配器使用。依赖 SQLx/Tokio,不依赖 VM 内核;普通对象、CLI 和 Server 不连接这层。 | 内容 | 入口 | | --- | --- | -| Cargo.toml | SQLx、Tokio 和检查所需依赖 | -| [src](src/README.md) | 配置解析、连接池、元数据表和操作 | -| [tests](tests/README.md) | 配置、持久化、隔离与专用 PostgreSQL 检查 | +| Cargo.toml | SQLx、Tokio 与远端检查依赖 | +| [src](src/README.md) | 显式 DatabaseConfig、连接池与关闭 | +| [tests](tests/README.md) | 连接不建表、调用方 SQL、隔离/持久化、真实 PostgreSQL | -Storage 仅保存 key/value 元数据,不保存 EndorObject 或执行帧。运行与连接规则见 [运行指南](../../docs/operations/README.md),环境优先级与错误规则见 [配置](../../docs/operations/configuration.md),生产数据与迁移边界见 [部署](../../docs/operations/deployment.md)。返回 [工作区](../README.md)。 +没有默认数据库、自动环境变量读取、基础信息表或通用元数据 put/get。特殊类的名称、布局与查询接口尚未定义;当前只保留连接能力。设计边界见 [数据库对象](../../docs/runtime/database-objects.md),参数见 [配置](../../docs/operations/configuration.md)。返回 [工作区](../README.md)。 diff --git a/crates/storage/src/README.md b/crates/storage/src/README.md index 230c666..124bb60 100644 --- a/crates/storage/src/README.md +++ b/crates/storage/src/README.md @@ -1,10 +1,7 @@ -# Storage 源码导航 +# 数据库连接源码导航 -| 文件 | 职责 | -| --- | --- | -| [lib.rs](lib.rs) | StorageConfig::from_values/from_env、StorageError、SQLite/PostgreSQL 连接池、schema 初始化、ping/put/get/close | -| [schema.sql](schema.sql) | 当前 aster_metadata 文本 key/value 表,幂等建表 | +[lib.rs](lib.rs) 是唯一实现文件:DatabaseConfig 显式选择 SQLite 文件/内存或 PostgreSQL;DatabaseConnection 提供 connect、sqlite_pool/postgres_pool、backend_name、ping 和 close;DatabaseError 对公开错误脱敏。 -配置改动同步 [配置表](../../../docs/operations/configuration.md) 与 storage 测试。schema 改动须考虑已有数据库,当前没有版本化迁移;不得将 CREATE IF NOT EXISTS 当作已有结构升级。Storage 克隆共享连接池,close 影响所有克隆。 +connect 只建立连接池,不执行应用 DDL,不保存基础信息。没有 schema.sql、初始化表、put/get 或 from_env。业务表和查询由未来的数据库特殊类决定;本层不替它定义数据模型。 -连接与持久化契约见 [运行指南](../../../docs/operations/README.md),测试定位见 [tests](../tests/README.md)。返回 [storage](../README.md)。 +克隆共享连接池,close 会关闭所有克隆;具体生命周期及限制见 [数据库对象](../../../docs/runtime/database-objects.md)。配置变动同步 [配置](../../../docs/operations/configuration.md) 和 [tests](../tests/README.md)。返回 [storage](../README.md)。 diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 2346062..774040c 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,4 +1,5 @@ -//! Host metadata storage; deliberately has no dependency on core. +//! Explicit database connections for database-backed objects. +//! Connecting never creates application tables or stores VM/host metadata. use std::fmt; use std::path::PathBuf; use std::str::FromStr; @@ -8,17 +9,15 @@ use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; use sqlx::{PgPool, SqlitePool}; -const SCHEMA: &str = include_str!("schema.sql"); - /// An explicit PostgreSQL configuration never falls back to SQLite. #[derive(Clone)] -pub enum StorageConfig { +pub enum DatabaseConfig { SqliteFile(PathBuf), SqliteMemory, Postgres(String), } -impl fmt::Debug for StorageConfig { +impl fmt::Debug for DatabaseConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::SqliteFile(path) => f.debug_tuple("SqliteFile").field(path).finish(), @@ -28,57 +27,14 @@ impl fmt::Debug for StorageConfig { } } -impl StorageConfig { - /// DATABASE_URL accepts PostgreSQL only; SQLITE_PATH selects file or :memory:. - /// Empty/invalid explicit values are errors rather than default selection. - pub fn from_values( - database_url: Option<&str>, - sqlite_path: Option<&str>, - ) -> Result { - if let Some(url) = database_url { - if !(url.starts_with("postgres://") || url.starts_with("postgresql://")) { - return Err(StorageError::Config( - "DATABASE_URL must be a PostgreSQL URL", - )); - } - return Ok(Self::Postgres(url.into())); - } - match sqlite_path.unwrap_or("data/aster.db") { - "" => Err(StorageError::Config("SQLITE_PATH cannot be empty")), - ":memory:" => Ok(Self::SqliteMemory), - path => Ok(Self::SqliteFile(path.into())), - } - } - - pub fn from_env() -> Result { - let database_url = read_env("DATABASE_URL")?; - // Irrelevant SQLite settings must not prevent PostgreSQL configuration. - if database_url.is_some() { - return Self::from_values(database_url.as_deref(), None); - } - let sqlite_path = read_env("SQLITE_PATH")?; - Self::from_values(None, sqlite_path.as_deref()) - } -} - -fn read_env(name: &str) -> Result, StorageError> { - match std::env::var(name) { - Ok(value) => Ok(Some(value)), - Err(std::env::VarError::NotPresent) => Ok(None), - Err(std::env::VarError::NotUnicode(_)) => Err(StorageError::Config( - "storage environment must be valid Unicode", - )), - } -} - #[derive(Debug)] -pub enum StorageError { +pub enum DatabaseError { Config(&'static str), Directory(std::io::Error), Database(sqlx::Error), } -impl fmt::Display for StorageError { +impl fmt::Display for DatabaseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Config(message) => f.write_str(message), @@ -91,7 +47,7 @@ impl fmt::Display for StorageError { } } -impl std::error::Error for StorageError { +impl std::error::Error for DatabaseError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Config(_) => None, @@ -101,7 +57,7 @@ impl std::error::Error for StorageError { } } -impl From for StorageError { +impl From for DatabaseError { fn from(error: sqlx::Error) -> Self { Self::Database(error) } @@ -113,20 +69,26 @@ enum Backend { Postgres(PgPool), } -/// Clones share a pool. A memory SQLite store uses exactly one connection, so -/// initialization and every operation see the same database for its lifetime. +/// Created only on explicit request by an object or host adapter. Clones share +/// a pool; close() closes it for all users. There is no automatic schema or data. +/// In-memory SQLite uses one connection to preserve its database lifetime. #[derive(Clone)] -pub struct Storage { +pub struct DatabaseConnection { backend: Backend, } -impl Storage { - /// Connects eagerly and initializes the v1 metadata schema before returning. - pub async fn connect(config: &StorageConfig) -> Result { +impl DatabaseConnection { + /// Connects eagerly to the explicit backend without executing application DDL. + pub async fn connect(config: &DatabaseConfig) -> Result { let backend = match config { - StorageConfig::Postgres(url) => { + DatabaseConfig::Postgres(url) => { + if !(url.starts_with("postgres://") || url.starts_with("postgresql://")) { + return Err(DatabaseError::Config( + "expected a PostgreSQL connection URL", + )); + } let options = PgConnectOptions::from_str(url) - .map_err(|_| StorageError::Config("invalid PostgreSQL connection URL"))?; + .map_err(|_| DatabaseError::Config("invalid PostgreSQL connection URL"))?; Backend::Postgres( PgPoolOptions::new() .max_connections(5) @@ -135,16 +97,16 @@ impl Storage { .await?, ) } - StorageConfig::SqliteFile(path) => { + DatabaseConfig::SqliteFile(path) => { if path.as_os_str().is_empty() || path == std::path::Path::new(":memory:") { - return Err(StorageError::Config( - "use SqliteMemory for an in-memory database", + return Err(DatabaseError::Config( + "expected a nonempty file path; use SqliteMemory for :memory:", )); } if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { tokio::fs::create_dir_all(parent) .await - .map_err(StorageError::Directory)?; + .map_err(DatabaseError::Directory)?; } let options = SqliteConnectOptions::new() .filename(path) @@ -159,7 +121,7 @@ impl Storage { .await?, ) } - StorageConfig::SqliteMemory => { + DatabaseConfig::SqliteMemory => { let options = SqliteConnectOptions::new().in_memory(true); Backend::Sqlite( SqlitePoolOptions::new() @@ -172,21 +134,22 @@ impl Storage { ) } }; - let storage = Self { backend }; - storage.initialize().await?; - Ok(storage) + Ok(Self { backend }) } - async fn initialize(&self) -> Result<(), StorageError> { + /// Backend-specific access for the database-backed object implementation. + pub fn sqlite_pool(&self) -> Option<&SqlitePool> { match &self.backend { - Backend::Sqlite(pool) => { - sqlx::query(SCHEMA).execute(pool).await?; - } - Backend::Postgres(pool) => { - sqlx::query(SCHEMA).execute(pool).await?; - } + Backend::Sqlite(pool) => Some(pool), + Backend::Postgres(_) => None, + } + } + + pub fn postgres_pool(&self) -> Option<&PgPool> { + match &self.backend { + Backend::Postgres(pool) => Some(pool), + Backend::Sqlite(_) => None, } - Ok(()) } pub fn backend_name(&self) -> &'static str { @@ -196,7 +159,7 @@ impl Storage { } } - pub async fn ping(&self) -> Result<(), StorageError> { + pub async fn ping(&self) -> Result<(), DatabaseError> { match &self.backend { Backend::Sqlite(pool) => { sqlx::query("SELECT 1").execute(pool).await?; @@ -208,38 +171,7 @@ impl Storage { Ok(()) } - pub async fn put(&self, key: &str, value: &str) -> Result<(), StorageError> { - match &self.backend { - Backend::Sqlite(pool) => { - sqlx::query("INSERT INTO aster_metadata (key, value) VALUES (?1, ?2) ON CONFLICT (key) DO UPDATE SET value = excluded.value") - .bind(key).bind(value).execute(pool).await?; - } - Backend::Postgres(pool) => { - sqlx::query("INSERT INTO aster_metadata (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = excluded.value") - .bind(key).bind(value).execute(pool).await?; - } - } - Ok(()) - } - - pub async fn get(&self, key: &str) -> Result, StorageError> { - Ok(match &self.backend { - Backend::Sqlite(pool) => { - sqlx::query_scalar("SELECT value FROM aster_metadata WHERE key = ?1") - .bind(key) - .fetch_optional(pool) - .await? - } - Backend::Postgres(pool) => { - sqlx::query_scalar("SELECT value FROM aster_metadata WHERE key = $1") - .bind(key) - .fetch_optional(pool) - .await? - } - }) - } - - /// Explicitly closes the shared pool, including all Storage clones. + /// Explicitly closes the shared pool, including all DatabaseConnection clones. pub async fn close(&self) { match &self.backend { Backend::Sqlite(pool) => pool.close().await, diff --git a/crates/storage/src/schema.sql b/crates/storage/src/schema.sql deleted file mode 100644 index cd6b39b..0000000 --- a/crates/storage/src/schema.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE IF NOT EXISTS aster_metadata ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT NOT NULL -) diff --git a/crates/storage/tests/README.md b/crates/storage/tests/README.md index 69ee7d1..66e866f 100644 --- a/crates/storage/tests/README.md +++ b/crates/storage/tests/README.md @@ -1,8 +1,8 @@ -# Storage 行为检查 +# 数据库连接检查 | 文件 | 覆盖与前提 | | --- | --- | -| [storage.rs](storage.rs) | 配置默认值/优先级/错误、内存共享与实例隔离、临时文件重连、目录错误;不处理 PostgreSQL 协议的 socket 仅检查连接超时和不降级 | -| [postgres.rs](postgres.rs) | 真实 PostgreSQL 写入/覆盖/缺失键/重连;默认 ignore,显式运行需 ASTER_TEST_POSTGRES_URL 指向专用测试库 | +| [storage.rs](storage.rs) | 显式错误配置、凭据脱敏、SQLite 连接后无应用表、克隆共享与独立内存隔离、调用方自建数据跨文件重连、目录错误、PostgreSQL 失败不降级 | +| [postgres.rs](postgres.rs) | 真实连接后 public 无表;测试在事务中创建临时 class_rows、参数绑定和读取,提交自动删除临时表,再连接;默认 ignore,需全新专用 ASTER_TEST_POSTGRES_URL | -SQLite 文件检查使用临时目录;PostgreSQL 成功路径不使用生产数据库。ces-control 脚本提供一次性的测试数据库,见 [验证](../../../docs/operations/validation.md) 和 [scripts](../../../scripts/README.md)。不在本地运行或为测试下载 SQLite 依赖。返回 [storage](../README.md)。 +所有业务表都由测试自己创建,连接层不得自动初始化基础信息表。临时 SQLite 文件和 PostgreSQL 测试库由远端 CI 隔离,不使用生产数据。检查命令见 [验证](../../../docs/operations/validation.md),原生连接能力边界见 [数据库对象](../../../docs/runtime/database-objects.md)。本地不运行测试或下载依赖。返回 [storage](../README.md)。 diff --git a/crates/storage/tests/postgres.rs b/crates/storage/tests/postgres.rs index 06c7dce..65135aa 100644 --- a/crates/storage/tests/postgres.rs +++ b/crates/storage/tests/postgres.rs @@ -1,35 +1,53 @@ -use storage::{Storage, StorageConfig}; +use storage::{DatabaseConfig, DatabaseConnection}; -/// Run explicitly against a disposable database; never silently skip a missing URL. +/// Run against a fresh disposable database, never a production or shared schema. #[tokio::test] -#[ignore = "requires ASTER_TEST_POSTGRES_URL pointing to a test PostgreSQL database"] -async fn postgres_roundtrip_and_reconnect() { +#[ignore = "requires ASTER_TEST_POSTGRES_URL pointing to a fresh disposable PostgreSQL database"] +async fn postgres_connects_without_schema_and_executes_caller_owned_sql() { let url = std::env::var("ASTER_TEST_POSTGRES_URL") - .expect("set ASTER_TEST_POSTGRES_URL to a disposable PostgreSQL database"); - let config = StorageConfig::from_values(Some(&url), None).unwrap(); - let store = Storage::connect(&config).await.unwrap(); - assert_eq!(store.backend_name(), "postgresql"); - store.ping().await.unwrap(); - let key = format!( - "test.{}.{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - assert_eq!(store.get(&key).await.unwrap(), None); - store.put(&key, "你好 ' ; $1").await.unwrap(); - assert_eq!( - store.get(&key).await.unwrap().as_deref(), - Some("你好 ' ; $1") - ); - store.put(&key, "updated").await.unwrap(); - store.close().await; - let reopened = Storage::connect(&config).await.unwrap(); - assert_eq!( - reopened.get(&key).await.unwrap().as_deref(), - Some("updated") - ); + .expect("set ASTER_TEST_POSTGRES_URL to a fresh disposable PostgreSQL database"); + let config = DatabaseConfig::Postgres(url); + let database = DatabaseConnection::connect(&config).await.unwrap(); + assert_eq!(database.backend_name(), "postgresql"); + assert!(database.sqlite_pool().is_none()); + database.ping().await.unwrap(); + let pool = database.postgres_pool().unwrap(); + let tables: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'", + ) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(tables, 0, "connecting must not create application tables"); + let mut transaction = pool.begin().await.unwrap(); + sqlx::query( + "CREATE TEMP TABLE class_rows (id INTEGER PRIMARY KEY, value TEXT NOT NULL) ON COMMIT DROP", + ) + .execute(&mut *transaction) + .await + .unwrap(); + sqlx::query("INSERT INTO class_rows (id, value) VALUES ($1, $2)") + .bind(1_i64) + .bind("你好 ' ; $1") + .execute(&mut *transaction) + .await + .unwrap(); + let value: String = sqlx::query_scalar("SELECT value FROM class_rows WHERE id = $1") + .bind(1_i64) + .fetch_one(&mut *transaction) + .await + .unwrap(); + assert_eq!(value, "你好 ' ; $1"); + transaction.commit().await.unwrap(); + database.close().await; + let reopened = DatabaseConnection::connect(&config).await.unwrap(); + reopened.ping().await.unwrap(); + let tables: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'", + ) + .fetch_one(reopened.postgres_pool().unwrap()) + .await + .unwrap(); + assert_eq!(tables, 0); reopened.close().await; } diff --git a/crates/storage/tests/storage.rs b/crates/storage/tests/storage.rs index 39fb08a..151e091 100644 --- a/crates/storage/tests/storage.rs +++ b/crates/storage/tests/storage.rs @@ -1,69 +1,104 @@ -use storage::{Storage, StorageConfig}; +use storage::{DatabaseConfig, DatabaseConnection}; #[test] -fn configuration_defaults_precedence_and_errors() { - assert!( - matches!(StorageConfig::from_values(None, None).unwrap(), StorageConfig::SqliteFile(p) if p.to_str() == Some("data/aster.db")) - ); - assert!(matches!( - StorageConfig::from_values(None, Some(":memory:")).unwrap(), - StorageConfig::SqliteMemory - )); - assert!(matches!( - StorageConfig::from_values(Some("postgresql://localhost/aster"), Some("")).unwrap(), - StorageConfig::Postgres(_) - )); - assert!(StorageConfig::from_values(Some(""), None).is_err()); - assert!(StorageConfig::from_values(Some("mysql://localhost/aster"), None).is_err()); - assert!(StorageConfig::from_values(None, Some("")).is_err()); - let config = StorageConfig::Postgres("postgres://user:secret@localhost/db".into()); +fn postgres_configuration_debug_redacts_credentials() { + let config = DatabaseConfig::Postgres("postgres://user:secret@localhost/db".into()); assert!(!format!("{config:?}").contains("secret")); } #[tokio::test] -async fn memory_store_is_shared_by_clones_but_isolated_between_stores() { - let store = Storage::connect(&StorageConfig::SqliteMemory) +async fn explicit_invalid_backends_fail_without_fallback() { + for config in [ + DatabaseConfig::Postgres(String::new()), + DatabaseConfig::Postgres("mysql://localhost/db".into()), + DatabaseConfig::SqliteFile("".into()), + DatabaseConfig::SqliteFile(":memory:".into()), + ] { + assert!(DatabaseConnection::connect(&config).await.is_err()); + } +} + +#[tokio::test] +async fn memory_connections_start_without_tables_and_clones_share_the_pool() { + let database = DatabaseConnection::connect(&DatabaseConfig::SqliteMemory) .await .unwrap(); - let other = Storage::connect(&StorageConfig::SqliteMemory) + let other = DatabaseConnection::connect(&DatabaseConfig::SqliteMemory) .await .unwrap(); - store.ping().await.unwrap(); - assert_eq!(store.get("missing").await.unwrap(), None); - store.put("message", "你好 ' ? $1 ;").await.unwrap(); - assert_eq!( - store.clone().get("message").await.unwrap().as_deref(), - Some("你好 ' ? $1 ;") - ); - assert_eq!(other.get("message").await.unwrap(), None); - store.put("message", "updated").await.unwrap(); - assert_eq!( - store.get("message").await.unwrap().as_deref(), - Some("updated") - ); - store.close().await; + assert!(database.postgres_pool().is_none()); + let pool = database.sqlite_pool().unwrap(); + let tables: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(tables, 0, "connecting must not create application tables"); + // This schema belongs to the test's simulated database-backed object. + sqlx::query("CREATE TABLE class_rows (id INTEGER PRIMARY KEY, value TEXT NOT NULL)") + .execute(pool) + .await + .unwrap(); + sqlx::query("INSERT INTO class_rows (id, value) VALUES (?, ?)") + .bind(1_i64) + .bind("你好 ' ? $1 ;") + .execute(pool) + .await + .unwrap(); + let cloned = database.clone(); + let value: String = sqlx::query_scalar("SELECT value FROM class_rows WHERE id = ?") + .bind(1_i64) + .fetch_one(cloned.sqlite_pool().unwrap()) + .await + .unwrap(); + assert_eq!(value, "你好 ' ? $1 ;"); + let other_tables: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'") + .fetch_one(other.sqlite_pool().unwrap()) + .await + .unwrap(); + assert_eq!(other_tables, 0); + database.close().await; + assert!(cloned.ping().await.is_err()); other.close().await; } #[tokio::test] -async fn sqlite_file_creates_parents_and_survives_reopen() { +async fn explicit_sqlite_file_preserves_only_caller_created_data_across_reconnects() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("nested/data/metadata.db"); - let config = StorageConfig::SqliteFile(path.clone()); - let store = Storage::connect(&config).await.unwrap(); - store.put("project", "Aster VM").await.unwrap(); - store.close().await; + let path = dir.path().join("nested/data/class.db"); + let config = DatabaseConfig::SqliteFile(path.clone()); + let database = DatabaseConnection::connect(&config).await.unwrap(); + let pool = database.sqlite_pool().unwrap(); + let tables: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(tables, 0); + sqlx::query("CREATE TABLE class_rows (id INTEGER PRIMARY KEY, value TEXT NOT NULL)") + .execute(pool) + .await + .unwrap(); + sqlx::query("INSERT INTO class_rows (id, value) VALUES (?, ?)") + .bind(1_i64) + .bind("caller-owned data") + .execute(pool) + .await + .unwrap(); + database.close().await; assert!(path.is_file()); - let reopened = Storage::connect(&config).await.unwrap(); - assert_eq!( - reopened.get("project").await.unwrap().as_deref(), - Some("Aster VM") - ); - reopened.put("project", "v2").await.unwrap(); - assert_eq!( - reopened.get("project").await.unwrap().as_deref(), - Some("v2") - ); + let reopened = DatabaseConnection::connect(&config).await.unwrap(); + let value: String = sqlx::query_scalar("SELECT value FROM class_rows WHERE id = ?") + .bind(1_i64) + .fetch_one(reopened.sqlite_pool().unwrap()) + .await + .unwrap(); + assert_eq!(value, "caller-owned data"); + let tables: Vec = + sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table'") + .fetch_all(reopened.sqlite_pool().unwrap()) + .await + .unwrap(); + assert_eq!(tables, vec!["class_rows"]); reopened.close().await; } @@ -73,7 +108,7 @@ async fn invalid_sqlite_directory_reports_error() { let file = dir.path().join("file"); std::fs::write(&file, "occupied").unwrap(); assert!( - Storage::connect(&StorageConfig::SqliteFile(file.join("aster.db"))) + DatabaseConnection::connect(&DatabaseConfig::SqliteFile(file.join("class.db"))) .await .is_err() ); @@ -81,14 +116,12 @@ async fn invalid_sqlite_directory_reports_error() { #[tokio::test] async fn postgres_connection_failure_is_an_error() { - // Hold a TCP port without serving PostgreSQL: deterministic local failure. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!( "postgres://aster:secret@{}/aster?sslmode=disable", listener.local_addr().unwrap() ); - let result = Storage::connect(&StorageConfig::Postgres(url)).await; - let error = match result { + let error = match DatabaseConnection::connect(&DatabaseConfig::Postgres(url)).await { Ok(_) => panic!("unreachable PostgreSQL must fail"), Err(error) => error, }; diff --git a/docker-compose.ces-control.yml b/docker-compose.ces-control.yml index 9182f49..c881c8d 100644 --- a/docker-compose.ces-control.yml +++ b/docker-compose.ces-control.yml @@ -8,10 +8,9 @@ services: - "127.0.0.1:${ASTER_PORT:-18081}:8080" environment: ASTER_LISTEN: 0.0.0.0:8080 - DATABASE_URL: "postgres://aster:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/aster?sslmode=disable" - depends_on: - postgres: - condition: service_healthy + ASTER_STATE_DIR: /data/host + volumes: + - /var/lib/endorphin-aster-vm/host:/data/host restart: unless-stopped stop_grace_period: 20s logging: diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml index e806fa2..4d3acc8 100644 --- a/docker-compose.postgres.yml +++ b/docker-compose.postgres.yml @@ -6,10 +6,9 @@ services: - "127.0.0.1:${ASTER_PORT:-8080}:8080" environment: ASTER_LISTEN: 0.0.0.0:8080 - DATABASE_URL: "postgres://aster:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/aster?sslmode=disable" - depends_on: - postgres: - condition: service_healthy + ASTER_STATE_DIR: /data/host + volumes: + - aster-host:/data/host restart: unless-stopped postgres: @@ -28,4 +27,5 @@ services: restart: unless-stopped volumes: + aster-host: aster-postgres: diff --git a/docker-compose.yml b/docker-compose.yml index 2802930..2f84f5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: - "127.0.0.1:${ASTER_PORT:-8080}:8080" environment: ASTER_LISTEN: 0.0.0.0:8080 - SQLITE_PATH: /data/aster.db + ASTER_STATE_DIR: /data/host volumes: - aster-sqlite:/data restart: unless-stopped diff --git a/docs/README.md b/docs/README.md index 3e8e0af..a168d23 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,13 +6,16 @@ | 想知道什么 | 权威说明 | 代码入口 | | --- | --- | --- | -| 四个 crate 如何分层,哪些能力已实现 | [架构](architecture/README.md) | [crates](../crates/README.md) | +| 各个 crate 如何分层,哪些能力已实现 | [架构](architecture/README.md) | [crates](../crates/README.md) | | EndorObject、对象头、句柄、默认操作 | [对象模型](runtime/objects.md) | [core/src](../crates/core/src/README.md) | | Number、String、Dict、Cell、Function、异常数据 | [基本对象](runtime/builtins.md) | [core/src](../crates/core/src/README.md) | | 操作码、代码验证、局部槽、调用协议 | [字节码 VM](runtime/bytecode.md) | instruction.rs / code.rs / frame.rs / eval.rs;见 [文件表](../crates/core/src/README.md) | +| 基础信息保存在哪里、旧库如何迁移 | [宿主文件信息](operations/host-information.md) | [host](../crates/host/README.md) | +| UTF-8 在日本地区是否适用,其他编码怎样进入 VM | [文本编码](runtime/text-encoding.md) | [core](../crates/core/README.md)、[host](../crates/host/README.md) | +| 数据库连接给谁用,是否自动建表或保存基础信息 | [数据库特殊类](runtime/database-objects.md) | [storage](../crates/storage/README.md) | | VM、对象与执行帧何时创建/释放,循环如何回收 | [生命周期](runtime/lifecycle.md) | heap.rs / eval.rs;见 [文件表](../crates/core/src/README.md) | -| LC_CTYPE、UTF-8、环境变量和执行限额 | [配置](operations/configuration.md) | [core](../crates/core/README.md)、[storage](../crates/storage/README.md)、[server](../crates/server/README.md) | -| CLI/HTTP 怎么运行,存储保存什么 | [运行](operations/README.md) | [cli](../crates/cli/README.md)、[server](../crates/server/README.md) | +| LC_CTYPE、UTF-8、显式数据库配置和执行限额 | [配置](operations/configuration.md) | [core](../crates/core/README.md)、[storage](../crates/storage/README.md)、[server](../crates/server/README.md) | +| CLI/HTTP 怎么运行,数据库在哪里使用 | [运行](operations/README.md) | [cli](../crates/cli/README.md)、[server](../crates/server/README.md) | | Docker、PostgreSQL、ces-control、回退与数据目录 | [部署](operations/deployment.md) | [scripts](../scripts/README.md)、[工作流](../.gitea/workflows/README.md) | | 检查覆盖什么,结果如何确认 | [验证](operations/validation.md) | [core/tests](../crates/core/tests/README.md)、[storage/tests](../crates/storage/tests/README.md) | | 改代码后文档写在哪里、怎样避免失效 | [维护规则](contributing/README.md) | 各目录 README 与本索引 | @@ -26,7 +29,7 @@ | [operations](operations/README.md) | 运行、配置、部署和验证 | | [contributing](contributing/README.md) | 开发定位方法与随代码更新文档的规则 | -原 outputs/bytecode-vm.md 与 endor-builtins-design.md 已核对并融合到 runtime 下的对应主题;不再在 outputs 维护独立版本。原 docs 平铺文档已迁入这些主题。过时启动模型、旧测试通过数量和机器资源快照已移除;历史内容需要时查 Git 历史,不混入当前行为说明。 +旧 outputs 的设计正文已融合到 runtime,重复入口和 outputs 目录已移除;CPython 研究与早期报告保存在本地忽略的 work/research,不纳入 Git。原 docs 平铺文档已迁入这些主题。过时启动模型、旧测试通过数量和机器资源快照已移除;历史内容需要时查 Git 历史,不混入当前行为说明。 ## 仓库内查询 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 8b9b437..c4bd01d 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,44 +1,43 @@ # 系统架构 -Endor 的目标是云原生语言;本仓库当前实现运行时和宿主演示,尚未提供源码编译器、云调度器或多租户执行沙箱。云原生方向在当前阶段落实为内核与进程配置分离、可预测的文本/数字表示、明确的数据持久化边界。 +Endor 的目标是云原生语言;本仓库当前实现运行时和宿主演示,尚无源码编译器、云调度器或多租户执行沙箱。当前侧重核心语义独立于进程环境,以及数据库能力按对象需求显式提供。 ## 组件与依赖方向 ```text -CLI ────→ Core - └─────→ Storage -Server ─→ Core - └─────→ Storage +CLI ────→ Core + Host +Server ─→ Core + Host +未来数据库特殊类的宿主实现 ─→ Storage ``` -Core 与 Storage 互不依赖。HTTP、Tokio、SQLx、数据库、网络和后续浏览器能力属于宿主,不进入零外部依赖的 core。源码包导航见 [crates](../../crates/README.md)。 +CLI、Server 依赖 Host 保存文件信息,不依赖 Storage;Core 与 Storage 也互不依赖。HTTP、Tokio、SQLx、数据库、网络和浏览器能力不进入零依赖的 core。包与文件定位见 [crates](../../crates/README.md)。 | 组件 | 当前职责 | 全局契约 | | --- | --- | --- | | Core | EndorObject、对象堆、Number、wordcode、执行帧、求值器 | [运行时](../runtime/README.md) | -| Storage | 一个确定后端上的宿主元数据读写 | [运行与存储](../operations/README.md) | -| CLI | 对象引导示例、元数据 put/get | [CLI](../../crates/cli/README.md) | -| Server | 数据库健康检查、只读运行时信息、进程退出 | [Server](../../crates/server/README.md) | -| Gitea/Docker | 远端验证、镜像构建、PostgreSQL 持久部署 | [部署](../operations/deployment.md) | +| Host | 基础信息的逐键 UTF-8 JSON 文件与旧库导入 | [宿主信息](../operations/host-information.md) | +| Storage | 按显式配置建立 SQLite/PostgreSQL 连接池,不定义应用表 | [数据库特殊类](../runtime/database-objects.md) | +| CLI | 对象引导演示与文件信息命令 | [CLI](../../crates/cli/README.md) | +| Server | HTTP 存活检查、只读 VM 信息及文件版本记录 | [Server](../../crates/server/README.md) | +| Gitea/Docker | 双数据库适配验证、镜像构建、宿主与 PostgreSQL 资源部署 | [部署](../operations/deployment.md) | ## 当前契约 -- Vm 从空堆启动。EndorObject 是实现层公共结构,不是语言根类;当前没有 Python type、继承或元类。 -- 堆中对象地址稳定,公开句柄校验 VM 身份和槽位代数;句柄不是跨进程 ID。 +- Vm 从空堆启动。EndorObject 是实现层公共结构,不是语言根类;没有 Python type、继承或元类。 +- 对象地址稳定,公开句柄校验 VM 身份和槽位代数;句柄不是跨进程 ID。 - 引用计数负责通常的释放,显式全堆追踪负责不可达循环;执行帧中的对象值也作为根。 - 函数共享不可变代码对象,语言调用使用显式帧向量;字节码参考 CPython 的职责划分,不兼容其操作码 ABI。 -- Number 优先保存不同数字的无损表示;没有统一转 f64,也没有定义数值算术、相等和哈希。 -- 异常对象保存数据;执行错误使用 VmError,没有 raise/try/except 传播。 -- 内核不读取进程环境或修改 locale。宿主负责配置解析、I/O 和信号;详见 [配置](../operations/configuration.md)。 +- Number 优先无损表示;数值算术、相等和哈希尚未定义。异常对象保存数据,尚无 raise/try/except 传播。 +- 内核不读取环境或修改 locale。宿主处理监听和信号;数据库配置由真正消费它的对象适配层显式传入,见 [配置](../operations/configuration.md)。 ## 数据与服务边界 -Storage 仅保存 `aster_metadata(key, value)`;连接时幂等创建当前 schema。文件与数据库中的元数据可以跨进程保留,VM 堆、执行帧、句柄不会因此恢复。宿主重启会创建新的空 VM。 +数据库用于未来某种特殊类,不保存基础信息、版本、部署记录或普通对象。Storage 连接不建应用表,不提供统一 KV;实际数据布局和查询由特殊类定义。当前只有连接适配,还没有实现该类或把它接入 Vm。 -HTTP 宿主通过 Arc 共享只读 Vm。GET /bootstrap 只读对象模型与堆数量,GET /health 实际探测数据库。当前没有语言执行端点、对象写入端点或认证协议;不能把现有服务称为远程执行平台。 +HTTP 宿主通过 Arc 共享只读 Vm。GET /bootstrap 只读对象模型和数量,GET /health 仅报告 HTTP 可响应。没有语言执行、对象写入或认证协议,不能称为远程执行平台。重启仍创建新空 VM,没有堆快照。 -## 后续扩展边界 +部署保留既有 PostgreSQL 服务与数据目录,应用启动不依赖数据库连接。基础信息从数据库改为 Host 文件层;发布脚本一次性只读导入旧 PostgreSQL 记录,不删除原表或卷。脚本结果未主动跟踪,不能据此认定远端已经完成迁移。 -源码前端、函数定义/迭代器指令、可变 VM 宿主回调、异常传播、自动 GC、内存配额、执行取消、能力隔离、VM 快照及语言类型体系都需要各自的设计。现有帧数/指令预算只是部分执行限额,不构成完整沙箱。 +## 后续边界 -主题的详细规则只在对应文档维护,本页保留组件关系与摘要。返回 [文档索引](../README.md)。 +特殊类接口与异步数据库调用、源码前端、更多指令、异常传播、自动 GC、内存配额、取消和能力隔离等均需按实际需求设计。当前帧/指令限额不等于完整沙箱。返回 [文档索引](../README.md)。 diff --git a/docs/contributing/README.md b/docs/contributing/README.md index fff2385..18cbfc3 100644 --- a/docs/contributing/README.md +++ b/docs/contributing/README.md @@ -15,9 +15,12 @@ | 对象头、句柄、引用协议与 GC 算法 | [objects.md](../runtime/objects.md) | core/src 文件表 | | Number、String、Dict、Cell、函数环境和异常数据 | [builtins.md](../runtime/builtins.md) | core/src 与 tests 文件表 | | 指令编码、栈效果、调用协议与执行限制 | [bytecode.md](../runtime/bytecode.md) | instruction/code/frame/eval 文件职责 | +| 宿主基础信息、JSON 文件和旧库导入 | [host-information.md](../operations/host-information.md) | host、cli、server 与脚本 README | +| 地区与文本编码边界 | [text-encoding.md](../runtime/text-encoding.md) | 配置主题与实际 I/O 入口 | +| 数据库特殊类、显式连接和数据模型归属 | [database-objects.md](../runtime/database-objects.md) | storage 的源码与检查 README | | VM/帧/对象的创建、错误退出与销毁 | [lifecycle.md](../runtime/lifecycle.md) | 对象与字节码主题链接 | | 环境变量、默认值、优先级、UTF-8、locale 和配置边界 | [configuration.md](../operations/configuration.md) | 相关 crate README 链接 | -| 运行命令、端点和宿主存储行为 | [operations/README](../operations/README.md) | cli/server/storage README | +| 运行命令、端点和数据库连接边界 | [operations/README](../operations/README.md) | cli/server/storage README | | 镜像、CI、部署、回退和数据目录 | [deployment.md](../operations/deployment.md) | scripts/工作流 README | | 检查入口、覆盖范围、结果边界 | [validation.md](../operations/validation.md) | 各 tests README | | 不可变研究表、聊天导出、临时脚本或构建产物 | 工作区外输出或忽略目录 | 不作为当前项目契约、不提交研究 HTML | @@ -44,6 +47,6 @@ Rust 实现依赖模块可见性、trait 和 rustfmt,不照搬 CPython C 符 ## 导出与迁移 -本轮把 outputs 的字节码及基本对象说明核对后融合入 runtime 主题。以后只维护仓库正文;需要分享时导出副本,副本注明仓库权威位置,避免继续平行编辑。CPython 284 项研究 HTML/Markdown 仍是独立研究材料,不上传 Git。 +旧 outputs 正文已融合入 runtime,输出目录已移除。以后只维护仓库正文,不保留同文入口文件;CPython 284 项研究 HTML/Markdown 和早期报告位于本地忽略的 work/research,不上传 Git。交接入口见 [handoff.md](handoff.md)。 旧平铺路径 docs/architecture.md、development.md、endor-object.md、endor-builtins.md、bytecode-vm.md、validation.md 已由新的多层主题替代;当前入口以 [文档索引](../README.md) 为准。 diff --git a/docs/contributing/handoff.md b/docs/contributing/handoff.md new file mode 100644 index 0000000..a994915 --- /dev/null +++ b/docs/contributing/handoff.md @@ -0,0 +1,36 @@ +# Aster VM 后续开发交接 + +从 [文档索引](../README.md) 查询当前实现,再按目录 README 找文件。不要把旧聊天、早期报告或未查询的 CI 状态当成当前契约。 + +## 当前项目与命名 + +仓库为 endorphin-aster-vm,归属 ProjectEndorphin。包与目录为 core、storage、host、cli、server;core 的 Rust 库名为 endor_core,命令为 cli/server。旧部署服务、数据库和路径保留既有标识以保护数据。 + +## 已实现 + +- EndorObject、稳定 Box、代际句柄、显式强引用及手动循环回收;VM 从空堆启动,没有 Python type/元类或语言根类。 +- Function、NativeFunction、Dict、Cell、异常数据、UTF-8 String 和无损表示优先的 Number。 +- 小型 wordcode、共享代码对象、操作数/控制流/栈校验、显式执行帧及 execute/call。对象返回值持有根,用后 release_value;借用读取不自动 retain。 +- Host 文件信息独立于数据库:host.version、deployment.release 与 metadata put/get 使用 UTF-8 JSON。旧 PostgreSQL 基础信息只读导入缺失文件,不删除原库,见 [文件信息](../operations/host-information.md)。 +- Storage 只提供显式 DatabaseConfig/DatabaseConnection;没有默认数据库、自动应用建表或基础信息 KV。特殊类的名称/接口尚未定义,没有将连接接入 VM。 +- CLI 引导与文件信息,Server HTTP 存活/只读 VM 信息;生产 PostgreSQL 作为未来特殊类的预置资源保留。 + +## 资料位置 + +正式说明在 docs,多层 README 负责导航;源码目录 README 描述本目录。outputs 已移除,CPython 3.14 的 284 项对象研究表、生成脚本及早期历史报告保留在本地忽略的 work/research,不上传 Git。 + +## 尚未实现 + +源码 lexer/parser、MAKE_FUNCTION 等扩展指令、数值算术/相等/哈希、完整异常传播、可变 VM/异步数据库回调、生成器、自动 GC、堆快照、内存配额、取消及不可信代码沙箱。宿主回调 panic 后的执行根恢复也未完成。不能把未来设计写成已有能力。 + +## 工作约定 + +本地不构建、不运行测试/冒烟,不下载依赖;只允许静态阅读、格式化、语法/链接检查。运行验证交给 ces-control 的 SQLite、真实 PostgreSQL 与宿主流程。按用户要求不主动跟踪 CI/部署,结果未查询就明确写未核实。 + +数据库、卷和已有资料保留;不得改其他项目或共享 Runner。研究材料与 AGENTS.md 不提交。Git 身份按本地工作约定使用专用 codex-bot,不能借用仓库所有者凭据。 + +所有本项目对话标题包含 Aster VM。文档与代码同版本维护,不重新建立 outputs 或重复正文目录。开始下一项任务前先看 git status/最新提交,读取本地 AGENTS.md;本页不代替实时状态。 + +## 下一步 + +用户下一步尚未指定。先完成交接确认,不自动启动解析器、改数据库类接口、运行测试或跟踪 CI。需要继续哪个方向时由用户决定。 diff --git a/docs/operations/README.md b/docs/operations/README.md index 848f87e..c83f7d1 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -1,26 +1,22 @@ -# 运行与宿主存储 +# 运行宿主 -这里说明跨 CLI、Server 和 Storage 的运行流程。当前本机禁止构建、测试、冒烟与依赖下载;以下命令供用户指定的远端环境执行。环境变量见 [配置](configuration.md),容器和生产服务见 [部署](deployment.md),检查见 [验证](validation.md)。 +CLI 和 Server 运行 VM 引导、状态与文件基础信息,不连接数据库。Storage 为未来数据库特殊类保留显式连接能力,见 [数据库对象](../runtime/database-objects.md)。以下构建和运行命令仅供用户指定的远端环境,本地不执行。 -## 构建与 CLI +## CLI -工作区要求 Rust 1.91 或兼容更高版本;构建镜像固定 rust:1.91.1-bookworm,依赖由 Cargo.lock 锁定。 +工作区要求 Rust 1.91 或兼容更高版本,镜像固定 rust:1.91.1-bookworm,依赖由 Cargo.lock 锁定。 ```sh cargo build --workspace --locked cargo run -p cli -- bootstrap +cargo run -p cli -- help ``` -bootstrap 创建两个 Record,输出 runtime=EndorObject、type_system=disabled、objects_distinct=true 和 heap_objects=2,显式释放后输出 released heap_objects=0。它不读取数据库配置,因此无效 DATABASE_URL 也不影响该命令。 +bootstrap 创建两个 Record,输出 runtime=EndorObject、type_system=disabled、objects_distinct=true 和 heap_objects=2,显式释放后输出 released heap_objects=0。默认无参数执行同一示例。 -```sh -cargo run -p cli -- metadata put project 'Aster VM' -cargo run -p cli -- metadata get project -``` +CLI 不读取数据库环境,不创建基础信息数据库表。metadata put/get 通过 host 包写 UTF-8 JSON 文件,metadata import 仅导入缺失记录;示例、配置和协议见 [宿主信息](host-information.md)。输入与退出码见 [cli/src](../../crates/cli/src/README.md)。 -每次 metadata 命令读取宿主存储配置、连接、操作并关闭连接池;缺失键显示 (missing)。命令和退出码的源码位置见 [cli/src](../../crates/cli/src/README.md)。 - -## HTTP 宿主 +## HTTP ```sh cargo run -p server @@ -28,34 +24,28 @@ curl --fail http://127.0.0.1:8080/health curl --fail http://127.0.0.1:8080/bootstrap ``` -Server 创建空 VM、连接存储、初始化元数据表并写入 host.version,随后绑定监听地址。连接、初始化或绑定失败会退出,不降级换库。默认地址为 127.0.0.1:8080。 +Server 创建空 VM 并绑定监听地址,默认 127.0.0.1:8080;通过 host 包写入文件 host.version,不读取数据库 URL、不建表。 | 端点 | 行为 | | --- | --- | -| GET /health | 实际 ping 数据库;成功返回 200,失联返回 503,含 status 和 storage 后端名 | -| GET /bootstrap | 返回 object_model=EndorObject、type_system=false、heap_objects=0;只读,不分配对象 | +| GET /health | 返回 200 和 status=ok;仅表明 HTTP 宿主存活,不包含数据库后端字段 | +| GET /bootstrap | 返回 object_model=EndorObject、type_system=false、heap_objects=0;只读、不分配对象 | -当前未提供语言执行、对象写入或认证 API。Unix 上 Ctrl-C/SIGTERM 触发 Axum 优雅退出,等待服务结束后关闭共享 Storage;最后的 Arc 释放时销毁 Vm。没有执行中脚本的取消协议。 +没有语言执行、对象写入或认证 API。Unix 上 Ctrl-C/SIGTERM 触发 Axum 优雅退出,最后的 Arc 释放时销毁 Vm;没有数据库池需要在此处关闭,也没有执行中脚本的取消协议。 -## 存储契约 +## 数据库能力的使用位置 -Storage 不依赖 Core,只保存宿主元数据。连接时执行 schema.sql:aster_metadata 的 key 为非空文本主键,value 为非空文本。put 使用参数绑定并更新重复键,get 对不存在的键返回 None。当前只有幂等建表,没有版本化迁移;CREATE IF NOT EXISTS 不能升级已有列结构。 +当前应用不把数据库作为启动依赖;即使进程带有失效 DATABASE_URL 或 SQLITE_PATH,也不会因此读取它们。特殊类将来需要连接时,宿主适配实现显式传入 DatabaseConfig,详见 [配置](configuration.md)。 -| 后端 | 当前行为 | -| --- | --- | -| SQLite 文件 | 默认 data/aster.db,相对当前工作目录;创建父目录和文件,开启 WAL,最多 5 个连接,busy timeout 5 秒 | -| SQLite 内存 | SQLITE_PATH=:memory:;单连接、不做空闲回收和最大存活轮换;不同 Storage 实例隔离 | -| PostgreSQL | DATABASE_URL 显式选择;最多 5 个连接,连接池获取超时 5 秒,启动即真实连接;失败不回退 SQLite | +Storage 支持 SQLite 文件/内存和 PostgreSQL,不自带应用 schema 或 key/value 数据模型。旧数据库数据保留原位,发布时只读导入到文件信息;新的普通宿主不会继续写入数据库;数据持久化也不自动保存 VM 堆、句柄或帧。 -Storage 克隆共享连接池,close 会关闭所有克隆使用的池。元数据可跨进程保存;VM 堆、句柄、全局对象和执行帧不持久化。重启服务创建新的空 VM。 - -## 出错时先查哪里 +## 定位问题 | 现象 | 定位 | | --- | --- | -| bootstrap 正常,metadata/服务启动失败 | [存储配置](configuration.md)、[storage/src](../../crates/storage/src/README.md) | -| /health 返回 503 | 当前数据库连接;该端点不检测语言执行能力 | -| 容器替换后元数据丢失 | [卷与生产数据路径](deployment.md),先确认是否使用内存库 | -| 重启后找不到原 VM 对象 | 当前未实现 VM 快照,见 [生命周期](../runtime/lifecycle.md) | +| Server 无法绑定地址 | ASTER_LISTEN 与端口占用,见 [配置](configuration.md) | +| /health 正常但数据库不可用 | health 不检测数据库;当前没有使用数据库的语言类 | +| 重启后 VM 对象消失 | 当前未实现 VM 快照,见 [生命周期](../runtime/lifecycle.md) | +| 需要连接或查询数据库 | [storage](../../crates/storage/README.md) 和 [数据库对象](../runtime/database-objects.md);先定义实际特殊类接口 | -返回 [文档索引](../README.md)。 +容器与预置 PostgreSQL 见 [部署](deployment.md),检查范围见 [验证](validation.md)。返回 [文档索引](../README.md)。 diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md index 54021b4..0952705 100644 --- a/docs/operations/configuration.md +++ b/docs/operations/configuration.md @@ -2,12 +2,13 @@ ## 当前选择 -当前先明确少量稳定边界,不引入 PyPreConfig/PyConfig 式两阶段初始化,也不新增 VM 配置文件、环境变量前缀或配置框架。Vm::new 从空堆启动;需要环境、数据库或网络的是宿主。 +当前先明确少量稳定边界,不引入 PyPreConfig/PyConfig 式两阶段初始化,也不新增 VM 配置文件、环境变量前缀或配置框架。Vm::new 从空堆启动;监听与网络由宿主处理,数据库连接仅由真正消费它的对象显式请求。 | 配置层 | 当前入口 | 责任 | | --- | --- | --- | | 内核 | Vm::new、显式创建对象/函数;call_with_limits/execute_with_limits | 可预测的运行时语义,不读取环境变量、修改 locale、注册信号或加载 .env | -| 宿主 | StorageConfig::from_env、Server 启动代码 | 将环境变量转成存储配置和监听地址,再启动服务 | +| 宿主 | Server 启动代码与 host::MetadataStore | 读取监听/文件信息目录;基础信息存 JSON 文件,不读取数据库配置 | +| 数据库对象适配 | DatabaseConnection::connect(&DatabaseConfig) | 显式选择后端后连接,不预设应用表;特殊类尚未接入 | | 部署 | Compose 与 ces-control 脚本 | 端口映射、数据库密码、持久卷和镜像版本 | | 构建/CI | Cargo.toml、Cargo.lock、Dockerfile、工作流 | 工具链、依赖锁定与验证环境;不作为语言运行语义配置 | @@ -28,12 +29,13 @@ LC_CTYPE 处理字符分类、大小写和多字节字符相关约定;小数 - 内核没有 setlocale 调用;不会为了启动 VM 修改 LANG、LC_ALL 或 LC_CTYPE。外部库、终端、字体或下游工具自身的行为不在这项保证之内。 - 尚无源码加载器、标识符语法、通用字节串对象或文件系统 API,因此不能宣称已完成源码编码、路径编码和全部 I/O 策略。 -## 已有宿主环境变量 +日文与外部 Shift_JIS/CP932 的处理见 [文本编码](../runtime/text-encoding.md);内部 UTF-8 不因地区切换。 + +## 已有进程与部署变量 | 名称 | 使用位置与默认值 | 错误和优先级 | | --- | --- | --- | -| DATABASE_URL | StorageConfig;未设置时才选择 SQLite | 仅 postgres:// 或 postgresql://;显式空值、错误协议和非 Unicode 值报错;连接失败不降级 | -| SQLITE_PATH | 无 DATABASE_URL 时使用;默认 data/aster.db | 空值和非 Unicode 值报错;:memory: 为临时库;设置 DATABASE_URL 后忽略此项 | +| ASTER_STATE_DIR | Host 文件信息目录;默认 data/host,容器 /data/host | 使用 var_os 保留操作系统路径;显式空值报错,不影响数据库选择 | | ASTER_LISTEN | Server;默认 127.0.0.1:8080 | 当前 var(...).unwrap_or_else:缺失或非 Unicode 都用默认值;有效 Unicode 的错误地址在绑定时失败 | | ASTER_PORT | Compose 宿主端口映射 | 独立方案默认 8080,ces-control 默认 18081;不是 Vm 配置 | | POSTGRES_PASSWORD | PostgreSQL Compose/生产环境文件 | Compose 必填;示例使用随机十六进制以避免 URL 转义歧义;不写入仓库 | @@ -41,9 +43,13 @@ LC_CTYPE 处理字符分类、大小写和多字节字符相关约定;小数 | ASTER_RELEASE_SHA | CI 与部署脚本 | 必须为 40 位小写十六进制提交 SHA;用于镜像和版本目录 | | ASTER_TEST_POSTGRES_URL | 指定环境显式运行 PostgreSQL 集成测试 | 专用测试库 URL;不是生产数据库配置 | -程序本身不自动加载 .env,Cargo 运行时须显式导出变量。Compose 会按其规则读取 .env;显式 --env-file 的生产文件由部署脚本管理。模板见 [.env.example](../../.env.example),执行链见 [部署](deployment.md)。 +文件信息命令及 Server 通过 host 包读取 ASTER_STATE_DIR,Server 还读取 ASTER_LISTEN;不会把 DATABASE_URL/SQLITE_PATH 作为基础信息存储配置。程序不自动加载 .env,Cargo 运行时须显式导出监听变量。Compose 会按其规则读取 .env;显式 --env-file 的生产文件由部署脚本管理。模板见 [.env.example](../../.env.example),执行链见 [部署](deployment.md)。 -数据库 URL 可能包含密码;当前 StorageConfig 的 Debug 脱敏,公开数据库错误不回显 URL。底层错误 source 仍保留给宿主,不应不加处理地作为公开日志输出。 +## 显式数据库配置 + +DatabaseConfig 没有默认值和 from_env,仅允许显式构造:Postgres(url)、SqliteFile(path)、SqliteMemory。没有默认 data/aster.db,也没有应用启动时的全局连接。connect 不执行应用 DDL,不保存基础信息;基础信息另由 [host 文件层](host-information.md) 保存;未来的特殊类负责消费连接和定义数据,见 [数据库对象](../runtime/database-objects.md)。 + +SQLite 文件显式连接时创建父目录与文件、启用 WAL,最多 5 个连接、busy timeout 5 秒;内存库单连接、不因池轮换销毁。PostgreSQL 最多 5 个连接、获取超时 5 秒,错误不降级。数据库 URL 的 Debug 和公开错误脱敏;底层错误 source 保留,不应不加处理地公开输出。 ## 已有执行限额 @@ -58,7 +64,7 @@ call_with_limits/execute_with_limits 使用自定义值;嵌套语言调用 ## 保留的入口缺口与以后再做的内容 -CLI 当前使用 std::env::args,遇到非 Unicode 命令行参数可能 panic,并没有统一转换成友好的配置错误。[Rust args 文档](https://doc.rust-lang.org/std/env/fn.args.html#panics)。Server 的非 Unicode ASTER_LISTEN 也尚未像存储变量一样严格报错;宿主输入错误接口尚未统一。 +CLI 当前使用 std::env::args,遇到非 Unicode 命令行参数可能 panic,并没有统一转换成友好的配置错误。[Rust args 文档](https://doc.rust-lang.org/std/env/fn.args.html#panics)。Server 的非 Unicode ASTER_LISTEN 也尚未严格报错;宿主输入错误接口尚未统一。 以后增加宿主输入时,应明确使用 OsString/PathBuf 保留系统路径,或显式拒绝不符合文本协议的输入;不要通过修改整个进程 locale 让隐式解码暂时成功。未来源码文本默认 UTF-8、协议数字固定格式是设计方向,只有加载器实现后才能写成已实现行为。 diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index e8e4fe2..2159f09 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -1,56 +1,57 @@ # Docker 与 ces-control 部署 -这里描述仓库当前工作流和脚本行为,不代表正在运行的服务器状态。正式持久部署选择 PostgreSQL,SQLite Compose 保留为独立运行选项。配置字段见 [配置](configuration.md),CI 检查细节见 [验证](validation.md)。 +这里描述仓库当前工作流和脚本行为,不代表正在运行的服务器状态。生产保留预置 PostgreSQL 与既有数据。当前 VM 宿主不消费数据库,数据库连接仅为未来特殊类保留。配置字段见 [配置](configuration.md),CI 检查细节见 [验证](validation.md)。 ## 镜像与独立运行 Dockerfile 的 source 阶段复制 Cargo 工作区及脚本;ci 阶段安装远端检查工具,build 阶段构建 server 和 cli,runtime 阶段使用 Debian slim、UID 10001、/data 工作目录及 HTTP 健康检查。 -指定环境的 SQLite 方案: +指定环境的独立宿主方案(沿用原 SQLite 卷,基础信息写入其中的 host 目录): ```sh cp .env.example .env docker compose up --build -d curl --fail http://127.0.0.1:8080/health -docker compose exec aster cli metadata put project 'Aster VM' +docker compose exec aster cli bootstrap ``` -默认 docker-compose.yml 只向应用提供 SQLITE_PATH,使用 aster-sqlite 命名卷;不透传 DATABASE_URL。down 保留数据,down -v 会删除卷。 +默认 docker-compose.yml 提供监听地址和 ASTER_STATE_DIR,保留 aster-sqlite 卷挂载;只写基础信息 JSON,不自动打开卷中的数据库。down 保留数据,down -v 会删除卷。 -指定环境的 PostgreSQL 独立替代方案:先在 .env 中将 POSTGRES_PASSWORD 改为随机十六进制字符串,例如 openssl rand -hex 24,再运行: +指定环境中同时预置 PostgreSQL 的独立方案:先在 .env 中将 POSTGRES_PASSWORD 改为随机十六进制字符串,例如 openssl rand -hex 24,再运行: ```sh docker compose -f docker-compose.postgres.yml up --build -d -docker compose -f docker-compose.postgres.yml exec aster cli metadata get project +docker compose -f docker-compose.postgres.yml exec aster cli bootstrap ``` -仅使用该 Compose 文件,不与默认 SQLite 文件叠加。此方案使用 aster-postgres 命名卷、PostgreSQL 17;数据库不发布宿主端口。两个独立方案不要同时占用相同应用端口。内部示例连接禁用 TLS,跨不可信网络使用 PostgreSQL TLS 验证。 +仅使用该 Compose 文件,不与默认文件叠加。此方案使用 aster-postgres 命名卷、PostgreSQL 17,以及独立的 aster-host 文件信息卷;数据库不发布宿主端口。两个独立方案不要同时占用相同应用端口。未来特殊类跨不可信网络连接数据库时,需要明确配置 PostgreSQL TLS 验证。 ## CI 到正式发布 [工作流](../../.gitea/workflows/README.md) 响应 main push 和 workflow_dispatch,在 ces-control 上串行运行: -1. 检出代码,调用 ci-ces-control.sh;在 Docker 中检查 workspace、SQLite/HTTP 冒烟及专用真实 PostgreSQL。 +1. 检出代码,调用 ci-ces-control.sh;在 Docker 中检查 workspace(含 SQLite 适配)、独立 HTTP 冒烟及专用真实 PostgreSQL。 2. 检查成功后构建 endorphin-aster-vm: 生产镜像。 -3. main 上调用 deploy-ces-control.sh 更新 PostgreSQL 宿主。 +3. main 上调用 deploy-ces-control.sh 更新 VM 宿主并保留预置 PostgreSQL。 临时检查容器与网络退出时清理,Cargo 下载与构建缓存保留在专属远端卷。生产镜像、已有发布目录与数据库数据保留;没有自动 image prune 或数据库卷清理。工作流不改动其他项目或共享 Runner 配置。 ## 生产目录与数据 -Cargo 包名为 core/storage/cli/server,core 的 Rust 库名为 endor_core,可执行文件为 cli/server。Compose 应用服务名 aster、数据库名、卷和生产路径是已有部署资源标识,运行命令为该服务中的 cli;包改名无需迁移这些数据或重建数据库。 +Cargo 包名为 core/storage/host/cli/server,core 的 Rust 库名为 endor_core,可执行文件为 cli/server。Compose 应用服务名 aster、数据库名、卷和生产路径是已有部署资源标识,运行命令为该服务中的 cli。应用不接收 DATABASE_URL、不依赖数据库健康才能启动;Compose 的整体 --wait 仍检查所预置 PostgreSQL 的就绪状态。 | 路径 | 用途 | | --- | --- | | /opt/endorphin-aster-vm/releases// | 固定版本的 compose.yml 与 release.env | | /opt/endorphin-aster-vm/current | 最近通过部署检查的版本符号链接 | | /etc/endorphin-aster-vm/production.env | 首次生成的数据库密码和默认端口,权限 0600 | +| /var/lib/endorphin-aster-vm/host | 文件基础信息,挂载到 /data/host,目录由部署准备并交给 UID 10001 | | /var/lib/endorphin-aster-vm/postgres | 独立的 PostgreSQL 持久数据目录 | | /var/lock/endorphin-aster-vm-deploy.lock | 避免并行切换生产版本的文件锁 | 生产 Compose 固定项目名 endorphin-aster-vm,默认应用入口仅绑定 127.0.0.1:18081;数据库不发布端口,两个服务使用 unless-stopped 自动重启和日志轮换。端口绑定回环地址不等于已实现应用认证。 -部署脚本只接受已构建镜像。启动容器、健康检查、真实元数据 put/get 及 PostgreSQL 后端确认都成功后才切换 current。失败时保留数据并尝试恢复上一个健康版本;首次部署没有旧版本可恢复。脚本内容导航见 [scripts](../../scripts/README.md)。 +部署脚本只接受已构建镜像。容器就绪、HTTP status=ok 和 CLI 对象引导/释放检查都成功后才切换 current;发布记录改写到 deployment.release.json;首次发布一次性只读导入旧基础信息,再保留完成标记,不删除数据库原记录。详情见 [宿主信息](host-information.md)。镜像 SHA、release.env 与 current 链接负责标识版本。失败时保留数据并尝试恢复上一个健康版本;首次部署没有旧版本可恢复。脚本内容导航见 [scripts](../../scripts/README.md)。 ## 查看、停止与恢复 diff --git a/docs/operations/host-information.md b/docs/operations/host-information.md new file mode 100644 index 0000000..b514019 --- /dev/null +++ b/docs/operations/host-information.md @@ -0,0 +1,47 @@ +# 宿主基础信息:文件保存 + +基础信息不使用数据库。host 包保存逐键 UTF-8 JSON,CLI 提供读写,Server 启动写 host.version,部署检查通过后写 deployment.release。Storage 的数据库连接仅给未来特殊类使用。 + +## 文件与配置 + +ASTER_STATE_DIR 使用操作系统路径,默认 data/host;显式空值报错。读取一个不存在的键返回 None,不创建目录;写入时创建目录。容器为 /data/host;生产挂载到 /var/lib/endorphin-aster-vm/host,独立 PostgreSQL Compose 使用 aster-host 卷。 + +例如 host.version 对应 host.version.json: + +```json +{ + "format_version": 1, + "key": "host.version", + "value": "0.1.0" +} +``` + +控制键以 ASCII 字母或数字开头,最长 128 字节,其余只能是字母、数字、点、下划线或连字符。它是信息命名协议,不是文本编码限制;值支持日文、中文、emoji 等 Unicode 文本。用户文本不应被当作文件路径或控制键。 + +文件必须是有效 UTF-8 JSON,版本和 key 必须匹配请求。损坏记录返回错误,不清空、不用默认值偷偷替换。文件不属于 VM 堆快照,也不存数据库凭据。 + +## 读写与生命周期 + +CLI 的 metadata put/get 通过 host::MetadataStore 访问文件,不连接 SQL: + +```sh +cli metadata put project.note '日本語・中文・😀' +cli metadata get project.note +cli metadata get host.version +``` + +MetadataStore::clone 只共享目录路径,没有缓存;每次读取重新打开文件。写入先建立同目录唯一临时文件,写完并 sync_all,再通过 rename 发布;Unix 新文件权限 0600。不同键分别写文件,不会因整张 map 的读改写丢失其他键;同键并发是最后一次成功发布生效。 + +没有跨键事务、分布式锁或共享网络文件系统一致性保证;文件同步与原子发布也不等于数据库的完整断电持久性协议。异常进程终止可能留下隐藏临时文件,正常失败会清理自己的临时文件。 + +## 旧基础信息的一次性迁移 + +ces-control 发布时,在 host/.legacy-imported 标记缺失的情况下,只读检查 PostgreSQL 原 public.aster_metadata 表。存在时,使用 UTF-8 客户端导出 key/value JSON,再经 cli metadata import 的标准输入导入。该命令只创建缺失文件,不覆盖 Server 刚写入的版本或其他已有文件;临时导出文件以私有权限创建并清理,不打印数据内容。 + +只导入缺失项通过同目录临时文件的原子 hard_link 实现,因此运行目录应使用支持该操作的本地文件系统。导入可重复执行;只有导入成功或源表不存在才写完成标记。损坏记录、不支持的控制键或 I/O 错误会停止发布,保留原库数据,不能当作迁移成功。 + +本迁移针对已配置的 PostgreSQL 生产路径,不自动扫描或删除其他 SQLite 文件。原数据库表与数据不会被修改/删除。此处说明发布脚本行为,不代表远端已执行成功;按约定不主动跟踪 CI 或服务器。 + +部署记录仍以版本镜像、release.env 与 current 链接为发布来源;deployment.release.json 是文件信息记录,不是数据库迁移表。脚本见 [scripts](../../scripts/README.md)。 + +返回 [运行指南](README.md) 或 [文档索引](../README.md)。 diff --git a/docs/operations/validation.md b/docs/operations/validation.md index 4941916..3fcdfdc 100644 --- a/docs/operations/validation.md +++ b/docs/operations/validation.md @@ -18,7 +18,7 @@ python3 scripts/smoke.py check.sh 执行 cargo fmt --all --check、cargo test --workspace --locked 和 cargo clippy --workspace --all-targets --locked -- -D warnings。workspace 测试也包含 rustdoc 示例;显式忽略的 PostgreSQL 测试需要单独执行。 -ci-ces-control.sh 负责准备专用临时 PostgreSQL 和测试 URL;成功路径使用真实数据库,不用模拟 socket 代替。测试库与生产数据不共享挂载。没有 ASTER_TEST_POSTGRES_URL 时,显式执行 PostgreSQL 测试会失败,不标记为通过。 +ci-ces-control.sh 负责准备全新专用临时 PostgreSQL 和测试 URL;成功路径使用真实数据库,不用模拟 socket 代替。测试库与生产数据不共享挂载。没有 ASTER_TEST_POSTGRES_URL 时,显式执行 PostgreSQL 测试会失败,不标记为通过。 ## 覆盖范围与文件位置 @@ -29,9 +29,9 @@ ci-ces-control.sh 负责准备专用临时 PostgreSQL 和测试 URL;成功路 | 基本对象 | Number 表示、字符串、Dict/Cell 引用与更新、函数环境、异常数据 | 同目录 builtins.rs | | 字节码 | 编解码、非法指令/控制流、跳转、调用、闭包、结果存活、失败清理与限额 | 同目录 execution.rs | | 公开示例 | 对象操作、手写指令执行与释放 | [core/src](../../crates/core/src/README.md) 的 lib.rs rustdoc | -| 存储 | 配置优先级、内存隔离/共享、文件重连、失败不降级、真实 PostgreSQL | [storage/tests](../../crates/storage/tests/README.md) | -| HTTP 状态 | 数据库关闭后 health 返回 unavailable | [server/src](../../crates/server/src/README.md) 的单元测试 | -| 端到端宿主 | CLI 跨进程元数据、独立 bootstrap、错误配置、HTTP 与 SIGTERM | [scripts](../../scripts/README.md) 的 smoke.py | +| 宿主文件信息 | UTF-8 值/路径、持久读取、同键替换、并发、坏记录、幂等迁移与保留新值 | [host/tests](../../crates/host/tests/README.md) | +| 数据库连接 | 不自动建表、显式配置、隔离/共享、调用方数据重连、失败不降级、真实 PostgreSQL | [storage/tests](../../crates/storage/tests/README.md) | +| 端到端宿主 | 文件 metadata 跨进程读写、日文值、Server 文件版本;忽略失效数据库变量且不创建 SQLite 文件、HTTP 与 SIGTERM | [scripts](../../scripts/README.md) 的 smoke.py | 测试源码说明验证意图,不能证明已通过;现有测试也不代表完整 Python 兼容、panic 恢复、云沙箱或 VM 快照已经实现。 diff --git a/docs/runtime/README.md b/docs/runtime/README.md index 9a3b2f9..ec2c677 100644 --- a/docs/runtime/README.md +++ b/docs/runtime/README.md @@ -7,6 +7,8 @@ | [对象模型](objects.md) | EndorObject/ObjectHeader、代际句柄、操作协议、托管引用与循环回收 | | [基本对象与 Number](builtins.md) | 函数、原生函数、字典、Cell、异常数据、字符串与无损数字 | | [字节码 VM](bytecode.md) | 指令编码、代码校验、局部槽、执行帧、调用/返回与边界 | +| [文本编码](text-encoding.md) | UTF-8、日文、外部 Shift_JIS/CP932 与宿主解码边界 | +| [数据库特殊类](database-objects.md) | 显式连接、调用方数据模型、未来类接入与资源生命周期 | | [生命周期](lifecycle.md) | 宿主持有 Vm、帧执行根、结果所有权、错误退出和整体销毁 | -文本/环境约束和执行限额集中在 [配置](../operations/configuration.md);四个组件之间的关系见 [系统架构](../architecture/README.md)。返回 [文档索引](../README.md)。 +文本/环境约束和执行限额集中在 [配置](../operations/configuration.md);组件之间的关系见 [系统架构](../architecture/README.md)。返回 [文档索引](../README.md)。 diff --git a/docs/runtime/database-objects.md b/docs/runtime/database-objects.md new file mode 100644 index 0000000..ef97154 --- /dev/null +++ b/docs/runtime/database-objects.md @@ -0,0 +1,36 @@ +# 数据库特殊类与连接边界 + +数据库连接用于某类明确需要数据库的特殊对象,不用于 VM 版本、部署版本、普通对象信息或统一 key/value 元数据。当前尚未定义该特殊类的名字、载荷布局或语言接口,本页说明已保留的连接能力及接入边界。 + +## 当前分层 + +- Core 负责对象、堆和执行,不依赖 SQLx 或数据库。 +- Storage 仅暴露 DatabaseConfig、DatabaseConnection 和 DatabaseError。 +- CLI/Server 不依赖 Storage,启动、bootstrap 和 health 不访问数据库;基础信息由独立 host 文件层处理。 +- 特殊类以后由宿主实现按需取得数据库连接;尚未自动挂入 Vm 或对象工厂。 + +## 显式建立连接 + +调用方先构造 DatabaseConfig::Postgres(url)、SqliteFile(path) 或 SqliteMemory,再 await DatabaseConnection::connect。没有 from_env、默认 SQLite 路径或应用启动时的全局数据库。 + +connect 会建立真实连接,并可在显式请求 SQLite 文件时创建父目录和文件,但不创建任何应用表。SQLite WAL、连接协商等驱动自身操作不等于应用 schema 初始化。不会创建基础信息表,也不会写入运行或发布信息。 + +sqlite_pool/postgres_pool 返回匹配后端的 SQLx 池借用,另一后端返回 None。特殊类以后可在自己的适配实现里执行参数化查询、事务及由该类定义的数据结构;本层不预设通用 KV、ORM、迁移或序列化协议。现有 SQLx API 见 [Pool 文档](https://docs.rs/sqlx/0.8.6/sqlx/struct.Pool.html)。 + +## 生命周期 + +DatabaseConnection::clone 共享连接池;close().await 显式关闭所有克隆使用的池,并等待已借出连接归还。取出的 SQLx 池还能被调用方克隆,因此单个包装值销毁不保证所有使用者立即结束。 + +未来将连接放进原生载荷时,必须另行定义异步关闭、共享所有权和错误传播;当前 VM 的同步 Drop/Native 回调不等于已经完成异步数据库类。连接跨进程不能复用,也不代表 VM 快照。 + +## 既有数据和部署 + +原先的基础信息存储已从代码中移除。已有数据库表、卷、密码和服务器数据未执行删除或迁移;它们是否存在、有哪些记录不由本次代码修改推断。 + +生产继续保留预置 PostgreSQL 服务与既有数据目录,等待实际类的消费接口。应用容器不注入数据库连接变量,当前不消费该服务。部署版本通过镜像标签、release.env 和 current 链接识别,基础信息写入文件;发布时仅为旧数据做一次只读导入,不继续写数据库。详见 [宿主信息](../operations/host-information.md)。 + +## 验证 + +SQLite 检查先确认新连接没有应用表,再由测试自己创建 class_rows 验证查询和重连。真实 PostgreSQL 使用全新临时测试库,检查连接不建表,并在事务中创建提交后自动删除的临时表。这些是适配层测试,不代表特殊类已实现。 + +文件定位见 [storage](../../crates/storage/README.md),运行与生产资源见 [部署](../operations/deployment.md)。返回 [运行时索引](README.md)。 diff --git a/docs/runtime/lifecycle.md b/docs/runtime/lifecycle.md index 037041a..8327f1c 100644 --- a/docs/runtime/lifecycle.md +++ b/docs/runtime/lifecycle.md @@ -30,6 +30,6 @@ Rust panic 尚无专门的捕获恢复或执行根 RAII 清理机制。宿主捕 Vm 没有独立后台线程、暂停状态或显式 shutdown 状态机。Rust 所有权销毁它所拥有的堆和剩余载荷,包括没有先做循环回收的对象;原生共享资源继续遵循各自的 Rust 所有权。外部 ObjectRef 不保活 Vm。 -Server 的 Tokio 运行时、监听器、信号处理和 Storage 由宿主管理;优雅退出见 [运行](../operations/README.md)。数据库持久化只保留宿主元数据,不能延长或恢复 VM 对象生命周期。 +Server 的 Tokio 运行时、监听器、信号处理和 Host 文件信息由宿主管理;优雅退出见 [运行](../operations/README.md)。当前宿主不持有数据库连接。数据库能力只为未来的特殊类按需提供,异步连接关闭与原生载荷生命周期仍需单独接入,见 [数据库特殊类](database-objects.md)。数据库不自动保存或恢复 VM 对象。 源码定位:[core/src](../../crates/core/src/README.md) 中的 vm.rs、heap.rs、frame.rs、eval.rs。返回 [运行时索引](README.md)。 diff --git a/docs/runtime/objects.md b/docs/runtime/objects.md index 15bf6b0..483f3a6 100644 --- a/docs/runtime/objects.md +++ b/docs/runtime/objects.md @@ -100,6 +100,6 @@ compare 的反向规则是 Lt 对应 Gt、Le 对应 Ge,Eq/Ne 保持不变。 已实现:统一对象结构、稳定地址、代际句柄、身份、显式引用计数、托管属性、原生操作覆盖、比较与哈希、原生调用、资源清理和显式循环回收。 -尚未实现:Python type 系统、语言根类、继承和描述符、源码解析与编译器、可变 VM 调用上下文、自动/分代/增量 GC、语言终结器/复活、完整资源限额和 VM 快照;执行器已有帧数和指令数限额。数据库仍然只保存宿主元数据。 +尚未实现:Python type 系统、语言根类、继承和描述符、源码解析与编译器、可变 VM 调用上下文、自动/分代/增量 GC、语言终结器/复活、完整资源限额和 VM 快照;执行器已有帧数和指令数限额。数据库仅为未来特殊类提供按需连接,普通对象或宿主基础信息不自动持久化,详见 [数据库特殊类](database-objects.md)。 验证范围与执行约定见 [验证](../operations/validation.md)。 diff --git a/docs/runtime/text-encoding.md b/docs/runtime/text-encoding.md new file mode 100644 index 0000000..e6f9b39 --- /dev/null +++ b/docs/runtime/text-encoding.md @@ -0,0 +1,29 @@ +# 文本编码与地区边界 + +## UTF-8 不限定地区 + +Value::Text 与 EndorStringObject 使用 Rust String,始终是有效 UTF-8。日文假名、汉字、中文和 emoji 都可以保存在同一字符串中;运行在日本地区并不要求 VM 改成另一套内部编码。[Rust String](https://doc.rust-lang.org/std/string/struct.String.html#utf-8)。 + +地区、编码、字体和语言规则是不同层面:LC_CTYPE 不决定已有 Rust String 里字符的含义;字体缺失会影响显示,不会把 UTF-8 内容变成另一种文本。 + +## 不同编码的外部数据 + +日本的旧系统或文件可能使用 Shift_JIS/Windows CP932,也可能使用 UTF-8;不能仅根据所在地推断文件编码。Windows 的代码页表中列有日文 932,见 [Microsoft 代码页说明](https://learn.microsoft.com/en-us/windows/win32/intl/code-page-identifiers)。 + +| 输入或输出 | 边界处理 | +| --- | --- | +| 已是 Rust String/明确 UTF-8 JSON | 保持 UTF-8,可直接传入当前 VM | +| Shift_JIS/CP932 等外部字节 | 由宿主 I/O 适配器按明确编码解码成 Unicode,再交给 VM | +| 非法 UTF-8 却声明为 UTF-8 | 使用严格解码返回错误,不默默替换字符 | +| 必须与旧系统交换文本 | 宿主显式选择目标编码;遇到目标编码不能表示的字符时明确报错或由业务另行选择策略 | +| 操作系统路径 | 使用 PathBuf/OsString 保留系统表示,不先强转成 VM 文本 | + +String::from_utf8 验证字节;它不会自动识别 Shift_JIS,也不会自动转换编码。某些错误编码字节可能恰好仍是合法 UTF-8,因此“能通过 UTF-8 校验”也不能代替输入协议。[from_utf8 文档](https://doc.rust-lang.org/std/string/struct.String.html#method.from_utf8)。 + +当前尚无通用文件/网络文本加载器、Shift_JIS 解码器或源码 parser;以上非 UTF-8 转换是未来宿主入口应遵守的边界,不是已经提供的内置转码能力。无需修改全局 locale 来处理单个文件。 + +## 其他日文文本差异 + +当前按 Unicode 标量值访问字符,不按人眼字形簇。预组合的“ガ”和“カ”加组合浊点可显示相似,但标量数量和序列不同;当前没有自动规范化或全角/半角转换。字符串和字典按当前内容语义处理,不为了所在地悄悄改写数据。源码标识符规则尚未定义。 + +宿主基础信息以 UTF-8 JSON 写入;控制键的 ASCII 命名限制属于文件信息协议,值可保存日文,详见 [宿主信息](../operations/host-information.md)。配置与 locale 见 [配置](../operations/configuration.md),返回 [运行时索引](README.md)。 diff --git a/scripts/README.md b/scripts/README.md index e74bf10..c3d8997 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,9 +5,9 @@ | 文件 | 入口与副作用 | | --- | --- | | [check.sh](check.sh) | fmt 检查、workspace 测试、严格 Clippy;会使用 Cargo 依赖和构建产物 | -| [smoke.py](smoke.py) | 标准库 Python 脚本;构建后用 Cargo metadata 定位 cli/server 的 debug 二进制,支持 CARGO_TARGET_DIR;临时 SQLite 数据、HTTP 探测和 SIGTERM 退出,不改生产配置 | +| [smoke.py](smoke.py) | 标准库 Python 脚本;构建后用 Cargo metadata 定位 cli/server 的 debug 二进制,支持 CARGO_TARGET_DIR;文件 metadata 跨进程与日文值;失效数据库变量下独立启动、不创建 SQLite 文件、HTTP 与 SIGTERM,不改生产配置 | | [ci-ces-control.sh](ci-ces-control.sh) | 需要 ASTER_RELEASE_SHA;构建 ci 镜像、创建临时网络/PostgreSQL/检查容器,运行全部检查;退出清理临时容器和网络,保留专属远端 Cargo 缓存卷 | -| [deploy-ces-control.sh](deploy-ces-control.sh) | ces-control root 执行;校验已构建镜像、锁定发布、保留密码和数据、验证新服务后切换 current,失败尝试恢复旧版;保留镜像;应用服务 aster 内调用 cli | +| [deploy-ces-control.sh](deploy-ces-control.sh) | ces-control root 执行;校验已构建镜像、锁定发布、保留密码和数据、验证 HTTP 与无数据库 CLI 引导后切换 current,失败尝试恢复旧版;保留镜像;应用服务 aster 内调用 cli bootstrap,基础信息改写文件;一次性只读导入旧基础信息,不覆盖新文件或删除原库 | CI/部署使用的镜像与路径仅属于本项目,禁止改动其他项目和共享 Runner。smoke.py 临时选择端口后再启动宿主,存在小的端口竞争窗口,指定环境遇到冲突可重试。 diff --git a/scripts/deploy-ces-control.sh b/scripts/deploy-ces-control.sh index c524dc9..046d890 100644 --- a/scripts/deploy-ces-control.sh +++ b/scripts/deploy-ces-control.sh @@ -13,7 +13,9 @@ umask 077 root=/opt/endorphin-aster-vm config=/etc/endorphin-aster-vm/production.env release="$root/releases/$release_sha" -mkdir -p "$release" /etc/endorphin-aster-vm /var/lib/endorphin-aster-vm/postgres +mkdir -p "$release" /etc/endorphin-aster-vm /var/lib/endorphin-aster-vm/postgres /var/lib/endorphin-aster-vm/host +chown 10001:10001 /var/lib/endorphin-aster-vm/host +chmod 700 /var/lib/endorphin-aster-vm/host exec 9>/var/lock/endorphin-aster-vm-deploy.lock flock 9 @@ -26,6 +28,11 @@ chmod 600 "$config" cp docker-compose.ces-control.yml "$release/compose.yml" printf 'ASTER_IMAGE=%s\n' "$image" > "$release/release.env" previous=$(readlink -f "$root/current" 2>/dev/null || true) +legacy_export= +cleanup_export() { + if [ -n "$legacy_export" ]; then rm -f "$legacy_export"; fi +} +trap cleanup_export EXIT compose_at() { location=$1 @@ -45,6 +52,54 @@ if ! compose_at "$release" up -d --no-build --wait --wait-timeout 180; then rollback exit 1 fi +if ! health=$(compose_at "$release" exec -T aster curl --fail --silent http://127.0.0.1:8080/health); then + rollback + exit 1 +fi +printf '%s' "$health" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d == {"status":"ok"}' || { + rollback + exit 1 +} +if ! bootstrap=$(compose_at "$release" exec -T aster cli bootstrap); then + rollback + exit 1 +fi +printf '%s' "$bootstrap" | python3 -c 'import sys; s=sys.stdin.read(); assert "runtime=EndorObject type_system=disabled" in s; assert "released heap_objects=0" in s' || { + rollback + exit 1 +} +# Import legacy basic information once, without changing database records. +# Files are authoritative; cli import only creates records that are missing. +if [ ! -f /var/lib/endorphin-aster-vm/host/.legacy-imported ]; then + if ! has_legacy=$(compose_at "$release" exec -T postgres env PGCLIENTENCODING=UTF8 psql -X -U aster -d aster -A -t -v ON_ERROR_STOP=1 \ + -c "SELECT to_regclass('public.aster_metadata') IS NOT NULL"); then + rollback + exit 1 + fi + if [ "$has_legacy" = t ]; then + legacy_export=$(mktemp) + if ! compose_at "$release" exec -T postgres env PGCLIENTENCODING=UTF8 psql -X -U aster -d aster -A -t -v ON_ERROR_STOP=1 \ + -c "SELECT COALESCE(json_agg(json_build_object('key', key, 'value', value)), '[]'::json) FROM public.aster_metadata" > "$legacy_export"; then + rollback + exit 1 + fi + if ! compose_at "$release" exec -T aster cli metadata import < "$legacy_export"; then + rollback + exit 1 + fi + rm -f "$legacy_export" + legacy_export= + elif [ "$has_legacy" != f ]; then + echo "Cannot determine legacy information schema" >&2 + rollback + exit 1 + fi + if ! touch /var/lib/endorphin-aster-vm/host/.legacy-imported; then + rollback + exit 1 + fi +fi +# File-backed host information; no SQL table or database write. if ! compose_at "$release" exec -T aster cli metadata put deployment.release "$release_sha"; then rollback exit 1 @@ -57,16 +112,8 @@ if [ "$actual" != "$release_sha" ]; then rollback exit 1 fi -if ! health=$(compose_at "$release" exec -T aster curl --fail --silent http://127.0.0.1:8080/health); then - rollback - exit 1 -fi -printf '%s' "$health" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d == {"status":"ok", "storage":"postgresql"}' || { - rollback - exit 1 -} ln -sfn "$release" "$root/current.next" mv -Tf "$root/current.next" "$root/current" compose_at "$release" ps -echo "Deployed release=$release_sha backend=postgresql endpoint=127.0.0.1:18081" +echo "Deployed release=$release_sha postgres=provisioned endpoint=127.0.0.1:18081" echo "Persistent PostgreSQL data: /var/lib/endorphin-aster-vm/postgres" diff --git a/scripts/smoke.py b/scripts/smoke.py index 26646d0..f37f727 100644 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -19,7 +19,6 @@ def run(*args, env): def main(): env = os.environ.copy() - env.pop("DATABASE_URL", None) run("cargo", "build", "--workspace", "--locked", env=env) # Cargo may use a configured CARGO_TARGET_DIR; discover the actual binaries. metadata = json.loads(run("cargo", "metadata", "--no-deps", "--format-version", "1", env=env)) @@ -27,18 +26,23 @@ def main(): cli = str(binary_dir / "cli") server = str(binary_dir / "server") with tempfile.TemporaryDirectory(prefix="aster-smoke-") as directory: + env["ASTER_STATE_DIR"] = str(Path(directory) / "host-info") env["SQLITE_PATH"] = str(Path(directory) / "nested" / "aster.db") - # This must succeed even when an explicit database URL is invalid. - isolated_env = {**env, "DATABASE_URL": "invalid-database-url"} - bootstrap = run(cli, "bootstrap", env=isolated_env) + # Neither executable may use database settings for basic VM information. + env["DATABASE_URL"] = "invalid-database-url" + bootstrap = run(cli, "bootstrap", env=env) assert "runtime=EndorObject type_system=disabled" in bootstrap, bootstrap assert "objects_distinct=true heap_objects=2" in bootstrap, bootstrap assert "released heap_objects=0" in bootstrap, bootstrap - assert not Path(env["SQLITE_PATH"]).exists(), "bootstrap must not open storage" - assert "sqlite" in run(cli, "metadata", "put", "smoke", "持久化成功", env=env) - assert run(cli, "metadata", "get", "smoke", env=env) == "持久化成功" - failed = subprocess.run([cli, "metadata", "get", "smoke"], cwd=ROOT, env=isolated_env, capture_output=True) - assert failed.returncode != 0, "invalid explicit DB config must fail" + assert "stored (file)" in run(cli, "metadata", "put", "project.note", "日本語・中文・😀", env=env) + assert run(cli, "metadata", "get", "project.note", env=env) == "日本語・中文・😀" + legacy = json.dumps([{"key": "project.note", "value": "old"}, + {"key": "legacy.note", "value": "preserved"}]) + subprocess.run([cli, "metadata", "import"], input=legacy, cwd=ROOT, env=env, + check=True, text=True, capture_output=True) + assert run(cli, "metadata", "get", "project.note", env=env) == "日本語・中文・😀" + assert run(cli, "metadata", "get", "legacy.note", env=env) == "preserved" + assert not Path(env["SQLITE_PATH"]).exists(), "CLI must not open a database" with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) @@ -58,7 +62,7 @@ def main(): log.seek(0) raise AssertionError(f"server did not become healthy: {log.read()}") time.sleep(0.1) - assert health == {"status": "ok", "storage": "sqlite"}, health + assert health == {"status": "ok"}, health for _ in range(2): with urllib.request.urlopen(f"http://127.0.0.1:{port}/bootstrap", timeout=2) as response: info = json.load(response) @@ -72,8 +76,12 @@ def main(): process.wait() raise AssertionError("server did not shut down gracefully") assert process.returncode == 0, process.returncode + assert not Path(env["SQLITE_PATH"]).exists(), "Server must not open a database" assert run(cli, "metadata", "get", "host.version", env=env) == "0.1.0" - print("PASS: bootstrap isolation; SQLite persistence; config failure; HTTP health/bootstrap; SIGTERM shutdown") + record = json.loads((Path(env["ASTER_STATE_DIR"]) / "host.version.json").read_text(encoding="utf-8")) + assert record == {"format_version": 1, "key": "host.version", "value": "0.1.0"} + assert run(cli, "metadata", "get", "project.note", env=env) == "日本語・中文・😀" + print("PASS: no implicit database access; file metadata persistence; HTTP health/bootstrap; SIGTERM shutdown") if __name__ == "__main__":