Initialize Aster VM and deploy PostgreSQL host on ces-control
Verify databases and deploy Aster VM / verify-and-deploy (push) Waiting to run

This commit is contained in:
2026-10-17 23:42:39 +08:00
commit 9648b9e8dd
34 changed files with 3717 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
target
data
work
outputs
.env
*.db
*.db-shm
*.db-wal
+10
View File
@@ -0,0 +1,10 @@
# Docker Compose reads .env automatically; cargo programs read exported env only.
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.
POSTGRES_PASSWORD=replace-with-a-random-hex-password
+33
View File
@@ -0,0 +1,33 @@
name: Verify databases and deploy Aster VM
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: endorphin-aster-vm-production
cancel-in-progress: false
jobs:
verify-and-deploy:
runs-on: ces-control
timeout-minutes: 60
env:
DOCKER_BUILDKIT: "0"
ASTER_RELEASE_SHA: ${{ gitea.sha }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Verify SQLite, PostgreSQL and host behavior
run: sh scripts/ci-ces-control.sh
- name: Build production image
run: docker build --target runtime -t "endorphin-aster-vm:$ASTER_RELEASE_SHA" .
- name: Deploy persistent PostgreSQL host
if: gitea.ref == 'refs/heads/main'
run: sh scripts/deploy-ces-control.sh
+9
View File
@@ -0,0 +1,9 @@
/target/
/data/
/work/
/outputs/
.env
*.db
*.db-shm
*.db-wal
__pycache__/
+27
View File
@@ -0,0 +1,27 @@
# Aster VM 工作约定
## 项目边界
- 用中文沟通与编写交接文档。内核只负责 VM、堆和对象语义;数据库、网络、浏览器能力属于宿主。
- 不实现词法、语法、渲染器、完整元类体系或默认 Python 兼容性。计划与已实现功能须分清。
- `aster-core` 保持零依赖。类对象及描述在堆中,实例引用类;根类 Object 唯一且没有父类。
- 句柄通过 VM 检查归属及对象种类,不暴露原始指针,不允许宿主构造堆索引。
- 默认持久化 SQLite;显式 PostgreSQL 配置失败必须报错,不静默降级。存储不代表 VM 堆快照。
- 用户明确禁止本地测试:不在本地运行测试或冒烟脚本,不为本地测试安装/下载 SQLite 或其他依赖。检查脚本保留供用户未来指定的 CI/部署环境使用;静态文件检查与 Git 配置检查可执行。未执行的验证须如实说明。
- 已指定 ces-control 为远端 CI/部署环境;CI 必须验证 SQLite 与真实 PostgreSQL,正式持久部署选择 PostgreSQL。禁止改动其他项目或共享 Runner,特别是 autodl-gpu-runner-controller。
- 已按用户提供的准确 URL 配置 origin;不创建远程仓库、不猜测 URL。未经用户明确授权,不 commit、不 push。
## Gitea Entroia 身份
- 对远程 host 为 `gitea.entroia.com` 的仓库,只使用专用账户 `codex-bot`;不得使用 `Zayden-Jung` 身份或其 SSH 密钥、凭据。
- 提交前在仓库范围设置 author:`Codex Bot` / `codex-bot@gitea.entroia.local`,不影响其他仓库。
- SSH 必须隔离现有配置和身份:`ssh -F /dev/null -o IdentitiesOnly=yes -i /Users/zzyang/.codex/credentials/gitea-entroia/codex_entroia_ed25519 -p 22222`。在仓库范围设置 `core.sshCommand` 为该命令。
- 远程格式:`ssh://git@gitea.entroia.com:22222/<owner>/<repo>.git`。SSH 用户为 `git`,由专用密钥识别为 `codex-bot`。owner 与认证身份分开,即使 owner 是 `Zayden-Jung` 也不切换凭据。
- Gitea API/HTTPS 认证只能在进程内从 macOS Keychain 读取 service `gitea.entroia.com`、account `codex-bot` 的密码。禁止打印、记录、提交、写入仓库或指令文件;禁止将密码或 Authorization 头放入命令行参数或磁盘。
- bot 权限不足时完成能做的本地工作,再请用户为 bot 授权,不借用其他账户。
## Git 操作后报告
- 每次 commit 或 push 后,中文最终报告须覆盖本次所有文件,包括新增、删除和重命名;说明各文件的功能部分、具体变化及目的。可按共同目的分组,无需粘贴源码、行号或逐行 diff。
- 同时简报 commit 标识、push 是否成功、测试或检查结果;未执行或失败如实说明。
- 这些是面向用户的结果报告要求,不写入 commit message;commit message 只清晰描述实际变更。
Generated
+1873
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[workspace]
members = ["crates/aster-core", "crates/aster-storage", "crates/aster-cli", "crates/aster-server"]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.91"
license = "MIT"
[workspace.dependencies]
aster-core = { path = "crates/aster-core" }
aster-storage = { path = "crates/aster-storage" }
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "tls-rustls-ring", "sqlite", "postgres"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "fs"] }
[workspace.lints.rust]
unsafe_code = "forbid"
+31
View File
@@ -0,0 +1,31 @@
FROM rust:1.91.1-bookworm AS source
WORKDIR /build
ENV CARGO_BUILD_JOBS=1
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
COPY scripts ./scripts
FROM source AS ci
RUN apt-get update && apt-get install -y --no-install-recommends python3 \
&& rm -rf /var/lib/apt/lists/* \
&& rustup component add rustfmt clippy
ENV CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0
CMD ["sh", "scripts/check.sh"]
FROM source AS build
RUN cargo build --locked --release -p aster-server -p aster-cli
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
COPY --from=build /build/target/release/aster-server /usr/local/bin/aster-server
COPY --from=build /build/target/release/aster /usr/local/bin/aster
USER 10001
WORKDIR /data
ENV ASTER_LISTEN=0.0.0.0:8080 SQLITE_PATH=/data/aster.db
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
CMD ["aster-server"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Aster VM contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+166
View File
@@ -0,0 +1,166 @@
# Aster VM
Rust 编写的可嵌入语言运行时,当前为第一阶段:启动时引导根类 `Object`,并由对象工厂创建类和实例。这里的“顶级类”指根类;“生成器”指运行时引导与对象工厂。
本项目尚无词法/语法前端、字节码执行器、渲染器、GC 或完整元类体系,不承诺 Python 兼容。数据库只保存宿主元数据,不保存或恢复 VM 堆。
## 结构
| crate | 职责 |
| --- | --- |
| `aster-core` | 零外部依赖的 `Value`、安全句柄、堆、类、实例、VM 与工厂 |
| `aster-storage` | SQLx PostgreSQL/SQLite 适配、元数据表初始化、读写接口;不依赖内核 |
| `aster-cli` | `aster bootstrap` 演示,以及宿主元数据读写 |
| `aster-server` | 最小常驻宿主,仅提供健康检查与根类信息,供 Docker 等方式运行 |
依赖方向:CLI/Server → Core 和 Storage;Core 与 Storage 互不依赖。未来浏览器宿主的网络、DOM、渲染等能力应留在宿主一侧。
## 直接运行
使用 Rust 1.91 或更高兼容版本;当前实测版本为 1.91.1,依赖锁定在 `Cargo.lock`。不需要 Docker。首次构建需要访问 crates.io。
```sh
cargo build --workspace --locked
cargo run -p aster-cli -- bootstrap
```
预期输出含 `root=Object parent=None`、`instances_distinct=true heap_objects=4`。这条命令不读取数据库配置、不打开数据库;即使设置了错误的 `DATABASE_URL` 也不影响引导。
```rust
use aster_core::{Value, Vm};
fn main() -> Result<(), aster_core::VmError> {
let mut vm = Vm::new()?;
let point = vm.create_class("Point", vm.root_class())?;
let instance = vm.create_instance(point)?;
vm.set_field(instance, "x", Value::Integer(42))?;
assert_eq!(vm.instance(instance)?.class(), point);
Ok(())
}
```
嵌入时将 `aster-core` 加为 Cargo 路径依赖即可。上例与 crate 文档中的可运行示例语义相同。
## 宿主存储
不设置 `DATABASE_URL` 时,使用 `SQLITE_PATH` 指定的 SQLite 文件,默认 `data/aster.db`,相对于进程工作目录;自动创建父目录及文件。部署时必须把该目录放在持久卷上。`SQLITE_PATH=:memory:` 仅用于临时运行/测试,不持久化。
```sh
cargo run -p aster-cli -- metadata put project 'Aster VM'
cargo run -p aster-cli -- metadata get project
SQLITE_PATH=work/custom/aster.db cargo run -p aster-cli -- metadata put stage bootstrap
```
PostgreSQL 通过导出的 `DATABASE_URL=postgres://...` 或 `postgresql://...` 配置,优先于 `SQLITE_PATH`。显式空值、错误协议、连接失败都会返回错误并以非零状态退出,绝不静默降级。`DATABASE_URL` 不接受 SQLite URL;SQLite 使用独立路径配置。
连接成功后执行 `schema.sql` 中幂等的 v1 建表语句,提供 `Storage::put/get/ping/close`。读写使用参数绑定,重复键执行更新,缺失键返回 `None`。尚无复杂迁移版本管理。
Cargo 程序**不自动加载 `.env`**,请导出需要的环境变量。`.env.example` 是配置示例;Docker Compose 会自动读取复制后的 `.env`。连接 URL 可能含密码,不要贴到日志或提交到 Git。命令行只显示脱敏错误,底层诊断作为 Rust error source 保留给宿主。
## 常驻宿主
```sh
cargo run -p aster-server
curl --fail http://127.0.0.1:8080/health
curl --fail http://127.0.0.1:8080/bootstrap
```
`ASTER_LISTEN` 默认 `127.0.0.1:8080`。宿主启动时创建 VM、连接数据库、初始化表并写入 `host.version=0.1.0`;初始化失败即退出。`/health` 实际探测数据库,成功返回 200,失联返回 503;`/bootstrap` 返回 `Object`、无父类、堆对象数 1,多次读取不会重复引导。支持 Ctrl-C/Unix SIGTERM 优雅退出。
这两个端点不提供执行语言、对象写入或业务 API。服务没有认证层;示例 Compose 端口仅绑定宿主回环地址,外部访问由后续宿主部署决定。
## Docker:默认 SQLite
```sh
cp .env.example .env
docker compose up --build -d
curl --fail http://127.0.0.1:8080/health
docker compose exec aster aster metadata put project 'Aster VM'
docker compose exec aster aster metadata get project
docker compose down
docker compose up -d
docker compose exec aster aster metadata get project
```
容器以 UID 10001 运行,`aster-sqlite` 命名卷挂载到 `/data`,保存数据库和 WAL 文件。`down` 保留卷;`down -v` 会删除持久数据。构建采用多阶段 Dockerfile,运行镜像同时含 `aster-server` 和 `aster`。Compose 的 SQLite 方案不会透传宿主 `DATABASE_URL`,以保证默认方案明确使用 SQLite。
## Docker:选择 PostgreSQL
先将 `.env` 中的 `POSTGRES_PASSWORD` 换成随机十六进制字符串(可用 `openssl rand -hex 24` 生成)。该值同时用于数据库和连接 URL;使用其他字符时需要自行正确处理 URL 编码。
```sh
docker compose -f docker-compose.postgres.yml up --build -d
docker compose -f docker-compose.postgres.yml exec aster aster metadata put project 'Aster VM'
docker compose -f docker-compose.postgres.yml exec aster aster metadata get project
docker compose -f docker-compose.postgres.yml down
```
这是**独立替代方案**,只传入这一个 Compose 文件,不与默认文件叠加;两个方案不要同时占用同一个宿主端口。PostgreSQL 17 使用 `aster-postgres` 命名卷,数据库不向宿主公开端口。示例内部网络禁用数据库 TLS;跨不可信网络的部署应使用 PostgreSQL TLS 验证配置,例如 URL 参数 `sslmode=verify-full`。更改已有 PostgreSQL 卷的密码需要数据库管理操作,修改 `.env` 不会修改旧卷中的用户密码。
## ces-control CI/CD 与持久化部署
正式部署选择 PostgreSQL;默认 SQLite Compose 仍保留作为独立运行选项。
`.gitea/workflows/ci-deploy.yml` 在 `ces-control` Runner 上响应 `main` push 和手动触发:先在远端 Docker 容器中执行 fmt、workspace 测试、严格 Clippy、真实 PostgreSQL 集成测试和 SQLite/HTTP 冒烟检查,通过后构建生产镜像,再部署。使用独立的临时 PostgreSQL 容器做测试,不连接生产数据库。测试容器与网络在退出时清理,编译缓存仅保留在远端专属 Docker 卷中。
正式服务由 `docker-compose.ces-control.yml` 管理,默认仅绑定 `127.0.0.1:18081`,内部连接 PostgreSQL 17。应用/数据库使用 `unless-stopped` 自动重启,日志轮换;数据库不发布端口。部署使用 commit SHA 标记镜像与 release 目录,健康检查或元数据读写失败时尝试恢复上一版应用,保留数据库。
| 服务器路径 | 用途 |
| --- | --- |
| `/opt/endorphin-aster-vm/releases/<sha>/` | 固定版本的 Compose 与镜像配置 |
| `/opt/endorphin-aster-vm/current` | 最近健康版本的符号链接 |
| `/etc/endorphin-aster-vm/production.env` | 首次自动生成的数据库密码和端口,权限 0600 |
| `/var/lib/endorphin-aster-vm/postgres` | PostgreSQL 持久数据,替换应用/数据库容器后保留 |
无需在 Gitea 中设置生产数据库密码。不要删除或重新生成已有数据库对应的 `production.env`;修改密码需先通过 PostgreSQL 管理操作完成同步。首次部署没有可用的旧版可以回退;数据库 schema 的未来升级也需要另行设计兼容性,应用回退不等于回退数据库。
在服务器查看/停止当前部署(停止不删除数据):
```sh
docker compose -p endorphin-aster-vm \
--env-file /etc/endorphin-aster-vm/production.env \
--env-file /opt/endorphin-aster-vm/current/release.env \
-f /opt/endorphin-aster-vm/current/compose.yml ps
curl --fail http://127.0.0.1:18081/health
curl --fail http://127.0.0.1:18081/bootstrap
# 同样参数接 stop 停服,接 up -d --wait 恢复。
```
本机不执行上述 CI 测试,也不为其安装 Rust/SQLite/PostgreSQL。正式部署与测试只运行在用户指定的 `ces-control`。该工作流不改动 `autodl-gpu-runner-controller`、rednote 或 TiktokUI。工作流语义依据 [Gitea Actions 文档](https://docs.gitea.com/usage/actions/),环境文件加载依据 [Docker Compose 文档](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/)。
## 验证(在用户指定的环境执行)
当前工作约定禁止在本地运行测试,也不为本地测试下载依赖。下面的脚本和示例在指定的 ces-control CI/部署环境执行,不代表每次修改后已全部执行。实际结果及此前清理情况见 [验证记录](docs/validation.md)。
```sh
sh scripts/check.sh
python3 scripts/smoke.py
```
检查脚本运行 fmt、workspace 测试和拒绝所有 warning 的 Clippy;冒烟脚本使用临时 SQLite 文件,验证独立引导、跨进程持久化、配置错误、HTTP 端点和 SIGTERM 退出,不需要外部数据库。
PostgreSQL 集成测试默认显式忽略,需要测试数据库。以下是指定验证环境中的 Docker 示例;仅操作专用测试容器,不使用生产库,当前不在本机执行:
```sh
docker run --rm -d --name aster-pg-test \
-e POSTGRES_PASSWORD=aster-test -e POSTGRES_USER=aster -e POSTGRES_DB=aster \
-p 127.0.0.1:55432:5432 postgres:17-bookworm
# 等待以下命令成功后再运行测试:
docker exec aster-pg-test pg_isready -U aster -d aster
ASTER_TEST_POSTGRES_URL='postgres://aster:aster-test@127.0.0.1:55432/aster?sslmode=disable' \
cargo test -p aster-storage --test postgres --locked -- --ignored
docker stop aster-pg-test
```
测试会留下唯一键的元数据,专用测试库可随容器删除。缺少测试 URL 时显式运行该测试会失败,不会假装通过。真实 PostgreSQL 成功路径及容器结果以 [验证记录](docs/validation.md) 为准。
## 当前语义与后续开发
- `Object` 是每个 VM 启动时唯一分配的根类,没有父类。类描述与类对象合为一个堆对象;实例保存类句柄。普通类必须指定已有父类,名称只是标签,无全局命名空间;保留精确名称 `Object`,其他同名类允许存在。
- 对外只提供私有字段的 `ObjectRef`/`ClassRef`/`InstanceRef`。句柄包含进程内 VM 身份与槽位,所有访问检查归属、范围、种类;写入对象字段也检查引用归属。句柄不是持久 ID,不支持外部构造或反序列化。
- 堆仅分配,不单独回收、不复用槽位;随 VM 销毁统一释放。宿主持有的句柄不延长 VM 生命周期。未来 GC 需要另行设计根集、代际槽位或稳定间接句柄,不能直接开始复用当前索引。
- Nil/布尔/整数/浮点直接放入 `Value`,字符串由 Rust `String` 持有,类和实例使用堆句柄。尚未定义完整语言数值、方法查找、继承执行或异常语义。
下一阶段建议先确定字段/方法查找与宿主调用边界,再设计可测的最小指令执行循环;等对象可达性规则明确后引入 GC。详见 [架构](docs/architecture.md) 与 [开发交接](docs/development.md)。
依赖 API 依据 [SQLx 0.8.6 官方文档](https://docs.rs/sqlx/0.8.6/sqlx/) 和 [Axum 官方文档](https://docs.rs/axum/0.8.9/axum/) 实现。SQLx 0.9 要求更高 Rust 版本,本阶段使用与本机工具链兼容的 0.8.6;确切解析版本以锁文件为准。
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "aster-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[[bin]]
name = "aster"
path = "src/main.rs"
[dependencies]
aster-core.workspace = true
aster-storage.workspace = true
tokio.workspace = true
[lints]
workspace = true
+66
View File
@@ -0,0 +1,66 @@
use std::error::Error;
use std::process::ExitCode;
use aster_core::{Value, Vm};
use aster_storage::{Storage, StorageConfig};
const USAGE: &str = "Usage: aster [bootstrap | metadata put <key> <value> | metadata get <key>]";
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("aster: {error}");
ExitCode::FAILURE
}
}
}
async fn run() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.as_slice()
{
[] | ["bootstrap"] => {
let mut vm = Vm::new()?;
let root = vm.bootstrap();
let class = vm.create_class("Example", root)?;
let first = vm.create_instance(class)?;
let second = vm.create_instance(class)?;
vm.set_field(first, "message", Value::Text("Hello from Aster VM".into()))?;
println!(
"root={} parent={:?}",
vm.class(root)?.name(),
vm.class(root)?.parent()
);
println!(
"class={} instances_distinct={} heap_objects={}",
vm.class(class)?.name(),
first != second,
vm.object_count()
);
println!("bootstrap is independent of host storage");
}
["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;
}
["metadata", "get", key] => {
let store = Storage::connect(&StorageConfig::from_env()?).await?;
println!(
"{}",
store.get(key).await?.as_deref().unwrap_or("(missing)")
);
store.close().await;
}
_ => return Err(USAGE.into()),
}
Ok(())
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "aster-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
+187
View File
@@ -0,0 +1,187 @@
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::VmError;
static NEXT_HEAP_ID: AtomicU64 = AtomicU64::new(1);
/// Opaque process-local identity. Cannot be constructed or deserialized by callers.
/// Handles do not keep the VM alive; every dereference goes through its owner VM.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ObjectRef {
heap_id: u64,
index: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ClassRef(pub(crate) ObjectRef);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct InstanceRef(pub(crate) ObjectRef);
impl From<ClassRef> for ObjectRef {
fn from(value: ClassRef) -> Self {
value.0
}
}
impl From<InstanceRef> for ObjectRef {
fn from(value: InstanceRef) -> Self {
value.0
}
}
/// Primitive values are immediate; only classes and instances live on the heap.
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Nil,
Bool(bool),
Integer(i64),
Float(f64),
Text(String),
Object(ObjectRef),
}
/// The class object and its immutable description are one heap allocation.
/// There is deliberately no metaclass or implicit class-of-class relationship.
#[derive(Debug)]
pub struct Class {
pub(crate) name: String,
pub(crate) parent: Option<ClassRef>,
}
impl Class {
pub fn name(&self) -> &str {
&self.name
}
pub fn parent(&self) -> Option<ClassRef> {
self.parent
}
}
#[derive(Debug)]
pub struct Instance {
pub(crate) class: ClassRef,
pub(crate) fields: BTreeMap<String, Value>,
}
impl Instance {
pub fn class(&self) -> ClassRef {
self.class
}
pub fn field(&self, name: &str) -> Option<&Value> {
self.fields.get(name)
}
}
#[derive(Debug)]
pub enum Object {
Class(Class),
Instance(Instance),
}
/// Allocation-only arena. No deletion or slot reuse until a GC model is chosen.
#[derive(Debug)]
pub(crate) struct Heap {
id: u64,
objects: Vec<Object>,
}
impl Heap {
pub(crate) fn new() -> Result<Self, VmError> {
let id = NEXT_HEAP_ID
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
.map_err(|_| VmError::IdentityExhausted)?;
Ok(Self {
id,
objects: Vec::new(),
})
}
pub(crate) fn allocate(&mut self, object: Object) -> ObjectRef {
let reference = ObjectRef {
heap_id: self.id,
index: self.objects.len(),
};
self.objects.push(object);
reference
}
pub(crate) fn get(&self, reference: ObjectRef) -> Result<&Object, VmError> {
self.validate_owner(reference)?;
self.objects
.get(reference.index)
.ok_or(VmError::InvalidHandle)
}
pub(crate) fn get_mut(&mut self, reference: ObjectRef) -> Result<&mut Object, VmError> {
self.validate_owner(reference)?;
self.objects
.get_mut(reference.index)
.ok_or(VmError::InvalidHandle)
}
fn validate_owner(&self, reference: ObjectRef) -> Result<(), VmError> {
if reference.heap_id != self.id {
return Err(VmError::ForeignHandle);
}
Ok(())
}
pub(crate) fn len(&self) -> usize {
self.objects.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_index_is_an_error_for_both_access_paths() {
let mut heap = Heap::new().unwrap();
let invalid = ObjectRef {
heap_id: heap.id,
index: usize::MAX,
};
assert!(matches!(heap.get(invalid), Err(VmError::InvalidHandle)));
assert!(matches!(heap.get_mut(invalid), Err(VmError::InvalidHandle)));
}
#[test]
fn typed_handles_are_checked_at_runtime_too() {
let mut vm = crate::Vm::new().unwrap();
let root = vm.root_class();
let instance = vm.create_instance(root).unwrap();
assert!(matches!(
vm.class(ClassRef(instance.0)),
Err(VmError::ExpectedClass)
));
assert!(matches!(
vm.instance(InstanceRef(root.0)),
Err(VmError::ExpectedInstance)
));
}
#[test]
fn invalid_value_reference_cannot_replace_a_field() {
let mut vm = crate::Vm::new().unwrap();
let root = vm.root_class();
let instance = vm.create_instance(root).unwrap();
vm.set_field(instance, "field", Value::Integer(1)).unwrap();
let invalid = ObjectRef {
heap_id: root.0.heap_id,
index: usize::MAX,
};
assert_eq!(
vm.set_field(instance, "field", Value::Object(invalid)),
Err(VmError::InvalidHandle)
);
assert_eq!(
vm.instance(instance).unwrap().field("field"),
Some(&Value::Integer(1))
);
}
}
+17
View File
@@ -0,0 +1,17 @@
//! Embeddable object runtime. No database, network, parser or executor dependencies.
//!
//! ```
//! use aster_core::{Value, Vm};
//! let mut vm = Vm::new()?;
//! let point = vm.create_class("Point", vm.root_class())?;
//! let instance = vm.create_instance(point)?;
//! vm.set_field(instance, "x", Value::Integer(42))?;
//! assert_eq!(vm.instance(instance)?.class(), point);
//! # Ok::<(), aster_core::VmError>(())
//! ```
mod heap;
mod vm;
pub use heap::{Class, ClassRef, Instance, InstanceRef, Object, ObjectRef, Value};
pub use vm::{Vm, VmError};
+146
View File
@@ -0,0 +1,146 @@
use std::collections::BTreeMap;
use std::fmt;
use crate::heap::Heap;
use crate::{Class, ClassRef, Instance, InstanceRef, Object, ObjectRef, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VmError {
ForeignHandle,
InvalidHandle,
ExpectedClass,
ExpectedInstance,
EmptyClassName,
ReservedClassName,
IdentityExhausted,
}
impl fmt::Display for VmError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::ForeignHandle => "handle belongs to another VM",
Self::InvalidHandle => "handle does not identify a live object",
Self::ExpectedClass => "object is not a class",
Self::ExpectedInstance => "object is not an instance",
Self::EmptyClassName => "class name cannot be blank",
Self::ReservedClassName => "Object is reserved for the root class",
Self::IdentityExhausted => "process-local VM identities exhausted",
})
}
}
impl std::error::Error for VmError {}
/// Owns its heap and exactly one root class. Not Clone: handles have one owner.
#[derive(Debug)]
pub struct Vm {
heap: Heap,
root: ClassRef,
}
impl Vm {
/// Bootstraps Object before returning. Does not perform any host I/O.
pub fn new() -> Result<Self, VmError> {
let mut heap = Heap::new()?;
let root = ClassRef(heap.allocate(Object::Class(Class {
name: "Object".into(),
parent: None,
})));
Ok(Self { heap, root })
}
/// Idempotent: construction has already performed bootstrap exactly once.
pub fn bootstrap(&mut self) -> ClassRef {
self.root
}
pub fn root_class(&self) -> ClassRef {
self.root
}
pub fn object_count(&self) -> usize {
self.heap.len()
}
/// All user classes have an existing parent. Class names are labels rather
/// than a global registry; duplicate non-reserved names are permitted.
pub fn create_class(
&mut self,
name: impl Into<String>,
parent: ClassRef,
) -> Result<ClassRef, VmError> {
self.class(parent)?;
let name = name.into();
if name.trim().is_empty() {
return Err(VmError::EmptyClassName);
}
if name == "Object" {
return Err(VmError::ReservedClassName);
}
Ok(ClassRef(self.heap.allocate(Object::Class(Class {
name,
parent: Some(parent),
}))))
}
pub fn create_instance(&mut self, class: ClassRef) -> Result<InstanceRef, VmError> {
self.class(class)?;
Ok(InstanceRef(self.heap.allocate(Object::Instance(
Instance {
class,
fields: BTreeMap::new(),
},
))))
}
pub fn object(&self, reference: ObjectRef) -> Result<&Object, VmError> {
self.heap.get(reference)
}
pub fn class(&self, reference: ClassRef) -> Result<&Class, VmError> {
match self.heap.get(reference.0)? {
Object::Class(class) => Ok(class),
Object::Instance(_) => Err(VmError::ExpectedClass),
}
}
pub fn instance(&self, reference: InstanceRef) -> Result<&Instance, VmError> {
match self.heap.get(reference.0)? {
Object::Instance(instance) => Ok(instance),
Object::Class(_) => Err(VmError::ExpectedInstance),
}
}
/// Converts a generic reference only after validating ownership and kind.
pub fn as_class(&self, reference: ObjectRef) -> Result<ClassRef, VmError> {
let typed = ClassRef(reference);
self.class(typed)?;
Ok(typed)
}
pub fn as_instance(&self, reference: ObjectRef) -> Result<InstanceRef, VmError> {
let typed = InstanceRef(reference);
self.instance(typed)?;
Ok(typed)
}
/// Checks both destination and referenced value before changing any field.
pub fn set_field(
&mut self,
instance: InstanceRef,
name: impl Into<String>,
value: Value,
) -> Result<(), VmError> {
self.instance(instance)?;
if let Value::Object(reference) = &value {
self.heap.get(*reference)?;
}
match self.heap.get_mut(instance.0)? {
Object::Instance(instance) => {
instance.fields.insert(name.into(), value);
Ok(())
}
Object::Class(_) => Err(VmError::ExpectedInstance),
}
}
}
+120
View File
@@ -0,0 +1,120 @@
use aster_core::{Value, Vm, VmError};
#[test]
fn bootstrap_is_unique_and_root_has_no_parent() {
let mut vm = Vm::new().unwrap();
let root = vm.root_class();
assert_eq!(vm.bootstrap(), root);
assert_eq!(vm.bootstrap(), root);
assert_eq!(vm.object_count(), 1);
assert_eq!(vm.class(root).unwrap().name(), "Object");
assert_eq!(vm.class(root).unwrap().parent(), None);
}
#[test]
fn factories_preserve_class_links_and_distinct_identity() {
let mut vm = Vm::new().unwrap();
let root = vm.root_class();
let class = vm.create_class("Point", root).unwrap();
let child = vm.create_class("ColoredPoint", class).unwrap();
let first = vm.create_instance(class).unwrap();
let second = vm.create_instance(class).unwrap();
let plain = vm.create_instance(root).unwrap();
assert_ne!(first, second);
assert_eq!(vm.class(child).unwrap().parent(), Some(class));
assert_eq!(vm.instance(first).unwrap().class(), class);
assert_eq!(vm.instance(plain).unwrap().class(), root);
vm.set_field(first, "x", Value::Integer(1)).unwrap();
vm.set_field(first, "next", Value::Object(second.into()))
.unwrap();
assert_eq!(
vm.instance(first).unwrap().field("x"),
Some(&Value::Integer(1))
);
assert_eq!(vm.instance(second).unwrap().field("x"), None);
assert_eq!(vm.as_class(class.into()).unwrap(), class);
assert_eq!(vm.as_instance(first.into()).unwrap(), first);
assert_eq!(vm.as_instance(class.into()), Err(VmError::ExpectedInstance));
assert_eq!(vm.as_class(first.into()), Err(VmError::ExpectedClass));
}
#[test]
fn foreign_handles_are_rejected_without_mutation() {
let mut vm = Vm::new().unwrap();
let mut other = Vm::new().unwrap();
let local = vm.create_instance(vm.root_class()).unwrap();
let foreign = other.create_instance(other.root_class()).unwrap();
let count = vm.object_count();
assert_eq!(
vm.create_class("Alien", other.root_class()),
Err(VmError::ForeignHandle)
);
assert_eq!(
vm.create_instance(other.root_class()),
Err(VmError::ForeignHandle)
);
assert!(matches!(
vm.object(foreign.into()),
Err(VmError::ForeignHandle)
));
assert!(matches!(vm.instance(foreign), Err(VmError::ForeignHandle)));
assert_eq!(
vm.set_field(foreign, "x", Value::Nil),
Err(VmError::ForeignHandle)
);
vm.set_field(local, "x", Value::Integer(7)).unwrap();
assert_eq!(
vm.set_field(local, "x", Value::Object(foreign.into())),
Err(VmError::ForeignHandle)
);
assert_eq!(
vm.instance(local).unwrap().field("x"),
Some(&Value::Integer(7))
);
assert_eq!(vm.object_count(), count);
}
#[test]
fn handles_outliving_a_vm_cannot_alias_a_new_vm() {
let old = {
let vm = Vm::new().unwrap();
vm.root_class()
};
let vm = Vm::new().unwrap();
assert_ne!(old, vm.root_class());
assert!(matches!(vm.class(old), Err(VmError::ForeignHandle)));
}
#[test]
fn invalid_names_do_not_allocate_and_names_are_not_a_namespace() {
let mut vm = Vm::new().unwrap();
assert_eq!(
vm.create_class(" ", vm.root_class()),
Err(VmError::EmptyClassName)
);
assert_eq!(
vm.create_class("Object", vm.root_class()),
Err(VmError::ReservedClassName)
);
assert_eq!(vm.object_count(), 1);
let a = vm.create_class("Label", vm.root_class()).unwrap();
let b = vm.create_class("Label", vm.root_class()).unwrap();
assert_ne!(a, b);
}
#[test]
fn handles_remain_valid_when_the_arena_grows() {
let mut vm = Vm::new().unwrap();
let root = vm.root_class();
let first = vm.create_instance(root).unwrap();
vm.set_field(first, "stable", Value::Bool(true)).unwrap();
for _ in 0..4096 {
vm.create_instance(root).unwrap();
}
assert_eq!(vm.instance(first).unwrap().class(), root);
assert_eq!(
vm.instance(first).unwrap().field("stable"),
Some(&Value::Bool(true))
);
assert_eq!(vm.class(root).unwrap().name(), "Object");
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "aster-server"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
aster-core.workspace = true
aster-storage.workspace = true
axum.workspace = true
serde.workspace = true
tokio.workspace = true
[lints]
workspace = true
+126
View File
@@ -0,0 +1,126 @@
use std::error::Error;
use std::future::Future;
use std::process::ExitCode;
use std::sync::Arc;
use aster_core::Vm;
use aster_storage::{Storage, StorageConfig};
use axum::{Json, Router, extract::State, http::StatusCode, routing::get};
use serde::Serialize;
#[derive(Clone)]
struct AppState {
vm: Arc<Vm>,
storage: Storage,
}
#[derive(Serialize)]
struct Health {
status: &'static str,
storage: &'static str,
}
#[derive(Serialize)]
struct BootstrapInfo {
root_class: String,
root_has_parent: bool,
heap_objects: usize,
}
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("aster-server: {error}");
ExitCode::FAILURE
}
}
}
async fn run() -> Result<(), Box<dyn Error>> {
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()?;
let app = Router::new()
.route("/health", get(health))
.route("/bootstrap", get(bootstrap))
.with_state(AppState {
vm,
storage: storage.clone(),
});
println!(
"Aster host listening on {} (storage={})",
listener.local_addr()?,
storage.backend_name()
);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await?;
storage.close().await;
Ok(())
}
async fn health(State(state): State<AppState>) -> (StatusCode, Json<Health>) {
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 bootstrap(State(state): State<AppState>) -> Json<BootstrapInfo> {
let root = state
.vm
.class(state.vm.root_class())
.expect("VM construction guarantees a root class");
Json(BootstrapInfo {
root_class: root.name().into(),
root_has_parent: root.parent().is_some(),
heap_objects: state.vm.object_count(),
})
}
fn shutdown_signal() -> std::io::Result<impl Future<Output = ()>> {
#[cfg(unix)]
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
Ok(async move {
#[cfg(unix)]
tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} }
#[cfg(not(unix))]
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");
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "aster-storage"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
sqlx.workspace = true
tokio.workspace = true
[dev-dependencies]
tempfile = "3"
[lints]
workspace = true
+249
View File
@@ -0,0 +1,249 @@
//! Host metadata storage; deliberately has no dependency on aster-core.
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
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 {
SqliteFile(PathBuf),
SqliteMemory,
Postgres(String),
}
impl fmt::Debug for StorageConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SqliteFile(path) => f.debug_tuple("SqliteFile").field(path).finish(),
Self::SqliteMemory => f.write_str("SqliteMemory"),
Self::Postgres(_) => f.write_str("Postgres([REDACTED])"),
}
}
}
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<Self, StorageError> {
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<Self, StorageError> {
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<Option<String>, 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 {
Config(&'static str),
Directory(std::io::Error),
Database(sqlx::Error),
}
impl fmt::Display for StorageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Config(message) => f.write_str(message),
Self::Directory(_) => f.write_str("cannot create SQLite parent directory"),
// CLI/HTTP logs must never print a connection URL or credentials.
Self::Database(_) => {
f.write_str("database operation failed; no fallback was attempted")
}
}
}
}
impl std::error::Error for StorageError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Config(_) => None,
Self::Directory(error) => Some(error),
Self::Database(error) => Some(error),
}
}
}
impl From<sqlx::Error> for StorageError {
fn from(error: sqlx::Error) -> Self {
Self::Database(error)
}
}
#[derive(Clone)]
enum Backend {
Sqlite(SqlitePool),
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.
#[derive(Clone)]
pub struct Storage {
backend: Backend,
}
impl Storage {
/// Connects eagerly and initializes the v1 metadata schema before returning.
pub async fn connect(config: &StorageConfig) -> Result<Self, StorageError> {
let backend = match config {
StorageConfig::Postgres(url) => {
let options = PgConnectOptions::from_str(url)
.map_err(|_| StorageError::Config("invalid PostgreSQL connection URL"))?;
Backend::Postgres(
PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(5))
.connect_with(options)
.await?,
)
}
StorageConfig::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",
));
}
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)?;
}
let options = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.busy_timeout(Duration::from_secs(5));
Backend::Sqlite(
SqlitePoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(5))
.connect_with(options)
.await?,
)
}
StorageConfig::SqliteMemory => {
let options = SqliteConnectOptions::new().in_memory(true);
Backend::Sqlite(
SqlitePoolOptions::new()
.max_connections(1)
.min_connections(1)
.idle_timeout(None)
.max_lifetime(None)
.connect_with(options)
.await?,
)
}
};
let storage = Self { backend };
storage.initialize().await?;
Ok(storage)
}
async fn initialize(&self) -> Result<(), StorageError> {
match &self.backend {
Backend::Sqlite(pool) => {
sqlx::query(SCHEMA).execute(pool).await?;
}
Backend::Postgres(pool) => {
sqlx::query(SCHEMA).execute(pool).await?;
}
}
Ok(())
}
pub fn backend_name(&self) -> &'static str {
match &self.backend {
Backend::Sqlite(_) => "sqlite",
Backend::Postgres(_) => "postgresql",
}
}
pub async fn ping(&self) -> Result<(), StorageError> {
match &self.backend {
Backend::Sqlite(pool) => {
sqlx::query("SELECT 1").execute(pool).await?;
}
Backend::Postgres(pool) => {
sqlx::query("SELECT 1").execute(pool).await?;
}
}
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<Option<String>, 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.
pub async fn close(&self) {
match &self.backend {
Backend::Sqlite(pool) => pool.close().await,
Backend::Postgres(pool) => pool.close().await,
}
}
}
+4
View File
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS aster_metadata (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
)
+35
View File
@@ -0,0 +1,35 @@
use aster_storage::{Storage, StorageConfig};
/// Run explicitly against a disposable database; never silently skip a missing URL.
#[tokio::test]
#[ignore = "requires ASTER_TEST_POSTGRES_URL pointing to a test PostgreSQL database"]
async fn postgres_roundtrip_and_reconnect() {
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")
);
reopened.close().await;
}
+97
View File
@@ -0,0 +1,97 @@
use aster_storage::{Storage, StorageConfig};
#[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());
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)
.await
.unwrap();
let other = Storage::connect(&StorageConfig::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;
other.close().await;
}
#[tokio::test]
async fn sqlite_file_creates_parents_and_survives_reopen() {
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;
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")
);
reopened.close().await;
}
#[tokio::test]
async fn invalid_sqlite_directory_reports_error() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("file");
std::fs::write(&file, "occupied").unwrap();
assert!(
Storage::connect(&StorageConfig::SqliteFile(file.join("aster.db")))
.await
.is_err()
);
}
#[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 {
Ok(_) => panic!("unreachable PostgreSQL must fail"),
Err(error) => error,
};
assert!(!error.to_string().contains("secret"));
assert!(error.to_string().contains("no fallback"));
}
+42
View File
@@ -0,0 +1,42 @@
name: endorphin-aster-vm
services:
aster:
image: ${ASTER_IMAGE:?set ASTER_IMAGE to the verified release image}
init: true
ports:
- "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
restart: unless-stopped
stop_grace_period: 20s
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
postgres:
image: postgres:17-bookworm
environment:
POSTGRES_DB: aster
POSTGRES_USER: aster
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
volumes:
- /var/lib/endorphin-aster-vm/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U aster -d aster"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
+31
View File
@@ -0,0 +1,31 @@
# Standalone alternative: docker compose -f docker-compose.postgres.yml up --build -d
services:
aster:
build: .
ports:
- "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
restart: unless-stopped
postgres:
image: postgres:17-bookworm
environment:
POSTGRES_DB: aster
POSTGRES_USER: aster
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- aster-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aster -d aster"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
volumes:
aster-postgres:
+14
View File
@@ -0,0 +1,14 @@
services:
aster:
build: .
ports:
- "127.0.0.1:${ASTER_PORT:-8080}:8080"
environment:
ASTER_LISTEN: 0.0.0.0:8080
SQLITE_PATH: /data/aster.db
volumes:
- aster-sqlite:/data
restart: unless-stopped
volumes:
aster-sqlite:
+33
View File
@@ -0,0 +1,33 @@
# 架构与语义
## 内核
`Vm` 拥有私有 `Heap`。`Vm::new` 分配一个 `Object::Class(Class)`,其描述为名称 Object、父类 None,然后保存 `ClassRef`。不存在可观察的“半初始化 VM”;`bootstrap` 只返回已建立的根类句柄。因此根类不依赖数据库、全局命名空间、元类、网络或异步运行时。
`create_class(name, parent)` 校验已有父类后创建不可变描述,父类不可事后改写,不能形成继承环;只有内核启动路径可以创建没有父类的类。`create_instance` 校验类句柄再创建独立字段表。没有复制原型或隐式字段继承。未实现方法与实例字段查找的语言规则。
句柄结构是私有 VM 身份加 `usize` 槽位。VM 身份用原子计数器分配,耗尽时返回错误,不回绕复用。`Vec` 扩容会移动 Rust 对象,但外部不保存它们的地址,旧句柄继续通过索引访问。当前绝不删除槽位,所以无代际号。VM 不实现 Clone,复制句柄不能复制 VM 身份。
检查顺序是归属、范围、种类。外部没有从整数构造句柄的 API;低层无效槽位与伪造类型用私有单元测试覆盖。通用句柄可通过 `as_class/as_instance` 做校验后的转换。不同 VM 的引用、已经销毁 VM 的引用,在新 VM 中都报 `ForeignHandle`。同一 VM 中不在有效槽位的引用报 `InvalidHandle`。写字段前检查目标和引用值,失败不会覆盖原字段。
`Value` 中标量和字符串不强制堆分配,引用目前仅指类或实例。当前字符串值是按 Rust 所有权复制的文本,并非具有对象身份的字符串实例。`Float` 使用 Rust f64/PartialEq 行为,尚未作为最终语言语义承诺。类不是自身或其他类的实例,根类也没有隐式元类。
没有 GC,循环引用不形成 Rust 引用计数环,因为边只是句柄;所有内存随 VM 释放。长期运行时分配只增不减,尚无资源限额或内存不足的可恢复错误处理。宿主必须控制使用范围。
## 宿主与持久化
`aster-storage` 不引用任何内核类型。它连接一个确定的后端,初始化 `aster_metadata(key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)`,通过参数绑定完成幂等更新及读取。当前 schema 是单条幂等 DDL;修改 schema 时再引入版本化迁移,禁止把 CREATE IF NOT EXISTS 当作升级已有列结构的工具。
SQLite 文件模式自动创建父目录、开启 WAL,连接池最多 5 条连接,busy timeout 为 5 秒。内存模式单连接,关闭空闲回收和最大存活时间,防止池正常轮换导致数据库消失;独立 Storage 实例的内存数据库相互隔离。`Storage` 克隆共享连接池,`close` 关闭全部克隆的池。
PostgreSQL 最多 5 条连接,获取/建立连接超时 5 秒,启动真实连接而非惰性连接。其配置失败或初始化失败均上抛,不进入 SQLite 分支。SQLite 的缺省只发生在**未配置** DATABASE_URL 时。
元数据仅供宿主管理,不代表类、实例、句柄或整堆快照。重启宿主会创建新的 VM、新的 Object 和新句柄,只保留文件/数据库内的元数据。服务仅写入 `host.version`,不会把句柄调试值写入数据库。
`aster-server` 保存只读 VM 和共享 Storage。GET /bootstrap 只读根类;GET /health 探测数据库可用性。没有对象操作 API、语言执行 API 或鉴权协议。HTTP/Tokio/SQLx 都不进入内核。后续浏览器宿主应通过明确的能力接口桥接,不能把浏览器类型塞进核心对象工厂。
## 已决定与未决定
已决定:唯一无父类 Object、类和实例分离、实例引用类、句柄由 VM 校验、存储属于宿主。
未决定:字节码格式、调用栈、方法解析、语言异常、外部对象协议、线程间共享 VM、GC/根集、堆序列化、浏览器通信、完整字符串对象/数值对象语义。不得在后续交接中把这些当成已实现契约。
+33
View File
@@ -0,0 +1,33 @@
# 开发交接
## 入口与验证
先阅读根目录 AGENTS.md。工作区四个 crate 的边界必须保持,内核无数据库/HTTP 依赖。用户明确禁止本地测试,不在本机运行检查/冒烟脚本,也不为此下载依赖。指定的远端验证环境是 ces-control;CI 在 Docker 中运行 `sh scripts/check.sh`、`python3 scripts/smoke.py` 和真实 PostgreSQL 集成测试。正式持久部署选择 PostgreSQL。检查记录见 validation.md。
核心 API 示例在 `aster-core/src/lib.rs`,由 rustdoc 实际编译执行。公开语义测试在 `aster-core/tests/runtime.rs`;伪造句柄仅在私有堆单元测试中构造,禁止为测试添加公开非法构造器。
存储测试用 `tempfile` 隔离文件,关闭池后重开,验证独立连接生命周期中的持久化。内存测试验证克隆共享、独立 store 隔离、特殊字符参数绑定、更新/缺失键。PostgreSQL 失败测试用只监听但不处理协议的本地 TCP socket,检验真实连接超时和不降级;成功测试显式 ignore,需要用户提供专用测试库。
尚未运行的冒烟脚本用于跨进程调用 CLI、启动 HTTP 宿主、探测真实 TCP 端点、发送 SIGTERM 并确认正常退出。它只创建临时目录,不改 `.env`,不操作外部服务器。端口通过临时绑定选择,释放后启动间存在很小的端口竞争窗口;在指定验证环境中若冲突,可重试整个脚本。
依赖使用 Cargo.lock。此前编译器为 1.91.1;更换依赖后须在指定环境检查 MSRV 和容器构建版本。核心测试无需数据库,但 Cargo 首次依赖解析仍可能访问注册表;不要因此在本机恢复下载或测试。
## 下一阶段建议
1. 先约定对象字段与方法查找规则、宿主调用的参数/返回值和错误边界,补语义测试。
2. 在 core 内构建最小、可测的执行循环,输入先用手写指令,继续不做解析器和渲染器。
3. 根据执行栈、对象图、宿主持有引用确定 GC 根集;如需回收槽位,升级代际/间接句柄模型并补陈旧句柄测试。
4. 对外部对象、浏览器能力、配额与取消建立最小接口,以实际需求驱动,不预先堆砌空 trait。
5. 需要升级元数据 schema 时引入可追踪的迁移;若要求 VM 快照,另行定义持久身份与格式,不能直接序列化现有句柄。
## Git 状态与后续远程
origin 为用户提供的 `ssh://git@gitea.entroia.com:22222/Zayden-Jung/endorphin-aster-vm.git`。仓库级 author 和隔离 SSH 命令已配置;不得复制其他身份或猜测 owner。发布 CI/CD 需要提交、推送并跟踪远端结果;遵守 AGENTS.md 的授权及中文逐文件报告要求。初始开发阶段记录与后续部署记录在 validation.md 中分开说明。
## 远端 CI 与部署
`scripts/ci-ces-control.sh` 构建 Dockerfile 的 ci 阶段,创建专属网络与临时 PostgreSQL,再依次运行 workspace 检查、显式 PostgreSQL 测试、SQLite/宿主端到端冒烟。Docker daemon 为 legacy builder,工作流显式设置 `DOCKER_BUILDKIT=0`;没有依赖 buildx。远端内存约 1.7 GiB,Rust 单任务编译、调试构建不生成 debug 信息。
`scripts/deploy-ces-control.sh` 仅部署已经构建的 SHA 镜像;使用文件锁防止并行切换生产版本,第一次自动生成服务器密码文件,后续保留。Compose 固定项目名,独占 PostgreSQL 数据目录,与其他项目隔离。部署通过健康检查和真实元数据写入/读取后才切换 current;失败时尝试恢复上一版健康 Compose,不删除数据库或镜像。
更换应用容器不应重建密码或清理数据目录。PostgreSQL major 版本升级与 schema 变更须另外计划迁移;禁止把应用镜像回退当作数据库回退。修改此链路后,验证必须继续放在 ces-control,不要恢复本机依赖下载。
+50
View File
@@ -0,0 +1,50 @@
# 实现与验证记录
日期:2026-10-17(Asia/Shanghai)。
## 初始开发阶段记录
用户已明确禁止在本地测试,并要求清理下载内容。停止继续下载、构建与测试;后续检查脚本仅供用户指定的 CI/部署环境使用。测试结果是此前已启动进程返回的历史记录,不代表此后又运行了测试。
尝试中断此前的 `cargo test --workspace` 时,该进程已经自然结束,返回状态 0,SQLite 构建依赖也已下载。随后按用户要求清理了本次新增缓存和构建产物,不再运行测试。
## 已观察结果
| 检查 | 实际结果 |
| --- | --- |
| `cargo fmt --all --check` | 通过;此后只修改文档与约定,没有修改 Rust 代码 |
| `cargo test -p aster-core --offline` | 3 项私有单元测试、6 项公开语义测试、1 项文档测试通过 |
| `cargo clippy -p aster-core --all-targets --offline -- -D warnings` | 通过,仅覆盖内核 |
| 此前启动的 `cargo test --workspace` | 最终返回 16 项通过、0 失败、1 项明确忽略;含核心 9 项、宿主 1 项、存储 5 项、文档 1 项 |
| PostgreSQL 失败路径 | 使用不处理协议的本地 TCP socket,连接超时并报错、不降级;已包含于上述存储测试 |
| 真实 PostgreSQL 成功路径 | 未验证;集成测试显式忽略,需专用测试库 |
| 两个 Compose 文件 | Ruby YAML 静态解析通过,未执行 Compose |
| `scripts/check.sh`/`scripts/smoke.py` | 分别通过 Shell/Python 语法检查,未运行完整脚本 |
| `cargo clippy --workspace --all-targets -- -D warnings` | 未执行,不宣称全工作区无 Clippy 警告 |
| CLI/HTTP/SIGTERM 端到端冒烟 | 未执行;宿主健康状态函数测试已在上述 workspace 测试通过 |
| Docker 构建及容器运行 | 未执行,本机无 Docker |
以上为初始阶段记录,当时没有外部部署、PostgreSQL 服务、commit 或 push。
## 清理结果
- 删除本项目创建后新增、且属于 Cargo.lock 依赖列表的 150 个 Cargo 下载归档与 150 个解压目录,包括 SQLite/SQLx 本次新增依赖。
- 删除项目 `target/` 构建产物及 Python 字节码缓存。
- 保留此前已存在的 Cargo 缓存、共享注册表索引、项目源码与 Cargo.lock;不清空其他项目的既有依赖。
- 没有运行 CLI 创建默认 `data/aster.db`。此前文件数据库测试使用的临时目录随测试自动释放。
## Git
初始阶段本地分支 main 尚无提交。origin 是用户提供的 `ssh://git@gitea.entroia.com:22222/Zayden-Jung/endorphin-aster-vm.git`。
只读 `ls-remote --symref origin HEAD` 使用专用 codex-bot 密钥、隔离 SSH 配置、禁用其他身份及交互式凭据,返回状态 0,未公布 HEAD 引用。只读成功不代表已验证写权限。
仓库级身份:`Codex Bot` / `codex-bot@gitea.entroia.local`;`core.sshCommand` 使用项目约定的隔离命令。没有读取密码或切换为仓库 owner 身份。
## ces-control CI/CD 准备
用户随后指定在 ces-control 上持久部署,并要求同时测试 SQLite 与 PostgreSQL、正式部署使用 PostgreSQL。新增的工作流和脚本将这些检查放在远端 Docker 中,不在本机下载依赖或运行测试。
准备阶段只读检查:Docker 29.1.3、Compose 2.40.3、x86_64、约 1.7 GiB 内存/4 GiB swap、73 GiB 可用磁盘;没有主机 Rust 或 buildx,已缓存 PostgreSQL 17 镜像。选用单任务 Rust 构建、legacy Docker builder,默认生产端口 18081(服务器回环地址),PostgreSQL 数据目录独立。
此处不预先宣称远端 CI 或部署已成功;最终结果以 Gitea Actions 对应运行与服务器状态核验为准。
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
cargo fmt --all --check
cargo test --workspace --locked
cargo clippy --workspace --all-targets --locked -- -D warnings
+52
View File
@@ -0,0 +1,52 @@
#!/bin/sh
# Run only on the designated remote CI host, never on the developer's machine.
set -eu
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
release_sha=${ASTER_RELEASE_SHA:?ASTER_RELEASE_SHA is required}
case "$release_sha" in *[!0-9a-f]*|'') echo "Invalid release SHA" >&2; exit 1;; esac
test "${#release_sha}" -eq 40
ci_id="${GITHUB_RUN_ID:-manual}-$$"
network="endorphin-aster-vm-ci-$ci_id"
postgres="endorphin-aster-vm-ci-pg-$ci_id"
checks="endorphin-aster-vm-ci-checks-$ci_id"
image="endorphin-aster-vm-ci:$release_sha"
cleanup() {
docker rm -f "$checks" "$postgres" >/dev/null 2>&1 || true
docker network rm "$network" >/dev/null 2>&1 || true
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
docker build --target ci -t "$image" .
docker network create "$network" >/dev/null
# Disposable test database: no production mounts and no published ports.
docker run -d --name "$postgres" --network "$network" --network-alias postgres \
--env POSTGRES_USER=aster --env POSTGRES_DB=aster \
--env POSTGRES_PASSWORD=aster-ci-only \
--tmpfs /var/lib/postgresql/data postgres:17-bookworm >/dev/null
ready=false
for attempt in $(seq 1 60); do
if docker exec "$postgres" pg_isready -h 127.0.0.1 -U aster -d aster >/dev/null 2>&1; then
ready=true
break
fi
sleep 1
done
if [ "$ready" != true ]; then
echo "CI PostgreSQL did not become ready" >&2
exit 1
fi
docker run --name "$checks" --init --network "$network" \
--mount type=volume,src=endorphin-aster-vm-ci-cargo,dst=/usr/local/cargo/registry \
--mount type=volume,src=endorphin-aster-vm-ci-target,dst=/build/target \
--env ASTER_TEST_POSTGRES_URL='postgres://aster:aster-ci-only@postgres:5432/aster?sslmode=disable' \
"$image" sh -eu -c '
sh scripts/check.sh
cargo test -p aster-storage --test postgres --locked -- --ignored
python3 scripts/smoke.py
'
+72
View File
@@ -0,0 +1,72 @@
#!/bin/sh
# Deploy only the image produced after the remote database checks succeed.
set -eu
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
test "$(id -u)" -eq 0 || { echo "Deployment requires root on ces-control" >&2; exit 1; }
release_sha=${ASTER_RELEASE_SHA:?ASTER_RELEASE_SHA is required}
case "$release_sha" in *[!0-9a-f]*|'') echo "Invalid release SHA" >&2; exit 1;; esac
test "${#release_sha}" -eq 40
image="endorphin-aster-vm:$release_sha"
docker image inspect "$image" >/dev/null
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
exec 9>/var/lock/endorphin-aster-vm-deploy.lock
flock 9
if [ ! -f "$config" ]; then
password=$(openssl rand -hex 24)
printf 'POSTGRES_PASSWORD=%s\nASTER_PORT=18081\n' "$password" > "$config"
unset password
fi
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)
compose_at() {
location=$1
shift
docker compose -p endorphin-aster-vm --env-file "$config" \
--env-file "$location/release.env" -f "$location/compose.yml" "$@"
}
rollback() {
echo "Deployment failed; application data has been retained" >&2
if [ -n "$previous" ] && [ "$previous" != "$release" ] && [ -f "$previous/compose.yml" ]; then
echo "Restoring the previously healthy release" >&2
compose_at "$previous" up -d --no-build --wait --wait-timeout 180 || true
fi
}
if ! compose_at "$release" up -d --no-build --wait --wait-timeout 180; then
rollback
exit 1
fi
if ! compose_at "$release" exec -T aster aster metadata put deployment.release "$release_sha"; then
rollback
exit 1
fi
if ! actual=$(compose_at "$release" exec -T aster aster metadata get deployment.release); then
rollback
exit 1
fi
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 "Persistent PostgreSQL data: /var/lib/endorphin-aster-vm/postgres"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Local end-to-end smoke test, no Docker or external DB required."""
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import tempfile
import time
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
def run(*args, env):
return subprocess.run(args, cwd=ROOT, env=env, check=True, text=True, capture_output=True).stdout.strip()
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))
binary_dir = Path(metadata["target_directory"]) / "debug"
cli = str(binary_dir / "aster")
server = str(binary_dir / "aster-server")
with tempfile.TemporaryDirectory(prefix="aster-smoke-") as directory:
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)
assert "root=Object parent=None" in bootstrap, bootstrap
assert "instances_distinct=true heap_objects=4" 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"
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
env["ASTER_LISTEN"] = f"127.0.0.1:{port}"
with tempfile.TemporaryFile(mode="w+") as log:
process = subprocess.Popen([server], cwd=ROOT, env=env, stdout=log, stderr=log)
try:
deadline = time.monotonic() + 15
while True:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1) as response:
health = json.load(response)
break
except (OSError, ValueError):
if process.poll() is not None or time.monotonic() >= deadline:
log.seek(0)
raise AssertionError(f"server did not become healthy: {log.read()}")
time.sleep(0.1)
assert health == {"status": "ok", "storage": "sqlite"}, 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)
assert info == {"root_class": "Object", "root_has_parent": False, "heap_objects": 1}, info
finally:
process.send_signal(signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
raise AssertionError("server did not shut down gracefully")
assert process.returncode == 0, process.returncode
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")
if __name__ == "__main__":
main()