Initialize Aster VM and deploy PostgreSQL host on ces-control
Verify databases and deploy Aster VM / verify-and-deploy (push) Waiting to run
Verify databases and deploy Aster VM / verify-and-deploy (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
target
|
||||
data
|
||||
work
|
||||
outputs
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
/target/
|
||||
/data/
|
||||
/work/
|
||||
/outputs/
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
__pycache__/
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+21
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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;确切解析版本以锁文件为准。
|
||||
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "aster-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS aster_metadata (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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/根集、堆序列化、浏览器通信、完整字符串对象/数值对象语义。不得在后续交接中把这些当成已实现契约。
|
||||
@@ -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,不要恢复本机依赖下载。
|
||||
@@ -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 对应运行与服务器状态核验为准。
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cargo fmt --all --check
|
||||
cargo test --workspace --locked
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
@@ -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
|
||||
'
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user